diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48b1432..4b7a8c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -292,8 +292,8 @@ the `tsdown` configuration before writing tests: } ~~~~ -Each package with tests exposes a `test` npm script that runs Node.js's -built-in test runner: +Every package except `@drfed/web` exposes a `test` npm script that runs +Node.js's built-in test runner: ~~~~ json "scripts": { @@ -362,13 +362,33 @@ The CLI parser lives in *packages/drfed/src/parser.ts*, the program metadata in The server currently supports: + - `--root-origin`/`-r` for the origin instances are subdomains of. Required. - `--listen`/`-l` for the host and port, defaulting to `localhost:8888`. - `--pglite-data-path`/`--data-path`/`-d` for local PGlite storage. - `--postgres-url`/`--database-url`/`-D` for PostgreSQL. - `--no-migrate`/`-M` to disable automatic migrations. + - `--email-from`/`-f` for the sender of login mail, defaulting to `noreply@` + at the root origin's host name. + - `--smtp-url`/`-s` for the SMTP server to deliver mail through. Keep CLI options explicit and documented through Optique descriptions, because -those descriptions feed the generated help output. +those descriptions feed the generated help output. Options that name a web +origin should use Optique's `origin()` value parser rather than `url()`, so +that every spelling of the same origin is normalized the same way. +*packages/drfed/src/valueparser.ts* wraps it as `rootOrigin()` to add the two +rules that are DrFed's own: the root origin may not name an IP address, since +every instance is a subdomain of it, and its host name may not run past the 253 +octets DNS allows, since login mail is sent from that domain. + +Requests are routed by the authority they arrive on, in +*packages/drfed/src/serving.ts*. A subdomain one label below the root origin +is an instance and serves ActivityPub only; the root origin and every other +authority serve GraphQL and never answer as an instance; anything deeper under +the root domain is answered 421, and an unusable `Host` header 400. The +classification itself lives in *packages/graphql/src/origin.ts* alongside the +functions that compose an instance's authority, so that the two can never +disagree about what an instance host looks like. Anything that changes how a +host is composed or compared belongs there, not in the server. Quality bar @@ -377,11 +397,16 @@ Quality bar Before sending a pull request, run: ~~~~ sh -mise run check mise run build +mise run check mise run test ~~~~ +Build first. `mise run check` type-checks each package against the *dist/* of +the ones it depends on, so on a fresh checkout, or after adding a subpath +export, checking before building reports missing modules that are not actually +missing. This is the order CI uses. + Run `mise run dev` for changes that affect startup, CLI parsing, migration execution, the GraphQL server, or package build output. Manually verify the installed CLI behavior when changing package metadata, `bin` entries, build diff --git a/packages/drfed/.env.example b/packages/drfed/.env.example index 2af672f..85abd2a 100644 --- a/packages/drfed/.env.example +++ b/packages/drfed/.env.example @@ -1 +1,2 @@ DRFED_LOGIN_ORIGINS=https://drfed.example.com,http://localhost:3000 +DRFED_ROOT_ORIGIN=http://drfed.localhost:8888 diff --git a/packages/drfed/README.md b/packages/drfed/README.md index fa2556d..eca68cd 100644 --- a/packages/drfed/README.md +++ b/packages/drfed/README.md @@ -12,8 +12,9 @@ Usage ----- ~~~~ sh -drfed-server --data-path .pgdata -drfed-server --database-url postgres://localhost/drfed +drfed-server --root-origin https://drfed.example.com --data-path .pgdata +drfed-server --root-origin https://drfed.example.com \ + --database-url postgres://localhost/drfed ~~~~ The server listens on `localhost:8888` by default. Pass `--listen HOST:PORT` @@ -21,6 +22,55 @@ to override. Automatic database migrations run on startup unless `--no-migrate` is given. +Root origin +----------- + +`--root-origin` is required, and everything else follows from it. Each +instance is served from its own subdomain of that origin, so with +`https://drfed.example.com` the instance `foo-bar` lives at +`https://foo-bar.drfed.example.com`. A non-default port belongs in the value +and is carried into every instance, which is what makes a development +deployment work: + +~~~~ sh +drfed-server --root-origin http://drfed.localhost:8888 --data-path .pgdata +~~~~ + +Requests are routed by the authority they arrive on: + +| Authority | Serves | +| ------------------------------------- | ------------------------------------- | +| `.` | ActivityPub only; GraphQL answers 404 | +| the root origin | GraphQL | +| the listening address, internal names | GraphQL | +| anything deeper under the root domain | 421 Misdirected Request | +| an unusable `Host` header | 400 Bad Request | + +The authority has to match in full, port included. A request for +`foo.drfed.example.com:8888` against a root origin of +`https://drfed.example.com` does not name an instance and is served the control +surface, not a 404, so do not read the table above as isolating GraphQL by host +name alone. Ports 80 and 443 both count as no port at all, since which of them +is the default depends on a scheme that is deliberately not compared. + +Not comparing the scheme is what lets a deployment sit behind a TLS-terminating +reverse proxy. Such a proxy must pass two things through: + + - `Host`, unchanged, because that is what names the instance. A `Host` + carrying the root zone's trailing dot is answered 400 rather than served, + because the HTTP layer refuses it before DrFed sees it. + - `X-Forwarded-Proto: https`. Without it the request looks like plain HTTP, + and every actor URI DrFed mints names `http://`, which is not where the + actor lives and not what the rest of the fediverse will accept. + +Deploying this way needs a wildcard DNS record for `*.` and, over +HTTPS, a wildcard TLS certificate to match. + +Changing the root origin after instances exist does not move them. Their host +names are already part of the actor URIs the rest of the fediverse has stored, +so the server only warns at startup about instances it can no longer reach. + + Environment ----------- @@ -29,7 +79,7 @@ or HTTPS origins allowed in email login links: ~~~~ sh DRFED_LOGIN_ORIGINS=https://drfed.example.com,http://localhost:3000 \ - drfed-server --data-path .pgdata + drfed-server --root-origin https://drfed.example.com --data-path .pgdata ~~~~ For repository development, create the environment file loaded by @@ -39,18 +89,29 @@ For repository development, create the environment file loaded by cp packages/drfed/.env.example packages/drfed/.env ~~~~ +That file also carries `DRFED_ROOT_ORIGIN`, which `mise run dev` passes as +`--root-origin`. It defaults to `http://drfed.localhost:8888`; every subdomain +of `localhost` resolves to the loopback address without any DNS or */etc/hosts* +setup, which is what makes per-instance subdomains usable locally. + Options ------- -| Option | Short | Description | -| ------------------------- | ----- | ------------------------------------------------ | -| `--listen HOST:PORT` | `-l` | Address to listen on (default: `localhost:8888)` | -| `--pglite-data-path PATH` | `-d` | Directory for PGlite storage | -| `--postgres-url URL` | `-D` | PostgreSQL connection URL | -| `--no-migrate` | `-M` | Skip automatic migrations | -| `--help` | | Show help | -| `--version` | | Show version | +| Option | Short | Description | +| ------------------------- | ----- | -------------------------------------------------------------------- | +| `--root-origin ORIGIN` | `-r` | Origin instances are subdomains of (required) | +| `--listen HOST:PORT` | `-l` | Address to listen on (default: `localhost:8888`) | +| `--pglite-data-path PATH` | `-d` | Directory for PGlite storage | +| `--postgres-url URL` | `-D` | PostgreSQL connection URL | +| `--no-migrate` | `-M` | Skip automatic migrations | +| `--email-from ADDRESS` | `-f` | Sender of login mail (default: `noreply@` at the root origin's host) | +| `--smtp-url URL` | `-s` | SMTP server to deliver mail through | +| `--help` | | Show help | +| `--version` | | Show version | `--pglite-data-path` and `--postgres-url` are mutually exclusive. One of them must be provided. + +Without `--smtp-url`, mail is written to the log instead of being delivered, +which is why a development server can sign you in without a mail server. diff --git a/packages/drfed/package.json b/packages/drfed/package.json index bcb540c..33442e6 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -41,6 +41,20 @@ "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./valueparser": { + "types": "./dist/valueparser.d.mts", + "default": "./dist/valueparser.mjs" + }, + "./serving": { + "types": "./dist/serving.d.mts", + "default": "./dist/serving.mjs" + } + }, "files": [ "bin/", "dist/", @@ -50,6 +64,11 @@ "drfed-server": "bin/drfed-server.mjs" }, "tsdown": { + "entry": [ + "src/index.ts", + "src/valueparser.ts", + "src/serving.ts" + ], "dts": { "sourcemap": true, "tsconfig": "../../tsconfig.drfed.json" @@ -57,7 +76,8 @@ "sourcemap": true }, "scripts": { - "build": "tsdown" + "build": "tsdown", + "test": "node --test" }, "devDependencies": { "@logtape/testing-node": "catalog:", diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index ff30897..c0c084b 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -38,6 +38,7 @@ import type { } from "./parser.ts"; import program from "./program.ts"; import seedData from "./seed.ts"; +import { createFetchHandler, warnAboutStrandedInstances } from "./serving.ts"; async function runServer(options: ServerOptions) { const values = process.env.DRFED_LOGIN_ORIGINS?.split(",").map((value) => @@ -66,20 +67,21 @@ async function runServer(options: ServerOptions) { ? new PgliteKvStore(credentials.client) : new PostgresKvStore(credentials.client); const federation = await createFederation(options.drizzle.db, { kv }); - const { mailer, root } = options; + const { emailFrom, mailer, rootOrigin } = options; const yogaServer = createYogaServer(options.drizzle.db, federation, { - root, + rootOrigin, + emailFrom, mailer, loginOrigins, }); + await warnAboutStrandedInstances(options.drizzle.db, rootOrigin); const server = serve({ - fetch: (req) => - federation.fetch(req, { - onNotFound: yogaServer.fetch, - onNotAcceptable: yogaServer.fetch, - contextData: undefined, - }), + fetch: createFetchHandler({ + federation, + rootOrigin, + serveControlSurface: yogaServer.fetch, + }), hostname: options.address.host, manual: true, port: options.address.port, diff --git a/packages/drfed/src/parser.test.ts b/packages/drfed/src/parser.test.ts new file mode 100644 index 0000000..8d7d64e --- /dev/null +++ b/packages/drfed/src/parser.test.ts @@ -0,0 +1,158 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { describe, it } from "@logtape/testing-node/autoload"; + +const execFileAsync = promisify(execFile); + +const commandTimeout = 30_000; + +// The binary rather than the parser module, because what matters here is the +// contract the installed command exposes. Parsing `--pglite-data-path` opens +// a database as a side effect, so every case below either fails during parsing +// or takes the schema-generation branch, which needs no database at all. That +// is also why none of them need `DRFED_LOGIN_ORIGINS`: the server never gets +// far enough to read it. +const binary = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "bin", + "drfed-server.mjs", +); + +async function run( + args: readonly string[], +): Promise<{ code: number; stdout: string; stderr: string }> { + try { + const { stdout, stderr } = await execFileAsync( + process.execPath, + [binary, ...args], + { + // A deliberately minimal environment. Leaving `DRFED_LOGIN_ORIGINS` + // out means that a command line which parses successfully still stops + // immediately instead of starting a server, whatever the developer + // happens to have exported. + env: { PATH: process.env.PATH ?? "" }, + timeout: commandTimeout, + }, + ); + return { code: 0, stdout, stderr }; + } catch (e) { + const error = e as { code?: number; stdout?: string; stderr?: string }; + return { + code: error.code ?? 1, + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + }; + } +} + +describe("drfed-server", () => { + it("requires --root-origin to serve", async () => { + const { code, stderr } = await run(["--data-path", "/nonexistent-drfed"]); + + assert.notEqual(code, 0); + assert.match(stderr, /Missing option .*--root-origin/u); + }); + + it("advertises --root-origin as required in its help", async () => { + const { code, stdout } = await run(["--help"]); + assert.equal(code, 0); + // Optional options are bracketed in the usage line; this one must not be. + assert.match(stdout, /--root-origin\/-r ORIGIN/u); + assert.doesNotMatch(stdout, /\[--root-origin/u); + }); + + it("no longer accepts the old --root-domain option", async () => { + // Everything else on this command line is valid, so the only thing that + // can go wrong is the retired option. If it were reinstated, parsing + // would succeed and the run would instead stop on the missing + // `DRFED_LOGIN_ORIGINS`, which says something else entirely. + const dataPath = await mkdtemp(join(tmpdir(), "drfed-parser-test-")); + try { + const { code, stderr } = await run([ + "--data-path", + dataPath, + "--root-origin=https://drfed.net", + "--root-domain=drfed.net", + ]); + assert.notEqual(code, 0); + // The message names the offending token, so this cannot pass for some + // other reason. + assert.match(stderr, /Unexpected option or argument: "--root-domain/u); + } finally { + await rm(dataPath, { force: true, recursive: true }); + } + }); + + it("rejects a root origin that names an IP address", async () => { + const { code, stderr } = await run([ + "--data-path", + "/nonexistent-drfed", + "--root-origin=http://127.0.0.1:8888", + ]); + assert.notEqual(code, 0); + assert.match(stderr, /IP address/u); + }); + + it("accepts --email-from and rejects a malformed address", async () => { + const dataPath = await mkdtemp(join(tmpdir(), "drfed-parser-test-")); + try { + // Valid: parsing gets past the option and stops only on the missing + // login origins, which is the next thing the server reads. + const accepted = await run([ + "--data-path", + dataPath, + "--root-origin=https://drfed.net", + "--email-from=postmaster@mail.example", + ]); + assert.notEqual(accepted.code, 0); + assert.match(accepted.stderr, /DRFED_LOGIN_ORIGINS/u); + + const rejected = await run([ + "--data-path", + dataPath, + "--root-origin=https://drfed.net", + "--email-from=not-an-address", + ]); + assert.notEqual(rejected.code, 0); + assert.doesNotMatch(rejected.stderr, /DRFED_LOGIN_ORIGINS/u); + } finally { + await rm(dataPath, { force: true, recursive: true }); + } + }); + + it("generates the GraphQL schema without a root origin", async () => { + // Schema generation is the other branch of the parser and must stay + // usable without any deployment configuration; `mise run build` calls it. + const { code, stdout } = await run([ + "--generate-graphql-schema", + "--output-file", + "-", + ]); + assert.equal(code, 0); + assert.match(stdout, /type Query/u); + }); +}); diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index ba66bb7..8ba2240 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 { domain, socketAddress, url } from "@optique/core/valueparser"; +import { email, socketAddress, url } from "@optique/core/valueparser"; import { loggingOptions } from "@optique/logtape"; import { path } from "@optique/run/valueparser"; import { LogTapeTransport } from "@upyo/logtape"; @@ -31,6 +31,8 @@ import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; import postgres from "postgres"; +import { rootOrigin } from "./valueparser.ts"; + const pgliteParser = map( option( "--pglite-data-path", @@ -107,9 +109,18 @@ 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 rootOriginParser = option( + "--root-origin", + "-r", + rootOrigin({ metavar: "ORIGIN" }), + { + description: message`The origin this deployment is served from. Every instance gets a subdomain of it, so ${"https://drfed.net"} serves the instance ${"foo-bar"} at ${"https://foo-bar.drfed.net"}.`, + }, +); + +const emailFromParser = optional( + option("--email-from", "-f", email({ lowercase: true }), { + description: message`The address login mail is sent from. Defaults to ${"noreply@"} at the root origin's host name.`, }), ); @@ -134,7 +145,8 @@ const serverParser = object("DrFed server", { ), }), ), - root: rootParser, + rootOrigin: rootOriginParser, + emailFrom: emailFromParser, mailer: smtpParser, seed: seedParser, }); diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts new file mode 100644 index 0000000..d6b52a3 --- /dev/null +++ b/packages/drfed/src/serving.test.ts @@ -0,0 +1,244 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from "node:assert/strict"; + +import { + createFetchHandler, + findStrandedInstances, + warnAboutStrandedInstances, +} from "@drfed/drfed/serving"; +import { migrate, relations, schema } from "@drfed/models"; +import { uuidV7 } from "@drfed/models/uuid"; +import { PGlite } from "@electric-sql/pglite"; +import { describe, it } from "@logtape/testing-node/autoload"; +import { drizzle } from "drizzle-orm/pglite"; + +const rootOrigin = new URL("https://drfed.net"); +const dayInMilliseconds = 86_400_000; + +/** + * A stand-in for the request object a server adapter hands the handler, whose + * `url` may be a string `URL` refuses. `Request` cannot express that: undici + * parses the URL in its own constructor. + * @param url The request URL, valid or not. + * @param host The `Host` header to report. + * @returns Something shaped enough like a `Request` for the router. + */ +function fakeRequest(url: string, host: string): Request { + return { headers: new Headers({ host }), url } as Request; +} + +function handler(): { + handle: (request: Request) => Promise; + fetch: (url: string, init?: RequestInit) => Promise; + federationCalls: string[]; + controlCalls: string[]; +} { + const federationCalls: string[] = []; + const controlCalls: string[] = []; + const handle = createFetchHandler({ + rootOrigin, + federation: { + async fetch(request, options) { + federationCalls.push(request.url); + // Stand in for a dispatcher that resolved to nothing, which is what + // every route does on a subdomain no instance has claimed. + return await options.onNotFound(request); + }, + }, + serveControlSurface(request) { + controlCalls.push(request.url); + return new Response("graphql", { status: 200 }); + }, + }); + return { + handle, + fetch: async (url, init) => await handle(new Request(url, init)), + federationCalls, + controlCalls, + }; +} + +describe("createFetchHandler()", () => { + it("serves ActivityPub on an instance subdomain", async () => { + const { fetch, federationCalls, controlCalls } = handler(); + const response = await fetch("https://foo-bar.drfed.net/users/x"); + assert.equal(response.status, 404); + assert.deepEqual(federationCalls, ["https://foo-bar.drfed.net/users/x"]); + assert.deepEqual(controlCalls, []); + }); + + it("keeps the control surface off instance subdomains", async () => { + // The whole point of routing by authority: a tenant's host must never + // answer for GraphQL, even though the same process serves it. + const { fetch, federationCalls, controlCalls } = handler(); + const response = await fetch("https://foo-bar.drfed.net/graphql"); + assert.equal(response.status, 404); + assert.deepEqual(controlCalls, []); + assert.equal(federationCalls.length, 1); + }); + + it("serves the control surface on the root origin", async () => { + const { fetch, federationCalls, controlCalls } = handler(); + const response = await fetch("https://drfed.net/graphql"); + assert.equal(response.status, 200); + assert.deepEqual(controlCalls, ["https://drfed.net/graphql"]); + assert.deepEqual(federationCalls, []); + }); + + it("serves the control surface on an unrelated authority", async () => { + // The frontend reaches the backend by its listening address, which names + // no instance; that has to keep working. + const { fetch, controlCalls } = handler(); + assert.equal((await fetch("http://127.0.0.1:8888/graphql")).status, 200); + assert.equal((await fetch("http://localhost:3000/graphql")).status, 200); + assert.equal(controlCalls.length, 2); + }); + + it("answers 421 below an instance subdomain", async () => { + const { fetch, federationCalls, controlCalls } = handler(); + const response = await fetch("https://a.b.drfed.net/"); + assert.equal(response.status, 421); + assert.deepEqual(federationCalls, []); + assert.deepEqual(controlCalls, []); + }); + + it("refuses a request whose URL disagrees with its Host header", async () => { + // The srvx adapter substitutes the literal `_invalid_` for a `Host` it + // cannot parse, which would otherwise classify as the control surface and + // answer GraphQL to a request that named a tenant. + const { fetch, federationCalls, controlCalls } = handler(); + const response = await fetch("http://_invalid_/graphql", { + headers: { host: "foo-bar.drfed.net." }, + }); + assert.equal(response.status, 400); + assert.deepEqual(controlCalls, []); + assert.deepEqual(federationCalls, []); + }); + + it("refuses a request URL that cannot be parsed", async () => { + // The srvx adapter validates `Host` against a structural pattern and + // builds the request URL by concatenation, so hosts that `URL` rejects + // still reach the handler. Parsing one used to throw, and with no rejection handler + // anywhere above, a single unauthenticated request ended the process. + const { handle, federationCalls, controlCalls } = handler(); + // Both fail the WHATWG IPv4 host parser, which is spec-defined rather + // than a property of whichever ICU the runtime was built with. + const hosts = ["1.2.3.4.5", "999.1.1.1"]; + const results = await Promise.all( + hosts.map(async (host) => { + assert.equal(URL.canParse(`http://${host}/`), false, host); + const response = await handle(fakeRequest(`http://${host}/`, host)); + return { host, status: response.status }; + }), + ); + for (const { host, status } of results) { + assert.equal(status, 400, host); + } + assert.deepEqual(controlCalls, []); + assert.deepEqual(federationCalls, []); + }); + + it("accepts a Host that merely spells the authority differently", async () => { + // A reverse proxy may forward a `Host` that writes out the default port, + // and the check must not fire on that. + const { fetch } = handler(); + const control = await fetch("https://drfed.net/graphql", { + headers: { host: "drfed.net:443" }, + }); + assert.equal(control.status, 200); + // A root-zone dot is likewise only a spelling, and the check tolerates it + // here. Note this shape does not actually reach the handler in + // production: srvx's own host pattern rejects a trailing dot and + // substitutes `_invalid_`, so such a request is answered 400 above. The + // tolerance still matters for adapters that do pass it through. + const tenant = await fetch("https://foo-bar.drfed.net/users/x", { + headers: { host: "foo-bar.drfed.net." }, + }); + assert.equal(tenant.status, 404); + }); + + it("never asks the database what exists", async () => { + // An unclaimed subdomain still routes to ActivityPub, where the + // dispatchers resolve to nothing. That is what keeps routing free of a + // per-request lookup. + const { fetch, federationCalls } = handler(); + assert.equal((await fetch("https://nobody.drfed.net/users/x")).status, 404); + assert.deepEqual(federationCalls, ["https://nobody.drfed.net/users/x"]); + }); +}); + +describe("findStrandedInstances()", () => { + it("reports only local instances outside the root origin", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, relations, schema }); + const rows = [ + // Reachable under the configured root origin. + { host: "here.drfed.net", slug: "here", local: true }, + // Left behind by a root origin change. + { host: "there.drfed.org", slug: "there", local: true }, + // Also stranded: an instance occupies exactly one label. + { host: "deep.nested.drfed.net", slug: "deep", local: true }, + // Remote instances are nobody's business here. + { host: "remote.example.com", slug: "remote", local: false }, + // Not a URL at all: the WHATWG IPv4 parser rejects it. Reporting + // such a row must not throw, because startup waits on this scan and + // one bad row would otherwise keep the deployment from coming back + // up. The slug and the host disagree here, which is the point: only + // the stored host is consulted. + { host: "999.1.1.1", slug: "unparseable", local: true }, + ]; + const expires = new Date(Date.now() + dayInMilliseconds); + const seeded = rows.map(({ host, slug, local }) => ({ + host, + localId: local ? uuidV7() : null, + slug, + })); + await db + .insert(schema.localInstances) + .values( + seeded + .filter(({ localId }) => localId != null) + .map(({ localId, slug }) => ({ id: localId!, slug, expires })), + ); + await db + .insert(schema.instances) + .values( + seeded.map(({ host, localId }) => ({ id: uuidV7(), localId, host })), + ); + + const stranded = await findStrandedInstances(db, rootOrigin); + assert.deepEqual([...stranded].sort(), [ + "999.1.1.1", + "deep.nested.drfed.net", + "there.drfed.org", + ]); + assert.ok(!stranded.includes("here.drfed.net")); + // It only reports; nothing is rewritten. + const after = await db.select().from(schema.instances); + assert.deepEqual( + after.map(({ host }) => host).sort(), + rows.map(({ host }) => host).sort(), + ); + await warnAboutStrandedInstances(db, rootOrigin); + } finally { + await client.close(); + } + }); +}); diff --git a/packages/drfed/src/serving.ts b/packages/drfed/src/serving.ts new file mode 100644 index 0000000..05ff70d --- /dev/null +++ b/packages/drfed/src/serving.ts @@ -0,0 +1,193 @@ +// 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 { canonicalizeAuthority, classifyHost } from "@drfed/graphql/origin"; +import type { Database } from "@drfed/models"; +import { getLogger } from "@logtape/logtape"; + +/** + * The part of a Fedify `Federation` that the router needs, narrowed so that + * the routing can be exercised without building one. + */ +export interface FederationHandler { + fetch( + request: Request, + options: { + onNotFound(request: Request): Response | Promise; + onNotAcceptable(request: Request): Response | Promise; + contextData: undefined; + }, + ): Promise; +} + +/** + * Options for {@link createFetchHandler}. + */ +export interface FetchHandlerOptions { + /** + * The root origin this deployment serves instances under. + */ + readonly rootOrigin: URL; + + /** + * The ActivityPub surface, served on instance subdomains. + */ + readonly federation: FederationHandler; + + /** + * The control surface, i.e. the GraphQL server, served everywhere else. + */ + readonly serveControlSurface: ( + request: Request, + ) => Response | Promise; +} + +/** + * Builds the server's request handler, which decides from the authority alone + * which of DrFed's two faces answers. + * + * An instance's subdomain serves ActivityPub and nothing else, so that a + * tenant can never reach the control surface; every other authority is the + * control surface and never answers as an instance. A subdomain nobody has + * claimed still routes to ActivityPub, where every dispatcher resolves to + * nothing and the request ends in a 404, which keeps the routing from having + * to ask the database what exists. + * @param options The surfaces to route between, and the root origin to route + * by. + * @returns A handler suitable for passing to `serve()`. + */ +export function createFetchHandler( + options: FetchHandlerOptions, +): (request: Request) => Promise { + const { federation, rootOrigin, serveControlSurface } = options; + return async (request: Request): Promise => { + // A server adapter may accept a `Host` that `URL` will not. srvx checks + // it against a structural pattern and builds the request URL by + // concatenation, so `1.2.3.4.5`, `999.1.1.1` and undecodable A-labels such + // as `xn--a` all arrive here as URLs that cannot be parsed. Parsing one + // throws inside this handler, and srvx attaches no rejection handler, so + // the process would die on a single unauthenticated request. + if (!URL.canParse(request.url)) return invalidHost(); + const url = new URL(request.url); + // An adapter may also substitute something for a `Host` it cannot make + // sense of rather than refuse the request: srvx uses the literal + // `_invalid_`, which does parse. Routing on that would quietly hand the + // control surface to a request that was aiming at a tenant. + const host = request.headers.get("host"); + if ( + host != null && + canonicalizeAuthority(host) !== canonicalizeAuthority(url.host) + ) { + return invalidHost(); + } + switch (classifyHost(url, rootOrigin)) { + case "instance": + return await federation.fetch(request, { + onNotFound: notFound, + onNotAcceptable: notFound, + contextData: undefined, + }); + case "misdirected": + // Below the root domain but deeper than the single label an instance + // occupies, so nothing here will ever answer. Saying so is more use + // to whoever misconfigured the DNS than a bare 404 would be. + return misdirected(); + default: + return await serveControlSurface(request); + } + }; +} + +function notFound(): Response { + return new Response("Not found.", { + headers: { "content-type": "text/plain; charset=utf-8" }, + status: 404, + }); +} + +function invalidHost(): Response { + // RFC 9110 section 7.2 asks for 400 when the Host field value is invalid, + // which covers both a host `URL` rejects and one the adapter replaced. + return new Response("Bad request: invalid Host header.", { + headers: { "content-type": "text/plain; charset=utf-8" }, + status: 400, + }); +} + +function misdirected(): Response { + return new Response( + "Misdirected request: this server does not serve that host.", + { + headers: { "content-type": "text/plain; charset=utf-8" }, + status: 421, + }, + ); +} + +/** + * Finds local instances whose host does not sit one label below the given root + * origin, which is what happens when a deployment's root origin is changed + * after instances already exist. + * @param db The database to look in. + * @param rootOrigin The configured root origin. + * @returns The hosts that are no longer reachable, in no particular order. + */ +export async function findStrandedInstances( + db: Database, + rootOrigin: URL, +): Promise { + const instances = await db.query.instances.findMany({ + columns: { host: true }, + where: { localId: { isNotNull: true } }, + }); + return instances + .map(({ host }) => host) + .filter((host) => { + // A stored host need not be a parseable authority at all: an older, + // laxer rule may have let one through, and whether a given host parses + // can even move with the runtime's ICU. A startup check is the last + // place that should throw over it. + const url = `${rootOrigin.protocol}//${host}`; + if (!URL.canParse(url)) return true; + return classifyHost(new URL(url), rootOrigin) !== "instance"; + }); +} + +/** + * Warns about local instances the configured root origin no longer covers. + * + * Nothing is repaired. An instance's host is woven into the actor URIs that + * the rest of the fediverse has already seen and stored, so rewriting it would + * break exactly the federation it was meant to fix. Say what is wrong, and + * leave the decision to a human. + * @param db The database to look in. + * @param rootOrigin The configured root origin. + */ +export async function warnAboutStrandedInstances( + db: Database, + rootOrigin: URL, +): Promise { + const stranded = await findStrandedInstances(db, rootOrigin); + if (stranded.length < 1) return; + logger.warn( + "{count} local instance(s) are hosted outside the configured root " + + "origin {rootOrigin}, and are no longer reachable: {hosts}. Their " + + "actor URIs already name those hosts, so nothing has been changed.", + { count: stranded.length, hosts: stranded, rootOrigin: rootOrigin.origin }, + ); +} + +const logger = getLogger(["drfed", "instances"]); diff --git a/packages/drfed/src/valueparser.test.ts b/packages/drfed/src/valueparser.test.ts new file mode 100644 index 0000000..3f79c0a --- /dev/null +++ b/packages/drfed/src/valueparser.test.ts @@ -0,0 +1,153 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from "node:assert/strict"; + +import { rootOrigin } from "@drfed/drfed/valueparser"; +import { describe, it } from "@logtape/testing-node/autoload"; + +const parser = rootOrigin(); + +function parse(input: string) { + return parser.parse(input); +} + +function parsed(input: string): string { + const result = parse(input); + assert.ok(result.success, `expected ${input} to parse`); + return result.value.origin; +} + +describe("rootOrigin()", () => { + // Normalization is Optique's; these cases pin the behaviour this deployment + // option depends on rather than re-testing the library. + it("normalizes spellings of the same origin", () => { + for (const input of [ + "https://drfed.net", + "https://drfed.net/", + "HTTPS://DrFed.NET", + "https://drfed.net/path?query=1#fragment", + "https://drfed.net:443/", + "https://drfed.net.", + ]) { + assert.equal(parsed(input), "https://drfed.net", input); + } + }); + + it("keeps a non-default port", () => { + assert.equal( + parsed("http://drfed.localhost:8888"), + "http://drfed.localhost:8888", + ); + assert.equal(parsed("http://drfed.net:80/"), "http://drfed.net"); + }); + + it("accepts only HTTP and HTTPS", () => { + assert.equal(parsed("http://drfed.net"), "http://drfed.net"); + assert.equal(parse("ftp://drfed.net").success, false); + assert.equal(parse("mailto:someone@drfed.net").success, false); + }); + + it("rejects input that is not an absolute URL", () => { + for (const input of ["", "drfed.net", "/path", "https://"]) { + assert.equal(parse(input).success, false, input); + } + }); + + it("rejects credentials rather than stripping them", () => { + // Asserted on the parse path as well as the validate one, because the two + // reach the wrapped parser by different routes and only the DrFed rules + // are shared between them. + assert.equal(parse("https://user:pw@drfed.net/").success, false); + }); + + // The two rules below are DrFed's own, not Optique's. + it("rejects an IP address, which cannot take a subdomain", () => { + for (const input of [ + "http://127.0.0.1:8888", + "http://[::1]:8888", + "http://[2001:db8::1]", + // The URL parser canonicalizes every other IPv4 spelling into the + // dotted quad, so these name the same host as 127.0.0.1. + "http://0x7f.1", + "http://2130706433", + ]) { + assert.equal(parse(input).success, false, input); + } + // A name that merely begins with digits is still a name. + assert.equal(parsed("https://1.drfed.net"), "https://1.drfed.net"); + }); + + it("rejects a host name longer than a domain name may be", () => { + // 253 octets is the limit, and the mail library refuses to build a message + // whose sender domain exceeds it, so accepting one here would only defer + // the failure to every login attempt. + const label = "a".repeat(63); + const longest = [label, label, label, "a".repeat(61)].join("."); + assert.equal(longest.length, 253); + assert.equal(parsed(`https://${longest}`), `https://${longest}`); + assert.equal(parse(`https://${longest}a`).success, false); + // The root zone's dot is stripped before the length is measured. + assert.equal(parsed(`https://${longest}.`), `https://${longest}`); + }); + + it("offers a placeholder that is a fresh value each time", () => { + // Spreading the wrapped parser would have frozen one shared `URL` here. + const first = parser.placeholder; + const second = parser.placeholder; + assert.notEqual(first, second); + assert.equal(first.href, second.href); + }); + + it("validates a fallback value as strictly as it parses one", () => { + // Optique checks a value that came from somewhere other than the command + // line, such as an environment variable, through `validate()`. Without + // it the check falls back to `format()` then `parse()`, and `format()` + // emits only the origin, so a value carrying credentials would be + // laundered into an accepted one. + assert.ok(parser.validate); + for (const [input, valid] of [ + ["https://drfed.net/", true], + ["http://drfed.localhost:8888/", true], + // Rejected by the wrapped parser. + ["https://u:p@drfed.net/", false], + ["ftp://drfed.net/", false], + // Rejected by the two rules this wrapper adds. + ["http://127.0.0.1:8888/", false], + [ + `https://${"a".repeat(63)}.${"a".repeat(63)}.${"a".repeat(63)}.${"a".repeat(62)}/`, + false, + ], + ] as const) { + assert.equal(parser.validate(new URL(input)).success, valid, input); + } + }); + + it("round-trips through format() and normalize()", () => { + const result = parse("https://drfed.net/path"); + assert.ok(result.success); + assert.equal(parser.format(result.value), "https://drfed.net"); + assert.equal( + parser.normalize?.(new URL("https://drfed.net/path")).href, + "https://drfed.net/", + ); + }); + + it("uses ORIGIN as the default metavar", () => { + assert.equal(rootOrigin().metavar, "ORIGIN"); + assert.equal(rootOrigin({ metavar: "ROOT" }).metavar, "ROOT"); + }); +}); diff --git a/packages/drfed/src/valueparser.ts b/packages/drfed/src/valueparser.ts new file mode 100644 index 0000000..cc07173 --- /dev/null +++ b/packages/drfed/src/valueparser.ts @@ -0,0 +1,138 @@ +// 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 { message } from "@optique/core/message"; +import { + type NonEmptyString, + type ValueParser, + type ValueParserResult, + ensureNonEmptyString, + origin, +} from "@optique/core/valueparser"; + +/** + * A host name that the WHATWG URL parser has canonicalized into a dotted-quad + * IPv4 address. Matching the canonical form is enough, because the parser + * turns every other spelling (`0x7f.1`, `2130706433`, …) into it. + */ +const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/u; + +/** + * The greatest length a domain name may have, in octets. + */ +const MAX_HOSTNAME_LENGTH = 253; + +/** + * Options for the {@link rootOrigin} value parser. + */ +export interface RootOriginOptions { + /** + * The metavariable name for this parser. This is used in help messages to + * indicate what kind of value this parser expects. + * @default `"ORIGIN"` + */ + readonly metavar?: NonEmptyString; +} + +/** + * Applies the two rules that are DrFed's own to an origin the wrapped parser + * has already accepted, so that `parse()` and `validate()` cannot come to + * different conclusions about the same value. + * @param result The inner parser's successful result. + * @param input What to name in an error message. + * @returns The result unchanged, or a failure explaining which rule it broke. + */ +function check( + result: { readonly success: true; readonly value: URL }, + input: string, +): ValueParserResult { + const { hostname } = result.value; + if (hostname.startsWith("[") || IPV4_PATTERN.test(hostname)) { + return { + success: false, + error: message`${input} names an IP address rather than a domain, which cannot take a subdomain.`, + }; + } + if (hostname.length > MAX_HOSTNAME_LENGTH) { + return { + success: false, + error: message`The host name of ${input} is longer than the ${String(MAX_HOSTNAME_LENGTH)} characters a domain name may have.`, + }; + } + return result; +} + +/** + * Creates a {@link ValueParser} for the origin a DrFed deployment is served + * from. + * + * Parsing and normalization are Optique's `origin()`: the input is canonical- + * ized rather than rejected, so `HTTPS://DrFed.NET/` and + * `https://drfed.net:443/path` both come out as `https://drfed.net`, and the + * root zone's trailing dot is stripped. Only HTTP and HTTPS are accepted. + * + * Two further constraints are DrFed's own, because this origin is not just any + * origin. Every instance is a subdomain of it, and `foo.127.0.0.1` is not a + * host name at all, so an IP address is refused. Login mail is sent from + * `noreply@` at its host name, and the mail library will not build a message + * whose domain runs past the 253 octets DNS allows, so a longer host is + * refused too. Both are caught here, at the boundary, rather than surfacing + * later as an instance nobody can address or a login that never arrives. + * @param options Configuration options for the parser. + * @returns A {@link ValueParser} producing the deployment's root origin. + */ +export function rootOrigin( + options: RootOriginOptions = {}, +): ValueParser<"sync", URL> { + const metavar = options.metavar ?? "ORIGIN"; + ensureNonEmptyString(metavar); + const inner = origin({ + allowedProtocols: ["http:", "https:"], + metavar, + }); + return { + mode: "sync", + metavar, + // Delegated one member at a time rather than spread: `placeholder` is a + // getter on the parser being wrapped, and spreading would call it once and + // hand every caller the same mutable `URL`. + get placeholder(): URL { + return inner.placeholder; + }, + parse(input: string) { + const result = inner.parse(input); + return result.success ? check(result, input) : result; + }, + // Optique validates a fallback value, such as one from an environment + // variable, through this rather than through `parse()`. Without it the + // check degrades to `format()` followed by `parse()`, and since `format()` + // emits only the origin, a value carrying credentials would be laundered + // into an accepted one. + validate(value: URL) { + const result = inner.validate?.(value) ?? { success: true, value }; + return result.success ? check(result, value.href) : result; + }, + format(value: URL): string { + return inner.format(value); + }, + normalize(value: URL): URL { + return inner.normalize?.(value) ?? value; + }, + suggest(prefix: string) { + return inner.suggest?.(prefix) ?? []; + }, + }; +} diff --git a/packages/graphql/package.json b/packages/graphql/package.json index ce60ce6..9457ba5 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -69,6 +69,10 @@ "./schema": { "types": "./dist/schema.d.mts", "default": "./dist/schema.mjs" + }, + "./origin": { + "types": "./dist/origin.d.mts", + "default": "./dist/origin.mjs" } }, "files": [ @@ -83,7 +87,8 @@ "src/builder.ts", "src/federation.ts", "src/instance.ts", - "src/schema.ts" + "src/schema.ts", + "src/origin.ts" ], "dts": { "sourcemap": true, diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 4deae76..74e0b14 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -20,6 +20,7 @@ import assert from "node:assert/strict"; import { type Database, schema } from "@drfed/models"; import { describe, it } from "@logtape/testing-node/autoload"; +import { eq } from "drizzle-orm/sql/expressions"; import { hashSecret } from "./auth/hash.ts"; import { withTestHarness } from "./harness.test.ts"; @@ -156,6 +157,61 @@ describe("Mutation.generateActors", () => { ); }); }); + + it("builds actor URIs from the stored host and the root scheme", async () => { + // The stored host deliberately disagrees with what recomposing + // `${slug}.${root}` would produce, and the root origin is HTTP on a + // non-default port. Both are visible in the generated URIs only if the + // resolver reads `instances.host` and takes the scheme from the root + // origin, rather than assembling `https://${slug}.${root}` itself. + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await db + .update(schema.instances) + .set({ host: "renamed.drfed.localhost:8888" }) + .where(eq(schema.instances.id, localInstanceId)); + + const response = await post( + { + query: generateActorsMutation, + variables: { + instance: globalId("Instance", localInstanceId), + size: 1, + }, + }, + auth, + ); + + assert.equal(response.status, ok); + const body = await response.json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.generateActors.resultType, "CreateActorsSuccess"); + const [generated] = body.data.generateActors.actors; + assert.equal( + generated.iri, + `http://renamed.drfed.localhost:8888/users/${generated.uuid}`, + ); + + const [actor] = await db.select().from(schema.actors); + assert.ok(actor != null); + for (const url of [ + actor.iri, + actor.inboxUrl, + actor.outboxUrl, + actor.followersUrl, + actor.followingUrl, + actor.featuredUrl, + actor.profileUrl, + ]) { + assert.ok(url != null); + assert.equal( + new URL(url).origin, + "http://renamed.drfed.localhost:8888", + url, + ); + } + }, new URL("http://drfed.localhost:8888")); + }); }); describe("Actor", () => { diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 91dfdb3..7774d63 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -245,7 +245,7 @@ builder.mutationFields((t) => ({ // Find the instance that the account is included const [instance] = await tx .select({ - slug: schema.localInstances.slug, + host: schema.instances.host, maxActors: schema.localInstances.maxActors, }) .from(schema.instanceMembers) @@ -273,8 +273,10 @@ builder.mutationFields((t) => ({ message: "Can't find the instance.", }; } - const { slug, maxActors } = instance; - const host = `${slug}.${ctx.root}`; + // Read the stored authority rather than recomposing it from the + // slug, so that actor URIs cannot drift from the instance the rest of + // the fediverse already knows. + const { host, maxActors } = instance; const currActors = await tx.$count( schema.actors, eq(schema.actors.instanceId, targetInstanceId), @@ -292,7 +294,7 @@ builder.mutationFields((t) => ({ } // Create actors const fedCtx = ctx.federation.createContext( - new URL(`https://${host}`), + new URL(`${ctx.rootOrigin.protocol}//${host}`), undefined, ); const ids = Array.from({ length: size }, () => ({ id: uuid() })); diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index 804e7bc..367d04a 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -133,6 +133,74 @@ async function requestLoginCode( } describe("email authentication", () => { + it("sends login mail from the deployment's own domain", async () => { + // A From address at drfed.org would fail the SPF and DMARC checks of every + // deployment but the project's own, so the default has to follow the root + // origin rather than the project. + await withTestHarness(async ({ db, mailer, post }) => { + await db + .insert(schema.accounts) + .values({ id: accountId, email, name: "Login Test" }); + + await requestLoginCode(post, mailer); + + const [message] = mailer.getSentMessages(); + ok(message); + equal(message.sender.address, "noreply@drfed.example"); + }, new URL("https://drfed.example")); + }); + + it("keeps the port out of the derived sender", async () => { + // The address is derived from the host name, not the authority: a + // development deployment on a non-default port must not send from + // `noreply@drfed.localhost:8888`. + await withTestHarness(async ({ db, mailer, post }) => { + await db + .insert(schema.accounts) + .values({ id: accountId, email, name: "Login Test" }); + + await requestLoginCode(post, mailer); + + const [message] = mailer.getSentMessages(); + ok(message); + equal(message.sender.address, "noreply@drfed.localhost"); + }, new URL("http://drfed.localhost:8888")); + }); + + it("keeps the root zone's dot out of the derived sender", async () => { + // A trailing dot is not valid in an email address, so a root origin + // written with one must not leak it into the From header. + await withTestHarness(async ({ db, mailer, post }) => { + await db + .insert(schema.accounts) + .values({ id: accountId, email, name: "Login Test" }); + + await requestLoginCode(post, mailer); + + const [message] = mailer.getSentMessages(); + ok(message); + equal(message.sender.address, "noreply@drfed.example"); + }, new URL("https://drfed.example.")); + }); + + it("sends login mail from an explicitly configured address", async () => { + await withTestHarness( + async ({ db, mailer, post }) => { + await db + .insert(schema.accounts) + .values({ id: accountId, email, name: "Login Test" }); + + await requestLoginCode(post, mailer); + + const [message] = mailer.getSentMessages(); + ok(message); + equal(message.sender.address, "postmaster@mail.example"); + }, + new URL("https://drfed.example"), + "postmaster@mail.example", + ); + }); + it("does not let the login grant reach another account's email", async () => { await withTestHarness(async ({ db, mailer, post }) => { await db.insert(schema.accounts).values([ diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index a792ce8..5b98d3d 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -56,7 +56,7 @@ export interface ServerContext { readonly mailer: Transport; /** - * Email address to send. + * The address login mail is sent from. */ readonly emailFrom: string; @@ -66,9 +66,11 @@ export interface ServerContext { readonly loginOrigins: ReadonlySet; /** - * Root domain. + * The root origin of this deployment, which every instance's subdomain is + * derived from. It is always equal to its own origin, i.e. it carries no + * path, query or credentials. */ - readonly root: string; + readonly rootOrigin: URL; /** * The federation instance. diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index dc44087..bce194d 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -18,12 +18,15 @@ import assert from "node:assert/strict"; import { createYogaServer } from "@drfed/graphql"; import createFederation, { buildFederation } from "@drfed/graphql/federation"; +import { schema } from "@drfed/models"; +import { uuidV7 } from "@drfed/models/uuid"; import { MemoryKvStore } from "@fedify/fedify"; import { describe, it } from "@logtape/testing-node/autoload"; import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; const origin = new URL("https://drfed.test"); +const activityJson = "application/activity+json"; describe("createFederation()", () => { it("registers the actor URI layout", async () => { @@ -60,6 +63,86 @@ describe("createFederation()", () => { }); }); + it("resolves an actor when the Host names the authority differently", async () => { + // A reverse proxy may forward a `Host` that writes out the default port, + // so `Context.host` reads `demo.drfed.test:443` while the stored host is + // `demo.drfed.test`. Without canonicalizing the lookup key, every actor + // on that instance answers 404 to such a request. + await withTemporaryDatabase(async (db) => { + const localInstanceId = uuidV7(); + const instanceId = uuidV7(); + const localActorId = uuidV7(); + const actorId = uuidV7(); + const base = "https://demo.drfed.test"; + await db.insert(schema.localInstances).values({ + id: localInstanceId, + slug: "demo", + expires: new Date(Date.now() + 86_400_000), + }); + await db.insert(schema.instances).values({ + id: instanceId, + localId: localInstanceId, + host: "demo.drfed.test", + }); + await db.insert(schema.localActors).values({ id: localActorId }); + await db.insert(schema.actors).values({ + id: actorId, + localId: localActorId, + type: "Person", + username: "alice", + instanceId, + iri: `${base}/users/${actorId}`, + inboxUrl: `${base}/users/${actorId}/inbox`, + outboxUrl: `${base}/users/${actorId}/outbox`, + }); + + const federation = await createFederation(db, { + kv: new MemoryKvStore(), + }); + const fetchAs = async (host: string): Promise => { + const response = await federation.fetch( + // HTTP, so that `URL` keeps a port of 443 instead of eliding it. + new Request(`http://${host}/users/${actorId}`, { + headers: { accept: activityJson }, + }), + { + contextData: undefined, + onNotFound: () => new Response(null, { status: 404 }), + onNotAcceptable: () => new Response(null, { status: 406 }), + }, + ); + return response.status; + }; + + assert.equal(await fetchAs("demo.drfed.test"), 200); + assert.equal(await fetchAs("demo.drfed.test:443"), 200); + // A genuinely different authority still resolves to nothing. + assert.equal(await fetchAs("demo.drfed.test:9999"), 404); + assert.equal(await fetchAs("other.drfed.test"), 404); + + // WebFinger resolves the handle through a second lookup of its own, so + // it needs the same tolerance; without it, `acct:` on the port-carrying + // spelling answers 404 while the actor dispatcher answers 200. + const webFingerAs = async (host: string): Promise => { + const resource = encodeURIComponent(`acct:alice@${host}`); + const response = await federation.fetch( + new Request( + `http://${host}/.well-known/webfinger?resource=${resource}`, + ), + { + contextData: undefined, + onNotFound: () => new Response(null, { status: 404 }), + onNotAcceptable: () => new Response(null, { status: 406 }), + }, + ); + return response.status; + }; + assert.equal(await webFingerAs("demo.drfed.test"), 200); + assert.equal(await webFingerAs("demo.drfed.test:443"), 200); + assert.equal(await webFingerAs("demo.drfed.test:9999"), 404); + }); + }); + it("builds independent instances from one builder", async () => { await withTemporaryDatabase(async (db) => { const builder = buildFederation(db); @@ -75,7 +158,11 @@ describe("createYogaServer()", () => { await withTestHarness(({ db, mailer, federation }) => { const loginOrigins = new Set(["https://drfed.test"]); assert.doesNotThrow(() => - createYogaServer(db, federation, { mailer, loginOrigins }), + createYogaServer(db, federation, { + mailer, + loginOrigins, + rootOrigin: new URL("https://drfed.test"), + }), ); }); }); diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 614b78d..fbb1dc0 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -38,6 +38,8 @@ import { import { getLogger } from "@logtape/logtape"; import { validate as validateUuid } from "uuid"; +import { canonicalizeAuthority } from "./origin.ts"; + /** * The vocabulary object types that DrFed serves as actors. */ @@ -66,7 +68,7 @@ async function findLocalActor( where: { id: identifier as Uuid, localId: { isNotNull: true }, - instance: { host: ctx.host }, + instance: { host: canonicalizeAuthority(ctx.host) }, }, }); return actor ?? null; @@ -107,7 +109,7 @@ export function buildFederation(db: Database): FederationBuilder { where: { username, localId: { isNotNull: true }, - instance: { host: ctx.host }, + instance: { host: canonicalizeAuthority(ctx.host) }, deleted: { isNull: true }, }, }); diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index dd6a44e..6a68e94 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -161,17 +161,29 @@ export async function withTemporaryDatabase( * ``` * * @param callback A function that receives the test harness. + * @param rootOrigin The deployment's root origin. Defaults to + * `https://drfed.org`; pass one carrying a port to exercise + * a development-style deployment. + * @param emailFrom The address login mail is sent from. Left unset by + * default, so that the derived one is exercised. * @returns The callback's resolved value. */ export async function withTestHarness( // oxlint-disable-next-line promise/prefer-await-to-callbacks callback: (harness: TestHarness) => Promise | T, + rootOrigin: URL = new URL("https://drfed.org"), + emailFrom?: string, ): Promise> { return await withTemporaryDatabase(async (db) => { const mailer = new MockTransport(); const federation = await createFederation(db, { kv: new MemoryKvStore() }); const loginOrigins = new Set(["https://drfed.test"]); - const yoga = createYogaServer(db, federation, { mailer, loginOrigins }); + const yoga = createYogaServer(db, federation, { + mailer, + loginOrigins, + rootOrigin, + emailFrom, + }); const fetch: TestFetch = yoga.fetch.bind(yoga); const harness: TestHarness = { diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 014870f..9786876 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -28,6 +28,7 @@ import { import { hashSecret } from "./auth/hash.ts"; import type { ServerContext, UserContext } from "./builder.ts"; +import { canonicalHostname } from "./origin.ts"; import { schema } from "./schema.ts"; /** * Options for Yoga server. @@ -39,9 +40,10 @@ export interface YogaServerOptions { mailer?: Transport | undefined; /** - * Email address to send. + * The address login mail is sent from. Defaults to `noreply@` at the root + * origin's host name. */ - emailFrom?: string; + emailFrom?: string | undefined; /** * Origin list for login. @@ -49,9 +51,13 @@ export interface YogaServerOptions { loginOrigins: ReadonlySet; /** - * Root domain. + * The root origin of this deployment. Every instance is served from a + * subdomain of it, so `https://drfed.net` puts the instance `foo-bar` at + * `https://foo-bar.drfed.net`. Required: there is no sensible default for + * installed software, and guessing one would silently hand out subdomains + * of somebody else's domain. */ - root?: string | undefined; + rootOrigin: URL; } /** @@ -105,9 +111,13 @@ const fillOptions = ( opt: YogaServerOptions, ): Omit => ({ mailer: opt.mailer ?? mockTransport(), - emailFrom: opt.emailFrom ?? "noreply@drfed.org", + // Derived from the deployment's own domain rather than the project's, so + // that the operator's mail server is authorized to send it. A From address + // at drfed.org would fail the SPF and DMARC checks of every deployment but + // the project's own, and the login mail would be rejected or junked. + emailFrom: opt.emailFrom ?? `noreply@${canonicalHostname(opt.rootOrigin)}`, loginOrigins: opt.loginOrigins, - root: opt.root ?? "drfed.org", + rootOrigin: opt.rootOrigin, }); const getAccessToken = (headers: Headers) => diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index f8726e5..6b4814c 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -63,6 +63,21 @@ const remoteInstanceQuery = ` } `; +const remoteInstanceUrlQuery = ` + query RemoteInstanceUrl($uuid: UUID!) { + accountByUuid(uuid: $uuid) { + instances { + edges { + node { + host + url + } + } + } + } + } +`; + const localInstanceQuery = ` query LocalInstance($uuid: UUID!) { accountByUuid(uuid: $uuid) { @@ -237,6 +252,7 @@ const createInstanceMutation = ` ... on Instance { uuid host + url } ... on CreateInstanceError { type @@ -247,6 +263,143 @@ const createInstanceMutation = ` `; describe("Mutation.createInstance", () => { + it("exposes the instance's absolute origin", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await authenticate(db); + const response = await post( + { query: createInstanceMutation, variables: { slug: "my-instance" } }, + auth, + ); + + assert.equal(response.status, ok); + const body = await response.json(); + assert.equal(body.errors, undefined); + assert.equal( + body.data.createInstance.url, + "https://my-instance.drfed.org", + ); + }); + }); + + it("gives a development instance an http URL carrying its port", async () => { + // The frontend links to an instance's endpoints from this field, so it + // has to name somewhere that actually answers. + await withTestHarness(async ({ db, post }) => { + const auth = await authenticate(db); + const response = await post( + { query: createInstanceMutation, variables: { slug: "my-instance" } }, + auth, + ); + + assert.equal(response.status, ok); + const body = await response.json(); + assert.equal(body.errors, undefined); + assert.equal( + body.data.createInstance.url, + "http://my-instance.drfed.localhost:8888", + ); + }, new URL("http://drfed.localhost:8888")); + }); + + it("carries the root origin's port into the instance host", async () => { + // A development deployment is reached on a non-default port, and the host + // has to include it: that authority is what Fedify's `Context.host` + // reports, and the federation dispatchers look the instance up by it. + await withTestHarness(async ({ db, post }) => { + const auth = await authenticate(db); + const response = await post( + { query: createInstanceMutation, variables: { slug: "my-instance" } }, + auth, + ); + + assert.equal(response.status, ok); + const body = await response.json(); + assert.equal(body.errors, undefined); + assert.equal( + body.data.createInstance.host, + "my-instance.drfed.localhost:8888", + ); + }, new URL("http://drfed.localhost:8888")); + }); + + it("rejects a slug that is not a usable domain name label", async () => { + // The database constraint would reject these too, but as an unhandled + // query error rather than one of the results the mutation declares. + await withTestHarness(async ({ db, post }) => { + const auth = await authenticate(db); + const slugs = ["-foo", "foo-", "abc", "Foo-bar", "ab--cd"]; + const results = await Promise.all( + slugs.map(async (slug) => { + const response = await post( + { query: createInstanceMutation, variables: { slug } }, + auth, + ); + return { slug, status: response.status, body: await response.json() }; + }), + ); + for (const { slug, status, body } of results) { + assert.equal(status, ok, slug); + assert.equal(body.errors, undefined, slug); + assert.equal( + body.data.createInstance.__typename, + "CreateInstanceError", + slug, + ); + assert.equal(body.data.createInstance.type, "InvalidSlug", slug); + } + // Nothing was created along the way. + assert.equal((await db.select().from(schema.instances)).length, 0); + }); + }); + + it("refuses a slug whose composed host this runtime cannot parse", async () => { + // `xn--a` carries the A-label prefix without being decodable Punycode. + // Whether that composes a parseable host is answered by the runtime's own + // ICU, so the expectation is taken from the same source the guard reads + // rather than hardcoded; the point is that the two agree. + const slug = "xn--a"; + const parses = URL.canParse(`https://${slug}.drfed.org`); + await withTestHarness(async ({ db, post }) => { + const auth = await authenticate(db); + const response = await post( + { query: createInstanceMutation, variables: { slug } }, + auth, + ); + + assert.equal(response.status, ok); + const body = await response.json(); + assert.equal(body.errors, undefined); + if (parses) { + assert.equal(body.data.createInstance.__typename, "Instance"); + assert.equal(body.data.createInstance.host, `${slug}.drfed.org`); + return; + } + assert.equal(body.data.createInstance.__typename, "CreateInstanceError"); + assert.equal(body.data.createInstance.type, "InvalidSlug"); + // The message has to describe the condition that actually failed, not + // the shape rules this slug satisfies. + assert.match(body.data.createInstance.message, /cannot parse/u); + assert.equal((await db.select().from(schema.instances)).length, 0); + }); + }); + + it("accepts a decodable xn-- slug", async () => { + // `xn--3e0b707e` is the Punycode encoding of `한국`; DrFed exists to debug + // federation, and IDN host names are one of the things that break it. + await withTestHarness(async ({ db, post }) => { + const auth = await authenticate(db); + const response = await post( + { query: createInstanceMutation, variables: { slug: "xn--3e0b707e" } }, + auth, + ); + assert.equal(response.status, ok); + const body = await response.json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createInstance.__typename, "Instance"); + assert.equal(body.data.createInstance.host, "xn--3e0b707e.drfed.org"); + }); + }); + it("creates an instance and adds the viewer as a member", async () => { await withTestHarness(async ({ db, post }) => { const auth = await authenticate(db); @@ -810,6 +963,26 @@ describe("LocalInstance authorization", () => { }); describe("Remote instance", () => { + it("gives a remote instance an https URL", async () => { + // A remote host is reached over HTTPS whatever scheme this deployment + // happens to serve itself on, so the root origin must not leak into it. + await withTestHarness(async ({ db, post }) => { + await seedRemoteInstance(db); + const auth = await createSession(db); + + const response = await post( + { query: remoteInstanceUrlQuery, variables: { uuid: accountId } }, + auth, + ); + + assert.equal(response.status, ok); + const body = await response.json(); + assert.equal(body.errors, undefined); + const [edge] = body.data.accountByUuid.instances.edges; + assert.equal(edge.node.url, "https://remote.example.com"); + }, new URL("http://drfed.localhost:8888")); + }); + it("returns a created remote instance", async () => { await withTestHarness(async ({ db, post }) => { await seedRemoteInstance(db); diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index 31d2335..ed42f1b 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -15,11 +15,13 @@ // along with this program. If not, see . import { schema } from "@drfed/models"; +import { isValidSlug } from "@drfed/models/slug"; import { uuidV7 as uuid } from "@drfed/models/uuid"; import { DrizzleQueryError } from "drizzle-orm"; import { eq } from "drizzle-orm/sql/expressions"; import builder, { type DrFedObjectRef } from "./builder.ts"; +import { instanceHost } from "./origin.ts"; const InstanceRef = builder.drizzleNode("instances", { name: "Instance", @@ -35,6 +37,21 @@ const InstanceRef = builder.drizzleNode("instances", { type: "UUID", }), host: t.exposeString("host"), + url: t.string({ + description: + "The absolute origin the `Instance` is served at, e.g. " + + "`https://foo-bar.drfed.net`. Local instances follow this " + + "deployment's root origin, so a development deployment yields an " + + "`http:` URL carrying its port; remote instances are always `https:`.", + resolve(instance, _args, ctx) { + // Built by concatenation rather than through `URL`, so that a host + // which is not a parseable authority yields a useless string instead + // of throwing in the middle of a query. + const scheme = + instance.localId == null ? "https:" : ctx.rootOrigin.protocol; + return `${scheme}//${instance.host}`; + }, + }), created: t.expose("created", { type: "DateTime", description: "The creation date/time of the `Instance`.", @@ -144,7 +161,7 @@ builder.queryFields((t) => ({ export const CreateInstanceErrorType = builder.enumType( "CreateInstanceErrorType", { - values: ["SlugAlreadyTaken", "TooManyInstances"] as const, + values: ["InvalidSlug", "SlugAlreadyTaken", "TooManyInstances"] as const, }, ); @@ -193,8 +210,8 @@ builder.mutationFields((t) => ({ type: "String", required: true, description: - "A unique instance slug, which will be a part of the instance " + - "domain name (e.g., `slug.drfed.net`).", + "A unique instance slug, which becomes the leftmost label of the " + + "instance's domain name, e.g. `slug` in `slug.example.com`.", }), }, async resolve(_query, { slug }, ctx) { @@ -204,6 +221,38 @@ builder.mutationFields((t) => ({ throw new Error("You must be authenticated to create an instance."); } const { account } = ctx; + // Checked here rather than left to the database constraint, which + // surfaces as an unhandled query error. The second half catches what + // the shape rules cannot: an `xn--` label that is not decodable + // Punycode composes a host this runtime refuses to parse, and an + // instance nothing can address is worse than a rejected slug. Whether + // a label decodes is answered by the runtime's own ICU, so it is asked + // of the composed host here rather than baked into `isValidSlug()`, + // which every deployment has to agree on. + // Composed as a string, not through `instanceOrigin()`, which builds a + // `URL` and would throw here rather than answer. + const host = instanceHost(ctx.rootOrigin, slug); + if (!isValidSlug(slug)) { + return { + type: "InvalidSlug" as const, + message: + `The slug ${JSON.stringify(slug)} is not usable as a ` + + "domain name label. It must be 4 to 63 characters of lowercase " + + "letters, digits and hyphens, and start and end with a letter " + + "or a digit.", + }; + } + if (!URL.canParse(`${ctx.rootOrigin.protocol}//${host}`)) { + // Reached by a slug that satisfies every rule above, so it needs its + // own message: the shape text would name conditions this slug meets. + return { + type: "InvalidSlug" as const, + message: + `The slug ${JSON.stringify(slug)} composes the host name ` + + `${JSON.stringify(host)}, which this server cannot parse. A ` + + "slug beginning with `xn--` has to be decodable Punycode.", + }; + } let tooManyInstances = false; try { return await ctx.db.transaction(async (tx) => { @@ -220,7 +269,6 @@ builder.mutationFields((t) => ({ if (local == null) { throw new Error("Failed to create local instance."); } - const host = `${slug}.${ctx.root}`; const [instance] = await tx .insert(schema.instances) .values({ id: uuid(), localId: local.id, host }) diff --git a/packages/graphql/src/origin.test.ts b/packages/graphql/src/origin.test.ts new file mode 100644 index 0000000..06bba66 --- /dev/null +++ b/packages/graphql/src/origin.test.ts @@ -0,0 +1,271 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from "node:assert/strict"; + +import { + canonicalHostname, + canonicalizeAuthority, + classifyHost, + instanceHost, + instanceOrigin, +} from "@drfed/graphql/origin"; +import { describe, it } from "@logtape/testing-node/autoload"; + +const production = new URL("https://drfed.net"); +const development = new URL("http://drfed.localhost:8888"); + +describe("instanceHost()", () => { + it("prefixes the slug to the root authority", () => { + assert.equal(instanceHost(production, "foo-bar"), "foo-bar.drfed.net"); + }); + + it("carries a non-default port into the instance authority", () => { + assert.equal( + instanceHost(development, "foo-bar"), + "foo-bar.drfed.localhost:8888", + ); + }); + + it("drops a port that is the scheme's default", () => { + assert.equal( + instanceHost(new URL("https://drfed.net:443"), "foo-bar"), + "foo-bar.drfed.net", + ); + }); + + it("drops the root zone's trailing dot", () => { + assert.equal( + instanceHost(new URL("https://drfed.net."), "foo-bar"), + "foo-bar.drfed.net", + ); + assert.equal( + instanceHost(new URL("http://drfed.localhost.:8888"), "foo-bar"), + "foo-bar.drfed.localhost:8888", + ); + }); +}); + +describe("canonicalHostname()", () => { + it("strips the root zone's trailing dot", () => { + assert.equal(canonicalHostname(new URL("https://drfed.net.")), "drfed.net"); + assert.equal(canonicalHostname(new URL("https://drfed.net")), "drfed.net"); + assert.equal( + canonicalHostname(new URL("http://drfed.localhost.:8888")), + "drfed.localhost", + ); + assert.equal(canonicalHostname(new URL("http://[::1]:8888")), "[::1]"); + }); +}); + +describe("canonicalizeAuthority()", () => { + it("leaves a canonical authority alone", () => { + assert.equal(canonicalizeAuthority("demo.drfed.net"), "demo.drfed.net"); + assert.equal( + canonicalizeAuthority("demo.drfed.localhost:8888"), + "demo.drfed.localhost:8888", + ); + }); + + it("drops either of the web's default ports", () => { + // A reverse proxy may forward a `Host` naming the default port verbatim. + assert.equal(canonicalizeAuthority("demo.drfed.net:443"), "demo.drfed.net"); + assert.equal(canonicalizeAuthority("demo.drfed.net:80"), "demo.drfed.net"); + }); + + it("drops the root zone's dot", () => { + assert.equal(canonicalizeAuthority("demo.drfed.net."), "demo.drfed.net"); + }); + + it("keeps any other port", () => { + assert.equal( + canonicalizeAuthority("demo.drfed.net:8443"), + "demo.drfed.net:8443", + ); + }); + + it("agrees with what instanceHost() composes", () => { + for (const root of [ + production, + development, + new URL("https://drfed.net."), + new URL("https://drfed.net:443"), + ]) { + const host = instanceHost(root, "demo"); + assert.equal(canonicalizeAuthority(host), host); + } + }); + + it("returns anything it cannot parse unchanged", () => { + assert.equal(canonicalizeAuthority("_invalid_"), "_invalid_"); + assert.equal(canonicalizeAuthority(""), ""); + }); +}); + +describe("instanceOrigin()", () => { + it("keeps the root origin's scheme", () => { + assert.equal( + instanceOrigin(production, "foo-bar").origin, + "https://foo-bar.drfed.net", + ); + assert.equal( + instanceOrigin(development, "foo-bar").origin, + "http://foo-bar.drfed.localhost:8888", + ); + }); + + it("agrees with instanceHost()", () => { + for (const root of [production, development]) { + assert.equal( + instanceOrigin(root, "foo-bar").host, + instanceHost(root, "foo-bar"), + ); + } + }); +}); + +describe("classifyHost()", () => { + function classify(requestUrl: string, root: URL = production) { + return classifyHost(new URL(requestUrl), root); + } + + it("treats one label below the root domain as an instance", () => { + assert.equal(classify("https://foo-bar.drfed.net/users/x"), "instance"); + assert.equal(classify("https://qux.drfed.net/"), "instance"); + assert.equal( + classify("http://foo-bar.drfed.localhost:8888/inbox", development), + "instance", + ); + }); + + it("treats the root origin itself as the control surface", () => { + assert.equal(classify("https://drfed.net/graphql"), "admin"); + assert.equal( + classify("http://drfed.localhost:8888/graphql", development), + "admin", + ); + }); + + it("treats unrelated authorities as the control surface", () => { + assert.equal(classify("http://127.0.0.1:8888/graphql"), "admin"); + assert.equal(classify("http://localhost:3000/graphql"), "admin"); + assert.equal(classify("https://internal.example.com/graphql"), "admin"); + // A host name that merely ends in the same characters is not a subdomain. + assert.equal(classify("https://xdrfed.net/graphql"), "admin"); + }); + + it("treats a deeper subdomain as misdirected", () => { + assert.equal(classify("https://a.b.drfed.net/"), "misdirected"); + assert.equal(classify("https://a.b.c.drfed.net/"), "misdirected"); + assert.equal( + classify("http://a.b.drfed.localhost:8888/", development), + "misdirected", + ); + }); + + it("ignores the request scheme", () => { + // A TLS-terminating reverse proxy forwards plain HTTP even when the + // deployment's root origin is HTTPS. + assert.equal(classify("http://foo-bar.drfed.net/users/x"), "instance"); + assert.equal(classify("http://drfed.net/graphql"), "admin"); + }); + + it("reads either of the web's default ports as no port", () => { + // A client may address the https default port explicitly, and a proxy may + // forward that `Host` verbatim over plain HTTP, in which case `URL.port` + // keeps the 443 that https would have elided. It still names the same + // authority, so it must not fall through to the control surface. + assert.equal(classify("http://foo-bar.drfed.net:443/users/x"), "instance"); + assert.equal(classify("http://a.b.drfed.net:443/"), "misdirected"); + assert.equal(classify("http://drfed.net:443/graphql"), "admin"); + assert.equal(classify("https://foo-bar.drfed.net:80/users/x"), "instance"); + assert.equal(classify("https://foo-bar.drfed.net:443/users/x"), "instance"); + // Any other port still names a different authority. + assert.equal(classify("http://foo-bar.drfed.net:8443/"), "admin"); + }); + + it("treats an empty leading label as misdirected", () => { + // The WHATWG parser accepts empty labels, so `Host: .drfed.net` is + // reachable; it is a subdomain of nothing and can never name an instance. + assert.equal(classify("https://.drfed.net/"), "misdirected"); + assert.equal(classify("https://foo..drfed.net/"), "misdirected"); + }); + + it("distinguishes the port", () => { + // The port is part of the authority instances federate under, so the same + // host name on another port has not named an instance. + assert.equal(classify("https://foo-bar.drfed.net:8443/"), "admin"); + assert.equal( + classify("http://foo-bar.drfed.localhost:9999/", development), + "admin", + ); + assert.equal( + classify("http://foo-bar.drfed.localhost/", development), + "admin", + ); + }); + + it("ignores the root zone's trailing dot on either side", () => { + // `foo-bar.drfed.net.` and `foo-bar.drfed.net` name the same host, so + // neither may fall through to the control surface. + assert.equal(classify("https://foo-bar.drfed.net./users/x"), "instance"); + assert.equal(classify("https://a.b.drfed.net./"), "misdirected"); + assert.equal(classify("https://drfed.net./graphql"), "admin"); + const dotted = new URL("https://drfed.net."); + assert.equal( + classify("https://foo-bar.drfed.net/users/x", dotted), + "instance", + ); + assert.equal( + classify("https://foo-bar.drfed.net./users/x", dotted), + "instance", + ); + assert.equal(classify("https://a.b.drfed.net/", dotted), "misdirected"); + assert.equal(classify("https://drfed.net/graphql", dotted), "admin"); + const dottedDev = new URL("http://drfed.localhost.:8888"); + assert.equal( + classify("http://foo-bar.drfed.localhost:8888/inbox", dottedDev), + "instance", + ); + assert.equal( + classify("http://foo-bar.drfed.localhost.:8888/inbox", development), + "instance", + ); + assert.equal( + classify("http://foo-bar.drfed.localhost.:9999/inbox", development), + "admin", + ); + }); + + it("handles IPv6 literal authorities", () => { + const loopback = new URL("http://[::1]:8888"); + assert.equal(classify("http://[::1]:8888/graphql", loopback), "admin"); + assert.equal(classify("http://[2001:db8::1]/graphql", loopback), "admin"); + assert.equal(classify("http://[::1]:9999/graphql", loopback), "admin"); + }); + + it("round-trips a composed instance authority", () => { + for (const root of [ + production, + development, + new URL("https://drfed.net."), + new URL("http://drfed.localhost.:8888"), + ]) { + const url = instanceOrigin(root, "foo-bar"); + assert.equal(classifyHost(url, root), "instance"); + } + }); +}); diff --git a/packages/graphql/src/origin.ts b/packages/graphql/src/origin.ts new file mode 100644 index 0000000..deb4d55 --- /dev/null +++ b/packages/graphql/src/origin.ts @@ -0,0 +1,155 @@ +// 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 . + +/** + * What a DrFed server should do with a request, decided from the authority it + * arrived on. + * + * - `"instance"`: the authority names an instance hosted by this deployment, + * so the request belongs to that instance's ActivityPub surface. It may + * still be an instance nobody has created, in which case every dispatcher + * resolves to nothing and the request ends in a 404. + * - `"admin"`: the authority is the root origin itself, or an address the + * deployment is reachable on without naming an instance, such as the + * listening socket or an internal name a reverse proxy uses. The request + * belongs to the control surface, i.e. GraphQL. + * - `"misdirected"`: the authority sits under the root domain but cannot name + * an instance, because instances occupy exactly one label below it. Nothing + * here will ever answer, and saying so is more useful than a 404. + */ +export type HostKind = "instance" | "admin" | "misdirected"; + +/** + * Composes the authority an instance is federated under. + * + * The root origin contributes its authority rather than its host name, so a + * deployment that is not on its scheme's default port carries that port into + * every instance: with a root origin of `http://drfed.localhost:8888`, the + * slug `foo-bar` yields `foo-bar.drfed.localhost:8888`. That is deliberate, + * because this value has to equal Fedify's `Context.host` for the federation + * dispatchers to find the instance. For the same reason the root zone's + * trailing dot is stripped, so that a root origin given as `https://drfed.net.` + * yields the same authority as `https://drfed.net`. + * @param rootOrigin The root origin of this deployment. + * @param slug The slug of the instance. + * @returns The value to store in `instances.host`. + */ +export function instanceHost(rootOrigin: URL, slug: string): string { + return `${slug}.${canonicalAuthority(rootOrigin)}`; +} + +/** + * Composes the absolute origin an instance is served at. + * @param rootOrigin The root origin of this deployment. + * @param slug The slug of the instance. + * @returns The instance's origin, e.g. `https://foo-bar.drfed.net`. + */ +export function instanceOrigin(rootOrigin: URL, slug: string): URL { + return new URL(`${rootOrigin.protocol}//${instanceHost(rootOrigin, slug)}`); +} + +/** + * Decides what a request is for from the authority it arrived on. + * + * Only the authority is compared, never the scheme. A deployment behind a + * TLS-terminating reverse proxy sees plain HTTP requests even though its root + * origin is HTTPS, and refusing those would break every such deployment. + * + * The port is part of the comparison, because it is part of the authority + * instances are federated under. A request that reaches the same host name on + * some other port has not named an instance, and is treated as reaching the + * control surface. + * @param url The URL of the incoming request. + * @param rootOrigin The root origin of this deployment. + * @returns What the request should be served from. + */ +export function classifyHost(url: URL, rootOrigin: URL): HostKind { + // Compared apart from the host name, so that an IPv6 literal — which keeps + // its brackets in `URL.hostname` — needs no special handling here. + if (canonicalPort(url) !== canonicalPort(rootOrigin)) return "admin"; + const hostname = canonicalHostname(url); + const root = canonicalHostname(rootOrigin); + if (hostname === root) return "admin"; + const suffix = `.${root}`; + if (!hostname.endsWith(suffix)) return "admin"; + const label = hostname.slice(0, -suffix.length); + // Exactly one non-empty label below the root domain is an instance; + // anything deeper, or an empty label, can never name one. + return label !== "" && !label.includes(".") ? "instance" : "misdirected"; +} + +/** + * Strips the root zone's trailing dot from a host name. + * + * `example.com.` and `example.com` name the same host, but the WHATWG URL + * parser keeps the dot, so comparing host names without stripping it would + * classify a request for `foo.drfed.net.` as though it had nothing to do with + * `drfed.net` at all. + * @param url The URL to read the host name from. + * @returns The host name without its root-zone dot. + */ +export function canonicalHostname(url: URL): string { + const { hostname } = url; + return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname; +} + +/** + * The port of a URL, with both of the web's default ports read as "default". + * + * `URL.port` is empty only when the port is the default *for that URL's + * scheme*, so `http://example.com:443` keeps its `443` while + * `https://example.com:443` does not. Comparing the raw values would let the + * scheme back into a comparison that deliberately ignores it: a deployment + * behind a TLS-terminating proxy sees `http://…:443` for a request a client + * addressed to the https default port, and that names the same authority as + * `https://…` with no port at all. + * @param url The URL to read the port from. + * @returns The port, or the empty string when it is a default one. + */ +function canonicalPort(url: URL): string { + const { port } = url; + return port === "80" || port === "443" ? "" : port; +} + +/** + * The authority of a URL with {@link canonicalHostname} applied, i.e. the host + * name without its root-zone dot, followed by the port when it is not the + * scheme's default. + * @param url The URL to read the authority from. + * @returns The canonical authority. + */ +function canonicalAuthority(url: URL): string { + const hostname = canonicalHostname(url); + const port = canonicalPort(url); + return port === "" ? hostname : `${hostname}:${port}`; +} + +/** + * Canonicalizes an authority that arrived as a bare string, the way + * {@link instanceHost} composes one. + * + * Fedify reports `Context.host` verbatim from the request, so it may carry + * spellings that name the instance without matching the stored `host`: a + * root-zone dot, or a default port a client wrote out. Normalizing both sides + * is what lets such a request reach its instance instead of a 404. + * @param authority The authority to canonicalize. + * @returns The canonical spelling, or the input unchanged if it is not an + * authority at all. + */ +export function canonicalizeAuthority(authority: string): string { + const url = `https://${authority}`; + return URL.canParse(url) ? canonicalAuthority(new URL(url)) : authority; +} diff --git a/packages/models/drizzle/20260915061356_tighten_slug_constraint/migration.sql b/packages/models/drizzle/20260915061356_tighten_slug_constraint/migration.sql new file mode 100644 index 0000000..061ccd3 --- /dev/null +++ b/packages/models/drizzle/20260915061356_tighten_slug_constraint/migration.sql @@ -0,0 +1,12 @@ +-- A slug becomes the leftmost label of the instance's host name, so it has +-- to be a valid DNS label. The previous check allowed leading and trailing +-- hyphens, which no resolver accepts, and RFC 5891's reserved LDH labels. +-- The `xn--` prefix stays allowed on purpose, so that instances can carry +-- internationalized domain names. +-- +-- No rows are rewritten or deleted. If a database predating this migration +-- holds a slug the new check rejects, ADD CONSTRAINT fails and the offending +-- `local_instances` row has to be corrected by hand before migrating again. +ALTER TABLE "local_instances" DROP CONSTRAINT "instances_slug_check";--> statement-breakpoint +ALTER TABLE "local_instances" ADD CONSTRAINT "local_instances_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{2,61}[a-z0-9]$' + AND ("slug" !~ '^..--' OR "slug" ~ '^xn--')); \ No newline at end of file diff --git a/packages/models/drizzle/20260915061356_tighten_slug_constraint/snapshot.json b/packages/models/drizzle/20260915061356_tighten_slug_constraint/snapshot.json new file mode 100644 index 0000000..a440c92 --- /dev/null +++ b/packages/models/drizzle/20260915061356_tighten_slug_constraint/snapshot.json @@ -0,0 +1,1290 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "848bb850-02fc-4850-9ce9-7993d69e8713", + "prevIds": ["3d7bb672-489b-4d1e-8efb-e602d21f6e98"], + "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_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "max_instances", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "actor_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followersUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followingUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "featuredUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bioHtml", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automaticallyApprovesFollowers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fieldHtmls", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspendedUntil", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successorId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followingCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followersCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "postsCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "header", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "varchar(63)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "maxActors", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "char(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "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_challenges" + }, + { + "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_challenges", + "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": "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][a-z0-9-]{2,61}[a-z0-9]$'\n AND (\"slug\" !~ '^..--' OR \"slug\" ~ '^xn--')", + "name": "local_instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"maxActors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + } + ], + "renames": [] +} diff --git a/packages/models/drizzle/20260915063339_widen_instance_host/migration.sql b/packages/models/drizzle/20260915063339_widen_instance_host/migration.sql new file mode 100644 index 0000000..2af65b9 --- /dev/null +++ b/packages/models/drizzle/20260915063339_widen_instance_host/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "instances" ALTER COLUMN "host" SET DATA TYPE varchar(259) USING "host"::varchar(259); \ No newline at end of file diff --git a/packages/models/drizzle/20260915063339_widen_instance_host/snapshot.json b/packages/models/drizzle/20260915063339_widen_instance_host/snapshot.json new file mode 100644 index 0000000..0279649 --- /dev/null +++ b/packages/models/drizzle/20260915063339_widen_instance_host/snapshot.json @@ -0,0 +1,1290 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "7c4a0afb-efbc-4ae7-b6b5-ebc6daaddb3e", + "prevIds": ["848bb850-02fc-4850-9ce9-7993d69e8713"], + "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_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "max_instances", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "actor_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followersUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followingUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "featuredUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bioHtml", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automaticallyApprovesFollowers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fieldHtmls", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspendedUntil", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successorId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followingCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followersCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "postsCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(259)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "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_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "char(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "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_challenges" + }, + { + "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_challenges", + "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": "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][a-z0-9-]{2,61}[a-z0-9]$'\n AND (\"slug\" !~ '^..--' OR \"slug\" ~ '^xn--')", + "name": "local_instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"maxActors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + } + ], + "renames": [] +} diff --git a/packages/models/package.json b/packages/models/package.json index 6c64816..8bca1a4 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -73,6 +73,10 @@ "./login": { "types": "./dist/login.d.mts", "default": "./dist/login.mjs" + }, + "./slug": { + "types": "./dist/slug.d.mts", + "default": "./dist/slug.mjs" } }, "files": [ @@ -89,7 +93,8 @@ "src/relations.ts", "src/schema.ts", "src/uuid.ts", - "src/login.ts" + "src/login.ts", + "src/slug.ts" ], "dts": { "sourcemap": true, diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index c348e20..d40166d 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -78,7 +78,13 @@ export const instances = pgTable("instances", { created: timestamp({ withTimezone: true }) .notNull() .default(currentTimestamp), - host: varchar({ length: 100 }).notNull().unique(), + // The authority an instance is federated under, which is what Fedify's + // `Context.host` reports and therefore what dispatchers look instances up + // by. That is a DNS name, at most 253 octets, plus a `:port` suffix of up + // to 6 more characters when the deployment is not on the scheme's default + // port. Both locally composed `.` names and remote + // hosts discovered from the fediverse live here. + host: varchar({ length: 259 }).notNull().unique(), nodeInfoUrl: text(), software: text(), softwareVersion: text(), @@ -96,7 +102,16 @@ export const localInstances = pgTable( maxActors: integer().notNull().default(10), }, (table) => [ - check("instances_slug_check", sql`${table.slug} ~ '^[a-z0-9-]{4,63}$'`), + // Keep this in agreement with `isValidSlug()` in ./slug.ts. A slug + // becomes the leftmost label of the instance's host name, so it has to be + // a valid DNS label: no leading or trailing hyphen, and none of RFC 5891's + // reserved LDH labels except the `xn--` prefix of an A-label, which stays + // allowed so that instances can carry internationalized domain names. + check( + "local_instances_slug_check", + sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{2,61}[a-z0-9]$' + AND (${table.slug} !~ '^..--' OR ${table.slug} ~ '^xn--')`, + ), check("instances_max_actors_check", sql`${table.maxActors} > 0`), ], ); diff --git a/packages/models/src/slug.test.ts b/packages/models/src/slug.test.ts new file mode 100644 index 0000000..c08ff08 --- /dev/null +++ b/packages/models/src/slug.test.ts @@ -0,0 +1,126 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from "node:assert/strict"; + +import { migrate } from "@drfed/models/migrate"; +import { isValidSlug } from "@drfed/models/slug"; +import { uuidV7 } from "@drfed/models/uuid"; +import { PGlite } from "@electric-sql/pglite"; +import { describe, it } from "@logtape/testing-node/autoload"; + +describe("isValidSlug()", () => { + it("accepts ordinary slugs", () => { + for (const slug of ["abcd", "foo-bar", "a-b-c", "instance1", "0123"]) { + assert.ok(isValidSlug(slug), slug); + } + }); + + it("enforces the length bounds", () => { + assert.equal(isValidSlug("abc"), false); + assert.ok(isValidSlug("abcd")); + assert.ok(isValidSlug("a".repeat(63))); + assert.equal(isValidSlug("a".repeat(64)), false); + }); + + it("rejects leading and trailing hyphens", () => { + for (const slug of ["-foo", "foo-", "-foo-", "----"]) { + assert.equal(isValidSlug(slug), false, slug); + } + }); + + it("allows xn-- A-labels", () => { + // `xn--3e0b707e` is the Punycode encoding of `한국`. + assert.ok(isValidSlug("xn--3e0b707e")); + assert.ok(isValidSlug("xn--9t4b11yi5a")); + }); + + it("rejects other reserved LDH labels", () => { + for (const slug of ["ab--cd", "00--11", "aa--bb-cc"]) { + assert.equal(isValidSlug(slug), false, slug); + } + }); + + it("rejects a bare xn-- prefix", () => { + // It ends with a hyphen, so it is not a valid label on its own. + assert.equal(isValidSlug("xn--"), false); + }); + + it("rejects characters outside the label alphabet", () => { + for (const slug of [ + "Foo-bar", + "foo_bar", + "foo.bar", + "foo bar", + "한국", + "", + ]) { + assert.equal(isValidSlug(slug), false, JSON.stringify(slug)); + } + }); +}); + +// `isValidSlug()` restates the `local_instances_slug_check` constraint in +// TypeScript. Nothing keeps the two in step automatically, so assert against +// a real database that they still agree. +describe("local_instances_slug_check", () => { + it("agrees with isValidSlug()", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const slugs = [ + "foo-bar", + "abcd", + "xn--3e0b707e", + "a".repeat(63), + "abc", + "-foo", + "foo-", + "ab--cd", + "xn--", + "Foo-bar", + ]; + const results = await Promise.all( + slugs.map(async (slug) => { + try { + await client.query( + "INSERT INTO local_instances (id, slug, expires) " + + "VALUES ($1, $2, now())", + [uuidV7(), slug], + ); + return { slug, constraint: null }; + } catch (e) { + return { + slug, + constraint: + e != null && typeof e === "object" && "constraint" in e + ? e.constraint + : undefined, + }; + } + }), + ); + for (const { slug, constraint } of results) { + assert.equal(constraint == null, isValidSlug(slug), slug); + if (constraint != null) { + assert.equal(constraint, "local_instances_slug_check", slug); + } + } + } finally { + await client.close(); + } + }); +}); diff --git a/packages/models/src/slug.ts b/packages/models/src/slug.ts new file mode 100644 index 0000000..9aca19f --- /dev/null +++ b/packages/models/src/slug.ts @@ -0,0 +1,69 @@ +// 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 . + +/** + * The shape of a syntactically valid slug: 4 to 63 characters drawn from + * lowercase letters, digits and hyphens, and starting and ending with a letter + * or a digit. + * + * The bounds on the inner group encode the overall 4–63 length: two anchoring + * characters plus 2–61 in between. The upper bound is the maximum length of + * a DNS label, and the lower bound keeps very short slugs — which are the ones + * most likely to collide with an operational host name such as `www` or + * `api` — out of circulation. + */ +const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{2,61}[a-z0-9]$/u; + +/** + * Labels whose third and fourth characters are both hyphens. RFC 5891 § 4.2.3.1 + * reserves the whole class and gives a meaning to exactly one member of it, + * the `xn--` prefix that introduces a Punycode-encoded A-label. + */ +const RESERVED_LDH_PATTERN = /^..--/u; + +/** + * The prefix of an A-label, i.e. the ASCII-compatible encoding of an + * internationalized domain name label. + */ +const A_LABEL_PREFIX = "xn--"; + +/** + * Checks whether a slug can be used as the leftmost label of an instance's + * host name. + * + * A slug has to be a valid DNS label, because it becomes one: an instance with + * the slug `foo-bar` is served at `foo-bar.`. That rules out + * leading and trailing hyphens, which no resolver accepts, and the reserved + * LDH labels of RFC 5891 — except for `xn--`, which is deliberately allowed so + * that instances can carry internationalized domain names. DrFed is a tool for + * debugging federation, and IDN host names are one of the things that break it. + * + * Whether such a label is decodable Punycode is deliberately not checked here. + * Node answers that out of its bundled ICU, so the answer moves with the + * runtime, and a rule that accepts a slug on one deployment while rejecting it + * on another is worse than no rule at all. `createInstance` instead refuses a + * slug whose composed host the local runtime cannot parse, which is the thing + * that actually matters. + * + * This duplicates the `local_instances_slug_check` constraint in + * {@link file://./schema.ts}; the two must be kept in agreement. + * @param slug The slug to check. + * @returns `true` if the slug is usable, `false` otherwise. + */ +export function isValidSlug(slug: string): boolean { + if (!SLUG_PATTERN.test(slug)) return false; + return !RESERVED_LDH_PATTERN.test(slug) || slug.startsWith(A_LABEL_PREFIX); +} diff --git a/packages/web/src/routes/instance/[slug].tsx b/packages/web/src/routes/instance/[slug].tsx index 2b80abe..131ebd3 100644 --- a/packages/web/src/routes/instance/[slug].tsx +++ b/packages/web/src/routes/instance/[slug].tsx @@ -42,6 +42,7 @@ const instanceDetailQuery = graphql` instance { id host + url actors(first: 100) { totalCount edges { @@ -55,6 +56,23 @@ const instanceDetailQuery = graphql` } `; +/** + * The federation endpoints worth linking to from an instance's page. + * + * Built from the origin the server reports rather than from the host name + * alone, so that a development instance links to the port it is actually + * served on instead of an `https:` URL that answers nowhere. + * @param origin The instance's absolute origin. + * @returns The endpoints, in the order they are shown. + */ +function endpoints(origin: string): { label: string; url: string }[] { + return [ + { label: "NodeInfo", url: `${origin}/nodeinfo/2.1` }, + { label: "WebFinger", url: `${origin}/.well-known/webfinger` }, + { label: "Shared inbox", url: `${origin}/inbox` }, + ]; +} + const loadInstanceDetailQuery = query( (slug: string) => loadQuery( @@ -124,30 +142,16 @@ export default function InstanceDetailPage(

Federation endpoints

- - -
-
Shared inbox
-
- {`https://${instance().host}/inbox`} -
-
+ + {({ label, url }) => ( +
+
{label}
+
+ {url} +
+
+ )} +
diff --git a/packages/web/src/routes/workspace/create/instance.tsx b/packages/web/src/routes/workspace/create/instance.tsx index 4f1441f..b48678a 100644 --- a/packages/web/src/routes/workspace/create/instance.tsx +++ b/packages/web/src/routes/workspace/create/instance.tsx @@ -43,6 +43,11 @@ const createInstanceMutation = graphql` } `; +// Mirrors `isValidSlug()` in `@drfed/models/slug`, which the server enforces +// along with a matching database constraint. Kept as a copy rather than an +// import, because this package talks to the server only over GraphQL and +// should not take a dependency on its packages for a check that is only here +// to save a round trip; the server remains the authority. const createInstanceSchema = v.object({ slug: v.pipe( v.string(), @@ -50,8 +55,14 @@ const createInstanceSchema = v.object({ v.minLength(4, "The slug must contain at least 4 characters."), v.maxLength(63, "The slug must contain at most 63 characters."), v.regex( - /^[a-z0-9-]+$/u, - "The slug can contain only lowercase letters, numbers, and hyphens.", + /^[a-z0-9][a-z0-9-]*[a-z0-9]$/u, + "The slug can contain only lowercase letters, numbers, and hyphens, " + + "and must start and end with a letter or a number.", + ), + v.check( + (slug) => !/^..--/u.test(slug) || slug.startsWith("xn--"), + "The slug cannot have two hyphens as its third and fourth characters, " + + "unless it begins with `xn--`.", ), ), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fbd89d..0245b28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,14 +34,14 @@ catalogs: specifier: 2.3.0-dev.840 version: 2.3.0-dev.840 '@optique/core': - specifier: ^1.2.0 - version: 1.2.0 + specifier: ^1.3.0 + version: 1.3.0 '@optique/logtape': - specifier: ^1.2.0 - version: 1.2.0 + specifier: ^1.3.0 + version: 1.3.0 '@optique/run': - specifier: ^1.2.0 - version: 1.2.0 + specifier: ^1.3.0 + version: 1.3.0 '@types/node': specifier: ^26.0.0 version: 26.0.0 @@ -127,13 +127,13 @@ importers: version: 2.3.0-dev.840 '@optique/core': specifier: 'catalog:' - version: 1.2.0 + version: 1.3.0 '@optique/logtape': specifier: 'catalog:' - version: 1.2.0(@logtape/logtape@2.3.0-dev.840) + version: 1.3.0(@logtape/logtape@2.3.0-dev.840) '@optique/run': specifier: 'catalog:' - version: 1.2.0 + version: 1.3.0 '@upyo/logtape': specifier: 'catalog:' version: 0.6.0-dev.263(@logtape/logtape@2.3.0-dev.840)(@upyo/core@0.6.0-dev.263) @@ -289,10 +289,10 @@ importers: version: 1.0.0(solid-js@1.9.14) '@solidjs/start': specifier: ^2.0.0 - version: 2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: 2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: 3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1(supports-color@7.2.0))(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) relay-runtime: specifier: ^21.0.1 version: 21.0.1 @@ -320,7 +320,7 @@ importers: version: 20.1.1 eslint-plugin-solid: specifier: ^0.14.5 - version: 0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2) + version: 0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) relay-compiler: specifier: ^21.0.1 version: 21.0.1 @@ -1102,12 +1102,12 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} - '@optique/core@1.2.0': - resolution: {integrity: sha512-h3gHGe8BCo5iVpOt4CcWCmTnPDYdyys0XqOCCc5ZPA2A2S+GmupMkqYNeI57+SildteuGEgBrGP+MlwRlWdzPA==} + '@optique/core@1.3.0': + resolution: {integrity: sha512-Mg0onYl6TswC2+BGkTIycltIQ/rFzPfu6g6LcxT63wXiu8MiyK7xoXnoRmmQGPHv+gJ3qFvl4gc/q1C8bMpdew==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} - '@optique/logtape@1.2.0': - resolution: {integrity: sha512-bzinVaWd75QKNoLy3eYffLY3dIajtxtmaZcPrpFIB5RVGCNfOBiBGn+fhVYOH6mZ4XaiByGa7Ly04iKX08+KNA==} + '@optique/logtape@1.3.0': + resolution: {integrity: sha512-ZrPrmrGZC3CW6dGS9omGnU8U7uJfPRFOzUinVKz63yxEx8ohdr5chkESu8pI14xwhlOf05pCm8us3bw71Yt6TA==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} peerDependencies: '@logtape/file': ^2.2.2 @@ -1116,8 +1116,8 @@ packages: '@logtape/file': optional: true - '@optique/run@1.2.0': - resolution: {integrity: sha512-4H+sJbpKlB0DsFYyJmyTj2atDdF+9j14MbH99zPTK7YGQysWQ4Vo8UR1O91+ssIDUnKkMNoss7zMvaMf6A52Jw==} + '@optique/run@1.3.0': + resolution: {integrity: sha512-LhnBjIGK1CfF1nW+1sLxyI+EZ4/JZBy5EEMmkdXfmViDdQzJOvJKjEkF1ofxDpjfWcbs4fW8XkMBxE8zRCnwrA==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} '@oxc-parser/binding-android-arm-eabi@0.134.0': @@ -3912,20 +3912,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@7.2.0) '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3954,19 +3954,19 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@7.2.0) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -3987,9 +3987,9 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -4000,7 +4000,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8': + '@babel/traverse@7.29.8(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -4008,7 +4008,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4250,17 +4250,17 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - eslint: 9.39.4(jiti@2.7.0) + eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4273,10 +4273,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.5(supports-color@7.2.0)': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4631,16 +4631,16 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} - '@optique/core@1.2.0': {} + '@optique/core@1.3.0': {} - '@optique/logtape@1.2.0(@logtape/logtape@2.3.0-dev.840)': + '@optique/logtape@1.3.0(@logtape/logtape@2.3.0-dev.840)': dependencies: '@logtape/logtape': 2.3.0-dev.840 - '@optique/core': 1.2.0 + '@optique/core': 1.3.0 - '@optique/run@1.2.0': + '@optique/run@1.3.0': dependencies: - '@optique/core': 1.2.0 + '@optique/core': 1.3.0 '@oxc-parser/binding-android-arm-eabi@0.134.0': optional: true @@ -5040,10 +5040,10 @@ snapshots: dependencies: solid-js: 1.9.14 - '@solidjs/start@2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@solidjs/start@2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) '@babel/types': 7.29.8 '@solidjs/meta': 0.29.4(solid-js@1.9.14) '@types/babel__traverse': 7.28.0 @@ -5067,7 +5067,7 @@ snapshots: srvx: 0.12.5 terracotta: 1.1.1(solid-js@1.9.14) vite: 8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-solid: 2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + vite-plugin-solid: 2.11.14(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) optionalDependencies: '@solidjs/router': 1.0.0(solid-js@1.9.14) transitivePeerDependencies: @@ -5142,11 +5142,11 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/project-service@8.62.1(typescript@7.0.2)': + '@typescript-eslint/project-service@8.62.1(supports-color@7.2.0)(typescript@7.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@7.0.2) '@typescript-eslint/types': 8.62.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -5162,13 +5162,13 @@ snapshots: '@typescript-eslint/types@8.62.1': {} - '@typescript-eslint/typescript-estree@8.62.1(typescript@7.0.2)': + '@typescript-eslint/typescript-estree@8.62.1(supports-color@7.2.0)(typescript@7.0.2)': dependencies: - '@typescript-eslint/project-service': 8.62.1(typescript@7.0.2) + '@typescript-eslint/project-service': 8.62.1(supports-color@7.2.0)(typescript@7.0.2) '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@7.0.2) '@typescript-eslint/types': 8.62.1 '@typescript-eslint/visitor-keys': 8.62.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.5 semver: 7.8.4 tinyglobby: 0.2.17 @@ -5177,13 +5177,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2)': + '@typescript-eslint/utils@8.62.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.62.1 '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/typescript-estree': 8.62.1(typescript@7.0.2) - eslint: 9.39.4(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.62.1(supports-color@7.2.0)(typescript@7.0.2) + eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0) typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -5404,19 +5404,19 @@ snapshots: pvutils: 1.2.0 tslib: 2.8.1 - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7(supports-color@7.2.0)): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/types': 7.29.8 html-entities: 2.3.3 parse5: 7.3.0 - babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.14): + babel-preset-solid@1.9.12(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.14): dependencies: - '@babel/core': 7.29.7 - babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@7.2.0) + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7(supports-color@7.2.0)) optionalDependencies: solid-js: 1.9.14 @@ -5540,9 +5540,11 @@ snapshots: '@electric-sql/pglite': 0.5.3 drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)) - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 deep-is@0.1.4: {} @@ -5671,10 +5673,10 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2): + eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2): dependencies: - '@typescript-eslint/utils': 8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2) - eslint: 9.39.4(jiti@2.7.0) + '@typescript-eslint/utils': 8.62.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0) estraverse: 5.3.0 is-html: 2.0.0 kebab-case: 1.0.2 @@ -5695,14 +5697,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.7.0): + eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@7.2.0) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 + '@eslint/eslintrc': 3.3.5(supports-color@7.2.0) '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -5712,7 +5714,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -5951,11 +5953,11 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.11.1: + ioredis@5.11.1(supports-color@7.2.0): dependencies: '@ioredis/commands': 1.10.0 cluster-key-slot: 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) denque: 2.1.0 redis-errors: 1.2.0 redis-parser: 3.0.0 @@ -6173,7 +6175,7 @@ snapshots: nf3@0.3.23: {} - nitro@3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + nitro@3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1(supports-color@7.2.0))(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: consola: 3.4.2 crossws: 0.4.10(srvx@0.11.16) @@ -6188,7 +6190,7 @@ snapshots: rolldown: 1.2.0 srvx: 0.11.16 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1(supports-color@7.2.0))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.4.2 giget: 3.3.0 @@ -6629,10 +6631,10 @@ snapshots: '@corvu/utils': 0.4.2(solid-js@1.9.14) solid-js: 1.9.14 - solid-refresh@0.6.3(solid-js@1.9.14): + solid-refresh@0.6.3(solid-js@1.9.14)(supports-color@7.2.0): dependencies: '@babel/generator': 7.29.8 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.8 solid-js: 1.9.14 transitivePeerDependencies: @@ -6843,11 +6845,11 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1(supports-color@7.2.0))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): optionalDependencies: chokidar: 5.0.0 db0: 0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))) - ioredis: 5.11.1 + ioredis: 5.11.1(supports-color@7.2.0) lru-cache: 11.5.1 ofetch: 2.0.0-alpha.3 @@ -6899,14 +6901,14 @@ snapshots: transitivePeerDependencies: - typescript - vite-plugin-solid@2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + vite-plugin-solid@2.11.14(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.14) + babel-preset-solid: 1.9.12(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.14) merge-anything: 5.1.7 solid-js: 1.9.14 - solid-refresh: 0.6.3(solid-js@1.9.14) + solid-refresh: 0.6.3(solid-js@1.9.14)(supports-color@7.2.0) vite: 8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) vitefu: 1.1.3(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 512da5c..469756d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,9 +16,9 @@ catalog: "@logtape/graphql-yoga": 2.3.0-dev.840 "@logtape/logtape": 2.3.0-dev.840 "@logtape/testing-node": 2.3.0-dev.840 - "@optique/core": ^1.2.0 - "@optique/logtape": ^1.2.0 - "@optique/run": ^1.2.0 + "@optique/core": ^1.3.0 + "@optique/logtape": ^1.3.0 + "@optique/run": ^1.3.0 "@types/node": ^26.0.0 "@upyo/core": 0.6.0-dev.263+e633e1e6 "@upyo/logtape": 0.6.0-dev.263+e633e1e6 @@ -46,9 +46,9 @@ minimumReleaseAgeExclude: - "@logtape/logtape@2.3.0-dev.840" - "@logtape/testing-node@2.3.0-dev.840" - "@logtape/testing@2.3.0-dev.840" - - "@optique/core@1.2.0" - - "@optique/logtape@1.2.0" - - "@optique/run@1.2.0" + - "@optique/core@1.3.0" + - "@optique/logtape@1.3.0" + - "@optique/run@1.3.0" - "@upyo/core@0.6.0-dev.263" - "@upyo/logtape@0.6.0-dev.263" - "@upyo/mock@0.6.0-dev.263" diff --git a/scripts/dev.mts b/scripts/dev.mts index c6cc533..f0301d7 100644 --- a/scripts/dev.mts +++ b/scripts/dev.mts @@ -33,6 +33,22 @@ interface ShutdownOptions { const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const packagesDir = join(root, "packages"); + +// The server itself is started with `--env-file`, but the root origin has to +// be known here, to be passed as a command-line option. Loading the same file +// keeps the two in one place. It is not committed, so tolerate its absence. +const envFile = join(packagesDir, "drfed", ".env"); +try { + process.loadEnvFile(envFile); +} catch { + // Left to `drfed-server` to complain about, since it needs + // `DRFED_LOGIN_ORIGINS` from the same file anyway. +} + +// Any subdomain of `localhost` resolves to the loopback address without any +// DNS or /etc/hosts setup, which is what makes per-instance subdomains usable +// in development. +const defaultRootOrigin = "http://drfed.localhost:8888"; const isWindows = process.platform === "win32"; const pnpm = isWindows ? "pnpm.cmd" : "pnpm"; @@ -307,7 +323,7 @@ try { "../../.pgdata", "--listen=0.0.0.0:8888", "--log-format=color", - "--root-domain=drfed.org", + `--root-origin=${process.env.DRFED_ROOT_ORIGIN ?? defaultRootOrigin}`, ]; const logLevel = process.env.usage_log_level;