- |
+ |
+ {/* An absence pay was asked for directly, AND answered: this row is
+ inside `kind === 'known'`, so it can no longer be reached by a
+ failed read. It used to be an absence from the newest 200 rows of
+ every status, which for `stuck` is the one absence in this console
+ nobody may be told wrongly. */}
{statusFilter === 'all'
? 'No withdrawals yet.'
: `No ${statusFilter} withdrawals.`}
@@ -688,6 +1048,18 @@ export default function Prices() {
)}
+
+ {abandoning && (
+ setAbandoning(null)}
+ onAbandoned={(updated) => {
+ replaceWithdrawal(updated)
+ setAbandoning(null)
+ setAbandoned(updated)
+ }}
+ />
+ )}
)
}
diff --git a/apps/site/package.json b/apps/site/package.json
index 029cf27..8395348 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -7,7 +7,8 @@
"dev": "vite --port 3000",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3000",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "test": "node --import tsx --test \"src/**/*.test.ts\""
},
"dependencies": {
"@cloudsforge/shared": "^0.4.0",
@@ -23,6 +24,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
+ "tsx": "^4.19.2",
"typescript": "^5.7.3",
"vite": "^6.0.0"
}
diff --git a/apps/site/src/lib/site.test.ts b/apps/site/src/lib/site.test.ts
new file mode 100644
index 0000000..fdf58b6
--- /dev/null
+++ b/apps/site/src/lib/site.test.ts
@@ -0,0 +1,156 @@
+/*
+ * The public copy must not disagree with the code it describes.
+ *
+ * This site is marketing, so nothing here has a runtime behaviour to test. What
+ * it has instead is claims, and the /roadmap page makes one about itself:
+ * "nothing claimed here that you cannot go and use". The defect these cases
+ * exist for (CF-37) was that inversion: EMBER moved from crediting at the chain
+ * tip to waiting a real confirmation depth, and cosmetics moved from
+ * localStorage to a database row that every roster renders, and both sentences
+ * on the site went on saying the old thing. The site was understating what runs.
+ *
+ * So the assertions are of two kinds:
+ *
+ * 1. AGAINST THE REGISTRY. `@cloudsforge/shared`'s deposit registry is the
+ * same module forge-pay's watcher reads, so EMBER's depth here is EMBER's
+ * depth there. A case that hardcoded 60 would pass on the day it was
+ * written and rot exactly like the copy did; these read the registry.
+ *
+ * 2. AGAINST THE OLD CLAIMS. A fail-closed sweep over every string this module
+ * exports, so a retired claim cannot come back in a sentence nobody thought
+ * to re-check. It walks the exports rather than naming the constants: new
+ * copy is covered the moment it is added, which is the only way a rule like
+ * this survives contact with a marketing page.
+ *
+ * Run: pnpm --filter @cloudsforge/site test
+ */
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { depositChainInfo, SUPPORTED_DEPOSIT_COINS } from '@cloudsforge/shared'
+import {
+ EMBER_CONFIRMATIONS,
+ EMBER_CONFIRMATION_MINUTES,
+ LOOP_CAVEAT,
+ ROADMAP,
+} from './site.ts'
+import * as site from './site.ts'
+
+/** Every string reachable from the module's exports, however deeply nested. */
+function everyString(value: unknown, seen = new Set()): string[] {
+ if (typeof value === 'string') return [value]
+ if (value === null || typeof value !== 'object') return []
+ if (seen.has(value)) return []
+ seen.add(value)
+ return Object.values(value as Record).flatMap((v) => everyString(v, seen))
+}
+
+const COPY = everyString(site)
+
+/** The roadmap entry the register names, found by title so a reorder is fine. */
+const emberEntry = ROADMAP.find((e) => e.title === 'EMBER credited honestly')
+const forSaleEntry = ROADMAP.find((e) => e.title === 'Deliver everything already for sale')
+
+test('the depth the site quotes is the depth Forge Pay credits at', () => {
+ // THE LOAD-BEARING CASE. `site.ts` writes the number rather than importing it,
+ // because the shared entrypoint drags zod into the browser bundle and does not
+ // tree-shake out (+15 kB gzipped for one integer; the reasoning is above
+ // `EMBER_CONFIRMATIONS`). This is what makes that safe: the registry is read
+ // here, in node, where it is free — so the day someone changes EMBER's depth in
+ // shared-libs, this repo's build goes red instead of the site going quietly
+ // stale, which is precisely how it went stale last time.
+ assert.equal(EMBER_CONFIRMATIONS, depositChainInfo('EMBER').confirmations)
+ assert.ok(EMBER_CONFIRMATIONS > 0, 'the site must not claim a zero-confirmation credit')
+ assert.equal(EMBER_CONFIRMATION_MINUTES, Math.round((EMBER_CONFIRMATIONS * 15) / 60))
+})
+
+test('the pages that quote a depth quote the registry’s depth', () => {
+ const depth = `${depositChainInfo('EMBER').confirmations} blocks`
+ assert.ok(LOOP_CAVEAT.includes(depth), `the home-page caveat should say "${depth}"`)
+ assert.ok(emberEntry, 'the roadmap entry for EMBER should still exist')
+ assert.ok(emberEntry!.body.includes(depth), `the roadmap entry should say "${depth}"`)
+})
+
+test('no public copy claims EMBER credits at the chain tip', () => {
+ // Verbatim fragments of the sentences CF-37 found, plus the shapes they could
+ // come back as. forge-pay reads `eth_getBalance` at `latest - confirmations`
+ // and the tip reading only ever reaches `pending` — there is no code path that
+ // credits at the tip, so no sentence here may say there is.
+ const banned = [
+ /credits? (?:it )?at the (?:chain )?tip/i,
+ /instead of waiting for confirmations/i,
+ /exposes only the balance at the tip/i,
+ /counts the moment it is seen/i,
+ /without waiting for confirmations/i,
+ ]
+ for (const pattern of banned) {
+ const offender = COPY.find((s) => pattern.test(s))
+ assert.equal(offender, undefined, `retired claim ${pattern} is back in: ${offender}`)
+ }
+})
+
+test('EMBER is on the shipped side of the roadmap, with every other coin', () => {
+ assert.ok(emberEntry, 'the roadmap entry for EMBER should still exist')
+ // Every supported coin now has a non-zero depth and is credited at it, so an
+ // entry saying this is still being worked on is the understatement the ticket
+ // is about. It stays as a Shipped entry rather than being deleted — the work
+ // happened, and a roadmap that only lists what is missing is not a roadmap.
+ assert.equal(emberEntry!.phase, 'Shipped')
+ assert.equal(emberEntry!.status, 'shipping')
+ for (const chain of SUPPORTED_DEPOSIT_COINS) {
+ assert.ok(chain.confirmations > 0, `${chain.coin} must credit at a real depth`)
+ }
+})
+
+test('the caveat survives, and for the reason that is still true', () => {
+ // Not "delete the caveat": EMBER waits its depth now, but the deposit registry
+ // says the controls that matter on a young CPU-mined chain are economic and
+ // "live in forge-pay", and they are not implemented there. The caveat is only
+ // honest while it names that, so this asserts the reason and not just that
+ // some sentence is present.
+ assert.ok(/\bcaps?\b/i.test(LOOP_CAVEAT), 'the caveat should name the missing per-user caps')
+ assert.ok(/reorg/i.test(LOOP_CAVEAT), 'the caveat should name the missing reorg halt')
+})
+
+test('no public copy claims cosmetics are private to your own browser', () => {
+ // A cosmetic is a `player_cosmetics` row, checked against Pay's entitlements on
+ // equip and fanned out to `players.cosmeticStyle` for every world you are in
+ // (ninety-days-after `services/game/src/routes/cosmetics.ts`). Other people see
+ // it. The old sentence was an argument against buying one, and it was wrong.
+ const banned = [
+ /stored in your own browser/i,
+ /nobody else ever sees/i,
+ /only you (?:can )?see/i,
+ ]
+ for (const pattern of banned) {
+ const offender = COPY.find((s) => pattern.test(s))
+ assert.equal(offender, undefined, `retired claim ${pattern} is back in: ${offender}`)
+ }
+})
+
+test('the undelivered private world is still admitted to', () => {
+ // The other half of that roadmap entry is NOT stale and must not be swept away
+ // with the cosmetics sentence: Forge Pay still charges for `private_world` and
+ // nothing in the estate provisions one.
+ assert.ok(forSaleEntry, 'the "already for sale" entry should still exist')
+ assert.match(forSaleEntry!.body, /private world/i)
+ assert.match(forSaleEntry!.body, /provisions nothing/i)
+})
+
+test('the entry names every kind the game classifies as undelivered', () => {
+ // Striking the cosmetics sentence left this entry naming the private world and
+ // nothing else, while `ninety-days-after/apps/game/src/lib/shop.ts` enumerates
+ // three: `private_world` ("no world was raised"), `convenience` ("the feature
+ // does not exist") and `cosmetic` ("nothing draws it"). An exhaustive-sounding
+ // list of one is the same understatement this file exists to catch, and it is
+ // the shape the next SKU that ships undelivered would drift into.
+ //
+ // The kinds are matched by the words the copy has to use rather than by
+ // importing the game's module: that repo is a sibling checkout and may not be
+ // present, and this suite has to be able to run on a runner that has only
+ // this one. A rename over there breaks nothing here; a SENTENCE going missing
+ // over here is what this catches.
+ assert.ok(forSaleEntry, 'the "already for sale" entry should still exist')
+ const body = forSaleEntry!.body
+ assert.match(body, /convenience/i, 'the convenience items are sold and undelivered too')
+ assert.match(body, /cosmetic kinds nothing draws/i, 'so are the cosmetic kinds with no renderer')
+})
diff --git a/apps/site/src/lib/site.ts b/apps/site/src/lib/site.ts
index 7f52b3a..7181ec5 100644
--- a/apps/site/src/lib/site.ts
+++ b/apps/site/src/lib/site.ts
@@ -196,12 +196,62 @@ export const LOOP = [
].map((s) => ({ ...s, accent: surface(s.accentKey).accent }))
/**
- * The part of the loop we will not oversell. EMBER is the only coin Forge Pay
- * credits at the chain tip rather than after confirmations, because Hearth's API
- * exposes nothing else yet.
+ * EMBER's confirmation depth — the number Forge Pay actually credits at, said
+ * once so the two places that quote it cannot disagree with each other.
+ *
+ * IT IS WRITTEN HERE AND NOT IMPORTED, which needs defending, because the file's
+ * whole habit is to derive rather than restate. `depositChainInfo('EMBER')` in
+ * `@cloudsforge/shared` is the registry forge-pay's watcher itself reads (it
+ * probes `eth_getBalance` at `latest - confirmations`, `services/pay/src/
+ * chains.ts`), so importing it would make the drift impossible by construction —
+ * but the shared entrypoint carries zod, and it does not tree-shake out: the
+ * import costs this bundle +62 kB raw / +15 kB gzipped, measured, on the front
+ * door of the site. `src/lib/obs.tsx` refused the same trade for the same reason
+ * and reaches for the zero-dependency `/products` subpath instead; there is no
+ * equivalent subpath for the deposit registry.
+ *
+ * SO THE BINDING IS A TEST INSTEAD, and it is not optional: `site.test.ts` reads
+ * `depositChainInfo('EMBER').confirmations` — in node, where zod is free — and
+ * fails if the copy below does not quote it. Move the depth in shared-libs and
+ * this repo's build goes red until someone rewrites the sentences, which is what
+ * has to happen anyway: "about fifteen minutes" is not a number a script can
+ * update.
+ *
+ * That is the defect this constant exists for. EMBER stopped crediting at the
+ * chain tip when Hearth became an account-model EVM chain, and the site went on
+ * saying it did, because nothing anywhere would ever have noticed.
+ */
+export const EMBER_CONFIRMATIONS = 60
+
+/**
+ * Hearth's block interval, in seconds. It matches the "15-second blocks" already
+ * claimed in the Hearth product copy above, and the ~15 minutes Hearth's own
+ * exchange-integration guide quotes for a 60-block deposit — change all three
+ * together or none.
+ */
+const HEARTH_BLOCK_SECONDS = 15
+
+/** The depth spelled as a wait, because "60 blocks" tells a reader nothing. */
+export const EMBER_CONFIRMATION_MINUTES = Math.round(
+ (EMBER_CONFIRMATIONS * HEARTH_BLOCK_SECONDS) / 60,
+)
+
+/**
+ * The part of the loop we will not oversell — and the reason has changed.
+ *
+ * It used to be that EMBER credited at the chain tip. It does not any more: it
+ * waits the depth above, the same depth Hearth publishes to exchanges. What is
+ * still true is that depth was never the real control on a young chain mined
+ * with ordinary CPUs — the deposit registry itself says the controls that matter
+ * are economic (per-user caps, a halt on any reorg past a few blocks) and that
+ * they "live in forge-pay". They are not implemented there yet; checked before
+ * this sentence was written, and there is nothing in
+ * `forge-pay/services/pay/src` that caps a user or halts on a reorg. So the
+ * caveat stays and only its reason changes. Delete it when those land, not
+ * before.
*/
export const LOOP_CAVEAT =
- 'Hearth is on testnet, and EMBER is the one coin Forge Pay still credits at the chain tip instead of waiting for confirmations. Until that is fixed it is a loop we will demonstrate, not one we would ask you to trust with size.'
+ `Hearth is on testnet, and an EMBER deposit now waits ${EMBER_CONFIRMATIONS} blocks — about ${EMBER_CONFIRMATION_MINUTES} minutes — before it credits, the same depth Hearth publishes to exchanges. Depth is not the whole story on a young chain mined with ordinary CPUs: the per-user caps and the automatic halt on a deep reorg are not built yet. It is a loop we will demonstrate, not one we would ask you to trust with size.`
/** Company accent, for the surfaces that carry no product colour of their own. */
export const COMPANY_ACCENT = CLOUDSFORGE_EMBER
@@ -355,11 +405,11 @@ export const ROADMAP = [
body: 'Hearth mines on testnet with real proof-of-work, difficulty retargeting and reorgs, and a browser wallet that signs its own transactions. ForgeMint deploys compiled contracts across five EVM chains and Solana. Crucible backtests ten strategies against real exchange history with fees and slippage charged. Ninety Days After resolves a full 90-day season and seals it into a readable archive.',
},
{
- phase: 'Now',
+ phase: 'Shipped',
title: 'EMBER credited honestly',
- status: 'building',
+ status: 'shipping',
spans: 'Hearth · Forge Pay',
- body: 'BTC, ETH, SOL and XRP deposits already wait for real confirmation depth before they credit. EMBER does not — Hearth’s API exposes only the balance at the tip, so a deposit counts the moment it is seen. Fixing that needs a change in the node, and it is the single thing standing between the mine-to-spend loop and a headline.',
+ body: `Every coin waits for real confirmation depth before it credits, EMBER included. Hearth became an account-model chain with an Ethereum JSON-RPC, so Forge Pay reads your balance as of ${EMBER_CONFIRMATIONS} blocks back — about ${EMBER_CONFIRMATION_MINUTES} minutes — rather than at the tip: a deposit is visible as pending the moment we see it, and spendable only once it is that deep. That is the same depth Hearth publishes to exchanges. What is not built yet is the economics around it — per-user deposit caps, and an automatic halt on a deep reorg — which is why we still say not to trust the loop with size.`,
},
{
phase: 'Now',
@@ -373,7 +423,22 @@ export const ROADMAP = [
title: 'Deliver everything already for sale',
status: 'planned',
spans: 'Games · Forge Pay',
- body: 'A rented private world charges Shards and provisions nothing. Cosmetics are stored in your own browser, so nobody else ever sees what you bought — which removes the entire reason to buy one. Both are sold today. Both get built or pulled.',
+ // The cosmetics sentence that used to be here came off, not the entry: what
+ // an account wears is a row in the game's database now, entitlement-checked
+ // against Forge Pay on equip and fanned out to every roster you appear on,
+ // so the "nobody else sees it" half was simply false.
+ //
+ // What replaced it is the game's own list rather than the one thing this
+ // entry used to name. `ninety-days-after/apps/game/src/lib/shop.ts`
+ // classifies THREE kinds as sold-and-not-delivered — `private_world` ("no
+ // world was raised"), `convenience` ("the feature does not exist") and
+ // `cosmetic` for the kinds with no renderer ("nothing draws it") — and the
+ // shop withholds all three listings. Withholding a listing stops the next
+ // charge and does nothing for the ones already taken, and Forge Pay still
+ // serves every one of those routes to any caller that is not that page.
+ // Naming one of the three read as an exhaustive list of one, which is the
+ // same understatement CF-37 is about.
+ body: 'A rented private world charges Shards and provisions nothing: Forge Pay takes the money, writes the entitlement, and no world is ever raised from it. Two more sit beside it — four convenience items whose features do not exist, and cosmetic kinds nothing draws. The game has pulled all three listings from its shop, which stops the next charge and does nothing for the money already taken; Forge Pay still answers those routes for anything that is not that page. They get built or they get refunded.',
},
{
phase: 'Next',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 30ee869..9fdf48c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -54,6 +54,9 @@ importers:
tailwindcss:
specifier: ^4.0.0
version: 4.3.3
+ tsx:
+ specifier: ^4.19.2
+ version: 4.23.1
typescript:
specifier: ^5.7.3
version: 5.9.3
@@ -97,6 +100,9 @@ importers:
tailwindcss:
specifier: ^4.0.0
version: 4.3.3
+ tsx:
+ specifier: ^4.19.2
+ version: 4.23.1
typescript:
specifier: ^5.7.3
version: 5.9.3
@@ -130,6 +136,9 @@ importers:
jose:
specifier: ^5.9.6
version: 5.10.0
+ nodemailer:
+ specifier: ^8.0.11
+ version: 8.0.11
postgres:
specifier: ^3.4.5
version: 3.4.9
@@ -140,6 +149,9 @@ importers:
'@types/node':
specifier: ^22.10.5
version: 22.20.1
+ '@types/nodemailer':
+ specifier: ^8.0.1
+ version: 8.0.1
tsx:
specifier: ^4.19.2
version: 4.23.1
@@ -862,6 +874,9 @@ packages:
'@types/node@22.20.1':
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
+ '@types/nodemailer@8.0.1':
+ resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1281,6 +1296,10 @@ packages:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'}
+ nodemailer@8.0.11:
+ resolution: {integrity: sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==}
+ engines: {node: '>=6.0.0'}
+
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
@@ -2041,6 +2060,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/nodemailer@8.0.1':
+ dependencies:
+ '@types/node': 22.20.1
+
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -2369,6 +2392,8 @@ snapshots:
node-releases@2.0.51: {}
+ nodemailer@8.0.11: {}
+
on-exit-leak-free@2.1.2: {}
path-scurry@2.0.2:
diff --git a/services/nimbus/.env.example b/services/nimbus/.env.example
index 740a894..8eecfcf 100644
--- a/services/nimbus/.env.example
+++ b/services/nimbus/.env.example
@@ -13,6 +13,27 @@
# "use the libpq defaults" and dials a database nobody configured.
NIMBUS_DATABASE_URL=postgres://cloudsforge:cloudsforge@localhost:5432/nimbus
+# Encrypts the RS256 signing key at rest. REQUIRED, min 24 chars, placeholders
+# refused — the service will not fall back to storing the key in the clear.
+# Generate: openssl rand -hex 32
+#
+# WHY IT IS NOT OPTIONAL. The private JWK is the estate's universal forging
+# credential: every service verifies `iss` plus `aud: cloudsforge` and nothing
+# else, so a self-minted {sub, roles:['admin']} is admin in all of them. It used
+# to sit as plaintext JSONB in a database every service shares one role on, which
+# turned any read-only leak — a stolen backup, a SELECT-only injection, a copied
+# dump — into silent, offline, unrevocable impersonation everywhere.
+#
+# KEEP IT OUT OF THE DATABASE AND OUT OF BACKUPS OF IT. That separation is the
+# entire mechanism; a copy of this string stored next to the ciphertext protects
+# nothing.
+#
+# Changing it makes the stored key undecryptable, and nimbus says so rather than
+# signing with something else. Recover by minting a replacement key
+# (POST /admin/signing-keys, then activate it), or on a deployment where every
+# session may be dropped, by deleting the signing_keys rows and restarting.
+NIMBUS_KEY_SECRET=
+
# --- Identity -------------------------------------------------------------
NIMBUS_PORT=4001
@@ -21,13 +42,80 @@ NIMBUS_PORT=4001
# public https origins is logged as an error at boot.
NIMBUS_ISSUER=http://localhost:4001
+# Where the account portal lives for real users, and the ONLY thing a link that
+# leaves this process is built from — today that means the password-reset link,
+# both the emailed one and the one the operator console is handed.
+#
+# It is configuration and not the request's Host header on purpose. A reset link
+# is minted for one person and read by another, out of an email the deployment's
+# own relay sent; deriving it from the request let an unauthenticated stranger
+# choose where it pointed, with `curl -H 'Host: evil.test' -X POST
+# /auth/password/forgot`. Nothing about the fragment helps — the attacker's page
+# reads location.hash.
+#
+# Unset, it falls back to NIMBUS_ISSUER, which is already the URL browsers reach
+# this service on. Set it when the portal has a friendlier hostname than the
+# issuer does: in the composed stack that is https://account. while the
+# issuer is https://nimbus., and compose sets it for you.
+# NIMBUS_PUBLIC_URL=http://localhost:4001
+
+# --- Outbound mail --------------------------------------------------------
+# The only mail this service sends: the password-reset link. Set SMTP_HOST and a
+# user who clicks "forgot password" is emailed a single-use link that expires in
+# 30 minutes; leave it unset and nothing is sent — the request is still recorded
+# and an operator issues the link from the admin console (Users → Reset
+# password), which is how it worked before this existed and is a supported way
+# to run, not a degraded one. Nimbus says which of the two it is, at info, on
+# every boot, and both the /forgot page and the 202 body change their wording to
+# match, so a user is never promised an email nobody can send.
+#
+# There is no provider code and no SDK — this is plain SMTP, so Brevo, Resend,
+# SendGrid, Mailtrap, a Gmail app password and your own postfix are the same four
+# settings and changing provider is an edit here and a restart. Free credentials
+# in two minutes: Brevo (300/day, no card, no custom domain) — SMTP & API → SMTP
+# → generate a key, then SMTP_HOST=smtp-relay.brevo.com, SMTP_PORT=587,
+# SMTP_USER=, SMTP_PASS=, SMTP_FROM=.
+#
+# The link that is emailed is built from NIMBUS_PUBLIC_URL above and never from
+# the request — read the note there before changing either.
+#
+# Unset = no mail. Everything below it is only read when it is set.
+SMTP_HOST=
+# 587 is STARTTLS and is what every provider documents first. 465 is implicit TLS
+# and additionally needs SMTP_SECURE=true; the two are NOT interchangeable, and
+# 465 with secure unset hangs until the connect timeout.
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=
+# Treat as a secret: it can send mail as you, and nothing rate-limits that but
+# the provider. Never your account password — providers issue a separate key.
+SMTP_PASS=
+# The From header, as a full RFC 5322 address. Must be an address the provider
+# has authorised for you, or it accepts the connection and rejects the message.
+# SMTP_HOST set without this (or without USER/PASS) is a boot-time error, not a
+# silent fallback to no mail.
+SMTP_FROM=CloudsForge
+# Optional. Where a reply goes, if you would rather it was not the From address.
+SMTP_REPLY_TO=
+
# --- Origins --------------------------------------------------------------
# Browser origins allowed to call this API (CORS). Comma-separated, never a
-# wildcard. account. is always allowed on top of whatever is set here.
+# wildcard, and EXACTLY what you write here — nothing is appended.
+#
+# This used to say account. was always allowed on top. It was not: the
+# service appended the literal https://account.cloudsforge.online, which on any
+# other apex is a foreign domain, and never your account.. The portal is
+# served by Nimbus itself and calls this API same-origin, so it needs no entry;
+# list account. yourself only if something ELSE is served from there. In
+# the composed stack docker-compose.yml supplies the whole list.
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,http://localhost:4004,http://localhost:4006,http://localhost:4010
# Origins the account portal will hand a single-use sign-in code back to.
# Defaults to CORS_ORIGINS. Product surfaces only — never an API service.
+# Because it defaults to the list above, an origin added there for CORS also
+# becomes a legal destination for a live sign-in code. Set this explicitly when
+# those two sets differ.
# PORTAL_ALLOWED_ORIGINS=
# Which peers may set X-Forwarded-For/-Proto/-Host. Defaults to private ranges,
diff --git a/services/nimbus/package.json b/services/nimbus/package.json
index 798c29b..9668229 100644
--- a/services/nimbus/package.json
+++ b/services/nimbus/package.json
@@ -7,7 +7,8 @@
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit",
- "build": "tsc --noEmit"
+ "build": "tsc --noEmit",
+ "test": "node --import tsx --test --test-concurrency=1 src/*.test.ts"
},
"dependencies": {
"@cloudsforge/shared": "^0.4.0",
@@ -18,11 +19,13 @@
"drizzle-orm": "^0.45.2",
"fastify": "^5.2.0",
"jose": "^5.9.6",
+ "nodemailer": "^8.0.11",
"postgres": "^3.4.5",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
+ "@types/nodemailer": "^8.0.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
diff --git a/services/nimbus/src/bootLog.ts b/services/nimbus/src/bootLog.ts
index 64df58d..fcafffc 100644
--- a/services/nimbus/src/bootLog.ts
+++ b/services/nimbus/src/bootLog.ts
@@ -7,7 +7,10 @@
* Fields mirror loggerOptions() in ./obs.ts so the two are indistinguishable
* downstream.
*/
-const LEVELS = { warn: 40, error: 50, fatal: 60 } as const
+// `info` is here for the settings that are legitimately optional: a deployment
+// that has not configured outbound mail is a supported one, and announcing it at
+// warn trains an operator to ignore the level that the broken settings use.
+const LEVELS = { info: 30, warn: 40, error: 50, fatal: 60 } as const
export function bootLog(
level: keyof typeof LEVELS,
diff --git a/services/nimbus/src/db/migrate.ts b/services/nimbus/src/db/migrate.ts
index 5b9ee69..51424b7 100644
--- a/services/nimbus/src/db/migrate.ts
+++ b/services/nimbus/src/db/migrate.ts
@@ -104,16 +104,104 @@ const STEPS: { name: string; ddl: string }[] = [
);
`,
},
+ {
+ name: 'refresh_token_rotation_grace',
+ // Nullable and with no backfill on purpose. `revoked` alone cannot say WHY a
+ // row is spent, and the family burn is only correct for a replay of a token
+ // that was already rotated some time ago; a token rotated 200ms ago is two
+ // tabs refreshing at once, not a thief. rotated_at is the only thing that
+ // distinguishes them. Every row that exists when this runs is left null, so
+ // it keeps exactly today's behaviour — a re-presentation of it burns the
+ // family — and only rotations performed after this deploy earn the grace.
+ ddl: `
+ ALTER TABLE refresh_tokens
+ ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMPTZ;
+ `,
+ },
+ {
+ name: 'signing_key_envelope_and_rotation',
+ // Three changes, one step, because a half-applied set of them is a service
+ // that cannot decide which key signs.
+ //
+ // 1. private_jwk_enc holds the AES-256-GCM envelope (src/keyEnvelope.ts).
+ // 2. private_jwk stops being NOT NULL so keys.ts can empty it in place. The
+ // column is NOT dropped here: dropping it in the same deploy that starts
+ // writing the new one leaves no way back if the envelope is wrong, and a
+ // NULL column is auditable — `SELECT count(*) FROM signing_keys WHERE
+ // private_jwk IS NOT NULL` is the question "is the key still readable to
+ // anyone who can read this database", and it should answer 0.
+ // 3. status/status_changed_at make rotation expressible. Existing rows
+ // default to 'active' and now(), which is what they are: the one key that
+ // signs. The CHECK is deliberate — a typo'd status that silently drops a
+ // key out of the JWKS would present as every service rejecting every
+ // token, a long way from the UPDATE that caused it.
+ ddl: `
+ ALTER TABLE signing_keys
+ ADD COLUMN IF NOT EXISTS private_jwk_enc TEXT,
+ ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active',
+ ADD COLUMN IF NOT EXISTS status_changed_at TIMESTAMPTZ NOT NULL DEFAULT now();
+
+ ALTER TABLE signing_keys
+ ALTER COLUMN private_jwk DROP NOT NULL;
+
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'signing_keys_status_check'
+ ) THEN
+ ALTER TABLE signing_keys
+ ADD CONSTRAINT signing_keys_status_check
+ CHECK (status IN ('active', 'published', 'retired'));
+ END IF;
+ END $$;
+
+ CREATE INDEX IF NOT EXISTS signing_keys_status_idx ON signing_keys(status);
+ `,
+ },
]
+/**
+ * The advisory-lock key this migration serialises on.
+ *
+ * Any 64-bit constant would do; this one is `nimbus` in hex so that a
+ * `SELECT * FROM pg_locks WHERE locktype = 'advisory'` during a stuck boot names
+ * the thing holding it.
+ */
+const MIGRATION_LOCK_KEY = 0x6e696d627573 // 'nimbus', 121399085790579
+
export async function migrate(log: FastifyBaseLogger): Promise {
- for (const { name, ddl } of STEPS) {
- try {
- await sql.unsafe(ddl)
- } catch (err) {
- log.error({ err, migration: name }, `migration failed: ${name}`)
- throw err
+ /* ONE MIGRATOR AT A TIME, and the lock is not belt-and-braces.
+ *
+ * `CREATE TABLE IF NOT EXISTS` is idempotent but it is NOT concurrency-safe:
+ * two sessions that both find the table missing both proceed to create it, and
+ * the loser gets `duplicate key value violates unique constraint
+ * "pg_type_typname_nsp_index"` out of the system catalogue rather than a
+ * clean no-op. The same is true of `ADD COLUMN IF NOT EXISTS` and of the
+ * DO-block that adds the status CHECK. That is not hypothetical: two test
+ * suites that node --test was running in parallel against one scratch database
+ * raced here, and the failure — `duplicate key … pg_type_typname_nsp_index` —
+ * named neither of them. The suites take turns now (package.json's
+ * --test-concurrency=1), which is why that particular collision cannot recur;
+ * the lock is for the case nothing can serialise from the outside, which is
+ * two containers of this service starting together.
+ *
+ * pg_advisory_xact_lock rather than the session form: it is released by the
+ * commit or the rollback, so a migration that throws cannot leave the lock
+ * held by a pooled connection that is handed to the next caller. Taking it
+ * inside sql.begin() is also what pins every statement below to ONE
+ * connection, which the pool does not otherwise promise. */
+ await sql.begin(async (tx) => {
+ await tx`SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_KEY})`
+ for (const { name, ddl } of STEPS) {
+ try {
+ await tx.unsafe(ddl)
+ } catch (err) {
+ // Named here and rethrown, so the boot dies on the step that broke
+ // rather than serving requests against a half-migrated schema.
+ log.error({ err, migration: name }, `migration failed: ${name}`)
+ throw err
+ }
}
- }
+ })
log.info({ migrations: STEPS.length }, 'migrations applied')
}
diff --git a/services/nimbus/src/db/schema.ts b/services/nimbus/src/db/schema.ts
index 0e083e2..f049120 100644
--- a/services/nimbus/src/db/schema.ts
+++ b/services/nimbus/src/db/schema.ts
@@ -26,6 +26,13 @@ export const refreshTokens = pgTable('refresh_tokens', {
.default(sql`gen_random_uuid()`),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
revoked: boolean('revoked').notNull().default(false),
+ // When this row was spent BY A ROTATION, and only by a rotation. `revoked` is
+ // set by four different things (rotation, logout, a password change, a family
+ // burn) and cannot tell them apart; this column can, which is what lets a
+ // re-presentation seconds after a rotation be answered as the concurrent
+ // refresh it almost always is instead of as theft. Null on a live row and on
+ // every row revoked some other way — those keep burning the family.
+ rotatedAt: timestamp('rotated_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
@@ -66,11 +73,44 @@ export const passwordResetTokens = pgTable('password_reset_tokens', {
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
+/**
+ * Where a signing key is in its life. Exactly one row is `active` at a time.
+ *
+ * `published` is the state that makes rotation safe in both directions. A key
+ * must be in the JWKS before it signs anything, or the tokens it mints are
+ * rejected by every service whose JWKS cache has not refreshed; and a key must
+ * STAY in the JWKS after it stops signing, until the last access token it minted
+ * has expired. Both of those are the same state — in the document, not signing —
+ * so there is one name for it.
+ *
+ * `retired` is the only state that leaves the JWKS. Nothing deletes a row: the
+ * kid of a key that ever signed is worth keeping so an old token in a log can be
+ * attributed to the key that made it.
+ */
+export const SIGNING_KEY_STATUSES = ['active', 'published', 'retired'] as const
+export type SigningKeyStatus = (typeof SIGNING_KEY_STATUSES)[number]
+
export const signingKeys = pgTable('signing_keys', {
kid: text('kid').primaryKey(),
- privateJwk: jsonb('private_jwk').$type>().notNull(),
+ // The private half, AES-256-GCM under NIMBUS_KEY_SECRET (../keyEnvelope.ts).
+ // Nullable only because the plaintext column below outlived it for one deploy;
+ // a row written by this build always has it.
+ privateJwkEnc: text('private_jwk_enc'),
+ // THE PLAINTEXT COLUMN, KEPT ONLY TO BE EMPTIED. It held the RS256 private key
+ // as readable JSONB — the estate's universal forging credential, in a database
+ // eight services share a role on (CF-16). keys.ts re-encrypts any row that
+ // still has one at boot and NULLs it in the same statement. The column stays
+ // so that upgrade is a no-op on a fresh database and so nothing silently
+ // writes here again; the day every deployment has run this build once, it can
+ // be dropped. Note what re-encrypting does NOT undo: a backup taken before the
+ // upgrade still contains the key, and the only fix for that is rotation.
+ privateJwk: jsonb('private_jwk').$type>(),
publicJwk: jsonb('public_jwk').$type>().notNull(),
+ status: text('status').$type().notNull().default('active'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ // When it entered its current state. `activate` refuses a key that has not
+ // been published for a whole access-token TTL, and that is the clock it reads.
+ statusChangedAt: timestamp('status_changed_at', { withTimezone: true }).notNull().defaultNow(),
})
export type UserRow = typeof users.$inferSelect
diff --git a/services/nimbus/src/env.test.ts b/services/nimbus/src/env.test.ts
new file mode 100644
index 0000000..ba3f790
--- /dev/null
+++ b/services/nimbus/src/env.test.ts
@@ -0,0 +1,208 @@
+import assert from 'node:assert/strict'
+import { execFile } from 'node:child_process'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { test } from 'node:test'
+
+/**
+ * What the origin allowlists are allowed to contain.
+ *
+ * WHY THIS TEST SPAWNS A PROCESS. env.ts is evaluated once, at import, off `process.env`,
+ * and it calls `process.exit(1)` on a bad database URL. There is no seam to inject a
+ * different environment into, and `import()`-ing it twice in one process gets the module
+ * cache, not a re-read. So each case is a real boot of the real module with a real
+ * environment, and the assertions are made against what that boot produced. Anything less
+ * would be testing a copy of the logic rather than the logic.
+ *
+ * THE DEFECT (CF-47). env.ts appended the literal `https://account.cloudsforge.online` to
+ * corsOrigins on every boot, on the reasoning that nimbus serves the account portal. The
+ * apex is configurable — compose builds every origin from ${CLOUDSFORGE_APEX} — so on any
+ * other apex that is a host on somebody else's domain. And corsOrigins is the DEFAULT for
+ * portalAllowedOrigins, the return-URL allowlist: a foreign-apex deployment that had not set
+ * PORTAL_ALLOWED_ORIGINS would accept `/login?return=https://account.cloudsforge.online/...`
+ * and hand that domain a live single-use sign-in code (routes/portal.ts:74-77).
+ *
+ * `shipped()` below is the derivation as it stood before the fix, run over the same inputs,
+ * so each case shows itself catching the version that had the bug.
+ *
+ * NOTE ON HERMETICITY. env.ts loads services/nimbus/.env and then the repo root .env, and
+ * dotenv keeps the first value it sees — but it never overwrites a variable already present
+ * in the environment. Every variable a case depends on is therefore set explicitly below,
+ * except PORTAL_ALLOWED_ORIGINS, whose absence is the whole point of the second case. A
+ * stray local .env that sets it will fail this file loudly rather than pass it quietly.
+ */
+
+const execFileAsync = promisify(execFile)
+const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+
+/** The corsOrigins derivation as it was before this change. */
+function shipped(configured: string[]): string[] {
+ const out = [...configured]
+ if (!out.includes('https://account.cloudsforge.online')) {
+ out.push('https://account.cloudsforge.online')
+ }
+ return out
+}
+
+interface Booted {
+ corsOrigins: string[]
+ portalAllowedOrigins: string[]
+ /** Every structured line env.ts wrote to stdout while booting. */
+ bootLines: { level: number; msg: string }[]
+}
+
+/**
+ * Boot env.ts in a child process under `vars` and return what it produced. The environment
+ * is replaced, not extended: an inherited CORS_ORIGINS would otherwise decide the answer.
+ */
+async function boot(vars: Record): Promise {
+ const MARK = '__ENV_JSON__'
+ const source = `
+ import { env } from './src/env.ts'
+ console.log('${MARK}' + JSON.stringify({
+ corsOrigins: env.corsOrigins,
+ portalAllowedOrigins: env.portalAllowedOrigins,
+ }))
+ `
+ const { stdout } = await execFileAsync(process.execPath, ['--import', 'tsx', '-e', source], {
+ cwd: pkgRoot,
+ env: {
+ // Enough to find node and give tsx somewhere to write its cache, and no more.
+ PATH: process.env.PATH ?? '',
+ HOME: process.env.HOME ?? '',
+ TMPDIR: process.env.TMPDIR ?? '',
+ NIMBUS_DATABASE_URL: 'postgres://u:p@localhost:5432/nimbus',
+ // env.ts refuses to boot without it (CF-16), so every child boot needs one.
+ NIMBUS_KEY_SECRET: 'a-test-secret-that-is-long-enough',
+ ...vars,
+ },
+ })
+ const line = stdout.split('\n').find((l) => l.startsWith(MARK))
+ assert.ok(line, `env.ts printed no result. stdout:\n${stdout}`)
+ const parsed = JSON.parse(line.slice(MARK.length)) as Omit
+ const bootLines = stdout
+ .split('\n')
+ .filter((l) => l.startsWith('{'))
+ .map((l) => JSON.parse(l) as { level: number; msg: string })
+ return { ...parsed, bootLines }
+}
+
+test('a foreign apex is never added to either allowlist', async () => {
+ // The scenario from the register: an operator deploys on forge.example and sets
+ // CORS_ORIGINS by hand. PORTAL_ALLOWED_ORIGINS is left unset, so it inherits.
+ const configured = ['https://forge.example', 'https://admin.forge.example']
+ const booted = await boot({
+ NIMBUS_ISSUER: 'https://nimbus.forge.example',
+ CORS_ORIGINS: configured.join(','),
+ })
+
+ assert.deepEqual(booted.corsOrigins, configured)
+ assert.deepEqual(booted.portalAllowedOrigins, configured)
+
+ // The property, stated the way it matters: no origin outside the operator's own apex may
+ // receive a sign-in code. Asserting the exact lists above would pass just as well if the
+ // append moved somewhere else, so say it once more in the form the harm takes.
+ const foreign = booted.portalAllowedOrigins.filter((o) => !o.endsWith('forge.example'))
+ assert.deepEqual(foreign, [], 'a domain the operator does not control can receive a sign-in code')
+
+ // ...and the same inputs through the shipped derivation, which fails it.
+ assert.ok(shipped(configured).some((o) => !o.endsWith('forge.example')))
+})
+
+test('nothing is appended when CORS_ORIGINS is not set either', async () => {
+ const booted = await boot({ NIMBUS_ISSUER: 'https://nimbus.forge.example' })
+ assert.deepEqual(
+ booted.corsOrigins.filter((o) => !o.startsWith('http://localhost:')),
+ [],
+ 'the built-in default must be local ports only',
+ )
+})
+
+test('a pure-localhost boot does not report public origins', async () => {
+ // The second, quieter half of the same defect. With the append in place, the default
+ // portalAllowedOrigins inherited an https:// origin on a laptop with nothing public
+ // configured at all — so every `pnpm dev` logged "NIMBUS_ISSUER is a localhost URL but
+ // public origins are allowed" at error. A boot error that is wrong on the developer path
+ // is one nobody reads on the deploy path.
+ const booted = await boot({
+ NIMBUS_ISSUER: 'http://localhost:4001',
+ CORS_ORIGINS: 'http://localhost:3000,http://localhost:4004',
+ })
+ const issuerErrors = booted.bootLines.filter((l) => l.msg.startsWith('NIMBUS_ISSUER'))
+ assert.deepEqual(issuerErrors, [], 'the localhost issuer check fired with no public origin set')
+
+ // The check must still fire when an https origin really is configured, or the fix above
+ // has simply disabled it.
+ const real = await boot({
+ NIMBUS_ISSUER: 'http://localhost:4001',
+ CORS_ORIGINS: 'https://account.forge.example',
+ })
+ assert.equal(real.bootLines.filter((l) => l.msg.startsWith('NIMBUS_ISSUER')).length, 1)
+})
+
+/* ------------------------- the key secret is fail-closed ------------------------- */
+
+/**
+ * Boot env.ts and report whether it survived. `boot()` above cannot be used: it parses a
+ * result line the process never prints when it exits at import, and a `boot()` that tolerated
+ * that would stop being able to tell a bad config from a working one.
+ */
+async function bootExpectingExit(
+ vars: Record,
+): Promise<{ code: number; lines: { level: number; msg: string }[] }> {
+ const source = "import './src/env.ts'"
+ try {
+ const { stdout } = await execFileAsync(process.execPath, ['--import', 'tsx', '-e', source], {
+ cwd: pkgRoot,
+ env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', TMPDIR: process.env.TMPDIR ?? '', ...vars },
+ })
+ return { code: 0, lines: parseLines(stdout) }
+ } catch (err) {
+ const e = err as { code?: number; stdout?: string }
+ return { code: e.code ?? -1, lines: parseLines(e.stdout ?? '') }
+ }
+}
+
+function parseLines(stdout: string): { level: number; msg: string }[] {
+ return stdout
+ .split('\n')
+ .filter((l) => l.startsWith('{'))
+ .map((l) => JSON.parse(l) as { level: number; msg: string })
+}
+
+const DB = 'postgres://u:p@localhost:5432/nimbus'
+
+test('nimbus refuses to boot without NIMBUS_KEY_SECRET rather than storing the key in the clear', async () => {
+ // CF-16. The RS256 private key was plaintext JSONB in a database every service shares one
+ // Postgres role on, and it is the estate's universal forging credential. A fallback here
+ // would be worse than none at all: the column would look protected while every deployment
+ // that never set the variable kept the key readable.
+ const booted = await bootExpectingExit({ NIMBUS_DATABASE_URL: DB })
+ assert.equal(booted.code, 1, 'a missing key secret must be fatal, not a warning')
+ const fatal = booted.lines.filter((l) => l.level === 60 && l.msg.startsWith('NIMBUS_KEY_SECRET'))
+ assert.equal(fatal.length, 1, 'and it must say which variable, at fatal, in the log stream')
+})
+
+test('a placeholder or a short NIMBUS_KEY_SECRET is refused too', async () => {
+ // The same rules ForgeKeyvault applies to KEYVAULT_MASTER_SECRET, deliberately: a secret
+ // copied out of an example file is a secret an attacker also has.
+ // The third is a real-looking secret that is simply too short. Not the literal "short":
+ // the refusal's own message contains that word, and the assertion below would read it as
+ // the value having been echoed.
+ for (const value of ['changeme', 'dev-nimbus-key-secret', 'x7QwPz2m']) {
+ const booted = await bootExpectingExit({ NIMBUS_DATABASE_URL: DB, NIMBUS_KEY_SECRET: value })
+ assert.equal(booted.code, 1, `"${value}" was accepted as a key secret`)
+ // The refusal must never quote the value back — this one is a placeholder, the next one
+ // an operator pastes will not be.
+ assert.ok(!JSON.stringify(booted.lines).includes(value))
+ }
+})
+
+test('a real secret boots', async () => {
+ const booted = await bootExpectingExit({
+ NIMBUS_DATABASE_URL: DB,
+ NIMBUS_KEY_SECRET: 'a-test-secret-that-is-long-enough',
+ })
+ assert.equal(booted.code, 0)
+})
diff --git a/services/nimbus/src/env.ts b/services/nimbus/src/env.ts
index a385b8a..e2358ba 100644
--- a/services/nimbus/src/env.ts
+++ b/services/nimbus/src/env.ts
@@ -19,18 +19,41 @@ const splitList = (raw: string) =>
.map((o) => o.trim())
.filter(Boolean)
+/** Trimmed, with the empty string treated as absent. */
+const optional = (raw: string | undefined): string | undefined => {
+ const v = raw?.trim()
+ return v ? v : undefined
+}
+
// Allowed browser origins for CORS. Prod sets CORS_ORIGINS (comma-separated,
-// e.g. the *.cloudsforge.online subdomains behind the Cloudflare Tunnel);
-// dev falls back to the local app ports. Nimbus now also serves the shared
-// CloudsForge account portal, so account.cloudsforge.online is always allowed.
-const configuredCors = splitList(
+// e.g. the subdomains of the deployment's apex behind the Cloudflare Tunnel);
+// dev falls back to the local app ports.
+//
+// THIS LIST IS EXACTLY WHAT THE OPERATOR CONFIGURED — nothing is appended. It
+// used to push `https://account.cloudsforge.online` unconditionally, on the
+// reasoning that nimbus serves the shared account portal and should therefore
+// always allow its own subdomain. Both halves of that were wrong:
+//
+// * The portal is served BY nimbus and fetches its own API with relative
+// URLs, so it is same-origin and needs no CORS entry at all.
+// * The apex is configurable. docker-compose.yml builds every origin from
+// ${CLOUDSFORGE_APEX}, so on a deployment whose apex is not
+// cloudsforge.online this appended a host on somebody else's domain — and
+// corsOrigins is the DEFAULT for portalAllowedOrigins below, which is the
+// return-URL allowlist. A foreign-apex run that did not set
+// PORTAL_ALLOWED_ORIGINS would therefore accept
+// /login?return=https://account.cloudsforge.online/... and hand a live
+// single-use sign-in code to a domain the operator does not control.
+//
+// The remedy is not to derive `account.` here — nimbus reads no apex, and
+// a service carrying its own copy of the origin list is precisely what
+// docker-compose.yml:57-61 records as having drifted before. Compose already
+// interpolates https://account.${CLOUDSFORGE_APEX} into both CORS_ORIGINS and
+// PORTAL_ALLOWED_ORIGINS, so the one list stays one list.
+const corsOrigins = splitList(
process.env.CORS_ORIGINS ??
'http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,http://localhost:3004,http://localhost:4004',
)
-const corsOrigins = [...configuredCors]
-if (!corsOrigins.includes('https://account.cloudsforge.online')) {
- corsOrigins.push('https://account.cloudsforge.online')
-}
// Origins the account portal may hand a sign-in code back to (return-URL
// allowlist). Defaults to the CORS origins — an explicit list of real product
@@ -56,9 +79,13 @@ const issuer = process.env.NIMBUS_ISSUER ?? 'http://localhost:4001'
// It fails as "logged in, then bounced", nowhere near the setting that caused
// it — the exact class of silence this service has already been bitten by, so
// say it once, loudly, at boot rather than leaving it to be inferred.
-// Read the CONFIGURED lists, not corsOrigins — account.cloudsforge.online is
-// appended unconditionally above, so it is public on a pure-localhost setup too.
-const publicOrigins = [...configuredCors, ...portalAllowedOrigins].filter((o) =>
+// Both lists, because either can be set on its own. Neither is synthesised any
+// more: while corsOrigins carried an appended https://account.cloudsforge.online
+// this check fired on every plain `pnpm dev` — the default portalAllowedOrigins
+// inherited that origin, so a pure-localhost setup always had one "public"
+// origin and always logged this error. A boot error that is wrong on the
+// developer path is a boot error nobody reads on the deploy path.
+const publicOrigins = [...corsOrigins, ...portalAllowedOrigins].filter((o) =>
o.startsWith('https://'),
)
const issuerIsLocal = /^https?:\/\/(localhost|127\.0\.0\.1)(:|$)/.test(issuer)
@@ -72,6 +99,89 @@ if (issuerIsLocal && publicOrigins.length > 0) {
})
}
+/* ------------------------------------------------------------------ *
+ * The origin a link that LEAVES this process is built from.
+ *
+ * Everything nimbus renders for the browser derives its URLs from the request
+ * (routes/portal.ts baseUrl), and that is correct there: those links are handed
+ * straight back to the same browser on the same host, so reflecting the host
+ * only ever reflects it at itself.
+ *
+ * A password-reset link is not that. It is minted for one person and read by
+ * another — by a mail client, hours later, out of a message the deployment's
+ * own relay signed and sent. Deriving THAT from `req.host` means an
+ * unauthenticated stranger picks where it points: `curl -H 'Host: evil.test'
+ * -X POST /auth/password/forgot -d '{"email":"victim@…"}'` produces a genuine,
+ * correctly branded CloudsForge email whose button is
+ * `http://evil.test/reset#token=`. The victim clicks a link that
+ * arrived exactly as it should have, and hands the attacker's page a single-use
+ * token that changes their password. `X-Forwarded-Host` is the same hole one
+ * hop further out: cloudflared is a private-range peer and trustProxy above
+ * believes it, so the header comes off the internet.
+ *
+ * So this is configuration, not a header. There used to be no such setting, on
+ * the reasoning that nimbus answers on two hostnames and a link minted on
+ * whichever one the caller used is reachable by construction — true, and beside
+ * the point once anything mails the link somewhere.
+ *
+ * NIMBUS_PUBLIC_URL is where the account portal lives for real users
+ * (https://account.). Unset, it falls back to NIMBUS_ISSUER, which is
+ * already the URL browsers reach nimbus on in every deployment and defaults to
+ * this process's own localhost port in development — so the fallback is a
+ * working link, never a header.
+ * ------------------------------------------------------------------ */
+
+/** Scheme + host + port, no trailing slash, or null if `raw` is not that. */
+function originOf(raw: string): string | null {
+ let url: URL
+ try {
+ url = new URL(raw)
+ } catch {
+ return null
+ }
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
+ return url.origin
+}
+
+function readPublicUrl(): string {
+ const issuerOrigin = originOf(issuer)
+ // NIMBUS_ISSUER is validated nowhere else and is a plain string in a JWT
+ // claim, so it can be something that is not a URL at all. Fall back to the
+ // port this process actually listens on rather than to nothing.
+ const fallback = issuerOrigin ?? `http://localhost:${process.env.NIMBUS_PORT ?? 4001}`
+
+ const raw = optional(process.env.NIMBUS_PUBLIC_URL)
+ if (!raw) return fallback
+
+ const origin = originOf(raw)
+ if (!origin) {
+ // Fail back to the fallback, never to the request. A reset link built from
+ // a header is the defect this setting exists to close, so a malformed value
+ // must not reopen it — it costs a wrong-looking (but working) link and a
+ // boot error that says exactly which variable is wrong.
+ bootLog('error', 'NIMBUS_PUBLIC_URL is not an http(s) URL', {
+ value: raw,
+ using: fallback,
+ fix: 'Set it to the origin users reach the account portal on, scheme included and nothing after the host — e.g. https://account.. Unset it to use NIMBUS_ISSUER.',
+ })
+ return fallback
+ }
+ return origin
+}
+
+const publicUrl = readPublicUrl()
+
+/**
+ * The origins nimbus expects to be ADDRESSED on. Not an allowlist — nothing is
+ * authorised by it and no link is built from it. It exists so that a request
+ * arriving on some other host can be told apart from the two legitimate ones
+ * and logged, which is the only warning anybody gets that a Host-header probe
+ * is happening or that NIMBUS_PUBLIC_URL is set to the wrong thing.
+ */
+const publicUrlAliases = [publicUrl, originOf(issuer)].filter(
+ (o, i, all): o is string => typeof o === 'string' && all.indexOf(o) === i,
+)
+
// Nimbus cannot do one useful thing without a database, and postgres() treats an
// undefined connection string as "use the libpq defaults" — so an absent value
// did not fail, it quietly dialled localhost:5432 as whatever user the process
@@ -99,6 +209,66 @@ function requireDatabaseUrl(): string {
return raw
}
+/**
+ * The secret the RS256 signing key is encrypted at rest under (CF-16).
+ *
+ * REQUIRED, AND FAIL-CLOSED, for the same reason KEYVAULT_MASTER_SECRET is: a
+ * default here would be worse than no encryption at all, because everyone would
+ * believe the column was protected while the key that unlocks it is in the
+ * published source. The private JWK is the estate's universal forging
+ * credential — every service checks `iss` plus `aud: 'cloudsforge'` and nothing
+ * more — and it is the thing forge-keyvault's admin surface trusts.
+ *
+ * The rules are keyvault's rules, deliberately: present, not a known
+ * placeholder, at least 24 characters. This is the one variable nimbus will not
+ * start without besides the database, and the refusal is loud because a silent
+ * downgrade to plaintext is exactly the failure this closes.
+ *
+ * WHAT CHANGING IT COSTS. The stored key becomes undecryptable, which is
+ * survivable and not silent: nimbus refuses to serve rather than signing with
+ * something else. The recovery is to insert a new signing key — see
+ * `POST /admin/signing-keys` — or, on a deployment where every session may be
+ * dropped, to delete the rows and let first-boot generation run again. Access
+ * tokens are 15 minutes and refresh tokens are opaque DB rows rather than
+ * signed, so the cost of losing the key is a 15-minute window of 401s that
+ * clients recover from through /auth/refresh — not an estate-wide logout.
+ */
+const REJECTED_SECRETS = new Set([
+ 'changeme',
+ 'change-me',
+ 'dev-master-secret-change-me',
+ 'dev-nimbus-key-secret',
+ 'nimbus-key-secret',
+ 'secret',
+])
+
+function requireKeySecret(): string {
+ const raw = optional(process.env.NIMBUS_KEY_SECRET)
+ const generate = 'Generate one with `openssl rand -hex 32` and set NIMBUS_KEY_SECRET in .env. Keep it OUT of the database and out of any backup of it — that separation is the whole point.'
+ if (!raw) {
+ bootLog('fatal', 'NIMBUS_KEY_SECRET is not set', {
+ fix: `The RS256 signing key is stored encrypted under it, and nimbus will not fall back to plaintext. ${generate}`,
+ })
+ process.exit(1)
+ }
+ if (REJECTED_SECRETS.has(raw)) {
+ bootLog('fatal', 'NIMBUS_KEY_SECRET is set to a known placeholder', {
+ fix: generate,
+ })
+ process.exit(1)
+ }
+ if (raw.length < 24) {
+ bootLog('fatal', 'NIMBUS_KEY_SECRET is too short', {
+ // The length, never the value.
+ length: raw.length,
+ minimum: 24,
+ fix: generate,
+ })
+ process.exit(1)
+ }
+ return raw
+}
+
// Where ForgeKeyvault answers. Nimbus proxies the admin console's vault reads
// through itself because keyvault is deliberately never routed publicly (it
// holds custody keys and binds to loopback), so the browser cannot reach it.
@@ -126,15 +296,19 @@ if (keyvaultIsLocal && !issuerIsLocal) {
/**
* Where Forge Pay answers, from THIS process.
*
- * Nimbus proxies the admin console's Forge Pay reads and the one administered-price
- * write, for a different reason than the keyvault proxy above. Pay IS publicly routed
- * (deploy/cloudflared/config.example.yml routes pay., with /internal refused at the
- * edge), and admin. is on pay's CORS allowlist — so the console can and does read
- * from it directly in principle. What it cannot do is WRITE: pay registers @fastify/cors
- * without a `methods` option, and the preflight it actually returns is
- * `access-control-allow-methods: GET,HEAD,POST`. `PUT /admin/prices/:coin` is the only way
- * to set the EMBER price, and no browser will send it. The proxy is how that write happens
- * at all; the reads come with it so the panel has one origin and one failure mode.
+ * Nimbus proxies the admin console's Forge Pay reads, the one administered-price write
+ * and the withdrawal abandon, for a different reason than the keyvault proxy above.
+ * Keyvault is unroutable and the console physically cannot reach it. Pay IS publicly
+ * routed (deploy/cloudflared/config.example.yml routes pay., with /internal refused
+ * at the edge) and admin. is on pay's CORS allowlist, so the console could call
+ * every one of those routes direct. It goes through here for the panel's sake rather than
+ * the network's: one origin, one auth path and one failure mode for a screen whose every
+ * other call is to Nimbus on the same bearer.
+ *
+ * This note used to say the console could not send the price PUT, because pay's preflight
+ * named GET, HEAD and POST only. That was true until forge-pay f3eb408 (2026-07-28) stated
+ * pay's method list explicitly and included PUT; the argument that survived it is written
+ * out at the top of routes/pay.ts (CF-46).
*
* NOTE THE DEFAULT, which differs from keyvaultUrl's above ON PURPOSE. docker-compose.yml
* gives the nimbus service KEYVAULT_URL explicitly, so that one can default to localhost
@@ -149,6 +323,101 @@ if (keyvaultIsLocal && !issuerIsLocal) {
*/
const payUrl = process.env.PAY_API_URL ?? 'http://pay:4003'
+/* ------------------------------------------------------------------ *
+ * Outbound mail (SMTP).
+ *
+ * One env-driven transport and no provider code. Brevo's free relay, a Gmail
+ * app password, Resend, SendGrid, Mailtrap and a self-hosted postfix all speak
+ * the same four settings, so switching provider is an .env edit and a restart —
+ * never a code change, never a second secret, never an SDK that has to be kept
+ * current. The only thing nimbus sends is a password-reset link.
+ *
+ * UNSET IS A SUPPORTED MODE, not a degraded one. With no SMTP_HOST the reset
+ * flow behaves exactly as it did before mail existed: the token is minted and
+ * recorded, and an operator issues the link from the console. That is announced
+ * at info, because an operator who has deliberately not configured mail must not
+ * be handed a warning every boot — the level has to keep meaning something for
+ * the checks above it.
+ * ------------------------------------------------------------------ */
+
+export interface SmtpConfig {
+ host: string
+ port: number
+ /** true = implicit TLS (465). false = plain connect then STARTTLS (587). */
+ secure: boolean
+ user: string
+ pass: string
+ /** RFC 5322 From, e.g. `CloudsForge `. */
+ from: string
+ replyTo?: string
+}
+
+function readSmtp(): SmtpConfig | null {
+ const host = optional(process.env.SMTP_HOST)
+ if (!host) {
+ bootLog('info', 'SMTP is not configured — password reset links are issued by an operator', {
+ fix: 'Optional. To send reset emails set SMTP_HOST, SMTP_USER, SMTP_PASS and SMTP_FROM (see .env.example, "Outbound mail"). Until then a self-service reset is recorded and an admin hands the link over from the console: Users → Reset password.',
+ })
+ return null
+ }
+
+ const user = optional(process.env.SMTP_USER)
+ const pass = optional(process.env.SMTP_PASS)
+ const from = optional(process.env.SMTP_FROM)
+
+ // Half-configured is the dangerous state, and it is the likely one: an
+ // operator pastes the host out of a provider's quickstart and comes back for
+ // the credentials later. nodemailer would then connect and be refused with a
+ // 5xx per send — inside deliverPasswordReset, which swallows everything so the
+ // route cannot become an enumeration oracle. So the only place this can be
+ // said out loud is here, at boot, before it is silent forever.
+ const missing = [
+ !user && 'SMTP_USER',
+ !pass && 'SMTP_PASS',
+ !from && 'SMTP_FROM',
+ ].filter(Boolean) as string[]
+ if (missing.length > 0) {
+ bootLog('error', 'SMTP_HOST is set but the rest of the SMTP settings are not', {
+ host,
+ missing,
+ fix: `Set ${missing.join(', ')} as well, or unset SMTP_HOST to go back to operator-issued reset links. Credentials come from your provider (Brevo: SMTP & API → SMTP keys). SMTP_FROM must be a full address, e.g. "CloudsForge ".`,
+ })
+ return null
+ }
+
+ // 587 (STARTTLS) is the default because it is what every relay in the list
+ // above documents first. 465 is implicit TLS and needs SMTP_SECURE=true: the
+ // two are not interchangeable, and a 465 connection with secure=false hangs
+ // until the connection timeout rather than failing with anything readable.
+ const rawPort = optional(process.env.SMTP_PORT)
+ const port = rawPort ? Number(rawPort) : 587
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ bootLog('error', 'SMTP_PORT is not a port number', {
+ value: rawPort,
+ fix: 'Use 587 for STARTTLS (the usual choice) or 465 with SMTP_SECURE=true for implicit TLS. Leave it unset for 587.',
+ })
+ return null
+ }
+ const secure = optional(process.env.SMTP_SECURE) === 'true'
+ if (port === 465 && !secure) {
+ bootLog('warn', 'SMTP_PORT is 465 but SMTP_SECURE is not true', {
+ fix: 'Port 465 is implicit TLS: set SMTP_SECURE=true, or use port 587, which negotiates STARTTLS after connecting.',
+ })
+ }
+
+ bootLog('info', 'SMTP is configured — password reset links are emailed', {
+ host,
+ port,
+ secure,
+ // The address, never the credential. `user` is frequently an account id or
+ // an email and is not a secret; `pass` never appears in any log line here.
+ from,
+ })
+ return { host, port, secure, user: user!, pass: pass!, from: from!, replyTo: optional(process.env.SMTP_REPLY_TO) }
+}
+
+const smtp = readSmtp()
+
// Reveal returns a plaintext custody private key. Proxying it would make it
// reachable from the public internet on an admin token alone, where today it
// additionally requires network access to a loopback-bound service — a second
@@ -161,10 +430,18 @@ export const env = {
trustProxy,
portalAllowedOrigins,
issuer,
+ /** Origin every link that leaves this process is built from. Never a header. */
+ publicUrl,
+ /** The origins nimbus expects to be addressed on. For logging only. */
+ publicUrlAliases,
databaseUrl: requireDatabaseUrl(),
+ /** Encrypts `signing_keys.private_jwk_enc`. Never logged, never returned. */
+ keySecret: requireKeySecret(),
keyvaultUrl,
payUrl,
vaultRevealProxy,
+ /** null when outbound mail is not configured — a supported deployment. */
+ smtp,
adminEmail: process.env.ADMIN_EMAIL,
adminPassword: process.env.ADMIN_PASSWORD,
adminHandle: process.env.ADMIN_HANDLE,
diff --git a/services/nimbus/src/forgotTiming.test.ts b/services/nimbus/src/forgotTiming.test.ts
new file mode 100644
index 0000000..424f0d1
--- /dev/null
+++ b/services/nimbus/src/forgotTiming.test.ts
@@ -0,0 +1,156 @@
+import assert from 'node:assert/strict'
+import { randomUUID } from 'node:crypto'
+import { createServer, type Server, type Socket, type AddressInfo } from 'node:net'
+import { after, test } from 'node:test'
+import Fastify from 'fastify'
+import type { FastifyBaseLogger } from 'fastify'
+
+/**
+ * POST /auth/password/forgot answers in the same time for an address that exists and one
+ * that does not.
+ *
+ * THE DEFECT. Wiring SMTP delivery into this route made it `await` the send before replying.
+ * The status code and the body were written with great care to be identical for both cases —
+ * the route's own comment calls a differing "status, body or TIMING" an account-enumeration
+ * oracle — and then the clock said everything they refused to. Measured against a relay that
+ * accepts the connection and never speaks: an unknown address answered in 10ms and a known
+ * one in 6015ms, the whole attempt budget. Even a healthy local sink split 4ms from 33ms, and
+ * a real relay's TLS handshake is wider than that. Any relay outage silently converted this
+ * endpoint into a reliable "does this person have an account" API, answerable one request at
+ * a time by anyone.
+ *
+ * WHAT IS ASSERTED. That the reply does not wait for the relay: the send is still in flight —
+ * this file's fake relay has the connection open and is deliberately saying nothing — when
+ * both answers have already been written, and the two answers are within a few milliseconds
+ * of each other rather than a few seconds.
+ *
+ * WHY A HUNG RELAY AND NOT A REFUSED PORT. A refused connection fails in under a millisecond,
+ * so a route that awaited it would still look fast here and the case would pass against the
+ * bug. The failure mode that matters is the slow one, so the relay accepts and stalls. Put
+ * the route back the way it shipped — mint, send, THEN reply — and this case reports the
+ * known address at 6078ms against the unknown one's 5ms, and fails on both assertions.
+ *
+ * HOW TO RUN IT. The same scratch database as tokens.test.ts, for the same reason: the branch
+ * under test is chosen by a real SELECT against `users`.
+ */
+
+function testDatabase(): { url: string } | { skip: string } {
+ const raw = process.env.NIMBUS_TEST_DATABASE_URL?.trim()
+ if (!raw) {
+ return { skip: 'NIMBUS_TEST_DATABASE_URL is not set — see the comment at the top of this file' }
+ }
+ let name: string
+ try {
+ name = new URL(raw).pathname.replace(/^\//, '')
+ } catch {
+ return { skip: 'NIMBUS_TEST_DATABASE_URL is not a URL' }
+ }
+ if (!/test/i.test(name)) {
+ return { skip: `refusing to run against database "${name}": its name must contain "test"` }
+ }
+ return { url: raw }
+}
+
+const target = testDatabase()
+
+/* ------------------------- the relay that never answers ------------------------- */
+
+/** Connections it has accepted. Nothing is ever written back on any of them. */
+let connections = 0
+const sockets: Socket[] = []
+const relay: Server = createServer((socket) => {
+ connections += 1
+ sockets.push(socket)
+ socket.on('error', () => {})
+})
+await new Promise((resolve) => relay.listen(0, '127.0.0.1', resolve))
+const relayPort = (relay.address() as AddressInfo).port
+
+/* env.ts is evaluated once, at import, off process.env — so the SMTP settings have to be in
+ * place before anything below is loaded, exactly as pay.test.ts does with PAY_API_URL. */
+let client!: typeof import('./db/client.js')
+let authRoutes!: (typeof import('./routes/auth.js'))['authRoutes']
+
+if ('url' in target) {
+ process.env.NIMBUS_DATABASE_URL = target.url
+ process.env.NIMBUS_KEY_SECRET ??= 'test-secret-that-is-long-enough-32b'
+ process.env.NIMBUS_PUBLIC_URL = 'https://account.forge.example'
+ process.env.NIMBUS_ISSUER = 'https://nimbus.forge.example'
+ process.env.SMTP_HOST = '127.0.0.1'
+ process.env.SMTP_PORT = String(relayPort)
+ process.env.SMTP_USER = 'u'
+ process.env.SMTP_PASS = 'p'
+ process.env.SMTP_FROM = 'CloudsForge '
+ client = await import('./db/client.js')
+ authRoutes = (await import('./routes/auth.js')).authRoutes
+ const { migrate } = await import('./db/migrate.js')
+ await migrate({ info() {}, error() {} } as unknown as FastifyBaseLogger)
+}
+
+const skip = 'skip' in target ? target.skip : false
+
+after(async () => {
+ // Cut the stalled send short rather than leaving the runner waiting out the mailer's
+ // six-second attempt budget after the last assertion has passed. The listener goes first:
+ // a destroyed socket is a connection reset, which the mailer retries once, and the retry
+ // has to find nothing listening or it stalls for another six seconds.
+ relay.close()
+ for (const socket of sockets) socket.destroy()
+ if ('url' in target) {
+ await client.sql`DELETE FROM users WHERE email LIKE 'timing-%@example.invalid'`
+ await client.sql.end()
+ }
+})
+
+test('the reply does not wait for the relay, so the clock says nothing about the account', { skip }, async () => {
+ const id = randomUUID()
+ const known = `timing-${id}@example.invalid`
+ await client.sql`
+ INSERT INTO users (id, email, handle, password_hash)
+ VALUES (${id}, ${known}, ${`timing-${id.slice(0, 8)}`}, 'not-a-hash')
+ `
+
+ const app = Fastify({ logger: false })
+ await app.register(authRoutes)
+ await app.ready()
+
+ const ask = async (email: string): Promise<{ status: number; ms: number; body: string }> => {
+ const started = performance.now()
+ const res = await app.inject({
+ method: 'POST',
+ url: '/auth/password/forgot',
+ payload: { email },
+ })
+ return { status: res.statusCode, ms: performance.now() - started, body: res.body }
+ }
+
+ const missing = await ask(`timing-${randomUUID()}@example.invalid`)
+ const present = await ask(known)
+ await app.close()
+
+ // The two answers a caller can see, unchanged and still identical.
+ assert.equal(missing.status, 202)
+ assert.equal(present.status, 202)
+ assert.equal(missing.body, present.body)
+
+ // The one it could read off a stopwatch. The mailer's budget is 6s and the pre-fix known
+ // branch spent all of it; half a second is far below anything a relay can cost and far
+ // above the jitter of two injected requests.
+ assert.ok(
+ present.ms < 500,
+ `the known-address branch took ${present.ms.toFixed(0)}ms — it is waiting for the relay`,
+ )
+ assert.ok(
+ Math.abs(present.ms - missing.ms) < 500,
+ `known ${present.ms.toFixed(0)}ms vs unknown ${missing.ms.toFixed(0)}ms — that gap is the oracle`,
+ )
+
+ // …and the assertion above only means something if a send was really attempted and really
+ // hung. The relay has the connection open and has said nothing on it.
+ const deadline = Date.now() + 3_000
+ while (connections === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25))
+ assert.ok(
+ connections > 0,
+ 'no SMTP connection was ever made, so this case did not measure a detached send',
+ )
+})
diff --git a/services/nimbus/src/index.ts b/services/nimbus/src/index.ts
index 8578e11..f4f4246 100644
--- a/services/nimbus/src/index.ts
+++ b/services/nimbus/src/index.ts
@@ -14,7 +14,7 @@ import { db } from './db/client.js'
import { users } from './db/schema.js'
import { migrate } from './db/migrate.js'
import { hashPassword } from './passwords.js'
-import { getSigningKey } from './keys.js'
+import { encryptStoredSigningKeys, getSigningKey } from './keys.js'
import { authRoutes } from './routes/auth.js'
import { portalRoutes } from './routes/portal.js'
import { adminRoutes } from './routes/admin.js'
@@ -75,6 +75,10 @@ async function main(): Promise {
installProcessHandlers(app)
await step(app.log, 'migrate', () => migrate(app.log))
+ // Empty the plaintext private_jwk column before anything can read it (CF-16).
+ // Before the signing-key step below, so the very first key this process loads
+ // is loaded through the envelope.
+ await step(app.log, 'encrypt-signing-keys', () => encryptStoredSigningKeys(app.log))
// Ensure the signing key exists / is cached before serving.
await step(app.log, 'signing-key', () => getSigningKey())
await step(app.log, 'seed-admin', () => seedAdmin(app.log))
@@ -136,9 +140,11 @@ async function main(): Promise {
// Admin-gated read proxy in front of ForgeKeyvault, which is never routed
// publicly. Separately encapsulated for the same reason adminRoutes is.
await app.register(vaultProxyRoutes)
- // Admin-gated proxy in front of Forge Pay's operator surface. Pay IS routed, but
- // its CORS preflight allows GET/HEAD/POST only, so the browser cannot PUT the
- // administered EMBER price. Encapsulated like the two above.
+ // Admin-gated proxy in front of Forge Pay's operator surface. Unlike keyvault, pay
+ // IS routed and the console could call it direct; these routes come through here so
+ // the price panel has one origin and one auth path (see routes/pay.ts, which states
+ // the whole case now that pay's preflight allows the PUT). Encapsulated like the two
+ // above.
await app.register(payProxyRoutes)
await step(app.log, 'listen', () => app.listen({ host: '0.0.0.0', port: env.port }))
diff --git a/services/nimbus/src/keyEnvelope.ts b/services/nimbus/src/keyEnvelope.ts
new file mode 100644
index 0000000..30207fc
--- /dev/null
+++ b/services/nimbus/src/keyEnvelope.ts
@@ -0,0 +1,83 @@
+import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
+import { env } from './env.js'
+
+/* ------------------------------------------------------------------ *
+ * The envelope the RS256 signing key is stored in.
+ *
+ * WHY THERE IS ONE (CF-16). `signing_keys.private_jwk` held the private key as
+ * plaintext JSONB. Every service in the estate connects to the same Postgres as
+ * the same role, and that key is the estate's universal forging credential:
+ * every service verifies `iss` plus `aud: 'cloudsforge'` and nothing else, so a
+ * self-minted `{sub, roles:['admin']}` is accepted by pay, game, crucible,
+ * forge-mint and forge-keyvault alike. Contrast forge-keyvault, which encrypts
+ * the custody keys nimbus's token unlocks and refuses to boot on a placeholder
+ * secret — the key above it had no equivalent.
+ *
+ * WHAT IT ACTUALLY BUYS, stated honestly, because the answer is narrower than
+ * "the JWK is now safe". An attacker who can run code in a container has .env,
+ * and .env has this secret; an attacker with write-capable SQL can insert their
+ * own admin `users` row and needs no key at all. What encryption at rest closes
+ * is the READ-ONLY vector: a stolen pg backup, a SELECT-only injection, a copied
+ * dump. Those turn a database read into silent, unrevocable, offline-usable
+ * impersonation across every product, and they are the vectors an env-held
+ * secret genuinely defeats, because the secret is not in the artifact.
+ *
+ * WHY IT IS THE SAME SHAPE AS KEYVAULT'S (`forge-keyvault/src/crypto.ts`):
+ * scrypt from an env secret, AES-256-GCM, `v:` + base64(iv||tag||ct). Two
+ * envelope formats in one estate is one format nobody can read in an incident.
+ * The version prefix is what makes NIMBUS_KEY_SECRET rotatable later: a v2
+ * derives differently while every v1 blob keeps decrypting under its own rule.
+ * ------------------------------------------------------------------ */
+
+const CURRENT_VERSION = 1
+
+/** `v:`. ':' is not in the base64 alphabet, so this cannot be ambiguous. */
+const VERSIONED = /^v([0-9]{1,3}):([A-Za-z0-9+/]+={0,2})$/
+
+/**
+ * Per-key data key: scrypt(NIMBUS_KEY_SECRET, salt) -> 32 bytes.
+ *
+ * The kid is in the salt so one derived key never unlocks another signing key —
+ * which matters the moment there is more than one, i.e. the moment rotation
+ * exists. The version is in the salt too, so a derived key does not survive a
+ * rotation of the secret itself.
+ */
+function deriveKey(version: number, kid: string): Buffer {
+ return scryptSync(env.keySecret, `nimbus:v${version}:${kid}`, 32)
+}
+
+/** AES-256-GCM the private JWK of `kid`. Returns `v:base64(iv||tag||ct)`. */
+export function encryptPrivateJwk(kid: string, jwk: Record): string {
+ const key = deriveKey(CURRENT_VERSION, kid)
+ const iv = randomBytes(12)
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
+ const ct = Buffer.concat([cipher.update(JSON.stringify(jwk), 'utf8'), cipher.final()])
+ const tag = cipher.getAuthTag()
+ return `v${CURRENT_VERSION}:${Buffer.concat([iv, tag, ct]).toString('base64')}`
+}
+
+export function decryptPrivateJwk(kid: string, blob: string): Record {
+ const match = VERSIONED.exec(blob.trim())
+ if (!match) {
+ // Not a shape this ever wrote. Say that rather than letting it fail as a GCM
+ // authentication error, which reads like the wrong secret and sends an
+ // operator rotating something that was never the problem.
+ throw new Error(`signing key ${kid} is not in a recognised envelope format`)
+ }
+ const version = Number(match[1])
+ if (version > CURRENT_VERSION) {
+ throw new Error(
+ `signing key ${kid} is envelope version ${version}; this build reads up to v${CURRENT_VERSION}`,
+ )
+ }
+ const buf = Buffer.from(match[2]!, 'base64')
+ const iv = buf.subarray(0, 12)
+ const tag = buf.subarray(12, 28)
+ const ct = buf.subarray(28)
+ const decipher = createDecipheriv('aes-256-gcm', deriveKey(version, kid), iv)
+ decipher.setAuthTag(tag)
+ return JSON.parse(Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8')) as Record<
+ string,
+ unknown
+ >
+}
diff --git a/services/nimbus/src/keys.test.ts b/services/nimbus/src/keys.test.ts
new file mode 100644
index 0000000..c5a14d3
--- /dev/null
+++ b/services/nimbus/src/keys.test.ts
@@ -0,0 +1,271 @@
+import assert from 'node:assert/strict'
+import { after, test } from 'node:test'
+import type { FastifyBaseLogger } from 'fastify'
+
+/**
+ * The signing key: encrypted at rest, and replaceable without a flag day (CF-16).
+ *
+ * THE DEFECT. `signing_keys.private_jwk` was the RS256 private key as plaintext JSONB. Every
+ * service in the estate connects to the same Postgres as the same role, and that key is the
+ * estate's universal forging credential — every service verifies `iss` plus
+ * `aud: 'cloudsforge'` and nothing else, so a self-minted `{sub, roles:['admin']}` is admin in
+ * forge-pay, the game, Crucible, ForgeMint and ForgeKeyvault alike. A stolen backup or a
+ * SELECT-only injection was therefore silent, offline, unrevocable impersonation everywhere.
+ * There was also no way to replace the key: `getJwks()` published exactly one, and
+ * `getSigningKey()` cached for the process lifetime, so a swap meant hand-editing a row, a
+ * restart, and a window in which every live token was rejected.
+ *
+ * WHY IT NEEDS A REAL DATABASE. Both halves are properties of what is IN the table — that the
+ * plaintext column ends up NULL, that exactly one row is active, that the JWKS is what a
+ * verifier would fetch — and the migration under test is DDL. A stub would assert that this
+ * file's own idea of a row matches itself.
+ *
+ * HOW TO RUN IT. Point NIMBUS_TEST_DATABASE_URL at a scratch database, as tokens.test.ts
+ * documents at length. Every case skips without one; CI provides one.
+ *
+ * WHY THE SUITE RUNS ONE FILE AT A TIME (`--test-concurrency=1`, package.json). The cases
+ * below empty `signing_keys` and then plant rows that are deliberately not usable keys — the
+ * plaintext-migration case writes `{kty:'RSA', d:…, n:…}`, which has no `e` and cannot be
+ * imported. Every other suite in this service signs an access token, and node --test runs
+ * FILES in parallel by default: pay.test.ts would intermittently pick up this file's fixture
+ * as the active key and die inside jose with "key.e must be of type string". One database, one
+ * `signing_keys` table, and no way for a suite to own a row it did not create — so the files
+ * take turns instead. Cases within a file already run in order.
+ */
+
+/** The scratch database, or the reason there is not one. */
+function testDatabase(): { url: string } | { skip: string } {
+ const raw = process.env.NIMBUS_TEST_DATABASE_URL?.trim()
+ if (!raw) {
+ return { skip: 'NIMBUS_TEST_DATABASE_URL is not set — see the comment at the top of this file' }
+ }
+ let name: string
+ try {
+ name = new URL(raw).pathname.replace(/^\//, '')
+ } catch {
+ return { skip: 'NIMBUS_TEST_DATABASE_URL is not a URL' }
+ }
+ if (!/test/i.test(name)) {
+ return { skip: `refusing to run against database "${name}": its name must contain "test"` }
+ }
+ return { url: raw }
+}
+
+const target = testDatabase()
+
+let keys!: typeof import('./keys.js')
+let envelope!: typeof import('./keyEnvelope.js')
+let client!: typeof import('./db/client.js')
+let tokens!: typeof import('./tokens.js')
+let adminRoutes!: typeof import('./routes/admin.js')['adminRoutes']
+let issuer!: string
+
+const quiet = { info() {}, warn() {}, error() {} } as unknown as FastifyBaseLogger
+
+if ('url' in target) {
+ process.env.NIMBUS_DATABASE_URL = target.url
+ // env.ts refuses to boot without one, which is itself asserted below in a child process.
+ process.env.NIMBUS_KEY_SECRET ??= 'test-secret-that-is-long-enough-32b'
+ client = await import('./db/client.js')
+ envelope = await import('./keyEnvelope.js')
+ keys = await import('./keys.js')
+ tokens = await import('./tokens.js')
+ adminRoutes = (await import('./routes/admin.js')).adminRoutes
+ issuer = (await import('./env.js')).env.issuer
+ const { migrate } = await import('./db/migrate.js')
+ await migrate(quiet)
+}
+
+const skip = 'skip' in target ? target.skip : false
+
+/** Every case starts from an empty table; the module cache does not, so clear both. */
+async function reset(): Promise {
+ await client.sql`DELETE FROM signing_keys`
+ // getSigningKey() memoises the active key for thirty seconds and these cases run in
+ // milliseconds, so without this every case after the first would sign with a row it had
+ // just deleted. It is the same call activateSigningKey makes for the same reason.
+ keys.forgetActiveSigningKey()
+}
+
+after(async () => {
+ if ('url' in target) {
+ await client.sql`DELETE FROM signing_keys`
+ await client.sql.end()
+ }
+})
+
+/* ---------------------------- encryption at rest ---------------------------- */
+
+test('a stored signing key is not readable from the database', { skip }, async () => {
+ await reset()
+ const key = await keys.getSigningKey()
+
+ const rows = await client.sql<{ private_jwk: unknown; private_jwk_enc: string | null }[]>`
+ SELECT private_jwk, private_jwk_enc FROM signing_keys WHERE kid = ${key.kid}
+ `
+ const row = rows[0]!
+ // THE ASSERTION THE TICKET IS ABOUT. Everything a reader of this database gets.
+ assert.equal(row.private_jwk, null, 'the plaintext column must never be written again')
+ assert.ok(row.private_jwk_enc, 'the private half must be stored, encrypted')
+ // An RSA private JWK is recognisable by its private exponent. If any of `d`, `p` or `q`
+ // is legible in the stored blob, the envelope is not doing anything.
+ const blob = row.private_jwk_enc!
+ assert.match(blob, /^v1:[A-Za-z0-9+/]+={0,2}$/, 'the envelope must be versioned base64')
+ for (const field of ['"d"', '"p"', '"q"', '"dp"', '"qi"']) {
+ assert.ok(!blob.includes(field), `the blob leaks ${field}`)
+ }
+})
+
+test('a key stored in plaintext by an older build is encrypted at boot', { skip }, async () => {
+ await reset()
+ const key = await keys.getSigningKey()
+
+ // Exactly the row the pre-fix build left: the private JWK legible in the JSONB column.
+ const legible = { kty: 'RSA', d: 'this-is-the-private-exponent', n: 'modulus' }
+ // Serialised with an explicit cast rather than a bound object: postgres-js has no type for
+ // an untyped parameter here and hands the object straight to Buffer.byteLength.
+ await client.sql`
+ UPDATE signing_keys
+ SET private_jwk = ${JSON.stringify(legible)}::jsonb, private_jwk_enc = NULL
+ WHERE kid = ${key.kid}
+ `
+ const before = await client.sql<{ n: number }[]>`
+ SELECT count(*)::int AS n FROM signing_keys WHERE private_jwk IS NOT NULL
+ `
+ assert.equal(before[0]!.n, 1, 'the pre-fix state is one readable key')
+
+ assert.equal(await keys.encryptStoredSigningKeys(quiet), 1)
+
+ const after = await client.sql<{ n: number }[]>`
+ SELECT count(*)::int AS n FROM signing_keys WHERE private_jwk IS NOT NULL
+ `
+ assert.equal(after[0]!.n, 0, 'the plaintext column must be empty after a boot')
+ // …and the same call again finds nothing to do, because a boot runs it every time.
+ assert.equal(await keys.encryptStoredSigningKeys(quiet), 0)
+
+ const rows = await client.sql<{ private_jwk_enc: string }[]>`
+ SELECT private_jwk_enc FROM signing_keys WHERE kid = ${key.kid}
+ `
+ assert.deepEqual(envelope.decryptPrivateJwk(key.kid, rows[0]!.private_jwk_enc), legible)
+})
+
+test('the envelope is bound to the kid, so one blob does not open another key', { skip }, async () => {
+ const jwk = { kty: 'RSA', d: 'secret' }
+ const blob = envelope.encryptPrivateJwk('aaaaaaaaaaaaaaaa', jwk)
+ assert.deepEqual(envelope.decryptPrivateJwk('aaaaaaaaaaaaaaaa', blob), jwk)
+ // The kid is in the scrypt salt. Reading it under a different kid is a GCM failure, not a
+ // different plaintext — which is what makes it safe to have more than one key at a time.
+ assert.throws(() => envelope.decryptPrivateJwk('bbbbbbbbbbbbbbbb', blob))
+})
+
+test('a blob that is not an envelope is refused by name, not by GCM', { skip }, async () => {
+ // The failure mode this avoids: a value that was never encrypted (a hand-edited row, a
+ // restored dump from before this build) fails as "unable to authenticate data", which
+ // reads like the wrong secret and sends an operator rotating something that was fine.
+ assert.throws(
+ () => envelope.decryptPrivateJwk('aaaaaaaaaaaaaaaa', '{"kty":"RSA"}'),
+ /not in a recognised envelope format/,
+ )
+})
+
+/* -------------------------------- rotation -------------------------------- */
+
+/** Backdate a key's status change, i.e. wait out the publish window without waiting. */
+async function agePublication(kid: string, byMs: number): Promise {
+ const at = new Date(Date.now() - byMs).toISOString()
+ await client.sql`
+ UPDATE signing_keys SET status_changed_at = ${at}::timestamptz WHERE kid = ${kid}
+ `
+}
+
+test('a new key is published before it signs, and refuses to be rushed', { skip }, async () => {
+ await reset()
+ const original = await keys.getSigningKey()
+
+ const minted = await keys.mintSigningKey()
+ assert.equal(minted.status, 'published')
+
+ // STEP ONE'S WHOLE POINT: it is in the document immediately, so every verifier can fetch
+ // it, and it has signed nothing.
+ const jwks = await keys.getJwks()
+ assert.deepEqual(
+ jwks.keys.map((k) => k.kid),
+ [original.kid, minted.kid],
+ 'the active key first, then the newcomer — a fixed order, so the document is comparable',
+ )
+ assert.equal((await keys.getSigningKey()).kid, original.kid, 'it must not sign yet')
+
+ // STEP TWO IS ENFORCED, not documented. Activating now would mint tokens under a kid no
+ // consumer has fetched, and every service in the estate 401s until its cache turns over.
+ const early = await keys.activateSigningKey(minted.kid)
+ assert.equal(early.status, 'too_soon')
+
+ await agePublication(minted.kid, keys.PUBLISH_BEFORE_ACTIVE_MS + 1_000)
+ const activated = await keys.activateSigningKey(minted.kid)
+ assert.equal(activated.status, 'ok')
+
+ // STEP THREE: the new key signs, and — the part that makes this not a flag day — the old
+ // one is still in the JWKS, so every token it minted in the last fifteen minutes still
+ // verifies.
+ assert.equal((await keys.getSigningKey()).kid, minted.kid)
+ const after = await keys.getJwks()
+ assert.deepEqual(
+ after.keys.map((k) => k.kid),
+ [minted.kid, original.kid],
+ )
+
+ // And the pre-fix behaviour, for contrast: publishing only the signing key meant that at
+ // this exact moment every outstanding token was unverifiable.
+ assert.equal(after.keys.length, 2, 'the shipped getJwks() returned one key here')
+})
+
+test('a retired key leaves the JWKS, and the active one cannot be retired', { skip }, async () => {
+ await reset()
+ const original = await keys.getSigningKey()
+ const minted = await keys.mintSigningKey()
+ await agePublication(minted.kid, keys.PUBLISH_BEFORE_ACTIVE_MS + 1_000)
+ assert.equal((await keys.activateSigningKey(minted.kid)).status, 'ok')
+
+ // Nothing could sign afterwards, so this must be refused rather than leaving the estate
+ // to discover it one login later.
+ assert.equal((await keys.retireSigningKey(minted.kid)).status, 'is_active')
+
+ const retired = await keys.retireSigningKey(original.kid)
+ assert.equal(retired.status, 'ok')
+ const jwks = await keys.getJwks()
+ assert.deepEqual(
+ jwks.keys.map((k) => k.kid),
+ [minted.kid],
+ )
+ // The row survives retirement: an old token in a log is still attributable to the key
+ // that made it.
+ const listed = await keys.listSigningKeys()
+ assert.equal(listed.find((k) => k.kid === original.kid)?.status, 'retired')
+})
+
+test('activate refuses a key it has never published, and one that does not exist', { skip }, async () => {
+ await reset()
+ const original = await keys.getSigningKey()
+ const minted = await keys.mintSigningKey()
+ await keys.retireSigningKey(minted.kid)
+
+ assert.equal((await keys.activateSigningKey(minted.kid)).status, 'not_published')
+ assert.equal((await keys.activateSigningKey(original.kid)).status, 'is_active')
+ assert.equal((await keys.activateSigningKey('ffffffffffffffff')).status, 'not_found')
+})
+
+test('nothing the management surface returns contains key material', { skip }, async () => {
+ await reset()
+ await keys.getSigningKey()
+ await keys.mintSigningKey()
+
+ const listed = JSON.stringify(await keys.listSigningKeys())
+ for (const field of ['"d"', '"p"', '"q"', 'private_jwk', 'privateJwk']) {
+ assert.ok(!listed.includes(field), `listSigningKeys() leaks ${field}`)
+ }
+ // The JWKS is public by design and must carry the PUBLIC halves and nothing more.
+ const jwks = JSON.stringify(await keys.getJwks())
+ for (const field of ['"d"', '"p"', '"q"', '"dp"', '"qi"']) {
+ assert.ok(!jwks.includes(field), `the JWKS leaks ${field}`)
+ }
+})
diff --git a/services/nimbus/src/keys.ts b/services/nimbus/src/keys.ts
index 2c1ca27..ae834da 100644
--- a/services/nimbus/src/keys.ts
+++ b/services/nimbus/src/keys.ts
@@ -1,7 +1,45 @@
import { randomBytes } from 'node:crypto'
+import { and, asc, eq, isNotNull, ne } from 'drizzle-orm'
+import type { FastifyBaseLogger } from 'fastify'
import { exportJWK, generateKeyPair, importJWK, type JWK, type KeyLike } from 'jose'
import { db } from './db/client.js'
-import { signingKeys } from './db/schema.js'
+import { signingKeys, type SigningKeyStatus } from './db/schema.js'
+import { decryptPrivateJwk, encryptPrivateJwk } from './keyEnvelope.js'
+
+/* ------------------------------------------------------------------ *
+ * The RS256 signing key: where it is kept, which one signs, and how one is
+ * replaced without signing anybody out (CF-16).
+ *
+ * AT REST it is AES-256-GCM under NIMBUS_KEY_SECRET — see ./keyEnvelope.ts for
+ * what that does and does not buy. Nothing in this file writes the private half
+ * anywhere else and nothing returns it: `SigningKey.privateKey` is a jose
+ * KeyLike, not bytes, and the management routes hand back a kid and a status.
+ *
+ * WHICH ONE SIGNS is the single row with status 'active'. Which ones VERIFY is
+ * every row that is not 'retired', which is what getJwks() publishes. Those are
+ * different questions and used to be the same one — getJwks() returned the
+ * signing key and nothing else — which is what made rotation impossible without
+ * a flag day: a swap invalidated every token minted under the old key at the
+ * instant it happened, and the new key started signing before a single consumer
+ * had seen it in the document.
+ *
+ * SO A ROTATION IS THREE STEPS, none of which drops a request:
+ *
+ * 1. POST /admin/signing-keys mints a key as 'published'. It is in the
+ * JWKS immediately and signs nothing.
+ * 2. wait one access-token TTL every consumer's JWKS cache now has it.
+ * activateSigningKey ENFORCES this rather
+ * than trusting the operator to have counted.
+ * 3. POST …/:kid/activate it starts signing; the old key becomes
+ * 'published', so the tokens it already
+ * minted keep verifying until they expire.
+ * …/:kid/retire drops it from the document
+ * once they have.
+ *
+ * THE CACHE IS TIME-BOUNDED, and that is what makes step 3 take effect at all.
+ * It used to be for the lifetime of the process, so activating a key required a
+ * restart of every instance — the same flag day under another name.
+ * ------------------------------------------------------------------ */
export interface SigningKey {
kid: string
@@ -11,28 +49,88 @@ export interface SigningKey {
publicJwk: JWK
}
-let cached: SigningKey | null = null
+/**
+ * How long the active key is reused before the table is consulted again.
+ *
+ * Every login and every refresh signs a token, so this is a hot path and the
+ * read has to be amortised. Thirty seconds is the whole cost of a rotation
+ * propagating across instances, and it is safe to be that lazy for one reason:
+ * the key being activated has already been in the JWKS for an access-token TTL,
+ * so a straggler still signing with the previous key for another half a minute
+ * mints tokens every consumer can still verify.
+ */
+const CACHE_TTL_MS = 30_000
/**
- * Load the RS256 signing key from the DB, or generate + persist one on first boot.
- * The private JWK and a stable kid are stored in `signing_keys` and reused across restarts.
+ * How long a key must have been published before it may be activated.
+ *
+ * One access-token TTL (15m — see issueAccessToken in tokens.ts) plus a margin
+ * for consumers that cache the JWKS a little longer than they should.
+ * Activating sooner mints tokens under a `kid` verifiers have not fetched yet,
+ * and the symptom is every service 401ing every request until its cache turns
+ * over: a self-inflicted outage that looks exactly like a key compromise.
*/
-export async function getSigningKey(): Promise {
- if (cached) return cached
-
- const rows = await db.select().from(signingKeys).limit(1)
- const existing = rows[0]
- if (existing) {
- const privateKey = await importJWK(existing.privateJwk as unknown as JWK, 'RS256')
- cached = {
- kid: existing.kid,
- privateKey,
- publicJwk: existing.publicJwk as unknown as JWK,
- }
- return cached
+export const PUBLISH_BEFORE_ACTIVE_MS = 20 * 60_000
+
+/**
+ * How stale a verifier cache may be before an unknown `kid` forces a re-read.
+ *
+ * The `kid` in a token header is attacker-chosen, so a miss must not be a free
+ * database query: without this floor, a stream of tokens carrying random kids is
+ * a stream of SELECTs. One second is far shorter than a rotation and far longer
+ * than a burst, so a key minted by another instance is verifiable within a
+ * second of it existing, and a flood costs one query per second.
+ */
+const VERIFIER_MISS_RELOAD_MS = 1_000
+
+let cached: { key: SigningKey; at: number } | null = null
+let verifiers: { keys: Map; at: number } | null = null
+
+/**
+ * Forget the memoised active key, so the next signature re-reads the table.
+ *
+ * Exported because activateSigningKey is not the only caller that needs it: a
+ * suite that empties `signing_keys` between cases would otherwise keep signing
+ * with a row that no longer exists. It is safe to call at any time — the cost is
+ * one SELECT — and it cannot be used to change WHICH key signs, only to notice
+ * sooner that it changed. The instances that did not call it notice within
+ * CACHE_TTL_MS.
+ *
+ * It drops the VERIFIER cache too. Those are two caches over one table and a
+ * caller who wants the table re-read wants both; leaving the verifiers behind
+ * would make a suite that empties `signing_keys` keep accepting tokens signed by
+ * a key that no longer exists.
+ */
+export function forgetActiveSigningKey(): void {
+ cached = null
+ verifiers = null
+}
+
+/** Row shape every reader here needs. Kept narrow so the private half is opt-in. */
+interface StoredKey {
+ kid: string
+ privateJwkEnc: string | null
+ publicJwk: Record | null
+}
+
+/** Decode a stored row into something that can sign. */
+async function toSigningKey(row: StoredKey): Promise {
+ if (!row.privateJwkEnc) {
+ // Only reachable if something wrote a row without the envelope, which this
+ // build cannot do. Fail loudly rather than falling back to the plaintext
+ // column — a fallback is how a plaintext column survives forever.
+ throw new Error(`signing key ${row.kid} has no encrypted private half`)
+ }
+ const jwk = decryptPrivateJwk(row.kid, row.privateJwkEnc) as unknown as JWK
+ return {
+ kid: row.kid,
+ privateKey: await importJWK(jwk, 'RS256'),
+ publicJwk: row.publicJwk as unknown as JWK,
}
+}
- // First boot: generate an extractable RS256 keypair and persist it.
+/** Generate a keypair and store it in `status`. Returns the new kid. */
+async function mint(status: SigningKeyStatus): Promise {
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true })
const kid = randomBytes(8).toString('hex')
const privateJwk = await exportJWK(privateKey)
@@ -41,21 +139,303 @@ export async function getSigningKey(): Promise {
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
- await db
- .insert(signingKeys)
- .values({
- kid,
- privateJwk: privateJwk as unknown as Record,
- publicJwk: publicJwk as unknown as Record,
- })
- .onConflictDoNothing()
+ await db.insert(signingKeys).values({
+ kid,
+ // Encrypted before it is handed to the driver, so the plaintext never
+ // reaches a query log, a connection trace or a statement-level slow log.
+ privateJwkEnc: encryptPrivateJwk(kid, privateJwk as unknown as Record),
+ publicJwk: publicJwk as unknown as Record,
+ status,
+ })
+ return kid
+}
+
+/**
+ * Re-encrypt any signing key still sitting in the plaintext column, and empty it.
+ *
+ * Runs once per boot, before anything is served. Idempotent — a database this
+ * has already run against has no rows to find — and deliberately NOT part of
+ * migrate.ts: the DDL there must not need a secret, and this needs the one
+ * env.ts refuses to boot without.
+ *
+ * The UPDATE writes the envelope and NULLs the plaintext in one statement, so
+ * there is no window where the row has both and no way to end with neither.
+ * What it cannot do is reach backwards: a dump taken before this ran still holds
+ * the key in the clear, which is why it says so at warn and names rotation as
+ * the only actual remedy.
+ */
+export async function encryptStoredSigningKeys(log: FastifyBaseLogger): Promise {
+ const rows = await db
+ .select({ kid: signingKeys.kid, privateJwk: signingKeys.privateJwk })
+ .from(signingKeys)
+ .where(isNotNull(signingKeys.privateJwk))
+
+ for (const row of rows) {
+ await db
+ .update(signingKeys)
+ .set({ privateJwkEnc: encryptPrivateJwk(row.kid, row.privateJwk!), privateJwk: null })
+ .where(eq(signingKeys.kid, row.kid))
+ }
+
+ if (rows.length > 0) {
+ log.warn(
+ {
+ audit: 'signing_key_encrypted_at_rest',
+ kids: rows.map((r) => r.kid),
+ fix: 'Every backup, dump and replica taken before this boot still contains the key in plaintext and this cannot reach them. Treat it as disclosed: mint a replacement (POST /admin/signing-keys), wait one access-token TTL, activate it, then retire this one.',
+ },
+ 'signing key was stored in plaintext and has been encrypted at rest',
+ )
+ }
+ return rows.length
+}
- cached = { kid, privateKey, publicJwk }
- return cached
+/**
+ * The key that signs. Generates and activates one against an empty table.
+ *
+ * Cached for CACHE_TTL_MS. On the first boot of a fresh database two instances
+ * can both find no active key; both insert, and the deterministic ordering below
+ * decides which of the two wins — the same one in every process, which is the
+ * property that matters. Both are published, so tokens minted under either
+ * verify everywhere regardless.
+ */
+export async function getSigningKey(): Promise {
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.key
+
+ const active = await db
+ .select()
+ .from(signingKeys)
+ .where(eq(signingKeys.status, 'active'))
+ // Oldest first, kid as the tie-break: a total order, so every instance picks
+ // the same row without coordinating, and the key that has been signing
+ // longest keeps signing rather than a boot race changing the answer.
+ .orderBy(asc(signingKeys.createdAt), asc(signingKeys.kid))
+ .limit(1)
+
+ const row = active[0]
+ if (row) {
+ const key = await toSigningKey(row)
+ cached = { key, at: Date.now() }
+ return key
+ }
+
+ // Empty table (first boot), or an operator retired everything. Either way this
+ // service cannot mint a token without a key, so make one and use it.
+ const kid = await mint('active')
+ const created = await db.select().from(signingKeys).where(eq(signingKeys.kid, kid)).limit(1)
+ const key = await toSigningKey(created[0]!)
+ cached = { key, at: Date.now() }
+ return key
}
-/** JWKS document exposing the public signing key(s). */
+/**
+ * JWKS document: every key that is not retired, in a deterministic order.
+ *
+ * NOT just the signing key, which is what it used to be. A verifier keyed by
+ * `kid` needs the public half of whatever minted the token in front of it, and
+ * during a rotation that is not the key currently signing — in one direction
+ * because the new key must be fetchable before it signs, and in the other
+ * because the old key's tokens outlive its last signature by up to their TTL.
+ *
+ * The order is fixed (active first, then oldest, then kid) so the document is
+ * byte-identical across instances and across calls. Consumers cache this, some
+ * of them by comparing what they fetched with what they held; a set that
+ * shuffles makes that comparison meaningless and the cache useless.
+ */
export async function getJwks(): Promise<{ keys: JWK[] }> {
- const key = await getSigningKey()
- return { keys: [key.publicJwk] }
+ const rows = await db
+ .select({
+ kid: signingKeys.kid,
+ publicJwk: signingKeys.publicJwk,
+ status: signingKeys.status,
+ createdAt: signingKeys.createdAt,
+ })
+ .from(signingKeys)
+ .where(ne(signingKeys.status, 'retired'))
+ .orderBy(asc(signingKeys.createdAt), asc(signingKeys.kid))
+
+ if (rows.length === 0) {
+ // No key has ever been generated, or every one was retired. Generating on
+ // demand keeps /.well-known/jwks.json consistent with what /auth/login would
+ // mint a millisecond later, rather than publishing an empty document that
+ // teaches every consumer to cache "this issuer has no keys".
+ const key = await getSigningKey()
+ return { keys: [key.publicJwk] }
+ }
+
+ const active = rows.filter((r) => r.status === 'active')
+ const rest = rows.filter((r) => r.status !== 'active')
+ return { keys: [...active, ...rest].map((r) => r.publicJwk as unknown as JWK) }
+}
+
+/**
+ * The public key a token signed BY THIS SERVICE should be verified against.
+ *
+ * Nimbus verifies its own tokens locally instead of fetching its own JWKS, and
+ * that shortcut used to mean "verify against whatever is signing right now".
+ * That is the one thing a rotation deliberately makes untrue: the moment a
+ * replacement key is activated, every token minted in the previous fifteen
+ * minutes was signed by a key that is still published, still in the document
+ * every other service verifies against — and would have been rejected here. The
+ * operator who clicked activate is holding one of those tokens, so the symptom
+ * of a correct rotation was the console 401ing at the admin who performed it,
+ * while pay, game and Crucible went on accepting the same token happily. Verify
+ * against the same SET this issuer publishes, and the three-step rotation above
+ * is true for nimbus as well.
+ *
+ * NOT a weakening: the set is exactly the non-retired keys, i.e. the ones this
+ * service minted and still stands behind. Retiring a key still ends it here, in
+ * the same breath as it leaves the JWKS.
+ */
+export async function getVerificationKey(kid: string | undefined): Promise {
+ if (!verifiers || Date.now() - verifiers.at >= CACHE_TTL_MS) {
+ verifiers = await loadVerifiers()
+ }
+ if (kid) {
+ const hit = verifiers.keys.get(kid)
+ if (hit) return hit
+ // A kid this process has not seen. Either another instance minted a key
+ // seconds ago, or the header is forged — and only the first deserves a
+ // query, so the re-read is floored (see VERIFIER_MISS_RELOAD_MS).
+ if (Date.now() - verifiers.at >= VERIFIER_MISS_RELOAD_MS) {
+ verifiers = await loadVerifiers()
+ const retry = verifiers.keys.get(kid)
+ if (retry) return retry
+ }
+ }
+ // No kid, or one nothing published. Hand back the active key so the answer is
+ // a signature failure — which is the caller's doing and a 401 — rather than an
+ // error, which would read as this service being unavailable.
+ return importJWK((await getSigningKey()).publicJwk as JWK, 'RS256')
+}
+
+async function loadVerifiers(): Promise<{ keys: Map; at: number }> {
+ const { keys } = await getJwks()
+ const map = new Map()
+ for (const jwk of keys) {
+ if (!jwk.kid) continue
+ map.set(jwk.kid, await importJWK(jwk, 'RS256'))
+ }
+ return { keys: map, at: Date.now() }
+}
+
+/* ------------------------------- rotation ------------------------------- */
+
+export interface SigningKeyRecord {
+ kid: string
+ status: SigningKeyStatus
+ createdAt: string
+ statusChangedAt: string
+ /** True while this key is in /.well-known/jwks.json. */
+ published: boolean
+ /** When it may be activated. Null unless it is waiting out the publish window. */
+ activatableAt: string | null
+}
+
+/** Every signing key, public metadata only. The private half is not selected. */
+export async function listSigningKeys(): Promise {
+ const rows = await db
+ .select({
+ kid: signingKeys.kid,
+ status: signingKeys.status,
+ createdAt: signingKeys.createdAt,
+ statusChangedAt: signingKeys.statusChangedAt,
+ })
+ .from(signingKeys)
+ .orderBy(asc(signingKeys.createdAt), asc(signingKeys.kid))
+
+ return rows.map((r) => ({
+ kid: r.kid,
+ status: r.status,
+ createdAt: r.createdAt.toISOString(),
+ statusChangedAt: r.statusChangedAt.toISOString(),
+ published: r.status !== 'retired',
+ activatableAt:
+ r.status === 'published'
+ ? new Date(r.statusChangedAt.getTime() + PUBLISH_BEFORE_ACTIVE_MS).toISOString()
+ : null,
+ }))
+}
+
+/** Mint a replacement key. Published immediately; signs nothing yet. */
+export async function mintSigningKey(): Promise {
+ const kid = await mint('published')
+ const all = await listSigningKeys()
+ return all.find((r) => r.kid === kid)!
+}
+
+/** Outcomes shared by both transitions. Each one is a distinct 4xx at the route. */
+export type RetireKeyResult =
+ | { status: 'ok'; keys: SigningKeyRecord[] }
+ | { status: 'not_found' }
+ | { status: 'is_active' }
+
+export type ActivateKeyResult =
+ | RetireKeyResult
+ | { status: 'too_soon'; activatableAt: string }
+ | { status: 'not_published' }
+
+/**
+ * Make a published key the signing key. The current signer becomes 'published'.
+ *
+ * REFUSES a key that has not been published for PUBLISH_BEFORE_ACTIVE_MS, and
+ * that refusal is the point of the design rather than an inconvenience to route
+ * around: activating early mints tokens under a `kid` no consumer has fetched,
+ * and every service in the estate then rejects every request until its JWKS
+ * cache turns over. The operator rotating a key BECAUSE it leaked is exactly the
+ * operator most likely to skip the wait, so the wait is enforced here instead of
+ * written in a runbook. The way to end a leaked key sooner is to activate the
+ * replacement and then retire the old one, which costs only the tokens it has
+ * already minted — at most fifteen minutes of them.
+ */
+export async function activateSigningKey(kid: string): Promise {
+ const rows = await db.select().from(signingKeys).where(eq(signingKeys.kid, kid)).limit(1)
+ const row = rows[0]
+ if (!row) return { status: 'not_found' }
+ if (row.status === 'active') return { status: 'is_active' }
+ if (row.status !== 'published') return { status: 'not_published' }
+
+ const readyAt = row.statusChangedAt.getTime() + PUBLISH_BEFORE_ACTIVE_MS
+ if (Date.now() < readyAt) {
+ return { status: 'too_soon', activatableAt: new Date(readyAt).toISOString() }
+ }
+
+ const now = new Date()
+ // Demote first. If the second statement fails, the estate has no active key
+ // for an instant and getSigningKey() mints one — recoverable, and every token
+ // already out there still verifies. The other order leaves two active keys,
+ // and then which one signs depends on which instance you ask.
+ await db
+ .update(signingKeys)
+ .set({ status: 'published', statusChangedAt: now })
+ .where(and(eq(signingKeys.status, 'active'), ne(signingKeys.kid, kid)))
+ await db
+ .update(signingKeys)
+ .set({ status: 'active', statusChangedAt: now })
+ .where(eq(signingKeys.kid, kid))
+
+ // This process is the one that changed it; the others notice within
+ // CACHE_TTL_MS.
+ forgetActiveSigningKey()
+ return { status: 'ok', keys: await listSigningKeys() }
+}
+
+/** Drop a key from the JWKS. Refuses the active one — nothing else could sign. */
+export async function retireSigningKey(kid: string): Promise {
+ const rows = await db.select().from(signingKeys).where(eq(signingKeys.kid, kid)).limit(1)
+ const row = rows[0]
+ if (!row) return { status: 'not_found' }
+ if (row.status === 'active') return { status: 'is_active' }
+
+ await db
+ .update(signingKeys)
+ .set({ status: 'retired', statusChangedAt: new Date() })
+ .where(eq(signingKeys.kid, kid))
+
+ // Retiring is the operator saying "stop honouring this key", usually because
+ // it leaked, so this process must stop honouring it now rather than at the end
+ // of a cache window it cannot see. The other instances follow within
+ // CACHE_TTL_MS, the same bound every other change to this table has.
+ forgetActiveSigningKey()
+ return { status: 'ok', keys: await listSigningKeys() }
}
diff --git a/services/nimbus/src/mailer.ts b/services/nimbus/src/mailer.ts
new file mode 100644
index 0000000..20b5075
--- /dev/null
+++ b/services/nimbus/src/mailer.ts
@@ -0,0 +1,150 @@
+import { createTransport, type Transporter } from 'nodemailer'
+import { env } from './env.js'
+
+/* ------------------------------------------------------------------ *
+ * Outbound mail — one generic SMTP transport, configured entirely from
+ * the environment (see the SMTP block in ./env.ts).
+ *
+ * There is deliberately no provider in this file. Brevo, Resend, SendGrid,
+ * Mailtrap, a Gmail app password and a self-hosted relay are the same four
+ * settings and the same protocol; anything that named one of them here would
+ * make changing provider a code change, which is exactly the thing the owner
+ * asked not to happen. If a provider ever needs something SMTP cannot express,
+ * that is the moment to reconsider — not before.
+ *
+ * Nimbus sends exactly one kind of message today (a password-reset link), from
+ * a request path that must answer in bounded time, so the shape here is: reuse
+ * one transport, bound every attempt with a wall clock, retry at most once, and
+ * let the caller decide what a failure means. Nothing in here logs; the caller
+ * has the request-scoped logger and, more importantly, knows which of its own
+ * values are secret.
+ * ------------------------------------------------------------------ */
+
+/**
+ * How long one send may take, end to end.
+ *
+ * Every SMTP phase gets the same budget and the whole attempt is raced against
+ * it as well, because nodemailer's per-phase timeouts do not cover DNS: an
+ * unresolvable SMTP_HOST hangs in getaddrinfo, before the connection timeout is
+ * armed, and would otherwise hold the HTTP request open for the resolver's own
+ * timeout. Bare `sendMail` has no such bound at all.
+ *
+ * This is the worst case for POST /auth/password/forgot too, because the one
+ * retry below never applies to a timeout — see MAX_ATTEMPTS.
+ */
+const ATTEMPT_TIMEOUT_MS = 6_000
+
+/**
+ * One retry, and only one: a reset link is not worth a queue.
+ *
+ * It does not apply to a timeout. A relay that accepted the connection and then
+ * said nothing for six seconds is not going to answer a second six-second wait,
+ * and the user is sitting in front of the request while it happens — a hung
+ * relay must cost 6s, not 12s. The retry is for the failures that a second
+ * attempt genuinely fixes: a refused or reset connection, a transient 4xx.
+ */
+const MAX_ATTEMPTS = 2
+
+/** Distinguishable so the retry can decline to wait a second time. */
+class SmtpTimeoutError extends Error {
+ constructor(ms: number) {
+ super(`smtp send timed out after ${ms}ms`)
+ this.name = 'SmtpTimeoutError'
+ }
+}
+
+/**
+ * Built on first use and then reused.
+ *
+ * Not pooled. A pool holds a socket open to the relay between sends, and this
+ * service sends minutes or days apart — the connection is always dead by the
+ * next one, and a stale pooled socket fails in a way that looks like the relay
+ * is down. Non-pooled costs a TCP+TLS handshake per message, which for a
+ * password reset is not a cost anyone can measure.
+ */
+let transport: Transporter | null = null
+
+function getTransport(): Transporter | null {
+ const cfg = env.smtp
+ if (!cfg) return null
+ if (!transport) {
+ transport = createTransport({
+ host: cfg.host,
+ port: cfg.port,
+ // false means "connect in the clear, then STARTTLS", which nodemailer does
+ // by default and requires when it is offered. 465 is implicit TLS.
+ secure: cfg.secure,
+ auth: { user: cfg.user, pass: cfg.pass },
+ connectionTimeout: ATTEMPT_TIMEOUT_MS,
+ greetingTimeout: ATTEMPT_TIMEOUT_MS,
+ socketTimeout: ATTEMPT_TIMEOUT_MS,
+ })
+ }
+ return transport
+}
+
+/** True when mail can be sent at all, so callers can choose another route. */
+export function mailerConfigured(): boolean {
+ return env.smtp !== null
+}
+
+export interface OutboundMail {
+ to: string
+ subject: string
+ /** Always send both parts. A text-only mail lands in spam; an HTML-only one is unreadable in a client that refuses HTML. */
+ text: string
+ html: string
+}
+
+function withTimeout(work: Promise, ms: number): Promise {
+ let timer: NodeJS.Timeout
+ return Promise.race([
+ work,
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new SmtpTimeoutError(ms)), ms)
+ }),
+ ]).finally(() => clearTimeout(timer)) as Promise
+}
+
+/**
+ * Send one message. Throws on failure — including when SMTP is not configured,
+ * which callers are expected to test for with `mailerConfigured()` first rather
+ * than discover here.
+ *
+ * The thrown error is whatever SMTP said, unmodified. Callers that hold a secret
+ * in the message body MUST NOT log it as-is: an SMTP server is free to quote the
+ * message it rejected back at you.
+ */
+export async function sendMail(mail: OutboundMail): Promise {
+ const cfg = env.smtp
+ const tx = getTransport()
+ if (!cfg || !tx) throw new Error('SMTP is not configured')
+
+ let lastErr: unknown
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ await withTimeout(
+ tx.sendMail({
+ from: cfg.from,
+ to: mail.to,
+ ...(cfg.replyTo ? { replyTo: cfg.replyTo } : {}),
+ subject: mail.subject,
+ text: mail.text,
+ html: mail.html,
+ }),
+ ATTEMPT_TIMEOUT_MS,
+ )
+ return
+ } catch (err) {
+ lastErr = err
+ // Do not spend the caller's time twice on a relay that is not answering.
+ if (err instanceof SmtpTimeoutError) break
+ // A permanent rejection (5xx: bad credentials, sender not authorised,
+ // recipient refused) will be rejected identically a second time, and the
+ // second attempt costs the caller another round trip for nothing.
+ const code = (err as { responseCode?: number }).responseCode
+ if (typeof code === 'number' && code >= 500 && code < 600) break
+ }
+ }
+ throw lastErr
+}
diff --git a/services/nimbus/src/obs.test.ts b/services/nimbus/src/obs.test.ts
new file mode 100644
index 0000000..d3d4247
--- /dev/null
+++ b/services/nimbus/src/obs.test.ts
@@ -0,0 +1,124 @@
+import assert from 'node:assert/strict'
+import { Writable } from 'node:stream'
+import { test } from 'node:test'
+import Fastify, { type FastifyRequest } from 'fastify'
+import { loggerOptions, safeRequestUrl } from './obs.js'
+
+/**
+ * What the request logger is allowed to write down.
+ *
+ * WHY THIS IS AN END-TO-END TEST AND NOT ONLY A UNIT ONE. The defect was never in a function
+ * — it was that Fastify emits an "incoming request" line for every request, before any
+ * handler runs, through a serializer that copied `req.url` verbatim. No route could have
+ * prevented it and no reviewer of `routes/portal.ts` would have seen it, because the leak is
+ * a property of the logger and the URL shape together. So half of this file boots a real
+ * Fastify with the real `loggerOptions`, points pino at a buffer, and reads what actually
+ * came out.
+ *
+ * WHAT `shipped` IS. The serializer as it stood before this change, run through the same
+ * harness. A test that only asserts the fixed behaviour proves the harness cannot see the
+ * bug; this one shows it catching the version that had it, and then not catching the version
+ * that does not.
+ *
+ * THE CREDENTIAL IN QUESTION. A password-reset token: 32 random bytes as hex, the whole
+ * credential for taking over an account, valid for thirty minutes and stored on our side only
+ * as its SHA-256 precisely so that nobody with database access can recover an issued link.
+ * Writing it to stdout on the request that spends it hands it to Lantern, which persists it
+ * in an indexed column and in every backup.
+ */
+
+const TOKEN = 'fb957465df336cc7e6e27fa9fdfd94897e00d1c64841233a3c6b0f9fa2040b24'
+
+/** The `req` serializer as it was before this change. */
+const shipped = {
+ req(req: FastifyRequest) {
+ return { method: req.method, url: req.url, route: req.routeOptions?.url }
+ },
+}
+
+/**
+ * Serve one request through a real Fastify with a real pino, and return every line it wrote.
+ * `url` is the raw request target, exactly as it arrives on the socket.
+ */
+async function logLinesFor(
+ url: string,
+ serializers?: { req(req: FastifyRequest): Record },
+): Promise {
+ let out = ''
+ const stream = new Writable({
+ write(chunk, _enc, done) {
+ out += String(chunk)
+ done()
+ },
+ })
+ const options = loggerOptions('nimbus')
+ const app = Fastify({
+ logger: { ...options, serializers: { ...options.serializers, ...serializers }, stream },
+ })
+ // The real page is rendered by routes/portal.ts; all this stands in for is a route at the
+ // same path, because what is on trial is the line Fastify writes before reaching it.
+ app.get('/reset', async () => 'ok')
+ await app.inject({ method: 'GET', url })
+ await app.close()
+ return out
+}
+
+/* ------------------------------ the leak ------------------------------ */
+
+test('the serializer that shipped wrote the reset token to the log stream', async () => {
+ const lines = await logLinesFor(`/reset?token=${TOKEN}`, shipped)
+ // This is CF-17 verbatim: `{"req":{"method":"GET","url":"/reset?token=9f2c…"}}` at info, on
+ // the request that spends the token, from a process whose stdout Lantern stores.
+ assert.ok(lines.includes(`"url":"/reset?token=${TOKEN}"`))
+})
+
+test('the serializer in use does not, for the same request', async () => {
+ const lines = await logLinesFor(`/reset?token=${TOKEN}`)
+ assert.ok(!lines.includes(TOKEN))
+ // The name survives, so the line still says a token was presented — which is the part with
+ // diagnostic value and no secrecy.
+ assert.ok(lines.includes('"url":"/reset?token=[redacted]"'))
+ assert.ok(lines.includes('"route":"/reset"'))
+})
+
+test('a link that carries its token in the fragment never puts it on the wire at all', async () => {
+ // What a browser sends for https://account.example/reset#token=… — the fragment is not in
+ // the request line, so this is the whole of it.
+ const lines = await logLinesFor('/reset')
+ assert.ok(!lines.includes(TOKEN))
+ assert.ok(lines.includes('"url":"/reset"'))
+})
+
+test('a hand-written request target that smuggles a fragment is still stripped', async () => {
+ // No browser sends this. A curl -g against a raw path can, and `req.url` is whatever came
+ // down the socket, so the serializer must not assume the '?' branch is the only one.
+ const lines = await logLinesFor(`/reset#token=${TOKEN}`)
+ assert.ok(!lines.includes(TOKEN))
+})
+
+/* --------------------------- the serializer --------------------------- */
+
+test('safeRequestUrl keeps every parameter name and no parameter value', () => {
+ assert.equal(safeRequestUrl('/account'), '/account')
+ assert.equal(safeRequestUrl('/reset?token=abc'), '/reset?token=[redacted]')
+ assert.equal(
+ safeRequestUrl('/login?return=https%3A%2F%2Fpay.example%2F&hint=x'),
+ '/login?return=[redacted]&hint=[redacted]',
+ )
+ // A bare '?' and a bare name are both legal request targets.
+ assert.equal(safeRequestUrl('/health?'), '/health')
+ assert.equal(safeRequestUrl('/health?verbose'), '/health?verbose=[redacted]')
+})
+
+test('safeRequestUrl names a repeated parameter once and caps how many it names', () => {
+ assert.equal(safeRequestUrl('/x?a=1&a=2&b=3'), '/x?a=[redacted]&b=[redacted]')
+ const many = safeRequestUrl(`/x?${Array.from({ length: 30 }, (_, i) => `p${i}=v`).join('&')}`)
+ assert.equal(many.split('&').length, 13)
+ assert.ok(many.endsWith('&…'))
+})
+
+test('safeRequestUrl cannot be made to forge parameters in the log line', () => {
+ // The name is percent-decoded by URLSearchParams and must be re-encoded, or a caller
+ // chooses what the log line appears to say.
+ assert.equal(safeRequestUrl('/x?a%26b%3Dc=1'), '/x?a%26b%3Dc=[redacted]')
+})
diff --git a/services/nimbus/src/obs.ts b/services/nimbus/src/obs.ts
index 47f16a1..a0743cb 100644
--- a/services/nimbus/src/obs.ts
+++ b/services/nimbus/src/obs.ts
@@ -96,6 +96,56 @@ function scrub(text: string): string {
return out
}
+/** How many parameter names one logged URL is allowed to name. */
+const MAX_LOGGED_PARAMS = 12
+
+/**
+ * A request target safe to write down: the path, and the NAMES of its query
+ * parameters, never their values.
+ *
+ * `redact` cannot help here — it matches object paths, and the whole query
+ * string arrives as one opaque string in `req.url`. So the only place this can
+ * be decided is right here, and it is decided fail-closed: every value goes,
+ * including ones nobody has thought of yet. The alternative shape, an allowlist
+ * of "sensitive" names, is a list somebody has to remember to extend on the day
+ * they add a credential to a URL, which is the day they are thinking about
+ * something else.
+ *
+ * This is not hypothetical. Nimbus's password-reset link was `GET
+ * /reset?token=…`, so Fastify's own "incoming request" line wrote a live
+ * account-takeover credential to stdout, from where Lantern lifted it into an
+ * indexed column and every backup — on the one request that spends it, and in
+ * the one service whose schema goes to the trouble of storing only the token's
+ * SHA-256 so that not even an operator with database access can recover it.
+ *
+ * The names are kept because they are the part with diagnostic value and no
+ * secrecy: `?page=[redacted]&sort=[redacted]` still tells you which variant of
+ * a route was hit, and `?token=[redacted]` still tells you the caller brought
+ * one. `route` (the Fastify route pattern) is logged alongside and is
+ * unaffected.
+ */
+export function safeRequestUrl(url: string): string {
+ // A '#' cannot appear in a well-formed request target — a browser never sends
+ // the fragment — but `req.url` is whatever came down the socket, and the
+ // reset link now carries its token after exactly that character. Cut on
+ // whichever delimiter comes first so a hand-written request cannot smuggle a
+ // secret past the query-string branch below.
+ const cut = url.search(/[?#]/)
+ if (cut === -1) return url
+ const path = url.slice(0, cut)
+ if (url[cut] === '#') return `${path}#[redacted]`
+
+ // URLSearchParams both splits and percent-decodes; re-encode so a name
+ // carrying '&' or '=' cannot forge extra parameters in the log line.
+ const names = [...new Set(new URLSearchParams(url.slice(cut + 1)).keys())]
+ if (names.length === 0) return path
+ const shown = names
+ .slice(0, MAX_LOGGED_PARAMS)
+ .map((name) => `${encodeURIComponent(name).slice(0, 64)}=[redacted]`)
+ if (names.length > MAX_LOGGED_PARAMS) shown.push('…')
+ return `${path}?${shown.join('&')}`
+}
+
/**
* Pino config for a service. `LOG_LEVEL` is the point of most of it: the moment
* you need detail is never the moment you want to be editing source and
@@ -116,7 +166,10 @@ export function loggerOptions(service: string) {
req(req: FastifyRequest) {
return {
method: req.method,
- url: req.url,
+ // Never `req.url` raw: see safeRequestUrl. Fastify logs this line for
+ // every request before the route runs, so anything in the query
+ // string is written down whether the handler wanted it or not.
+ url: safeRequestUrl(req.url),
route: req.routeOptions?.url,
remoteAddress: req.ip,
userAgent: req.headers['user-agent'],
diff --git a/services/nimbus/src/passwordReset.test.ts b/services/nimbus/src/passwordReset.test.ts
new file mode 100644
index 0000000..fe1f946
--- /dev/null
+++ b/services/nimbus/src/passwordReset.test.ts
@@ -0,0 +1,246 @@
+import assert from 'node:assert/strict'
+import { execFile } from 'node:child_process'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { test } from 'node:test'
+import type { FastifyBaseLogger, FastifyRequest } from 'fastify'
+
+/**
+ * Where a password-reset link points, and what a failed send is allowed to write down.
+ *
+ * THE DEFECT. `resetUrlFor` built the link as `${req.protocol}://${req.host}` — the same
+ * derivation routes/portal.ts uses for its own redirects, where it is correct, because those
+ * go straight back to the browser that sent the host. A reset link is not that: it is minted
+ * for one person and read by another, out of an email this deployment's own relay signed and
+ * sent. While nothing delivered it, that was latent. Wiring SMTP made it an unauthenticated
+ * account takeover:
+ *
+ * curl -H 'Host: evil.attacker.test' -X POST .../auth/password/forgot \
+ * -d '{"email":"victim@example.com"}'
+ *
+ * returned 202 and the relay delivered a genuine, correctly branded CloudsForge email whose
+ * button read http://evil.attacker.test/reset#token=. `X-Forwarded-Host` did the
+ * same from the far side of the tunnel — cloudflared is a private-range peer and TRUST_PROXY
+ * believes it. The '#' does not help: the attacker's own page reads location.hash.
+ *
+ * WHAT IS ASSERTED. That the host in the link comes from configuration and cannot be moved by
+ * anything on the request, and that a delivery failure still produces the one scrubbed audit
+ * line an operator greps for — never a stack trace, and never the token.
+ *
+ * NO DATABASE. passwordReset.ts imports db/client.ts, but postgres() does not dial until a
+ * query runs and neither function under test issues one, so NIMBUS_DATABASE_URL below only has
+ * to be a syntactically valid URL. The env-derivation cases boot env.ts in a child process for
+ * the reason env.test.ts gives: it is evaluated once, at import, off process.env.
+ */
+
+const execFileAsync = promisify(execFile)
+const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+
+/** 32 random bytes as hex, the shape createPasswordResetToken issues. */
+const TOKEN = '1b0a9a8ab40a4f0e9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7'
+
+const PUBLIC_URL = 'https://account.forge.example'
+
+process.env.NIMBUS_DATABASE_URL ??= 'postgres://u:p@localhost:5432/nimbus'
+// env.ts refuses to boot without one (CF-16), and importing the module below boots it.
+process.env.NIMBUS_KEY_SECRET ??= 'a-test-secret-that-is-long-enough'
+process.env.NIMBUS_PUBLIC_URL = PUBLIC_URL
+process.env.NIMBUS_ISSUER = 'https://nimbus.forge.example'
+// Loopback with nothing listening: getTransport() builds a transport, every send is refused
+// at connect, and the failure path runs in milliseconds without touching the network.
+process.env.SMTP_HOST = '127.0.0.1'
+process.env.SMTP_PORT = '1'
+process.env.SMTP_USER = 'u'
+process.env.SMTP_PASS = 'p'
+process.env.SMTP_FROM = 'CloudsForge '
+
+const { deliverPasswordReset, resetUrlFor } = await import('./passwordReset.js')
+
+/* ------------------------------- helpers ------------------------------- */
+
+interface Line {
+ obj: Record
+ msg: string
+}
+
+/** Just enough logger to record what was written, in the shape pino takes it. */
+function recorder(): { log: FastifyBaseLogger; lines: Line[] } {
+ const lines: Line[] = []
+ const write = (obj: unknown, msg?: string) =>
+ lines.push({ obj: (obj ?? {}) as Record, msg: msg ?? '' })
+ const log = { info: write, warn: write, error: write, debug: write, fatal: write, trace: write }
+ return { log: log as unknown as FastifyBaseLogger, lines }
+}
+
+/**
+ * A request as Fastify presents one. `host` is already the resolved value: Fastify has
+ * applied X-Forwarded-Host by then when the peer is trusted, which is exactly why a forged
+ * header and a forged Host line are the same test here.
+ */
+function request(host: string, protocol: 'http' | 'https' = 'http'): {
+ req: FastifyRequest
+ lines: Line[]
+} {
+ const { log, lines } = recorder()
+ return { req: { host, protocol, log } as unknown as FastifyRequest, lines }
+}
+
+/* --------------------------- where the link points --------------------------- */
+
+test('a forged Host cannot move the reset link', () => {
+ const { req, lines } = request('evil.attacker.test')
+ const url = resetUrlFor(req, TOKEN)
+
+ assert.equal(url, `${PUBLIC_URL}/reset#token=${TOKEN}`)
+ // Said again as the harm rather than as the value, so that a future change which merely
+ // relocates the interpolation cannot pass this.
+ assert.equal(new URL(url).origin, PUBLIC_URL)
+ assert.ok(!url.includes('attacker'), 'the attacker chose where a live token is delivered')
+
+ // The one thing the request DOES get: a line naming the host it asked for. It is the only
+ // warning anybody receives that this is being probed.
+ const warned = lines.filter((l) => l.obj.audit === 'password_reset_host_ignored')
+ assert.equal(warned.length, 1)
+ assert.equal(warned[0]!.obj.addressedAs, 'http://evil.attacker.test')
+ assert.equal(warned[0]!.obj.minted, PUBLIC_URL)
+ // …and it must not contain the credential it is warning about.
+ assert.ok(!JSON.stringify(warned[0]).includes(TOKEN))
+})
+
+test('an X-Forwarded-Proto upgrade cannot move it either', () => {
+ // Fastify resolves X-Forwarded-Proto into req.protocol for a trusted peer, so an https
+ // scheme with an attacker host is the second half of the same probe.
+ const { req } = request('xfh.attacker.test', 'https')
+ assert.equal(resetUrlFor(req, TOKEN), `${PUBLIC_URL}/reset#token=${TOKEN}`)
+})
+
+test('the shipped derivation is what this catches', () => {
+ // `${req.protocol}://${req.host}` — the line as it stood. Kept here so the case above is
+ // demonstrably able to see the bug rather than only to agree with the fix.
+ const shipped = (host: string, protocol: string) => `${protocol}://${host}/reset#token=${TOKEN}`
+ assert.equal(shipped('evil.attacker.test', 'http'), `http://evil.attacker.test/reset#token=${TOKEN}`)
+ assert.notEqual(shipped('evil.attacker.test', 'http'), resetUrlFor(request('evil.attacker.test').req, TOKEN))
+})
+
+test('a request on a configured host is minted without a warning', () => {
+ for (const host of ['account.forge.example', 'nimbus.forge.example']) {
+ // Both are legitimate ways to reach nimbus (the portal hostname and the issuer), so
+ // neither may cry wolf — a warning that fires on the normal path is a warning nobody
+ // reads on the abnormal one.
+ const { req, lines } = request(host, 'https')
+ assert.equal(resetUrlFor(req, TOKEN), `${PUBLIC_URL}/reset#token=${TOKEN}`)
+ assert.deepEqual(lines, [], `${host} is a real nimbus host and must not warn`)
+ }
+})
+
+test('the token is in the fragment, so it is not in any request line', () => {
+ const url = new URL(resetUrlFor(request('account.forge.example', 'https').req, TOKEN))
+ assert.equal(url.pathname, '/reset')
+ assert.equal(url.search, '')
+ assert.equal(new URLSearchParams(url.hash.slice(1)).get('token'), TOKEN)
+})
+
+/* ------------------------ what a failed send writes down ------------------------ */
+
+test('a delivery failure is reported, not thrown, and carries no token', async () => {
+ const { log, lines } = recorder()
+ const url = `${PUBLIC_URL}/reset#token=${TOKEN}`
+
+ const result = await deliverPasswordReset(log, { userId: 'u1', email: 'someone@example.com' }, url)
+
+ assert.deepEqual(result, { delivered: false, channel: 'none' })
+ const failed = lines.filter((l) => l.obj.audit === 'password_reset_delivery_failed')
+ assert.equal(failed.length, 1, 'the one line an operator greps for when mail stops working')
+ assert.ok(!JSON.stringify(lines).includes(TOKEN))
+})
+
+test('a resetUrl the WHATWG URL parser rejects does not throw out of the delivery path', async () => {
+ // `new URL()` used to run over the resetUrl on this path, and the resetUrl used to come
+ // from the Host header — which Node's HTTP parser accepts in shapes the URL parser refuses
+ // (`Host: a^b`). The TypeError escaped into the route's catch, so the scrubbed audit line
+ // above was replaced by a stack trace: mail stopped working and the line saying so was the
+ // line that went missing. The link cannot take that shape any more; the contract that
+ // nothing in here throws is asserted anyway, because it is the contract.
+ const { log, lines } = recorder()
+ const url = `http://a^b/reset#token=${TOKEN}`
+
+ const result = await deliverPasswordReset(log, { userId: 'u1', email: 'someone@example.com' }, url)
+
+ assert.deepEqual(result, { delivered: false, channel: 'none' })
+ assert.equal(lines.filter((l) => l.obj.audit === 'password_reset_delivery_failed').length, 1)
+ assert.ok(!JSON.stringify(lines).includes(TOKEN))
+})
+
+/* ---------------------------- where the origin comes from ---------------------------- */
+
+/** Boot env.ts under `vars` and report what it made of NIMBUS_PUBLIC_URL. */
+async function bootPublicUrl(
+ vars: Record,
+): Promise<{ publicUrl: string; aliases: string[]; bootLines: { level: number; msg: string }[] }> {
+ const MARK = '__PUBLIC_URL__'
+ const source = `
+ import { env } from './src/env.ts'
+ console.log('${MARK}' + JSON.stringify({ publicUrl: env.publicUrl, aliases: env.publicUrlAliases }))
+ `
+ const { stdout } = await execFileAsync(process.execPath, ['--import', 'tsx', '-e', source], {
+ cwd: pkgRoot,
+ env: {
+ PATH: process.env.PATH ?? '',
+ HOME: process.env.HOME ?? '',
+ TMPDIR: process.env.TMPDIR ?? '',
+ NIMBUS_DATABASE_URL: 'postgres://u:p@localhost:5432/nimbus',
+ // env.ts refuses to boot without it (CF-16), so every child boot needs one.
+ NIMBUS_KEY_SECRET: 'a-test-secret-that-is-long-enough',
+ ...vars,
+ },
+ })
+ const line = stdout.split('\n').find((l) => l.startsWith(MARK))
+ assert.ok(line, `env.ts printed no result. stdout:\n${stdout}`)
+ return {
+ ...(JSON.parse(line.slice(MARK.length)) as { publicUrl: string; aliases: string[] }),
+ bootLines: stdout
+ .split('\n')
+ .filter((l) => l.startsWith('{'))
+ .map((l) => JSON.parse(l) as { level: number; msg: string }),
+ }
+}
+
+test('unset, the origin falls back to the issuer — never to a header', async () => {
+ const booted = await bootPublicUrl({ NIMBUS_ISSUER: 'https://nimbus.forge.example' })
+ assert.equal(booted.publicUrl, 'https://nimbus.forge.example')
+ assert.deepEqual(booted.aliases, ['https://nimbus.forge.example'])
+})
+
+test('a malformed NIMBUS_PUBLIC_URL falls back and says so, rather than reopening the hole', async () => {
+ const booted = await bootPublicUrl({
+ NIMBUS_ISSUER: 'https://nimbus.forge.example',
+ NIMBUS_PUBLIC_URL: 'account.forge.example',
+ })
+ // No scheme is the likely mistake, and it is not a URL. Falling back to the issuer costs a
+ // link on the less friendly hostname; falling back to the request would cost an account.
+ assert.equal(booted.publicUrl, 'https://nimbus.forge.example')
+ const errs = booted.bootLines.filter((l) => l.msg.startsWith('NIMBUS_PUBLIC_URL'))
+ assert.equal(errs.length, 1)
+ assert.equal(errs[0]!.level, 50)
+})
+
+test('a path or query on NIMBUS_PUBLIC_URL is reduced to the origin', async () => {
+ const booted = await bootPublicUrl({
+ NIMBUS_ISSUER: 'https://nimbus.forge.example',
+ NIMBUS_PUBLIC_URL: 'https://account.forge.example/portal?x=1',
+ })
+ assert.equal(booted.publicUrl, 'https://account.forge.example')
+})
+
+test('a non-http scheme is refused', async () => {
+ // `javascript:` and `data:` parse perfectly well as URLs. A link built from one would be
+ // rendered as an anchor href in an email and pasted into a browser bar by a user who was
+ // told to expect exactly that.
+ const booted = await bootPublicUrl({
+ NIMBUS_ISSUER: 'https://nimbus.forge.example',
+ NIMBUS_PUBLIC_URL: 'javascript:alert(1)',
+ })
+ assert.equal(booted.publicUrl, 'https://nimbus.forge.example')
+ assert.equal(booted.bootLines.filter((l) => l.msg.startsWith('NIMBUS_PUBLIC_URL')).length, 1)
+})
diff --git a/services/nimbus/src/passwordReset.ts b/services/nimbus/src/passwordReset.ts
index 58f797e..fa9d467 100644
--- a/services/nimbus/src/passwordReset.ts
+++ b/services/nimbus/src/passwordReset.ts
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from 'node:crypto'
import { and, desc, eq, isNull, lt, gt } from 'drizzle-orm'
import type { FastifyBaseLogger, FastifyRequest } from 'fastify'
import { env } from './env.js'
+import { mailerConfigured, sendMail } from './mailer.js'
import { db } from './db/client.js'
import { passwordResetTokens, users } from './db/schema.js'
@@ -59,21 +60,66 @@ export async function createPasswordResetToken(
}
/**
- * The absolute /reset link carrying a token, built from the host this request
- * actually arrived on — the same derivation the portal uses for its own return
- * URLs (routes/portal.ts baseUrl). There is deliberately no NIMBUS_PUBLIC_URL
- * setting to get wrong: nimbus answers on two hostnames (nimbus.* and
- * account.*), both serve this portal, and a link minted on the host the
- * operator is already talking to is reachable by construction.
+ * The absolute /reset link carrying a token.
+ *
+ * THE HOST COMES FROM CONFIGURATION, NOT FROM THE REQUEST, and that is the
+ * whole of this function. It used to be `${req.protocol}://${req.host}`, the
+ * same derivation routes/portal.ts uses for its own return URLs — which is
+ * right THERE, because those links go straight back to the browser that sent
+ * the host, and wrong here, because this one is put in an email and read by
+ * somebody else. While nothing delivered it that was a latent bug; wiring SMTP
+ * turned it into an unauthenticated account takeover. `curl -H 'Host:
+ * evil.test' -X POST /auth/password/forgot -d '{"email":"victim@…"}'` had the
+ * deployment's own relay send the victim a genuine, correctly branded reset
+ * email whose button was `http://evil.test/reset#token=`, and
+ * `X-Forwarded-Host` did the same from the far side of the tunnel, because
+ * cloudflared is a private-range peer that env.trustProxy believes. See
+ * env.publicUrl / NIMBUS_PUBLIC_URL.
+ *
+ * The fragment does not save it: the attacker's own page reads
+ * `location.hash`. Nothing about where the token sits in a URL helps when the
+ * attacker chose the origin.
+ *
+ * `req` is still taken, for one reason: a request arriving on a host that is
+ * neither NIMBUS_PUBLIC_URL nor NIMBUS_ISSUER is either a probe for exactly
+ * this or a misconfiguration, and this is the only place either can be seen.
+ * The link is minted the same way regardless.
*
* Both callers live here rather than restating the format: the self-service
* /auth/password/forgot route and the operator's POST
* /admin/users/:id/password-reset must produce the identical link, or one of
* the two paths quietly stops working.
+ *
+ * THE TOKEN GOES AFTER THE '#', NOT AFTER THE '?'. A fragment is the one part
+ * of a URL a browser keeps to itself: it is not in the request line, so it
+ * reaches no server log, no reverse-proxy access log and no Referer header on
+ * the next navigation. It used to be `?token=…`, and the consequence was that
+ * Fastify's own "incoming request" line wrote the live credential to stdout on
+ * the very request that spends it — from where Lantern lifted it into an
+ * indexed column and every backup. obs.ts now strips query values as well, but
+ * these two defences protect different things and both are wanted: the
+ * serializer covers OUR logs, and the fragment covers everything between the
+ * user's browser and us, which we do not run and cannot redact.
+ *
+ * routes/portal.ts reads it back out of `location.hash`.
*/
export function resetUrlFor(req: FastifyRequest, token: string): string {
- const base = `${req.protocol}://${req.host || `localhost:${env.port}`}`
- return `${base}/reset?token=${encodeURIComponent(token)}`
+ const addressedAs = req.host ? `${req.protocol}://${req.host}` : null
+ if (addressedAs && !env.publicUrlAliases.includes(addressedAs)) {
+ // Not an error and not a refusal — a request on an unexpected host is
+ // answered exactly like any other, because refusing would tell the sender
+ // which hosts are real. The link simply does not follow it.
+ req.log.warn(
+ {
+ audit: 'password_reset_host_ignored',
+ addressedAs,
+ minted: env.publicUrl,
+ fix: 'If this host is a legitimate way to reach the portal, set NIMBUS_PUBLIC_URL to it (or add the route in front of nimbus). If it is not, somebody is probing the reset link with a forged Host or X-Forwarded-Host header; the link they got points here, not there.',
+ },
+ 'password reset requested on an unrecognised host — link minted from NIMBUS_PUBLIC_URL',
+ )
+ }
+ return `${env.publicUrl}/reset#token=${encodeURIComponent(token)}`
}
/** Redeem a reset token exactly once. Returns the user id, or null. */
@@ -146,48 +192,225 @@ export async function listPendingResets(): Promise {
}
/* ------------------------------------------------------------------ *
- * DELIVERY SEAM — READ THIS BEFORE WIRING ANYTHING UP
- *
- * There is no mail infrastructure anywhere in this estate, and inventing one
- * here would have meant a dependency, a secret and an outbound network path
- * that nothing else in the platform has. So the token half above is complete
- * and correct, and delivery is this one function.
- *
- * Today it delivers NOTHING. A self-service reset request mints a valid token
- * that is then dropped on the floor, and the working path is the operator one:
- * an admin issues a link from the console (POST /admin/users/:id/password-reset)
- * and hands it over out of band. `listPendingResets()` is how they see who is
- * waiting. That is deliberate and the user-facing copy says so plainly.
- *
- * To finish it, replace the body below with a real send and return
- * `{ delivered: true, channel: '' }`. Requirements:
- * - `resetUrl` is the only copy of the token in existence. Do not log it, do
- * not persist it, do not put it in an error message.
+ * DELIVERY — the seam, now wired.
+ *
+ * The token half above is complete on its own, and delivery is this one
+ * function. It has two supported modes and both are real deployments:
+ *
+ * SMTP_HOST set — the link is emailed, over a generic SMTP transport that
+ * any relay can serve (see ./mailer.ts and env.ts).
+ * SMTP_HOST unset — nothing is sent, exactly as before mail existed: the
+ * request is recorded, and an operator issues the link
+ * from the console (POST /admin/users/:id/password-reset).
+ * `listPendingResets()` is how they see who is waiting.
+ *
+ * The three rules the seam was written under still hold, and every one of them
+ * is load-bearing:
+ * - `resetUrl` is the only copy of the token in existence. It is not logged,
+ * not persisted, and not allowed into an error message — an SMTP server may
+ * quote the message it rejected back at us, so the failure path scrubs the
+ * token out of the error text rather than trusting it not to be there.
* - Throwing here must not fail the request: the caller answers 202
* regardless, because a different answer for a known and an unknown email
- * is an account-enumeration oracle.
- * - Nothing else needs to change. The routes, the table and the TTL are done.
+ * is an account-enumeration oracle. Nothing below can throw; failure is
+ * reported through the return value.
+ * - The routes, the table and the TTL are unchanged.
* ------------------------------------------------------------------ */
export interface ResetDelivery {
delivered: boolean
- /** What carried it, for the log line. 'none' while the seam is unwired. */
+ /** What carried it, for the log line. 'none' when no mail is configured. */
channel: string
}
+/** HTML-escape. The link's host comes from the request, so it is not ours to trust. */
+const esc = (s: string) =>
+ s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
+
+/**
+ * Remove the token from anything about to be written down.
+ *
+ * Belt and braces over "do not log the URL": the values that reach a log line
+ * on the failure path are error messages from a remote server, and the one
+ * thing that must never appear in one is the string we just handed it.
+ */
+function withoutToken(text: string, resetUrl: string): string {
+ let out = text.split(resetUrl).join('[reset link redacted]')
+
+ // Read the token with a regexp rather than `new URL()`. The parser is the
+ // better tool right up until it throws: this runs on the failure path, inside
+ // the one function whose contract says nothing in it can throw, and the
+ // exception would replace the scrubbed audit line — the single line an
+ // operator greps for when mail stops working — with a stack trace. That was
+ // not theoretical while the URL came off the Host header, which Node's HTTP
+ // parser accepts in shapes the WHATWG URL parser rejects (`Host: a^b`).
+ //
+ // The token rides in the fragment (see resetUrlFor) and is read from the query
+ // string as well, because a link minted before that change is still redeemable
+ // for its remaining thirty minutes and its token must be scrubbed too.
+ const token = /[#?&]token=([^&\s]+)/.exec(resetUrl)?.[1]
+ if (!token) return out
+
+ out = out.split(token).join('[redacted]')
+ // The link is percent-encoded; a server quoting it back may not be. The token
+ // is hex today, so this is a no-op — and it is here so it stays one if the
+ // alphabet ever changes.
+ let decoded: string | null = null
+ try {
+ decoded = decodeURIComponent(token)
+ } catch {
+ decoded = null
+ }
+ if (decoded && decoded !== token) out = out.split(decoded).join('[redacted]')
+ return out
+}
+
+/* The portal's palette, flattened for mail clients — no CSS variables, no
+ * color-mix(), no gradients, no |