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..da48e5e 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,8 @@ 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 { type LogRecord, withConfig } from "@logtape/logtape"; +import { describe } from "@logtape/testing-node/autoload"; import { drizzle } from "drizzle-orm/pglite"; const rootOrigin = new URL("https://drfed.net"); @@ -294,3 +296,60 @@ it("records inbox requests only on the instance surface", async () => { await client.close(); } }); + +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_")); + }, + ); +}); diff --git a/packages/drfed/src/serving.ts b/packages/drfed/src/serving.ts index 3c76bde..65fd26a 100644 --- a/packages/drfed/src/serving.ts +++ b/packages/drfed/src/serving.ts @@ -95,11 +95,24 @@ 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 (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": // 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. */