diff --git a/.gitignore b/.gitignore index 256122a..68e6e34 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ index.db # Agent skills from npm packages (managed by skills-npm) **/skills/npm-* +mise.local.toml diff --git a/packages/graphql/package.json b/packages/graphql/package.json index ce60ce6..3203d7e 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -69,6 +69,10 @@ "./schema": { "types": "./dist/schema.d.mts", "default": "./dist/schema.mjs" + }, + "./object": { + "types": "./dist/object.d.mts", + "default": "./dist/object.mjs" } }, "files": [ @@ -83,7 +87,8 @@ "src/builder.ts", "src/federation.ts", "src/instance.ts", - "src/schema.ts" + "src/schema.ts", + "src/object.ts" ], "dts": { "sourcemap": true, diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 4deae76..a08fbbe 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -18,24 +18,22 @@ import assert from "node:assert/strict"; -import { type Database, schema } from "@drfed/models"; +import { schema } from "@drfed/models"; import { describe, it } from "@logtape/testing-node/autoload"; -import { hashSecret } from "./auth/hash.ts"; import { withTestHarness } from "./harness.test.ts"; - -const accepted = new Date("2026-08-04T00:00:00.000Z"); -const created = new Date("2026-08-04T00:00:00.000Z"); -const expires = new Date("2030-08-04T00:00:00.000Z"); -const ok = 200; - -const accountId = "00000000-0000-4000-8000-000000000001"; -const localInstanceId = "00000000-0000-4000-8000-000000000101"; -const remoteInstanceId = "00000000-0000-4000-8000-000000000102"; -const localActorId = "00000000-0000-4000-8000-000000000201"; -const remoteActorId = "00000000-0000-4000-8000-000000000202"; -const sessionId = "00000000-0000-4000-8000-000000000301"; -const accessToken = "test-access-token"; +import { + created, + globalId, + localActorId, + localInstanceId, + ok, + remoteActorId, + remoteInstanceId, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedRemoteActor, +} from "./seed.test.ts"; const generateActorsMutation = ` mutation GenerateActors($instance: ID!, $size: Int!) { @@ -240,96 +238,3 @@ describe("Actor", () => { }); }); }); - -function globalId(type: "Actor" | "Instance", id: string): string { - return Buffer.from(`${type}:${id}`).toString("base64"); -} - -async function seedAuthenticatedLocalInstance( - db: Database, -): Promise { - await db.insert(schema.accounts).values({ - id: accountId, - email: "owner@example.com", - name: "Owner", - created, - }); - await db.insert(schema.sessions).values({ - id: sessionId, - accountId, - tokenHash: await hashSecret(accessToken), - }); - await seedLocalInstance(db); - await db.insert(schema.instanceMembers).values({ - accountId, - instanceId: localInstanceId, - admin: true, - accepted, - created, - }); - return { headers: { authorization: `Bearer ${accessToken}` } }; -} - -async function seedLocalActor(db: Database): Promise { - await seedLocalInstance(db); - await db.insert(schema.localActors).values({ - id: localActorId, - avatar: "avatar.png", - header: "header.png", - }); - await db.insert(schema.actors).values({ - 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`, - outboxUrl: `https://test-instance.drfed.org/users/${localActorId}/outbox`, - avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, - followersUrl: `https://test-instance.drfed.org/users/${localActorId}/followers`, - followingUrl: `https://test-instance.drfed.org/users/${localActorId}/following`, - headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, - profileUrl: "https://test-instance.drfed.org/@alice", - featuredUrl: `https://test-instance.drfed.org/users/${localActorId}/featured`, - created, - }); -} - -async function seedLocalInstance(db: Database): Promise { - await db.insert(schema.localInstances).values({ - id: localInstanceId, - slug: "test-instance", - expires, - }); - await db.insert(schema.instances).values({ - id: localInstanceId, - localId: localInstanceId, - created, - host: "test-instance.drfed.org", - }); -} - -async function seedRemoteActor(db: Database): Promise { - await db.insert(schema.instances).values({ - id: remoteInstanceId, - created, - host: "remote.example.com", - }); - await db.insert(schema.actors).values({ - id: remoteActorId, - instanceId: remoteInstanceId, - type: "Service", - username: "bob", - iri: "https://remote.example.com/users/bob", - inboxUrl: "https://remote.example.com/users/bob/inbox", - outboxUrl: "https://remote.example.com/users/bob/outbox", - avatarUrl: "https://remote.example.com/users/bob/avatar.png", - followersUrl: "https://remote.example.com/users/bob/followers", - followingUrl: "https://remote.example.com/users/bob/following", - headerUrl: "https://remote.example.com/users/bob/header.png", - profileUrl: "https://remote.example.com/@bob", - featuredUrl: "https://remote.example.com/users/bob/featured", - created, - }); -} diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 054acff..4420f50 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -18,10 +18,20 @@ import assert from "node:assert/strict"; import { createYogaServer } from "@drfed/graphql"; import createFederation, { buildFederation } from "@drfed/graphql/federation"; +import { schema } from "@drfed/models"; import { MemoryKvStore } from "@fedify/fedify"; +import { Object as APObject } from "@fedify/vocab"; import { describe, it } from "@logtape/testing-node/autoload"; +import { eq } from "drizzle-orm"; +import { v7 as uuid } from "uuid"; import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; +import { + localActorId, + remoteActorId, + seedLocalActor, + seedRemoteActor, +} from "./seed.test.ts"; const origin = new URL("https://drfed.test"); @@ -32,6 +42,10 @@ describe("createFederation()", () => { kv: new MemoryKvStore(), }); const ctx = federation.createContext(origin, undefined); + assert.equal( + ctx.getObjectUri(APObject, { identifier: "a", id: "b" }).href, + "https://drfed.test/users/a/b", + ); assert.equal( ctx.getActorUri("identifier").href, "https://drfed.test/users/identifier", @@ -77,3 +91,222 @@ describe("createYogaServer()", () => { }); }); }); + +const actorIri = `https://test-instance.drfed.org/users/${localActorId}`; +const accept = { accept: "application/activity+json" }; + +function values(id: string) { + return { + id, + actorId: localActorId, + iri: `${actorIri}/${id}`, + type: "Note" as const, + contentHtml: "

