diff --git a/.oxlintrc.json b/.oxlintrc.json index 10405cf..6206ce6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -24,7 +24,6 @@ { "ignoreConsecutiveComments": true } ], "eslint/curly": ["error", "multi-line"], - "node/no-top-level-await": "off", "oxc/no-optional-chaining": "off", "oxc/no-async-await": "off", "eslint/eqeqeq": ["off", "smart"], @@ -42,6 +41,7 @@ "eslint/no-eq-null": "off", "eslint/no-magic-numbers": "off", "eslint/no-ternary": "off", + "eslint/no-negated-condition": "off", "eslint/no-nested-ternary": "off", "eslint/no-undefined": "off", "eslint/no-void": "off", diff --git a/mise.toml b/mise.toml index a8e44c3..283ddaf 100644 --- a/mise.toml +++ b/mise.toml @@ -4,6 +4,7 @@ min_version = "2026.6.10" "aqua:dahlia/hongdown" = "0.4.3" "github:nushell/nushell" = "0.114.1" node = "26" +"npm:@fedify/cli" = "2.4.0-dev.1758" "npm:oxlint" = "1.75.0" "npm:oxlint-tsgolint" = "7.0.2001" "npm:pglite-cli" = "0.0.1" diff --git a/packages/graphql/package.json b/packages/graphql/package.json index cc5e926..39c8d73 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -50,6 +50,10 @@ "types": "./dist/account.d.mts", "default": "./dist/account.mjs" }, + "./actor": { + "types": "./dist/actor.d.mts", + "default": "./dist/actor.mjs" + }, "./builder": { "types": "./dist/builder.d.mts", "default": "./dist/builder.mjs" @@ -71,6 +75,7 @@ "entry": [ "src/index.ts", "src/account.ts", + "src/actor.ts", "src/builder.ts", "src/instance.ts", "src/schema.ts" diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts new file mode 100644 index 0000000..140a892 --- /dev/null +++ b/packages/graphql/src/actor.test.ts @@ -0,0 +1,333 @@ +// 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-lines + +import assert from "node:assert/strict"; + +import { type Database, schema } from "@drfed/models"; +import { describe, it } from "@logtape/testing-node/autoload"; + +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"; + +const genActorsMutation = ` + mutation GenActors($instance: ID!, $size: Int!) { + genActors(instance: $instance, size: $size) { + resultType: __typename + ... on CreateActorsSuccess { + actors { + uuid + iri + username + local { + uuid + } + } + } + ... on CreateActorsError { + type + message + } + } + } +`; + +const actorQuery = ` + query Actor($id: ID!) { + node(id: $id) { + ... on Actor { + id + uuid + iri + handle + type + username + instance { + uuid + host + } + local { + avatar + header + } + inboxUrl + outboxUrl + avatarUrl + followersUrl + followeesUrl + headerUrl + profileUrl + featuredUrl + created + } + } + } +`; + +describe("Mutation.genActors", () => { + it("creates local actors", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + + const response = await post( + { + query: genActorsMutation, + variables: { + instance: globalId("Instance", localInstanceId), + size: 2, + }, + }, + auth, + ); + + assert.equal(response.status, ok); + const body = await response.json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.genActors.resultType, "CreateActorsSuccess"); + assert.equal(body.data.genActors.actors.length, 2); + assert.equal( + body.data.genActors.actors.every( + (actor: { + iri: unknown; + local: { uuid: unknown } | null; + username: unknown; + uuid: unknown; + }) => + typeof actor.uuid === "string" && + typeof actor.username === "string" && + actor.iri === + `https://test-instance.drfed.org/users/${actor.uuid}` && + typeof actor.local?.uuid === "string", + ), + true, + ); + + const actors = await db.select().from(schema.actors); + assert.equal(actors.length, 2); + assert.equal( + actors.every( + (actor) => + actor.instanceId === localInstanceId && + actor.localId != null && + actor.type === "Person", + ), + true, + ); + + const localActors = await db.select().from(schema.localActors); + assert.equal(localActors.length, 2); + assert.deepEqual( + new Set(localActors.map(({ id }) => id)), + new Set(actors.map(({ localId }) => localId)), + ); + }); + }); +}); + +describe("Actor", () => { + it("returns a local actor", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + + const response = await post({ + query: actorQuery, + variables: { id: globalId("Actor", localActorId) }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), { + data: { + node: { + id: globalId("Actor", localActorId), + uuid: localActorId, + iri: `https://test-instance.drfed.org/users/${localActorId}`, + handle: "@alice@test-instance.drfed.org", + type: "Person", + username: "alice", + instance: { + uuid: localInstanceId, + host: "test-instance.drfed.org", + }, + local: { + avatar: "avatar.png", + header: "header.png", + }, + 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`, + followeesUrl: `https://test-instance.drfed.org/users/${localActorId}/followees`, + 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: created.toISOString(), + }, + }, + }); + }); + }); + + it("returns a remote actor", async () => { + await withTestHarness(async ({ db, post }) => { + await seedRemoteActor(db); + + const response = await post({ + query: actorQuery, + variables: { id: globalId("Actor", remoteActorId) }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), { + data: { + node: { + id: globalId("Actor", remoteActorId), + uuid: remoteActorId, + iri: "https://remote.example.com/users/bob", + handle: "@bob@remote.example.com", + type: "Service", + username: "bob", + instance: { + uuid: remoteInstanceId, + host: "remote.example.com", + }, + local: null, + 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", + followeesUrl: "https://remote.example.com/users/bob/followees", + headerUrl: "https://remote.example.com/users/bob/header.png", + profileUrl: "https://remote.example.com/@bob", + featuredUrl: "https://remote.example.com/users/bob/featured", + created: created.toISOString(), + }, + }, + }); + }); + }); +}); + +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`, + followeesUrl: `https://test-instance.drfed.org/users/${localActorId}/followees`, + 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", + followeesUrl: "https://remote.example.com/users/bob/followees", + headerUrl: "https://remote.example.com/users/bob/header.png", + profileUrl: "https://remote.example.com/@bob", + featuredUrl: "https://remote.example.com/users/bob/featured", + created, + }); +} + +async function hashSecret(raw: string): Promise { + return new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)), + ).toHex(); +} diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts new file mode 100644 index 0000000..7a6b116 --- /dev/null +++ b/packages/graphql/src/actor.ts @@ -0,0 +1,349 @@ +// 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-lines-per-function eslint/max-lines + +import { schema } from "@drfed/models"; +import { actorTypeEnum } from "@drfed/models/schema"; +import type { PgInsertValue } from "drizzle-orm/pg-core"; +import { and, eq, gt, isNotNull } from "drizzle-orm/sql/expressions"; +import { v7 as uuid } from "uuid"; + +import builder, { type DrFedObjectRef } from "./builder.ts"; +// oxlint-disable-next-line import/no-cycle +import { Instance } from "./instance.ts"; +import templates from "./uri-templates.ts"; + +export const ActorType = builder.enumType("ActorType", { + values: actorTypeEnum.enumValues, +}); + +export const ACTOR_TYPES = actorTypeEnum.enumValues.join(" | "); + +const ActorRef = builder.drizzleNode("actors", { + name: "Actor", + description: "Represents an `Actor` in the DrFed platform.", + id: { + column: ({ id }) => id, + description: "The unique identifier of the `Actor`.", + }, + fields: (t) => ({ + uuid: t.expose("id", { + type: "UUID", + description: "The UUID of the `Actor`.", + }), + iri: t.exposeString("iri", { + description: "The Internationalized Resource Identifier of the `Actor`", + }), + handle: t.field({ + type: "String", + description: "The handle of the `Actor`.", + select: { + columns: { username: true }, + with: { instance: { columns: { host: true } } }, + }, + resolve: ({ instance, username }) => + templates.handle.expand({ username, host: instance.host }), + }), + type: t.expose("type", { + type: ActorType, + description: `The type of the \`Actor\`: ${ACTOR_TYPES}`, + }), + username: t.exposeString("username", { + description: "The username of the `Actor`.", + }), + instance: t.relation("instance", { + description: "The `Instance` that the `Actor` belongs to.", + }), + local: t.relation("localActor", { + nullable: true, + description: "The local details of the `Actor`, or null if it is remote.", + }), + inboxUrl: t.exposeString("inboxUrl", { + description: "The inbox URL of the `Actor`.", + }), + outboxUrl: t.exposeString("outboxUrl", { + description: "The outbox URL of the `Actor`.", + }), + avatarUrl: t.exposeString("avatarUrl", { + description: "The avatar URL of the `Actor`.", + nullable: true, + }), + followersUrl: t.exposeString("followersUrl", { + description: "The followers URL of the `Actor`.", + nullable: true, + }), + followeesUrl: t.exposeString("followeesUrl", { + description: "The followees URL of the `Actor`.", + nullable: true, + }), + headerUrl: t.exposeString("headerUrl", { + description: "The header URL of the `Actor`.", + nullable: true, + }), + profileUrl: t.exposeString("profileUrl", { + description: "The profile URL of the `Actor`.", + nullable: true, + }), + featuredUrl: t.exposeString("featuredUrl", { + description: "The featured URL of the `Actor`.", + nullable: true, + }), + created: t.expose("created", { + type: "DateTime", + description: "The creation date/time of the `Actor`.", + }), + }), +}); + +export const Actor: DrFedObjectRef = ActorRef; + +const LocalActorRef = builder.drizzleNode("localActors", { + name: "LocalActor", + description: "Represents the local details of an `Actor`.", + id: { + column: ({ id }) => id, + description: "The unique identifier of the local actor details.", + }, + fields: (t) => ({ + uuid: t.expose("id", { + type: "UUID", + description: "The UUID of the local actor details.", + }), + avatar: t.exposeString("avatar", { + nullable: true, + description: "The profile image of the actor.", + }), + header: t.exposeString("header", { + nullable: true, + description: "The profile banner image of the actor.", + }), + }), +}); + +export const LocalActor: DrFedObjectRef = LocalActorRef; + +interface CreateActorsSuccess { + readonly actors: readonly (typeof ActorRef.$inferType)[]; +} + +const CreateActorsSuccessRef = builder.objectRef( + "CreateActorsSuccess", +); + +CreateActorsSuccessRef.implement({ + fields: (t) => ({ + actors: t.field({ + type: [ActorRef], + resolve: ({ actors }) => actors, + }), + }), +}); + +const INVALID_SIZE = "InvalidSize" as const; +const INSTANCE_NOT_FOUND = "InstanceNotFound" as const; +const TOO_MANY_ACTORS = "TooManyActors" as const; +const CreateActorsErrors = [ + INVALID_SIZE, + INSTANCE_NOT_FOUND, + TOO_MANY_ACTORS, +] as const; + +const CreateActorsErrorType = builder.enumType("CreateActorsErrorType", { + values: CreateActorsErrors, +}); + +interface CreateActorsError { + readonly type: typeof CreateActorsErrorType.$inferType; + readonly message: string; +} + +const CreateActorsErrorRef = + builder.objectRef("CreateActorsError"); + +CreateActorsErrorRef.implement({ + description: "Represents an error that occurred while creating an `Actor`.", + fields: (t) => ({ + type: t.expose("type", { + type: CreateActorsErrorType, + 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 CreateActorsResult = builder.unionType("CreateActorsResult", { + types: [CreateActorsSuccessRef, CreateActorsErrorRef], + resolveType(value) { + if ("message" in value) return CreateActorsErrorRef; + return CreateActorsSuccessRef; + }, +}); + +interface SelectedInstance { + host: string; + maxActors: number; +} + +builder.mutationFields((t) => ({ + genActors: t.field({ + type: CreateActorsResult, + description: "Create actors.", + authScopes: { authenticated: true }, + args: { + instance: t.arg.globalID({ + for: Instance, + required: true, + description: "The ID of the target instance", + }), + size: t.arg({ + type: "Int", + required: true, + description: "How many actors to generate", + }), + }, + async resolve(_query, { instance: { id: instanceId }, size }, ctx) { + if (size < 1) { + return { + type: INVALID_SIZE, + message: `${size} is too small. At least 1 or more.`, + }; + } + if (ctx.account == null) { + // Note that the following error is not expected to be thrown, + // because the `authScopes` option above should prevent this resolver + throw new Error("You must be authenticated to create actors."); + } + const { account } = ctx; + + let instance: SelectedInstance | undefined; + let tooManyActors = false; + try { + return await ctx.db.transaction(async (tx) => { + // Find the instance that the account is included + [instance] = await tx + .select({ + host: schema.instances.host, + maxActors: schema.localInstances.maxActors, + }) + .from(schema.instanceMembers) + .innerJoin( + schema.instances, + eq(schema.instanceMembers.instanceId, schema.instances.id), + ) + .innerJoin( + schema.localInstances, + eq(schema.instances.localId, schema.localInstances.id), + ) + .where( + and( + eq(schema.instanceMembers.accountId, account.id), + gt(schema.localInstances.expires, new Date()), + eq(schema.instances.id, instanceId), + isNotNull(schema.instanceMembers.accepted), + ), + ) + .limit(1); + if (instance == null) throw new Error(INSTANCE_NOT_FOUND); + const { host, maxActors } = instance; + const currActors = await tx.$count( + schema.actors, + eq(schema.actors.instanceId, instanceId), + ); + + if (size + currActors > maxActors) { + return { + type: TOO_MANY_ACTORS, + message: `${size} is too big. The maximum number of actors of ${ + host + } is ${maxActors} and the current number of actors is ${ + currActors + }.`, + }; + } + // Create actors + const localActors = await tx + .insert(schema.localActors) + .values(Array.from({ length: size }, () => ({ id: uuid() }))) + .returning(); + const createdActors = await tx + .insert(schema.actors) + .values(localActors.map(({ id }) => genActor(id, instanceId, host))) + .returning(); + const actorCounts = await tx.$count( + schema.actors, + eq(schema.actors.instanceId, instanceId), + ); + if (actorCounts > maxActors) { + tooManyActors = true; + tx.rollback(); + } + return { actors: createdActors }; + }); + } catch (e) { + if (instance == null) { + if (e instanceof Error && e.message === INSTANCE_NOT_FOUND) { + return { + type: INSTANCE_NOT_FOUND, + message: "Can't find the instance.", + }; + } + } else if (tooManyActors) { + return { + type: TOO_MANY_ACTORS, + message: `${ + instance.host + } instance reached the maximum number of actors (${ + instance.maxActors + })`, + }; + } + throw e; + } + }, + }), +})); + +function genActor( + localId: string, + instanceId: string, + host: string, +): PgInsertValue { + const id = uuid(); + const username = id; + const templateArgs = { host, id, username }; + return { + id, + localId, + // FIXME: Generate handle using Faker.js or something + username, + instanceId, + type: "Person", + iri: templates.iri.expand(templateArgs), + inboxUrl: templates.inbox.expand(templateArgs), + outboxUrl: templates.outbox.expand(templateArgs), + followersUrl: templates.followers.expand(templateArgs), + followeesUrl: templates.followees.expand(templateArgs), + featuredUrl: templates.featured.expand(templateArgs), + profileUrl: templates.profile.expand(templateArgs), + }; +} diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index d6925cc..88e20e2 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . import { type Database, normalizeEmail, relations } from "@drfed/models"; -import type { Account, Session } from "@drfed/models/schema"; +import { type Account, type Session } from "@drfed/models/schema"; import { Template } from "@fedify/uri-template"; import SchemaBuilder, { type ObjectRef } from "@pothos/core"; import DrizzlePlugin from "@pothos/plugin-drizzle"; diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index f778f83..c6069a2 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -23,6 +23,8 @@ import { v7 as uuid } from "uuid"; // oxlint-disable-next-line import/no-cycle import { Account } from "./account.ts"; +// oxlint-disable-next-line import/no-cycle +import { ACTOR_TYPES, Actor, ActorType } from "./actor.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; const InstanceRef = builder.drizzleNode("instances", { @@ -179,6 +181,70 @@ builder.drizzleObjectField(InstanceRef, "members", (t) => ), ); +const actorsConnection = drizzleConnectionHelpers(builder, "actors", { + query: { orderBy: { created: "desc" } }, +}); + +// oxlint-disable-next-line max-lines-per-function +builder.drizzleObjectField(InstanceRef, "actors", (t) => + t.connection( + { + type: Actor, + description: "The `Actor`s that belong to the `Instance`.", + select(args, ctx, nestedSelection) { + return { + with: { + actors: actorsConnection.getQuery(args, ctx, nestedSelection), + }, + }; + }, + resolve(instance, args, ctx) { + return { + ...actorsConnection.resolve(instance.actors, args, ctx, instance), + totalCount() { + return ctx.db.$count( + schema.actors, + eq(schema.actors.instanceId, instance.id), + ); + }, + }; + }, + }, + { + fields(fb) { + return { + totalCount: fb.int({ + description: + "The total number of `Actor`s that belong to the `Instance`." + + "Note that pending members are not counted.", + resolve(connection) { + return connection.totalCount(); + }, + }), + }; + }, + }, + { + fields(fb) { + return { + created: fb.expose("created", { + type: "DateTime", + description: + "The date/time when the `Account` was added to the `Instance`.", + }), + type: fb.expose("type", { + type: ActorType, + description: `The type of the \`Actor\`: ${ACTOR_TYPES}`, + }), + username: fb.exposeString("username", { + description: "The username of the `Actor`.", + }), + }; + }, + }, + ), +); + export const CreateInstanceErrorType = builder.enumType( "CreateInstanceErrorType", { diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index 84bfa4f..f5af295 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -17,6 +17,7 @@ import "./account.ts"; import "./instance.ts"; import "./auth/entry.ts"; +import "./actor.ts"; import builder from "./builder.ts"; builder.queryType({}); diff --git a/packages/graphql/src/uri-templates.ts b/packages/graphql/src/uri-templates.ts new file mode 100644 index 0000000..29e5a2a --- /dev/null +++ b/packages/graphql/src/uri-templates.ts @@ -0,0 +1,52 @@ +// 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 { Template } from "@fedify/uri-template"; + +export const avatarTemplate = new Template( + "https://{host}/users/{id}/avatar/{avatar}", +); +export const featuredTemplate = new Template( + "https://{host}/users/{id}/featured", +); +export const followeesTemplate = new Template( + "https://{host}/users/{id}/followees", +); +export const followersTemplate = new Template( + "https://{host}/users/{id}/followers", +); +export const handleTemplate = new Template("@{username}@{host}"); +export const headerTemplate = new Template( + "https://{host}/users/{id}/header/{header}", +); +export const inboxTemplate = new Template("https://{host}/users/{id}/inbox"); +export const iriTemplate = new Template("https://{host}/users/{id}"); +export const outboxTemplate = new Template("https://{host}/users/{id}/outbox"); +export const profileTemplate = new Template("https://{host}/@{username}"); + +const templates = { + avatar: avatarTemplate, + featured: featuredTemplate, + followees: followeesTemplate, + followers: followersTemplate, + handle: handleTemplate, + header: headerTemplate, + inbox: inboxTemplate, + iri: iriTemplate, + outbox: outboxTemplate, + profile: profileTemplate, +}; +export default templates; diff --git a/packages/models/drizzle/20260821072150_merge_remote_actors/migration.sql b/packages/models/drizzle/20260821072150_merge_remote_actors/migration.sql new file mode 100644 index 0000000..ac731f6 --- /dev/null +++ b/packages/models/drizzle/20260821072150_merge_remote_actors/migration.sql @@ -0,0 +1,54 @@ +CREATE TYPE "actor_type" AS ENUM('Application', 'Group', 'Organization', 'Person', 'Service');--> statement-breakpoint +CREATE TABLE "actors" ( + "id" uuid PRIMARY KEY, + "localId" uuid UNIQUE, + "type" "actor_type" NOT NULL, + "username" text NOT NULL, + "instanceId" uuid NOT NULL, + "iri" text NOT NULL UNIQUE, + "inboxUrl" text NOT NULL, + "outboxUrl" text NOT NULL, + "followersUrl" text, + "followeesUrl" text, + "featuredUrl" text, + "profileUrl" text, + "avatarUrl" text, + "headerUrl" text, + "name" text, + "bioHtml" text, + "automaticallyApprovesFollowers" boolean DEFAULT false NOT NULL, + "fieldHtmls" jsonb DEFAULT '{}' NOT NULL, + "emojis" jsonb DEFAULT '{}' NOT NULL, + "tags" jsonb DEFAULT '{}' NOT NULL, + "sensitive" boolean DEFAULT false NOT NULL, + "suspended" timestamp with time zone, + "suspendedUntil" timestamp with time zone, + "successorId" uuid, + "aliases" text[] DEFAULT (ARRAY[]::text[])::text[] NOT NULL, + "followeesCount" integer DEFAULT 0 NOT NULL, + "followersCount" integer DEFAULT 0 NOT NULL, + "postsCount" integer DEFAULT 0 NOT NULL, + "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "published" timestamp with time zone, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "deleted" timestamp with time zone, + CONSTRAINT "username_key" UNIQUE("username","instanceId"), + CONSTRAINT "actors_username_check" CHECK ("username" NOT LIKE '%@%'), + CONSTRAINT "actors_suspended_check" CHECK ( + "suspendedUntil" IS NULL OR ( + "suspended" IS NOT NULL AND + "suspendedUntil" > "suspended" + ) + ) +); +--> statement-breakpoint +CREATE TABLE "local_actors" ( + "id" uuid PRIMARY KEY, + "avatar" text, + "header" text +); +--> statement-breakpoint +CREATE INDEX "actor_instance_index" ON "actors" ("instanceId");--> statement-breakpoint +ALTER TABLE "actors" ADD CONSTRAINT "actors_localId_local_actors_id_fkey" FOREIGN KEY ("localId") REFERENCES "local_actors"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "actors" ADD CONSTRAINT "actors_instanceId_instances_id_fkey" FOREIGN KEY ("instanceId") REFERENCES "instances"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "actors" ADD CONSTRAINT "actors_successorId_actors_id_fkey" FOREIGN KEY ("successorId") REFERENCES "actors"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/packages/models/drizzle/20260821072150_merge_remote_actors/snapshot.json b/packages/models/drizzle/20260821072150_merge_remote_actors/snapshot.json new file mode 100644 index 0000000..1caf8f4 --- /dev/null +++ b/packages/models/drizzle/20260821072150_merge_remote_actors/snapshot.json @@ -0,0 +1,1312 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "ac4fc4e2-2217-4c4c-9c8a-0ec2a8162819", + "prevIds": ["d8ac955f-1e74-4493-ade9-a25c40b01c20"], + "ddl": [ + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "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": "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": "followeesUrl", + "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": "followeesCount", + "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": "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": 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": ["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": "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": ["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" + } + ], + "renames": [] +} diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index 299db4c..13bc11a 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -72,6 +72,7 @@ export const relations = defineRelations(schema, (r) => ({ accepted: { isNotNull: true }, }, }), + actors: r.many.actors({ from: r.instances.id, to: r.actors.instanceId }), localInstances: r.one.localInstances({ from: r.instances.localId, to: r.localInstances.id, @@ -97,6 +98,20 @@ export const relations = defineRelations(schema, (r) => ({ optional: false, }), }, + actors: { + instance: r.one.instances({ + from: r.actors.instanceId, + to: r.instances.id, + optional: false, + }), + localActor: r.one.localActors({ + from: r.actors.localId, + to: r.localActors.id, + }), + }, + localActors: { + actor: r.one.actors({ from: r.localActors.id, to: r.actors.localId }), + }, })); export default relations; diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index d68bc1a..e1ddad8 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -16,14 +16,18 @@ import { sql } from "drizzle-orm"; import { + type AnyPgColumn, boolean, check, index, integer, + jsonb, + pgEnum, pgTable, primaryKey, text, timestamp, + unique, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -174,3 +178,100 @@ export const sessions = pgTable("sessions", { export type Session = typeof sessions.$inferSelect; export type NewSession = typeof sessions.$inferInsert; + +export const actorTypeEnum = pgEnum("actor_type", [ + "Application", + "Group", + "Organization", + "Person", + "Service", +]); + +export type ActorType = (typeof actorTypeEnum.enumValues)[number]; + +export const actors = pgTable( + "actors", + { + id: uuid().primaryKey(), + localId: uuid() + .unique() + .references(() => localActors.id, { onDelete: "cascade" }), + type: actorTypeEnum().notNull(), + username: text().notNull(), + instanceId: uuid() + .notNull() + .references(() => instances.id, { onDelete: "cascade" }), + iri: text().notNull().unique(), + inboxUrl: text().notNull(), + outboxUrl: text().notNull(), + followersUrl: text(), + followeesUrl: text(), + featuredUrl: text(), + profileUrl: text(), + avatarUrl: text(), + headerUrl: text(), + name: text(), + bioHtml: text(), + automaticallyApprovesFollowers: boolean().notNull().default(false), + fieldHtmls: jsonb().$type>().notNull().default({}), + emojis: jsonb().$type>().notNull().default({}), + tags: jsonb().$type>().notNull().default({}), + sensitive: boolean().notNull().default(false), + // Moderation sanction state, denormalized from flag_action records + // (which remain the audit source of truth): + // - Not sanctioned: suspended IS NULL + // - Temporary suspension: suspended = start, suspendedUntil = end + // - Permanent suspension (ban) for local actors, or permanent federation + // block for remote actors: suspended set, suspendedUntil IS NULL + // Whether a sanction is *currently* active is always determined by + // comparing against the current time (lazy expiry; no cron): + // suspended <= now AND (suspendedUntil IS NULL OR suspendedUntil > now). + suspended: timestamp({ withTimezone: true }), + suspendedUntil: timestamp({ withTimezone: true }), + successorId: uuid().references((): AnyPgColumn => actors.id, { + onDelete: "set null", + }), + aliases: text() + .array() + .notNull() + .default(sql`(ARRAY[]::text[])`), + followeesCount: integer().notNull().default(0), + followersCount: integer().notNull().default(0), + postsCount: integer().notNull().default(0), + updated: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp) + .$onUpdate(() => currentTimestamp), + published: timestamp({ withTimezone: true }), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + deleted: timestamp({ withTimezone: true }), + }, + (t) => [ + unique("username_key").on(t.username, t.instanceId), + check("actors_username_check", sql`${t.username} NOT LIKE '%@%'`), + check( + "actors_suspended_check", + sql` + ${t.suspendedUntil} IS NULL OR ( + ${t.suspended} IS NOT NULL AND + ${t.suspendedUntil} > ${t.suspended} + ) + `, + ), + index("actor_instance_index").on(t.instanceId), + ], +); + +export type Actor = typeof actors.$inferSelect; +export type NewActor = typeof actors.$inferInsert; + +export const localActors = pgTable("local_actors", { + id: uuid().primaryKey(), + avatar: text(), + header: text(), +}); + +export type LocalActor = typeof localActors.$inferSelect; +export type NewLocalActor = typeof localActors.$inferInsert;