@bongos/core 1.20.83 → 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.
- package/.bongos-core.json +151 -61
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +153 -4
- package/docs/api-reference.md +5 -3
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +12 -12
- package/docs/design/project-startup-direction.md +63 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +6 -0
- package/docs/page-inventory.json +8 -3
- package/docs/page-readings.json +382 -373
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_269_session_transcripts.sql +35 -0
- package/modules/hall-ui/public/genesis-home.js +17 -3
- package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
- package/modules/lifecycle/workflow-dispatch.js +21 -11
- package/modules/npm-release/release.js +10 -1
- package/modules/onboarding/founding-birth.js +116 -0
- package/modules/onboarding/genesis-home.js +72 -5
- package/modules/onboarding/port.js +9 -2
- package/modules/onboarding/routes/onboarding.js +8 -1
- package/modules/provisioning/birth.js +64 -0
- package/modules/provisioning/dns-resolves.js +54 -0
- package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
- package/modules/provisioning/pollers/liveness-sweep.js +5 -0
- package/modules/provisioning/provisioning.js +7 -6
- package/modules/provisioning/routes/provisioning.js +6 -2
- package/modules/provisioning/tests/liveness.mjs +48 -0
- package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
- package/modules/public-landing/public/projects.html +40 -15
- package/modules/sessions/db.js +58 -0
- package/modules/sessions/routes/sessions.js +97 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
- package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +35 -0
- package/scripts/gds/provision.js +77 -53
- package/scripts/gds/session-digest.js +11 -3
- package/scripts/gds/session-transcript-build.js +155 -0
- package/scripts/gds/session-transcript-upload.js +29 -0
- package/scripts/gds/ship-session-upload.js +6 -1
- package/scripts/gds/upgrade-commit-identity.js +32 -0
- package/scripts/gds/upgrade.js +1 -1
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/founding_birth.mjs +200 -0
- package/tests/founding_mode.mjs +3 -1
- package/tests/genesis_home.mjs +71 -2
- package/tests/hub_map_orb_states.mjs +5 -1
- package/tests/npm_release_release.mjs +37 -2
- package/tests/projects_hub.mjs +5 -3
- package/tests/projects_hub_dns_ready.mjs +153 -0
- package/tests/projects_hub_pre_uat.mjs +9 -5
- package/tests/provision_dns_first.mjs +171 -0
- package/tests/provision_settings_apply.mjs +6 -1
- package/tests/provisioning_settings_env.mjs +49 -0
- package/tests/session_transcript.mjs +273 -0
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/upgrade_commit_identity.mjs +89 -0
- package/tests/wizard_demo.mjs +29 -3
|
@@ -99,6 +99,41 @@ test('an unreadable registry and a missing dispatcher both release nothing, and
|
|
|
99
99
|
assert.equal((await requestRelease({ version: '1.20.73', registry: REG, dispatch: null })).body.error.code, 'dispatch_unavailable');
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
// ── the refusal names the credential the server actually uses (task 1004518) ─────────
|
|
103
|
+
|
|
104
|
+
test('a server on the access token is told to edit the ACCESS TOKEN', async () => {
|
|
105
|
+
const saved = process.env.GITHUB_PUSH_TOKEN;
|
|
106
|
+
process.env.GITHUB_PUSH_TOKEN = 'PAT';
|
|
107
|
+
try {
|
|
108
|
+
const d = await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { appToken: async () => null, repoInfo: REPO, fetchImpl: async () => ({ ok: false, status: 403 }) });
|
|
109
|
+
assert.equal(d.credential, 'token');
|
|
110
|
+
const r = await requestRelease({ version: '1.20.73', registry: REG, dispatch: async () => d });
|
|
111
|
+
assert.match(r.body.error.message, /access token \(GITHUB_PUSH_TOKEN/);
|
|
112
|
+
assert.doesNotMatch(r.body.error.message, /GitHub App/);
|
|
113
|
+
} finally {
|
|
114
|
+
if (saved === undefined) delete process.env.GITHUB_PUSH_TOKEN; else process.env.GITHUB_PUSH_TOKEN = saved;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a server on a GitHub App is told to edit the APP — the App wins over a token, as resolveToken orders them', async () => {
|
|
119
|
+
const saved = process.env.GITHUB_PUSH_TOKEN;
|
|
120
|
+
process.env.GITHUB_PUSH_TOKEN = 'PAT';
|
|
121
|
+
try {
|
|
122
|
+
let auth = null;
|
|
123
|
+
const d = await gp.dispatchWorkflow({ workflow: 'publish.yml' }, {
|
|
124
|
+
appToken: async () => 'APPTOK', repoInfo: REPO,
|
|
125
|
+
fetchImpl: async (_u, opts) => { auth = opts.headers.Authorization || opts.headers.authorization; return { ok: false, status: 403 }; },
|
|
126
|
+
});
|
|
127
|
+
assert.equal(d.credential, 'app');
|
|
128
|
+
assert.match(String(auth), /APPTOK/, 'the App token is the one sent');
|
|
129
|
+
const r = await requestRelease({ version: '1.20.73', registry: REG, dispatch: async () => d });
|
|
130
|
+
assert.match(r.body.error.message, /the server's GitHub App needs/);
|
|
131
|
+
assert.doesNotMatch(r.body.error.message, /access token/);
|
|
132
|
+
} finally {
|
|
133
|
+
if (saved === undefined) delete process.env.GITHUB_PUSH_TOKEN; else process.env.GITHUB_PUSH_TOKEN = saved;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
102
137
|
test('GitHub refusing for want of Actions: write is named as exactly that', async () => {
|
|
103
138
|
const r = await requestRelease({ version: '1.20.73', registry: REG, dispatch: async () => ({ ok: false, code: 'REFUSED', status: 403 }) });
|
|
104
139
|
assert.equal(r.status, 502);
|
|
@@ -113,7 +148,7 @@ test('dispatchWorkflow POSTs ref + inputs to the workflow\'s dispatches endpoint
|
|
|
113
148
|
let captured = null;
|
|
114
149
|
const fetchImpl = async (url, opts) => { captured = { url, opts, body: JSON.parse(opts.body) }; return { ok: false, status: 204 }; };
|
|
115
150
|
const r = await gp.dispatchWorkflow({ workflow: 'publish.yml', ref: 'main', inputs: { release_version: '1.20.73' } }, { token: 'T', repoInfo: REPO, fetchImpl });
|
|
116
|
-
assert.deepEqual(r, { ok: true });
|
|
151
|
+
assert.deepEqual(r, { ok: true, credential: 'token' });
|
|
117
152
|
assert.equal(captured.url, 'https://api.github.com/repos/someowner/somerepo/actions/workflows/publish.yml/dispatches');
|
|
118
153
|
assert.equal(captured.opts.method, 'POST');
|
|
119
154
|
assert.deepEqual(captured.body, { ref: 'main', inputs: { release_version: '1.20.73' } });
|
|
@@ -135,7 +170,7 @@ test('dispatchWorkflow: no credential is UNCONFIGURED; a 403 is REFUSED with its
|
|
|
135
170
|
} finally {
|
|
136
171
|
if (saved !== undefined) process.env.GITHUB_PUSH_TOKEN = saved;
|
|
137
172
|
}
|
|
138
|
-
assert.deepEqual(await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { token: 'T', repoInfo: REPO, fetchImpl: async () => ({ ok: false, status: 403 }) }), { ok: false, code: 'REFUSED', status: 403 });
|
|
173
|
+
assert.deepEqual(await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { token: 'T', repoInfo: REPO, fetchImpl: async () => ({ ok: false, status: 403 }) }), { ok: false, code: 'REFUSED', status: 403, credential: 'token' });
|
|
139
174
|
assert.equal((await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { token: 'T', repoInfo: REPO, fetchImpl: async () => { throw new Error('down'); } })).code, 'UNREACHABLE');
|
|
140
175
|
});
|
|
141
176
|
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -765,7 +765,9 @@ test('the table is in the world: a masthead, then ledger rows — no panel, no s
|
|
|
765
765
|
assert.match(COSMOS, /\.tbl \.tblRow:hover\{border-top-color:var\(--ink-faint\);\}/, 'hover is the hairline taking ink, not an accent tint');
|
|
766
766
|
assert.match(COSMOS, /\.rowGo\{[^}]*border:1px solid var\(--rule\);border-radius:var\(--radius-pill\);/, 'the row\'s door is the mine row\'s hairline pill');
|
|
767
767
|
assert.match(HUB, /<a class="rowGo" href="\/">view<\/a>/, 'the self row\'s door');
|
|
768
|
-
assert.match(SCRIPT, /'<a class="rowGo" href="' \+ esc\(
|
|
768
|
+
assert.match(SCRIPT, /'<a class="rowGo" href="' \+ esc\(siteHref\(p\)\)/, 'the generated rows\' door');
|
|
769
|
+
assert.match(SCRIPT, /function siteHref\(p\) \{ return p\._inst && !addressReady\(p\._inst\) \? '' : safeHttpUrl\(p\.origin\); \}/,
|
|
770
|
+
'scheme-checked, and none for your own project until its address answers (task 1004505)');
|
|
769
771
|
assert.match(COSMOS, /\.mastObj\{display:block;--dia:clamp\(96px,10vw,132px\);[^}]*background-image:var\(--plate-set-cut\);/, 'the object is the set\'s small bead by token, in both modes');
|
|
770
772
|
assert.doesNotMatch(COSMOS, /mix-blend-mode:var\(--mast/, 'the cutout needs no blend — the view wrap is a stacking context');
|
|
771
773
|
});
|
|
@@ -1426,7 +1428,7 @@ test('the save sends the choice and shows the platform’s receipt verbatim —
|
|
|
1426
1428
|
test('what the project RUNS comes only from its own manifest, and every answer is said', () => {
|
|
1427
1429
|
const runs = fnHub('loadVisRuns');
|
|
1428
1430
|
const manifest = fnHub('ensureManifest');
|
|
1429
|
-
assert.match(runs, /inst\.status !== 'active' \|\| !inst
|
|
1431
|
+
assert.match(runs, /inst\.status !== 'active' \|\| !addressReady\(inst\)/, 'only a live project whose address answers can be asked (task 1004505)');
|
|
1430
1432
|
assert.match(manifest, /'https:\/\/' \+ domain \+ '\/api\/bongos\/instance'/,
|
|
1431
1433
|
'the same public GET /instance the platform’s runner reads after a restart');
|
|
1432
1434
|
assert.doesNotMatch(runs, /fetch\(/, 'the card fires no GET of its own — one manifest read serves the pair (task 1003554)');
|
|
@@ -1631,7 +1633,7 @@ test('the joinability save sends the one key and shows the platform’s receipt
|
|
|
1631
1633
|
|
|
1632
1634
|
test('what the project RUNS for joinability comes only from its own manifest, and every answer is said', () => {
|
|
1633
1635
|
const runs = fnHub('loadJoinRuns');
|
|
1634
|
-
assert.match(runs, /inst\.status !== 'active' \|\| !inst
|
|
1636
|
+
assert.match(runs, /inst\.status !== 'active' \|\| !addressReady\(inst\)/, 'only a live project whose address answers can be asked (task 1004505)');
|
|
1635
1637
|
assert.match(runs, /ensureManifest\(inst\.domain\)/, 'the same public GET /instance the runner reads, through the shared reader');
|
|
1636
1638
|
assert.doesNotMatch(runs, /fetch\(/, 'the second card of the pair adds no round trip of its own (task 1003554)');
|
|
1637
1639
|
assert.match(runs, /p && p\.joinability/, 'the manifest key is brand.project.joinability');
|
|
@@ -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
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
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
|
|
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
|
|
361
|
-
'View only where the project is live and
|
|
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', () => {
|