From b1d494d87adc554a347b06bf50441a58e21ac9ec Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Mon, 13 Jul 2026 23:22:16 +0900
Subject: [PATCH 1/4] Add `loginTokens` model and `sessions` model and the
relations
Assisted-by: Claude Code:claude-opus-4-8
Assisted-by: Claude Code:claude-fable-5
Assisted-by: Codex:gpt-5-6-sol
---
.../20260716000333_email_auth/migration.sql | 20 +
.../20260716000333_email_auth/snapshot.json | 575 ++++++++++++++++++
packages/models/src/relations.ts | 23 +
packages/models/src/schema.ts | 46 ++
4 files changed, 664 insertions(+)
create mode 100644 packages/models/drizzle/20260716000333_email_auth/migration.sql
create mode 100644 packages/models/drizzle/20260716000333_email_auth/snapshot.json
diff --git a/packages/models/drizzle/20260716000333_email_auth/migration.sql b/packages/models/drizzle/20260716000333_email_auth/migration.sql
new file mode 100644
index 0000000..b249846
--- /dev/null
+++ b/packages/models/drizzle/20260716000333_email_auth/migration.sql
@@ -0,0 +1,20 @@
+CREATE TABLE "login_tokens" (
+ "id" uuid PRIMARY KEY,
+ "accountId" uuid NOT NULL,
+ "tokenHash" varchar(64) NOT NULL UNIQUE,
+ "codeHash" varchar(64) NOT NULL,
+ "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ "expires" timestamp with time zone DEFAULT CURRENT_TIMESTAMP + INTERVAL '15 minutes' NOT NULL,
+ "consumed" timestamp with time zone
+);
+--> statement-breakpoint
+CREATE TABLE "sessions" (
+ "id" uuid PRIMARY KEY,
+ "accountId" uuid NOT NULL,
+ "tokenHash" varchar(64) NOT NULL UNIQUE,
+ "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ "expires" timestamp with time zone DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 month' NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "login_tokens" ADD CONSTRAINT "login_tokens_accountId_accounts_id_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE CASCADE;--> statement-breakpoint
+ALTER TABLE "sessions" ADD CONSTRAINT "sessions_accountId_accounts_id_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE CASCADE;
\ No newline at end of file
diff --git a/packages/models/drizzle/20260716000333_email_auth/snapshot.json b/packages/models/drizzle/20260716000333_email_auth/snapshot.json
new file mode 100644
index 0000000..f3e0982
--- /dev/null
+++ b/packages/models/drizzle/20260716000333_email_auth/snapshot.json
@@ -0,0 +1,575 @@
+{
+ "version": "8",
+ "dialect": "postgres",
+ "id": "3cf3ea04-80a5-4f1b-80a7-9b1ced252582",
+ "prevIds": ["15845826-f718-42c3-85f2-fed368f261f4"],
+ "ddl": [
+ {
+ "isRlsEnabled": false,
+ "name": "accounts",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "instance_members",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "instances",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "login_tokens",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "sessions",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "email",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "varchar(100)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "admin",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "instanceId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "admin",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accepted",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "varchar(100)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "slug",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "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": "instances"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tokenHash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "codeHash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'",
+ "generated": null,
+ "identity": null,
+ "name": "expires",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "consumed",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tokenHash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'",
+ "generated": null,
+ "identity": null,
+ "name": "expires",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ {
+ "value": "accountId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": "\"accepted\" IS NOT NULL",
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "instance_members_accountId_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ {
+ "value": "instanceId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": "\"accepted\" IS NOT NULL",
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "instance_members_instanceId_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["accountId"],
+ "schemaTo": "public",
+ "tableTo": "accounts",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "NO ACTION",
+ "name": "instance_members_accountId_accounts_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["instanceId"],
+ "schemaTo": "public",
+ "tableTo": "instances",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "NO ACTION",
+ "name": "instance_members_instanceId_instances_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["accountId"],
+ "schemaTo": "public",
+ "tableTo": "accounts",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "login_tokens_accountId_accounts_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["accountId"],
+ "schemaTo": "public",
+ "tableTo": "accounts",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "sessions_accountId_accounts_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "columns": ["instanceId", "accountId"],
+ "nameExplicit": false,
+ "name": "instance_members_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "accounts_pkey",
+ "schema": "public",
+ "table": "accounts",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "instances_pkey",
+ "schema": "public",
+ "table": "instances",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "login_tokens_pkey",
+ "schema": "public",
+ "table": "login_tokens",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "sessions_pkey",
+ "schema": "public",
+ "table": "sessions",
+ "entityType": "pks"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["email"],
+ "nullsNotDistinct": false,
+ "name": "accounts_email_key",
+ "schema": "public",
+ "table": "accounts",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["slug"],
+ "nullsNotDistinct": false,
+ "name": "instances_slug_key",
+ "schema": "public",
+ "table": "instances",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["tokenHash"],
+ "nullsNotDistinct": false,
+ "name": "login_tokens_tokenHash_key",
+ "schema": "public",
+ "table": "login_tokens",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["tokenHash"],
+ "nullsNotDistinct": false,
+ "name": "sessions_tokenHash_key",
+ "schema": "public",
+ "table": "sessions",
+ "entityType": "uniques"
+ },
+ {
+ "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'",
+ "name": "accounts_email_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "value": "trim(both from \"name\") <> ''",
+ "name": "accounts_name_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "value": "\"slug\" ~ '^[a-z0-9-]{4,100}$'",
+ "name": "instances_slug_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "value": "\"expires\" < (\"created\" + INTERVAL '1 year')",
+ "name": "instances_expires_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "instances"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts
index f730edd..c1848df 100644
--- a/packages/models/src/relations.ts
+++ b/packages/models/src/relations.ts
@@ -17,6 +17,7 @@ import { defineRelations } from "drizzle-orm";
import * as schema from "./schema.ts";
+// oxlint-disable-next-line eslint/max-lines-per-function
export const relations = defineRelations(schema, (r) => ({
accounts: {
instances: r.many.instances({
@@ -35,6 +36,14 @@ export const relations = defineRelations(schema, (r) => ({
accepted: { isNotNull: true },
},
}),
+ sessions: r.many.sessions({
+ from: r.accounts.id,
+ to: r.sessions.accountId,
+ }),
+ loginTokens: r.many.loginTokens({
+ from: r.accounts.id,
+ to: r.loginTokens.accountId,
+ }),
},
instanceMembers: {
account: r.one.accounts({
@@ -64,6 +73,20 @@ export const relations = defineRelations(schema, (r) => ({
},
}),
},
+ sessions: {
+ account: r.one.accounts({
+ from: r.sessions.accountId,
+ to: r.accounts.id,
+ optional: false,
+ }),
+ },
+ loginTokens: {
+ account: r.one.accounts({
+ from: r.loginTokens.accountId,
+ to: r.accounts.id,
+ optional: false,
+ }),
+ },
}));
export default relations;
diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts
index 38ba83c..d2ba8af 100644
--- a/packages/models/src/schema.ts
+++ b/packages/models/src/schema.ts
@@ -109,3 +109,49 @@ export const instanceMembers = pgTable(
export type InstanceMember = typeof instanceMembers.$inferSelect;
export type NewInstanceMember = typeof instanceMembers.$inferInsert;
+
+/**
+ * Tokens for email login. `tokenHash` and `codeHash` store SHA-256 hex digests,
+ * not the raw secrets. The `tokenHash` field is used for lookup and
+ * the `codeHash` field is the hash of the raw code that is sent to the user's
+ * email.
+ */
+export const loginTokens = pgTable("login_tokens", {
+ id: uuid().primaryKey(),
+ accountId: uuid()
+ .notNull()
+ .references(() => accounts.id, { onDelete: "cascade" }),
+ tokenHash: varchar({ length: 64 }).notNull().unique(),
+ codeHash: varchar({ length: 64 }).notNull(),
+ created: timestamp({ withTimezone: true })
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ expires: timestamp({ withTimezone: true })
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP + INTERVAL '15 minutes'`),
+ consumed: timestamp({ withTimezone: true }),
+});
+
+export type LoginToken = typeof loginTokens.$inferSelect;
+export type NewLoginToken = typeof loginTokens.$inferInsert;
+
+/**
+ * Authenticated sessions. The `id` field is used to revoke a session, and
+ * the `tokenHash` is the hash of the bearer access token.
+ */
+export const sessions = pgTable("sessions", {
+ id: uuid().primaryKey(),
+ accountId: uuid()
+ .notNull()
+ .references(() => accounts.id, { onDelete: "cascade" }),
+ tokenHash: varchar({ length: 64 }).notNull().unique(),
+ created: timestamp({ withTimezone: true })
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ expires: timestamp({ withTimezone: true })
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP + INTERVAL '1 month'`),
+});
+
+export type Session = typeof sessions.$inferSelect;
+export type NewSession = typeof sessions.$inferInsert;
From fb7bf53916488164d914e78af66df21fcb073818 Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 16 Jul 2026 22:10:16 +0900
Subject: [PATCH 2/4] Add `loginByEmail`, `completeLoginChallenge`, and
`revokeSession`
---
packages/graphql/package.json | 4 +
packages/graphql/src/auth.test.ts | 159 ++++++++++++++++++++++++
packages/graphql/src/auth/challenge.ts | 119 ++++++++++++++++++
packages/graphql/src/auth/entry.ts | 35 ++++++
packages/graphql/src/auth/errors.ts | 43 +++++++
packages/graphql/src/auth/expand.ts | 75 +++++++++++
packages/graphql/src/auth/hash.ts | 64 ++++++++++
packages/graphql/src/auth/magic-link.ts | 98 +++++++++++++++
packages/graphql/src/auth/mail.ts | 57 +++++++++
packages/graphql/src/auth/revoke.ts | 66 ++++++++++
packages/graphql/src/builder.ts | 34 ++++-
packages/graphql/src/harness.test.ts | 10 +-
packages/graphql/src/index.ts | 72 ++++++++++-
packages/graphql/src/schema.ts | 3 +-
14 files changed, 832 insertions(+), 7 deletions(-)
create mode 100644 packages/graphql/src/auth.test.ts
create mode 100644 packages/graphql/src/auth/challenge.ts
create mode 100644 packages/graphql/src/auth/entry.ts
create mode 100644 packages/graphql/src/auth/errors.ts
create mode 100644 packages/graphql/src/auth/expand.ts
create mode 100644 packages/graphql/src/auth/hash.ts
create mode 100644 packages/graphql/src/auth/magic-link.ts
create mode 100644 packages/graphql/src/auth/mail.ts
create mode 100644 packages/graphql/src/auth/revoke.ts
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index 0ca4f60..6837d14 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -88,16 +88,20 @@
"@electric-sql/pglite": "catalog:",
"@logtape/testing-node": "catalog:",
"@types/node": "catalog:",
+ "@upyo/mock": "^0.5.0",
"tsdown": "catalog:",
"typescript": "catalog:"
},
"dependencies": {
"@drfed/models": "workspace:*",
+ "@fedify/uri-template": "^2.3.1",
"@logtape/graphql-yoga": "catalog:",
"@logtape/logtape": "catalog:",
"@pothos/core": "^4.13.0",
"@pothos/plugin-drizzle": "^0.17.4",
+ "@pothos/plugin-errors": "^4.9.1",
"@pothos/plugin-relay": "^4.7.0",
+ "@upyo/core": "^0.5.0",
"drizzle-orm": "catalog:",
"graphql": "^16.14.2",
"graphql-scalars": "^1.25.0",
diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts
new file mode 100644
index 0000000..b4037aa
--- /dev/null
+++ b/packages/graphql/src/auth.test.ts
@@ -0,0 +1,159 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// oxlint-disable max-lines-per-function max-statements no-magic-numbers
+
+import { deepEqual, equal, ok } from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import { schema } from "@drfed/models";
+
+import { withTestHarness } from "./harness.test.ts";
+
+const okStatus = 200;
+const accountId = "00000000-0000-4000-8000-000000000001";
+const email = "noreply@drfed.org";
+const verifyUrl = "https://drfed.org/transports/mock?token={token}&code={code}";
+
+const loginMutation = `
+ mutation Login($email: Email!, $verifyUrl: String) {
+ loginByEmail(email: $email, verifyUrl: $verifyUrl) {
+ ... on SendMail {
+ token
+ }
+ }
+ }
+`;
+
+const completeLoginMutation = `
+ mutation CompleteLogin($token: UUID!, $code: String!) {
+ completeLoginChallenge(token: $token, code: $code) {
+ id
+ accessToken
+ account {
+ uuid
+ email
+ }
+ }
+ }
+`;
+
+const viewerQuery = `
+ query Viewer {
+ viewer {
+ uuid
+ email
+ }
+ }
+`;
+
+const revokeSessionMutation = `
+ mutation RevokeSession($session: UUID!) {
+ revokeSession(session: $session) {
+ revoke
+ }
+ }
+`;
+
+const loginUrlPattern =
+ /https:\/\/drfed\.org\/transports\/mock\?token=[0-9a-f-]+&code=[0-9a-z]+/u;
+
+describe("email authentication", () => {
+ it("logs in, authenticates the viewer, and revokes the session", async () => {
+ await withTestHarness(async ({ db, mailer, post }) => {
+ await db.insert(schema.accounts).values({
+ id: accountId,
+ email,
+ name: "Login Test",
+ });
+
+ const loginResponse = await post({
+ query: loginMutation,
+ variables: { email, verifyUrl },
+ });
+ equal(loginResponse.status, okStatus);
+
+ const loginBody = await loginResponse.json();
+ equal(loginBody.errors, undefined);
+
+ const { token } = loginBody.data.loginByEmail;
+ equal(typeof token, "string");
+
+ const messages = mailer.getSentMessages();
+ equal(messages.length, 1);
+
+ const [message] = messages;
+ ok(message);
+ equal(typeof message.content.text, "string");
+
+ const urlMatch = message.content.text?.match(loginUrlPattern);
+ ok(urlMatch);
+
+ const loginUrl = new URL(urlMatch[0]);
+ equal(loginUrl.origin, "https://drfed.org");
+ equal(loginUrl.pathname, "/transports/mock");
+ equal(loginUrl.searchParams.get("token"), token);
+
+ const code = loginUrl.searchParams.get("code");
+ ok(code);
+
+ const completeResponse = await post({
+ query: completeLoginMutation,
+ variables: { token, code },
+ });
+ equal(completeResponse.status, okStatus);
+
+ const completeBody = await completeResponse.json();
+ equal(completeBody.errors, undefined);
+
+ const session = completeBody.data.completeLoginChallenge;
+ ok(session);
+ equal(session.account.uuid, accountId);
+ equal(session.account.email, email);
+ equal(typeof session.accessToken, "string");
+
+ const authorization = {
+ headers: { authorization: `Bearer ${session.accessToken}` },
+ };
+ const viewerResponse = await post({ query: viewerQuery }, authorization);
+ equal(viewerResponse.status, okStatus);
+ deepEqual(await viewerResponse.json(), {
+ data: { viewer: { uuid: accountId, email } },
+ });
+
+ const revokeResponse = await post(
+ {
+ query: revokeSessionMutation,
+ variables: { session: session.id },
+ },
+ authorization,
+ );
+ equal(revokeResponse.status, okStatus);
+ deepEqual(await revokeResponse.json(), {
+ data: { revokeSession: { revoke: true } },
+ });
+
+ const revokedViewerResponse = await post(
+ { query: viewerQuery },
+ authorization,
+ );
+ equal(revokedViewerResponse.status, okStatus);
+ deepEqual(await revokedViewerResponse.json(), {
+ data: { viewer: null },
+ });
+ });
+ });
+});
diff --git a/packages/graphql/src/auth/challenge.ts b/packages/graphql/src/auth/challenge.ts
new file mode 100644
index 0000000..b953dbd
--- /dev/null
+++ b/packages/graphql/src/auth/challenge.ts
@@ -0,0 +1,119 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// oxlint-disable no-magic-numbers
+
+import type { Database } from "@drfed/models";
+import { loginTokens, sessions } from "@drfed/models/schema";
+import { and, eq, gt, isNull } from "drizzle-orm/sql/expressions";
+
+import builder, { type UserContext } from "../builder.ts";
+import { LoginChallengeError } from "./errors.ts";
+import { equalSecretHashes, generateAccessToken, hashSecret } from "./hash.ts";
+
+const SessionRef = builder.drizzleObject("sessions", {
+ name: "Session",
+ fields: (t) => ({
+ id: t.expose("id", { type: "UUID" }),
+ accessToken: t.string({
+ nullable: true,
+ resolve(session) {
+ const accessToken =
+ "accessToken" in session ? session.accessToken : null;
+ return typeof accessToken === "string" ? accessToken : null;
+ },
+ }),
+ account: t.relation("account"),
+ created: t.expose("created", { type: "DateTime" }),
+ expires: t.expose("expires", { type: "DateTime" }),
+ }),
+});
+
+builder.mutationFields((t) => ({
+ completeLoginChallenge: t.drizzleField({
+ type: SessionRef,
+ nullable: true,
+ description: "Complete login challenge.",
+ args: {
+ token: t.arg({ type: "UUID", required: true }),
+ code: t.arg({ type: "String", required: true }),
+ },
+ async resolve(query, _root, { token, code }, ctx) {
+ try {
+ const tokenHash = await hashSecret(token.toLowerCase());
+ const now = new Date(Temporal.Now.instant().toString());
+ const row = await findToken(tokenHash, now, ctx);
+ const id = crypto.randomUUID();
+ const accessToken = generateAccessToken();
+ const accessHash = await hashSecret(accessToken);
+
+ await verifyCode(code, row.codeHash);
+ await ctx.db.transaction(async (tx) => {
+ await consumeToken(row.id, now, tx);
+ await insertSession(id, row.accountId, accessHash, tx);
+ });
+
+ const session = await ctx.db.query.sessions.findFirst(
+ query({ where: { id } }),
+ );
+ return session == null ? null : { ...session, accessToken };
+ } catch (error) {
+ if (error instanceof LoginChallengeError) {
+ return null;
+ }
+ throw error;
+ }
+ },
+ }),
+}));
+
+const findToken = async (tokenHash: string, now: Date, ctx: UserContext) =>
+ (await ctx.db.query.loginTokens.findFirst({
+ where: { tokenHash, expires: { gt: now }, consumed: { isNull: true } },
+ })) ??
+ new LoginChallengeError(
+ `Can't find an alive login token: ${tokenHash}.`,
+ ).throw();
+
+const verifyCode = async (userCode: string, dbCodeHash: string) =>
+ (await equalSecretHashes(
+ await hashSecret(userCode.toLowerCase()),
+ dbCodeHash,
+ )) ||
+ new LoginChallengeError(`The user's code and DB's one don't match`).throw();
+
+const consumeToken = async (tokenId: string, now: Date, tx: Database) =>
+ (
+ await tx
+ .update(loginTokens)
+ .set({ consumed: now })
+ .where(
+ and(
+ eq(loginTokens.id, tokenId),
+ isNull(loginTokens.consumed),
+ gt(loginTokens.expires, now),
+ ),
+ )
+ .returning({ consumed: loginTokens.consumed })
+ )[0]?.consumed ??
+ new LoginChallengeError("Updating `consumed` failed.").throw();
+
+const insertSession = async (
+ id: string,
+ accountId: string,
+ tokenHash: string,
+ tx: Database,
+) => await tx.insert(sessions).values({ id, accountId, tokenHash });
diff --git a/packages/graphql/src/auth/entry.ts b/packages/graphql/src/auth/entry.ts
new file mode 100644
index 0000000..d739369
--- /dev/null
+++ b/packages/graphql/src/auth/entry.ts
@@ -0,0 +1,35 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// oxlint-disable import/no-unassigned-import
+import "./magic-link.ts";
+import "./challenge.ts";
+import "./revoke.ts";
+import builder from "../builder.ts";
+
+builder.queryFields((t) => ({
+ viewer: t.drizzleField({
+ type: "accounts",
+ nullable: true,
+ description: "`Account` if authorized, else `null`",
+ resolve: (query, _root, _args, ctx) =>
+ ctx.session == null
+ ? null
+ : ctx.db.query.accounts.findFirst(
+ query({ where: { id: ctx.session.accountId } }),
+ ),
+ }),
+}));
diff --git a/packages/graphql/src/auth/errors.ts b/packages/graphql/src/auth/errors.ts
new file mode 100644
index 0000000..da97b01
--- /dev/null
+++ b/packages/graphql/src/auth/errors.ts
@@ -0,0 +1,43 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+// oxlint-disable func-names func-style id-length
+
+// oxlint-disable max-classes-per-file no-throw-literal
+export class Throwable extends Error {
+ throw(): never {
+ throw this;
+ }
+}
+
+export class LoginChallengeError extends Throwable {
+ constructor(message: string) {
+ super("Error while login challenge.");
+ this.name = "LoginChallengeError";
+ this.message = message;
+ }
+}
+
+export class VerifyUrlExpandingError extends Throwable {
+ constructor(message: string) {
+ super("Error while expand verify URL.");
+ this.name = "VerifyUrlExpandingError";
+ this.message = message;
+ }
+}
+
+export function throwError(error: Error): never {
+ throw error;
+}
diff --git a/packages/graphql/src/auth/expand.ts b/packages/graphql/src/auth/expand.ts
new file mode 100644
index 0000000..fbaa281
--- /dev/null
+++ b/packages/graphql/src/auth/expand.ts
@@ -0,0 +1,75 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { Template } from "@fedify/uri-template";
+import { getLogger } from "@logtape/logtape";
+
+import { VerifyUrlExpandingError } from "./errors.ts";
+
+export interface ExpandVerifyUrlParams {
+ template: string | undefined | null;
+ token: `${string}-${string}-${string}-${string}-${string}`;
+ code: string;
+ origins: ReadonlySet;
+}
+
+export default function expandVerifyUrl({
+ template,
+ token,
+ code,
+ origins,
+}: ExpandVerifyUrlParams): string | null {
+ // Showing the errors occurring here to the user poses a security threat,
+ // so they are handled as `null` and then processed as a fallback in
+ // `expandVerifyUrl`.
+ try {
+ if (template == null) {
+ return null;
+ }
+ const url = expandUrl(template, token, code);
+ assertAllow(url, origins);
+ return url.toString();
+ } catch (error) {
+ // Almost of errors that can occur in this code could be
+ logger.warn(`Error while expand verify URL: {error}`, { error });
+ return null;
+ }
+}
+
+function expandUrl(template: string, token: string, code: string) {
+ const expanded = Template.parse(template).expand({ token, code });
+ if (!(expanded.includes(token) && expanded.includes(code))) {
+ throw new VerifyUrlExpandingError(
+ // oxlint-disable-next-line prefer-template
+ "`template` doesn't seem have `token` or `code` variables. `template:`" +
+ template,
+ );
+ }
+ const url = URL.canParse(expanded)
+ ? new URL(expanded)
+ : new VerifyUrlExpandingError(
+ `Unsupported verify URL scheme: ${template}.`,
+ ).throw();
+ return url;
+}
+
+const assertAllow = (url: URL, origins: ReadonlySet) =>
+ !origins.has(url.origin) &&
+ new VerifyUrlExpandingError(
+ `Origin not allowed for verify URL: ${url.origin}.`,
+ ).throw();
+
+const logger = getLogger(["drfed", "graphql", "expand"]);
diff --git a/packages/graphql/src/auth/hash.ts b/packages/graphql/src/auth/hash.ts
new file mode 100644
index 0000000..eff57d8
--- /dev/null
+++ b/packages/graphql/src/auth/hash.ts
@@ -0,0 +1,64 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+// oxlint-disable func-names func-style id-length
+
+// oxlint-disable no-magic-numbers
+const BASE36_CHAR = "0123456789abcdefghijklmnopqrstuvwxyz";
+
+// It's not strictly uniform random sampling, but it's negligible enough.
+export const generateBase36Code = (len: number): string =>
+ Array.from(
+ // Choose `Uint16Array` cause `2 ** 16 % 36 = 16`
+ // It's greater than `2 ** 8 % 36 = 2 ** 32 % 36 = 4`.
+ crypto.getRandomValues(new Uint16Array(len)),
+ (num) => BASE36_CHAR[num % BASE36_CHAR.length],
+ ).join("");
+
+export const hashSecret = async (raw: string): Promise =>
+ new Uint8Array(
+ await crypto.subtle.digest("SHA-256", textEncoder.encode(raw)),
+ ).toHex();
+
+const textEncoder = new TextEncoder();
+
+export async function equalSecretHashes(
+ leftHash: string,
+ rightHash: string,
+): Promise {
+ const macs = (await Promise.all([leftHash, rightHash].map(getHmac))).map(
+ (buf) => new Uint8Array(buf),
+ );
+
+ // oxlint-disable-next-line no-bitwise id-length
+ const difference = macs[0]!.reduce((p, c, i) => p | (c ^ macs[1]![i]!), 0);
+
+ return difference === 0;
+}
+const compareKey = await crypto.subtle.generateKey(
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"],
+);
+const getHmac = (hash: string) =>
+ crypto.subtle.sign("HMAC", compareKey, textEncoder.encode(hash));
+
+const ACCESS_TOKEN_BYTES = 32;
+
+export const generateAccessToken = (): string =>
+ crypto.getRandomValues(new Uint8Array(ACCESS_TOKEN_BYTES)).toBase64({
+ alphabet: "base64url",
+ omitPadding: true,
+ });
diff --git a/packages/graphql/src/auth/magic-link.ts b/packages/graphql/src/auth/magic-link.ts
new file mode 100644
index 0000000..ea60909
--- /dev/null
+++ b/packages/graphql/src/auth/magic-link.ts
@@ -0,0 +1,98 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// oxlint-disable no-magic-numbers
+
+import { schema } from "@drfed/models";
+
+import builder, { type UserContext } from "../builder.ts";
+import { generateBase36Code, hashSecret } from "./hash.ts";
+import { logReceipt, sendMail } from "./mail.ts";
+
+interface SendMailShape {
+ readonly token: string;
+}
+
+const SendMailRef = builder.objectRef("SendMail").implement({
+ description: "The mail sent.",
+ fields: (t) => ({
+ token: t.expose("token", {
+ type: "UUID",
+ description: "Token for login.",
+ }),
+ }),
+});
+
+builder.mutationFields((t) => ({
+ loginByEmail: t.field({
+ type: SendMailRef,
+ description: "Send a magic link to email. Always returns `token: UUID`.",
+ args: {
+ email: t.arg({
+ type: "Email",
+ required: true,
+ description: "The email address of the `Account`.",
+ }),
+ verifyUrl: t.arg({
+ type: "String",
+ required: false,
+ description:
+ "The URL's origin must be in the server's allowlist. " +
+ "When omitted, the email carries the raw token and code.",
+ }),
+ },
+ async resolve(_root, { email, verifyUrl }, ctx) {
+ const token = crypto.randomUUID();
+ const account = await findAccount(email, ctx);
+ if (account == null) {
+ // Returned token even if the account does not exist to prevent account
+ // enumeration. Consider mailing in a worker to prevent timing attacks.
+ return { token };
+ }
+ const verifier = {
+ token,
+ template: verifyUrl,
+ code: generateBase36Code(CODE_LEN),
+ };
+
+ await insertToken(account.id, verifier, ctx);
+ // Do not check the email was sent. Instructs users to request a resend if
+ // the email does not arrive after a few minutes at the web page.
+ logReceipt(await sendMail(account.email, verifier, ctx));
+
+ return { token };
+ },
+ }),
+}));
+
+const CODE_LEN = 6;
+
+const findAccount = async (email: string, ctx: UserContext) =>
+ await ctx.db.query.accounts.findFirst({
+ where: { email },
+ });
+
+const insertToken = async (
+ accountId: string,
+ { token, code }: { token: string; code: string },
+ ctx: UserContext,
+) =>
+ await ctx.db.insert(schema.loginTokens).values({
+ id: crypto.randomUUID(),
+ accountId,
+ tokenHash: await hashSecret(token),
+ codeHash: await hashSecret(code),
+ });
diff --git a/packages/graphql/src/auth/mail.ts b/packages/graphql/src/auth/mail.ts
new file mode 100644
index 0000000..bde71a8
--- /dev/null
+++ b/packages/graphql/src/auth/mail.ts
@@ -0,0 +1,57 @@
+// 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 { getLogger } from "@logtape/logtape";
+import { type MessageContent, type Receipt, createMessage } from "@upyo/core";
+
+import type { UserContext } from "../builder.ts";
+import expandVerifyUrl, { type ExpandVerifyUrlParams } from "./expand.ts";
+
+export const sendMail = async (
+ to: string,
+ verifier: Omit,
+ ctx: UserContext,
+) =>
+ await ctx.mailer.send(
+ createMessage({
+ from: ctx.emailFrom,
+ to,
+ subject: "Sign in to DrFed",
+ content: renderLoginEmail({ ...verifier, origins: ctx.origins }),
+ }),
+ );
+
+const renderLoginEmail = (verifier: ExpandVerifyUrlParams): MessageContent => ({
+ text: `Hello, Welcome to DrFed! If you request to login to DrFed, please ${
+ // Prevent over 80.
+ linkOrRaw(expandVerifyUrl(verifier), verifier)
+ }. Otherwise, please just ignore this mail.`,
+});
+
+const linkOrRaw = (
+ link: string | null,
+ { token, code }: ExpandVerifyUrlParams,
+) => (link == null ? `use token: ${token} and code: ${code}` : `open ${link}`);
+
+export function logReceipt(receipt: Receipt): void {
+ if (receipt.successful) {
+ logger.debug(`${receipt.messageId} sent successfully.`);
+ return;
+ }
+ logger.warn(`Sending a mail failed: ${receipt.errorMessages.join("\n")}`);
+}
+
+const logger = getLogger(["drfed", "graphql", "auth"]);
diff --git a/packages/graphql/src/auth/revoke.ts b/packages/graphql/src/auth/revoke.ts
new file mode 100644
index 0000000..362a2f9
--- /dev/null
+++ b/packages/graphql/src/auth/revoke.ts
@@ -0,0 +1,66 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// oxlint-disable no-magic-numbers
+
+import { sessions } from "@drfed/models/schema";
+import { and, eq } from "drizzle-orm/sql/expressions";
+
+import builder, { type UserContext } from "../builder.ts";
+
+builder.mutationFields((t) => ({
+ revokeSession: t.field({
+ type: LogoutSuccessRef,
+ description: "Revokes a session. Return always `revoke: true`.",
+ args: {
+ session: t.arg({
+ type: "UUID",
+ required: true,
+ description: "The session ID to revoke.",
+ }),
+ },
+ async resolve(_query, { session }, ctx) {
+ if (ctx.session != null) {
+ await deleteSession(session, ctx);
+ }
+ // Return always true to prevent brute-force attack.
+ return { revoke: true };
+ },
+ }),
+}));
+
+interface LogoutSuccessShape {
+ revoke: boolean;
+}
+
+const LogoutSuccessRef = builder
+ .objectRef("LogoutSuccess")
+ .implement({
+ description: "The session revoked.",
+ fields: (t) => ({
+ revoke: t.expose("revoke", {
+ type: "Boolean",
+ description: "Revoking status.",
+ }),
+ }),
+ });
+
+const deleteSession = (id: string, ctx: UserContext) =>
+ ctx.db
+ .delete(sessions)
+ .where(
+ and(eq(sessions.id, id), eq(sessions.accountId, ctx.session!.accountId)),
+ );
diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts
index 029f2e6..8ada1cd 100644
--- a/packages/graphql/src/builder.ts
+++ b/packages/graphql/src/builder.ts
@@ -13,10 +13,14 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+
import { type Database, normalizeEmail, relations } from "@drfed/models";
+import type { Account, Session } from "@drfed/models/schema";
import SchemaBuilder, { type ObjectRef } from "@pothos/core";
import DrizzlePlugin from "@pothos/plugin-drizzle";
+import ErrorsPlugin from "@pothos/plugin-errors";
import RelayPlugin from "@pothos/plugin-relay";
+import type { Transport } from "@upyo/core";
import { getTableConfig } from "drizzle-orm/pg-core";
import { DateTimeResolver, UUIDResolver } from "graphql-scalars";
@@ -35,13 +39,38 @@ export interface ServerContext {
* The database instance.
*/
readonly db: Database;
+
+ /**
+ * Email sending services.
+ */
+ readonly mailer: Transport;
+
+ /**
+ * Email address to send.
+ */
+ readonly emailFrom: string;
+
+ /**
+ * Origin list.
+ */
+ readonly origins: ReadonlySet;
}
/**
* The user-related context data for the GraphQL server, which include every
* field from the {@link ServerContext}.
*/
-export interface UserContext extends ServerContext {}
+export interface UserContext extends ServerContext {
+ /**
+ * Session information.
+ */
+ readonly session?: Session;
+
+ /**
+ * Current viewer.
+ */
+ readonly account?: Account;
+}
export interface SchemaTypes {
Context: UserContext;
@@ -84,7 +113,8 @@ export const builder = new SchemaBuilder({
getTableConfig,
relations,
},
- plugins: [DrizzlePlugin, RelayPlugin],
+ plugins: [DrizzlePlugin, RelayPlugin, ErrorsPlugin],
+ errors: { defaultTypes: [] },
});
builder.addScalarType("DateTime", DateTimeResolver);
diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts
index f679fca..31a5bfb 100644
--- a/packages/graphql/src/harness.test.ts
+++ b/packages/graphql/src/harness.test.ts
@@ -17,6 +17,7 @@ import { createYogaServer } from "@drfed/graphql";
import type { ServerContext, UserContext } from "@drfed/graphql/builder";
import { type Database, migrate, relations, schema } from "@drfed/models";
import { PGlite } from "@electric-sql/pglite";
+import { MockTransport } from "@upyo/mock";
import { drizzle } from "drizzle-orm/pglite";
import type { YogaServerInstance } from "graphql-yoga";
@@ -56,6 +57,11 @@ export interface TestHarness {
*/
readonly db: Database;
+ /**
+ * The temporary mailer.
+ */
+ readonly mailer: MockTransport;
+
/**
* The test server's `fetch()` function, bound to the Yoga server instance.
*/
@@ -152,11 +158,13 @@ export async function withTestHarness(
callback: (harness: TestHarness) => Promise | T,
): Promise> {
return await withTemporaryDatabase(async (db) => {
- const yoga = createYogaServer(db);
+ const mailer = new MockTransport();
+ const yoga = createYogaServer(db, { mailer });
const fetch: TestFetch = yoga.fetch.bind(yoga);
const harness: TestHarness = {
db,
+ mailer,
fetch,
yoga,
post(body, init) {
diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts
index ea95121..115ec9a 100644
--- a/packages/graphql/src/index.ts
+++ b/packages/graphql/src/index.ts
@@ -13,33 +13,99 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+
import type { Database } from "@drfed/models";
import { getYogaLogger } from "@logtape/graphql-yoga";
+import { getLogger } from "@logtape/logtape";
+import type { Transport } from "@upyo/core";
+import { MockTransport } from "@upyo/mock";
import {
type YogaServerInstance,
createYoga,
useExecutionCancellation,
} from "graphql-yoga";
+import { hashSecret } from "./auth/hash.ts";
import type { ServerContext, UserContext } from "./builder.ts";
import { schema } from "./schema.ts";
+/**
+ * Options for Yoga server.
+ */
+export interface YogaServerOptions {
+ /**
+ * Email sending services.
+ */
+ mailer?: Transport | undefined;
+
+ /**
+ * Email address to send.
+ */
+ emailFrom?: string;
+
+ /**
+ * Origin list.
+ */
+ origins?: ReadonlySet;
+}
+
/**
* Creates a Yoga server instance with the provided schema and context.
- * @param db The database instance.
+ * @param {Database} db The database instance.
+ * @param {YogaServerOptions} _options Options for server.
* @returns A `YogaServerInstance` configured with the schema and context for
* handling GraphQL requests.
*/
export function createYogaServer(
db: Database,
+ _options: YogaServerOptions = {},
): YogaServerInstance {
+ const options = fillOptions(_options);
return createYoga({
- // oxlint-disable-next-line require-await
async context(ctx) {
- return { db, request: ctx.request };
+ const anonymous = { db, request: ctx.request, ...options };
+ const accessToken = getAccessToken(ctx.request.headers);
+ if (accessToken == null) {
+ return anonymous;
+ }
+ const authenticated = await findSession(accessToken, db);
+ if (authenticated == null) {
+ return anonymous;
+ }
+ const { account, ...session } = authenticated;
+ return { ...anonymous, session, account };
},
plugins: [useExecutionCancellation()],
schema,
logging: getYogaLogger(),
});
}
+
+function mockTransport() {
+ logger.warn(
+ "Without `mailer` option, use default `MockTransport` as mailer. " +
+ "With default `MockTransport`, you can't read any mails.",
+ );
+ return new MockTransport();
+}
+
+const fillOptions = (opt: YogaServerOptions): Required => ({
+ mailer: opt?.mailer ?? mockTransport(),
+ emailFrom: opt?.emailFrom ?? "noreply@drfed.org",
+ origins: opt?.origins ?? new Set(["https://drfed.org"]),
+});
+
+const getAccessToken = (headers: Headers) =>
+ /^Bearer (?[^\s]+)$/u.exec(headers.get("Authorization") ?? "")?.groups
+ ?.token ?? null;
+
+const findSession = async (accessToken: string, db: Database) =>
+ await db.query.sessions.findFirst({
+ where: {
+ tokenHash: await hashSecret(accessToken),
+ expires: { gt: new Date(Temporal.Now.instant().toString()) },
+ },
+ with: { account: true },
+ });
+
+const logger = getLogger(["drfed", "graphql", "yoga"]);
diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts
index e612e9f..84bfa4f 100644
--- a/packages/graphql/src/schema.ts
+++ b/packages/graphql/src/schema.ts
@@ -16,9 +16,10 @@
// oxlint-disable import/no-unassigned-import
import "./account.ts";
import "./instance.ts";
+import "./auth/entry.ts";
import builder from "./builder.ts";
builder.queryType({});
-// Builder.mutationType({});
+builder.mutationType({});
export const schema = builder.toSchema();
From f37414dcc42b5d4cc2aca45a04ed8cc1be1a5bfc Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 16 Jul 2026 22:12:31 +0900
Subject: [PATCH 3/4] Add `smtpParser` and `seedParser`
Assisted-by: Claude Code:claude-opus-4-8
Assisted-by: Claude Code:claude-fable-5
Assisted-by: Codex:gpt-5-6-sol
---
mise.toml | 2 ++
packages/drfed/package.json | 1 +
packages/drfed/src/index.ts | 9 +++++-
packages/drfed/src/parser.ts | 54 ++++++++++++++++++++++++---------
packages/drfed/src/seed.ts | 54 +++++++++++++++++++++++++++++++++
pnpm-lock.yaml | 58 ++++++++++++++++++++++++++++++++++++
scripts/dev.mts | 19 +++++++++++-
tsconfig.json | 2 +-
8 files changed, 182 insertions(+), 17 deletions(-)
create mode 100644 packages/drfed/src/seed.ts
diff --git a/mise.toml b/mise.toml
index c60217c..20a4474 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,6 +1,7 @@
min_version = "2026.6.10"
[tools]
+"aqua:axllent/mailpit" = "latest"
"aqua:dahlia/hongdown" = "0.4.3"
"github:nushell/nushell" = "latest"
node = "26"
@@ -108,6 +109,7 @@ let name = (if "usage_name" in $env { ["--name" $env.usage_name] } else { [] })
let custom = (if "usage_custom" in $env { ["--custom"] } else { [] })
pnpm exec drizzle-kit generate ...$name ...$custom
"""
+depends_post = ["fmt"]
[tasks.dev]
description = "Run the development server"
diff --git a/packages/drfed/package.json b/packages/drfed/package.json
index 31ff52a..f022e7c 100644
--- a/packages/drfed/package.json
+++ b/packages/drfed/package.json
@@ -74,6 +74,7 @@
"@optique/core": "^1.1.0",
"@optique/logtape": "catalog:",
"@optique/run": "^1.1.0",
+ "@upyo/smtp": "^0.5.0",
"drizzle-orm": "catalog:",
"pg": "catalog:",
"srvx": "^0.11.16"
diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts
index c40bc5d..ad66f79 100644
--- a/packages/drfed/src/index.ts
+++ b/packages/drfed/src/index.ts
@@ -27,6 +27,7 @@ import { serve } from "srvx";
import metadata from "../package.json" with { type: "json" };
import type { Options } from "./parser.ts";
import program from "./program.ts";
+import seedData from "./seed.ts";
// oxlint-disable-next-line max-lines-per-function
export async function main(): Promise {
@@ -69,7 +70,12 @@ export async function main(): Promise {
if (options.drizzle.migrate) {
await migrate({ credentials: options.drizzle.credentials });
}
- const yogaServer = createYogaServer(options.drizzle.db);
+ if (options.seed) {
+ await seedData(options.drizzle.db);
+ }
+ const yogaServer = createYogaServer(options.drizzle.db, {
+ mailer: options.mailer,
+ });
const server = serve({
fetch: yogaServer.fetch.bind(yogaServer),
hostname: options.address.host,
@@ -77,6 +83,7 @@ export async function main(): Promise {
port: options.address.port,
});
function shutdown() {
+ options.mailer?.closeAllConnections();
// oxlint-disable-next-line promise/catch-or-return promise/prefer-await-to-then no-magic-numbers
server.close().then(() => process.exit(0));
}
diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts
index 0a28bfc..c2bf884 100644
--- a/packages/drfed/src/parser.ts
+++ b/packages/drfed/src/parser.ts
@@ -13,16 +13,19 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+
import { relations, schema } from "@drfed/models";
+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, withDefault } from "@optique/core/modifiers";
+import { map, optional, withDefault } from "@optique/core/modifiers";
import type { InferValue } from "@optique/core/parser";
-import { option } from "@optique/core/primitives";
+import { flag, option } from "@optique/core/primitives";
import { socketAddress, url } from "@optique/core/valueparser";
import { loggingOptions } from "@optique/logtape";
import { path } from "@optique/run/valueparser";
+import { SmtpTransport } from "@upyo/smtp";
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
@@ -36,18 +39,21 @@ const pgliteParser = map(
description: message`The path to the directory where the PGlite database files will be stored. Mutually exclusive with ${optionNames(["--postgres-url", "--database-url", "-D"])}.`,
},
),
- (dbPath) => ({
- credentials: {
- driver: "pglite" as const,
- url: dbPath,
- },
- db: drizzlePglite({
- connection: { dataDir: dbPath },
- relations,
- schema,
- logger: getLogger(),
- }),
- }),
+ (dbPath) => {
+ const client = new PGlite(dbPath);
+ return {
+ credentials: {
+ driver: "pglite" as const,
+ client,
+ },
+ db: drizzlePglite({
+ client,
+ relations,
+ schema,
+ logger: getLogger(),
+ }),
+ };
+ },
);
const postgresParser = map(
@@ -75,6 +81,24 @@ const postgresParser = map(
}),
);
+const smtpParser = map(
+ option("--smtp-url", "-s", url({ allowedProtocols: ["smtp:"] }), {
+ description: message`The URL of the SMTP server to deliver emails through.`,
+ }),
+ (smtpUrl) =>
+ new SmtpTransport({
+ host: smtpUrl.hostname,
+ port: smtpUrl.port === "" ? SMTP_DEFAULT_PORT : Number(smtpUrl.port),
+ // SMTPS for later
+ secure: false,
+ }),
+);
+const SMTP_DEFAULT_PORT = 25;
+
+const seedParser = flag("--seed", {
+ description: message`Seeding initial data for dev or test.`,
+});
+
export const parser = object({
logging: loggingOptions({ level: "option", short: "-L" }),
address: withDefault(
@@ -97,6 +121,8 @@ export const parser = object({
),
}),
),
+ mailer: optional(smtpParser),
+ seed: seedParser,
});
export type Options = InferValue;
diff --git a/packages/drfed/src/seed.ts b/packages/drfed/src/seed.ts
new file mode 100644
index 0000000..c7d65af
--- /dev/null
+++ b/packages/drfed/src/seed.ts
@@ -0,0 +1,54 @@
+// 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 { type Database, schema } from "@drfed/models";
+
+export default async function seedData(db: Database): Promise {
+ await db.transaction(async (tx) => {
+ await seedAccounts(tx);
+ });
+}
+
+const accountId = "00000000-0000-4000-8000-000000000001";
+const memberId = "00000000-0000-4000-8000-000000000002";
+const pendingMemberId = "00000000-0000-4000-8000-000000000003";
+const created = new Date("2026-06-24T00:00:00.000Z");
+
+async function seedAccounts(db: Database): Promise {
+ await db
+ .insert(schema.accounts)
+ .values([
+ {
+ id: accountId,
+ email: "owner@example.com",
+ name: "Owner",
+ created,
+ },
+ {
+ id: memberId,
+ email: "member@example.com",
+ name: "Member",
+ created,
+ },
+ {
+ id: pendingMemberId,
+ email: "pending@example.com",
+ name: "Pending",
+ created,
+ },
+ ])
+ .onConflictDoNothing();
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 39919fd..f1c944a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -71,6 +71,9 @@ importers:
'@optique/run':
specifier: ^1.1.0
version: 1.1.0
+ '@upyo/smtp':
+ specifier: ^0.5.0
+ version: 0.5.0(@upyo/core@0.5.0)
drizzle-orm:
specifier: 'catalog:'
version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)
@@ -102,6 +105,9 @@ importers:
'@drfed/models':
specifier: workspace:*
version: link:../models
+ '@fedify/uri-template':
+ specifier: ^2.3.1
+ version: 2.3.1
'@logtape/graphql-yoga':
specifier: 'catalog:'
version: 2.3.0-dev.840(@logtape/logtape@2.3.0-dev.840)(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2)
@@ -114,9 +120,15 @@ importers:
'@pothos/plugin-drizzle':
specifier: ^0.17.4
version: 0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0))(graphql@16.14.2)
+ '@pothos/plugin-errors':
+ specifier: ^4.9.1
+ version: 4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2)
'@pothos/plugin-relay':
specifier: ^4.7.0
version: 4.7.0(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2)
+ '@upyo/core':
+ specifier: ^0.5.0
+ version: 0.5.0
drizzle-orm:
specifier: 'catalog:'
version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)
@@ -139,6 +151,9 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 26.0.0
+ '@upyo/mock':
+ specifier: ^0.5.0
+ version: 0.5.0(@upyo/core@0.5.0)
tsdown:
specifier: 'catalog:'
version: 0.22.3(typescript@6.0.3)
@@ -749,6 +764,10 @@ packages:
'@fastify/busboy@3.2.0':
resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
+ '@fedify/uri-template@2.3.1':
+ resolution: {integrity: sha512-322xch1WhasP/vjH4yDPA1RnYwI6tlG72aH7fCpdFge8IC845urKEMET9LzJ2G5HfeCKAXPAcaZZEx9FXnTNrg==}
+ engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'}
+
'@graphql-tools/executor@1.5.3':
resolution: {integrity: sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA==}
engines: {node: '>=16.0.0'}
@@ -1042,6 +1061,12 @@ packages:
drizzle-orm: '>=1.0.0-beta.2'
graphql: ^16.10.0
+ '@pothos/plugin-errors@4.9.1':
+ resolution: {integrity: sha512-nD8ZUKWlu2yzma28zQIrcs0ihfPmx/unXwmAKt+irWWzl6+/s2tkD+qO6wn4aH8swcxKIDibNa3HSTecCguWHw==}
+ peerDependencies:
+ '@pothos/core': '*'
+ graphql: ^16.10.0 || ^17.0.0
+
'@pothos/plugin-relay@4.7.0':
resolution: {integrity: sha512-IQ7f7WLu7uXxiZBcJgUTdspjIVzX3bgvd8XjnzxGJUbn/uE5HlVyCFmvhJffHIIS2OKSHhOOJRMwIwVwauwnSg==}
peerDependencies:
@@ -1517,6 +1542,22 @@ packages:
'@ungap/structured-clone@1.3.2':
resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==}
+ '@upyo/core@0.5.0':
+ resolution: {integrity: sha512-i7uwJtiQiUQ4cYx2BGy4hA3iM9Fh7XHEb6s2BxBDbNj8LAkcQYWF2o1rca/mIZP/rgbeGhCVNPY9tKYEBNUV+g==}
+ engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'}
+
+ '@upyo/mock@0.5.0':
+ resolution: {integrity: sha512-qkNzIvnbxrtoKLLtD1eV7vfUpznppXX1l6wlBkW5W9F1pHTTnM8GjFbyCAG/vrXX1bOavAz3YJGyt7ZnqiGf1w==}
+ engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'}
+ peerDependencies:
+ '@upyo/core': 0.5.0
+
+ '@upyo/smtp@0.5.0':
+ resolution: {integrity: sha512-NjRHQN8n9AnyKYLQFOTe/c1ODd58Vv++423RAWnj6GbhGweqM1RatxvwJF4GZCwSM/Z/L0Ef7bmmLFb+gqjjZQ==}
+ engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'}
+ peerDependencies:
+ '@upyo/core': 0.5.0
+
'@vercel/nft@1.10.2':
resolution: {integrity: sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw==}
engines: {node: '>=20'}
@@ -4157,6 +4198,8 @@ snapshots:
'@fastify/busboy@3.2.0': {}
+ '@fedify/uri-template@2.3.1': {}
+
'@graphql-tools/executor@1.5.3(graphql@16.14.2)':
dependencies:
'@graphql-tools/utils': 11.1.0(graphql@16.14.2)
@@ -4433,6 +4476,11 @@ snapshots:
drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)
graphql: 16.14.2
+ '@pothos/plugin-errors@4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2)':
+ dependencies:
+ '@pothos/core': 4.13.0(graphql@16.14.2)
+ graphql: 16.14.2
+
'@pothos/plugin-relay@4.7.0(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2)':
dependencies:
'@pothos/core': 4.13.0(graphql@16.14.2)
@@ -4915,6 +4963,16 @@ snapshots:
'@ungap/structured-clone@1.3.2': {}
+ '@upyo/core@0.5.0': {}
+
+ '@upyo/mock@0.5.0(@upyo/core@0.5.0)':
+ dependencies:
+ '@upyo/core': 0.5.0
+
+ '@upyo/smtp@0.5.0(@upyo/core@0.5.0)':
+ dependencies:
+ '@upyo/core': 0.5.0
+
'@vercel/nft@1.10.2(rollup@4.62.2)':
dependencies:
'@mapbox/node-pre-gyp': 2.0.3
diff --git a/scripts/dev.mts b/scripts/dev.mts
index c6ded27..d101893 100644
--- a/scripts/dev.mts
+++ b/scripts/dev.mts
@@ -13,6 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+
// oxlint-disable no-console no-magic-numbers eslin/max-lines
import { type ChildProcess, spawn } from "node:child_process";
import { existsSync } from "node:fs";
@@ -154,6 +155,7 @@ async function shutdown(
forceKill(serverProcess);
forceKill(webProcess);
forceKill(buildProcess);
+ forceKill(mailpitProcess);
process.exit(exitCode);
}
shuttingDown = true;
@@ -164,6 +166,7 @@ async function shutdown(
: terminate(serverProcess, signal === "SIGINT" ? "SIGINT" : "SIGTERM"),
terminate(webProcess, "SIGTERM"),
terminate(buildProcess, "SIGTERM"),
+ terminate(mailpitProcess, "SIGTERM"),
]);
process.exit(exitCode);
}
@@ -266,6 +269,7 @@ function sleep(ms: number): Promise {
let buildProcess: ChildProcess | undefined;
let serverProcess: ChildProcess | undefined;
let webProcess: ChildProcess | undefined;
+let mailpitProcess: ChildProcess | undefined;
let shuttingDown = false;
process.on("SIGINT", () => {
@@ -299,12 +303,20 @@ try {
await waitForBuilds(buildExit);
+ mailpitProcess = spawnManaged(
+ "mailpit",
+ ["--smtp", "localhost:1025", "--listen", "localhost:8025"],
+ root,
+ );
+
const serverArgs: string[] = [
"--watch",
"bin/drfed-server.mjs",
"--pglite-data-path",
"../../.pgdata",
"--listen=0.0.0.0:8888",
+ "--smtp-url=smtp://localhost:1025",
+ "--seed",
];
const logLevel = process.env.usage_log_level;
@@ -340,7 +352,12 @@ try {
result: await waitForExit(webProcess),
}))();
- const firstExit = await Promise.race([serverExit, webExit]);
+ const mailpitExit = (async () => ({
+ name: "mailpit" as const,
+ result: await waitForExit(mailpitProcess),
+ }))();
+
+ const firstExit = await Promise.race([serverExit, webExit, mailpitExit]);
if (firstExit.result.error != null) {
throw firstExit.result.error;
diff --git a/tsconfig.json b/tsconfig.json
index fb0d88c..118cd9d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,7 +4,7 @@
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
- "lib": ["ES2024"],
+ "lib": ["ESNEXT"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
From b3b791e9e8bf9d02d99f8b0b33ca03ff00ed682f Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 16 Jul 2026 22:13:26 +0900
Subject: [PATCH 4/4] Fix non-source code files
Assisted-by: Claude Code:claude-opus-4-8
Assisted-by: Claude Code:claude-fable-5
Assisted-by: Codex:gpt-5-6-sol
---
.hongdown.toml | 1 +
.oxfmtrc.json | 1 +
.oxlintrc.json | 3 ++-
.vscode/extensions.json | 2 +-
.vscode/settings.json | 1 +
cspell.jsonc | 18 ++++++++++++++++++
6 files changed, 24 insertions(+), 2 deletions(-)
create mode 100644 cspell.jsonc
diff --git a/.hongdown.toml b/.hongdown.toml
index f9750aa..2d7757f 100644
--- a/.hongdown.toml
+++ b/.hongdown.toml
@@ -6,4 +6,5 @@ exclude = [
"AGENTS.md",
"CLAUDE.md",
"node_modules/",
+ "plans/",
]
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index fdaf92b..abb763b 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -5,6 +5,7 @@
"ignorePatterns": [
"*.md",
"**/*.md",
+ "plans/",
"**/dist/*.{cjs,d.cts,d.mts,d.ts,js,mjs}"
]
}
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 4a2b932..776479b 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -17,7 +17,7 @@
{ "ignoreConsecutiveComments": true }
],
"eslint/eqeqeq": ["off", "smart"],
- "eslint/func-style": ["warn", "declaration"],
+ "eslint/func-style": ["off"],
"eslint/id-length": ["warn", { "exceptionPatterns": ["^_", "^[Tertv]$"] }],
"eslint/init-declarations": "off",
"eslint/max-params": ["warn", { "max": 4 }],
@@ -50,6 +50,7 @@
],
"import/no-namespace": "off",
"import/no-nodejs-modules": "off",
+ "import/no-relative-parent-imports": "off",
"import/prefer-default-export": "off",
"jsdoc/require-param-type": "off",
"jsdoc/require-returns-type": "off",
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 99e2f7d..7591fb2 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,3 +1,3 @@
{
- "recommendations": ["oxc.oxc-vscode"]
+ "recommendations": ["oxc.oxc-vscode", "hverlin.mise-vscode"]
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 377d71c..a69d107 100755
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -9,6 +9,7 @@
"files.trimFinalNewlines": true,
"oxc.enable.oxfmt": true,
"prettier.enable": false,
+ "oxc.enable": true,
"[css]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
diff --git a/cspell.jsonc b/cspell.jsonc
new file mode 100644
index 0000000..29e3c4c
--- /dev/null
+++ b/cspell.jsonc
@@ -0,0 +1,18 @@
+{
+ "words": [
+ "drfed",
+ "fedify",
+ "HMAC",
+ "logtape",
+ "mailpit",
+ "metavar",
+ "optique",
+ "oxlint",
+ "pglite",
+ "psql",
+ "smtps",
+ "upyo",
+ "valueparser",
+ "varchar",
+ ],
+}