From 35e9a6a99c9461d925515efaa03174767f8091e9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 15:03:43 +0900 Subject: [PATCH 01/13] Add an origin() Optique value parser DrFed needs to derive per-instance subdomains from a configurable root origin, which means the root origin has to carry a scheme and a port as well as a host name. Neither of Optique's built-in domain() nor url() value parsers expresses that: domain() drops the scheme and the port, while url() accepts paths, queries and credentials that an origin has no room for. This adds a hand-written origin() value parser to @drfed/drfed. It normalizes rather than rejects, reducing any absolute URL down to its origin, so HTTPS://Example.COM, https://example.com/, https://example.com/path?q#f and https://user:pw@example.com/ all parse to the same https://example.com. DrFed does not support mounting under a sub-path, so there is nothing meaningful to preserve past the origin. Input that is not an absolute URL is rejected, as is any URL whose protocol falls outside allowedProtocols or has no tuple origin, such as mailto: and data:. Nothing consumes origin() yet; a later commit replaces the current --root-domain option with a required --root-origin built on it. Since @drfed/drfed had neither an exports field nor a test script, this also adds both, so that the parser can be tested through the @drfed/drfed/valueparser subpath export the way the other packages do. AI provenance: I asked Claude Code (Opus 5) to design and implement a custom Optique value parser for root origins, and to decide how strictly it should treat URLs that carry more than an origin. Claude wrote the parser, the tests and the package metadata; I chose normalization over rejection, since an origin option has no use for a path. Claude Code (Fable 5) then reviewed the result and found that malformed allowedProtocols entries missing their trailing colon were accepted at construction and then rejected every valid input, so a guard mirroring Optique's own url() parser was added along with tests for it. Codex (GPT-6 Astra) also reviewed and reported no findings. I verified the result with mise run check, mise run build and mise run test. https://github.com/fedify-dev/drfed/issues/77 Assisted-by: Claude Code:claude-opus-5 Assisted-by: Claude Code:claude-fable-5 --- packages/drfed/package.json | 17 +++- packages/drfed/src/valueparser.test.ts | 127 +++++++++++++++++++++++++ packages/drfed/src/valueparser.ts | 125 ++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 packages/drfed/src/valueparser.test.ts create mode 100644 packages/drfed/src/valueparser.ts diff --git a/packages/drfed/package.json b/packages/drfed/package.json index bcb540c..5b75ecf 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -41,6 +41,16 @@ "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" + } + }, "files": [ "bin/", "dist/", @@ -50,6 +60,10 @@ "drfed-server": "bin/drfed-server.mjs" }, "tsdown": { + "entry": [ + "src/index.ts", + "src/valueparser.ts" + ], "dts": { "sourcemap": true, "tsconfig": "../../tsconfig.drfed.json" @@ -57,7 +71,8 @@ "sourcemap": true }, "scripts": { - "build": "tsdown" + "build": "tsdown", + "test": "node --test" }, "devDependencies": { "@logtape/testing-node": "catalog:", diff --git a/packages/drfed/src/valueparser.test.ts b/packages/drfed/src/valueparser.test.ts new file mode 100644 index 0000000..0ddc989 --- /dev/null +++ b/packages/drfed/src/valueparser.test.ts @@ -0,0 +1,127 @@ +// 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 { origin } from "@drfed/drfed/valueparser"; +import { describe, it } from "@logtape/testing-node/autoload"; + +function parse(input: string, options?: Parameters[0]) { + return origin(options).parse(input); +} + +function parsed(input: string, options?: Parameters[0]): string { + const result = parse(input, options); + assert.ok(result.success, `expected ${input} to parse`); + return result.value.origin; +} + +describe("origin()", () => { + it("normalizes anything that reduces to the same origin", () => { + for (const input of [ + "https://example.com", + "https://example.com/", + "HTTPS://Example.COM", + "https://example.com/path/to/thing", + "https://example.com/?query=1#fragment", + "https://user:pw@example.com/", + "https://example.com:443/", + ]) { + assert.equal(parsed(input), "https://example.com"); + } + }); + + it("keeps a non-default port", () => { + assert.equal( + parsed("http://drfed.localhost:8888"), + "http://drfed.localhost:8888", + ); + assert.equal(parsed("http://example.com:80/"), "http://example.com"); + }); + + it("returns a URL that is its own origin", () => { + const result = parse("https://example.com/path"); + assert.ok(result.success); + assert.equal(result.value.href, "https://example.com/"); + assert.equal(result.value.pathname, "/"); + assert.equal(result.value.search, ""); + assert.equal(result.value.username, ""); + }); + + it("rejects input that is not an absolute URL", () => { + for (const input of ["", "example.com", "/path", "https://"]) { + assert.equal(parse(input).success, false, input); + } + }); + + it("rejects protocols outside the allow list", () => { + const options = { allowedProtocols: ["http:", "https:"] } as const; + assert.equal(parsed("https://example.com", options), "https://example.com"); + assert.equal(parsed("http://example.com", options), "http://example.com"); + assert.equal(parse("ftp://example.com", options).success, false); + }); + + it("matches allowed protocols case-insensitively", () => { + assert.equal( + parsed("https://example.com", { allowedProtocols: ["HTTPS:"] }), + "https://example.com", + ); + }); + + it("rejects URLs without a tuple origin", () => { + for (const input of ["mailto:someone@example.com", "data:,hello"]) { + assert.equal(parse(input).success, false, input); + } + }); + + it("rejects a malformed allow list at construction time", () => { + assert.throws(() => origin({ allowedProtocols: [] }), TypeError); + // Missing the trailing colon would otherwise construct fine and then + // reject every input, reporting the rejected protocol as an allowed one. + assert.throws(() => origin({ allowedProtocols: ["https"] }), TypeError); + assert.throws( + () => origin({ allowedProtocols: ["https:", "ftp"] }), + TypeError, + ); + }); + + it("round-trips through format() and normalize()", () => { + const parser = origin(); + const result = parser.parse("https://example.com/path"); + assert.ok(result.success); + assert.equal(parser.format(result.value), "https://example.com"); + const reparsed = parser.parse(parser.format(result.value)); + assert.ok(reparsed.success); + assert.equal(reparsed.value.href, result.value.href); + assert.equal( + parser.normalize?.(new URL("https://example.com/path")).href, + "https://example.com/", + ); + }); + + it("offers a placeholder that is a valid origin", () => { + assert.equal(origin().placeholder.origin, "http://0.invalid"); + assert.equal( + origin({ allowedProtocols: ["https:"] }).placeholder.origin, + "https://0.invalid", + ); + }); + + it("uses ORIGIN as the default metavar", () => { + assert.equal(origin().metavar, "ORIGIN"); + assert.equal(origin({ metavar: "ROOT" }).metavar, "ROOT"); + }); +}); diff --git a/packages/drfed/src/valueparser.ts b/packages/drfed/src/valueparser.ts new file mode 100644 index 0000000..80c77ab --- /dev/null +++ b/packages/drfed/src/valueparser.ts @@ -0,0 +1,125 @@ +// 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, values } from "@optique/core/message"; +import { + type NonEmptyString, + type ValueParser, + ensureNonEmptyString, +} from "@optique/core/valueparser"; + +/** + * Options for the {@link origin} value parser. + */ +export interface OriginOptions { + /** + * 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; + + /** + * List of allowed URL protocols (e.g., `["http:", "https:"]`). Protocol + * names must include the trailing colon. If not specified, any protocol + * that yields a tuple origin is allowed. + */ + readonly allowedProtocols?: readonly string[]; +} + +/** + * Creates a {@link ValueParser} for web origins. + * + * The parser accepts any absolute URL and *normalizes* it down to its origin + * rather than rejecting the extra components, so every one of + * `HTTPS://Example.COM`, `https://example.com/`, and + * `https://user:pw@example.com/path?query#fragment` parses to the same + * `https://example.com`. Only two kinds of input are rejected: strings that + * are not absolute URLs at all, and URLs whose protocol is either outside + * {@link OriginOptions.allowedProtocols} or has no tuple origin (`mailto:`, + * `data:`, and friends, whose origin is the opaque `"null"`). + * @param options Configuration options for the origin parser. + * @returns A {@link ValueParser} that converts string input into `URL` + * objects that are guaranteed to equal their own origin. + */ +export function origin(options: OriginOptions = {}): ValueParser<"sync", URL> { + const metavar = options.metavar ?? "ORIGIN"; + ensureNonEmptyString(metavar); + let allowedProtocols: readonly string[] | undefined; + if (options.allowedProtocols != null) { + if (options.allowedProtocols.length < 1) { + throw new TypeError("allowedProtocols must not be empty."); + } + for (const protocol of options.allowedProtocols) { + // Without the trailing colon an entry can never match `URL.protocol`, + // so the parser would reject every input while reporting the rejected + // protocol as an allowed one. Fail loudly at construction instead. + if (!/^[a-z][a-z0-9+\-.]*:$/iu.test(protocol)) { + throw new TypeError( + "Each allowed protocol must be a valid protocol ending with " + + `a colon (e.g., "https:"), got: ${JSON.stringify(protocol)}.`, + ); + } + } + allowedProtocols = Object.freeze( + options.allowedProtocols.map((protocol) => protocol.toLowerCase()), + ); + } + return { + mode: "sync", + metavar, + // A getter, so that every access yields a fresh `URL` that callers may + // mutate without corrupting the parser. `.invalid` is reserved by + // RFC 2606 and can never resolve. + get placeholder(): URL { + return new URL(`${allowedProtocols?.[0] ?? "http:"}//0.invalid`); + }, + parse(input: string) { + if (!URL.canParse(input)) { + return { + success: false, + error: message`Invalid origin: ${input}.`, + }; + } + const url = new URL(input); + if ( + allowedProtocols != null && + !allowedProtocols.includes(url.protocol) + ) { + return { + success: false, + error: message`URL protocol ${url.protocol} is not allowed. Allowed protocols: ${values([...allowedProtocols])}.`, + }; + } + // `URL.origin` is the string `"null"` for schemes without a tuple + // origin, which `new URL()` cannot parse back. Such URLs can never + // name a host, so they are not origins in any useful sense. + if (url.origin === "null") { + return { + success: false, + error: message`The URL ${input} has no origin.`, + }; + } + return { success: true, value: new URL(url.origin) }; + }, + format(value: URL): string { + return value.origin; + }, + normalize(value: URL): URL { + return value.origin === "null" ? value : new URL(value.origin); + }, + }; +} From 19680cfef98d69e1f193f7a15c4ea2f71a6132b6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 15:28:27 +0900 Subject: [PATCH 02/13] Require instance slugs to be valid DNS labels An instance's slug becomes the leftmost label of its host name, so an instance with the slug foo-bar is served at foo-bar.. The check constraint did not hold slugs to that, though: ^[a-z0-9-]{4,63}$ admits -foo and foo-, which no resolver accepts, and ab--cd, which RFC 5891 section 4.2.3.1 reserves. The new rule anchors both ends on a letter or a digit and rejects the reserved LDH labels, with one deliberate exception. The xn-- prefix stays allowed, so that an instance can carry an internationalized domain name; DrFed exists to debug federation, and IDN host names are one of the things that break it. The rule now lives in two places. isValidSlug() in the new @drfed/models/slug module is what application code will call -- a later commit has createInstance reject a bad slug with a proper error instead of letting the constraint violation escape as an unhandled query error -- and the constraint itself remains the backstop. Nothing keeps the two in agreement automatically, so a test migrates a real PGlite database and asserts that it accepts and rejects exactly what isValidSlug() does. The constraint is also renamed from instances_slug_check to local_instances_slug_check, matching the table it has been attached to since the local and remote instance tables were separated. The migration rewrites no rows; a database holding a slug the new rule rejects will fail to migrate and has to be corrected by hand, which for now can only happen to a local development database. https://github.com/fedify-dev/drfed/issues/77 AI provenance: I asked Claude Code (Opus 5) to tighten the slug rule to the DNS label grammar and to keep the TypeScript and SQL halves in agreement, and I decided that xn-- should be permitted rather than blocked, since IDN support is worth more here than the marginal homograph risk on throwaway development instances. Claude wrote the module, the schema change, the migration and the tests. Codex (GPT-6 Astra) reviewed and differential-tested the two regexes over 1,929 inputs without finding a divergence. Claude Code (Fable 5) reviewed and pointed out that no committed test exercised the SQL half at all, so the database-backed test above was added in response. I verified the result with mise run check, mise run build and mise run test, and separately confirmed that the migration applies to a fresh PGlite database. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Claude Code:claude-fable-5 --- .../migration.sql | 12 + .../snapshot.json | 1290 +++++++++++++++++ packages/models/package.json | 7 +- packages/models/src/schema.ts | 11 +- packages/models/src/slug.test.ts | 126 ++ packages/models/src/slug.ts | 62 + 6 files changed, 1506 insertions(+), 2 deletions(-) create mode 100644 packages/models/drizzle/20260915061356_tighten_slug_constraint/migration.sql create mode 100644 packages/models/drizzle/20260915061356_tighten_slug_constraint/snapshot.json create mode 100644 packages/models/src/slug.test.ts create mode 100644 packages/models/src/slug.ts 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/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..5d68b94 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -96,7 +96,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..779e4ac --- /dev/null +++ b/packages/models/src/slug.ts @@ -0,0 +1,62 @@ +// 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. + * + * 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); +} From deb080063b7fb44134b7ca35c307c37e7d1d32d0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 15:34:37 +0900 Subject: [PATCH 03/13] Widen instances.host to hold a full authority instances.host was varchar(100), which is not enough for what the column actually holds. Locally it holds `.`, and a slug may be 63 characters on its own, so a root domain of 37 characters already overflows it; remote hosts discovered from the fediverse can be longer still. An overflow surfaced as a raw "value too long" error rather than anything a caller could act on. The column now holds 259 characters: a DNS name is at most 253 octets, and the value is an authority rather than a bare host name, so it may carry a `:port` suffix of up to 6 more characters. That matters because the authority is what Fedify's Context.host reports, and the federation dispatchers look instances up by it; an upcoming commit replaces --root-domain with a --root-origin that can name a port, at which point hosts such as foo-bar.drfed.localhost:8888 start being stored. The migration only widens the column, so no data is rewritten or lost and the unique constraint is unaffected. https://github.com/fedify-dev/drfed/issues/77 AI provenance: this commit exists because Claude Code (Fable 5), while reviewing the preceding slug change, noticed that a maximum-length slug could overflow the host column; I decided to fix it here rather than defer it, since the surrounding work is already about composing these host names. Claude Code (Opus 5) made the schema change and generated the migration. Codex (GPT-6 Astra) reviewed it and verified against PGlite that earlier migrations still replay, existing rows survive, uniqueness holds and the migration is safe to re-run. Claude Code (Fable 5) then observed that 253 would be too small once hosts carry ports, which is exactly where this series is heading, so the bound was raised to 259 before committing. I verified the result with mise run check, mise run build and mise run test, and confirmed against PGlite that a 259-character authority is accepted and a 260-character one is rejected. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- .../migration.sql | 1 + .../snapshot.json | 1290 +++++++++++++++++ packages/models/src/schema.ts | 8 +- 3 files changed, 1298 insertions(+), 1 deletion(-) create mode 100644 packages/models/drizzle/20260915063339_widen_instance_host/migration.sql create mode 100644 packages/models/drizzle/20260915063339_widen_instance_host/snapshot.json 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/src/schema.ts b/packages/models/src/schema.ts index 5d68b94..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(), From 2a85674ac8177f5df531f9a57af00c569476cdc2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 15:52:02 +0900 Subject: [PATCH 04/13] Add helpers for instance and root authorities DrFed serves each instance from its own subdomain of a configurable root domain, which means two things have to be decided consistently in several places: what authority an instance is federated under, and what a request should be served from given the authority it arrived on. This adds those decisions as pure functions in @drfed/graphql/origin, ahead of the commits that consume them. instanceHost() and instanceOrigin() compose an instance's authority from a root origin and a slug, carrying a non-default port along, because that authority has to equal Fedify's Context.host for the dispatchers to find the instance at all. classifyHost() sorts an incoming authority into one of three: instance exactly one label below the root domain. An instance nobody has created still lands here, and ends in a 404 once every dispatcher resolves to nothing. misdirected under the root domain but deeper than one label, so it can never name an instance. admin everything else: the root origin, the listening socket, an internal name a reverse proxy uses. The scheme is deliberately not compared, since a deployment behind a TLS-terminating proxy sees plain HTTP requests while its root origin is HTTPS. The port is compared, because it is part of the authority, but through a canonicalization that reads both 80 and 443 as no port -- URL elides a port only when it is the default for that URL's own scheme, so comparing the raw values would have let the scheme back in through the side door and put the control surface on a tenant's authority. Host names are likewise compared with the root zone's trailing dot stripped, since example.com. and example.com name the same host but the URL parser keeps the dot. https://github.com/fedify-dev/drfed/issues/77 AI provenance: I asked Claude Code (Opus 5) to extract the authority composition and request classification rules into testable functions, having already decided the three-way classification and that the scheme must be ignored while the port must not. Claude wrote the module and the tests. Both reviewers then found real holes in the comparison: Codex (GPT-6 Astra) showed that a trailing root-zone dot made a tenant subdomain classify as the control surface, and Claude Code (Fable 5) showed that comparing URL.port raw reintroduced the scheme, so a Host header naming port 443 forwarded over plain HTTP did the same thing. Both were fixed with regression tests, along with a missing test for the empty-label case. A related mismatch -- a dotted request now classifies as an instance but misses the dispatcher lookup, because Context.host keeps the dot -- is recorded for the routing commit, where the request URL is the right place to canonicalize. I verified the result with mise run check, mise run build and mise run test. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- packages/graphql/package.json | 7 +- packages/graphql/src/origin.test.ts | 213 ++++++++++++++++++++++++++++ packages/graphql/src/origin.ts | 137 ++++++++++++++++++ 3 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 packages/graphql/src/origin.test.ts create mode 100644 packages/graphql/src/origin.ts 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/origin.test.ts b/packages/graphql/src/origin.test.ts new file mode 100644 index 0000000..66afcb2 --- /dev/null +++ b/packages/graphql/src/origin.test.ts @@ -0,0 +1,213 @@ +// 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 { + 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("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..d9283dc --- /dev/null +++ b/packages/graphql/src/origin.ts @@ -0,0 +1,137 @@ +// 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. + */ +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); + return url.port === "" ? hostname : `${hostname}:${url.port}`; +} From 7a9eb24250fb15cf16aeecc4c069c0cbcca20ff4 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 16:32:33 +0900 Subject: [PATCH 05/13] Configure the deployment by root origin The root domain a deployment serves instances under was an optional --root-domain, and when it was omitted @drfed/graphql quietly filled in "drfed.org". Installed software has no business defaulting to the project's own domain: an operator who forgot the option would hand their users subdomains of somebody else's zone, with nothing said about it. It is required now, and the old name is gone rather than aliased, since 0.1.0 has not been released. A domain was also the wrong shape. An instance has to be found by the authority requests arrive on, which is a scheme, a host and a port -- not a host alone. Without the port, a development server on :8888 composed foo-bar.drfed.localhost while Fedify reported foo-bar.drfed.localhost:8888, so no instance was ever found and actor URIs came out with a hardcoded https: that pointed nowhere. So the option is --root-origin, parsed by the origin() value parser added earlier in this series, and ServerContext carries a URL rather than a string. IP addresses are refused there: every instance is a subdomain of this origin, and foo-bar.127.0.0.1 is not a host name -- it is not even a URL that parses. Two consequences beyond the rename. createInstance composes through instanceHost(), so a non-default port reaches instances.host. And generateActors now reads that stored host instead of recomposing it from the slug, taking its scheme from the root origin, so actor URIs cannot drift from the instance the rest of the fediverse already knows. For development, mise run dev reads DRFED_ROOT_ORIGIN from the same .env the server is started with, falling back to http://drfed.localhost:8888. Any subdomain of localhost resolves to the loopback address with no DNS or /etc/hosts setup, which is what makes per-instance subdomains usable locally at all. https://github.com/fedify-dev/drfed/issues/77 AI provenance: I asked Claude Code (Opus 5) to carry the root origin through every layer in one commit, having already decided that the option must be required, that it must be an origin rather than a domain, and that drfed.localhost is the development default. Claude made the change and wrote the tests. Codex (GPT-6 Astra) reviewed over three rounds and found that IP-literal origins were accepted although they cannot take a subdomain, that the resulting check was bypassable by a blob: URL whose hostname is empty while its origin carries the authority, and that neither the CLI contract nor the actor URI change was covered by a test that could fail. Claude Code (Fable 5) then found that the test added for the retired option passed for the wrong reason. All were fixed; the two behavioural tests were mutation-tested by restoring the old behaviour and confirming they fail. A related defect of the same class -- emailFrom defaulting to noreply@drfed.org -- was raised by Fable and is being fixed in the next commit. I verified the result with mise run check, mise run build and mise run test, and by running the built binary to confirm the option is required, rejects a non-HTTP scheme and an IP address, and leaves schema generation usable with no deployment configuration. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- packages/drfed/.env.example | 1 + packages/drfed/src/index.ts | 4 +- packages/drfed/src/parser.test.ts | 129 ++++++++++++++++++++++++ packages/drfed/src/parser.ts | 21 +++- packages/drfed/src/valueparser.test.ts | 46 +++++++++ packages/drfed/src/valueparser.ts | 37 ++++++- packages/graphql/src/actor.test.ts | 56 ++++++++++ packages/graphql/src/actor.ts | 10 +- packages/graphql/src/builder.ts | 6 +- packages/graphql/src/federation.test.ts | 6 +- packages/graphql/src/harness.test.ts | 10 +- packages/graphql/src/index.ts | 10 +- packages/graphql/src/instance.test.ts | 21 ++++ packages/graphql/src/instance.ts | 7 +- scripts/dev.mts | 18 +++- 15 files changed, 358 insertions(+), 24 deletions(-) create mode 100644 packages/drfed/src/parser.test.ts 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/src/index.ts b/packages/drfed/src/index.ts index ff30897..6d35142 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -66,10 +66,10 @@ 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 { mailer, rootOrigin } = options; const yogaServer = createYogaServer(options.drizzle.db, federation, { - root, + rootOrigin, mailer, loginOrigins, }); diff --git a/packages/drfed/src/parser.test.ts b/packages/drfed/src/parser.test.ts new file mode 100644 index 0000000..f83e5fe --- /dev/null +++ b/packages/drfed/src/parser.test.ts @@ -0,0 +1,129 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +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); + assert.match(stderr, /No matching option or argument found/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("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..97be9ee 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 { 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 { origin } from "./valueparser.ts"; + const pgliteParser = map( option( "--pglite-data-path", @@ -107,10 +109,19 @@ 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", + origin({ + allowedProtocols: ["http:", "https:"], + // Every instance is a subdomain of this origin, and an IP address cannot + // have one. + allowIpLiterals: false, + 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 serverParser = object("DrFed server", { @@ -134,7 +145,7 @@ const serverParser = object("DrFed server", { ), }), ), - root: rootParser, + rootOrigin: rootOriginParser, mailer: smtpParser, seed: seedParser, }); diff --git a/packages/drfed/src/valueparser.test.ts b/packages/drfed/src/valueparser.test.ts index 0ddc989..c040473 100644 --- a/packages/drfed/src/valueparser.test.ts +++ b/packages/drfed/src/valueparser.test.ts @@ -112,6 +112,52 @@ describe("origin()", () => { ); }); + it("accepts IP literals by default", () => { + assert.equal(parsed("http://127.0.0.1:8888"), "http://127.0.0.1:8888"); + assert.equal(parsed("http://[::1]:8888"), "http://[::1]:8888"); + }); + + it("rejects IP literals when they cannot take a subdomain", () => { + const options = { allowIpLiterals: false } as const; + // `foo.127.0.0.1` and `foo.[::1]` are not host names; prefixing a label + // to either of these makes a URL that does not parse at all. + 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 are the same host as 127.0.0.1. + "http://0x7f.1", + "http://2130706433", + ]) { + assert.equal(parse(input, options).success, false, input); + } + assert.equal(parsed("https://drfed.net", options), "https://drfed.net"); + // A name that merely begins with digits is still a name. + assert.equal(parsed("https://1.drfed.net", options), "https://1.drfed.net"); + }); + + it("sees through a URL that hides its authority in its origin", () => { + // A `blob:` URL reports an empty `hostname` while its origin carries the + // authority embedded in it, so a check against the original URL would let + // an IP address through. + for (const input of [ + "blob:http://127.0.0.1:8888/id", + "blob:http://[::1]/id", + ]) { + assert.equal( + parse(input, { allowIpLiterals: false }).success, + false, + input, + ); + } + // Still accepted when IP literals are allowed, normalized to the origin. + assert.equal( + parsed("blob:http://127.0.0.1:8888/id"), + "http://127.0.0.1:8888", + ); + }); + it("offers a placeholder that is a valid origin", () => { assert.equal(origin().placeholder.origin, "http://0.invalid"); assert.equal( diff --git a/packages/drfed/src/valueparser.ts b/packages/drfed/src/valueparser.ts index 80c77ab..9b07315 100644 --- a/packages/drfed/src/valueparser.ts +++ b/packages/drfed/src/valueparser.ts @@ -38,8 +38,24 @@ export interface OriginOptions { * that yields a tuple origin is allowed. */ readonly allowedProtocols?: readonly string[]; + + /** + * Whether to accept an origin whose host is an IP address rather than a + * domain name. Set this to `false` when the origin has to be able to take + * a subdomain, since `foo.127.0.0.1` and `foo.[::1]` are not host names at + * all. + * @default `true` + */ + readonly allowIpLiterals?: boolean; } +/** + * 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; + /** * Creates a {@link ValueParser} for web origins. * @@ -50,7 +66,9 @@ export interface OriginOptions { * `https://example.com`. Only two kinds of input are rejected: strings that * are not absolute URLs at all, and URLs whose protocol is either outside * {@link OriginOptions.allowedProtocols} or has no tuple origin (`mailto:`, - * `data:`, and friends, whose origin is the opaque `"null"`). + * `data:`, and friends, whose origin is the opaque `"null"`), and, when + * {@link OriginOptions.allowIpLiterals} is off, origins whose host is an IP + * address. * @param options Configuration options for the origin parser. * @returns A {@link ValueParser} that converts string input into `URL` * objects that are guaranteed to equal their own origin. @@ -78,6 +96,7 @@ export function origin(options: OriginOptions = {}): ValueParser<"sync", URL> { options.allowedProtocols.map((protocol) => protocol.toLowerCase()), ); } + const allowIpLiterals = options.allowIpLiterals ?? true; return { mode: "sync", metavar, @@ -113,7 +132,21 @@ export function origin(options: OriginOptions = {}): ValueParser<"sync", URL> { error: message`The URL ${input} has no origin.`, }; } - return { success: true, value: new URL(url.origin) }; + // The normalized origin, not `url` itself: a `blob:` URL reports an + // empty `hostname` while its origin carries the authority embedded in + // it, so checking the original would miss `blob:http://127.0.0.1/x`. + const normalized = new URL(url.origin); + if ( + !allowIpLiterals && + (normalized.hostname.startsWith("[") || + IPV4_PATTERN.test(normalized.hostname)) + ) { + return { + success: false, + error: message`${input} names an IP address rather than a domain, which cannot take a subdomain.`, + }; + } + return { success: true, value: normalized }; }, format(value: URL): string { return value.origin; 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/builder.ts b/packages/graphql/src/builder.ts index a792ce8..1ef1627 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -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..57cc2b2 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -75,7 +75,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/harness.test.ts b/packages/graphql/src/harness.test.ts index dd6a44e..03badb3 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -161,17 +161,25 @@ 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. * @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"), ): 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, + }); 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..03e1e34 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -49,9 +49,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; } /** @@ -107,7 +111,7 @@ const fillOptions = ( mailer: opt.mailer ?? mockTransport(), emailFrom: opt.emailFrom ?? "noreply@drfed.org", 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..0f61911 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -247,6 +247,27 @@ const createInstanceMutation = ` `; describe("Mutation.createInstance", () => { + 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("creates an instance and adds the viewer as a member", async () => { await withTestHarness(async ({ db, post }) => { const auth = await authenticate(db); diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index 31d2335..ca364cc 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -20,6 +20,7 @@ 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", @@ -193,8 +194,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) { @@ -220,7 +221,7 @@ builder.mutationFields((t) => ({ if (local == null) { throw new Error("Failed to create local instance."); } - const host = `${slug}.${ctx.root}`; + const host = instanceHost(ctx.rootOrigin, slug); const [instance] = await tx .insert(schema.instances) .values({ id: uuid(), localId: local.id, host }) 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; From 2e6e279ffdd0ac8aebbf17b31d93d6d609fbf3c2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 16:58:01 +0900 Subject: [PATCH 06/13] Send login mail from the deployment's own domain emailFrom had the defect the previous commit removed from the root domain: it fell back to noreply@drfed.org, runServer never passed anything else, and no option existed to. So every installed deployment sent its login mail claiming to be drfed.org, through the operator's own SMTP server, which drfed.org's SPF and DMARC records do not authorize. Receiving hosts reject or junk that, which breaks signing in outright, and whatever does arrive is misattributed to the project. There is now a --email-from option, and when it is omitted the address is derived from the deployment's own root origin instead, as noreply@. The host name, not the authority: a development server on :8888 must not claim to be noreply@drfed.localhost:8888. Two normalizations came out of this. A root origin may be written with the root zone's trailing dot, and drfed.example. is not a valid email domain, so origin() now strips that dot while normalizing, as it already did for case, trailing slashes, paths and credentials. canonicalHostname() in @drfed/graphql/origin, which applied the same rule internally, is exported so the derivation can use it for callers that build a URL directly rather than going through the CLI. And a host name longer than 253 characters is refused outright, since Upyo will not build a message whose sender domain exceeds that; a configuration mistake belongs at startup, next to the option at fault, not inside every login request. https://github.com/fedify-dev/drfed/issues/77 AI provenance: this commit exists because Claude Code (Fable 5), while reviewing the previous one, noticed that emailFrom defaulted to the project's own domain; I decided on an optional flag with a derived default rather than a required one. Claude Code (Opus 5) made the change and wrote the tests. Codex (GPT-6 Astra) found the trailing-dot case, and also claimed a 253-character host name made Upyo throw; I tested that and reported it as false, which was itself wrong -- 253 is exactly the last accepted length, so I had landed on the boundary rather than disproved the concern. Claude Code (Fable 5) caught the off-by-one, and the guard above is the result. Fable also noticed that nothing verified the port stays out of the address. A related defect of the same class in DRFED_LOGIN_ORIGINS is filed separately as https://github.com/fedify-dev/drfed/issues/81. I verified the result with mise run check, mise run build and mise run test, and confirmed the Upyo length boundary directly. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- packages/drfed/src/index.ts | 3 +- packages/drfed/src/parser.test.ts | 27 ++++++++++ packages/drfed/src/parser.ts | 9 +++- packages/drfed/src/valueparser.test.ts | 23 +++++++++ packages/drfed/src/valueparser.ts | 22 +++++++++ packages/graphql/src/auth.test.ts | 68 ++++++++++++++++++++++++++ packages/graphql/src/builder.ts | 2 +- packages/graphql/src/harness.test.ts | 4 ++ packages/graphql/src/index.ts | 12 +++-- packages/graphql/src/origin.test.ts | 13 +++++ packages/graphql/src/origin.ts | 2 +- 11 files changed, 178 insertions(+), 7 deletions(-) diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 6d35142..bd3e842 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -66,10 +66,11 @@ async function runServer(options: ServerOptions) { ? new PgliteKvStore(credentials.client) : new PostgresKvStore(credentials.client); const federation = await createFederation(options.drizzle.db, { kv }); - const { mailer, rootOrigin } = options; + const { emailFrom, mailer, rootOrigin } = options; const yogaServer = createYogaServer(options.drizzle.db, federation, { rootOrigin, + emailFrom, mailer, loginOrigins, }); diff --git a/packages/drfed/src/parser.test.ts b/packages/drfed/src/parser.test.ts index f83e5fe..c0caf9d 100644 --- a/packages/drfed/src/parser.test.ts +++ b/packages/drfed/src/parser.test.ts @@ -115,6 +115,33 @@ describe("drfed-server", () => { 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. diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index 97be9ee..bbb1603 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -22,7 +22,7 @@ import { message, optionNames } from "@optique/core/message"; import { map, optional, withDefault } from "@optique/core/modifiers"; import type { InferValue } from "@optique/core/parser"; import { flag, option } from "@optique/core/primitives"; -import { socketAddress, url } from "@optique/core/valueparser"; +import { email, socketAddress, url } from "@optique/core/valueparser"; import { loggingOptions } from "@optique/logtape"; import { path } from "@optique/run/valueparser"; import { LogTapeTransport } from "@upyo/logtape"; @@ -124,6 +124,12 @@ const rootOriginParser = option( }, ); +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.`, + }), +); + const serverParser = object("DrFed server", { address: withDefault( option("--listen", "-l", socketAddress({ requirePort: true }), { @@ -146,6 +152,7 @@ const serverParser = object("DrFed server", { }), ), rootOrigin: rootOriginParser, + emailFrom: emailFromParser, mailer: smtpParser, seed: seedParser, }); diff --git a/packages/drfed/src/valueparser.test.ts b/packages/drfed/src/valueparser.test.ts index c040473..6ea3e18 100644 --- a/packages/drfed/src/valueparser.test.ts +++ b/packages/drfed/src/valueparser.test.ts @@ -137,6 +137,29 @@ describe("origin()", () => { assert.equal(parsed("https://1.drfed.net", options), "https://1.drfed.net"); }); + it("normalizes the root zone's trailing dot away", () => { + // `drfed.example.` and `drfed.example` name the same host, but the dot is + // not valid in an email address and confuses host comparisons downstream. + assert.equal(parsed("https://drfed.example."), "https://drfed.example"); + assert.equal( + parsed("http://drfed.localhost.:8888"), + "http://drfed.localhost:8888", + ); + }); + + 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("sees through a URL that hides its authority in its origin", () => { // A `blob:` URL reports an empty `hostname` while its origin carries the // authority embedded in it, so a check against the original URL would let diff --git a/packages/drfed/src/valueparser.ts b/packages/drfed/src/valueparser.ts index 9b07315..1ea6206 100644 --- a/packages/drfed/src/valueparser.ts +++ b/packages/drfed/src/valueparser.ts @@ -56,6 +56,11 @@ export interface OriginOptions { */ 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; + /** * Creates a {@link ValueParser} for web origins. * @@ -136,6 +141,23 @@ export function origin(options: OriginOptions = {}): ValueParser<"sync", URL> { // empty `hostname` while its origin carries the authority embedded in // it, so checking the original would miss `blob:http://127.0.0.1/x`. const normalized = new URL(url.origin); + // `example.com.` and `example.com` name the same host, but the URL + // parser keeps the root zone's dot and almost nothing downstream expects + // it -- it is not valid in an email address, for one. + if (normalized.hostname.endsWith(".")) { + normalized.hostname = normalized.hostname.slice(0, -1); + } + // The URL parser runs domain-to-ASCII leniently and so accepts host + // names DNS never could. Rejecting them here turns what would + // otherwise be a runtime failure far from its cause -- Upyo refuses to + // build a message whose sender domain is this long -- into a startup + // error naming the option at fault. + if (normalized.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.`, + }; + } if ( !allowIpLiterals && (normalized.hostname.startsWith("[") || 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 1ef1627..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; diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index 03badb3..6a68e94 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -164,12 +164,15 @@ export async function withTemporaryDatabase( * @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(); @@ -179,6 +182,7 @@ export async function withTestHarness( mailer, loginOrigins, rootOrigin, + emailFrom, }); const fetch: TestFetch = yoga.fetch.bind(yoga); diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 03e1e34..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. @@ -109,7 +111,11 @@ 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, rootOrigin: opt.rootOrigin, }); diff --git a/packages/graphql/src/origin.test.ts b/packages/graphql/src/origin.test.ts index 66afcb2..0129c1e 100644 --- a/packages/graphql/src/origin.test.ts +++ b/packages/graphql/src/origin.test.ts @@ -17,6 +17,7 @@ import assert from "node:assert/strict"; import { + canonicalHostname, classifyHost, instanceHost, instanceOrigin, @@ -57,6 +58,18 @@ describe("instanceHost()", () => { }); }); +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("instanceOrigin()", () => { it("keeps the root origin's scheme", () => { assert.equal( diff --git a/packages/graphql/src/origin.ts b/packages/graphql/src/origin.ts index d9283dc..42edf4e 100644 --- a/packages/graphql/src/origin.ts +++ b/packages/graphql/src/origin.ts @@ -101,7 +101,7 @@ export function classifyHost(url: URL, rootOrigin: URL): HostKind { * @param url The URL to read the host name from. * @returns The host name without its root-zone dot. */ -function canonicalHostname(url: URL): string { +export function canonicalHostname(url: URL): string { const { hostname } = url; return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname; } From 46cd67e3d0f1d070212d91be77515a640b664708 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 18:40:47 +0900 Subject: [PATCH 07/13] Serve each surface from its own authority Every host served both of DrFed's faces. An instance's subdomain answered /graphql, and the root origin answered ActivityPub paths -- 404, but only because no instance happened to claim that host. The separation the subdomains exist for was not actually enforced anywhere. Requests are now routed by authority alone. A subdomain one label below the root origin is an instance and serves ActivityPub only; the root origin and every other authority serve the control surface and never answer as an instance. Anything under the root domain but deeper than one label can never name an instance, so it is answered 421 rather than a bare 404, which tells whoever misconfigured the DNS something useful. The listening socket and internal proxy names fall on the control side deliberately: the frontend reaches the backend at 127.0.0.1, naming no instance at all. Routing asks the database nothing. A subdomain nobody has claimed still goes to ActivityPub, where every dispatcher resolves to nothing and the request ends in a 404 of its own. Two things the authority may be spelled with needed care. A server adapter can hand over a host that `URL` refuses -- srvx checks the `Host` header against a pattern and builds the request URL by concatenation, so `1.2.3.4.5` and undecodable A-labels arrive intact -- and parsing one threw where nothing was waiting to catch it, so a single unauthenticated request ended the process. An adapter may also substitute for a host it cannot read, as srvx does with `_invalid_`, which would have handed the control surface to a request that named a tenant. Both are answered 400, per RFC 9110 section 7.2. Going the other way, a reverse proxy may forward an authority that names an instance without matching its stored host: `Host: demo.drfed.net:443` forwarded over plain HTTP against a stored `demo.drfed.net`. The dispatchers now canonicalize before looking the instance up, so those resolve instead of 404ing. The canonicalization is applied to the lookup key and not to the request, so nothing in the signature verification path sees a synthesized request. Finally, changing a deployment's root origin strands any instance already created under the old one. Startup now says so. It does not repair anything: an instance's host is woven into actor URIs the rest of the fediverse has already stored, so rewriting it would break the federation it was meant to fix. https://github.com/fedify-dev/drfed/issues/77 AI provenance: I asked Claude Code (Opus 5) to implement the routing table I had decided on, along with the startup warning and the canonicalization deferred from an earlier commit in this series. Claude wrote the module, the wiring and the tests. The reviewers found four defects, all reproduced before fixing: Codex (GPT-6 Astra) found that an instance whose slug is a malformed A-label made the startup scan throw, keeping the whole deployment from starting, and that srvx's invalid-authority substitute let a tenant-looking request reach GraphQL with a 200. Claude Code (Fable 5) found that a single unauthenticated request with a host such as 1.2.3.4.5 killed the process, which I confirmed by running it against a live server, and that the dispatcher tolerance had no test that could fail. Fable also pointed out that a comment I had written claimed more than ships, since srvx rejects trailing-dot hosts before they reach the router; the comment now says what actually happens. The behavioural tests were mutation-tested by restoring the old behaviour one call site at a time. I verified the result with mise run check, mise run build and mise run test, and by exercising the whole routing table against a running server with a seeded instance. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- packages/drfed/package.json | 7 +- packages/drfed/src/index.ts | 13 +- packages/drfed/src/serving.test.ts | 241 ++++++++++++++++++++++++ packages/drfed/src/serving.ts | 193 +++++++++++++++++++ packages/graphql/src/federation.test.ts | 83 ++++++++ packages/graphql/src/federation.ts | 6 +- packages/graphql/src/origin.test.ts | 45 +++++ packages/graphql/src/origin.ts | 20 +- 8 files changed, 598 insertions(+), 10 deletions(-) create mode 100644 packages/drfed/src/serving.test.ts create mode 100644 packages/drfed/src/serving.ts diff --git a/packages/drfed/package.json b/packages/drfed/package.json index 5b75ecf..33442e6 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -49,6 +49,10 @@ "./valueparser": { "types": "./dist/valueparser.d.mts", "default": "./dist/valueparser.mjs" + }, + "./serving": { + "types": "./dist/serving.d.mts", + "default": "./dist/serving.mjs" } }, "files": [ @@ -62,7 +66,8 @@ "tsdown": { "entry": [ "src/index.ts", - "src/valueparser.ts" + "src/valueparser.ts", + "src/serving.ts" ], "dts": { "sourcemap": true, diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index bd3e842..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) => @@ -74,13 +75,13 @@ async function runServer(options: ServerOptions) { 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/serving.test.ts b/packages/drfed/src/serving.test.ts new file mode 100644 index 0000000..46d334e --- /dev/null +++ b/packages/drfed/src/serving.test.ts @@ -0,0 +1,241 @@ +// 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 () => { + // srvx 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(); + const hosts = ["1.2.3.4.5", "999.1.1.1", "xn--a.drfed.net"]; + 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", local: true }, + // Left behind by a root origin change. + { host: "there.drfed.org", local: true }, + // Also stranded: an instance occupies exactly one label. + { host: "deep.nested.drfed.net", local: true }, + // Remote instances are nobody's business here. + { host: "remote.example.com", local: false }, + // `xn--a` passes the slug constraint but is not decodable Punycode, + // so this host is not a URL at all. Reporting it must not throw: + // startup waits on this scan, and one bad row would otherwise keep + // the whole deployment from coming back up. + { host: "xn--a.drfed.net", local: true }, + ]; + const expires = new Date(Date.now() + dayInMilliseconds); + const seeded = rows.map(({ host, local }) => ({ + host, + localId: local ? uuidV7() : null, + slug: host.split(".")[0]!, + })); + 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(), [ + "deep.nested.drfed.net", + "there.drfed.org", + "xn--a.drfed.net", + ]); + 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..232f24b --- /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 instance + // whose slug is a malformed A-label, such as `xn--a`, composes one that + // `URL` refuses outright, and 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/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 57cc2b2..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); 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/origin.test.ts b/packages/graphql/src/origin.test.ts index 0129c1e..06bba66 100644 --- a/packages/graphql/src/origin.test.ts +++ b/packages/graphql/src/origin.test.ts @@ -18,6 +18,7 @@ import assert from "node:assert/strict"; import { canonicalHostname, + canonicalizeAuthority, classifyHost, instanceHost, instanceOrigin, @@ -70,6 +71,50 @@ describe("canonicalHostname()", () => { }); }); +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( diff --git a/packages/graphql/src/origin.ts b/packages/graphql/src/origin.ts index 42edf4e..deb4d55 100644 --- a/packages/graphql/src/origin.ts +++ b/packages/graphql/src/origin.ts @@ -133,5 +133,23 @@ function canonicalPort(url: URL): string { */ function canonicalAuthority(url: URL): string { const hostname = canonicalHostname(url); - return url.port === "" ? hostname : `${hostname}:${url.port}`; + 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; } From 749318de540e98729c56429ff68a8e094cf41f29 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 18:54:39 +0900 Subject: [PATCH 08/13] Answer for a bad slug, and say where an instance lives createInstance left slug validation to the database. A slug the check constraint refused came back as an unhandled DrizzleQueryError rather than as one of the errors the mutation is declared to return, so a client asking for `-foo` got an internal error instead of being told what was wrong with it. There is now an InvalidSlug member of CreateInstanceErrorType, and the check runs before the insert. Moving the check into TypeScript also lets it say something the constraint never could. A slug may begin with `xn--`, on purpose, so that an instance can carry an internationalized domain name -- but carrying the prefix is not the same as being Punycode. `xn--a` passes every shape rule and the constraint, and yet `new URL("https://xn--a.drfed.net")` throws, so an instance created under it could never be addressed at all. isValidSlug() now requires the label to round-trip through domainToASCII(). Postgres cannot decode Punycode, so the two halves are deliberately asymmetric from here on: the constraint guards the shape, and this guards the meaning. Instance also gains a `url` field carrying the absolute origin it is served at, so that callers stop assembling one themselves. A local instance follows this deployment's root origin, which is what makes a development instance come out as http with its port attached; a remote one is always https, since the root origin says nothing about hosts elsewhere on the fediverse. It is built by concatenation rather than through URL, so that a host left behind by an older, laxer rule yields a useless string rather than throwing in the middle of a query. Also fixes a lint warning committed by mistake in the previous commit, where a comment began with a lowercase word. https://github.com/fedify-dev/drfed/issues/77 AI provenance: I asked Claude Code (Opus 5) to wire the slug rule into the mutation and to expose the instance origin the frontend will need, and I decided that Punycode decodability belongs in TypeScript rather than being forced into the database constraint. Claude made the change and wrote the tests. Codex (GPT-6 Astra) reviewed and confirmed against a live database that the new field neither throws nor misreports for a stranded local instance or for a malformed host predating the stricter rule; Claude Code (Fable 5) reviewed independently and found nothing to fix. I verified the result with mise run check, mise run build and mise run test. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- packages/drfed/src/serving.test.ts | 6 +- packages/graphql/src/instance.test.ts | 130 ++++++++++++++++++++++++++ packages/graphql/src/instance.ts | 31 +++++- packages/models/src/slug.test.ts | 8 ++ packages/models/src/slug.ts | 12 ++- 5 files changed, 182 insertions(+), 5 deletions(-) diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts index 46d334e..5a9d9a6 100644 --- a/packages/drfed/src/serving.test.ts +++ b/packages/drfed/src/serving.test.ts @@ -118,9 +118,9 @@ describe("createFetchHandler()", () => { }); it("refuses a request whose URL disagrees with its Host header", async () => { - // srvx 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. + // 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." }, diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index 0f61911..59097ab 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,44 @@ 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` @@ -268,6 +322,62 @@ describe("Mutation.createInstance", () => { }, new URL("http://drfed.localhost:8888")); }); + it("rejects a slug that is not a usable domain name label", async () => { + // The database constraint would reject most of these too, but as an + // unhandled query error; and it cannot tell a decodable A-label from a + // malformed one at all. + await withTestHarness(async ({ db, post }) => { + const auth = await authenticate(db); + const slugs = [ + "-foo", + "foo-", + "abc", + "Foo-bar", + "ab--cd", + // Carries the A-label prefix but is not decodable Punycode. + "xn--a", + ]; + 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("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); @@ -831,6 +941,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 ca364cc..27c15e9 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -15,6 +15,7 @@ // 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"; @@ -36,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`.", @@ -145,7 +161,7 @@ builder.queryFields((t) => ({ export const CreateInstanceErrorType = builder.enumType( "CreateInstanceErrorType", { - values: ["SlugAlreadyTaken", "TooManyInstances"] as const, + values: ["InvalidSlug", "SlugAlreadyTaken", "TooManyInstances"] as const, }, ); @@ -205,6 +221,19 @@ 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, and which cannot tell a + // decodable A-label from a malformed one in any case. + 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, start and end with a letter or a " + + "digit, and if it begins with `xn--` it must be valid Punycode.", + }; + } let tooManyInstances = false; try { return await ctx.db.transaction(async (tx) => { diff --git a/packages/models/src/slug.test.ts b/packages/models/src/slug.test.ts index c08ff08..a64c649 100644 --- a/packages/models/src/slug.test.ts +++ b/packages/models/src/slug.test.ts @@ -54,6 +54,14 @@ describe("isValidSlug()", () => { } }); + it("rejects an xn-- label that is not decodable Punycode", () => { + // The prefix alone is not enough. A host name built from such a label + // is not a URL at all, so an instance carrying it could never be reached. + for (const slug of ["xn--a", "xn--aa", "xn--zzzz-"]) { + 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); diff --git a/packages/models/src/slug.ts b/packages/models/src/slug.ts index 779e4ac..0d8ed8e 100644 --- a/packages/models/src/slug.ts +++ b/packages/models/src/slug.ts @@ -14,6 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { domainToASCII } from "node:url"; + /** * 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 @@ -58,5 +60,13 @@ const A_LABEL_PREFIX = "xn--"; */ export function isValidSlug(slug: string): boolean { if (!SLUG_PATTERN.test(slug)) return false; - return !RESERVED_LDH_PATTERN.test(slug) || slug.startsWith(A_LABEL_PREFIX); + if (!RESERVED_LDH_PATTERN.test(slug)) return true; + if (!slug.startsWith(A_LABEL_PREFIX)) return false; + // Carrying the prefix is not enough: the label has to be Punycode that + // actually decodes. `xn--a` does not, and a host name built from it is not + // a URL at all, which strands the instance the moment anything tries to + // address it. This check has no counterpart in the database constraint, + // which cannot decode Punycode; the constraint guards the shape, and this + // guards the meaning. + return domainToASCII(slug) === slug; } From 8441d69b5541ebd86ab392823900cdecf9d2373a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 19:08:07 +0900 Subject: [PATCH 09/13] Link to the origin an instance is actually served at The instance page built its federation endpoint links by putting `https://` in front of the host name. That is right in production and wrong everywhere else: a development instance is served at http://slug.drfed.localhost:8888, and every link on the page pointed at an https URL that answers nowhere. The links now come from the origin the server reports, which is exactly what Instance.url was added for. The three hand-written list entries become one list built from a small helper, since they differed only in a label and a path. https://github.com/fedify-dev/drfed/issues/77 AI provenance: I asked Claude Code (Opus 5) to make the page consume the new field, and it wrote the change. Codex (GPT-6 Astra) and Claude Code (Fable 5) both reviewed and reported nothing to fix. Since this package has no test suite, I verified it by hand against a running mise run dev stack: signing in, creating an instance and opening its page now shows the host as legislature-comestible-lashes.drfed.localhost:8888 with all three endpoints linked as http URLs carrying that port. The linked endpoints themselves still answer 404 on a development server, which is expected and unrelated: no NodeInfo dispatcher is registered yet, the shared inbox only accepts POST, and WebFinger has no handle to resolve until an actor exists. An earlier commit in this series verified that a real actor and its WebFinger do resolve over an instance subdomain. Assisted-by: Claude Code:claude-opus-5 --- packages/web/src/routes/instance/[slug].tsx | 52 +++++++++++---------- 1 file changed, 28 insertions(+), 24 deletions(-) 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} +
+
+ )} +
From 6554c699c004cedc2f109988d7b15b6ddd024547 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 19:21:01 +0900 Subject: [PATCH 10/13] Document the subdomain layout Nine commits changed how DrFed is addressed and none of them said so outside the code. The README now has a section on the root origin: what it is required for, that each instance is a subdomain of it, what each authority is served, and what an operator has to arrange -- a wildcard DNS record, a matching wildcard certificate, and a reverse proxy that forwards both the Host header and the forwarded protocol. That last one is worth stating plainly: without `X-Forwarded-Proto: https` the request looks like plain HTTP and every actor URI DrFed mints names `http://`, which is not where the actor lives. The routing table is qualified rather than left to be read as host-based isolation. The authority has to match in full, port included, so a request on some other port is served the control surface rather than a 404. The options table gains --root-origin, --email-from and --smtp-url, and loses a stray parenthesis that had been sitting inside the backticks of the --listen default. The usage examples now pass --root-origin, since without it the command no longer starts. CONTRIBUTING.md gets the same option list, a pointer to where routing and authority composition live so that the next person changes them in one place, and a note that an option naming a web origin should use this repository's own origin() value parser rather than Optique's url(). Two corrections while there: it claimed every package with tests has a test script, which is now every package except the web frontend; and it told contributors to check before building, which fails on a fresh checkout because the type checker reads each package's dependencies out of their dist directories. CI has always built first. https://github.com/fedify-dev/drfed/issues/77 AI provenance: I asked Claude Code (Opus 5) to write up the behaviour this series introduced, and it drafted both documents. Codex (GPT-6 Astra) reviewed for accuracy against the code and found two operational omissions: the forwarded-protocol requirement, and that the routing table read as though GraphQL were isolated by host name when the port must match too. I verified the first against a running server before rewriting it -- forwarding only Host really does yield http actor URIs, and adding the header really does fix them -- and both are now documented. Claude Code (Fable 5) reviewed independently afterwards. No behaviour changed in this commit. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- CONTRIBUTING.md | 30 +++++++++++++-- packages/drfed/README.md | 83 ++++++++++++++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48b1432..74175fa 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,30 @@ 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 the `origin()` value parser in +*packages/drfed/src/valueparser.ts* rather than Optique's `url()`, so that +every spelling of the same origin is normalized the same way. + +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 +394,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/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. From dc387dd5c5b8386c6b460ca415d5c7a22f5274f8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 15 Sep 2026 20:38:21 +0900 Subject: [PATCH 11/13] Stop the slug rule from moving with the runtime CI failed on every platform while this branch passed locally. The cause was not the tests: mise.toml pins node to a floating major, so CI runs a different patch release than I do, with different bundled ICU, and the two disagree about whether `http://xn--a.drfed.net/` parses. Mine says no; CI says yes. isValidSlug() asked that same question, through domainToASCII(), to decide whether an `xn--` label was decodable Punycode. A rule every deployment has to agree on cannot be answered out of whichever ICU the local Node happens to bundle: the same slug would be accepted on one server and refused on another. So the function goes back to shape alone, and loses its dependency on node:url. What actually matters is narrower, and belongs where the answer only has to hold locally. createInstance now refuses a slug whose composed host this runtime cannot parse, since an instance nothing can address is worse than a rejected slug. That rejection carries its own message: a slug that reaches it has satisfied every shape rule, so quoting those rules back at the user would name conditions they met. The tests stop asserting IDNA outcomes. The unparseable hosts are now 1.2.3.4.5 and 999.1.1.1, which the WHATWG IPv4 host parser rejects by specification rather than by ICU, and the new guard is covered by a test that takes its expectation from URL.canParse() rather than hardcoding one, so it asserts that the guard and the runtime agree instead of prescribing what the runtime should say. A stored host that no longer parses stays tolerated: the startup scan reports it rather than throwing. generateActors does still throw over one, which is filed separately. https://github.com/fedify-dev/drfed/issues/77 AI provenance: the failure was mine to diagnose; I traced it to the floating node pin and decided the fix had to remove the runtime dependency from the rule rather than only from the assertions, since the production behaviour was the part that varied. Claude Code (Opus 5) made the change. Codex (GPT-6 Astra) reviewed and pointed out that the new guard had no test, and proposed deriving the expectation from URL.canParse() rather than hardcoding it, which is what the test now does. Claude Code (Fable 5) reviewed and found that the error message described rules the rejected slug satisfied, and separately that generateActors throws over such a host. I mutation tested the guard by deleting it and confirming the new test fails. Verified with mise run build, mise run check and mise run test. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- packages/drfed/src/serving.test.ts | 29 +++++++++-------- packages/drfed/src/serving.ts | 8 ++--- packages/graphql/src/instance.test.ts | 46 ++++++++++++++++++++------- packages/graphql/src/instance.ts | 28 +++++++++++++--- packages/models/src/slug.test.ts | 8 ----- packages/models/src/slug.ts | 19 +++++------ 6 files changed, 85 insertions(+), 53 deletions(-) diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts index 5a9d9a6..d6b52a3 100644 --- a/packages/drfed/src/serving.test.ts +++ b/packages/drfed/src/serving.test.ts @@ -136,7 +136,9 @@ describe("createFetchHandler()", () => { // 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(); - const hosts = ["1.2.3.4.5", "999.1.1.1", "xn--a.drfed.net"]; + // 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); @@ -188,24 +190,25 @@ describe("findStrandedInstances()", () => { const db = drizzle({ client, relations, schema }); const rows = [ // Reachable under the configured root origin. - { host: "here.drfed.net", local: true }, + { host: "here.drfed.net", slug: "here", local: true }, // Left behind by a root origin change. - { host: "there.drfed.org", local: true }, + { host: "there.drfed.org", slug: "there", local: true }, // Also stranded: an instance occupies exactly one label. - { host: "deep.nested.drfed.net", local: true }, + { host: "deep.nested.drfed.net", slug: "deep", local: true }, // Remote instances are nobody's business here. - { host: "remote.example.com", local: false }, - // `xn--a` passes the slug constraint but is not decodable Punycode, - // so this host is not a URL at all. Reporting it must not throw: - // startup waits on this scan, and one bad row would otherwise keep - // the whole deployment from coming back up. - { host: "xn--a.drfed.net", local: true }, + { 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, local }) => ({ + const seeded = rows.map(({ host, slug, local }) => ({ host, localId: local ? uuidV7() : null, - slug: host.split(".")[0]!, + slug, })); await db .insert(schema.localInstances) @@ -222,9 +225,9 @@ describe("findStrandedInstances()", () => { const stranded = await findStrandedInstances(db, rootOrigin); assert.deepEqual([...stranded].sort(), [ + "999.1.1.1", "deep.nested.drfed.net", "there.drfed.org", - "xn--a.drfed.net", ]); assert.ok(!stranded.includes("here.drfed.net")); // It only reports; nothing is rewritten. diff --git a/packages/drfed/src/serving.ts b/packages/drfed/src/serving.ts index 232f24b..05ff70d 100644 --- a/packages/drfed/src/serving.ts +++ b/packages/drfed/src/serving.ts @@ -156,10 +156,10 @@ export async function findStrandedInstances( return instances .map(({ host }) => host) .filter((host) => { - // A stored host need not be a parseable authority at all. An instance - // whose slug is a malformed A-label, such as `xn--a`, composes one that - // `URL` refuses outright, and a startup check is the last place that - // should throw over it. + // 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"; diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index 59097ab..6b4814c 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -323,20 +323,11 @@ describe("Mutation.createInstance", () => { }); it("rejects a slug that is not a usable domain name label", async () => { - // The database constraint would reject most of these too, but as an - // unhandled query error; and it cannot tell a decodable A-label from a - // malformed one at all. + // 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", - // Carries the A-label prefix but is not decodable Punycode. - "xn--a", - ]; + const slugs = ["-foo", "foo-", "abc", "Foo-bar", "ab--cd"]; const results = await Promise.all( slugs.map(async (slug) => { const response = await post( @@ -361,6 +352,37 @@ describe("Mutation.createInstance", () => { }); }); + 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. diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index 27c15e9..ed42f1b 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -222,16 +222,35 @@ builder.mutationFields((t) => ({ } const { account } = ctx; // Checked here rather than left to the database constraint, which - // surfaces as an unhandled query error, and which cannot tell a - // decodable A-label from a malformed one in any case. + // 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, start and end with a letter or a " + - "digit, and if it begins with `xn--` it must be valid Punycode.", + "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; @@ -250,7 +269,6 @@ builder.mutationFields((t) => ({ if (local == null) { throw new Error("Failed to create local instance."); } - const host = instanceHost(ctx.rootOrigin, slug); const [instance] = await tx .insert(schema.instances) .values({ id: uuid(), localId: local.id, host }) diff --git a/packages/models/src/slug.test.ts b/packages/models/src/slug.test.ts index a64c649..c08ff08 100644 --- a/packages/models/src/slug.test.ts +++ b/packages/models/src/slug.test.ts @@ -54,14 +54,6 @@ describe("isValidSlug()", () => { } }); - it("rejects an xn-- label that is not decodable Punycode", () => { - // The prefix alone is not enough. A host name built from such a label - // is not a URL at all, so an instance carrying it could never be reached. - for (const slug of ["xn--a", "xn--aa", "xn--zzzz-"]) { - 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); diff --git a/packages/models/src/slug.ts b/packages/models/src/slug.ts index 0d8ed8e..9aca19f 100644 --- a/packages/models/src/slug.ts +++ b/packages/models/src/slug.ts @@ -14,8 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { domainToASCII } from "node:url"; - /** * 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 @@ -53,6 +51,13 @@ const A_LABEL_PREFIX = "xn--"; * 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. @@ -60,13 +65,5 @@ const A_LABEL_PREFIX = "xn--"; */ export function isValidSlug(slug: string): boolean { if (!SLUG_PATTERN.test(slug)) return false; - if (!RESERVED_LDH_PATTERN.test(slug)) return true; - if (!slug.startsWith(A_LABEL_PREFIX)) return false; - // Carrying the prefix is not enough: the label has to be Punycode that - // actually decodes. `xn--a` does not, and a host name built from it is not - // a URL at all, which strands the instance the moment anything tries to - // address it. This check has no counterpart in the database constraint, - // which cannot decode Punycode; the constraint guards the shape, and this - // guards the meaning. - return domainToASCII(slug) === slug; + return !RESERVED_LDH_PATTERN.test(slug) || slug.startsWith(A_LABEL_PREFIX); } From 08b8a7faca1383d15d4287bbcf707a60d518263c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 16 Sep 2026 17:49:06 +0900 Subject: [PATCH 12/13] Use Optique's own origin() value parser Optique 1.3.0 ships an origin() value parser, so the one this branch wrote by hand is gone. The library's does the same normalization and more of it: credentials are refused rather than quietly dropped, opaque schemes are refused when the allow list is built rather than when a value arrives, and how much of a URL beyond the origin to tolerate is a choice rather than a fixed rule. What remains is a wrapper, because two of the rules on this option are not properties of origins in general. The root origin may not name an IP address, since every instance is a subdomain of it and foo.127.0.0.1 is not a host name; and its host may not run past the 253 octets DNS allows, since login mail is sent from that domain and the mail library will not build a message with a longer one. Both came out of review on this branch, and the built-in has no option for either. The wrapper forwards the parser member by member instead of spreading it. origin() exposes placeholder as a getter, and spreading would have called it once and handed every caller the same mutable URL. It also forwards validate(), which Optique uses for values that did not come from the command line; without it that check degrades to format() followed by parse(), and since format() emits only the origin, a value carrying credentials would have been laundered into an accepted one. The two rules live in one helper both parse() and validate() call, so they cannot come to different conclusions about the same value. The unknown-option error in 1.3.0 names the offending token, so the test for the retired --root-domain now asserts that name appears instead of matching a generic message. https://github.com/fedify-dev/drfed/issues/77 AI provenance: the maintainer pointed out the new release and asked for the swap. Claude Code (Opus 5) made the change and decided to keep a wrapper rather than lose the two review-derived rules. Codex (GPT-6 Astra) found that the wrapper had dropped validate(), which it reproduced, and then verified member-for-member parity with the built-in once that was fixed. Claude Code (Fable 5) found that a doc comment had been orphaned, so the published declaration shipped undocumented, and that credential rejection was asserted only on the validate path. I verified each against the built output before acting, and mutation tested validate() by deleting it. Checked with mise run build, mise run check and mise run test. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- CONTRIBUTING.md | 9 +- packages/drfed/src/parser.test.ts | 4 +- packages/drfed/src/parser.ts | 10 +- packages/drfed/src/valueparser.test.ts | 183 ++++++++------------- packages/drfed/src/valueparser.ts | 212 ++++++++++--------------- pnpm-lock.yaml | 168 ++++++++++---------- pnpm-workspace.yaml | 12 +- 7 files changed, 257 insertions(+), 341 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74175fa..4b7a8c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -373,9 +373,12 @@ The server currently supports: Keep CLI options explicit and documented through Optique descriptions, because those descriptions feed the generated help output. Options that name a web -origin should use the `origin()` value parser in -*packages/drfed/src/valueparser.ts* rather than Optique's `url()`, so that -every spelling of the same origin is normalized the same way. +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 diff --git a/packages/drfed/src/parser.test.ts b/packages/drfed/src/parser.test.ts index c0caf9d..8d7d64e 100644 --- a/packages/drfed/src/parser.test.ts +++ b/packages/drfed/src/parser.test.ts @@ -99,7 +99,9 @@ describe("drfed-server", () => { "--root-domain=drfed.net", ]); assert.notEqual(code, 0); - assert.match(stderr, /No matching option or argument found/u); + // 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 }); } diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index bbb1603..8ba2240 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -31,7 +31,7 @@ import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; import postgres from "postgres"; -import { origin } from "./valueparser.ts"; +import { rootOrigin } from "./valueparser.ts"; const pgliteParser = map( option( @@ -112,13 +112,7 @@ const seedParser = option("--dev-seed", { const rootOriginParser = option( "--root-origin", "-r", - origin({ - allowedProtocols: ["http:", "https:"], - // Every instance is a subdomain of this origin, and an IP address cannot - // have one. - allowIpLiterals: false, - metavar: "ORIGIN", - }), + 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"}.`, }, diff --git a/packages/drfed/src/valueparser.test.ts b/packages/drfed/src/valueparser.test.ts index 6ea3e18..3f79c0a 100644 --- a/packages/drfed/src/valueparser.test.ts +++ b/packages/drfed/src/valueparser.test.ts @@ -16,31 +16,34 @@ import assert from "node:assert/strict"; -import { origin } from "@drfed/drfed/valueparser"; +import { rootOrigin } from "@drfed/drfed/valueparser"; import { describe, it } from "@logtape/testing-node/autoload"; -function parse(input: string, options?: Parameters[0]) { - return origin(options).parse(input); +const parser = rootOrigin(); + +function parse(input: string) { + return parser.parse(input); } -function parsed(input: string, options?: Parameters[0]): string { - const result = parse(input, options); +function parsed(input: string): string { + const result = parse(input); assert.ok(result.success, `expected ${input} to parse`); return result.value.origin; } -describe("origin()", () => { - it("normalizes anything that reduces to the same 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://example.com", - "https://example.com/", - "HTTPS://Example.COM", - "https://example.com/path/to/thing", - "https://example.com/?query=1#fragment", - "https://user:pw@example.com/", - "https://example.com:443/", + "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://example.com"); + assert.equal(parsed(input), "https://drfed.net", input); } }); @@ -49,102 +52,43 @@ describe("origin()", () => { parsed("http://drfed.localhost:8888"), "http://drfed.localhost:8888", ); - assert.equal(parsed("http://example.com:80/"), "http://example.com"); + assert.equal(parsed("http://drfed.net:80/"), "http://drfed.net"); }); - it("returns a URL that is its own origin", () => { - const result = parse("https://example.com/path"); - assert.ok(result.success); - assert.equal(result.value.href, "https://example.com/"); - assert.equal(result.value.pathname, "/"); - assert.equal(result.value.search, ""); - assert.equal(result.value.username, ""); + 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 ["", "example.com", "/path", "https://"]) { + for (const input of ["", "drfed.net", "/path", "https://"]) { assert.equal(parse(input).success, false, input); } }); - it("rejects protocols outside the allow list", () => { - const options = { allowedProtocols: ["http:", "https:"] } as const; - assert.equal(parsed("https://example.com", options), "https://example.com"); - assert.equal(parsed("http://example.com", options), "http://example.com"); - assert.equal(parse("ftp://example.com", options).success, false); - }); - - it("matches allowed protocols case-insensitively", () => { - assert.equal( - parsed("https://example.com", { allowedProtocols: ["HTTPS:"] }), - "https://example.com", - ); - }); - - it("rejects URLs without a tuple origin", () => { - for (const input of ["mailto:someone@example.com", "data:,hello"]) { - assert.equal(parse(input).success, false, input); - } - }); - - it("rejects a malformed allow list at construction time", () => { - assert.throws(() => origin({ allowedProtocols: [] }), TypeError); - // Missing the trailing colon would otherwise construct fine and then - // reject every input, reporting the rejected protocol as an allowed one. - assert.throws(() => origin({ allowedProtocols: ["https"] }), TypeError); - assert.throws( - () => origin({ allowedProtocols: ["https:", "ftp"] }), - TypeError, - ); - }); - - it("round-trips through format() and normalize()", () => { - const parser = origin(); - const result = parser.parse("https://example.com/path"); - assert.ok(result.success); - assert.equal(parser.format(result.value), "https://example.com"); - const reparsed = parser.parse(parser.format(result.value)); - assert.ok(reparsed.success); - assert.equal(reparsed.value.href, result.value.href); - assert.equal( - parser.normalize?.(new URL("https://example.com/path")).href, - "https://example.com/", - ); - }); - - it("accepts IP literals by default", () => { - assert.equal(parsed("http://127.0.0.1:8888"), "http://127.0.0.1:8888"); - assert.equal(parsed("http://[::1]:8888"), "http://[::1]:8888"); + 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); }); - it("rejects IP literals when they cannot take a subdomain", () => { - const options = { allowIpLiterals: false } as const; - // `foo.127.0.0.1` and `foo.[::1]` are not host names; prefixing a label - // to either of these makes a URL that does not parse at all. + // 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 are the same host as 127.0.0.1. + // dotted quad, so these name the same host as 127.0.0.1. "http://0x7f.1", "http://2130706433", ]) { - assert.equal(parse(input, options).success, false, input); + assert.equal(parse(input).success, false, input); } - assert.equal(parsed("https://drfed.net", options), "https://drfed.net"); // A name that merely begins with digits is still a name. - assert.equal(parsed("https://1.drfed.net", options), "https://1.drfed.net"); - }); - - it("normalizes the root zone's trailing dot away", () => { - // `drfed.example.` and `drfed.example` name the same host, but the dot is - // not valid in an email address and confuses host comparisons downstream. - assert.equal(parsed("https://drfed.example."), "https://drfed.example"); - assert.equal( - parsed("http://drfed.localhost.:8888"), - "http://drfed.localhost:8888", - ); + assert.equal(parsed("https://1.drfed.net"), "https://1.drfed.net"); }); it("rejects a host name longer than a domain name may be", () => { @@ -160,37 +104,50 @@ describe("origin()", () => { assert.equal(parsed(`https://${longest}.`), `https://${longest}`); }); - it("sees through a URL that hides its authority in its origin", () => { - // A `blob:` URL reports an empty `hostname` while its origin carries the - // authority embedded in it, so a check against the original URL would let - // an IP address through. - for (const input of [ - "blob:http://127.0.0.1:8888/id", - "blob:http://[::1]/id", - ]) { - assert.equal( - parse(input, { allowIpLiterals: false }).success, + 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, - input, - ); + ], + ] as const) { + assert.equal(parser.validate(new URL(input)).success, valid, input); } - // Still accepted when IP literals are allowed, normalized to the origin. - assert.equal( - parsed("blob:http://127.0.0.1:8888/id"), - "http://127.0.0.1:8888", - ); }); - it("offers a placeholder that is a valid origin", () => { - assert.equal(origin().placeholder.origin, "http://0.invalid"); + 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( - origin({ allowedProtocols: ["https:"] }).placeholder.origin, - "https://0.invalid", + parser.normalize?.(new URL("https://drfed.net/path")).href, + "https://drfed.net/", ); }); it("uses ORIGIN as the default metavar", () => { - assert.equal(origin().metavar, "ORIGIN"); - assert.equal(origin({ metavar: "ROOT" }).metavar, "ROOT"); + 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 index 1ea6206..cc07173 100644 --- a/packages/drfed/src/valueparser.ts +++ b/packages/drfed/src/valueparser.ts @@ -14,167 +14,125 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { message, values } from "@optique/core/message"; +import { message } from "@optique/core/message"; import { type NonEmptyString, type ValueParser, + type ValueParserResult, ensureNonEmptyString, + origin, } from "@optique/core/valueparser"; /** - * Options for the {@link origin} value parser. + * 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 OriginOptions { +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; - - /** - * List of allowed URL protocols (e.g., `["http:", "https:"]`). Protocol - * names must include the trailing colon. If not specified, any protocol - * that yields a tuple origin is allowed. - */ - readonly allowedProtocols?: readonly string[]; - - /** - * Whether to accept an origin whose host is an IP address rather than a - * domain name. Set this to `false` when the origin has to be able to take - * a subdomain, since `foo.127.0.0.1` and `foo.[::1]` are not host names at - * all. - * @default `true` - */ - readonly allowIpLiterals?: boolean; } /** - * 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. + * 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. */ -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; +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 web origins. + * 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. * - * The parser accepts any absolute URL and *normalizes* it down to its origin - * rather than rejecting the extra components, so every one of - * `HTTPS://Example.COM`, `https://example.com/`, and - * `https://user:pw@example.com/path?query#fragment` parses to the same - * `https://example.com`. Only two kinds of input are rejected: strings that - * are not absolute URLs at all, and URLs whose protocol is either outside - * {@link OriginOptions.allowedProtocols} or has no tuple origin (`mailto:`, - * `data:`, and friends, whose origin is the opaque `"null"`), and, when - * {@link OriginOptions.allowIpLiterals} is off, origins whose host is an IP - * address. - * @param options Configuration options for the origin parser. - * @returns A {@link ValueParser} that converts string input into `URL` - * objects that are guaranteed to equal their own origin. + * 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 origin(options: OriginOptions = {}): ValueParser<"sync", URL> { +export function rootOrigin( + options: RootOriginOptions = {}, +): ValueParser<"sync", URL> { const metavar = options.metavar ?? "ORIGIN"; ensureNonEmptyString(metavar); - let allowedProtocols: readonly string[] | undefined; - if (options.allowedProtocols != null) { - if (options.allowedProtocols.length < 1) { - throw new TypeError("allowedProtocols must not be empty."); - } - for (const protocol of options.allowedProtocols) { - // Without the trailing colon an entry can never match `URL.protocol`, - // so the parser would reject every input while reporting the rejected - // protocol as an allowed one. Fail loudly at construction instead. - if (!/^[a-z][a-z0-9+\-.]*:$/iu.test(protocol)) { - throw new TypeError( - "Each allowed protocol must be a valid protocol ending with " + - `a colon (e.g., "https:"), got: ${JSON.stringify(protocol)}.`, - ); - } - } - allowedProtocols = Object.freeze( - options.allowedProtocols.map((protocol) => protocol.toLowerCase()), - ); - } - const allowIpLiterals = options.allowIpLiterals ?? true; + const inner = origin({ + allowedProtocols: ["http:", "https:"], + metavar, + }); return { mode: "sync", metavar, - // A getter, so that every access yields a fresh `URL` that callers may - // mutate without corrupting the parser. `.invalid` is reserved by - // RFC 2606 and can never resolve. + // 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 new URL(`${allowedProtocols?.[0] ?? "http:"}//0.invalid`); + return inner.placeholder; }, parse(input: string) { - if (!URL.canParse(input)) { - return { - success: false, - error: message`Invalid origin: ${input}.`, - }; - } - const url = new URL(input); - if ( - allowedProtocols != null && - !allowedProtocols.includes(url.protocol) - ) { - return { - success: false, - error: message`URL protocol ${url.protocol} is not allowed. Allowed protocols: ${values([...allowedProtocols])}.`, - }; - } - // `URL.origin` is the string `"null"` for schemes without a tuple - // origin, which `new URL()` cannot parse back. Such URLs can never - // name a host, so they are not origins in any useful sense. - if (url.origin === "null") { - return { - success: false, - error: message`The URL ${input} has no origin.`, - }; - } - // The normalized origin, not `url` itself: a `blob:` URL reports an - // empty `hostname` while its origin carries the authority embedded in - // it, so checking the original would miss `blob:http://127.0.0.1/x`. - const normalized = new URL(url.origin); - // `example.com.` and `example.com` name the same host, but the URL - // parser keeps the root zone's dot and almost nothing downstream expects - // it -- it is not valid in an email address, for one. - if (normalized.hostname.endsWith(".")) { - normalized.hostname = normalized.hostname.slice(0, -1); - } - // The URL parser runs domain-to-ASCII leniently and so accepts host - // names DNS never could. Rejecting them here turns what would - // otherwise be a runtime failure far from its cause -- Upyo refuses to - // build a message whose sender domain is this long -- into a startup - // error naming the option at fault. - if (normalized.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.`, - }; - } - if ( - !allowIpLiterals && - (normalized.hostname.startsWith("[") || - IPV4_PATTERN.test(normalized.hostname)) - ) { - return { - success: false, - error: message`${input} names an IP address rather than a domain, which cannot take a subdomain.`, - }; - } - return { success: true, value: normalized }; + 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 value.origin; + return inner.format(value); }, normalize(value: URL): URL { - return value.origin === "null" ? value : new URL(value.origin); + return inner.normalize?.(value) ?? value; + }, + suggest(prefix: string) { + return inner.suggest?.(prefix) ?? []; }, }; } 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" From ae485b46a489ee1d3a35d346f88cf7bc133c8b43 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 17 Sep 2026 16:20:21 +0900 Subject: [PATCH 13/13] Hold the slug form to the rule the server enforces The create-instance form validated slugs more loosely than the server does: its regex allowed -foo, foo- and ab--cd, all of which isValidSlug() and the database constraint refuse. The schema now mirrors that rule, so the form cannot accept a slug the mutation will turn away. Nothing is broken today. The field is read-only and filled from faker's noun list, whose thousand words all lowercase to [a-z0-9-], never begin or end with a hyphen, and never contain a double one, so three of them joined always satisfy the rule. The divergence would only surface if the field became editable, which is why it is worth closing now rather than after. The rule is copied rather than imported. This package depends on no @drfed package and reaches the server only over GraphQL; taking a dependency on the model layer for a check that exists to save a round trip would trade a real boundary for a small convenience. The server remains the authority, and the copy carries a comment saying so. https://github.com/fedify-dev/drfed/pull/83#discussion_r4024603091 AI provenance: reviewing the pull request, dodok8 asked whether faker-generated slugs satisfy the tightened rule and said the form should either match it or rely on faker. I asked Claude Code (Opus 5) to check rather than reason about it; enumerating faker's dictionary showed the generated slugs always conform, so the question resolved to the second half, and I chose to match the rule anyway because the form was looser than the server in ways nothing prevents a future editable field from hitting. Claude made the change and fuzzed the schema against isValidSlug() over 400000 inputs without finding a disagreement. Codex (GPT-6 Astra) reviewed, compared the two exhaustively over 66430 inputs, and reported nothing to fix. Verified with mise run build, mise run check and mise run test. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra --- .../web/src/routes/workspace/create/instance.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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--`.", ), ), });