diff --git a/.oxlintrc.json b/.oxlintrc.json index 9126f90..b955275 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -68,6 +68,7 @@ "import/prefer-default-export": "off", "jsdoc/require-param-type": "off", "jsdoc/require-returns-type": "off", + "node/no-top-level-await": "off", "promise/avoid-new": "warn" }, "overrides": [ diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 077b548..1195156 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -44,8 +44,8 @@ async function runServer(options: ServerOptions) { if (options.seed) { await seedData(options.drizzle.db); } - const { mailer } = options; - const yogaServer = createYogaServer(options.drizzle.db, { mailer }); + const { mailer, root } = options; + const yogaServer = createYogaServer(options.drizzle.db, { root, mailer }); const server = serve({ fetch: yogaServer.fetch.bind(yogaServer), hostname: options.address.host, diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index 0828e36..b7beff0 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -22,7 +22,7 @@ import { message, optionNames } from "@optique/core/message"; import { map, optional, withDefault } from "@optique/core/modifiers"; import type { InferValue } from "@optique/core/parser"; import { flag, option } from "@optique/core/primitives"; -import { socketAddress, url } from "@optique/core/valueparser"; +import { domain, socketAddress, url } from "@optique/core/valueparser"; import { loggingOptions } from "@optique/logtape"; import { path } from "@optique/run/valueparser"; import { LogTapeTransport } from "@upyo/logtape"; @@ -105,6 +105,12 @@ const seedParser = option("--dev-seed", { hidden: true, }); +const rootParser = optional( + option("--root-domain", "-r", domain({ lowercase: true }), { + description: message`The root domain of host.`, + }), +); + const serverParser = object("DrFed server", { address: withDefault( option("--listen", "-l", socketAddress({ requirePort: true }), { @@ -126,6 +132,7 @@ const serverParser = object("DrFed server", { ), }), ), + root: rootParser, mailer: smtpParser, seed: seedParser, }); diff --git a/packages/graphql/src/account.test.ts b/packages/graphql/src/account.test.ts index 060dc6a..c20ca5c 100644 --- a/packages/graphql/src/account.test.ts +++ b/packages/graphql/src/account.test.ts @@ -52,7 +52,8 @@ const accountInstancesQuery = ` admin node { uuid - slug + location + host } } } @@ -82,7 +83,8 @@ const accountInstancesResponse = { admin: true, node: { uuid: acceptedInstanceId, - slug: "test-instance", + location: "Local", + host: "test-instance.drfed.org", }, }, ], @@ -148,20 +150,7 @@ async function seedAccounts(db: Database): Promise { async function seedMembershipGraph(db: Database): Promise { await seedAccounts(db); - await db.insert(schema.instances).values([ - { - id: acceptedInstanceId, - slug: "test-instance", - created, - expires, - }, - { - id: pendingInstanceId, - slug: "pending-instance", - created, - expires, - }, - ]); + await seedLocalInstances(db); await db.insert(schema.instanceMembers).values([ { accountId, @@ -193,3 +182,30 @@ async function seedMembershipGraph(db: Database): Promise { }, ]); } + +async function seedLocalInstances(db: Database): Promise { + await db.insert(schema.instances).values([ + { + id: acceptedInstanceId, + location: "Local", + created, + }, + { + id: pendingInstanceId, + location: "Local", + created, + }, + ]); + await db.insert(schema.localInstances).values([ + { + id: acceptedInstanceId, + slug: "test-instance", + expires, + }, + { + id: pendingInstanceId, + slug: "pending-instance", + expires, + }, + ]); +} diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index 2a08a30..d6925cc 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -56,6 +56,11 @@ export interface ServerContext { * Origin list. */ readonly origins: ReadonlySet; + + /** + * Root domain. + */ + readonly root: string; } /** diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 9e991b4..9ddbfa0 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -47,6 +47,11 @@ export interface YogaServerOptions { * Origin list. */ origins?: ReadonlySet; + + /** + * Root domain. + */ + root?: string | undefined; } /** @@ -104,6 +109,7 @@ const fillOptions = (opt: YogaServerOptions): Required => ({ "http://localhost:5173", "http://0.0.0.0:5173", ]), + root: opt?.root ?? "drfed.org", }); const getAccessToken = (headers: Headers) => diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index cc32334..b24ca04 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -19,6 +19,7 @@ import assert from "node:assert/strict"; import { type Database, schema } from "@drfed/models"; import { describe, it } from "@logtape/testing-node/autoload"; +import { DrizzleQueryError } from "drizzle-orm"; import { withTestHarness } from "./harness.test.ts"; @@ -31,9 +32,26 @@ const accountId = "00000000-0000-4000-8000-000000000001"; const memberId = "00000000-0000-4000-8000-000000000002"; const pendingMemberId = "00000000-0000-4000-8000-000000000003"; const instanceId = "00000000-0000-4000-8000-000000000101"; +const duplicateRemoteInstanceId = "00000000-0000-4000-8000-000000000102"; const sessionId = "00000000-0000-4000-8000-000000000201"; const accessToken = "test-access-token"; +const remoteInstanceQuery = ` + query RemoteInstance($uuid: UUID!) { + accountByUuid(uuid: $uuid) { + instances { + edges { + node { + uuid + location + host + } + } + } + } + } +`; + const instanceMembersQuery = ` query InstanceMembers($uuid: UUID!) { accountByUuid(uuid: $uuid) { @@ -122,7 +140,8 @@ const createInstanceMutation = ` __typename ... on Instance { uuid - slug + location + host } ... on CreateInstanceError { type @@ -146,12 +165,18 @@ describe("Mutation.createInstance", () => { const body = await response.json(); assert.equal(body.errors, undefined); assert.equal(body.data.createInstance.__typename, "Instance"); - assert.equal(body.data.createInstance.slug, "my-instance"); + assert.equal(body.data.createInstance.location, "Local"); + assert.equal(body.data.createInstance.host, "my-instance.drfed.org"); assert.equal(typeof body.data.createInstance.uuid, "string"); const instances = await db.select().from(schema.instances); assert.equal(instances.length, 1); - assert.equal(instances[0]?.slug, "my-instance"); + const instance = instances[0]!; + assert.equal(instance.location, "Local"); + const local = await db.query.localInstances.findFirst({ + where: { id: instance.id }, + }); + assert.equal(local?.slug, "my-instance"); const members = await db.select().from(schema.instanceMembers); assert.equal(members.length, 1); @@ -226,6 +251,61 @@ describe("Mutation.createInstance", () => { }); }); +describe("Remote instance", () => { + it("returns a created remote instance", async () => { + await withTestHarness(async ({ db, post }) => { + await seedRemoteInstance(db); + + const response = await post({ + query: remoteInstanceQuery, + variables: { uuid: accountId }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), { + data: { + accountByUuid: { + instances: { + edges: [ + { + node: { + uuid: instanceId, + location: "Remote", + host: "remote.example.com", + }, + }, + ], + }, + }, + }, + }); + }); + }); + + it("requires a unique host", async () => { + await withTestHarness(async ({ db }) => { + await seedRemoteInstance(db); + await db.insert(schema.instances).values({ + id: duplicateRemoteInstanceId, + location: "Remote", + created, + }); + + await assert.rejects( + db.insert(schema.remoteInstances).values({ + id: duplicateRemoteInstanceId, + host: "remote.example.com", + }), + (error: unknown) => + error instanceof DrizzleQueryError && + error.cause != null && + "constraint" in error.cause && + error.cause.constraint === "remote_instances_host_key", + ); + }); + }); +}); + /** * Seeds an account and an authenticated session, then returns the request * options carrying the session's bearer token. @@ -294,8 +374,12 @@ async function seedInstanceMembers(db: Database): Promise { ]); await db.insert(schema.instances).values({ id: instanceId, - slug: "test-instance", + location: "Local", created, + }); + await db.insert(schema.localInstances).values({ + id: instanceId, + slug: "test-instance", expires, }); await db.insert(schema.instanceMembers).values([ @@ -322,3 +406,27 @@ async function seedInstanceMembers(db: Database): Promise { }, ]); } + +async function seedRemoteInstance(db: Database): Promise { + await db.insert(schema.accounts).values({ + id: accountId, + email: "owner@example.com", + name: "Owner", + created, + }); + await db.insert(schema.instances).values({ + id: instanceId, + location: "Remote", + created, + }); + await db.insert(schema.remoteInstances).values({ + id: instanceId, + host: "remote.example.com", + }); + await db.insert(schema.instanceMembers).values({ + accountId, + instanceId, + accepted, + created, + }); +} diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index 32cdd5c..73649b7 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -16,7 +16,7 @@ // oxlint-disable max-lines-per-function import { schema } from "@drfed/models"; -import { instanceMembers } from "@drfed/models/schema"; +import { instanceMembers, locationEnum } from "@drfed/models/schema"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { DrizzleQueryError } from "drizzle-orm"; import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; @@ -26,6 +26,10 @@ import { v7 as uuid } from "uuid"; import { Account } from "./account.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; +const Location = builder.enumType("Location", { + values: locationEnum.enumValues, +}); + const InstanceRef = builder.drizzleNode("instances", { name: "Instance", description: "Represents an `Instance` in the DrFed platform.", @@ -40,10 +44,28 @@ const InstanceRef = builder.drizzleNode("instances", { type: "UUID", description: "The UUID of the `Instance`.", }), - slug: t.exposeString("slug"), - expires: t.expose("expires", { - type: "DateTime", - description: "The expiration date/time of the `Instance`.", + location: t.expose("location", { + type: Location, + description: 'The location of the `Instance`: "Local" | "Remote"', + }), + host: t.string({ + async resolve({ id, location }, _, { db, root }) { + if (location === "Local") { + const ins = await db.query.localInstances.findFirst({ + columns: { slug: true }, + where: { id }, + }); + if (ins == null) throwUncontested(id); + return `${ins.slug}.${root}`; + } + const ins = await db.query.remoteInstances.findFirst({ + columns: { host: true }, + where: { id }, + }); + if (ins == null) throwUncontested(id); + return ins.host; + }, + description: "The host of the `Instance`.", }), created: t.expose("created", { type: "DateTime", @@ -52,6 +74,10 @@ const InstanceRef = builder.drizzleNode("instances", { }), }); +function throwUncontested(id: string): never { + throw new Error(`DB consistency is broken.: ${id}`); +} + export const Instance: DrFedObjectRef = InstanceRef; const instanceMembersConnection = drizzleConnectionHelpers( @@ -218,15 +244,10 @@ builder.mutationFields((t) => ({ let tooManyInstances = false; try { return await ctx.db.transaction(async (tx) => { + const id = uuid(); const [instance] = await tx .insert(schema.instances) - .values({ - id: uuid(), - slug, - expires: new Date( - Temporal.Now.instant().add({ hours: 8750 }).toString(), - ), - }) + .values({ id, location: "Local" }) .returning(); if (instance == null) throw new Error("Failed to create instance."); await tx.insert(schema.instanceMembers).values({ @@ -241,6 +262,13 @@ builder.mutationFields((t) => ({ tooManyInstances = true; tx.rollback(); } + await tx.insert(schema.localInstances).values({ + id, + slug, + expires: new Date( + Temporal.Now.instant().add({ hours: YEAR_BY_HOURS }).toString(), + ), + }); return instance; }); } catch (e) { @@ -254,7 +282,7 @@ builder.mutationFields((t) => ({ e instanceof DrizzleQueryError && e.cause != null && "constraint" in e.cause && - e.cause.constraint === "instances_slug_key" + e.cause.constraint === "local_instances_slug_key" ) { return { message: `The slug ${JSON.stringify(slug)} is already taken.`, @@ -266,3 +294,5 @@ builder.mutationFields((t) => ({ }, }), })); + +const YEAR_BY_HOURS = 8760; diff --git a/packages/models/drizzle/20260802205417_separate-instance-local-remote/migration.sql b/packages/models/drizzle/20260802205417_separate-instance-local-remote/migration.sql new file mode 100644 index 0000000..7b30cef --- /dev/null +++ b/packages/models/drizzle/20260802205417_separate-instance-local-remote/migration.sql @@ -0,0 +1,26 @@ +CREATE TYPE "location" AS ENUM('Local', 'Remote');--> statement-breakpoint +CREATE TABLE "local_instances" ( + "id" uuid PRIMARY KEY, + "slug" varchar(63) NOT NULL UNIQUE, + "expires" timestamp with time zone NOT NULL, + "maxActors" integer DEFAULT 10 NOT NULL, + CONSTRAINT "instances_slug_check" CHECK ("slug" ~ '^[a-z0-9-]{4,63}$'), + CONSTRAINT "instances_max_actors_check" CHECK ("maxActors" > 0) +); +--> statement-breakpoint +CREATE TABLE "remote_instances" ( + "id" uuid PRIMARY KEY, + "host" varchar(100) NOT NULL UNIQUE, + "nodeInfoUrl" text, + "software" text, + "softwareVersion" text +); +--> statement-breakpoint +ALTER TABLE "instances" DROP CONSTRAINT "instances_slug_key";--> statement-breakpoint +ALTER TABLE "instances" DROP CONSTRAINT "instances_slug_check";--> statement-breakpoint +ALTER TABLE "instances" DROP CONSTRAINT "instances_expires_check";--> statement-breakpoint +ALTER TABLE "instances" ADD COLUMN "location" "location" NOT NULL;--> statement-breakpoint +ALTER TABLE "instances" DROP COLUMN "slug";--> statement-breakpoint +ALTER TABLE "instances" DROP COLUMN "expires";--> statement-breakpoint +ALTER TABLE "local_instances" ADD CONSTRAINT "local_instances_id_instances_id_fkey" FOREIGN KEY ("id") REFERENCES "instances"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "remote_instances" ADD CONSTRAINT "remote_instances_id_instances_id_fkey" FOREIGN KEY ("id") REFERENCES "instances"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/models/drizzle/20260802205417_separate-instance-local-remote/snapshot.json b/packages/models/drizzle/20260802205417_separate-instance-local-remote/snapshot.json new file mode 100644 index 0000000..fa02ce8 --- /dev/null +++ b/packages/models/drizzle/20260802205417_separate-instance-local-remote/snapshot.json @@ -0,0 +1,768 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "08f1dfcb-1110-4550-9777-36d017ecfb10", + "prevIds": ["d11b25cc-8a54-4c9f-95c5-1891ce2a133a"], + "ddl": [ + { + "values": ["Local", "Remote"], + "name": "location", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "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_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "remote_instances", + "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": "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": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location", + "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": "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": "remote_instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "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": 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": ["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": ["id"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_instances_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "local_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": ["id"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_instances_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "remote_instances" + }, + { + "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": "instances_pkey", + "schema": "public", + "table": "instances", + "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": "remote_instances_pkey", + "schema": "public", + "table": "remote_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "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": ["host"], + "nullsNotDistinct": false, + "name": "remote_instances_host_key", + "schema": "public", + "table": "remote_instances", + "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": "\"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 c1848df..d8f2e51 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -72,6 +72,26 @@ export const relations = defineRelations(schema, (r) => ({ accepted: { isNotNull: true }, }, }), + localInstances: r.one.localInstances({ + from: r.instances.id, + to: r.localInstances.id, + }), + remoteInstances: r.one.remoteInstances({ + from: r.instances.id, + to: r.remoteInstances.id, + }), + }, + localInstance: { + instances: r.one.instances({ + from: r.localInstances.id, + to: r.instances.id, + }), + }, + remoteInstance: { + instances: r.one.instances({ + from: r.remoteInstances.id, + to: r.instances.id, + }), }, sessions: { account: r.one.accounts({ diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 6693785..518cef0 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -13,19 +13,26 @@ // // 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 { sql } from "drizzle-orm"; import { boolean, check, index, integer, + pgEnum, pgTable, primaryKey, + text, timestamp, uuid, varchar, } from "drizzle-orm/pg-core"; +const currentTimestamp = sql`CURRENT_TIMESTAMP`; + /** * The database table to represent accounts. */ @@ -39,7 +46,7 @@ export const accounts = pgTable( admin: boolean().notNull().default(false), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), }, (table) => [ check( @@ -54,30 +61,54 @@ export const accounts = pgTable( export type Account = typeof accounts.$inferSelect; export type NewAccount = typeof accounts.$inferInsert; +export const locationEnum = pgEnum("location", ["Local", "Remote"]); +export type Location = (typeof locationEnum.enumValues)[number]; + /** * The database table to represent instances. */ -export const instances = pgTable( - "instances", +export const instances = pgTable("instances", { + id: uuid().primaryKey(), + location: locationEnum().notNull(), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), +}); + +export type Instance = typeof instances.$inferSelect; +export type NewInstance = typeof instances.$inferInsert; + +export const localInstances = pgTable( + "local_instances", { - id: uuid().primaryKey(), - slug: varchar({ length: 100 }).notNull().unique(), + id: uuid() + .primaryKey() + .references(() => instances.id, { onDelete: "cascade" }), + slug: varchar({ length: 63 }).notNull().unique(), expires: timestamp({ withTimezone: true }).notNull(), - created: timestamp({ withTimezone: true }) - .notNull() - .default(sql`CURRENT_TIMESTAMP`), + maxActors: integer().notNull().default(10), }, (table) => [ - check("instances_slug_check", sql`${table.slug} ~ '^[a-z0-9-]{4,100}$'`), - check( - "instances_expires_check", - sql`${table.expires} < (${table.created} + INTERVAL '1 year')`, - ), + check("instances_slug_check", sql`${table.slug} ~ '^[a-z0-9-]{4,63}$'`), + check("instances_max_actors_check", sql`${table.maxActors} > 0`), ], ); -export type Instance = typeof instances.$inferSelect; -export type NewInstance = typeof instances.$inferInsert; +export type LocalInstance = typeof localInstances.$inferSelect; +export type NewLocalInstance = typeof localInstances.$inferInsert; + +export const remoteInstances = pgTable("remote_instances", { + id: uuid() + .primaryKey() + .references(() => instances.id, { onDelete: "cascade" }), + host: varchar({ length: 100 }).notNull().unique(), + nodeInfoUrl: text(), + software: text(), + softwareVersion: text(), +}); + +export type RemoteInstance = typeof remoteInstances.$inferSelect; +export type NewRemoteInstance = typeof remoteInstances.$inferInsert; /** * The association table between instances and its member accounts. @@ -97,7 +128,7 @@ export const instanceMembers = pgTable( accepted: timestamp({ withTimezone: true }), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), }, (table) => [ primaryKey({ columns: [table.instanceId, table.accountId] }), @@ -128,7 +159,7 @@ export const loginTokens = pgTable("login_tokens", { codeHash: varchar({ length: 64 }).notNull(), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), expires: timestamp({ withTimezone: true }) .notNull() .default(sql`CURRENT_TIMESTAMP + INTERVAL '15 minutes'`), @@ -150,7 +181,7 @@ export const sessions = pgTable("sessions", { tokenHash: varchar({ length: 64 }).notNull().unique(), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), expires: timestamp({ withTimezone: true }) .notNull() .default(sql`CURRENT_TIMESTAMP + INTERVAL '1 month'`), diff --git a/scripts/dev.mts b/scripts/dev.mts index 4d94252..88711ea 100644 --- a/scripts/dev.mts +++ b/scripts/dev.mts @@ -306,6 +306,7 @@ try { "../../.pgdata", "--listen=0.0.0.0:8888", "--log-format=color", + "--root-domain=drfed.org", ]; const logLevel = process.env.usage_log_level;