Hello

", + }; +} + +describe("ActivityPub objects", () => { + for (const visibility of ["public", "unlisted"] as const) { + it(`serves ${visibility} objects with contentMap and recipients`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await db.insert(schema.objects).values({ + ...object, + visibility, + language: "ko-KR", + name: "Title", + summary: "CW", + sensitive: true, + }); + const response = await federation.fetch( + new Request(object.iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.type, "Note"); + assert.equal(body.id, object.iri); + assert.equal(body.attributedTo, actorIri); + assert.equal(body.content, object.contentHtml); + assert.deepEqual(body.contentMap, { "ko-kr": object.contentHtml }); + assert.equal(body.name, "Title"); + assert.equal(body.summary, "CW"); + assert.equal(body.sensitive, true); + assert.ok(body.published); + assert.ok(body.updated); + assert.equal( + body.to, + visibility === "public" ? "as:Public" : `${actorIri}/followers`, + ); + assert.equal( + body.cc, + visibility === "public" ? `${actorIri}/followers` : "as:Public", + ); + }); + }); + } + for (const deleted of [null, new Date("2026-09-06T12:00:00Z")]) { + it(`does not serve followers-only objects (deleted: ${deleted != null})`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await db + .insert(schema.objects) + .values({ ...object, visibility: "followers", deleted }); + const response = await federation.fetch( + new Request(object.iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 404); + }); + }); + } + it("serves Articles and tombstones", async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await db.insert(schema.objects).values({ ...object, type: "Article" }); + const response = await federation.fetch( + new Request(object.iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal((await response.json()).type, "Article"); + const deletedAt = new Date("2026-09-06T12:00:00.000Z"); + await db + .update(schema.objects) + .set({ deleted: deletedAt }) + .where(eq(schema.objects.id, object.id)); + const deleted = await federation.fetch( + new Request(object.iri, { headers: accept }), + { contextData: undefined }, + ); + // Fedify serializes generic object tombstones with HTTP 200. + assert.equal(deleted.status, 200); + const tombstone = await deleted.json(); + assert.equal(tombstone.type, "Tombstone"); + assert.equal(new Date(tombstone.deleted).getTime(), deletedAt.getTime()); + }); + }); + for (const scenario of [ + "missing", + "malformed", + "remote", + "host", + "actor", + "deletedActor", + ] as const) { + it(`rejects ${scenario} object requests`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const object = values(uuid()); + await db.insert(schema.objects).values({ + ...object, + actorId: scenario === "remote" ? remoteActorId : localActorId, + }); + if (scenario === "deletedActor") { + await db + .update(schema.actors) + .set({ deleted: new Date() }) + .where(eq(schema.actors.id, localActorId)); + } + const iri = + scenario === "missing" + ? `${actorIri}/${uuid()}` + : scenario === "malformed" + ? `${actorIri}/bad` + : scenario === "host" + ? object.iri.replace("test-instance.drfed.org", "wrong.example") + : scenario === "actor" || scenario === "remote" + ? object.iri.replace(localActorId, remoteActorId) + : object.iri; + const response = await federation.fetch( + new Request(iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 404); + }); + }); + } +}); + +describe("ActivityPub outbox", () => { + it("paginates Create activities while excluding followers-only and deleted objects", async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const ids = Array.from({ length: 23 }, () => uuid()); + await db.insert(schema.objects).values( + ids.map((id, index) => ({ + ...values(id), + visibility: + index === 22 + ? ("followers" as const) + : index === 20 + ? ("unlisted" as const) + : ("public" as const), + deleted: index === 21 ? new Date() : null, + })), + ); + await db + .update(schema.actors) + .set({ postsCount: 23 }) + .where(eq(schema.actors.id, localActorId)); + const fetchJson = async (iri: string) => { + const response = await federation.fetch( + new Request(iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + return await response.json(); + }; + const collection = await fetchJson(`${actorIri}/outbox`); + assert.equal(collection.type, "OrderedCollection"); + assert.equal(collection.totalItems, 23); + const page = await fetchJson(`${actorIri}/outbox?cursor=`); + assert.equal(page.orderedItems.length, 20); + const activity = page.orderedItems[0]; + assert.deepEqual( + { + type: activity.type, + id: activity.id, + actor: activity.actor, + objectId: activity.object.id, + to: activity.to, + cc: activity.cc, + }, + { + type: "Create", + id: `${values(ids[20]!).iri}/activity`, + actor: actorIri, + objectId: values(ids[20]!).iri, + to: `${actorIri}/followers`, + cc: "as:Public", + }, + ); + const last = await fetchJson(page.next); + assert.equal(last.orderedItems.length, 1); + assert.equal(last.orderedItems[0].object.id, values(ids[0]!).iri); + assert.equal(last.next, undefined); + const bad = await federation.fetch( + new Request(`${actorIri}/outbox?cursor=bad`, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(bad.status, 404); + }); + }); + it("serves an empty outbox", async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const response = await federation.fetch( + new Request(`${actorIri}/outbox?cursor=`, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.type, "OrderedCollectionPage"); + assert.deepEqual(body.orderedItems ?? [], []); + assert.equal(body.next, undefined); + }); + }); +}); diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 614b78d..0fb46dd 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -15,7 +15,11 @@ // along with this program. If not, see . import type { Database } from "@drfed/models"; -import type { Actor } from "@drfed/models/schema"; +import type { + ActivityPubObject, + Actor, + ObjectType, +} from "@drfed/models/schema"; import type { Uuid } from "@drfed/models/uuid"; import { type Context, @@ -25,12 +29,18 @@ import { createFederationBuilder, } from "@fedify/fedify"; import { + Object as APObject, Activity, Application, + Article, + Create, Endpoints, Group, Image, + LanguageString, + Note, Organization, + PUBLIC_COLLECTION, Person, Service, Tombstone, @@ -130,14 +140,64 @@ export function buildFederation(db: Database): FederationBuilder { }); }); - builder.setOutboxDispatcher( - "/users/{identifier}/outbox", - async (ctx, identifier) => - // FIXME: Return the actual activities once the data model stores them - (await findActiveActor(db, ctx, identifier)) == null - ? null - : { items: [] }, + builder.setObjectDispatcher( + APObject, + "/users/{identifier}/{id}", + async (ctx, { identifier, id }) => { + if (!validateUuid(identifier) || !validateUuid(id)) return null; + const object = await db.query.objects.findFirst({ + where: { + id, + actorId: identifier, + actor: { + localId: { isNotNull: true }, + deleted: { isNull: true }, + instance: { host: ctx.host }, + }, + }, + }); + if (object == null || object.visibility === "followers") return null; + if (object.deleted != null) { + return new Tombstone({ + id: ctx.getObjectUri(APObject, { identifier, id }), + deleted: Temporal.Instant.from(object.deleted.toISOString()), + }); + } + return toObject(ctx, object); + }, ); + builder + .setOutboxDispatcher( + "/users/{identifier}/outbox", + async (ctx, identifier, cursor) => { + if ((await findActiveActor(db, ctx, identifier)) == null) return null; + if (cursor != null && cursor !== "" && !validateUuid(cursor)) { + return null; + } + const rows = await db.query.objects.findMany({ + where: { + actorId: identifier, + deleted: { isNull: true }, + visibility: { in: ["public", "unlisted"] }, + ...(cursor == null || cursor === "" ? {} : { id: { lt: cursor } }), + }, + orderBy: { id: "desc" }, + limit: OUTBOX_PAGE_SIZE + 1, + }); + const page = rows.slice(0, OUTBOX_PAGE_SIZE); + return { + items: page.map((object) => toCreate(ctx, object)), + nextCursor: rows.length > OUTBOX_PAGE_SIZE ? page.at(-1)!.id : null, + }; + }, + ) + .setFirstCursor(async (ctx, identifier) => + (await findActiveActor(db, ctx, identifier)) == null ? null : "", + ) + .setCounter( + async (ctx, identifier) => + (await findActiveActor(db, ctx, identifier))?.postsCount ?? null, + ); builder .setFollowersDispatcher( @@ -241,3 +301,60 @@ function toActorObject( } const logger = getLogger(["drfed", "graphql", "federation"]); + +const OUTBOX_PAGE_SIZE = 20; +type ObjectProps = ConstructorParameters[0]; +const objectConstructors: Record APObject> = + { + Article: (props) => new Article(props), + Note: (props) => new Note(props), + }; + +function recipients( + ctx: Context, + object: ActivityPubObject, +): { tos: URL[]; ccs: URL[] } { + const followers = ctx.getFollowersUri(object.actorId); + switch (object.visibility) { + case "public": + return { tos: [PUBLIC_COLLECTION], ccs: [followers] }; + case "unlisted": + return { tos: [followers], ccs: [PUBLIC_COLLECTION] }; + case "followers": + return { tos: [followers], ccs: [] }; + default: + throw new Error( + `Unsupported visibility: ${object.visibility satisfies never}`, + ); + } +} + +function toObject(ctx: Context, object: ActivityPubObject): APObject { + return objectConstructors[object.type]({ + id: new URL(object.iri), + attribution: ctx.getActorUri(object.actorId), + contents: [ + object.contentHtml, + ...(object.language == null + ? [] + : [new LanguageString(object.contentHtml, object.language)]), + ], + name: object.name, + summary: object.summary, + sensitive: object.sensitive, + published: Temporal.Instant.from(object.published.toISOString()), + updated: Temporal.Instant.from(object.updated.toISOString()), + url: object.url == null ? null : new URL(object.url), + ...recipients(ctx, object), + }); +} + +function toCreate(ctx: Context, object: ActivityPubObject): Create { + return new Create({ + id: new URL(`${object.iri}/activity`), + actor: ctx.getActorUri(object.actorId), + object: toObject(ctx, object), + published: Temporal.Instant.from(object.published.toISOString()), + ...recipients(ctx, object), + }); +} diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts new file mode 100644 index 0000000..69f9f8c --- /dev/null +++ b/packages/graphql/src/object.test.ts @@ -0,0 +1,340 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable max-statements +// Sequential mutations exercise counter updates in the same database. +// oxlint-disable no-await-in-loop + +import assert from "node:assert/strict"; + +import { schema } from "@drfed/models"; +import { describe, it } from "@logtape/testing-node/autoload"; +import { eq } from "drizzle-orm"; +import { v7 as uuid } from "uuid"; + +import { withTestHarness } from "./harness.test.ts"; +import { + globalId, + localActorId, + remoteActorId, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedRemoteActor, +} from "./seed.test.ts"; + +const fields = `id uuid iri url type actor { uuid } visibility name summary contentHtml language sensitive published updated created`; +const mutation = `mutation Create($actor: ID!, $contentHtml: String!, $language: String, $type: ObjectType! = Note, $visibility: ObjectVisibility! = PUBLIC) { + createObject(actor: $actor, contentHtml: $contentHtml, language: $language, type: $type, visibility: $visibility) { + resultType: __typename + ... on Object { ${fields} } + ... on CreateObjectError { errorType: type message } + } +}`; +const variables = { + actor: globalId("Actor", localActorId), + contentHtml: "

