@bongos/core 1.20.82 → 1.21.1

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 (76) hide show
  1. package/.bongos-core.json +154 -64
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
  9. package/docs/adr/README.md +1 -1
  10. package/docs/api/openapi.json +153 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/architecture.md +3 -0
  13. package/docs/copy-inventory.md +10 -10
  14. package/docs/copy-registry.json +12 -12
  15. package/docs/design/project-startup-direction.md +63 -0
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +8 -0
  18. package/docs/page-inventory.json +8 -3
  19. package/docs/page-readings.json +382 -373
  20. package/docs/recipes/private-npm-distribution.md +1 -1
  21. package/migrations/core_269_session_transcripts.sql +35 -0
  22. package/modules/hall-ui/public/genesis-home.js +17 -3
  23. package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
  24. package/modules/lifecycle/done-when.js +82 -42
  25. package/modules/lifecycle/workflow-dispatch.js +21 -11
  26. package/modules/npm-release/release.js +10 -1
  27. package/modules/onboarding/founding-birth.js +116 -0
  28. package/modules/onboarding/genesis-home.js +72 -5
  29. package/modules/onboarding/port.js +9 -2
  30. package/modules/onboarding/routes/onboarding.js +8 -1
  31. package/modules/provisioning/birth.js +64 -0
  32. package/modules/provisioning/dns-resolves.js +54 -0
  33. package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
  34. package/modules/provisioning/pollers/liveness-sweep.js +5 -0
  35. package/modules/provisioning/provisioning.js +7 -6
  36. package/modules/provisioning/routes/provisioning.js +6 -2
  37. package/modules/provisioning/tests/liveness.mjs +48 -0
  38. package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
  39. package/modules/public-landing/public/projects.html +40 -15
  40. package/modules/sessions/db.js +58 -0
  41. package/modules/sessions/routes/sessions.js +97 -0
  42. package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
  43. package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
  44. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
  45. package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
  46. package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +41 -0
  50. package/scripts/gds/provision.js +77 -53
  51. package/scripts/gds/session-digest.js +11 -3
  52. package/scripts/gds/session-transcript-build.js +155 -0
  53. package/scripts/gds/session-transcript-upload.js +29 -0
  54. package/scripts/gds/ship-session-upload.js +6 -1
  55. package/scripts/gds/upgrade-commit-identity.js +32 -0
  56. package/scripts/gds/upgrade.js +1 -1
  57. package/src/bongos/routes.js +3 -0
  58. package/src/branding.js +9 -0
  59. package/src/module-api.js +1 -1
  60. package/tests/auto_satisfy_criteria.mjs +20 -0
  61. package/tests/founding_birth.mjs +200 -0
  62. package/tests/founding_mode.mjs +3 -1
  63. package/tests/genesis_home.mjs +71 -2
  64. package/tests/goal_closure_invariants.mjs +68 -0
  65. package/tests/hub_map_orb_states.mjs +5 -1
  66. package/tests/npm_release_release.mjs +37 -2
  67. package/tests/projects_hub.mjs +5 -3
  68. package/tests/projects_hub_dns_ready.mjs +153 -0
  69. package/tests/projects_hub_pre_uat.mjs +9 -5
  70. package/tests/provision_dns_first.mjs +171 -0
  71. package/tests/provision_settings_apply.mjs +6 -1
  72. package/tests/provisioning_settings_env.mjs +49 -0
  73. package/tests/session_transcript.mjs +273 -0
  74. package/tests/ui_design_kit.mjs +1 -1
  75. package/tests/upgrade_commit_identity.mjs +89 -0
  76. package/tests/wizard_demo.mjs +29 -3
