Files
website/scripts/checkNoExternalHosts.js
wtclaude 12ff201ed5
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an
operator can edit. A relocation, not a regression: nothing that sends mail today
starts depending on an operator authoring something first.

- `email.*` block family in its own registry, sharing the page family's envelope
  walk and validate-then-sanitize order by binding rather than by copy.
- A server-side renderer producing both parts of a multipart message; the text
  part is byte-identical to the literals this commit deletes.
- Nine seeded templates, six of them wired now; the seeder's `customized = 0`
  guard lives in the UPDATE's own WHERE.
- `renderByKey` falls back to the shipped seed when a row is missing or unusable,
  so no failure of the table can stop a password reset.

Also fixes `check:hosts` reading the template key `auth.email-verify` as the
hostname `auth.email`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 13:07:39 -05:00

193 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
// ── §3.2 rule 4 — no phone-home in the engagement subsystem ────────────────
//
// ENGAGEMENT.md §3.2 records a posture the codebase already has and this check
// exists to keep: **no transport may ship a default host, endpoint, API base or
// sender.** A transport with no operator configuration is `unconfigured` and its
// channel is off — it never quietly falls back to a destination we chose.
//
// The rule is easy to hold and easy to break by accident, and the removed Gmail
// transport is the proof of both: `smtp.gmail.com` and port 465 were literals in
// `mailer.buildTransport()`, which made "which provider" a code edit and made the
// deployment's mail depend on a host nobody configured. Deleting that literal is
// what this check was written against, and it is the first thing it would have
// caught.
//
// **It reads code, not prose.** A comment naming `smtp.gmail.com` as the
// migration path for existing operators is exactly the documentation this phase
// owes, and a check that forbade it would teach people to phrase around it. So
// comments and the insides of ordinary strings are masked out; what is checked is
// a HOSTNAME OR URL appearing as a string literal in the engagement trees. Same
// design, and the same reasoning, as `checkModuleIdentifiers.js` — including
// having its own test suite, because a check that silently stops checking is
// worse than no check.
//
// Scope is the engagement subsystem plus the mail path it owns, not the whole
// server: core legitimately talks to hosts an operator configured elsewhere
// (ntfy, Discord, the sidecar), and those are not this rule's business.
const fs = require('fs')
const path = require('path')
const ROOT = path.resolve(__dirname, '..')
// The trees the rule covers. `server/src/engagement/` is where transports and,
// later, the rules engine live; `utils/mailer.js` is the one file outside it that
// composes and sends mail.
const TREES = [path.join(ROOT, 'server', 'src', 'engagement')]
const FILES = [path.join(ROOT, 'server', 'src', 'utils', 'mailer.js')]
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs'])
// A URL, or a bare dotted hostname with a real TLD. The TLD length floor is what
// keeps `emailConfig.model` and `foo.js` out of it — a two-plus-letter final
// label after at least one dot, with no path characters, is a host.
// The `(?![-\w])` after the TLD is not redundant with `\b`: `\b` matches between
// `l` and `-`, so `auth.email-verify` — an engagement TEMPLATE KEY, and one the
// plan names (§4.6.1) — was read as the host `auth.email` with a stray suffix.
// A real hostname's TLD is the last label, so a `-` or a word character following
// it means the match is a truncation of a longer identifier rather than a
// destination. Everything a host IS followed by (a quote, `/`, `:`, `?`) still
// matches.
const URL_LITERAL = /\b(?:https?|smtps?):\/\/[^\s'"`]+/
const HOSTNAME_LITERAL = /\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|co|email|mail|cloud|app|us|eu)(?![-\w])/i
// Hosts that are not destinations: the loopback family, and the RFC 2606 names
// reserved for documentation. A placeholder in an admin form's help text is the
// opposite of a phone-home — it shows the operator the SHAPE of a value they
// must supply, and blanking it would make the form worse to hold the rule.
const ALLOWED = [
/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)$/i,
/(?:^|\.)example\.(?:com|net|org)$/i,
/(?:^|\.)(?:invalid|test|localhost)$/i,
]
const isAllowed = (host) => ALLOWED.some((re) => re.test(host))
const hostOf = (literal) => {
const withoutScheme = literal.replace(/^[a-z]+:\/\//i, '')
return withoutScheme.split(/[/?#:]/)[0]
}
/**
* Blank comments and mask string bodies in one left-to-right pass, keeping every
* offset aligned so reported line numbers stay honest.
*
* Lifted from `checkModuleIdentifiers.maskCode` deliberately rather than
* imported: that file's masking is tuned to ITS four checks (it keeps quotes so a
* route-path check can re-read the original at the same offsets), and coupling
* two checks through a shared helper means a change made for one silently
* re-scopes the other. Both are ~40 lines and both are tested.
*/
function maskComments(src) {
const out = Array.from(src)
const blank = (from, to) => {
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
}
let i = 0
while (i < src.length) {
const c = src[i]
const next = src[i + 1]
if (c === '/' && next === '/') {
let j = i
while (j < src.length && src[j] !== '\n') j++
blank(i, j)
i = j
continue
}
if (c === '/' && next === '*') {
const end = src.indexOf('*/', i + 2)
const j = end === -1 ? src.length : end + 2
blank(i, j)
i = j
continue
}
if (c === '"' || c === "'" || c === '`') {
let j = i + 1
while (j < src.length) {
if (src[j] === '\\') { j += 2; continue }
if (src[j] === c) break
j++
}
// Keep the string body: it is what this check reads. Only the delimiters
// matter for finding it, and comments are what has to go.
i = j + 1
continue
}
i++
}
return out.join('')
}
// Every string literal in the (comment-free) source, with its line number.
const STRING = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g
function lineOf(src, index) {
return src.slice(0, index).split('\n').length
}
/** Check one file's contents. Returns [{ file, line, literal, host }]. */
function checkFile(rel, src) {
const hits = []
const code = maskComments(src)
for (const m of code.matchAll(STRING)) {
const value = m[2]
if (!value) continue
const urlMatch = value.match(URL_LITERAL)
const hostMatch = urlMatch ? null : value.match(HOSTNAME_LITERAL)
const literal = urlMatch ? urlMatch[0] : hostMatch ? hostMatch[0] : null
if (!literal) continue
const host = hostOf(literal)
if (isAllowed(host)) continue
hits.push({ file: rel, line: lineOf(src, m.index), literal, host })
}
return hits
}
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full, out)
else out.push(full)
}
return out
}
function run() {
const files = [...TREES.flatMap((t) => walk(t)), ...FILES.filter((f) => fs.existsSync(f))]
const hits = []
for (const file of files) {
if (!CODE.has(path.extname(file))) continue
const rel = path.relative(ROOT, file).split(path.sep).join('/')
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
}
return hits
}
module.exports = { run, checkFile, maskComments, isAllowed, hostOf }
if (require.main === module) {
const hits = run()
if (hits.length === 0) {
console.log('OK — the engagement subsystem names no external host (ENGAGEMENT.md §3.2 rule 4).')
process.exit(0)
}
console.error(
`\nThe engagement subsystem names ${hits.length} external host${hits.length === 1 ? '' : 's'} ` +
'in code (ENGAGEMENT.md §3.2 rule 4). A destination belongs in operator-supplied ' +
'configuration, never in a literal:\n',
)
for (const h of hits) {
console.error(` ${h.file}:${h.line} "${h.literal}"`)
}
console.error(
'\nIf this is help text or documentation rather than a destination, put it in a comment or ' +
'use an example.com placeholder — the check masks comments and allows the reserved ' +
'documentation names on purpose.\n',
)
process.exit(1)
}