From 79879de2f55df8b92ebb59bd1ff99528006ea7b5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 6 Oct 2026 22:33:25 +0900 Subject: [PATCH 1/2] Persist local actor signing keys Store RSA and Ed25519 JWK pairs in local_actor_keys, with an enum key kind, a composite primary key and cascading local-actor ownership. Generate missing keys on first use outside transactions, then persist and reload the winning pairs under short locks. Publish canonical public key identities while keeping private material out of errors and query logs. Prewarm keys through a best-effort Fedify task after actor creation commits. Bound the ephemeral queue, clean up its listeners, and await requests and active tasks before closing the database. Preserve listen errors and force-close stalled requests during shutdown. The maintainer requested the implementation and chose lazy generation, optional background prewarming and an enum key type. Codex generated the implementation and tests; Claude Code reviewed the design. Codex and Claude Code reviews shaped canonical key identities, race coverage, startup diagnostics and shutdown cleanup. The final test timing and Windows signal exclusion were adjusted after the Claude review cap and received local validation rather than another independent review. Validation: mise run build, mise run check, mise run test, PostgreSQL client races and deletion during generation, isolated development startup, and npm tarball CLI, migration and shutdown smoke checks. The full suite also passed with four CPUs. Windows and macOS execution was not performed. Fixes https://github.com/fedify-dev/drfed/issues/87 Assisted-by: Codex:gpt-6.1-sol Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5-1 --- packages/drfed/package.json | 7 +- packages/drfed/src/index.ts | 72 +- packages/drfed/src/lifecycle.test.ts | 151 + packages/drfed/src/parser.ts | 6 +- packages/drfed/src/query-logger.test.ts | 36 + packages/drfed/src/query-logger.ts | 34 + packages/drfed/src/serving.test.ts | 31 +- packages/drfed/src/serving.ts | 14 +- packages/federation/package.json | 12 +- packages/federation/src/actor-key-task.ts | 90 + packages/federation/src/actor-key.ts | 182 + packages/federation/src/actor.ts | 22 +- packages/federation/src/federation.test.ts | 357 +- packages/federation/src/index.ts | 13 +- packages/federation/src/seed.test.ts | 82 +- packages/federation/src/task-queue.test.ts | 99 + packages/federation/src/task-queue.ts | 129 + .../graphql/src/activity-delivery.test.ts | 3 + .../src/activity-delivery/inbound.test.ts | 29 + packages/graphql/src/actor.test.ts | 60 + packages/graphql/src/actor.ts | 12 +- packages/graphql/src/harness.test.ts | 12 +- packages/graphql/src/seed.test.ts | 82 +- .../migration.sql | 11 + .../snapshot.json | 3627 +++++++++++++++++ packages/models/src/schema.ts | 32 + 26 files changed, 5142 insertions(+), 63 deletions(-) create mode 100644 packages/drfed/src/lifecycle.test.ts create mode 100644 packages/drfed/src/query-logger.test.ts create mode 100644 packages/drfed/src/query-logger.ts create mode 100644 packages/federation/src/actor-key-task.ts create mode 100644 packages/federation/src/actor-key.ts create mode 100644 packages/federation/src/task-queue.test.ts create mode 100644 packages/federation/src/task-queue.ts create mode 100644 packages/models/drizzle/20261006122653_local_actor_keys/migration.sql create mode 100644 packages/models/drizzle/20261006122653_local_actor_keys/snapshot.json diff --git a/packages/drfed/package.json b/packages/drfed/package.json index 3be46b9..fa77024 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -53,6 +53,10 @@ "./serving": { "types": "./dist/serving.d.mts", "default": "./dist/serving.mjs" + }, + "./query-logger": { + "types": "./dist/query-logger.d.mts", + "default": "./dist/query-logger.mjs" } }, "files": [ @@ -67,7 +71,8 @@ "entry": [ "src/index.ts", "src/valueparser.ts", - "src/serving.ts" + "src/serving.ts", + "src/query-logger.ts" ], "dts": { "sourcemap": true, diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 75ca17a..df54f67 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -18,12 +18,13 @@ import { writeFile } from "node:fs/promises"; import process from "node:process"; import createFederation, { createInboundRecorder } from "@drfed/federation"; +import { KeyGenerationQueue } from "@drfed/federation/task-queue"; import { createYogaServer } from "@drfed/graphql"; import { schema } from "@drfed/graphql/schema"; import { migrate } from "@drfed/models"; import { PgliteKvStore } from "@fedify/pglite"; import { PostgresKvStore } from "@fedify/postgres"; -import { configure, getConsoleSink } from "@logtape/logtape"; +import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; import { createLoggingConfig } from "@optique/logtape"; import { run } from "@optique/run"; import { SmtpTransport } from "@upyo/smtp"; @@ -50,8 +51,21 @@ async function runServer(options: ServerOptions) { : new PostgresKvStore(credentials.client); const federation = await createFederation(options.drizzle.db, { kv, + queue: { task: new KeyGenerationQueue() }, + taskQueueResolution: "strict", + manuallyStartQueue: true, allowPrivateAddress: true, }); + const workerAbort = new AbortController(); + // oxlint-disable promise/prefer-await-to-then + const worker = federation + .startQueue(undefined, { queue: "task", signal: workerAbort.signal }) + .catch(() => { + getLogger(["drfed", "server"]).error( + "Actor key worker stopped unexpectedly.", + ); + }); + // oxlint-enable promise/prefer-await-to-then const { emailFrom, mailer, rootOrigin, loginOrigins } = options; const yogaServer = createYogaServer(options.drizzle.db, federation, { @@ -73,23 +87,55 @@ async function runServer(options: ServerOptions) { }), hostname: options.address.host, manual: true, + gracefulShutdown: false, port: options.address.port, }); + let closing = false; function shutdown() { - if (mailer instanceof SmtpTransport) { - mailer.closeAllConnections(); + if (closing) { + process.exit(1); } - // oxlint-disable-next-line promise/catch-or-return promise/prefer-await-to-then - server.close().then(async () => { - await ("driver" in credentials - ? credentials.client.close() - : credentials.client.end()); - process.exit(0); - }); + closing = true; + const deadline = setTimeout(() => process.exit(1), 10_000); + const requests = server.close(); + const forceClose = setTimeout(() => { + // A stalled upload must not prevent database cleanup on shutdown. + // oxlint-disable-next-line promise/prefer-await-to-then + void server.close(true).catch(() => process.exit(1)); + }, 5000); + workerAbort.abort(); + // The task worker awaits its active handler before resolving. + // oxlint-disable promise/prefer-await-to-then + Promise.all([requests, worker]) + .then(async () => { + clearTimeout(forceClose); + if (mailer instanceof SmtpTransport) mailer.closeAllConnections(); + await ("driver" in credentials + ? credentials.client.close() + : credentials.client.end()); + clearTimeout(deadline); + process.exit(0); + }) + .catch(() => { + process.exit(1); + }); + } + // oxlint-enable promise/prefer-await-to-then + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + try { + await server.serve(); + } catch (error) { + process.off("SIGINT", shutdown); + process.off("SIGTERM", shutdown); + workerAbort.abort(); + await Promise.all([server.close(), worker]); + if (mailer instanceof SmtpTransport) mailer.closeAllConnections(); + await ("driver" in credentials + ? credentials.client.close() + : credentials.client.end()); + throw new Error("Could not start the server.", { cause: error }); } - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); - await server.serve(); } async function runSchemaGenerator( diff --git a/packages/drfed/src/lifecycle.test.ts b/packages/drfed/src/lifecycle.test.ts new file mode 100644 index 0000000..10df44c --- /dev/null +++ b/packages/drfed/src/lifecycle.test.ts @@ -0,0 +1,151 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { type Server, connect, createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { it } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; + +const binary = fileURLToPath( + new URL("../bin/drfed-server.mjs", import.meta.resolve("@drfed/drfed")), +); +async function reservePort() { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address != null && typeof address !== "string"); + return { server, port: address.port }; +} +async function closeServer(server: Server) { + const closed = once(server, "close"); + server.close(); + await closed; +} +function startServer(port: number, dataPath: string) { + const child = spawn( + process.execPath, + [ + binary, + "--root-origin=http://drfed.test", + "--login-origin=http://drfed.test", + `--listen=127.0.0.1:${port}`, + `--pglite-data-path=${dataPath}`, + "--log-level=error", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.stdout.resume(); + const exited = once(child, "close"); + return { child, exited, stderr: () => stderr }; +} + +async function waitForExit(run: ReturnType) { + const stopTimeout = new AbortController(); + const timeout = async () => { + await delay(90_000, undefined, { signal: stopTimeout.signal }); + assert.fail(`CLI did not exit within 90 seconds: ${run.stderr()}`); + }; + try { + return await Promise.race([run.exited, timeout()]); + } finally { + stopTimeout.abort(); + } +} + +it("preserves a listen error when server startup fails", async () => { + const { server, port } = await reservePort(); + const dataPath = await mkdtemp(join(tmpdir(), "drfed-startup-")); + const run = startServer(port, dataPath); + try { + const [code] = await waitForExit(run); + assert.equal(code, 1); + assert.match(run.stderr(), /EADDRINUSE/u); + } finally { + run.child.kill("SIGKILL"); + await closeServer(server); + await rm(dataPath, { recursive: true, force: true }); + } +}); + +it( + "force-closes a stalled upload and exits normally on SIGTERM", + { + // Windows child.kill("SIGTERM") forcibly terminates without running handlers. + skip: process.platform === "win32", + }, + async () => { + const { server, port } = await reservePort(); + await closeServer(server); + const dataPath = await mkdtemp(join(tmpdir(), "drfed-shutdown-")); + const run = startServer(port, dataPath); + try { + let ready = false; + const readinessDeadline = performance.now() + 90_000; + while (performance.now() < readinessDeadline) { + assert.equal(run.child.exitCode, null, run.stderr()); + try { + // oxlint-disable-next-line no-await-in-loop + const response = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: "POST", + signal: AbortSignal.timeout(1000), + headers: { "content-type": "application/json" }, + body: '{"query":"{ __typename }"}', + }); + if (response.ok) { + ready = true; + break; + } + } catch { + // Migrations and listening have not finished yet. + } + // oxlint-disable-next-line no-await-in-loop + await delay(100); + } + assert.ok(ready, run.stderr()); + const stalled = connect({ host: "127.0.0.1", port }); + // Force-closing a stalled upload may reset its socket. + stalled.on("error", () => undefined); + try { + await once(stalled, "connect"); + stalled.write( + "POST /graphql HTTP/1.1\r\nHost: drfed.test\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n{", + ); + await delay(100); + run.child.kill("SIGTERM"); + const [code, signal] = await waitForExit(run); + assert.equal(signal, null); + assert.equal(code, 0, run.stderr()); + } finally { + stalled.destroy(); + } + } finally { + run.child.kill("SIGKILL"); + await rm(dataPath, { recursive: true, force: true }); + } + }, +); diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index 1b0ad73..eb4def8 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -16,7 +16,6 @@ import { relations, schema } from "@drfed/models"; import { PGlite } from "@electric-sql/pglite"; -import { getLogger } from "@logtape/drizzle-orm"; import { merge, object, or } from "@optique/core/constructs"; import { message, optionNames } from "@optique/core/message"; import { map, multiple, optional, withDefault } from "@optique/core/modifiers"; @@ -31,6 +30,7 @@ import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; import postgres from "postgres"; +import { privateKeySafeLogger } from "./query-logger.ts"; import { rootOrigin } from "./valueparser.ts"; const pgliteParser = map( @@ -54,7 +54,7 @@ const pgliteParser = map( client, relations, schema, - logger: getLogger(), + logger: privateKeySafeLogger(), }), }; }, @@ -80,7 +80,7 @@ const postgresParser = map( client, relations, schema, - logger: getLogger(), + logger: privateKeySafeLogger(), }), }; }, diff --git a/packages/drfed/src/query-logger.test.ts b/packages/drfed/src/query-logger.test.ts new file mode 100644 index 0000000..ed1cbfd --- /dev/null +++ b/packages/drfed/src/query-logger.test.ts @@ -0,0 +1,36 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from "node:assert/strict"; +import { it } from "node:test"; + +import { privateKeySafeLogger } from "@drfed/drfed/query-logger"; + +it("never forwards key parameters to the ordinary SQL logger", () => { + const calls: unknown[] = []; + const logger = privateKeySafeLogger({ + logQuery(query, params) { + calls.push([query, params]); + }, + }); + logger.logQuery('insert into "local_actor_keys" values ($1)', [ + "PRIVATE_SECRET", + ]); + logger.logQuery('select * from "local_actor_keys"', []); + assert.deepEqual(calls, []); + logger.logQuery("select $1", [42]); + assert.deepEqual(calls, [["select $1", [42]]]); +}); diff --git a/packages/drfed/src/query-logger.ts b/packages/drfed/src/query-logger.ts new file mode 100644 index 0000000..b8b5f60 --- /dev/null +++ b/packages/drfed/src/query-logger.ts @@ -0,0 +1,34 @@ +// 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 { getLogger as getQueryLogger } from "@logtape/drizzle-orm"; +import { getLogger } from "@logtape/logtape"; +import type { Logger } from "drizzle-orm/logger"; +/** Suppress parameters for SQL that reads or writes private signing material. + * @returns A logger that never logs private-key parameters. + */ +export function privateKeySafeLogger( + delegate: Logger = getQueryLogger(), +): Logger { + const logger = getLogger(["drfed", "database"]); + return { + logQuery(query, params) { + if (query.includes("local_actor_keys")) { + logger.debug("Query: {query}", { query }); + } else delegate.logQuery(query, params); + }, + }; +} diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts index 2e1933e..6a0783b 100644 --- a/packages/drfed/src/serving.test.ts +++ b/packages/drfed/src/serving.test.ts @@ -15,6 +15,7 @@ // along with this program. If not, see . import assert from "node:assert/strict"; +import { it } from "node:test"; import { createFetchHandler, @@ -26,7 +27,7 @@ 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 { describe } from "@logtape/testing-node/autoload"; import { drizzle } from "drizzle-orm/pglite"; const rootOrigin = new URL("https://drfed.net"); @@ -294,3 +295,31 @@ it("records inbox requests only on the instance surface", async () => { await client.close(); } }); + +it("returns plain 500 for federation failures and continues serving", async () => { + let broken = true; + const fetch = createFetchHandler({ + rootOrigin, + federation: { + fetch() { + if (broken) throw new Error("PRIVATE_SECRET"); + return Promise.resolve(new Response("ok")); + }, + }, + serveControlSurface: () => new Response("graphql"), + }); + // Check the two request paths sequentially. + // oxlint-disable no-await-in-loop + for (const method of ["GET", "POST"]) { + const response = await fetch( + new Request("https://demo.drfed.net/users/x/inbox", { method }), + ); + assert.equal(response.status, 500); + assert.equal(await response.text(), "Internal server error"); + } + broken = false; + assert.equal( + (await fetch(new Request("https://demo.drfed.net/users/x"))).status, + 200, + ); +}); diff --git a/packages/drfed/src/serving.ts b/packages/drfed/src/serving.ts index 3c76bde..ea75ef9 100644 --- a/packages/drfed/src/serving.ts +++ b/packages/drfed/src/serving.ts @@ -95,11 +95,15 @@ export function createFetchHandler( } switch (classifyHost(url, rootOrigin)) { case "instance": - return await federation.fetch(request, { - onNotFound: notFound, - onNotAcceptable: notFound, - contextData: undefined, - }); + try { + return await federation.fetch(request, { + onNotFound: notFound, + onNotAcceptable: notFound, + contextData: undefined, + }); + } catch { + return new Response("Internal server error", { status: 500 }); + } case "misdirected": // Below the root domain but deeper than the single label an instance // occupies, so nothing here will ever answer. Saying so is more use diff --git a/packages/federation/package.json b/packages/federation/package.json index 9ab59d7..086492f 100644 --- a/packages/federation/package.json +++ b/packages/federation/package.json @@ -57,6 +57,14 @@ "./origin": { "types": "./dist/origin.d.mts", "default": "./dist/origin.mjs" + }, + "./actor-key": { + "types": "./dist/actor-key.d.mts", + "default": "./dist/actor-key.mjs" + }, + "./task-queue": { + "types": "./dist/task-queue.d.mts", + "default": "./dist/task-queue.mjs" } }, "files": [ @@ -68,7 +76,9 @@ "src/index.ts", "src/activity-delivery.ts", "src/object.ts", - "src/origin.ts" + "src/origin.ts", + "src/actor-key.ts", + "src/task-queue.ts" ], "dts": { "sourcemap": true, diff --git a/packages/federation/src/actor-key-task.ts b/packages/federation/src/actor-key-task.ts new file mode 100644 index 0000000..1b1e9ba --- /dev/null +++ b/packages/federation/src/actor-key-task.ts @@ -0,0 +1,90 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { Database } from "@drfed/models"; +import { validateUuid } from "@drfed/models/uuid"; +import type { + Context, + Federation, + FederationBuilder, + TaskDefinition, +} from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; + +import { ensureActorKeyPairs } from "./actor-key.ts"; + +interface Payload { + identifier: string; +} +type Handle = TaskDefinition; +const builders = new WeakMap, Handle>(); +const handles = new WeakMap, Handle>(); +const logger = getLogger(["drfed", "federation", "actor-key"]); +const taskSchema: Handle["schema"] = { + "~standard": { + version: 1, + vendor: "drfed", + validate(value) { + if ( + typeof value === "object" && + value != null && + "identifier" in value && + typeof value.identifier === "string" && + validateUuid(value.identifier) + ) { + return { value: { identifier: value.identifier } }; + } + return { issues: [{ message: "Expected an actor UUID." }] }; + }, + }, +}; +export function registerActorKeyTask( + builder: FederationBuilder, + db: Database, +): void { + const handle = builder.defineTask("drfed.ensureActorKeyPairs", { + schema: taskSchema, + retryPolicy: () => null, + async handler(ctx, data) { + await ensureActorKeyPairs(db, ctx, data.identifier); + }, + }); + builders.set(builder, handle); +} +export function attachActorKeyTask( + builder: FederationBuilder, + federation: Federation, + enabled: boolean, +): void { + const handle = builders.get(builder); + if (enabled && handle != null) handles.set(federation, handle); +} +/** Schedule expendable prewarming after actor creation has committed. */ +export async function enqueueActorKeyGeneration( + ctx: Context, + identifiers: readonly string[], +): Promise { + const handle = handles.get(ctx.federation); + if (handle == null || identifiers.length === 0) return; + try { + await ctx.enqueueTaskMany( + handle, + identifiers.map((identifier) => ({ identifier })), + ); + } catch { + logger.warn("Could not schedule actor key prewarming."); + } +} diff --git a/packages/federation/src/actor-key.ts b/packages/federation/src/actor-key.ts new file mode 100644 index 0000000..c0419af --- /dev/null +++ b/packages/federation/src/actor-key.ts @@ -0,0 +1,182 @@ +// 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 . + +// Generation is intentionally outside transactions; call this before opening +// a transaction that will deliver an activity. +// oxlint-disable no-await-in-loop +import { type Database, schema } from "@drfed/models"; +import type { LocalActorKey } from "@drfed/models/schema"; +import { type Uuid, validateUuid } from "@drfed/models/uuid"; +import { + type Context, + exportJwk, + generateCryptoKeyPair, + importJwk, +} from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; +import { and, eq } from "drizzle-orm"; + +import { canonicalizeAuthority } from "./origin.ts"; + +const logger = getLogger(["drfed", "federation", "actor-key"]); +const pending = new WeakMap>>(); +const algorithms = ["RSASSA-PKCS1-v1_5", "Ed25519"] as const; + +async function readPairs(rows: LocalActorKey[]): Promise { + const result: CryptoKeyPair[] = []; + for (const type of algorithms) { + const row = rows.find((entry) => entry.type === type); + if (row == null) continue; + const { publicKey, privateKey } = row; + const fields = + type === "Ed25519" + ? (["kty", "crv", "x"] as const) + : (["kty", "n", "e"] as const); + if (fields.some((field) => publicKey[field] !== privateKey[field])) { + throw new Error("Invalid signing key pair."); + } + const pair = { + publicKey: await importJwk(publicKey, "public"), + privateKey: await importJwk(privateKey, "private"), + }; + if ( + pair.publicKey.algorithm.name !== type || + pair.privateKey.algorithm.name !== type + ) { + throw new Error("Invalid signing algorithm."); + } + const challenge = new Uint8Array([1]); + const signature = await crypto.subtle.sign( + type, + pair.privateKey, + challenge, + ); + if ( + !(await crypto.subtle.verify(type, pair.publicKey, signature, challenge)) + ) { + throw new Error("Mismatched signing keys."); + } + result.push(pair); + } + return result; +} + +/** + * Load durable local actor keys, generating missing pairs on first use. + * Deleted actors retain existing complete pairs for signing their Delete. + * @returns Persisted pairs, or an empty array for an unavailable actor. + * The optional generator is a test seam; production uses Fedify's defaults. + */ +export function ensureActorKeyPairs( + db: Database, + ctx: Context, + identifier: string, + generate: typeof generateCryptoKeyPair = generateCryptoKeyPair, +): Promise { + if (!validateUuid(identifier)) return Promise.resolve([]); + let entries = pending.get(db); + if (entries == null) { + entries = new Map(); + pending.set(db, entries); + } + const key = `${canonicalizeAuthority(ctx.host)}:${identifier}`; + const existing = entries.get(key); + if (existing != null) return existing; + // The shared promise must remain identical for joining callers. + // oxlint-disable promise/prefer-await-to-then + const operation = ensure(db, ctx, identifier as Uuid, generate) + .catch(() => { + // Drizzle errors include bound JWKs in their message and cause. Never pass + // those errors to Fedify's logging, tracing, or delivery recorder. + logger.error("Could not load signing keys for actor {identifier}.", { + identifier, + }); + throw new Error("Could not load actor signing keys."); + }) + .finally(() => { + entries.delete(key); + }); + // oxlint-enable promise/prefer-await-to-then + entries.set(key, operation); + return operation; +} + +async function ensure( + db: Database, + ctx: Context, + identifier: Uuid, + generate: typeof generateCryptoKeyPair, +): Promise { + const host = canonicalizeAuthority(ctx.host); + const actor = await db.query.actors.findFirst({ + where: { id: identifier, localId: { isNotNull: true }, instance: { host } }, + }); + if (actor?.localId == null) return []; + const rows = await db + .select() + .from(schema.localActorKeys) + .where(eq(schema.localActorKeys.localActorId, actor.localId)); + const stored = await readPairs(rows); + if (stored.length === 2) return stored; + if (actor.deleted != null) return []; + const candidates: (typeof schema.localActorKeys.$inferInsert)[] = []; + for (const type of algorithms) { + if (rows.some((row) => row.type === type)) continue; + const pair = await generate(type); + candidates.push({ + localActorId: actor.localId, + type, + publicKey: await exportJwk(pair.publicKey), + privateKey: await exportJwk(pair.privateKey), + }); + } + const saved = await db.transaction(async (tx) => { + // Parent first: local_actors deletion cascades to actors in this order. + const [local] = await tx + .select({ id: schema.localActors.id }) + .from(schema.localActors) + .where(eq(schema.localActors.id, actor.localId!)) + .for("key share"); + if (local == null) return []; + const [current] = await tx + .select({ deleted: schema.actors.deleted }) + .from(schema.actors) + .innerJoin( + schema.instances, + eq(schema.actors.instanceId, schema.instances.id), + ) + .where( + and( + eq(schema.actors.id, identifier), + eq(schema.actors.localId, local.id), + eq(schema.instances.host, host), + ), + ) + .for("no key update", { of: schema.actors }); + if (current == null) return []; + if (current.deleted == null) { + await tx + .insert(schema.localActorKeys) + .values(candidates) + .onConflictDoNothing(); + } + return await tx + .select() + .from(schema.localActorKeys) + .where(eq(schema.localActorKeys.localActorId, local.id)); + }); + return saved.length === 2 ? await readPairs(saved) : []; +} diff --git a/packages/federation/src/actor.ts b/packages/federation/src/actor.ts index f7bdd94..4128642 100644 --- a/packages/federation/src/actor.ts +++ b/packages/federation/src/actor.ts @@ -17,7 +17,7 @@ import type { Database, schema } from "@drfed/models"; import type { Actor, Resource } from "@drfed/models/schema"; import { type Uuid, validateUuid } from "@drfed/models/uuid"; -import type { Context, FederationBuilder } from "@fedify/fedify"; +import type { ActorKeyPair, Context, FederationBuilder } from "@fedify/fedify"; import { Application, Endpoints, @@ -29,6 +29,7 @@ import { Tombstone, } from "@fedify/vocab"; +import { ensureActorKeyPairs } from "./actor-key.ts"; import { canonicalizeAuthority } from "./origin.ts"; /** @@ -113,10 +114,19 @@ export function registerActorDispatcher( if (actor.deleted != null) { return new Tombstone({ id: ctx.getActorUri(identifier) }); } - return toActorObject(ctx, identifier, actor); + const keyContext = ctx.federation.createContext( + new URL(actor.resource.iri), + ctx.data, + ); + const keys = await keyContext.getActorKeyPairs(actor.id); + if (keys.length !== 2) { + throw new Error("Could not load actor signing keys."); + } + return toActorObject(ctx, identifier, actor, keys); }) - // FIXME: https://github.com/fedify-dev/drfed/issues/87 - .setKeyPairsDispatcher(() => []) + .setKeyPairsDispatcher((ctx, identifier) => + ensureActorKeyPairs(db, ctx, identifier), + ) .mapHandle(async (ctx, username) => { const actor = await db.query.actors.findFirst({ where: { @@ -128,7 +138,6 @@ export function registerActorDispatcher( }); return actor?.id ?? null; }); - // FIXME: https://github.com/fedify-dev/drfed/issues/87 } // Whether a sanction is *currently* active is always determined by comparing @@ -147,9 +156,12 @@ function toActorObject( ctx: Context, identifier: string, actor: StoredActor, + keys: ActorKeyPair[], ): ActorObject { return actorConstructors[actor.type]({ id: new URL(actor.resource.iri), + publicKey: keys[0]!.cryptographicKey, + assertionMethods: keys.map((key) => key.multikey), preferredUsername: actor.username, name: actor.name, summary: actor.bioHtml, diff --git a/packages/federation/src/federation.test.ts b/packages/federation/src/federation.test.ts index 650ebe4..8b94434 100644 --- a/packages/federation/src/federation.test.ts +++ b/packages/federation/src/federation.test.ts @@ -19,11 +19,20 @@ import assert from "node:assert/strict"; -import createFederation, { buildFederation } from "@drfed/federation"; +import createFederation, { + buildFederation, + enqueueActorKeyGeneration, +} from "@drfed/federation"; +import { ensureActorKeyPairs } from "@drfed/federation/actor-key"; +import { KeyGenerationQueue } from "@drfed/federation/task-queue"; import { schema } from "@drfed/models"; import { PUBLIC_IRI } from "@drfed/models/resource"; import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; -import { MemoryKvStore } from "@fedify/fedify"; +import { + MemoryKvStore, + exportJwk, + generateCryptoKeyPair, +} from "@fedify/fedify"; import { Object as APObject, Create } from "@fedify/vocab"; import { describe, it } from "@logtape/testing-node/autoload"; import { eq, sql } from "drizzle-orm"; @@ -32,6 +41,7 @@ import { withFederation, withTemporaryDatabase } from "./harness.test.ts"; import { localActorId, remoteActorId, + seedActorKeys, seedActors, seedLocalActor, seedObjects, @@ -717,3 +727,346 @@ describe("stored collection membership and independent activity addressing", () }); }); }); + +describe("durable actor signing keys", () => { + it("generates keys on first use and publishes only their public forms", async () => { + await withFederation(async ({ db, federation }) => { + await seedLocalActor(db, { keys: false }); + assert.equal(await db.$count(schema.localActorKeys), 0); + const response = await federation.fetch( + new Request(actorIri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.publicKey.id, `${actorIri}#main-key`); + assert.equal(body.assertionMethod.length, 2); + assert.ok(body.publicKey.publicKeyPem); + const ctx = federation.createContext(new URL(actorIri), undefined); + const first = await ctx.getActorKeyPairs(localActorId); + assert.deepEqual( + first.map((pair) => pair.privateKey.algorithm.name), + ["RSASSA-PKCS1-v1_5", "Ed25519"], + ); + const saved = await db.select().from(schema.localActorKeys); + assert.equal(saved.length, 2); + assert.equal( + JSON.stringify(body).includes(saved[0]!.privateKey["d"]!), + false, + ); + const fresh = await createFederation(db, { kv: new MemoryKvStore() }); + const again = await fresh + .createContext(new URL(actorIri), undefined) + .getActorKeyPairs(localActorId); + assert.deepEqual( + await crypto.subtle.exportKey("jwk", first[0]!.publicKey), + await crypto.subtle.exportKey("jwk", again[0]!.publicKey), + ); + assert.deepEqual(await db.select().from(schema.localActorKeys), saved); + }); + }); + it("publishes canonical key ownership for uppercase UUID requests", async () => { + await withFederation(async ({ db, federation }) => { + await seedLocalActor(db); + const id = "abcdefab-cdef-4abc-8def-abcdefabcdef" as Uuid; + await db.insert(schema.localActors).values({ id }); + const iri = `https://test-instance.drfed.org/users/${id}`; + await seedActors(db, { + id, + localId: id, + iri, + instanceId: "00000000-0000-4000-8000-000000000101" as Uuid, + type: "Person", + username: "uppercase-test", + inboxUrl: `${iri}/inbox`, + created: Temporal.Now.instant(), + }); + const response = await federation.fetch( + new Request(iri.replace(id, id.toUpperCase()), { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.id, iri); + assert.equal(body.publicKey.owner, iri); + assert.equal(body.publicKey.id, `${iri}#main-key`); + for (const key of body.assertionMethod) assert.equal(key.controller, iri); + }); + }); + for (const requestOrigin of [ + "https://test-instance.drfed.org.", + "https://test-instance.drfed.org:443", + "http://test-instance.drfed.org", + "http://test-instance.drfed.org:443", + ]) { + it(`publishes stored key ownership when requested from ${requestOrigin}`, async () => { + await withFederation(async ({ db, federation }) => { + await seedLocalActor(db); + const response = await federation.fetch( + new Request(new URL(new URL(actorIri).pathname, requestOrigin), { + headers: accept, + }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.id, actorIri); + assert.equal(body.publicKey.owner, actorIri); + assert.equal(body.publicKey.id, `${actorIri}#main-key`); + assert.deepEqual( + body.assertionMethod.map((key: { id: string }) => key.id), + [`${actorIri}#multikey-1`, `${actorIri}#multikey-2`], + ); + for (const key of body.assertionMethod) { + assert.equal(key.controller, actorIri); + } + }); + }); + } + it("preserves stored keys when repairing a missing kind and when deleted", async () => { + await withFederation(async ({ db, federation }) => { + await seedLocalActor(db); + const [rsa] = await db + .select() + .from(schema.localActorKeys) + .where(eq(schema.localActorKeys.type, "RSASSA-PKCS1-v1_5")); + await db + .delete(schema.localActorKeys) + .where(eq(schema.localActorKeys.type, "Ed25519")); + const ctx = federation.createContext(new URL(actorIri), undefined); + assert.equal( + (await ensureActorKeyPairs(db, ctx, localActorId)).length, + 2, + ); + assert.deepEqual( + ( + await db + .select() + .from(schema.localActorKeys) + .where(eq(schema.localActorKeys.type, "RSASSA-PKCS1-v1_5")) + )[0], + rsa, + ); + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + assert.equal( + (await ensureActorKeyPairs(db, ctx, localActorId)).length, + 2, + ); + await db + .delete(schema.localActorKeys) + .where(eq(schema.localActorKeys.type, "Ed25519")); + assert.deepEqual(await ensureActorKeyPairs(db, ctx, localActorId), []); + assert.equal(await db.$count(schema.localActorKeys), 1); + }); + }); + for (const deletion of ["soft", "hard", "actor row"] as const) { + it(`does not persist keys when ${deletion} deletion happens during generation`, async () => { + await withFederation(async ({ db, federation }) => { + await seedLocalActor(db, { keys: false }); + const ctx = federation.createContext(new URL(actorIri), undefined); + let deleted = false; + const pairs = await ensureActorKeyPairs( + db, + ctx, + localActorId, + async (type) => { + if (!deleted) { + deleted = true; + if (deletion === "soft") { + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + } else if (deletion === "hard") { + await db + .delete(schema.localActors) + .where(eq(schema.localActors.id, localActorId)); + } else { + await db + .delete(schema.actors) + .where(eq(schema.actors.id, localActorId)); + } + } + return await generateCryptoKeyPair(type); + }, + ); + assert.deepEqual(pairs, []); + assert.equal(await db.$count(schema.localActorKeys), 0); + }); + }); + } + it("rejects missing, remote and wrong-host actors without storing keys", async () => { + await withFederation(async ({ db, federation }) => { + await seedLocalActor(db, { keys: false }); + await seedRemoteActor(db); + const ctx = federation.createContext(new URL(actorIri), undefined); + assert.deepEqual(await ensureActorKeyPairs(db, ctx, "invalid"), []); + assert.deepEqual(await ensureActorKeyPairs(db, ctx, uuid()), []); + assert.deepEqual(await ensureActorKeyPairs(db, ctx, remoteActorId), []); + assert.deepEqual( + await ensureActorKeyPairs( + db, + federation.createContext(new URL("https://other.example"), undefined), + localActorId, + ), + [], + ); + assert.equal(await db.$count(schema.localActorKeys), 0); + }); + }); + it("uses the local row identity and enforces key storage constraints", async () => { + await withFederation(async ({ db, federation }) => { + await seedLocalActor(db, { keys: false }); + const localId = uuid(); + await db.insert(schema.localActors).values({ id: localId }); + await db + .update(schema.actors) + .set({ localId }) + .where(eq(schema.actors.id, localActorId)); + await seedActorKeys(db, localId); + const ctx = federation.createContext(new URL(actorIri), undefined); + assert.equal( + (await ensureActorKeyPairs(db, ctx, localActorId)).length, + 2, + ); + const [row] = await db.select().from(schema.localActorKeys); + assert.equal(row!.localActorId, localId); + await assert.rejects(db.insert(schema.localActorKeys).values(row!)); + await assert.rejects( + db.update(schema.localActorKeys).set({ + publicKey: { ...row!.publicKey, d: "secret" } as NonNullable< + typeof row + >["publicKey"], + }), + ); + await db + .delete(schema.localActors) + .where(eq(schema.localActors.id, localId)); + assert.equal(await db.$count(schema.localActorKeys), 0); + assert.deepEqual(await ensureActorKeyPairs(db, ctx, localActorId), []); + }); + }); + it("coalesces generation and returns a competing persisted winner", async () => { + await withFederation(async ({ db, federation }) => { + await seedLocalActor(db, { keys: false }); + const ctx = federation.createContext(new URL(actorIri), undefined); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let count = 0; + const candidates: Record = {}; + const candidate = async (type?: "RSASSA-PKCS1-v1_5" | "Ed25519") => { + count += 1; + entered.resolve(); + await release.promise; + const pair = await generateCryptoKeyPair(type); + candidates[type!] = await exportJwk(pair.publicKey); + return pair; + }; + const first = ensureActorKeyPairs(db, ctx, localActorId, candidate); + const second = ensureActorKeyPairs(db, ctx, localActorId, candidate); + await entered.promise; + await seedActorKeys(db, localActorId); + release.resolve(); + const [one, two] = await Promise.all([first, second]); + assert.equal(count, 2); + assert.equal(one, two); + assert.equal(one.length, 2); + const rows = await db.select().from(schema.localActorKeys); + assert.equal(rows.length, 2); + for (const pair of one) { + const type = pair.publicKey.algorithm.name; + const stored = rows.find((row) => row.type === type)!; + assert.deepEqual(await exportJwk(pair.publicKey), stored.publicKey); + assert.deepEqual(await exportJwk(pair.privateKey), stored.privateKey); + assert.notDeepEqual(candidates[type], stored.publicKey); + } + }); + }); + it("sanitizes failures and clears failed in-flight generation", async () => { + await withFederation(async ({ db, federation }) => { + await seedLocalActor(db, { keys: false }); + const ctx = federation.createContext(new URL(actorIri), undefined); + await assert.rejects( + ensureActorKeyPairs(db, ctx, localActorId, () => + Promise.reject(new Error("PRIVATE_SECRET")), + ), + (error: unknown) => + error instanceof Error && + error.message === "Could not load actor signing keys." && + error.cause == null, + ); + assert.equal(await db.$count(schema.localActorKeys), 0); + await seedActorKeys(db, localActorId); + assert.equal( + (await ensureActorKeyPairs(db, ctx, localActorId)).length, + 2, + ); + await db + .update(schema.localActorKeys) + .set({ privateKey: { kty: "RSA", d: "PRIVATE_SECRET" } }); + await assert.rejects( + ensureActorKeyPairs(db, ctx, localActorId), + /Could not load actor signing keys/u, + ); + }); + }); + it("prewarms through real task dispatch for each federation's own handle", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db, { keys: false }); + const queue = new KeyGenerationQueue(); + const federation = await createFederation(db, { + kv: new MemoryKvStore(), + queue: { task: queue }, + manuallyStartQueue: true, + taskQueueResolution: "strict", + }); + const ctx = federation.createContext(new URL(actorIri), undefined); + await enqueueActorKeyGeneration(ctx, [localActorId]); + assert.equal((await queue.getDepth()).queued, 1); + const abort = new AbortController(); + const worker = federation.startQueue(undefined, { + queue: "task", + signal: abort.signal, + }); + try { + await assertEventually( + async () => (await db.$count(schema.localActorKeys)) === 2, + ); + await enqueueActorKeyGeneration(ctx, [localActorId]); + } finally { + abort.abort(); + await worker; + } + const otherQueue = new KeyGenerationQueue(); + const other = await createFederation(db, { + kv: new MemoryKvStore(), + queue: { task: otherQueue }, + manuallyStartQueue: true, + }); + await enqueueActorKeyGeneration( + other.createContext(new URL(actorIri), undefined), + [localActorId], + ); + assert.equal((await otherQueue.getDepth()).queued, 1); + const noQueue = await createFederation(db, { kv: new MemoryKvStore() }); + await enqueueActorKeyGeneration( + noQueue.createContext(new URL(actorIri), undefined), + [localActorId], + ); + }); + }); +}); +async function assertEventually(check: () => Promise): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + // oxlint-disable-next-line no-await-in-loop + if (await check()) return; + // oxlint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + } + assert.fail("Background task did not finish."); +} diff --git a/packages/federation/src/index.ts b/packages/federation/src/index.ts index 5973310..310d612 100644 --- a/packages/federation/src/index.ts +++ b/packages/federation/src/index.ts @@ -35,6 +35,7 @@ import { attachKv, trackPublicKeys, } from "./activity-delivery/tracking.ts"; +import { attachActorKeyTask, registerActorKeyTask } from "./actor-key-task.ts"; import { registerActorDispatcher } from "./actor.ts"; import { registerCollectionDispatchers } from "./collection.ts"; import { registerInboxListeners } from "./inbox.ts"; @@ -42,6 +43,7 @@ import { registerObjectDispatchers } from "./object-dispatchers.ts"; export { createInboundRecorder } from "./activity-delivery/inbound.ts"; export type { TrackedFederation } from "./activity-delivery/tracking.ts"; +export { enqueueActorKeyGeneration } from "./actor-key-task.ts"; export { deliverActivity } from "./activity-delivery/outbound.ts"; /** @@ -55,6 +57,7 @@ export { deliverActivity } from "./activity-delivery/outbound.ts"; export function buildFederation(db: Database): FederationBuilder { const builder = createFederationBuilder(); registerActorDispatcher(builder, db); + registerActorKeyTask(builder, db); registerInboxListeners(builder); registerObjectDispatchers(builder, db); registerCollectionDispatchers(builder, db); @@ -80,7 +83,8 @@ export default async function createFederation( db: Database, options: FederationOptions, ): Promise { - const federation = await buildFederation(db).build({ + const builder = buildFederation(db); + const federation = await builder.build({ ...options, kv: trackPublicKeys( options.kv, @@ -102,5 +106,12 @@ export default async function createFederation( }); if (outboxQueue(options.queue) != null) markQueued(federation); attachKv(federation, options.kv); + attachActorKeyTask( + builder, + federation, + options.queue != null && + "task" in options.queue && + options.queue.task != null, + ); return federation as TrackedFederation; } diff --git a/packages/federation/src/seed.test.ts b/packages/federation/src/seed.test.ts index 953e29a..9213b20 100644 --- a/packages/federation/src/seed.test.ts +++ b/packages/federation/src/seed.test.ts @@ -26,6 +26,7 @@ import { } from "@drfed/models"; import { type AddressingInput, PUBLIC_IRI } from "@drfed/models/resource"; import { type Uuid, uuidV7 } from "@drfed/models/uuid"; +import { exportJwk, generateCryptoKeyPair } from "@fedify/fedify"; import type { PgInsertValue } from "drizzle-orm/pg-core"; export const created = Temporal.Instant.from("2026-08-04T00:00:00.000Z"); @@ -36,26 +37,33 @@ export const remoteInstanceId = "00000000-0000-4000-8000-000000000102"; export const localActorId = "00000000-0000-4000-8000-000000000201" as const; export const remoteActorId = "00000000-0000-4000-8000-000000000202" as const; -export async function seedLocalActor(db: Database): Promise { +export async function seedLocalActor( + db: Database, + options: { keys?: boolean } = {}, +): Promise { await seedLocalInstance(db); await db.insert(schema.localActors).values({ id: localActorId, avatar: "avatar.png", header: "header.png", }); - await seedActors(db, { - id: localActorId, - localId: localActorId, - instanceId: localInstanceId, - type: "Person", - username: "alice", - iri: `https://test-instance.drfed.org/users/${localActorId}`, - inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`, - avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, - headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, - profileUrl: "https://test-instance.drfed.org/@alice", - created, - }); + await seedActors( + db, + { + id: localActorId, + localId: localActorId, + instanceId: localInstanceId, + type: "Person", + username: "alice", + iri: `https://test-instance.drfed.org/users/${localActorId}`, + inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`, + avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, + headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, + profileUrl: "https://test-instance.drfed.org/@alice", + created, + }, + options, + ); } export async function seedLocalInstance( @@ -108,6 +116,7 @@ type ActorSeed = PgInsertValue & { export async function seedActors( db: Database, values: ActorSeed | ActorSeed[], + options: { keys?: boolean } = {}, ): Promise { for (const { iri, ...actor } of Array.isArray(values) ? values : [values]) { await promoteResource( @@ -143,6 +152,9 @@ export async function seedActors( }, actor.id, ); + if (actor.localId != null && options.keys !== false) { + await seedActorKeys(db, actor.localId as Uuid); + } } } type ObjectSeed = PgInsertValue & { @@ -208,3 +220,45 @@ export async function seedObjects( ); } } + +let fixtureKeys: + | Promise< + { + type: "RSASSA-PKCS1-v1_5" | "Ed25519"; + publicKey: JsonWebKey; + privateKey: JsonWebKey; + }[] + > + | undefined; +/** In-memory synthetic fixture material; generation-specific tests opt out. */ +export async function seedActorKeys( + db: Database, + localId: Uuid, +): Promise { + fixtureKeys ??= (async () => { + const rsa = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const ed = await generateCryptoKeyPair("Ed25519"); + return await Promise.all( + [rsa, ed].map(async (pair) => ({ + type: pair.privateKey.algorithm.name as "RSASSA-PKCS1-v1_5" | "Ed25519", + publicKey: await exportJwk(pair.publicKey), + privateKey: await exportJwk(pair.privateKey), + })), + ); + })(); + await db + .insert(schema.localActorKeys) + .values( + (await fixtureKeys).map((pair) => ({ ...pair, localActorId: localId })), + ) + .onConflictDoNothing(); +} diff --git a/packages/federation/src/task-queue.test.ts b/packages/federation/src/task-queue.test.ts new file mode 100644 index 0000000..4f0add1 --- /dev/null +++ b/packages/federation/src/task-queue.test.ts @@ -0,0 +1,99 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from "node:assert/strict"; +import { getEventListeners } from "node:events"; +import { it } from "node:test"; + +import { KeyGenerationQueue } from "@drfed/federation/task-queue"; + +it("bounds complete batches and drops work after shutdown", async () => { + const queue = new KeyGenerationQueue(2); + await queue.enqueueMany([1, 2, 3]); + assert.equal(queue.dropped, 3); + assert.equal((await queue.getDepth()).queued, 0); + await queue.enqueueMany([1, 2]); + const abort = new AbortController(); + const received: unknown[] = []; + await queue.listen( + (value) => { + received.push(value); + if (received.length === 2) abort.abort(); + }, + { signal: abort.signal }, + ); + assert.deepEqual(received, [1, 2]); + await queue.enqueue(4); + assert.equal(queue.dropped, 4); + assert.equal(getEventListeners(abort.signal, "abort").length, 0); +}); +it("releases wait listeners through repeated wakeups and idle abort", async () => { + const queue = new KeyGenerationQueue(); + const abort = new AbortController(); + let notification = Promise.withResolvers(); + const worker = queue.listen(() => notification.resolve(), { + signal: abort.signal, + }); + for (let index = 0; index < 100; index += 1) { + // oxlint-disable-next-line no-await-in-loop + await queue.enqueue(index); + // oxlint-disable-next-line no-await-in-loop + await notification.promise; + notification = Promise.withResolvers(); + assert.ok(getEventListeners(abort.signal, "abort").length <= 1); + } + abort.abort(); + await worker; + assert.equal(getEventListeners(abort.signal, "abort").length, 0); +}); +it("waits for an active handler, continues after errors and rejects delays", async () => { + const queue = new KeyGenerationQueue(); + const abort = new AbortController(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + await queue.enqueueMany([1, 2]); + const worker = queue.listen( + async (value) => { + if (value === 1) throw new Error("test failure"); + entered.resolve(); + await release.promise; + }, + { signal: abort.signal }, + ); + await entered.promise; + let finished = false; + // oxlint-disable-next-line promise/prefer-await-to-then + const settled = worker.then(() => { + finished = true; + return undefined; + }); + abort.abort(); + await Promise.resolve(); + assert.equal(finished, false); + release.resolve(); + await settled; + await assert.rejects( + queue.enqueue(3, { delay: Temporal.Duration.from({ seconds: 1 }) }), + /delayed/u, + ); +}); +it("closes an already-aborted queue and clears its buffered work", async () => { + const queue = new KeyGenerationQueue(); + await queue.enqueue(1); + await queue.listen(() => assert.fail(), { signal: AbortSignal.abort() }); + assert.equal(queue.dropped, 1); + assert.equal((await queue.getDepth()).queued, 0); +}); diff --git a/packages/federation/src/task-queue.ts b/packages/federation/src/task-queue.ts new file mode 100644 index 0000000..f2bfb01 --- /dev/null +++ b/packages/federation/src/task-queue.ts @@ -0,0 +1,129 @@ +// 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 +import type { + MessageQueue, + MessageQueueEnqueueOptions, + MessageQueueListenOptions, +} from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; + +const logger = getLogger(["drfed", "federation", "task-queue"]); + +/** + * Bounded, ephemeral FIFO transport for immediate key prewarming only. + * Fedify owns the message codec and task dispatch. Dropped warmups are repaired + * by lazy key generation. No polling timers or retained abort listeners. + */ +export class KeyGenerationQueue implements MessageQueue { + readonly nativeRetrial = false; + readonly nativeDeduplication = false; + readonly atomicEnqueueMany = true; + #messages: unknown[] = []; + #wake: (() => void) | undefined; + #closed = false; + #listening = false; + #dropped = 0; + readonly #capacity: number; + constructor(capacity = 1024) { + if (!Number.isSafeInteger(capacity) || capacity < 1) { + throw new RangeError("Invalid task queue capacity."); + } + this.#capacity = capacity; + } + /** Number of expendable messages discarded due to capacity or shutdown. + * @returns The cumulative dropped message count. + */ + get dropped(): number { + return this.#dropped; + } + enqueue( + message: unknown, + options?: MessageQueueEnqueueOptions, + ): Promise { + return this.enqueueMany([message], options); + } + enqueueMany( + messages: readonly unknown[], + options?: MessageQueueEnqueueOptions, + ): Promise { + if ( + options?.delay != null && + Temporal.Duration.from(options.delay).sign !== 0 + ) { + return Promise.reject( + new TypeError("Key prewarming does not support delayed messages."), + ); + } + if ( + this.#closed || + this.#messages.length + messages.length > this.#capacity + ) { + this.#dropped += messages.length; + return Promise.resolve(); + } + this.#messages.push(...messages); + this.#wake?.(); + return Promise.resolve(); + } + getDepth() { + return Promise.resolve({ + queued: this.#messages.length, + ready: this.#messages.length, + delayed: 0, + }); + } + async listen( + handler: (message: unknown) => void | Promise, + options?: MessageQueueListenOptions, + ): Promise { + if (this.#listening || this.#closed) { + throw new Error("Key task queue cannot be restarted."); + } + this.#listening = true; + const signal = options?.signal; + try { + // The AbortSignal is changed by the worker owner. + // oxlint-disable-next-line no-unmodified-loop-condition + while (!signal?.aborted) { + if (this.#messages.length === 0) { + const wake = Promise.withResolvers(); + this.#wake = () => wake.resolve(); + signal?.addEventListener("abort", this.#wake, { once: true }); + try { + await wake.promise; + } finally { + signal?.removeEventListener("abort", this.#wake); + this.#wake = undefined; + } + continue; + } + const message = this.#messages.shift(); + try { + await handler(message); + } catch { + logger.error("A key prewarming message failed."); + } + } + } finally { + this.#closed = true; + this.#dropped += this.#messages.length; + this.#messages = []; + this.#wake = undefined; + } + } +} diff --git a/packages/graphql/src/activity-delivery.test.ts b/packages/graphql/src/activity-delivery.test.ts index a062c83..f79f618 100644 --- a/packages/graphql/src/activity-delivery.test.ts +++ b/packages/graphql/src/activity-delivery.test.ts @@ -129,6 +129,7 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori kv, contextLoaderFactory: () => contextLoader, documentLoaderFactory: () => documentLoader, + authenticatedDocumentLoaderFactory: () => documentLoader, }); const recorder = createInboundRecorder({ db, federation, rootOrigin }); const send = async ( @@ -264,6 +265,8 @@ it("records missing signatures, failed key fetches and non-JSON bodies, and skip contextLoaderFactory: () => contextLoader, documentLoaderFactory: () => () => Promise.reject(new TypeError("offline")), + authenticatedDocumentLoaderFactory: () => () => + Promise.reject(new TypeError("offline")), }); const recorder = createInboundRecorder({ db, federation, rootOrigin }); assert.equal( diff --git a/packages/graphql/src/activity-delivery/inbound.test.ts b/packages/graphql/src/activity-delivery/inbound.test.ts index a913499..abbf43b 100644 --- a/packages/graphql/src/activity-delivery/inbound.test.ts +++ b/packages/graphql/src/activity-delivery/inbound.test.ts @@ -129,6 +129,7 @@ async function createRecorder( kv, contextLoaderFactory: () => contextLoader, documentLoaderFactory: () => documentLoader, + authenticatedDocumentLoaderFactory: () => documentLoader, }); const recorder = createInboundRecorder({ db, federation, rootOrigin }); return { @@ -519,6 +520,19 @@ it("records a request Fedify throws on, and throws the exception again", async ( ], ]), ), + authenticatedDocumentLoaderFactory: () => + keyLoader( + new Map([ + [ + httpKeyId.href, + new CryptographicKey({ + id: httpKeyId, + owner: actorIri, + publicKey: pair.publicKey, + }), + ], + ]), + ), }); const recorder = createInboundRecorder({ db, federation, rootOrigin }); const body = JSON.stringify( @@ -969,6 +983,19 @@ it("receives a queued activity once the queue worker runs its listener", async ( ], ]), ), + authenticatedDocumentLoaderFactory: () => + keyLoader( + new Map([ + [ + proofKeyId.href, + new Multikey({ + id: proofKeyId, + controller: actorIri, + publicKey: pair.publicKey, + }), + ], + ]), + ), }); const controller = new AbortController(); const start = () => @@ -1556,6 +1583,8 @@ it("orders deliveries by arrival, even when handling ends out of order", async ( contextLoaderFactory: () => contextLoader, documentLoaderFactory: () => () => Promise.reject(new TypeError("offline")), + authenticatedDocumentLoaderFactory: () => () => + Promise.reject(new TypeError("offline")), }); const { promise: reached, resolve: enter } = Promise.withResolvers(); const { promise: gate, resolve: release } = Promise.withResolvers(); diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 19a86c0..14ffa15 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -20,6 +20,7 @@ import assert from "node:assert/strict"; +import { KeyGenerationQueue } from "@drfed/federation/task-queue"; import { schema } from "@drfed/models"; import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; import { faker } from "@faker-js/faker"; @@ -195,6 +196,7 @@ describe("Mutation.generateActors", () => { ), ); + assert.equal(await db.$count(schema.localActorKeys), 0); const actors = await db.select().from(schema.actors); assert.equal(actors.length, 2); assert.equal(username.mock.callCount(), 3); @@ -228,6 +230,64 @@ describe("Mutation.generateActors", () => { }); }); + it("schedules only committed actors and survives a failed prewarm enqueue", async (test) => { + const queue = new KeyGenerationQueue(); + await withTestHarness( + async ({ db, post, federation }) => { + const auth = await seedAuthenticatedLocalInstance(db); + let observedActors: number | undefined; + let observedKeys: number | undefined; + const enqueue = test.mock.method(queue, "enqueueMany", async () => { + observedActors = await db.$count(schema.actors); + observedKeys = await db.$count(schema.localActorKeys); + throw new Error("Expendable queue unavailable."); + }); + const response = await post( + { + query: generateActorsMutation, + variables: { + instance: globalId("Instance", localInstanceId), + size: 2, + }, + }, + auth, + ); + const body = await response.json(); + assert.equal(body.errors, undefined); + assert.equal( + body.data.generateActors.resultType, + "CreateActorsSuccess", + ); + assert.equal(enqueue.mock.callCount(), 1); + assert.equal(observedActors, 2); + assert.equal(observedKeys, 0); + const actor = body.data.generateActors.actors[0]; + const context = federation.createContext(new URL(actor.iri), undefined); + assert.equal((await context.getActorKeyPairs(actor.uuid)).length, 2); + assert.equal(await db.$count(schema.localActorKeys), 2); + const rejected = await post( + { + query: generateActorsMutation, + variables: { + instance: globalId("Instance", localInstanceId), + size: 10000, + }, + }, + auth, + ); + assert.equal( + (await rejected.json()).data.generateActors.resultType, + "CreateActorsError", + ); + assert.equal(enqueue.mock.callCount(), 1); + }, + undefined, + undefined, + undefined, + { queue: { task: queue } }, + ); + }); + it("builds actor URIs from the stored host and the root scheme", async () => { // The stored host deliberately disagrees with what recomposing // `${slug}.${root}` would produce, and the root origin is HTTP on a diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index f11dfdc..c27daa3 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -19,6 +19,7 @@ // Keep dependent database writes and observations sequential. // oxlint-disable no-await-in-loop +import { enqueueActorKeyGeneration } from "@drfed/federation"; import { type Database, promoteResource, schema } from "@drfed/models"; import { type CollectionRole, actorTypeEnum } from "@drfed/models/schema"; import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; @@ -284,7 +285,8 @@ builder.mutationFields((t) => ({ // Relay decodes global IDs as strings; Instance uses UUID identifiers. const targetInstanceId = instanceId as Uuid; - return await ctx.db.transaction(async (tx) => { + let createdOrigin: URL | undefined; + const result = await ctx.db.transaction(async (tx) => { // Find the instance that the account is included const [instance] = await tx .select({ @@ -346,6 +348,7 @@ builder.mutationFields((t) => ({ }; } // Create actors + createdOrigin = new URL(instanceUrl); const fedCtx = ctx.federation.createContext( new URL(instanceUrl), undefined, @@ -403,6 +406,13 @@ builder.mutationFields((t) => ({ } return { actors: createdActors }; }); + if ("actors" in result && createdOrigin != null) { + await enqueueActorKeyGeneration( + ctx.federation.createContext(createdOrigin, undefined), + result.actors.map((actor) => actor.id), + ); + } + return result; }, }), })); diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index bfe919c..6f1ddeb 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -18,7 +18,11 @@ import { createYogaServer } from "@drfed/graphql"; import type { ServerContext, UserContext } from "@drfed/graphql/builder"; import { type Database, migrate, relations, schema } from "@drfed/models"; import { PGlite } from "@electric-sql/pglite"; -import { type Federation, MemoryKvStore } from "@fedify/fedify"; +import { + type Federation, + type FederationOptions, + MemoryKvStore, +} from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; import { MockTransport } from "@upyo/mock"; import { drizzle } from "drizzle-orm/pglite"; @@ -191,10 +195,14 @@ export async function withTestHarness( rootOrigin: URL = new URL("https://drfed.org"), loginOrigins: ReadonlySet = new Set(["https://drfed.test"]), emailFrom?: string, + federationOptions: Pick, "queue"> = {}, ): Promise> { return await withTemporaryDatabase(async (db) => { const mailer = new MockTransport(); - const federation = await createFederation(db, { kv: new MemoryKvStore() }); + const federation = await createFederation(db, { + kv: new MemoryKvStore(), + ...federationOptions, + }); const yoga = createYogaServer(db, federation, { mailer, rootOrigin, diff --git a/packages/graphql/src/seed.test.ts b/packages/graphql/src/seed.test.ts index adc34a3..8140dc9 100644 --- a/packages/graphql/src/seed.test.ts +++ b/packages/graphql/src/seed.test.ts @@ -26,6 +26,7 @@ import { } from "@drfed/models"; import { type AddressingInput, PUBLIC_IRI } from "@drfed/models/resource"; import { type Uuid, uuidV7 } from "@drfed/models/uuid"; +import { exportJwk, generateCryptoKeyPair } from "@fedify/fedify"; import type { PgInsertValue } from "drizzle-orm/pg-core"; import { hashSecret } from "./auth/hash.ts"; @@ -81,26 +82,33 @@ export async function seedAuthenticatedLocalInstance( return { headers: { authorization: `Bearer ${accessToken}` } }; } -export async function seedLocalActor(db: Database): Promise { +export async function seedLocalActor( + db: Database, + options: { keys?: boolean } = {}, +): Promise { await seedLocalInstance(db); await db.insert(schema.localActors).values({ id: localActorId, avatar: "avatar.png", header: "header.png", }); - await seedActors(db, { - id: localActorId, - localId: localActorId, - instanceId: localInstanceId, - type: "Person", - username: "alice", - iri: `https://test-instance.drfed.org/users/${localActorId}`, - inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`, - avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, - headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, - profileUrl: "https://test-instance.drfed.org/@alice", - created, - }); + await seedActors( + db, + { + id: localActorId, + localId: localActorId, + instanceId: localInstanceId, + type: "Person", + username: "alice", + iri: `https://test-instance.drfed.org/users/${localActorId}`, + inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`, + avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, + headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, + profileUrl: "https://test-instance.drfed.org/@alice", + created, + }, + options, + ); } export async function seedLocalInstance( @@ -153,6 +161,7 @@ type ActorSeed = PgInsertValue & { export async function seedActors( db: Database, values: ActorSeed | ActorSeed[], + options: { keys?: boolean } = {}, ): Promise { for (const { iri, ...actor } of Array.isArray(values) ? values : [values]) { await promoteResource( @@ -188,6 +197,9 @@ export async function seedActors( }, actor.id, ); + if (actor.localId != null && options.keys !== false) { + await seedActorKeys(db, actor.localId as Uuid); + } } } type ObjectSeed = PgInsertValue & { @@ -253,3 +265,45 @@ export async function seedObjects( ); } } + +let fixtureKeys: + | Promise< + { + type: "RSASSA-PKCS1-v1_5" | "Ed25519"; + publicKey: JsonWebKey; + privateKey: JsonWebKey; + }[] + > + | undefined; +/** In-memory synthetic fixture material; generation-specific tests opt out. */ +export async function seedActorKeys( + db: Database, + localId: Uuid, +): Promise { + fixtureKeys ??= (async () => { + const rsa = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const ed = await generateCryptoKeyPair("Ed25519"); + return await Promise.all( + [rsa, ed].map(async (pair) => ({ + type: pair.privateKey.algorithm.name as "RSASSA-PKCS1-v1_5" | "Ed25519", + publicKey: await exportJwk(pair.publicKey), + privateKey: await exportJwk(pair.privateKey), + })), + ); + })(); + await db + .insert(schema.localActorKeys) + .values( + (await fixtureKeys).map((pair) => ({ ...pair, localActorId: localId })), + ) + .onConflictDoNothing(); +} diff --git a/packages/models/drizzle/20261006122653_local_actor_keys/migration.sql b/packages/models/drizzle/20261006122653_local_actor_keys/migration.sql new file mode 100644 index 0000000..ef19765 --- /dev/null +++ b/packages/models/drizzle/20261006122653_local_actor_keys/migration.sql @@ -0,0 +1,11 @@ +CREATE TYPE "local_actor_key_type" AS ENUM('RSASSA-PKCS1-v1_5', 'Ed25519');--> statement-breakpoint +CREATE TABLE "local_actor_keys" ( + "local_actor_id" uuid, + "type" "local_actor_key_type", + "public_key" jsonb NOT NULL, + "private_key" jsonb NOT NULL, + CONSTRAINT "local_actor_keys_pkey" PRIMARY KEY("local_actor_id","type"), + CONSTRAINT "local_actor_keys_public_key_check" CHECK (jsonb_typeof("public_key") = 'object' AND NOT ("public_key" ?| array['d','p','q','dp','dq','qi','oth','k'])) +); +--> statement-breakpoint +ALTER TABLE "local_actor_keys" ADD CONSTRAINT "local_actor_keys_local_actor_id_local_actors_id_fkey" FOREIGN KEY ("local_actor_id") REFERENCES "local_actors"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/models/drizzle/20261006122653_local_actor_keys/snapshot.json b/packages/models/drizzle/20261006122653_local_actor_keys/snapshot.json new file mode 100644 index 0000000..28379a3 --- /dev/null +++ b/packages/models/drizzle/20261006122653_local_actor_keys/snapshot.json @@ -0,0 +1,3627 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "1dbf7e97-eced-481e-8b51-850cfa40c2f8", + "prevIds": ["7b4b4a36-eeea-4c8c-a48f-4e4928939348"], + "ddl": [ + { + "values": ["inbound", "outbound"], + "name": "activity_delivery_direction", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "received", + "acknowledged", + "unverified", + "rejected", + "queued", + "sent", + "failed", + "permanently_failed", + "abandoned" + ], + "name": "activity_delivery_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["http_signature", "ld_signature", "object_integrity_proof"], + "name": "activity_delivery_verification_mechanism", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "verified", + "invalid_signature", + "key_fetch_error", + "no_signature", + "unattempted", + "unobserved" + ], + "name": "activity_delivery_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": ["RSASSA-PKCS1-v1_5", "Ed25519"], + "name": "local_actor_key_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_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activity_delivery_actor_collections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activity_delivery_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activity_delivery_attempts", + "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_actor_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": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "object_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instance_id", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "activity_delivery_direction", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "direction", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "activity_delivery_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "activity_delivery_verification_mechanism", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_mechanism", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "activity_delivery_verification_result", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_result", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "types", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "object_type", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "object_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signed_key_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_key_id", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_actor_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_host", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inbox_url", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "request_url", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headers", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status_code", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "response_body", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipient_iris", + "entityType": "columns", + "schema": "public", + "table": "activity_deliveries" + }, + { + "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_deliveries" + }, + { + "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_deliveries" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivery_id", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actor_collections" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actor_collections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collection_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actor_collections" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivery_id", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "inbox_owner", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "addressed", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "addressed_directly", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sender", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_attempts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivery_id", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_attempts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "succeeded", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status_code", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "response_body", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "activity_delivery_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_delivery_attempts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "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": "collection_id", + "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": "local_id", + "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": "instance_id", + "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": "inbox_url", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile_url", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_url", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "header_url", + "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": "bio_html", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automatically_approves_followers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "field_htmls", + "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": "suspended_until", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successor_id", + "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": "following_count", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followers_count", + "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": "source_id", + "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": "target_id", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collection_id", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "item_id", + "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": "owner_actor_id", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "total_items", + "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": "account_id", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instance_id", + "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": "local_id", + "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": "node_info_url", + "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": "software_version", + "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": "local_actor_id", + "entityType": "columns", + "schema": "public", + "table": "local_actor_keys" + }, + { + "type": "local_actor_key_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "local_actor_keys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_key", + "entityType": "columns", + "schema": "public", + "table": "local_actor_keys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_key", + "entityType": "columns", + "schema": "public", + "table": "local_actor_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": "max_actors", + "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": "account_id", + "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": "actor_id", + "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": "content_html", + "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": "account_id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_hash", + "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": "actor_id", + "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": "object_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_object_published_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instance_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"created\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_delivery_instance_created_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_deliveries" + }, + { + "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_delivery_actor_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_deliveries" + }, + { + "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_delivery_verification_key_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_deliveries" + }, + { + "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_delivery_outbound_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"created\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"delivery_id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_delivery_actor_created_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "delivery_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_delivery_attempt_delivery_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "collection_id", + "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": "instance_id", + "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": "target_id", + "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": "collection_id", + "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": "account_id", + "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": "instance_id", + "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": "actor_id", + "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": ["actor_id"], + "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": ["object_id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_objectId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["instance_id"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activity_deliveries_instance_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "nameExplicit": false, + "columns": ["actor_id"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "activity_deliveries_actor_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "nameExplicit": false, + "columns": ["verification_key_id"], + "schemaTo": "public", + "tableTo": "key_versions", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "activity_deliveries_verification_key_id_key_versions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "nameExplicit": true, + "columns": ["delivery_id", "actor_id"], + "schemaTo": "public", + "tableTo": "activity_delivery_actors", + "columnsTo": ["delivery_id", "actor_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activity_delivery_actor_collections_link_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_delivery_actor_collections" + }, + { + "nameExplicit": false, + "columns": ["delivery_id"], + "schemaTo": "public", + "tableTo": "activity_deliveries", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activity_delivery_actors_eZEmaeqml3qX_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "nameExplicit": false, + "columns": ["actor_id"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activity_delivery_actors_actor_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "nameExplicit": false, + "columns": ["delivery_id"], + "schemaTo": "public", + "tableTo": "activity_deliveries", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activity_delivery_attempts_BJqYxUSnVaXX_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_delivery_attempts" + }, + { + "nameExplicit": false, + "columns": ["actor_id"], + "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": ["collection_id"], + "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": ["local_id"], + "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": ["instance_id"], + "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": ["successor_id"], + "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": ["source_id"], + "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": ["target_id"], + "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": ["collection_id"], + "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": ["item_id"], + "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": ["owner_actor_id"], + "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": ["account_id"], + "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": ["instance_id"], + "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": ["local_id"], + "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": ["local_actor_id"], + "schemaTo": "public", + "tableTo": "local_actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_actor_keys_local_actor_id_local_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "local_actor_keys" + }, + { + "nameExplicit": false, + "columns": ["account_id"], + "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": ["actor_id"], + "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": ["account_id"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "columns": ["delivery_id", "actor_id", "collection_iri"], + "nameExplicit": false, + "name": "activity_delivery_actor_collections_pkey", + "entityType": "pks", + "schema": "public", + "table": "activity_delivery_actor_collections" + }, + { + "columns": ["delivery_id", "actor_id"], + "nameExplicit": false, + "name": "activity_delivery_actors_pkey", + "entityType": "pks", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "columns": ["actor_id", "role"], + "nameExplicit": false, + "name": "actor_collection_references_pkey", + "entityType": "pks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "columns": ["collection_id", "item_id"], + "nameExplicit": false, + "name": "collection_items_pkey", + "entityType": "pks", + "schema": "public", + "table": "collection_items" + }, + { + "columns": ["instance_id", "account_id"], + "nameExplicit": false, + "name": "instance_members_pkey", + "entityType": "pks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["local_actor_id", "type"], + "nameExplicit": false, + "name": "local_actor_keys_pkey", + "entityType": "pks", + "schema": "public", + "table": "local_actor_keys" + }, + { + "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_deliveries_pkey", + "schema": "public", + "table": "activity_deliveries", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "activity_delivery_attempts_pkey", + "schema": "public", + "table": "activity_delivery_attempts", + "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", "instance_id"], + "nullsNotDistinct": false, + "name": "username_key", + "entityType": "uniques", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": ["source_id", "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": ["local_id"], + "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": ["token_hash"], + "nullsNotDistinct": false, + "name": "sessions_tokenHash_key", + "schema": "public", + "table": "sessions", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"max_instances\" >= 0", + "name": "accounts_max_instances_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "(\"direction\" = 'inbound' AND \"status\" IN ('received', 'acknowledged', 'unverified', 'rejected')) OR (\"direction\" = 'outbound' AND \"status\" IN ('queued', 'sent', 'failed', 'permanently_failed', 'abandoned'))", + "name": "activity_deliveries_direction_status_check", + "entityType": "checks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "value": "(\"completed\" IS NULL) = (\"status\" = 'queued')", + "name": "activity_deliveries_completed_check", + "entityType": "checks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "value": "\"status_code\" IS NULL OR \"status_code\" BETWEEN 100 AND 599", + "name": "activity_deliveries_status_code_check", + "entityType": "checks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "value": "\"direction\" <> 'outbound' OR \"verification_key_id\" IS NULL", + "name": "activity_deliveries_outbound_key_check", + "entityType": "checks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "value": "(\"direction\" = 'inbound') = (\"verification_result\" IS NOT NULL)", + "name": "activity_deliveries_verification_result_check", + "entityType": "checks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "value": "\"verification_mechanism\" IS NULL OR (\"direction\" = 'inbound' AND \"verification_result\" NOT IN ('unattempted', 'unobserved'))", + "name": "activity_deliveries_verification_mechanism_check", + "entityType": "checks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "value": "(\"direction\" = 'inbound') = (\"body\" IS NOT NULL)", + "name": "activity_deliveries_body_check", + "entityType": "checks", + "schema": "public", + "table": "activity_deliveries" + }, + { + "value": "\"inbox_owner\" OR \"addressed\" OR \"sender\"", + "name": "activity_delivery_actors_role_check", + "entityType": "checks", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "value": "NOT \"addressed_directly\" OR \"addressed\"", + "name": "activity_delivery_actors_addressed_directly_check", + "entityType": "checks", + "schema": "public", + "table": "activity_delivery_actors" + }, + { + "value": "\"status_code\" IS NULL OR \"status_code\" BETWEEN 100 AND 599", + "name": "activity_delivery_attempts_status_code_check", + "entityType": "checks", + "schema": "public", + "table": "activity_delivery_attempts" + }, + { + "value": "\"succeeded\" = (\"error\" IS NULL)", + "name": "activity_delivery_attempts_error_check", + "entityType": "checks", + "schema": "public", + "table": "activity_delivery_attempts" + }, + { + "value": "\"username\" NOT LIKE '%@%'", + "name": "actors_username_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\n \"suspended_until\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspended_until\" > \"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": "jsonb_typeof(\"public_key\") = 'object' AND NOT (\"public_key\" ?| array['d','p','q','dp','dq','qi','oth','k'])", + "name": "local_actor_keys_public_key_check", + "entityType": "checks", + "schema": "public", + "table": "local_actor_keys" + }, + { + "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": "\"max_actors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "trim(both from \"content_html\") <> ''", + "name": "objects_content_html_check", + "entityType": "checks", + "schema": "public", + "table": "objects" + } + ], + "renames": [] +} diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 7cea79e..ecddcb3 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -14,6 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import type { webcrypto } from "node:crypto"; + import { desc, sql } from "drizzle-orm"; import { type AnyPgColumn, @@ -337,6 +339,36 @@ export const localActors = pgTable("local_actors", { export type LocalActor = typeof localActors.$inferSelect; export type NewLocalActor = typeof localActors.$inferInsert; +/** Signing algorithms available to local actors. */ +export const localActorKeyTypeEnum = pgEnum("local_actor_key_type", [ + "RSASSA-PKCS1-v1_5", + "Ed25519", +]); +export type LocalActorKeyType = + (typeof localActorKeyTypeEnum.enumValues)[number]; +/** Private signing material; never expose this table through GraphQL. */ +export const localActorKeys = pgTable( + "local_actor_keys", + { + localActorId: uuid("local_actor_id") + .$type() + .notNull() + .references(() => localActors.id, { onDelete: "cascade" }), + type: localActorKeyTypeEnum().notNull(), + publicKey: jsonb("public_key").$type().notNull(), + privateKey: jsonb("private_key").$type().notNull(), + }, + (table) => [ + primaryKey({ columns: [table.localActorId, table.type] }), + check( + "local_actor_keys_public_key_check", + sql`jsonb_typeof(${table.publicKey}) = 'object' AND NOT (${table.publicKey} ?| array['d','p','q','dp','dq','qi','oth','k'])`, + ), + ], +); +export type LocalActorKey = typeof localActorKeys.$inferSelect; +export type NewLocalActorKey = typeof localActorKeys.$inferInsert; + export const objectTypeEnum = pgEnum("object_type", ["Article", "Note"]); export type ObjectType = (typeof objectTypeEnum.enumValues)[number]; /** ActivityPub objects authored by actors. */ From f1ffdeddbaa8a11e0bd9c794b9a9475110e22c39 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 7 Oct 2026 00:45:06 +0900 Subject: [PATCH 2/2] Log federation failures without exposing secrets Record the request method, pathname and a coarse error type before returning the generic federation 500 response. Do not inspect error names, messages or causes, which can carry private signing key data. Extend the serving regression test to capture scoped LogTape records and exclude secret error values and URL query parameters. Successful requests still produce no failure record. The maintainer requested review fixes. Codex implemented the logging and regression assertions, preserving the agreed key secrecy boundary. Validation: mise run build, mise run check and mise run test. https://github.com/fedify-dev/drfed/pull/114#discussion_r4197333736 Assisted-by: Codex:gpt-6.1-sol --- packages/drfed/src/serving.test.ts | 78 +++++++++++++++++++++--------- packages/drfed/src/serving.ts | 11 ++++- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts index 6a0783b..da48e5e 100644 --- a/packages/drfed/src/serving.test.ts +++ b/packages/drfed/src/serving.test.ts @@ -27,6 +27,7 @@ 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 { type LogRecord, withConfig } from "@logtape/logtape"; import { describe } from "@logtape/testing-node/autoload"; import { drizzle } from "drizzle-orm/pglite"; @@ -296,30 +297,59 @@ it("records inbox requests only on the instance surface", async () => { } }); -it("returns plain 500 for federation failures and continues serving", async () => { - let broken = true; - const fetch = createFetchHandler({ - rootOrigin, - federation: { - fetch() { - if (broken) throw new Error("PRIVATE_SECRET"); - return Promise.resolve(new Response("ok")); - }, +it("logs federation failures without secrets and continues serving", async () => { + const records: LogRecord[] = []; + await withConfig( + { + sinks: { capture: (record) => records.push(record) }, + loggers: [{ category: ["drfed", "serving"], sinks: ["capture"] }], + }, + async () => { + let broken = true; + const fetch = createFetchHandler({ + rootOrigin, + federation: { + fetch() { + if (broken) { + const error = new Error("PRIVATE_SECRET", { + cause: { privateKey: "PRIVATE_CAUSE" }, + }); + error.name = "PRIVATE_NAME"; + throw error; + } + return Promise.resolve(new Response("ok")); + }, + }, + serveControlSurface: () => new Response("graphql"), + }); + // Check the two request paths sequentially. + // oxlint-disable no-await-in-loop + for (const method of ["GET", "POST"]) { + const response = await fetch( + new Request( + "https://demo.drfed.net/users/x/inbox?token=PRIVATE_QUERY", + { method }, + ), + ); + assert.equal(response.status, 500); + assert.equal(await response.text(), "Internal server error"); + } + broken = false; + assert.equal( + (await fetch(new Request("https://demo.drfed.net/users/x"))).status, + 200, + ); + assert.equal(records.length, 2); + for (const [index, record] of records.entries()) { + assert.equal(record.level, "error"); + assert.deepEqual(record.category, ["drfed", "serving"]); + assert.deepEqual(record.properties, { + method: ["GET", "POST"][index], + path: "/users/x/inbox", + errorType: "Error", + }); + } + assert.ok(!JSON.stringify(records).includes("PRIVATE_")); }, - serveControlSurface: () => new Response("graphql"), - }); - // Check the two request paths sequentially. - // oxlint-disable no-await-in-loop - for (const method of ["GET", "POST"]) { - const response = await fetch( - new Request("https://demo.drfed.net/users/x/inbox", { method }), - ); - assert.equal(response.status, 500); - assert.equal(await response.text(), "Internal server error"); - } - broken = false; - assert.equal( - (await fetch(new Request("https://demo.drfed.net/users/x"))).status, - 200, ); }); diff --git a/packages/drfed/src/serving.ts b/packages/drfed/src/serving.ts index ea75ef9..65fd26a 100644 --- a/packages/drfed/src/serving.ts +++ b/packages/drfed/src/serving.ts @@ -101,7 +101,16 @@ export function createFetchHandler( onNotAcceptable: notFound, contextData: undefined, }); - } catch { + } catch (error) { + // Error messages, names and causes can contain private key material. + getLogger(["drfed", "serving"]).error( + "Federation request {method} {path} failed ({errorType}).", + { + method: request.method, + path: url.pathname, + errorType: error instanceof Error ? "Error" : typeof error, + }, + ); return new Response("Internal server error", { status: 500 }); } case "misdirected":