Skip to content
2 changes: 1 addition & 1 deletion packages/drfed/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
DRFED_LOGIN_ORIGINS=https://drfed.example.com,http://localhost:3000
DRFED_LOGIN_ORIGINS=https://drfed.example.com,https://drfed.exeample2.com
DRFED_ROOT_ORIGIN=http://drfed.localhost:8888
26 changes: 18 additions & 8 deletions packages/drfed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ Usage
-----

~~~~ sh
drfed-server --root-origin https://drfed.example.com --data-path .pgdata
drfed-server --root-origin https://drfed.example.com \
--login-origin https://drfed.example.com --data-path .pgdata
drfed-server --root-origin https://drfed.example.com \
--login-origin https://drfed.example.com \
--database-url postgres://localhost/drfed
~~~~

Expand All @@ -33,7 +35,8 @@ 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
drfed-server --root-origin http://drfed.localhost:8888 \
--login-origin http://localhost:3000 --data-path .pgdata
~~~~

Requests are routed by the authority they arrive on:
Expand Down Expand Up @@ -71,15 +74,16 @@ 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
-----------
Login origins
-------------

`DRFED_LOGIN_ORIGINS` is required and accepts a comma-separated list of HTTP
or HTTPS origins allowed in email login links:
`--login-origin` specifies an HTTP or HTTPS origin allowed in email login
links. At least one is required; repeat the option to allow multiple origins:

~~~~ sh
DRFED_LOGIN_ORIGINS=https://drfed.example.com,http://localhost:3000 \
drfed-server --root-origin https://drfed.example.com --data-path .pgdata
drfed-server --root-origin https://drfed.example.com --data-path .pgdata \
--login-origin https://drfed.example.com \
--login-origin http://localhost:3000
~~~~

For repository development, create the environment file loaded by
Expand All @@ -89,6 +93,11 @@ For repository development, create the environment file loaded by
cp packages/drfed/.env.example packages/drfed/.env
~~~~