Hello

", +}; + +describe("Mutation.createObject", () => { + it("creates verbatim HTML, canonicalizes language and resolves every node field", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const contentHtml = '

Hello

'; + const response = await post( + { + query: mutation, + variables: { ...variables, contentHtml, language: "KO-kr" }, + }, + auth, + ); + const body = await response.json(); + assert.equal(body.errors, undefined); + const object = body.data.createObject; + assert.equal(object.resultType, "Object"); + assert.equal(object.type, "Note"); + assert.equal(object.visibility, "PUBLIC"); + assert.equal(object.language, "ko-KR"); + assert.equal(object.contentHtml, contentHtml); + assert.equal( + object.iri, + `https://test-instance.drfed.org/users/${localActorId}/${object.uuid}`, + ); + assert.equal(object.sensitive, false); + assert.equal(object.url, null); + assert.deepEqual(object.actor, { uuid: localActorId }); + const row = await db.query.objects.findFirst({ + where: { id: object.uuid }, + }); + assert.equal(row?.contentHtml, contentHtml); + assert.equal(row?.language, "ko-KR"); + assert.equal( + (await db.query.actors.findFirst({ where: { id: localActorId } })) + ?.postsCount, + 1, + ); + const node = await post({ + query: `query($id: ID!) { node(id: $id) { resultType: __typename ... on Object { ${fields} } } }`, + variables: { id: object.id }, + }); + assert.deepEqual(await node.json(), { data: { node: object } }); + }); + }); + for (const [input, error] of [ + [{ contentHtml: " \n\t" }, "InvalidContent"], + [{ language: "not_a_tag" }, "InvalidLanguage"], + [{ language: "" }, "InvalidLanguage"], + [ + { language: "en-x-abcdefgh-abcdefgh-abcdefgh-abcdefgh" }, + "InvalidLanguage", + ], + ] as const) { + it(`rejects invalid input ${JSON.stringify(input)}`, async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const body = await ( + await post( + { query: mutation, variables: { ...variables, ...input } }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createObject.errorType, error); + assert.equal(await db.$count(schema.objects), 0); + }); + }); + } + for (const scenario of [ + "remote", + "nonmember", + "pending", + "expired", + "deleted", + "missing", + "malformed", + ] as const) { + it(`hides ${scenario} actors`, async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + await seedRemoteActor(db); + if (scenario === "nonmember") await db.delete(schema.instanceMembers); + if (scenario === "pending") { + await db.update(schema.instanceMembers).set({ accepted: null }); + } + if (scenario === "expired") { + await db.update(schema.localInstances).set({ expires: new Date(0) }); + } + if (scenario === "deleted") { + await db + .update(schema.actors) + .set({ deleted: new Date() }) + .where(eq(schema.actors.id, localActorId)); + } + const actorId = + scenario === "remote" + ? remoteActorId + : scenario === "missing" + ? uuid() + : scenario === "malformed" + ? "bad" + : localActorId; + const body = await ( + await post( + { + query: mutation, + variables: { ...variables, actor: globalId("Actor", actorId) }, + }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createObject.errorType, "ActorNotFound"); + assert.equal(await db.$count(schema.objects), 0); + }); + }); + } + for (const text of ["", " \n\t", " Keep spacing "]) { + it(`normalizes optional text ${JSON.stringify(text)}`, async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const query = mutation.replace( + "type: $type,", + `name: ${JSON.stringify(text)}, summary: ${JSON.stringify(text)}, type: $type,`, + ); + const body = await (await post({ query, variables }, auth)).json(); + assert.equal(body.errors, undefined); + const expected = text.trim() === "" ? null : text; + assert.equal(body.data.createObject.name, expected); + assert.equal(body.data.createObject.summary, expected); + const row = await db.query.objects.findFirst(); + assert.equal(row?.name, expected); + assert.equal(row?.summary, expected); + }); + }); + } + it("requires authentication", async () => { + await withTestHarness(async ({ post }) => { + const body = await (await post({ query: mutation, variables })).json(); + assert.ok(body.errors?.length); + }); + }); + it("creates Articles with optional fields and all visibilities on a suspended actor", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + await db + .update(schema.actors) + .set({ suspended: new Date(0) }) + .where(eq(schema.actors.id, localActorId)); + for (const visibility of ["PUBLIC", "UNLISTED", "FOLLOWERS"]) { + const query = mutation.replace( + "type: $type,", + 'name: "Title", summary: "CW", sensitive: true, type: $type,', + ); + const body = await ( + await post( + { query, variables: { ...variables, type: "Article", visibility } }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createObject.type, "Article"); + assert.equal(body.data.createObject.visibility, visibility); + assert.equal(body.data.createObject.name, "Title"); + assert.equal(body.data.createObject.summary, "CW"); + assert.equal(body.data.createObject.sensitive, true); + } + assert.equal( + (await db.query.actors.findFirst({ where: { id: localActorId } })) + ?.postsCount, + 3, + ); + }); + }); +}); + +describe("Actor.objects", () => { + it("keeps followers objects publicly readable through GraphQL", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + const id = uuid(); + await db.insert(schema.objects).values({ + id, + actorId: localActorId, + type: "Note", + visibility: "followers", + iri: `https://test-instance.drfed.org/users/${localActorId}/${id}`, + contentHtml: "GraphQL debugging content", + }); + const query = `query($object: ID!, $actor: ID!) { + node(id: $object) { ... on Object { uuid visibility contentHtml } } + nodes(ids: [$object]) { ... on Object { uuid } } + actor: node(id: $actor) { ... on Actor { objects(first: 1) { totalCount edges { node { uuid } } } } } + }`; + const body = await ( + await post({ + query, + variables: { + object: globalId("Object", id), + actor: globalId("Actor", localActorId), + }, + }) + ).json(); + assert.deepEqual(body, { + data: { + node: { + uuid: id, + visibility: "FOLLOWERS", + contentHtml: "GraphQL debugging content", + }, + nodes: [{ uuid: id }], + actor: { + objects: { totalCount: 1, edges: [{ node: { uuid: id } }] }, + }, + }, + }); + }); + }); + + it("paginates by published time and UUID, excluding deleted and other actors", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const ids = Array.from({ length: 5 }, () => uuid()); + await db.insert(schema.objects).values( + ids.map((id, index) => ({ + id, + actorId: index === 4 ? remoteActorId : localActorId, + type: "Note" as const, + iri: `https://test.example/${id}`, + contentHtml: "test", + published: new Date(index === 0 ? "2027-01-01" : "2026-01-01"), + deleted: index === 3 ? new Date() : null, + })), + ); + const query = `query($actor: ID!, $after: String, $before: String, $first: Int, $last: Int) { node(id: $actor) { ... on Actor { objects(first: $first, after: $after, last: $last, before: $before) { totalCount edges { cursor node { uuid } } pageInfo { hasNextPage hasPreviousPage } } } } }`; + const first = await ( + await post({ + query, + variables: { actor: globalId("Actor", localActorId), first: 2 }, + }) + ).json(); + assert.equal(first.errors, undefined); + const connection = first.data.node.objects; + assert.equal(connection.totalCount, 3); + assert.deepEqual( + connection.edges.map( + (edge: { node: { uuid: string } }) => edge.node.uuid, + ), + [ids[0], ids[2]], + ); + assert.equal(connection.pageInfo.hasNextPage, true); + const next = await ( + await post({ + query, + variables: { + actor: globalId("Actor", localActorId), + first: 2, + after: connection.edges[1].cursor, + }, + }) + ).json(); + assert.equal(next.errors, undefined); + assert.deepEqual( + next.data.node.objects.edges.map( + (edge: { node: { uuid: string } }) => edge.node.uuid, + ), + [ids[1]], + ); + assert.equal(next.data.node.objects.pageInfo.hasNextPage, false); + const previous = await ( + await post({ + query, + variables: { + actor: globalId("Actor", localActorId), + last: 2, + before: next.data.node.objects.edges[0].cursor, + }, + }) + ).json(); + assert.equal(previous.errors, undefined); + assert.deepEqual(previous.data.node.objects.edges, connection.edges); + }); + }); +}); diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts new file mode 100644 index 0000000..429a82a --- /dev/null +++ b/packages/graphql/src/object.ts @@ -0,0 +1,336 @@ +// 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 { schema } from "@drfed/models"; +import { objectTypeEnum } from "@drfed/models/schema"; +import { Object as APObject } from "@fedify/vocab"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; +import { and, eq, gt, isNotNull, isNull, sql } from "drizzle-orm"; +import { v7 as uuid, validate as validateUuid } from "uuid"; + +import { Actor } from "./actor.ts"; +import builder, { type DrFedObjectRef } from "./builder.ts"; + +const ObjectType = builder.enumType("ObjectType", { + values: objectTypeEnum.enumValues, +}); +const ObjectVisibility = builder.enumType("ObjectVisibility", { + values: { + PUBLIC: { value: "public" }, + UNLISTED: { value: "unlisted" }, + FOLLOWERS: { value: "followers" }, + } as const, +}); +const ObjectRef = builder.drizzleNode("objects", { + name: "Object", + description: "Represents an ActivityPub object authored by an `Actor`.", + id: { + column: ({ id }) => id, + description: "The Relay global ID of the object.", + }, + fields: (t) => ({ + uuid: t.expose("id", { + type: "UUID", + description: "The UUID of the object.", + }), + iri: t.exposeString("iri", { + description: "The canonical ActivityPub identifier of the object.", + }), + url: t.expose("url", { + type: "URL", + nullable: true, + description: "The human-readable page URL, if available.", + }), + type: t.expose("type", { + type: ObjectType, + description: "The ActivityStreams vocabulary type: Note or Article.", + }), + actor: t.relation("actor", { + description: "The actor that authored the object.", + }), + visibility: t.expose("visibility", { + type: ObjectVisibility, + description: + "ActivityPub addressing policy. FOLLOWERS objects are not served over ActivityPub; GraphQL reads remain public.", + }), + name: t.exposeString("name", { + nullable: true, + description: "The optional title of the object.", + }), + summary: t.exposeString("summary", { + nullable: true, + description: "The optional summary or content warning.", + }), + contentHtml: t.exposeString("contentHtml", { + description: + "HTML preserved verbatim. Clients must sanitize it before browser rendering.", + }), + language: t.exposeString("language", { + nullable: true, + description: + "The canonical BCP 47 tag used for contentMap, if specified.", + }), + sensitive: t.exposeBoolean("sensitive", { + description: "Whether the content is marked sensitive.", + }), + published: t.expose("published", { + type: "DateTime", + description: "The ActivityStreams publication time.", + }), + updated: t.expose("updated", { + type: "DateTime", + description: "The time the stored object was last updated.", + }), + created: t.expose("created", { + type: "DateTime", + description: + "The time the object was stored in DrFed, distinct from its publication time.", + }), + }), +}); +export const ActivityPubObject: DrFedObjectRef = ObjectRef; + +const objectsConnection = drizzleConnectionHelpers(builder, "objects", { + query: { + where: { deleted: { isNull: true } }, + orderBy: { published: "desc", id: "desc" }, + }, +}); +builder.drizzleObjectField("actors", "objects", (t) => + t.connection( + { + type: ActivityPubObject, + description: + "Non-deleted objects, newest publication first. All visibilities are publicly readable through GraphQL.", + select(args, ctx, nestedSelection) { + return { + with: { + objects: objectsConnection.getQuery(args, ctx, nestedSelection), + }, + }; + }, + resolve(actor, args, ctx) { + return { + ...objectsConnection.resolve(actor.objects, args, ctx, actor), + totalCount() { + return ctx.db.$count( + schema.objects, + and( + eq(schema.objects.actorId, actor.id), + isNull(schema.objects.deleted), + ), + ); + }, + }; + }, + }, + { + name: "ObjectConnection", + fields: (fb) => ({ + totalCount: fb.int({ + description: + "The number of non-deleted objects authored by this actor, across all visibilities.", + resolve: (connection) => connection.totalCount(), + }), + }), + }, + { name: "ObjectEdge" }, + ), +); + +const CreateObjectErrorType = builder.enumType("CreateObjectErrorType", { + values: ["ActorNotFound", "InvalidContent", "InvalidLanguage"] as const, +}); +interface CreateObjectError { + readonly type: typeof CreateObjectErrorType.$inferType; + readonly message: string; +} +const CreateObjectErrorRef = + builder.objectRef("CreateObjectError"); +CreateObjectErrorRef.implement({ + fields: (t) => ({ + type: t.expose("type", { + type: CreateObjectErrorType, + description: + "The type of the error. Use this for programmatic error handling.", + }), + message: t.exposeString("message", { + description: + "A human-readable message describing the error. " + + "Don't use this for programmatic error handling, " + + "use the `type` field instead.", + }), + }), +}); +const CreateObjectResult = builder.unionType("CreateObjectResult", { + types: [ObjectRef, CreateObjectErrorRef], + resolveType: (value) => + "message" in value ? CreateObjectErrorRef : ObjectRef, +}); +const actorNotFound: CreateObjectError = { + type: "ActorNotFound", + message: "Can't find the actor.", +}; + +builder.mutationFields((t) => ({ + createObject: t.field({ + type: CreateObjectResult, + description: + "Create a local ActivityPub object without delivering it to remote servers.", + authScopes: { authenticated: true }, + args: { + actor: t.arg.globalID({ + for: Actor, + required: true, + description: + "The local author actor ID. The viewer must be an accepted instance member.", + }), + type: t.arg({ + type: ObjectType, + required: true, + defaultValue: "Note", + description: "The ActivityStreams object type to create.", + }), + contentHtml: t.arg.string({ + required: true, + description: + "Non-empty HTML stored verbatim; browser clients must sanitize it before rendering.", + }), + name: t.arg.string({ + description: + "Optional title. Empty or whitespace-only values are stored as null.", + }), + summary: t.arg.string({ + description: + "Optional summary or content warning. Empty or whitespace-only values are stored as null.", + }), + language: t.arg.string({ + description: + "A BCP 47 language tag, canonicalized and limited to 35 characters.", + }), + sensitive: t.arg.boolean({ + required: true, + defaultValue: false, + description: "Whether to mark the content as sensitive.", + }), + visibility: t.arg({ + type: ObjectVisibility, + required: true, + defaultValue: "public", + description: + "ActivityPub addressing policy; this does not restrict GraphQL reads.", + }), + }, + async resolve( + _parent, + { actor: { id: actorId }, language, ...input }, + ctx, + ) { + if (input.contentHtml.trim() === "") { + return { + type: "InvalidContent" as const, + message: "Content must not be empty.", + }; + } + let canonicalLanguage: string | null = null; + if (language != null) { + try { + canonicalLanguage = Intl.getCanonicalLocales(language)[0] ?? null; + if (canonicalLanguage == null || canonicalLanguage.length > 35) { + throw new RangeError("Language tag is too long."); + } + } catch (error) { + if (!(error instanceof RangeError)) throw error; + return { + type: "InvalidLanguage" as const, + message: + "Language must be a valid BCP 47 tag of at most 35 characters.", + }; + } + } + const { account } = ctx; + if (account == null) { + throw new Error("You must be authenticated to create objects."); + } + if (!validateUuid(actorId)) return actorNotFound; + return await ctx.db.transaction(async (tx) => { + const [actor] = await tx + .select({ id: schema.actors.id, host: schema.instances.host }) + .from(schema.actors) + .for("update", { of: schema.actors }) + .innerJoin( + schema.instances, + eq(schema.actors.instanceId, schema.instances.id), + ) + .innerJoin( + schema.localInstances, + eq(schema.instances.localId, schema.localInstances.id), + ) + .innerJoin( + schema.instanceMembers, + eq(schema.instanceMembers.instanceId, schema.instances.id), + ) + .where( + and( + eq(schema.actors.id, actorId), + isNotNull(schema.actors.localId), + isNull(schema.actors.deleted), + gt(schema.localInstances.expires, new Date()), + eq(schema.instanceMembers.accountId, account.id), + isNotNull(schema.instanceMembers.accepted), + ), + ) + .limit(1); + if (actor == null) return actorNotFound; + const id = uuid(); + const fedCtx = ctx.federation.createContext( + new URL(`https://${actor.host}`), + undefined, + ); + const iri = fedCtx.getObjectUri(APObject, { + identifier: actorId, + id, + }).href; + const [object] = await tx + .insert(schema.objects) + .values({ + ...input, + name: normalizeOptionalText(input.name), + summary: normalizeOptionalText(input.summary), + id, + actorId, + iri, + language: canonicalLanguage, + }) + .returning(); + await tx + .update(schema.actors) + .set({ postsCount: sql`${schema.actors.postsCount} + 1` }) + .where(eq(schema.actors.id, actorId)); + if (object == null) { + throw new Error("Object insertion returned no row."); + } + return object; + }); + }, + }), +})); + +function normalizeOptionalText( + value: string | null | undefined, +): string | null { + return value == null || value.trim() === "" ? null : value; +} diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index f5af295..7dc7463 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -18,6 +18,7 @@ import "./account.ts"; import "./instance.ts"; import "./auth/entry.ts"; import "./actor.ts"; +import "./object.ts"; import builder from "./builder.ts"; builder.queryType({}); diff --git a/packages/graphql/src/seed.test.ts b/packages/graphql/src/seed.test.ts new file mode 100644 index 0000000..3ab0a01 --- /dev/null +++ b/packages/graphql/src/seed.test.ts @@ -0,0 +1,134 @@ +// 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, schema } from "@drfed/models"; + +import { hashSecret } from "./auth/hash.ts"; + +export const accepted = new Date("2026-08-04T00:00:00.000Z"); +export const created = new Date("2026-08-04T00:00:00.000Z"); +export const expires = new Date("2030-08-04T00:00:00.000Z"); +export const ok = 200; + +export const accountId = "00000000-0000-4000-8000-000000000001"; +export const localInstanceId = "00000000-0000-4000-8000-000000000101"; +export const remoteInstanceId = "00000000-0000-4000-8000-000000000102"; +export const localActorId = "00000000-0000-4000-8000-000000000201"; +export const remoteActorId = "00000000-0000-4000-8000-000000000202"; +export const sessionId = "00000000-0000-4000-8000-000000000301"; +export const accessToken = "test-access-token"; + +export function globalId( + type: "Actor" | "Instance" | "Object", + id: string, +): string { + return Buffer.from(`${type}:${id}`).toString("base64"); +} + +export async function seedAuthenticatedLocalInstance( + db: Database, +): Promise { + await db.insert(schema.accounts).values({ + id: accountId, + email: "owner@example.com", + name: "Owner", + created, + }); + await db.insert(schema.sessions).values({ + id: sessionId, + accountId, + tokenHash: await hashSecret(accessToken), + }); + await seedLocalInstance(db); + await db.insert(schema.instanceMembers).values({ + accountId, + instanceId: localInstanceId, + admin: true, + accepted, + created, + }); + return { headers: { authorization: `Bearer ${accessToken}` } }; +} + +export async function seedLocalActor(db: Database): Promise { + await seedLocalInstance(db); + await db.insert(schema.localActors).values({ + id: localActorId, + avatar: "avatar.png", + header: "header.png", + }); + await db.insert(schema.actors).values({ + 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`, + outboxUrl: `https://test-instance.drfed.org/users/${localActorId}/outbox`, + avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, + followersUrl: `https://test-instance.drfed.org/users/${localActorId}/followers`, + followingUrl: `https://test-instance.drfed.org/users/${localActorId}/following`, + headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, + profileUrl: "https://test-instance.drfed.org/@alice", + featuredUrl: `https://test-instance.drfed.org/users/${localActorId}/featured`, + created, + }); +} + +export async function seedLocalInstance(db: Database): Promise { + await db + .insert(schema.localInstances) + .values({ + id: localInstanceId, + slug: "test-instance", + expires, + }) + .onConflictDoNothing(); + await db + .insert(schema.instances) + .values({ + id: localInstanceId, + localId: localInstanceId, + created, + host: "test-instance.drfed.org", + }) + .onConflictDoNothing(); +} + +export async function seedRemoteActor(db: Database): Promise { + await db.insert(schema.instances).values({ + id: remoteInstanceId, + created, + host: "remote.example.com", + }); + await db.insert(schema.actors).values({ + id: remoteActorId, + instanceId: remoteInstanceId, + type: "Service", + username: "bob", + iri: "https://remote.example.com/users/bob", + inboxUrl: "https://remote.example.com/users/bob/inbox", + outboxUrl: "https://remote.example.com/users/bob/outbox", + avatarUrl: "https://remote.example.com/users/bob/avatar.png", + followersUrl: "https://remote.example.com/users/bob/followers", + followingUrl: "https://remote.example.com/users/bob/following", + headerUrl: "https://remote.example.com/users/bob/header.png", + profileUrl: "https://remote.example.com/@bob", + featuredUrl: "https://remote.example.com/users/bob/featured", + created, + }); +} diff --git a/packages/models/drizzle/20260906133944_add_objects/migration.sql b/packages/models/drizzle/20260906133944_add_objects/migration.sql new file mode 100644 index 0000000..3eb0b0b --- /dev/null +++ b/packages/models/drizzle/20260906133944_add_objects/migration.sql @@ -0,0 +1,23 @@ +CREATE TYPE "object_type" AS ENUM('Article', 'Note');--> statement-breakpoint +CREATE TYPE "object_visibility" AS ENUM('public', 'unlisted', 'followers');--> statement-breakpoint +CREATE TABLE "objects" ( + "id" uuid PRIMARY KEY, + "actorId" uuid NOT NULL, + "type" "object_type" NOT NULL, + "iri" text NOT NULL UNIQUE, + "url" text, + "visibility" "object_visibility" DEFAULT 'public'::"object_visibility" NOT NULL, + "name" text, + "summary" text, + "contentHtml" text NOT NULL, + "language" varchar(35), + "sensitive" boolean DEFAULT false NOT NULL, + "published" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "deleted" timestamp with time zone, + CONSTRAINT "objects_content_html_check" CHECK (trim(both from "contentHtml") <> '') +); +--> statement-breakpoint +CREATE INDEX "object_actor_published_index" ON "objects" ("actorId","published" desc,"id" desc);--> statement-breakpoint +ALTER TABLE "objects" ADD CONSTRAINT "objects_actorId_actors_id_fkey" FOREIGN KEY ("actorId") REFERENCES "actors"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/models/drizzle/20260906133944_add_objects/snapshot.json b/packages/models/drizzle/20260906133944_add_objects/snapshot.json new file mode 100644 index 0000000..7deecd3 --- /dev/null +++ b/packages/models/drizzle/20260906133944_add_objects/snapshot.json @@ -0,0 +1,1597 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "97f2c698-2361-4eae-b5c0-969ee1d2f8e7", + "prevIds": ["6b70d7c3-f645-4130-8f85-073c3204c78d"], + "ddl": [ + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Article", "Note"], + "name": "object_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["public", "unlisted", "followers"], + "name": "object_visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "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_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "objects", + "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": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "actor_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followersUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followingUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "featuredUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bioHtml", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automaticallyApprovesFollowers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fieldHtmls", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspendedUntil", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successorId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followingCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followersCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "postsCount", + "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": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "header", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "varchar(63)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "maxActors", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "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_tokens" + }, + { + "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_tokens" + }, + { + "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_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "object_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "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": "object_visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "summary", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentHtml", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_instance_index", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "accountId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_accountId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_instanceId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"published\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "object_actor_published_index", + "entityType": "indexes", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_localId_local_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["successorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "actors_successorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "instances_localId_local_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instances" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "login_tokens_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "login_tokens" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "objects_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "columns": ["instanceId", "accountId"], + "nameExplicit": false, + "name": "instance_members_pkey", + "entityType": "pks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "public", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "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_tokens", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "objects_pkey", + "schema": "public", + "table": "objects", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": ["username", "instanceId"], + "nullsNotDistinct": false, + "name": "username_key", + "entityType": "uniques", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "nullsNotDistinct": false, + "name": "actors_localId_key", + "schema": "public", + "table": "actors", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iri"], + "nullsNotDistinct": false, + "name": "actors_iri_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": ["slug"], + "nullsNotDistinct": false, + "name": "local_instances_slug_key", + "schema": "public", + "table": "local_instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "login_tokens_tokenHash_key", + "schema": "public", + "table": "login_tokens", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iri"], + "nullsNotDistinct": false, + "name": "objects_iri_key", + "schema": "public", + "table": "objects", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "sessions_tokenHash_key", + "schema": "public", + "table": "sessions", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"max_instances\" >= 0", + "name": "accounts_max_instances_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"username\" NOT LIKE '%@%'", + "name": "actors_username_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\n \"suspendedUntil\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspendedUntil\" > \"suspended\"\n )\n ", + "name": "actors_suspended_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\"slug\" ~ '^[a-z0-9-]{4,63}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"maxActors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "trim(both from \"contentHtml\") <> ''", + "name": "objects_content_html_check", + "entityType": "checks", + "schema": "public", + "table": "objects" + } + ], + "renames": [] +} diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index 3ec17e1..fc2d2b9 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -98,7 +98,15 @@ export const relations = defineRelations(schema, (r) => ({ optional: false, }), }, + objects: { + actor: r.one.actors({ + from: r.objects.actorId, + to: r.actors.id, + optional: false, + }), + }, actors: { + objects: r.many.objects({ from: r.actors.id, to: r.objects.actorId }), instance: r.one.instances({ from: r.actors.instanceId, to: r.instances.id, diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index c348e20..0d0c20c 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { sql } from "drizzle-orm"; +import { desc, sql } from "drizzle-orm"; import { type AnyPgColumn, boolean, @@ -288,3 +288,56 @@ export const localActors = pgTable("local_actors", { export type LocalActor = typeof localActors.$inferSelect; export type NewLocalActor = typeof localActors.$inferInsert; + +export const objectTypeEnum = pgEnum("object_type", ["Article", "Note"]); +export type ObjectType = (typeof objectTypeEnum.enumValues)[number]; +export const objectVisibilityEnum = pgEnum("object_visibility", [ + "public", + "unlisted", + "followers", +]); +export type ObjectVisibility = (typeof objectVisibilityEnum.enumValues)[number]; + +/** ActivityPub objects authored by actors. */ +export const objects = pgTable( + "objects", + { + id: uuid().primaryKey(), + actorId: uuid() + .notNull() + .references(() => actors.id, { onDelete: "cascade" }), + type: objectTypeEnum().notNull(), + iri: text().notNull().unique(), + url: text(), + visibility: objectVisibilityEnum().notNull().default("public"), + name: text(), + summary: text(), + contentHtml: text().notNull(), + language: varchar({ length: 35 }), + sensitive: boolean().notNull().default(false), + published: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + updated: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp) + .$onUpdate(() => currentTimestamp), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + deleted: timestamp({ withTimezone: true }), + }, + (t) => [ + check( + "objects_content_html_check", + sql`trim(both from ${t.contentHtml}) <> ''`, + ), + index("object_actor_published_index").on( + t.actorId, + desc(t.published), + desc(t.id), + ), + ], +); +export type ActivityPubObject = typeof objects.$inferSelect; +export type NewActivityPubObject = typeof objects.$inferInsert;