#!/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) }