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>
193 lines
7.6 KiB
JavaScript
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)
|
|
}
|