Adds a second runtime next to Cloudflare Workers. One Node process serves
the Next app, a realtime WebSocket hub, the job queues, the backup schedule
and an SMTP listener; SQLite and local files replace D1 and R2. The
application code is unchanged: the runtime builds an object with the same
binding interfaces (`server/runtime/`) and publishes it before Next starts,
so `getEnv()` hands route handlers the same shape on both platforms.
Runtime (server/)
- `sqlite-database.ts`: the D1 API over better-sqlite3, so Drizzle's D1
driver, the raw `env.DB.prepare` callers, the FTS5 index and its triggers
all work as-is. Migrations are applied from drizzle/migrations at start,
recorded in `d1_migrations` like Wrangler does.
- `file-bucket.ts`: the R2 API over a directory, with metadata sidecars and
byte ranges. `mailer.ts`: the send_email builder over nodemailer (any SMTP
relay) or Cloudflare's Email Sending REST API; generates the Message-ID so
threading matches Workers. `queue.ts`: in-process queues with the same
retry policy. `realtime.ts`: per-user WebSocket fan-out in place of the
Durable Object. `misc.ts`: rate limiter and ASSETS fetcher.
- `smtp.ts`: inbound SMTP on port 25 via smtp-server; reject rules answer
550, forward rules relay the raw message through the outbound SMTP.
- `index.ts`: the custom server; `scripts/build-server.mjs` bundles it with
esbuild to dist/server.mjs.
Application changes
- `src/lib/runtime.ts` + `getEnv()` prefer the Node env when present.
- `src/lib/email/intake.ts` factors the Worker email handler's logic so the
SMTP listener and the new signed `/api/inbound` webhook share it.
- Domains without Cloudflare credentials are recorded with zone id
"manual": Cloudflare calls no-op, the domain is active at once, and the
DNS page lists MX/SPF/DMARC records to create. Setup checks, the domains
page copy and the self-update button adapt to the runtime; `/api/auth/me`
exposes `runtime` and `managesDns`.
- `APP_URL` overrides the origin for reset links and the JMAP session
behind a proxy. The direct `getCloudflareContext` import in the update
route is replaced with `getEnv()`.
Deployment
- Dockerfile (multi-stage, 1.1 GB, Workers toolchain pruned), compose file,
`.env.docker.example`, `docs/self-hosting.md`, README section.
- `deploy/cloudflare-email-relay`: a Worker that keeps MX on Cloudflare
Email Routing and posts messages to the server with an HMAC signature,
acting on the reject/forward decision it returns.
Verified on a fresh data directory and again inside the built image:
migrations apply, first-run setup and registration work with no Cloudflare
credentials, mail over SMTP lands in the inbox with the raw copy on disk,
a reject rule answers 550, a send through SMTP_URL loops back and is stored
as sent and received, an inbound reply threads with the sent copy, FTS
search finds body text, a backup completes to the volume, the WebSocket
delivers a push, the relay webhook rejects a bad signature and returns
store/reject decisions, JMAP serves a session with the APP_URL origin.
Container healthcheck reports healthy. tsc (11) and eslint (105) unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h
The app collected a recovery email at first run and never read it, and had
no second factor. A lost password meant editing D1 by hand.
Password reset
- `POST /api/auth/password-reset/request` mails a single-use link (hashed
token, 30 minutes) to the account's `reset_email`. It answers 200 for any
well-formed input so the form cannot probe for accounts, and is behind the
login rate limiter and Turnstile.
- `POST /api/auth/password-reset/confirm` sets the password, burns the
token, revokes every session and writes an audit entry.
- Reset mail goes through `sendSystemEmail` straight to the send binding
from the first admin mailbox on a sending-enabled domain: no Sent copy,
no contact upsert, no webhooks, auto-generated headers.
- New /forgot-password and /reset-password pages; "Forgot password?" on the
login form.
Two-factor authentication
- RFC 6238 TOTP over Web Crypto (`src/lib/auth/totp.ts`, no dependency;
verified against the RFC 4226/6238 test vectors), ±1 step tolerance,
constant-time compare. `qrcode` renders the otpauth URL as inline SVG.
- Enrolment stores a secret but only trusts it once the user proves a code
from it; that also issues 8 single-use recovery codes and signs out every
other session. Turning it off needs the password and a current code.
- Login with MFA on returns `{ mfaRequired, challengeToken }` instead of a
session (`login_challenges`, 5 minutes); `/api/auth/mfa/verify` accepts a
TOTP or recovery code and issues the session, so the SPA still receives
its bearer token on the final step.
- Settings > Account > Security gains a card with enrol / new codes / off
dialogs; `/api/auth/me` exposes `mfaEnabled`.
Related
- Changing a password now signs out every other session.
- Admins can set a password on a managed account (PATCH /api/accounts/[id]
gains `password`; every session of that user is revoked). The route
already hashed passwords but no caller ever passed one.
- Migration 0031: three columns on users, `password_reset_tokens`,
`mfa_recovery_codes`, `login_challenges`; bootstrap SQL and backup list
updated.
Verified on local D1: unknown and known accounts get the same reset answer;
a bad token, a used token and a reused token are rejected; redemption
revokes an open session and the old password stops working; enrolment
rejects a wrong password and a wrong code; confirming with a real code
returns 8 codes and revokes the other session but not the current one;
login then requires MFA, a wrong code is rejected, a right code and a
recovery code each sign in, the challenge is consumed, a recovery code
cannot be reused, and disabling restores password-only login. The QR
enrolment, MFA step and reset pages were exercised in the browser.
tsc (11) and eslint (105) unchanged from main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Takes `npm audit` from 17 vulnerabilities (11 high) to 0 without downgrading
anything:
- next 16.2.6 -> 16.3.3, clearing the Server Function disclosure advisory plus
the bundled postcss and sharp advisories.
- nanoid -> ^5.1.16 (runtime dependency; patch bump inside 5.x rather than the
Node 22+ only 6.0).
- wrangler -> ^4.127.1 with @cloudflare/workers-types -> ^5.x, which fixes the
miniflare -> sharp/undici/ws chain. These two have to move together or npm's
peer resolution fails.
- overrides pins @esbuild-kit/core-utils to esbuild ^0.25.4 for the last four
advisories. `npm audit fix --force` wanted drizzle-kit 0.31 -> 0.18, which is
genuinely breaking; drizzle-kit already depends on tsx and esbuild ^0.25
directly, so the deprecated @esbuild-kit chain is vestigial. The lockfile is
regenerated so the override actually applies.
`next lint` was removed in Next 16, so `npm run lint` failed outright with
"no such directory: ./lint". Switched to the ESLint CLI and migrated
eslint.config.mjs off the FlatCompat shim onto eslint-config-next's native flat
configs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h