`mise run dev` reads `DRFED_LOGIN_ORIGINS` as a comma-separated list and passes
each value as a `--login-origin` option. If unset, it defaults to
`http://localhost:3000`. The installed CLI does not read this variable;
pass `--login-origin` explicitly.

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*
Expand All @@ -101,6 +110,7 @@ Options
| Option | Short | Description |
| ------------------------- | ----- | -------------------------------------------------------------------- |
| `--root-origin ORIGIN` | `-r` | Origin instances are subdomains of (required) |
| `--login-origin ORIGIN` | | Origin allowed in login links (required; may be repeated) |
| `--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 |
Expand Down
20 changes: 1 addition & 19 deletions packages/drfed/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,6 @@ 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) =>
value.trim(),
);
if (values == null || values.some((value) => value === "")) {
throw new TypeError("DRFED_LOGIN_ORIGINS must contain valid origins.");
}
const loginOrigins = new Set(
values.map((value) => {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new TypeError(
`Unsupported login origin protocol: ${url.protocol}`,
);
}
return url.origin;
}),
);

const { credentials } = options.drizzle;
if (options.drizzle.migrate) await migrate({ credentials });
if (options.seed) await seedData(options.drizzle.db);
Expand All @@ -67,7 +49,7 @@ async function runServer(options: ServerOptions) {
? new PgliteKvStore(credentials.client)
: new PostgresKvStore(credentials.client);
const federation = await createFederation(options.drizzle.db, { kv });
const { emailFrom, mailer, rootOrigin } = options;
const { emailFrom, mailer, rootOrigin, loginOrigins } = options;

const yogaServer = createYogaServer(options.drizzle.db, federation, {
rootOrigin,
Expand Down
134 changes: 134 additions & 0 deletions packages/drfed/src/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// DrFed: A web-based platform for developing and debugging ActivityPub apps
// Copyright (C) 2026 DrFed team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { it } from "node:test";
import { fileURLToPath } from "node:url";

const serverTimeout = 30_000;
const requestTimeout = 5_000;
const binary = fileURLToPath(
new URL("../bin/drfed-server.mjs", import.meta.url),
);

// oxlint-disable-next-line max-statements
it("normalizes CLI login origins and preserves the allowlist", async () => {
// The CLI requires a nonzero port; obtain an available one from the OS.
const socket = createServer();
socket.listen(0, "127.0.0.1");
await once(socket, "listening");
const address = socket.address();
assert.ok(address != null && typeof address !== "string");
await socket[Symbol.asyncDispose]();

const dataPath = await mkdtemp(join(tmpdir(), "drfed-login-test-"));
const child = spawn(
process.execPath,
[
binary,
"--data-path",
dataPath,
`--listen=127.0.0.1:${address.port}`,
"--root-origin=https://drfed.example",
"--login-origin=https://app.example.",
"--login-origin=http://127.0.0.1:3000",
"--login-origin=http://[::1]:3000",
],
{
env: { PATH: process.env.PATH ?? "" },
timeout: serverTimeout,
killSignal: "SIGKILL",
stdio: ["ignore", "pipe", "pipe"],
},
);
const ready = Promise.withResolvers<void>();
const closed = Promise.withResolvers<void>();
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
if (stdout.includes("Listening on:")) ready.resolve();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.once("error", ready.reject);
child.once("close", (code, signal) => {
ready.reject(
new Error(`CLI exited before listening (${code}, ${signal}): ${stderr}`),
);
closed.resolve();
});

try {
await ready.promise;
const endpoint = `http://127.0.0.1:${address.port}/graphql`;
await Promise.all(
[
"https://app.example",
"http://127.0.0.1:3000",
"http://[::1]:3000",
"https://untrusted.example",
].map(async (origin) => {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
signal: AbortSignal.timeout(requestTimeout),
body: JSON.stringify({
query: `
mutation Login($email: Email!, $verifyUrl: URITemplate!) {
loginByEmail(email: $email, verifyUrl: $verifyUrl) {
challengeId
}
}
`,
variables: {
email: "unknown@example.com",
verifyUrl: `${origin}/verify?challengeId={challengeId}&code={code}`,
},
}),
});
assert.equal(response.status, 200);
const body = await response.json();
if (origin === "https://untrusted.example") {
assert.equal(body.data, null);
assert.equal(
body.errors[0].message,
`Verify URL origin is not allowed: ${origin}.`,
);
} else {
assert.equal(
body.errors,
undefined,
`${origin}: ${JSON.stringify(body)}`,
);
assert.ok(body.data.loginByEmail.challengeId);
}
}),
);
} finally {
child.kill("SIGTERM");
await closed.promise;
await rm(dataPath, { force: true, recursive: true });
}
});
26 changes: 12 additions & 14 deletions packages/drfed/src/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ 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.
// or takes the schema-generation branch, which needs no database at all.
const binary = join(
dirname(fileURLToPath(import.meta.url)),
"..",
Expand All @@ -50,10 +48,8 @@ async function run(
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.
// A deliberately minimal environment, independent of what the
// developer happens to have exported.
env: { PATH: process.env.PATH ?? "" },
timeout: commandTimeout,
},
Expand Down Expand Up @@ -86,10 +82,8 @@ describe("drfed-server", () => {
});

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.
// If the retired option were reinstated, parsing would instead stop on
// the missing --login-origin, which says something else entirely.
const dataPath = await mkdtemp(join(tmpdir(), "drfed-parser-test-"));
try {
const { code, stderr } = await run([
Expand Down Expand Up @@ -121,24 +115,28 @@ describe("drfed-server", () => {
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.
// required --login-origin option.
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);
assert.match(
accepted.stderr,
/Expected at least 1 values, but got only 0\./u,
);

const rejected = await run([
"--data-path",
dataPath,
"--root-origin=https://drfed.net",
"--login-origin=https://drfed.net",
"--email-from=not-an-address",
]);
assert.notEqual(rejected.code, 0);
assert.doesNotMatch(rejected.stderr, /DRFED_LOGIN_ORIGINS/u);
assert.match(rejected.stderr, /Expected a valid email address/u);
} finally {
await rm(dataPath, { force: true, recursive: true });
}
Expand Down
19 changes: 17 additions & 2 deletions packages/drfed/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ import { PGlite } from "@electric-sql/pglite";
import { getLogger } from "@logtape/drizzle-orm";
import { merge, object, or } from "@optique/core/constructs";
import { message, optionNames } from "@optique/core/message";
import { map, optional, withDefault } from "@optique/core/modifiers";
import { map, multiple, optional, withDefault } from "@optique/core/modifiers";
import type { InferValue } from "@optique/core/parser";
import { flag, option } from "@optique/core/primitives";
import { email, socketAddress, url } from "@optique/core/valueparser";
import { email, origin, socketAddress, url } from "@optique/core/valueparser";
import { loggingOptions } from "@optique/logtape";
import { path } from "@optique/run/valueparser";
import { LogTapeTransport } from "@upyo/logtape";
Expand Down Expand Up @@ -124,6 +124,20 @@ const emailFromParser = optional(
}),
);

const loginOriginParser = map(
multiple(
option(
"--login-origin",
origin({ allowedProtocols: ["http:", "https:"] }),
{
description: message`The frontend origin allowed in email login links.`,
},
),
{ min: 1 },
),
(values) => new Set(values.map((value) => value.origin)),
);

const serverParser = object("DrFed server", {
address: withDefault(
option("--listen", "-l", socketAddress({ requirePort: true }), {
Expand All @@ -146,6 +160,7 @@ const serverParser = object("DrFed server", {
}),
),
rootOrigin: rootOriginParser,
loginOrigins: loginOriginParser,
emailFrom: emailFromParser,
mailer: smtpParser,
seed: seedParser,
Expand Down
Loading
Loading