@bongos/core 1.20.38 → 1.20.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.bongos-core.json +47 -37
  2. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +1 -0
  3. package/docs/copy-inventory.md +1 -1
  4. package/docs/copy-registry.json +9 -9
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-readings.json +251 -248
  7. package/modules/npm-release/preview/childlog.js +95 -0
  8. package/modules/npm-release/preview/commands.js +46 -5
  9. package/modules/npm-release/preview/divert.js +16 -6
  10. package/modules/npm-release/preview/env.js +18 -7
  11. package/modules/npm-release/preview/proxy.js +4 -1
  12. package/modules/npm-release/preview/runtime.js +43 -9
  13. package/modules/npm-release/preview/supervisor.js +170 -31
  14. package/modules/npm-release/public/work.css +3 -0
  15. package/modules/npm-release/public/work.js +6 -1
  16. package/modules/npm-release/routes/preview.js +7 -5
  17. package/modules/provisioning/render-standup.js +13 -1
  18. package/modules/public-landing/public/projects.html +22 -8
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/release-notes.json +16 -0
  22. package/scripts/gds/provision-render.js +13 -1
  23. package/scripts/gds/render-payload.js +6 -5
  24. package/src/module-api.js +1 -1
  25. package/tests/npm_release_preview_commands.mjs +1 -1
  26. package/tests/npm_release_preview_diagnosis.mjs +376 -0
  27. package/tests/npm_release_preview_divert.mjs +10 -0
  28. package/tests/npm_release_preview_proxy.mjs +26 -0
  29. package/tests/npm_release_preview_routes.mjs +15 -2
  30. package/tests/npm_release_preview_supervisor.mjs +4 -4
  31. package/tests/projects_hub_app_step.mjs +49 -0
  32. package/tests/projects_hub_render_connect.mjs +2 -1
  33. package/tests/provision_render.mjs +23 -2
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/preview/childlog.js — what the preview child printed, kept small and
4
+ // with secrets taken out (task 1004465).
5
+ //
6
+ // WHY. The child used to be spawned with stdio 'ignore', so when a preview started but could
7
+ // not serve a single page, its own error went nowhere and the only way to find out was ssh.
8
+ // This keeps the last CAP bytes of its stdout and stderr, writes them to a file in the
9
+ // preview's scratch directory, and hands the deploy page a short redacted tail.
10
+ //
11
+ // REDACTION IS BY VALUE AND BY SHAPE. Every value in the environment the child was given
12
+ // (and the live one) whose name looks secret is replaced wherever it appears; then the common
13
+ // shapes (a password in a connection URL, `token=...`, a bearer header, a GitHub or API key)
14
+ // are replaced even if nobody listed them. The tail never reaches a caller unredacted: it is
15
+ // redacted at read time as well as at write time, so a secret that arrives split across two
16
+ // chunks is still caught.
17
+
18
+ const CAP = 64 * 1024;
19
+ // The file is rewritten at most this often, asynchronously: the child's output is handled
20
+ // inside the LIVE hall's process, and a chatty or crash-looping child must never turn into a
21
+ // run of blocking disk writes on its event loop.
22
+ const FLUSH_MS = 500;
23
+ const TAIL_LINES = 20;
24
+ const MAX_LINE = 300;
25
+ const SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PASS|KEY|CREDENTIAL|COOKIE|AUTH)/i;
26
+
27
+ const SHAPES = [
28
+ [/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]*:)[^\s@/]+@/gi, '$1[redacted]@'],
29
+ [/\b((?:api[_-]?key|token|secret|password|passwd|authorization|cookie|signing[_-]?key)\w*\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
30
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [redacted]'],
31
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,})/g, '[redacted]'],
32
+ ];
33
+
34
+ /** The values in these environments that look like secrets and are long enough to matter. */
35
+ function secretValues(...envs) {
36
+ const out = new Set();
37
+ for (const env of envs) {
38
+ for (const [k, v] of Object.entries(env || {})) {
39
+ if (SECRET_NAME.test(k) && typeof v === 'string' && v.length >= 6) out.add(v);
40
+ }
41
+ }
42
+ // Longest first, so a value that contains another is replaced whole.
43
+ return [...out].sort((a, b) => b.length - a.length);
44
+ }
45
+
46
+ /** Take the secrets out of one piece of text. Pure. */
47
+ function redact(text, secrets = []) {
48
+ let s = String(text);
49
+ for (const v of secrets) s = s.split(v).join('[redacted]');
50
+ for (const [re, to] of SHAPES) s = s.replace(re, to);
51
+ return s;
52
+ }
53
+
54
+ /**
55
+ * @param {object} [o]
56
+ * @param {string[]} [o.secrets] exact values to take out (from secretValues)
57
+ * @param {object} [o.fsImpl] fs (writeFile, the callback form) — optional, with `file`
58
+ * @param {string} [o.file] where to keep the capped raw-but-redacted log
59
+ * @param {number} [o.cap]
60
+ */
61
+ function createChildLog({ secrets = [], fsImpl = null, file = null, cap = CAP, flushMs = FLUSH_MS } = {}) {
62
+ let buf = '';
63
+ let timer = null;
64
+ let closed = false;
65
+ const canWrite = () => !!(fsImpl && file && typeof fsImpl.writeFile === 'function');
66
+ function flush() {
67
+ timer = null;
68
+ if (closed || !canWrite()) return;
69
+ try { fsImpl.writeFile(file, buf, () => { /* best effort */ }); } catch { /* best effort */ }
70
+ }
71
+ return {
72
+ /** Add output. Never throws: a log that cannot be kept must not fail a start. */
73
+ append(chunk) {
74
+ try {
75
+ buf = (buf + redact(chunk, secrets)).slice(-cap);
76
+ if (canWrite() && !timer && !closed) {
77
+ timer = setTimeout(flush, flushMs);
78
+ if (timer && typeof timer.unref === 'function') timer.unref();
79
+ }
80
+ } catch { /* best effort */ }
81
+ },
82
+ /** Write the file now (tests; the timer does this in production). */
83
+ flush,
84
+ /** Stop writing: the scratch directory is about to be removed. */
85
+ close() { closed = true; if (timer) { clearTimeout(timer); timer = null; } },
86
+ /** The last `n` non-empty lines, each shortened. */
87
+ tail(n = TAIL_LINES) {
88
+ return redact(buf, secrets).split(/\r?\n/).filter((l) => l.trim())
89
+ .slice(-n).map((l) => (l.length > MAX_LINE ? `${l.slice(0, MAX_LINE)}…` : l));
90
+ },
91
+ size: () => buf.length,
92
+ };
93
+ }
94
+
95
+ module.exports = { createChildLog, redact, secretValues };
@@ -36,11 +36,49 @@ function installCommand({ cacheRoot, version }) {
36
36
  return { cmd: 'npm', args: ['install', '--prefix', versionDir(cacheRoot, version), `${PACKAGE}@${version}`, '--omit=dev'] };
37
37
  }