@@ -0,0 +1,153 @@
1
+ // tests/projects_hub_dns_ready.mjs
2
+ //
3
+ // No address link renders before the platform's own lookup answers (task 1004505,
4
+ // BV2.PS17). A founder's first click on a name that does not resolve yet is cached as
5
+ // "not found" by their resolver for the zone's negative TTL (30 minutes on
6
+ // cloudbongos.com), so the hub holds every door to the address until the instance read
7
+ // carries `dns_resolves` (the server's sighting — modules/provisioning/dns-resolves.js)
8
+ // or a verified up probe. The status alone, 'active' included, is never enough.
9
+ //
10
+ // EXECUTED by the vm-slice idiom: the done panel's renderProgress, the card's
11
+ // addressRow and the row's orbFor are sliced verbatim out of projects.html and run
12
+ // under a narrow stub, and the markup they write is read back. Every slice boundary is
13
+ // asserted, so a rename fails loudly instead of testing nothing. vm-born values are
14
+ // compared as primitives or through JSON.
15
+ //
16
+ // The runner half (the record is created first) is tests/provision_dns_first.mjs.
17
+ //
18
+ // Run: node tests/projects_hub_dns_ready.mjs
19
+
20
+ import { test } from 'node:test';
21
+ import { strict as assert } from 'node:assert';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import vm from 'node:vm';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
29
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
30
+
31
+ function slice(from, to) {
32
+ const a = HUB.indexOf(from);
33
+ const b = HUB.indexOf(to, a + 1);
34
+ assert.ok(a > 0, `slice start must exist: ${from}`);
35
+ assert.ok(b > a, `slice end must follow it: ${to}`);
36
+ return HUB.slice(a, b);
37
+ }
38
+
39
+ const PROGRESS = slice('var PROG_FLOWS = {', 'function startProgress(');
40
+ const OVERLAY = slice('function verifiedAt(inst)', 'function chipMarkup(');
41
+ const ADDRESS = slice('var ADDRESS_SETTING_UP', 'function publishRow(');
42
+ const ORB = slice('function orbFor(', '/* ── progress strips');
43
+ for (const [name, region] of [['renderProgress', PROGRESS], ['addressReady', OVERLAY], ['addressRow', ADDRESS], ['orbFor', ORB]]) {
44
+ assert.ok(region.includes(`function ${name}(`), `the slice must carry ${name}`);
45
+ }
46
+
47
+ function boot() {
48
+ const liveProgress = { innerHTML: '' };
49
+ const sandbox = {
50
+ $: (id) => (id === 'liveProgress' ? liveProgress : null),
51
+ esc: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
52
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
53
+ projectPageLink: (label) => '<a href="?view=manage&id=1">' + label + '</a>',
54
+ relTime: () => '2 minutes ago',
55
+ canRestart: () => false,
56
+ planetOf: () => ({ tpl: 'blob-tint', acc: 'var(--a1)' }),
57
+ planetAttrs: () => '',
58
+ FRESH_MS: 12 * 60 * 1000,
59
+ LIVE_MOMENT_MS: 10 * 60 * 1000,
60
+ lastProgHtml: '',
61
+ };
62
+ vm.createContext(sandbox);
63
+ vm.runInContext(PROGRESS + '\n' + OVERLAY + '\n' + ADDRESS + '\n' + ORB, sandbox);
64
+ return { sandbox, progress: () => liveProgress.innerHTML };
65
+ }
66
+
67
+ const NOW = () => new Date().toISOString();
68
+ const base = { id: 1, slug: 'lantern', status: 'active', domain: 'lantern.cloudbongos.com', last_verified_at: null, last_verified_up: null, dns_note: null };
69
+ const ADDRESS_LINK = /<a [^>]*href="https:\/\/lantern\.cloudbongos\.com/;
70
+
71
+ test('active but not yet resolved: the done panel holds the link, says so, and keeps watching', () => {
72
+ const h = boot();
73
+ const terminal = h.sandbox.renderProgress({ ...base, dns_resolves: false });
74
+ assert.equal(terminal, false, 'the panel keeps polling until the lookup answers');
75
+ assert.doesNotMatch(h.progress(), ADDRESS_LINK);
76
+ assert.doesNotMatch(h.progress(), /lantern\.cloudbongos\.com/, 'and does not even name it to be typed');
77
+ assert.match(h.progress(), /your address is being set up/);
78
+ });
79
+
80
+ test('a down probe on a never-resolved address is still "being set up", never a link', () => {
81
+ const h = boot();
82
+ h.sandbox.renderProgress({ ...base, dns_resolves: false, last_verified_up: false, last_verified_at: NOW() });
83
+ assert.doesNotMatch(h.progress(), ADDRESS_LINK);
84
+ assert.match(h.progress(), /being set up/);
85
+ });
86
+
87
+ test('once the lookup answers, the done panel links the address', () => {
88
+ const h = boot();
89
+ const terminal = h.sandbox.renderProgress({ ...base, dns_resolves: true });
90
+ assert.equal(terminal, false, 'resolving is not yet answering — the panel still waits for the probe');
91
+ assert.match(h.progress(), ADDRESS_LINK);
92
+ const live = boot();
93
+ assert.equal(live.sandbox.renderProgress({ ...base, dns_resolves: true, last_verified_up: true, last_verified_at: NOW() }), true);
94
+ assert.match(live.progress(), ADDRESS_LINK);
95
+ });
96
+
97
+ test('the card and the manage page: no link and no name before the lookup answers, in every pre-live state', () => {
98
+ const { sandbox } = boot();
99
+ for (const status of ['requested', 'provisioning', 'active']) {
100
+ const row = sandbox.addressRow({ ...base, status, dns_resolves: false });
101
+ assert.doesNotMatch(row, /<a /, `${status}: no link`);
102
+ assert.doesNotMatch(row, /lantern\.cloudbongos\.com/, `${status}: no name`);
103
+ assert.equal(row, sandbox.ADDRESS_SETTING_UP, `${status}: the being-set-up line`);
104
+ assert.doesNotMatch(sandbox.addressRow({ ...base, status, dns_resolves: false }, true), /<a /, `${status}: the manage page too`);
105
+ }
106
+ });
107
+
108
+ test('the card links the address once it resolves, and keeps a verified-up project linked', () => {
109
+ const { sandbox } = boot();
110
+ assert.match(sandbox.addressRow({ ...base, dns_resolves: true }), ADDRESS_LINK);
111
+ assert.match(sandbox.addressRow({ ...base, last_verified_up: true, last_verified_at: NOW() }), ADDRESS_LINK, 'an up probe is the same proof');
112
+ });
113
+
114
+ test('a manual-DNS note still names the address (pointing it is the owner\'s step) but never links it', () => {
115
+ const { sandbox } = boot();
116
+ const note = 'Automatic DNS isn\'t set up on this platform — point it at your server.';
117
+ for (const status of ['provisioning', 'active']) {
118
+ const row = sandbox.addressRow({ ...base, status, dns_resolves: false, dns_note: note });
119
+ assert.match(row, /<code>lantern\.cloudbongos\.com<\/code>/);
120
+ assert.doesNotMatch(row, /<a /);
121
+ }
122
+ });
123
+
124
+ test('the row\'s planet opens the site only once the address answers', () => {
125
+ const { sandbox } = boot();
126
+ assert.match(sandbox.orbFor({ ...base, dns_resolves: false }), /^<span /);
127
+ assert.match(sandbox.orbFor({ ...base, dns_resolves: true }), /^<a [^>]*href="https:\/\/lantern\.cloudbongos\.com"/);
128
+ });
129
+
130
+ test('addressReady: a domain plus a sighting or an up probe — never the status alone', () => {
131
+ const { sandbox } = boot();
132
+ const cases = [
133
+ [{ ...base }, false],
134
+ [{ ...base, dns_resolves: true }, true],
135
+ [{ ...base, last_verified_up: true }, true],
136
+ [{ ...base, last_verified_up: false }, false],
137
+ [{ ...base, domain: null, dns_resolves: true }, false],
138
+ ];
139
+ assert.equal(JSON.stringify(cases.map(([i]) => sandbox.addressReady(i))), JSON.stringify(cases.map(([, want]) => want)));
140
+ });
141
+
142
+ test('every door to the address on the page asks addressReady', () => {
143
+ const SCRIPT = HUB;
144
+ assert.match(SCRIPT, /inst\.status === 'active' && addressReady\(inst\)\) acts\.push\('<a class="btn small ghost" href="https:\/\/' \+ esc\(inst\.domain\) \+ '" target="_blank" rel="noopener">View<\/a>'\)/, 'the row\'s View');
145
+ assert.match(SCRIPT, /inst\.status === 'active' && addressReady\(inst\)\) acts\.push\('<a class="btn small ghost" href="https:\/\/' \+ esc\(inst\.domain\) \+ '" target="_blank" rel="noopener">Open site →<\/a>'\)/, 'the manage page\'s Open site');
146
+ assert.match(SCRIPT, /live\.instStatus !== 'active' \|\| !live\.addrReady \|\| live\.manifest !== 'done'/, 'first sign-in and the hall step stay locked');
147
+ assert.match(SCRIPT, /live\.addrReady = addressReady\(inst\); renderSteps\(\);/, 'and unlock on the tick the lookup answers');
148
+ assert.equal((SCRIPT.match(/inst\.status !== 'active' \|\| !addressReady\(inst\)\) \{ (vis|join)Runs\(''\); return; \}/g) || []).length, 2,
149
+ 'the browser\'s own manifest reads wait for the name too');
150
+ // the only raw address hrefs left are behind one of those gates
151
+ const raw = SCRIPT.match(/href="https:\/\/' \+ esc\(inst\.domain\)/g) || [];
152
+ assert.equal(raw.length, 4, 'orb, the card link, View and Open site — each gated above or in addressRow');
153
+ });
@@ -67,7 +67,11 @@ test('addressRow branches by state before it trusts the domain — torn down and
67
67
  assert.doesNotMatch(body, /shows an error page/, 'the pre-park promise is gone — teardown removes the proxy block that served it');
68
68
  assert.match(body, /if \(inst\.status === 'error'\) return 'Address <code>/, 'snagged has its own line');
69
69
  assert.match(body, /nothing is answering there yet/);
70
- assert.equal((body.match(/— it answers there once the instance is live\.'/g) || []).length, 1, 'the standup promise survives only for queued / standing-up');
70
+ // task 1004505: a queued or standing-up project no longer names its address — the
71
+ // name is held, with the link, until the platform's own lookup has seen it answer
72
+ assert.doesNotMatch(body, /it answers there once the instance is live/, 'the old pre-live line named an address that may not resolve yet');
73
+ assert.match(body, /if \(!addressReady\(inst\)\) \{/, 'an active project without a sighting is held too');
74
+ assert.match(SCRIPT, /var ADDRESS_SETTING_UP = 'Your address is being set up — it shows here, with a link, as soon as it answers\.';/);
71
75
  assert.match(body, /We keep checking every few minutes/, 'an unreachable project says what the platform is doing');
72
76
  });
73
77
 
@@ -185,7 +189,7 @@ test('First sign-in and Enter your builders hall unlock only when the project an
185
189
  const first = fn('renderStepFirstSignIn');
186
190
  const hall = fn('renderStepEnterHall');
187
191
  for (const [name, body] of [['first-sign-in', first], ['enter-hall', hall]]) {
188
- assert.match(body, /live\.instStatus !== 'active' \|\| live\.manifest !== 'done'/, `${name} gates on both`);
192
+ assert.match(body, /live\.instStatus !== 'active' \|\| !live\.addrReady \|\| live\.manifest !== 'done'/, `${name} gates on status, a resolving address (task 1004505) and sign-in`);
189
193
  assert.match(body, /class="slocked"/, `${name} has a locked rendering`);
190
194
  }
191
195
  assert.match(first, /Unlocks once your project is answering and sign-in above is wired/);
@@ -357,8 +361,8 @@ test('the row carries two doors and nothing that acts; the address field is a se
357
361
  assert.doesNotMatch(card, /<button/, 'no sender survives on a row');
358
362
  assert.doesNotMatch(card, /attachFold|<div class="attach">/, 'and no field either');
359
363
  assert.match(card, /'<div class="actions">' \+ acts\.join\(''\)/, 'one doors strip, always rendered');
360
- assert.match(card, /inst\.status === 'active' && inst\.domain\) acts\.push\([\s\S]{0,180}>View<\/a>'\)/,
361
- 'View only where the project is live and has an address to open');
364
+ assert.match(card, /inst\.status === 'active' && addressReady\(inst\)\) acts\.push\([\s\S]{0,180}>View<\/a>'\)/,
365
+ 'View only where the project is live and its address answers (task 1004505)');
362
366
  assert.match(card, /acts\.push\('<a class="btn small ghost" data-manage="' \+ iid \+ '" href="\?view=manage&id=' \+ iid \+ '">Manage<\/a>'\);/,
363
367
  'Manage on every row, as a real link');
364
368
  // the section on the manage page: the heading is the disclosure, so the field
@@ -730,7 +734,7 @@ test('the done panel re-learns the GitHub-App leg from the instance read, yieldi
730
734
  assert.match(sp, /if \(ga\.app_slug\) live\.appSlug = ga\.app_slug;\n\s*live\.manifest = seeded;/);
731
735
  assert.match(sp, /if \(seedChanged\) \{ rememberManifest\(undefined, live\.manifest, live\.appSlug \|\| undefined\); renderSteps\(\); \}/, 'change-only: an identical tick neither rewrites the bridge nor re-renders');
732
736
  const seedAt = sp.indexOf('var ga = data.github_app;');
733
- const statusAt = sp.indexOf("if (inst && inst.status !== live.instStatus)");
737
+ const statusAt = sp.indexOf("if (inst && (inst.status !== live.instStatus || addressReady(inst) !== live.addrReady))");
734
738
  assert.ok(seedAt > 0 && seedAt < statusAt, 'the manifest seed lands before the status re-render, so one renderSteps() paints both');
735
739
  /* the bridge moved from enter() to enterCreated() when task 1003503 split the
736
740
  created-record branch out — same seed, same place in the same flow */
@@ -0,0 +1,171 @@
1
+ // tests/provision_dns_first.mjs
2
+ //
3
+ // A new project's address never gets stuck on "site can't be reached" (task 1004505,
4
+ // BV2.PS17). Live on 2026-10-01: a project took 3.6 minutes to stand up, its DNS
5
+ // record was created partway through, and the founder's first click — offered early
6
+ // by the hub — reached a home resolver before the record existed. That resolver cached
7
+ // the "not found" for the zone's negative TTL (1800s on cloudbongos.com), so the
8
+ // project stayed unreachable on that network for up to half an hour after it was live.
9
+ //
10
+ // What these tests pin, at unit altitude (a live project cannot be created here):
11
+ // • the runner's DNS UPSERT is its FIRST mutation — before the status flips to
12
+ // 'provisioning', before any command on the box, before any file is written, and
13
+ // so before anything that can make the address visible or reachable;
14
+ // • a DNS failure stops the run before anything else was touched;
15
+ // • at the end of the standup the runner asks its own resolver once and records the
16
+ // answer (dns_resolves), before the row reads 'active' — only for a record it made.
17
+ // The hub half (no address link until dns_resolves) is tests/projects_hub_dns_ready.mjs.
18
+ //
19
+ // Hermetic: recorded executors, a fake db, no shell, no network.
20
+ //
21
+ // Run: node tests/provision_dns_first.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ // The app-user preflight reads the account this runs as; pin a valid one so the run
28
+ // reaches the steps under test on any machine.
29
+ process.env.PROVISION_APP_USER = 'bongos';
30
+ const P = require('../scripts/gds/provision.js');
31
+
32
+ let passed = 0, failed = 0;
33
+ async function ta(name, fn) {
34
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+
38
+ const HOOK = '/opt/infra/box-dns-cloudflare.sh';
39
+ const MEMINFO = 'MemTotal: 2015216 kB\nMemAvailable: 1135180 kB\n';
40
+ const lantern = { id: 7, slug: 'lantern', hosting_shape: 'co-tenant', status: 'requested', owner_builder_id: 3, port: 3003, db_name: 'lantern', domain: 'lantern.cloudbongos.com', target_ref: null };
41
+
42
+ // One ordered ledger across both executors, the file writes and the status writes.
43
+ function rig({ apply = true, hookThrows = false, answers = true } = {}) {
44
+ const events = [], logs = [];
45
+ const deps = {
46
+ apply, log: (m) => logs.push(String(m)),
47
+ db: { query: async (sql) => (/count\(\*\)/.test(sql) ? { rows: [{ n: 1 }] } : { rows: [] }) },
48
+ provisioning: {
49
+ setInstanceStatus: async (_db, _id, status) => { events.push({ where: 'status', status }); },
50
+ recordEvent: async () => {},
51
+ recordDnsNote: async () => {},
52
+ lookupAnswers: async (host) => { events.push({ where: 'lookup', host }); return answers; },
53
+ recordDnsResolved: async (_db, id) => { events.push({ where: 'resolved', id }); },
54
+ },
55
+ exec: (cmd, opts) => { events.push({ where: 'box', cmd, env: opts && opts.env }); return { ok: true, stdout: /meminfo/.test(cmd) ? MEMINFO : '' }; },
56
+ controlExec: (cmd, opts) => {
57
+ events.push({ where: 'control', cmd, env: opts && opts.env });
58
+ if (hookThrows && cmd === HOOK) throw new Error('zone not reachable');
59
+ return { ok: true, stdout: '' };
60
+ },
61
+ writeFile: (p) => { events.push({ where: 'write', path: p }); return { ok: true }; },
62
+ controlWriteFile: (p) => { events.push({ where: 'write', path: p }); return { ok: true }; },
63
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
64
+ checkCaddyWiring: () => null,
65
+ sleep: async () => {},
66
+ };
67
+ const at = (pred) => events.findIndex(pred);
68
+ const hookAt = () => at((e) => e.cmd === HOOK);
69
+ return { deps, events, logs, at, hookAt };
70
+ }
71
+
72
+ async function withHook(hook, fn) {
73
+ const saved = P.CONFIG.dnsHook, savedIp = P.CONFIG.publicIp;
74
+ P.CONFIG.dnsHook = hook;
75
+ P.CONFIG.publicIp = '203.0.113.9';
76
+ try { return await fn(); } finally { P.CONFIG.dnsHook = saved; P.CONFIG.publicIp = savedIp; }
77
+ }
78
+
79
+ console.log('the DNS record comes first:');
80
+
81
+ await ta('the UPSERT runs before the status flips, before any box command that changes it, and before any file is written', async () => {
82
+ await withHook(HOOK, async () => {
83
+ const r = rig();
84
+ const res = await P.provisionInstance(lantern, r.deps);
85
+ assert.equal(res.ok, true, JSON.stringify(res));
86
+ const hook = r.hookAt();
87
+ assert.ok(hook >= 0, 'the record was created');
88
+ assert.deepEqual(r.events[hook].env, { BOX_HOSTNAME: 'lantern.cloudbongos.com', BOX_IP: '203.0.113.9', CLOUDFLARE_ZONE: 'cloudbongos.com', BOX_PROXIED: '0' });
89
+ assert.equal(r.events[hook].where, 'control', 'the Cloudflare token never leaves the control plane');
90
+ const provisioning = r.at((e) => e.status === 'provisioning');
91
+ const active = r.at((e) => e.status === 'active');
92
+ assert.ok(hook < provisioning, `DNS (${hook}) before the provisioning flip (${provisioning})`);
93
+ assert.ok(provisioning < active, 'and the run still ends active');
94
+ const firstWrite = r.at((e) => e.where === 'write');
95
+ assert.ok(firstWrite > hook, 'web.env and the Caddy snippet are written after the record exists');
96
+ const caddyWrite = r.at((e) => e.where === 'write' && /lantern\.caddy$/.test(e.path));
97
+ const reload = r.at((e) => /systemctl reload caddy/.test(e.cmd || ''));
98
+ assert.ok(caddyWrite > hook && reload > hook, 'the address is made reachable only after its record exists');
99
+ // Every box command before the record is a read-only preflight question.
100
+ for (const e of r.events.slice(0, hook)) {
101
+ assert.ok((e.where === 'box' || e.where === 'control') && !/createdb|useradd|systemctl (enable|reload|restart)|install /.test(e.cmd), `nothing mutating before DNS: ${JSON.stringify(e)}`);
102
+ }
103
+ });
104
+ });
105
+
106
+ await ta('a DNS failure stops the run before the status flips and before anything is written', async () => {
107
+ await withHook(HOOK, async () => {
108
+ const r = rig({ hookThrows: true });
109
+ await assert.rejects(() => P.provisionInstance(lantern, r.deps), /zone not reachable/);
110
+ assert.equal(r.events.filter((e) => e.where === 'status').length, 0, 'no status write');
111
+ assert.equal(r.events.filter((e) => e.where === 'write').length, 0, 'no file written');
112
+ assert.ok(!r.events.some((e) => /createdb|systemctl/.test(e.cmd || '')), 'no database, no unit');
113
+ });
114
+ });
115
+
116
+ await ta('dry run: the plan names the DNS step before the server, database and Caddy steps', async () => {
117
+ await withHook(HOOK, async () => {
118
+ const r = rig({ apply: false });
119
+ await P.provisionInstance(lantern, r.deps);
120
+ const idx = (re) => r.logs.findIndex((l) => re.test(l));
121
+ const dns = idx(/\[dns\] upsert A lantern\.cloudbongos\.com/);
122
+ assert.ok(dns >= 0, r.logs.join('\n'));
123
+ for (const re of [/\[server\]/, /\[db\] create/, /\[config\]/, /\[caddy\]/, /\[healthz\]/]) {
124
+ assert.ok(idx(re) > dns, `${re} after the DNS step:\n${r.logs.join('\n')}`);
125
+ }
126
+ });
127
+ });
128
+
129
+ console.log('\nthe runner records whether its own lookup answers:');
130
+
131
+ await ta('an answering name is recorded as resolving BEFORE the row reads active', async () => {
132
+ await withHook(HOOK, async () => {
133
+ const r = rig({ answers: true });
134
+ await P.provisionInstance(lantern, r.deps);
135
+ const lookup = r.at((e) => e.where === 'lookup');
136
+ const resolved = r.at((e) => e.where === 'resolved');
137
+ const active = r.at((e) => e.status === 'active');
138
+ assert.ok(lookup > r.hookAt(), 'the lookup comes long after the record went in');
139
+ assert.ok(resolved > lookup && resolved < active, `resolved (${resolved}) before active (${active})`);
140
+ assert.equal(r.events[lookup].host, 'lantern.cloudbongos.com');
141
+ });
142
+ });
143
+
144
+ await ta('a name that does not answer yet is NOT recorded — the hub keeps holding the link', async () => {
145
+ await withHook(HOOK, async () => {
146
+ const r = rig({ answers: false });
147
+ const res = await P.provisionInstance(lantern, r.deps);
148
+ assert.equal(res.ok, true);
149
+ assert.equal(r.at((e) => e.where === 'resolved'), -1);
150
+ });
151
+ });
152
+
153
+ await ta('no hook (manual DNS): no lookup is made, so the control plane never caches a miss for it', async () => {
154
+ await withHook(null, async () => {
155
+ const r = rig();
156
+ await P.provisionInstance(lantern, r.deps);
157
+ assert.equal(r.at((e) => e.where === 'lookup'), -1);
158
+ assert.equal(r.hookAt(), -1);
159
+ });
160
+ });
161
+
162
+ await ta('dry run: no lookup, nothing recorded', async () => {
163
+ await withHook(HOOK, async () => {
164
+ const r = rig({ apply: false });
165
+ await P.provisionInstance(lantern, r.deps);
166
+ assert.equal(r.at((e) => e.where === 'lookup'), -1);
167
+ });
168
+ });
169
+
170
+ console.log(`\nprovision_dns_first: ${passed} passed, ${failed} failed`);
171
+ process.exit(failed ? 1 : 0);
@@ -75,6 +75,9 @@ ${NEUTRAL_PREFIX}_NIGHT_TINT=on
75
75
  ${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE=
76
76
  ${NEUTRAL_PREFIX}_LOOK_ACCENT=
77
77
  ${NEUTRAL_PREFIX}_LOGO_URL=
78
+ ${NEUTRAL_PREFIX}_FOUNDING_BIRTH=
79
+ ${NEUTRAL_PREFIX}_DEMO_PEOPLE=
80
+ ${NEUTRAL_PREFIX}_DEMO_HOURS=
78
81
  `;
79
82
 
80
83
  t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
@@ -142,6 +145,8 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
142
145
  // custom accent and the logo address as companions, empty when there is none
143
146
  [`${NEUTRAL_PREFIX}_LOOK`]: 'own', [`${NEUTRAL_PREFIX}_NIGHT_TINT`]: 'on',
144
147
  [`${NEUTRAL_PREFIX}_LOOK_ACCENT`]: '', [`${NEUTRAL_PREFIX}_LOGO_URL`]: '',
148
+ // the birth marker and a demo's box (task 1004506), empty on an un-stamped row
149
+ [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
145
150
  });
146
151
  const body = P.webEnvBody(inst, {});
147
152
  for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
@@ -242,7 +247,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
242
247
  assert.equal(r.ok, true);
243
248
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
244
249
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
245
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
250
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
246
251
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
247
252
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
248
253
  });
@@ -124,6 +124,9 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
124
124
  // The look and its night tint (ADR 0358, task 1004421), with their two companions:
125
125
  // a custom look's accent and the logo address, empty when there is none.
126
126
  CLOUDBONGOS_LOOK: 'own', CLOUDBONGOS_NIGHT_TINT: 'on', CLOUDBONGOS_LOOK_ACCENT: '', CLOUDBONGOS_LOGO_URL: '',
127
+ // The birth marker and a demo's box (task 1004506): empty on a row the create
128
+ // route never stamped, which is every row made before it shipped (spec D8).
129
+ CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
127
130
  });
128
131
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
129
132
  // of the comparison is that the standup lines carry exactly the same key set.
@@ -132,9 +135,55 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
132
135
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
133
136
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
134
137
  X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
138
+ X_FOUNDING_BIRTH: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '',
135
139
  }, 'defaults, explicitly');
136
140
  });
137
141
 
142
+ console.log('\nThe birth marker rides the same transport (task 1004506):');
143
+
144
+ t('a stamped row carries its birth; only a stamped DEMO carries the box', () => {
145
+ const at = '2026-10-01T12:00:00.000Z';
146
+ const real = provisioning.settingsEnvVars({ settings: { founding_birth: at } }, 'X');
147
+ assert.equal(real.X_FOUNDING_BIRTH, at);
148
+ assert.equal(real.X_DEMO_PEOPLE, '', 'a real project has no demo box');
149
+ assert.equal(real.X_DEMO_HOURS, '');
150
+ const bornDemo = provisioning.settingsEnvVars({ settings: { founding_birth: at }, demo: { people: 3, hours_each: 4, outcome: null } }, 'X');
151
+ assert.deepEqual([bornDemo.X_FOUNDING_BIRTH, bornDemo.X_DEMO_PEOPLE, bornDemo.X_DEMO_HOURS], [at, '3', '4']);
152
+ const oldDemo = provisioning.settingsEnvVars({ settings: {}, demo: { people: 3, hours_each: 4 } }, 'X');
153
+ assert.deepEqual([oldDemo.X_FOUNDING_BIRTH, oldDemo.X_DEMO_PEOPLE, oldDemo.X_DEMO_HOURS], ['', '', ''],
154
+ 'a demo made before the birth shipped is not born into anything, box and all');
155
+ assert.equal(provisioning.settingsEnvVars({ settings: { founding_birth: 'yesterday' } }, 'X').X_FOUNDING_BIRTH, '', 'a mangled stamp reads as none');
156
+ });
157
+
158
+ t('the stamp can neither leak as a setting nor be written by a client patch', () => {
159
+ const { BIRTH_KEY } = require('../modules/provisioning/birth.js');
160
+ assert.ok(!(BIRTH_KEY in provisioning.SETTINGS_VOCAB));
161
+ assert.ok(!(BIRTH_KEY in provisioning.PLANET_VOCAB));
162
+ assert.ok(!(BIRTH_KEY in provisioning.effectiveSettings({ settings: { [BIRTH_KEY]: '2026-10-01T00:00:00.000Z' } })));
163
+ });
164
+
165
+ t('stampBirth writes the row it is given, once: an existing stamp is kept', () => {
166
+ const { stampBirth, BIRTH_KEY } = require('../modules/provisioning/birth.js');
167
+ const seen = [];
168
+ // This harness is synchronous; the statement is issued before stampBirth's first await.
169
+ stampBirth({ query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } }, 41, new Date('2026-10-01T00:00:00.000Z'));
170
+ assert.equal(seen.length, 1);
171
+ assert.match(seen[0].sql, /UPDATE provisioning_instances/);
172
+ assert.match(seen[0].sql, /WHERE id = \$1 AND NOT \(COALESCE\(settings, '\{\}'::jsonb\) \? \$2::text\)/, 'write-once');
173
+ assert.deepEqual(seen[0].params, [41, BIRTH_KEY, '2026-10-01T00:00:00.000Z']);
174
+ });
175
+
176
+ t('the instance reads the marker into founding.*, and never publishes it to the client', () => {
177
+ const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
178
+ "const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({f:r.founding||null,c:'founding' in b.clientBranding(r)}))"],
179
+ { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env } }));
180
+ assert.deepEqual(read({}), { f: null, c: false }, 'no env, no founding block');
181
+ assert.deepEqual(read({ CLOUDBONGOS_FOUNDING_BIRTH: '2026-10-01T00:00:00.000Z', CLOUDBONGOS_DEMO_PEOPLE: '3', CLOUDBONGOS_DEMO_HOURS: '4' }),
182
+ { f: { birth: '2026-10-01T00:00:00.000Z', demoPeople: '3', demoHours: '4' }, c: false });
183
+ assert.deepEqual(read({ CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '' }), { f: null, c: false },
184
+ 'the empty lines every un-born instance carries resolve to nothing');
185
+ });
186
+
138
187
  console.log('\nThe artist gate rides the same transport (ADR 0241, task 1003575):');
139
188
 
140
189
  t('the stamp is carried only when the gate was actually RAISED, and it is the row\'s own value', () => {