38
38
 
39
- /** Drop the copy if one is left over — used before a start and on every stop. */
39
+ /**
40
+ * Drop the copy if one is left over — used before a start and on every stop. --force ends any
41
+ * connection still open on the copy (Postgres 13+), which is exactly the case that used to
42
+ * leave it behind: a child that had not finished exiting. The name is the fixed PREVIEW_DB
43
+ * and nothing is interpolated, so this can never name the live database.
44
+ */
40
45
  function dropDbCommand() {
46
+ return { cmd: 'dropdb', args: ['--if-exists', '--force', PREVIEW_DB] };
47
+ }
48
+
49
+ /** Plain drop, for a server whose dropdb has no --force (Postgres 12 and older). */
50
+ function dropDbPlainCommand() {
41
51
  return { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] };
42
52
  }
43
53
 
54
+ /**
55
+ * End every connection to the copy and ONLY the copy: the database name is the literal
56
+ * PREVIEW_DB, never a parameter. Run on the maintenance database, since the copy cannot be
57
+ * connected to while it is being emptied.
58
+ */
59
+ function terminateBackendsCommand() {
60
+ return {
61
+ cmd: 'psql',
62
+ args: ['-X', '-d', 'postgres', '-tAc',
63
+ `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${PREVIEW_DB}' AND pid <> pg_backend_pid()`],
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Can this command only ever touch the copy? True for the drop commands above and nothing
69
+ * else. The supervisor checks it before running any drop, so a future edit that pointed one
70
+ * at another database is refused at run time, not discovered on the droplet.
71
+ */
72
+ function targetsOnlyPreviewDb(command) {
73
+ if (!command || !Array.isArray(command.args)) return false;
74
+ if (command.cmd === 'dropdb') {
75
+ const last = command.args[command.args.length - 1];
76
+ return last === PREVIEW_DB && command.args.slice(0, -1).every((a) => a.startsWith('--'));
77
+ }
78
+ if (command.cmd === 'psql') return JSON.stringify(command.args) === JSON.stringify(terminateBackendsCommand().args);
79
+ return false;
80
+ }
81
+
44
82
  /** An empty database for the copy. Deliberately no template (see the header). */
45
83
  function createDbCommand() {
46
84
  return { cmd: 'createdb', args: [PREVIEW_DB] };
@@ -65,17 +103,20 @@ function restoreCommand({ dumpFile }) {
65
103
  * DATABASE_URL is blanked because the pool prefers it to PGDATABASE, and PGDATABASE is
66
104
  * pinned to the copy so nothing can be applied to the live database by mistake.
67
105
  */
68
- function migrateCommand({ cacheRoot, version, env }) {
106
+ function migrateCommand({ cacheRoot, version, env, instanceDir }) {
69
107
  const dir = versionDir(cacheRoot, version);
108
+ // With a scratch instance directory, THAT is the instance root (its config/ and
109
+ // migrations/instance/ are copies of the live instance's); without one, the install directory.
110
+ const root = instanceDir || dir;
70
111
  return {
71
112
  cmd: 'bash',
72
113
  args: [path.join(dir, 'node_modules', '@bongos', 'core', 'scripts', 'migrate.sh')],
73
- cwd: dir,
74
- env: { ...env, INIT_CWD: dir, PGDATABASE: PREVIEW_DB, DATABASE_URL: '' },
114
+ cwd: root,
115
+ env: { ...env, INIT_CWD: root, PGDATABASE: PREVIEW_DB, DATABASE_URL: '' },
75
116
  };
76
117
  }
77
118
 
78
119
  module.exports = {
79
120
  VERSION_RE, PACKAGE, versionDir, entryPath,
80
- installCommand, dropDbCommand, createDbCommand, dumpCommand, restoreCommand, migrateCommand,
121
+ installCommand, dropDbCommand, dropDbPlainCommand, terminateBackendsCommand, targetsOnlyPreviewDb, createDbCommand, dumpCommand, restoreCommand, migrateCommand,
81
122
  };
@@ -44,9 +44,19 @@ function pathOf(req) {
44
44
  return q === -1 ? url : url.slice(0, q);
45
45
  }
46
46
 
47
- /** The Set-Cookie value that removes the steering cookie. */
48
- function clearCookie() {
49
- return `${COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`;
47
+ /** Path/HttpOnly/SameSite/Secure the steering cookie is set with; the clear repeats them. */
48
+ function steerFlags(req) {
49
+ return `HttpOnly; SameSite=Lax${req && req.secure ? '; Secure' : ''}`;
50
+ }
51
+
52
+ /** The Set-Cookie value that sets the steering cookie. */
53
+ function setCookie(version, req) {
54
+ return `${COOKIE_NAME}=${version}; Path=/; ${steerFlags(req)}`;
55
+ }
56
+
57
+ /** The Set-Cookie value that removes it: same Path, SameSite and Secure as the set (task 1004466). */
58
+ function clearCookie(req) {
59
+ return `${COOKIE_NAME}=; Path=/; Max-Age=0; ${steerFlags(req)}`;
50
60
  }
51
61
 
52
62
  // Run an express-style gate to its verdict without letting it write a response of its own:
@@ -77,14 +87,14 @@ function createDivert({ supervisor, forward, requireBuilder, requirePermission }
77
87
  const st = supervisor.status();
78
88
  if (st.state !== 'running' || st.version !== wanted) {
79
89
  // The preview this cookie pointed at is gone: drop the cookie and serve the live hall.
80
- res.append('Set-Cookie', clearCookie());
90
+ res.append('Set-Cookie', clearCookie(req));
81
91
  return next();
82
92
  }
83
93
  const p = pathOf(req);
84
94
  if (AUTH_PATH.test(p) || PREVIEW_PATH.test(p)) return next();
85
95
  if (req.headers.authorization) return next();
86
96
  if (!(await passes(requireBuilder, req)) || !(await passes(mayPreview, req))) {
87
- res.append('Set-Cookie', clearCookie());
97
+ res.append('Set-Cookie', clearCookie(req));
88
98
  return next();
89
99
  }
90
100
  supervisor.touch();
@@ -92,4 +102,4 @@ function createDivert({ supervisor, forward, requireBuilder, requirePermission }
92
102
  };
93
103
  }
94
104
 
95
- module.exports = { createDivert, cookieVersion, clearCookie, AUTH_PATH, PREVIEW_PATH };
105
+ module.exports = { createDivert, cookieVersion, clearCookie, setCookie, AUTH_PATH, PREVIEW_PATH };
@@ -41,12 +41,15 @@ const POLLERS_OFF = Object.freeze([
41
41
 
42
42
  // Modules that talk to the outside world, off. npm-release is off too: a preview must not
43
43
  // offer previews of its own.
44
- const MODULES_OFF = Object.freeze([
45
- 'BONGOS_MODULE_DISCORD',
46
- 'BONGOS_MODULE_AGENTS',
47
- 'BONGOS_MODULE_PROVISIONING',
48
- 'BONGOS_MODULE_NPM_RELEASE',
49
- ]);
44
+ //
45
+ // EVERY SPELLING OF THE PREFIX. The core reads <PREFIX>_MODULE_<KEY> with the PREFIX the
46
+ // instance's branding pack names, then the legacy ones. The child now runs with a copy of the
47
+ // live instance's config/ (task 1004465), so its prefix may be the live instance's own, and a
48
+ // switch spelled only BONGOS_ would then be ignored — Discord and the agents would come back
49
+ // on in the copy. So each switch is set under every prefix the core can resolve.
50
+ const MODULE_KEYS = Object.freeze(['DISCORD', 'AGENTS', 'PROVISIONING', 'NPM_RELEASE']);
51
+ const PREFIXES = Object.freeze(['BONGOS', 'CLOUDBONGOS', 'OTB', 'GDS', 'PMS']);
52
+ const MODULES_OFF = Object.freeze(PREFIXES.flatMap((pfx) => MODULE_KEYS.map((k) => `${pfx}_MODULE_${k}`)));
50
53
 
51
54
  /**
52
55
  * Build the child's environment.
@@ -55,9 +58,12 @@ const MODULES_OFF = Object.freeze([
55
58
  * @param {object} [o.env] the live process's environment (only CARRIED names are read)
56
59
  * @param {number|string} o.port the loopback port the preview listens on
57
60
  * @param {string} o.scratchHome an empty directory to serve as HOME
61
+ * @param {string} [o.envPrefix] the env prefix of the copied instance's branding pack, if any
62
+ * @param {string} [o.instanceRoot] the scratch directory shaped like an instance root (config/,
63
+ * migrations/instance/); the child reads its host content from it
58
64
  * @returns {Record<string,string>} a fresh object, never `env` itself
59
65
  */
60
- function buildPreviewEnv({ env = process.env, port, scratchHome } = {}) {
66
+ function buildPreviewEnv({ env = process.env, port, scratchHome, instanceRoot, envPrefix } = {}) {
61
67
  if (!port) throw new TypeError('buildPreviewEnv: port is required');
62
68
  if (!scratchHome) throw new TypeError('buildPreviewEnv: scratchHome is required');
63
69
  const out = {};
@@ -68,10 +74,15 @@ function buildPreviewEnv({ env = process.env, port, scratchHome } = {}) {
68
74
  out.PORT = String(port);
69
75
  out.HOST = '127.0.0.1';
70
76
  out.HOME = scratchHome;
77
+ // Named explicitly: without it the core takes the process's working directory as the instance
78
+ // root, and the install directory is not one (no config/, so no branding, no module switches).
79
+ if (instanceRoot) out.BONGOS_INSTANCE_ROOT = instanceRoot;
71
80
  out.PGDATABASE = PREVIEW_DB;
72
81
  out.CHAT_DRY_RUN = '1';
73
82
  for (const name of POLLERS_OFF) out[name] = '1';
74
83
  for (const name of MODULES_OFF) out[name] = '0';
84
+ // The copied instance's own prefix, when it is none of the above.
85
+ if (/^[A-Z][A-Z0-9_]*$/.test(String(envPrefix || ''))) for (const k of MODULE_KEYS) out[`${envPrefix}_MODULE_${k}`] = '0';
75
86
  return out;
76
87
  }
77
88
 
@@ -74,7 +74,10 @@ function createProxy({ port, banner, exitHref, httpImpl = http } = {}) {
74
74
  { host: '127.0.0.1', port: port(), method: req.method, path: req.originalUrl || req.url, headers },
75
75
  (upRes) => {
76
76
  const out = {};
77
- for (const [k, v] of Object.entries(upRes.headers)) if (!HOP.has(k)) out[k] = v;
77
+ // SET-COOKIE NEVER CROSSES (task 1004466). The preview shares the live hall's origin,
78
+ // so a cookie it set would overwrite the live hall's own (cb_stealth, the session,
79
+ // the steering cookie) and lock its owner out. Its cookies stay with the child.
80
+ for (const [k, v] of Object.entries(upRes.headers)) if (!HOP.has(k) && k.toLowerCase() !== 'set-cookie') out[k] = v;
78
81
  const isPage = /^text\/html\b/i.test(String(upRes.headers['content-type'] || ''));
79
82
  if (!isPage) {
80
83
  res.writeHead(upRes.statusCode, out);
@@ -22,6 +22,7 @@ const REAP_EVERY_MS = 60 * 1000;
22
22
  const PROGRAMS = Object.freeze({
23
23
  npm: (args, o) => spawn('npm', args, o),
24
24
  dropdb: (args, o) => spawn('dropdb', args, o),
25
+ psql: (args, o) => spawn('psql', args, o),
25
26
  createdb: (args, o) => spawn('createdb', args, o),
26
27
  pg_dump: (args, o) => spawn('pg_dump', args, o),
27
28
  pg_restore: (args, o) => spawn('pg_restore', args, o),
@@ -49,27 +50,60 @@ function runCommand({ cmd, args, env, cwd }) {
49
50
  });
50
51
  }
51
52
 
52
- /** Does something answer /healthz on the loopback port? */
53
- function probeHealth(port) {
53
+ /** GET one loopback path: { status, body } (body capped), or { error } when nothing answered. */
54
+ function getLoopback(port, urlPath) {
54
55
  return new Promise((resolve) => {
55
- const req = http.get({ host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, (res) => {
56
- res.resume();
57
- resolve(res.statusCode === 200);
56
+ const req = http.get({ host: '127.0.0.1', port, path: urlPath, timeout: 3000, headers: { accept: 'application/json' } }, (res) => {
57
+ let body = '';
58
+ res.on('data', (d) => { if (body.length < 16384) body += d; });
59
+ res.on('end', () => resolve({ status: res.statusCode, body }));
60
+ res.on('error', (e) => resolve({ error: e.message }));
58
61
  });
59
- req.on('error', () => resolve(false));
60
- req.on('timeout', () => { req.destroy(); resolve(false); });
62
+ req.on('error', (e) => resolve({ error: e.message }));
63
+ req.on('timeout', () => { req.destroy(); resolve({ error: 'timed out' }); });
61
64
  });
62
65
  }
63
66
 
67
+ /**
68
+ * Can the preview SERVE DATA? Not just "does something answer": /healthz touches no database,
69
+ * and on the first live run a preview that answered it 200 returned 500 on every page.
70
+ *
71
+ * 1. /healthz answers 200 (the process is up).
72
+ * 2. /version's schemaPending is not null. It is computed from a read of schema_migrations,
73
+ * and is null exactly when that read fails — so this proves the copy is reachable. A
74
+ * version too old to carry the field at all is passed on to step 3 instead.
75
+ * 3. a database-backed route is not a server error: /api/bongos/me answers 401 or 200 when
76
+ * the database and auth path work, and 500 when they do not.
77
+ *
78
+ * Returns { ok, reason }; the reason is shown to the owner, so it names the failing step.
79
+ */
80
+ async function probeHealth(port, get = getLoopback) {
81
+ const h = await get(port, '/healthz');
82
+ if (h.error || h.status !== 200) return { ok: false, reason: h.error ? `/healthz: ${h.error}` : `/healthz answered ${h.status}` };
83
+ const v = await get(port, '/version');
84
+ if (v.error || v.status !== 200) return { ok: false, reason: v.error ? `/version: ${v.error}` : `/version answered ${v.status}` };
85
+ let info = null;
86
+ try { info = JSON.parse(v.body); } catch { /* not JSON: handled below */ }
87
+ if (!info || typeof info !== 'object') return { ok: false, reason: '/version did not answer with JSON' };
88
+ if ('schemaPending' in info && info.schemaPending === null) {
89
+ return { ok: false, reason: '/version reports schemaPending null: the database copy could not be read' };
90
+ }
91
+ const me = await get(port, '/api/bongos/me');
92
+ if (me.error || me.status >= 500) {
93
+ return { ok: false, reason: me.error ? `/api/bongos/me: ${me.error}` : `a database-backed read (/api/bongos/me) answered ${me.status}` };
94
+ }
95
+ return { ok: true };
96
+ }
97
+
64
98
  /**
65
99
  * Build the production supervisor: the port from NPM_RELEASE_PREVIEW_PORT (default 3190,
66
100
  * loopback only), installs cached under ~/.cache/bongos-preview, and a timer that stops
67
101
  * an idle preview and a hook that stops it when this process does.
68
102
  */
69
- function createRuntime({ liveDb, log, env = process.env } = {}) {
103
+ function createRuntime({ liveDb, log, env = process.env, instanceRoot = null } = {}) {
70
104
  const port = Number(env.NPM_RELEASE_PREVIEW_PORT) || DEFAULT_PORT;
71
105
  const supervisor = createSupervisor({
72
- run: runCommand, spawn, probe: probeHealth, liveDb, log, port, env,
106
+ run: runCommand, spawn, probe: probeHealth, liveDb, log, port, env, instanceRoot,
73
107
  cacheRoot: path.join(os.homedir(), '.cache', 'bongos-preview'),
74
108
  });
75
109
  const timer = setInterval(() => {