@bongos/core 1.19.400 → 1.19.402
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 +48 -38
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0200-re-point-a-bring-your-own-domain.md +105 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +3 -2
- package/docs/copy-inventory.md +233 -232
- package/docs/copy-registry.json +293 -284
- package/docs/design/projects-hub-direction-v2.md +38 -0
- package/docs/recipes/ui-look-before-you-ship.md +1 -1
- package/modules/provisioning/migrations/provisioning_012_domain_repoint.sql +35 -0
- package/modules/provisioning/provisioning.js +68 -8
- package/modules/provisioning/routes/provisioning.js +50 -23
- package/modules/ui-design/kit/lib.js +6 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +90 -35
- package/public-landing/index.html +13 -15
- package/public-landing/index.probes.json +8 -5
- package/public-landing/projects.html +49 -20
- package/public-landing/projects.probes.json +17 -10
- package/public-landing/projects.states.json +1 -1
- package/scripts/gds/provision-net.js +33 -0
- package/scripts/gds/provision-units.js +18 -0
- package/scripts/gds/provision.js +97 -5
- package/src/module-api.js +5 -1
- package/tests/landing_page.mjs +8 -4
- package/tests/projects_hub.mjs +28 -1
- package/tests/projects_hub_pre_uat.mjs +28 -2
- package/tests/provision.mjs +185 -12
- package/tests/provisioning_default_domain.mjs +5 -3
- package/tests/provisioning_domain_attach.mjs +166 -17
- package/tests/public_landing_projects.mjs +5 -1
- package/tests/ui_design_kit.mjs +5 -4
package/tests/landing_page.mjs
CHANGED
|
@@ -475,14 +475,17 @@ test('the bar is markup: the mark, the Projects trigger, the mode glyph, the sig
|
|
|
475
475
|
assert.match(PAGE_CSS, /\.topbar\{position:relative;z-index:50;height:var\(--top-h\);display:flex;align-items:center;gap:22px;\s*padding:0 26px;background:var\(--bg\);border-bottom:1px solid var\(--rule\)\}/, 'the page ground closed by one hairline, z-index unified with the hub');
|
|
476
476
|
});
|
|
477
477
|
|
|
478
|
-
test('the Projects band deep-links the hub\'s views; About and Docs
|
|
478
|
+
test('the Projects band deep-links the hub\'s views; About and Docs are footer links, not band items; no item is current here', () => {
|
|
479
479
|
const body = HTML.split('<body>')[1].split('<script>')[0];
|
|
480
480
|
assert.match(body, /<div class="menuBand" id="menuProjects" data-menu="projects" hidden>/);
|
|
481
481
|
assert.match(body, /<a class="bandT" href="\/projects\?view=map">Sky<\/a>/);
|
|
482
482
|
assert.match(body, /<a class="bandT" href="\/projects\?view=map&sub=table">Table<\/a>/, 'the Table item lands on the table (the hub reads sub=table on boot)');
|
|
483
483
|
assert.match(body, /<li class="bandCell" id="mineCell" hidden><a class="bandT" href="\/projects\?view=mine">Your projects<\/a>/, 'member-gated: hidden until the session resolves');
|
|
484
484
|
assert.match(body, /<a class="bandT" href="\/projects\?view=new">New project<\/a>/);
|
|
485
|
-
|
|
485
|
+
// About and Docs are site pages, not project views (owner, 2026-08-28; task 1003332):
|
|
486
|
+
// they left the band for the footer row, beside Privacy / Terms / Contact
|
|
487
|
+
assert.doesNotMatch(body, /bandMore|<a class="bandT" href="#about">|<a class="bandT" href="\/docs">/, 'About and Docs are not in the Projects band');
|
|
488
|
+
assert.match(body, /<a href="#about">About<\/a>\s*<a href="\/docs">Docs<\/a>\s*<a href="\/privacy">Privacy<\/a>/, 'About and Docs open the footer row');
|
|
486
489
|
assert.match(body, /<section class="band" id="about">/, 'About has a real anchor: the lifecycle band');
|
|
487
490
|
assert.doesNotMatch(body.split('<main')[0], /aria-current/, 'the landing is none of those views');
|
|
488
491
|
// the profile band: the person, My projects, the deferred slot, Sign out as the honest anchor
|
|
@@ -496,9 +499,10 @@ test('the Projects band deep-links the hub\'s views; About and Docs sit under a
|
|
|
496
499
|
assert.ok(body.includes('>' + copy + '<'), `"${copy}" is only reachable with scripting on`);
|
|
497
500
|
}
|
|
498
501
|
// the menu container is .menuBand: .band is this page's own content sections
|
|
499
|
-
|
|
502
|
+
// the bands OVERLAY the page (task 1003332): absolute at the bar's height, so the hero never reflows
|
|
503
|
+
assert.match(PAGE_CSS, /\.menuBand\{position:absolute;left:0;right:0;top:var\(--top-h\);z-index:49;background:var\(--bg-card\);border-bottom:1px solid var\(--rule\);/);
|
|
500
504
|
assert.doesNotMatch(PAGE_CSS, /\.band\{[^}]*animation:bandIn/, 'the menu rule never lands on the content bands');
|
|
501
|
-
assert.
|
|
505
|
+
assert.doesNotMatch(PAGE_CSS, /\.bandMore/, 'the About/Docs rung left the sheet with the markup');
|
|
502
506
|
});
|
|
503
507
|
|
|
504
508
|
test('the bands are one disclosure: Escape returns focus, arrows cycle, Tab out closes, one closes the other', () => {
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -49,7 +49,14 @@ test('the Projects band carries the views; Home is gone and the mark is the way
|
|
|
49
49
|
assert.match(HUB, /href="\?view=map" data-nav="map" data-sub="sky" aria-current="page">Sky<\/a>/);
|
|
50
50
|
assert.match(HUB, /href="\?view=map&sub=table" data-nav="map" data-sub="table">Table<\/a>/);
|
|
51
51
|
assert.match(SCRIPT, /get\('sub'\) === 'table'\) setSub\('table'\);/, 'the Table deep link lands on the table');
|
|
52
|
-
|
|
52
|
+
// setView rewrites the URL to ?view=<v>; the boot query must be captured BEFORE it
|
|
53
|
+
// runs or sub=table is gone by the time it is read (task 1003332)
|
|
54
|
+
assert.match(SCRIPT, /var bootQ = new URLSearchParams\(location\.search\);\s*setView\(viewFromUrl\(\)\);[\s\S]{0,400}?if \(bootQ\.get\('sub'\) === 'table'\) setSub\('table'\);/, 'the boot query is read once, before setView');
|
|
55
|
+
// About and Docs are site pages, not project views (owner, 2026-08-28; task 1003332):
|
|
56
|
+
// they left the band for the footer row, beside Privacy / Terms / Contact
|
|
57
|
+
assert.doesNotMatch(HUB, /bandMore|<a class="bandT" href="\/#about">|<a class="bandT" href="\/docs">/, 'About and Docs are not in the Projects band');
|
|
58
|
+
assert.match(HUB, /<nav class="footNav" aria-label="Site"><a href="\/#about">About<\/a><a href="\/docs">Docs<\/a><a href="\/privacy">Privacy<\/a>/, 'About and Docs open the footer row');
|
|
59
|
+
assert.doesNotMatch(COSMOS, /\.bandMore/, 'the About/Docs rung left cosmos with the markup');
|
|
53
60
|
assert.doesNotMatch(HUB, /id="sub-sky"|id="sub-table"|class="toolbar"|class="seg"/, 'the toolbar and its seg are retired');
|
|
54
61
|
assert.doesNotMatch(SCRIPT, /dataset\.nav === 'home'/, 'no dead Home branch in the router');
|
|
55
62
|
assert.match(SCRIPT, /if \(a\.dataset\.sub\) setSub\(a\.dataset\.sub\);/, 'a band item may pick the map sub-view');
|
|
@@ -138,6 +145,10 @@ test('the bands: one disclosure, two instances — Escape returns focus, the car
|
|
|
138
145
|
assert.match(HUB, /<div class="menuBand" id="menuProjects" data-menu="projects" hidden>/);
|
|
139
146
|
assert.match(HUB, /<div class="menuBand" id="menuProfile" data-menu="profile" hidden>/);
|
|
140
147
|
assert.doesNotMatch(HUB, /role="menu"|aria-haspopup/, 'plain links in a disclosure — announced as what ships');
|
|
148
|
+
// the bands OVERLAY the page (owner, 2026-08-28; task 1003332): absolute against the
|
|
149
|
+
// document at the bar's height, so opening one never reflows the sky, the table or the footer
|
|
150
|
+
assert.match(COSMOS, /\.menuBand\{position:absolute;left:0;right:0;top:var\(--top-h\);z-index:49;background:var\(--bg-card\);border-bottom:1px solid var\(--rule\);/, 'a band is a layer under the bar, never a push');
|
|
151
|
+
assert.doesNotMatch(COSMOS, /PUSHES the page/, 'the sheet no longer describes the push-down');
|
|
141
152
|
assert.match(SCRIPT, /function closeMenus\(\) \{[\s\S]*?if \(inside && trig\) trig\.focus\(\);/, 'focus restoration is a property of the close, not of one caller');
|
|
142
153
|
assert.match(SCRIPT, /if \(ev\.key === 'Escape' && !openBand\(\)\) closeCard\(\);/, 'the card yields Escape to an open band');
|
|
143
154
|
assert.match(SCRIPT, /function setView\(v, focus\) \{[\s\S]*?\$\('qWrap'\)\.hidden = \$\('qToggle'\)\.hidden = \(v !== 'map'\);/, 'the search hides off the map in the router');
|
|
@@ -525,6 +536,22 @@ test('no sort control is offered for a column that cannot sort', () => {
|
|
|
525
536
|
}
|
|
526
537
|
});
|
|
527
538
|
|
|
539
|
+
test('the table is in the world: a masthead, then ledger rows — no panel, no shadow, the accent only on the row\'s door (task 1003332)', () => {
|
|
540
|
+
// the owner's pick from three rendered treatments: the view title in the display
|
|
541
|
+
// voice and one chrome object; the title is two words ("every project", the owner cut ", as rows")
|
|
542
|
+
assert.match(HUB, /<div class="tblMast">\s*<h2 class="tblTitle">every project<\/h2>\s*<span class="mastObj" aria-hidden="true"><\/span>\s*<\/div>\s*<table class="tbl">/, 'one masthead ahead of the table');
|
|
543
|
+
assert.doesNotMatch(HUB, /tblMast--t[123]|data-tbl|mastCallouts|mastField/, 'the render round\'s switch and the unpicked mastheads left with the pick');
|
|
544
|
+
assert.match(COSMOS, /\.tbl\{width:100%;border-collapse:collapse;background:none;\}/, 'the rows sit on the page ground — no card');
|
|
545
|
+
assert.doesNotMatch((COSMOS.match(/\.tbl\{[^}]*\}/) || [''])[0], /border-radius|box-shadow/, 'no radius, no shadow on the table');
|
|
546
|
+
assert.match(COSMOS, /\.tbl td\{padding:17px 14px;border-bottom:1px solid var\(--rule-soft\);/, 'ledger rows: hairlines');
|
|
547
|
+
assert.match(COSMOS, /\.tbl tbody tr:hover td\{border-bottom-color:var\(--ink-faint\);\}/, 'hover is the hairline taking ink, not an accent tint');
|
|
548
|
+
assert.match(COSMOS, /\.rowGo\{[^}]*color:var\(--accent-deep\);text-decoration:underline;/, 'the row\'s door is accent text');
|
|
549
|
+
assert.match(HUB, /<a class="rowGo" href="\/">Open →<\/a>/, 'the self row\'s door');
|
|
550
|
+
assert.match(SCRIPT, /'<a class="rowGo" href="' \+ esc\(safeHttpUrl\(p\.origin\)\)/, 'the generated rows\' door');
|
|
551
|
+
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');
|
|
552
|
+
assert.doesNotMatch(COSMOS, /mix-blend-mode:var\(--mast/, 'the cutout needs no blend — the view wrap is a stacking context');
|
|
553
|
+
});
|
|
554
|
+
|
|
528
555
|
test('every live column keeps its styling — .colType included since task 1002772', () => {
|
|
529
556
|
for (const live of ['.tbl .num', '.tbl .tags', '.colTags', '.colType', '.typeTag']) {
|
|
530
557
|
assert.ok(COSMOS.includes(live), `${live} styles a column that renders real data`);
|
|
@@ -335,11 +335,37 @@ test('the F19 re-kick door survives the fold: open, pre-filled, one button', ()
|
|
|
335
335
|
const fold = fn('attachFold');
|
|
336
336
|
assert.match(fold, /var rekick = inst\.status === 'active' && inst\.domain && inst\.error_note;/);
|
|
337
337
|
assert.match(fold, /\(rekick \? ' open' : ''\)/, 'the fold opens on its own for a stalled address');
|
|
338
|
-
assert.match(fold, /\(
|
|
339
|
-
assert.match(fold, /
|
|
338
|
+
assert.match(fold, /\(prefill \? ' value="' \+ esc\(inst\.domain\) \+ '"' : ''\)/, 'pre-filled with the address in play');
|
|
339
|
+
assert.match(fold, /var prefill = rekick \|\| repoint;/, 'a stalled address and a correctable one both arrive in the field');
|
|
340
|
+
assert.match(fold, /rekick \? 'Try the address again' : repoint \? 'Change the address' : 'Use this domain'/, 'and the button says what it does');
|
|
340
341
|
assert.match(fn('canAttachDomain'), /inst\.status === 'active' && inst\.domain && inst\.error_note && !inst\.open_intent\) return true;/, 'the route-side re-kick gate is untouched');
|
|
341
342
|
});
|
|
342
343
|
|
|
344
|
+
test('the correction door: a bring-your-own address can be changed, and the fold says what that costs (task 1003237)', () => {
|
|
345
|
+
const fold = fn('attachFold');
|
|
346
|
+
// the three doors are told apart by what the project is actually addressed as
|
|
347
|
+
assert.match(fold, /var byo = inst\.domain && inst\.domain !== assignedDefaultFor\(inst\);/,
|
|
348
|
+
'the platform-assigned <slug>.<apex> is NOT a correction — moving off it is the upgrade the fold already offered');
|
|
349
|
+
assert.match(fold, /var repoint = !rekick && byo;/, 'a stalled address keeps the F19 door, which is louder');
|
|
350
|
+
assert.match(fold, /'Use a different domain'/, 'the summary names the correction');
|
|
351
|
+
assert.match(fold, /attachNote/, 'and the consequence is stated where the button is');
|
|
352
|
+
assert.match(fold, /won’t use that address any more/, 'letting the old address go is SAID, not discovered');
|
|
353
|
+
// the door itself: an addressed, live project is no longer excluded
|
|
354
|
+
const gate = fn('canAttachDomain');
|
|
355
|
+
assert.doesNotMatch(gate, /!inst\.domain \|\| inst\.domain === assignedDefaultFor\(inst\)/,
|
|
356
|
+
'the "only a domainless or platform-addressed project may attach" clause is what the re-point removes');
|
|
357
|
+
assert.match(gate, /inst\.status === 'requested' \|\| inst\.status === 'error' \|\| inst\.status === 'active'/,
|
|
358
|
+
'state is still the gate — a mid-flight or torn-down project has nothing to point a domain at');
|
|
359
|
+
assert.match(COSMOS, /\.attachNote\{/, 'the note is styled');
|
|
360
|
+
assert.doesNotMatch((COSMOS.match(/\.attachNote[^\n]*/g) || []).join('\n'), /#[0-9a-f]{3,8}\b/i,
|
|
361
|
+
'and paints from tokens, so dark mode needs no twin');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('the retired refusal is gone from the card: domain_already_set can no longer be returned', () => {
|
|
365
|
+
assert.doesNotMatch(HUB, /domain_already_set/,
|
|
366
|
+
'the server stopped sending it (task 1003237) — a branch for it would be unreachable copy');
|
|
367
|
+
});
|
|
368
|
+
|
|
343
369
|
test('a heartbeat re-render keeps an open fold open and a half-typed domain typed', () => {
|
|
344
370
|
const card = fn('renderCard');
|
|
345
371
|
assert.match(card, /var keepOpen = !!\(oldFold && oldFold\.open\);/);
|
package/tests/provision.mjs
CHANGED
|
@@ -965,18 +965,21 @@ console.log('\nDNS UPSERT composition (P4, CONFIG override):');
|
|
|
965
965
|
|
|
966
966
|
// A recorder that also captures each exec's env (so we can assert the DNS hook env).
|
|
967
967
|
function dnsRecorder({ apply = false } = {}) {
|
|
968
|
-
const cmds = [], envs = [];
|
|
968
|
+
const cmds = [], envs = [], cleared = [];
|
|
969
969
|
const deps = {
|
|
970
970
|
apply, log: () => {},
|
|
971
971
|
db: { query: async () => ({ rows: [] }) },
|
|
972
|
-
provisioning: {
|
|
972
|
+
provisioning: {
|
|
973
|
+
setInstanceStatus: async () => {}, recordEvent: async () => {},
|
|
974
|
+
clearReleasedDomains: async (_db, id) => { cleared.push(id); },
|
|
975
|
+
},
|
|
973
976
|
exec: (cmd, opts) => { cmds.push(cmd); envs.push(opts && opts.env); return { ok: true, stdout: '' }; },
|
|
974
977
|
writeFile: () => ({ ok: true }), doApi: null,
|
|
975
978
|
// Preflight (task 1980) stubbed reachable so --apply DNS tests reach the step-5
|
|
976
979
|
// behavior they assert (the preflight itself is covered by its own test below).
|
|
977
980
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
978
981
|
};
|
|
979
|
-
return { deps, cmds, envs };
|
|
982
|
+
return { deps, cmds, envs, cleared };
|
|
980
983
|
}
|
|
981
984
|
|
|
982
985
|
await ta('a co-tenant WITH a domain UPSERTs DNS: the hook is invoked BY PATH with the control-plane env (proxied=false)', async () => {
|
|
@@ -1008,6 +1011,40 @@ await ta('--apply THROWS if a domain has no target IP (task-1180: never report s
|
|
|
1008
1011
|
} finally { P.CONFIG.publicIp = savedIp; P.CONFIG.dnsHook = savedHook; }
|
|
1009
1012
|
});
|
|
1010
1013
|
|
|
1014
|
+
await ta('the PROVISION run drains the release queue too — a re-point on a pre-active project never reaches the attach leg (task 1003237)', async () => {
|
|
1015
|
+
const savedIp = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1016
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1017
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1018
|
+
try {
|
|
1019
|
+
// an `error` row is frequently a PARTIAL standup (ADR 0181 §1): it may already
|
|
1020
|
+
// have UPSERTed the address it is now leaving, and domainAttachInstance no-ops
|
|
1021
|
+
// for anything but 'active', so this run is the only one that can let it go
|
|
1022
|
+
const { deps, cmds, envs, cleared } = dnsRecorder({ apply: true });
|
|
1023
|
+
await P.provisionInstance(
|
|
1024
|
+
{ ...coTenant, domain: 'right.example.com', released_domains: ['typo.example.com'] }, deps);
|
|
1025
|
+
const hooks = cmds.map((c, i) => i).filter((i) => cmds[i] === '/opt/infra/box-dns-cloudflare.sh');
|
|
1026
|
+
assert.equal(hooks.length, 2, 'one UPSERT for the new address, one RELEASE for the old');
|
|
1027
|
+
assert.equal(envs[hooks[0]].BOX_HOSTNAME, 'right.example.com');
|
|
1028
|
+
assert.equal(envs[hooks[0]].BOX_IP, '203.0.113.5');
|
|
1029
|
+
assert.equal(envs[hooks[1]].BOX_HOSTNAME, 'typo.example.com');
|
|
1030
|
+
assert.equal(envs[hooks[1]].BOX_IP, '192.0.2.1', 'parked at the blackhole, not left pointing here');
|
|
1031
|
+
assert.deepEqual(cleared, [coTenant.id], 'and the queue is drained');
|
|
1032
|
+
} finally { P.CONFIG.publicIp = savedIp; P.CONFIG.dnsHook = savedHook; }
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
await ta('a provision with an EMPTY release queue never invokes the hook twice', async () => {
|
|
1036
|
+
const savedIp = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1037
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1038
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1039
|
+
try {
|
|
1040
|
+
const { deps, cmds, cleared } = dnsRecorder({ apply: true });
|
|
1041
|
+
await P.provisionInstance({ ...coTenant, domain: 'right.example.com' }, deps);
|
|
1042
|
+
assert.equal(cmds.filter((c) => c === '/opt/infra/box-dns-cloudflare.sh').length, 1,
|
|
1043
|
+
'the ordinary standup is unchanged — no release, no extra call');
|
|
1044
|
+
assert.deepEqual(cleared, [], 'and an empty queue is never "drained"');
|
|
1045
|
+
} finally { P.CONFIG.publicIp = savedIp; P.CONFIG.dnsHook = savedHook; }
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1011
1048
|
await ta('no domain → DNS is skipped even with a hook + IP configured', async () => {
|
|
1012
1049
|
const savedIp = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1013
1050
|
P.CONFIG.publicIp = '203.0.113.5';
|
|
@@ -1698,21 +1735,45 @@ console.log('\ndomain-attach leg (task 1002692 — give an ACTIVE instance its l
|
|
|
1698
1735
|
// Recorder for the attach leg: captures commands (with env), file writes (with
|
|
1699
1736
|
// bodies), and recorded events. No status writes to capture — domain-attach must
|
|
1700
1737
|
// never touch the instance lifecycle.
|
|
1701
|
-
|
|
1702
|
-
|
|
1738
|
+
// `envBody` is what a `cat <web.env>` returns — the attach leg patches the origin
|
|
1739
|
+
// lines IN PLACE (task 1003237), so a test that cares about the write has to hand
|
|
1740
|
+
// it a file to patch. Default: the shape a manifest-onboarded instance really has
|
|
1741
|
+
// — creds and a federation block the ROW does not know, which a template re-write
|
|
1742
|
+
// would blank (ADR 0190 §2 / idea 1000842). Values are obvious fixtures.
|
|
1743
|
+
const WEB_ENV_ON_BOX = [
|
|
1744
|
+
'PORT=3010',
|
|
1745
|
+
'PGDATABASE=spike',
|
|
1746
|
+
`${NEUTRAL_PREFIX}_PUBLIC_ORIGIN=https://old.example.com`,
|
|
1747
|
+
`${NEUTRAL_PREFIX}_BUILDERS_ORIGIN=https://old.example.com`,
|
|
1748
|
+
`${NEUTRAL_PREFIX}_OAUTH_CLIENT_ID=Iv1.fixture-client-id`,
|
|
1749
|
+
`${NEUTRAL_PREFIX}_OAUTH_CLIENT_SECRET=fixture-secret-placed-by-the-manifest-flow`,
|
|
1750
|
+
'GITHUB_APP_PRIVATE_KEY=fixture-pem-flattened-to-one-line',
|
|
1751
|
+
`${NEUTRAL_PREFIX}_HUB_CLIENT_SECRET=fixture-federation-secret`,
|
|
1752
|
+
'',
|
|
1753
|
+
].join('\n');
|
|
1754
|
+
|
|
1755
|
+
function attachRecorder({ apply = false, envBody = WEB_ENV_ON_BOX } = {}) {
|
|
1756
|
+
const cmds = [], envs = [], writes = [], events = [], dnsNotes = [], cleared = [];
|
|
1703
1757
|
const deps = {
|
|
1704
1758
|
apply, log: () => {},
|
|
1705
1759
|
db: { query: async () => ({ rows: [] }) },
|
|
1706
1760
|
provisioning: {
|
|
1707
1761
|
recordEvent: async (_db, e) => { events.push(e); },
|
|
1708
1762
|
recordDnsNote: async (_db, id, note) => { dnsNotes.push({ id, note }); },
|
|
1763
|
+
clearReleasedDomains: async (_db, id) => { cleared.push(id); },
|
|
1764
|
+
},
|
|
1765
|
+
exec: (cmd, opts = {}) => {
|
|
1766
|
+
cmds.push(cmd); envs.push((opts && opts.env) || null);
|
|
1767
|
+
if (/\bcat\b.*web\.env/.test(cmd)) {
|
|
1768
|
+
return envBody === null ? { ok: false, softFailed: true, stdout: '' } : { ok: true, stdout: envBody };
|
|
1769
|
+
}
|
|
1770
|
+
return { ok: true, stdout: '' };
|
|
1709
1771
|
},
|
|
1710
|
-
exec: (cmd, opts = {}) => { cmds.push(cmd); envs.push((opts && opts.env) || null); return { ok: true, stdout: '' }; },
|
|
1711
1772
|
writeFile: (p, body) => { writes.push({ path: p, body }); return { ok: true }; },
|
|
1712
1773
|
doApi: null,
|
|
1713
1774
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1714
1775
|
};
|
|
1715
|
-
return { deps, cmds, envs, writes, events, dnsNotes };
|
|
1776
|
+
return { deps, cmds, envs, writes, events, dnsNotes, cleared };
|
|
1716
1777
|
}
|
|
1717
1778
|
|
|
1718
1779
|
const activeStandalone = {
|
|
@@ -1720,17 +1781,19 @@ const activeStandalone = {
|
|
|
1720
1781
|
port: 3010, db_name: 'spike', domain: 'spike.cloudbongos.com', target_ref: 'jaxri/spike-harbor',
|
|
1721
1782
|
};
|
|
1722
1783
|
|
|
1723
|
-
await ta('domainAttachInstance(active standalone): env
|
|
1784
|
+
await ta('domainAttachInstance(active standalone, --apply): env patch → restart → DNS upsert → caddy → healthz', async () => {
|
|
1724
1785
|
const savedPub = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1725
1786
|
P.CONFIG.publicIp = '203.0.113.5';
|
|
1726
1787
|
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1727
1788
|
try {
|
|
1728
|
-
const { deps, cmds, envs, writes } = attachRecorder();
|
|
1789
|
+
const { deps, cmds, envs, writes } = attachRecorder({ apply: true });
|
|
1729
1790
|
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1730
1791
|
assert.equal(r.ok, true);
|
|
1731
1792
|
const env = writes.find((w) => /\/spike\/web\.env$/.test(w.path));
|
|
1732
|
-
assert.ok(env, '
|
|
1733
|
-
assert.match(env.body,
|
|
1793
|
+
assert.ok(env, 'writes web.env');
|
|
1794
|
+
assert.match(env.body, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_PUBLIC_ORIGIN=https://spike\\.cloudbongos\\.com`),
|
|
1795
|
+
'the new address reaches the origins');
|
|
1796
|
+
assert.doesNotMatch(env.body, /old\.example\.com/, 'the stale origins are replaced, not appended');
|
|
1734
1797
|
const joined = cmds.join('\n');
|
|
1735
1798
|
assert.match(joined, /systemctl restart spike/, 'RESTART (not reload) so the process re-reads its env');
|
|
1736
1799
|
const di = cmds.indexOf('/opt/infra/box-dns-cloudflare.sh');
|
|
@@ -1745,6 +1808,113 @@ await ta('domainAttachInstance(active standalone): env re-write → restart →
|
|
|
1745
1808
|
} finally { P.CONFIG.publicIp = savedPub; P.CONFIG.dnsHook = savedHook; }
|
|
1746
1809
|
});
|
|
1747
1810
|
|
|
1811
|
+
// -- the web.env hazard this leg used to carry (task 1003237, idea 1000842) -----
|
|
1812
|
+
// domainAttachInstance re-wrote web.env from the TEMPLATE (webEnvBody), which
|
|
1813
|
+
// regenerates the file FROM THE ROW — and the row knows neither the OAuth creds
|
|
1814
|
+
// placeManifestCreds fetched, nor the App private key, nor the federation block.
|
|
1815
|
+
// A first attach was masked by wizard order (address first, GitHub App second).
|
|
1816
|
+
// A RE-POINT is not masked: it runs on a project the founder is already signed
|
|
1817
|
+
// in to, so the same move would break sign-in on the project they just corrected.
|
|
1818
|
+
|
|
1819
|
+
await ta('domainAttachInstance(--apply): patches ONLY the origin lines — creds, App key and federation survive', async () => {
|
|
1820
|
+
const savedHook = P.CONFIG.dnsHook; P.CONFIG.dnsHook = '';
|
|
1821
|
+
try {
|
|
1822
|
+
const { deps, writes } = attachRecorder({ apply: true });
|
|
1823
|
+
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1824
|
+
assert.equal(r.ok, true);
|
|
1825
|
+
const env = writes.find((w) => /\/spike\/web\.env$/.test(w.path));
|
|
1826
|
+
const P_ = reEsc(NEUTRAL_PREFIX);
|
|
1827
|
+
assert.match(env.body, new RegExp(`${P_}_OAUTH_CLIENT_ID=Iv1\\.fixture-client-id`), 'the manifest-placed client id survives');
|
|
1828
|
+
assert.match(env.body, new RegExp(`${P_}_OAUTH_CLIENT_SECRET=fixture-secret-placed-by-the-manifest-flow`), 'the client secret survives');
|
|
1829
|
+
assert.match(env.body, /GITHUB_APP_PRIVATE_KEY=fixture-pem-flattened-to-one-line/, 'the App private key survives');
|
|
1830
|
+
assert.match(env.body, new RegExp(`${P_}_HUB_CLIENT_SECRET=fixture-federation-secret`), 'the federation block survives');
|
|
1831
|
+
assert.match(env.body, /^PORT=3010$/m, 'and every unrelated line is untouched');
|
|
1832
|
+
assert.match(env.body, new RegExp(`${P_}_STATUS_ORIGIN=https://spike\\.cloudbongos\\.com`), 'a missing origin line is APPENDED');
|
|
1833
|
+
} finally { P.CONFIG.dnsHook = savedHook; }
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
await ta('domainAttachInstance(--apply): an unreadable web.env is REFUSED, never "patched" over', async () => {
|
|
1837
|
+
const { deps, writes } = attachRecorder({ apply: true, envBody: null });
|
|
1838
|
+
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1839
|
+
assert.equal(r.ok, false);
|
|
1840
|
+
assert.match(r.error, /cannot read/i);
|
|
1841
|
+
assert.equal(writes.length, 0, 'four origins over a missing PORT/PGDATABASE would take the instance down');
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
await ta('domainAttachInstance(dry-run): PLANS the patch — nothing is written', async () => {
|
|
1845
|
+
const { deps, writes } = attachRecorder();
|
|
1846
|
+
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1847
|
+
assert.equal(r.ok, true);
|
|
1848
|
+
assert.ok(!writes.some((w) => /web\.env$/.test(w.path)), 'a dry run must not write a synthetic body');
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
// -- releasing what a re-point moved off (task 1003237, ADR 0200) --------------
|
|
1852
|
+
|
|
1853
|
+
await ta('releasableDomains: the queue, minus the address the instance is CURRENTLY on', () => {
|
|
1854
|
+
assert.deepEqual(P.releasableDomains({ domain: 'right.example.com', released_domains: ['TYPO.example.com'] }),
|
|
1855
|
+
['typo.example.com'], 'normalized, so a case difference can never dodge the release');
|
|
1856
|
+
assert.deepEqual(P.releasableDomains({ domain: 'right.example.com', released_domains: ['right.example.com'] }),
|
|
1857
|
+
[], 'a row that somehow queued its own domain can never blackhole a live project');
|
|
1858
|
+
assert.deepEqual(P.releasableDomains({ domain: 'x.example.com' }), [], 'no queue is not a crash');
|
|
1859
|
+
assert.deepEqual(P.releasableDomains({ domain: 'x.example.com', released_domains: ['a.example.com', '', 'b.example.com'] }),
|
|
1860
|
+
['a.example.com', 'b.example.com'], 'every abandoned address is released, not just the last');
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
await ta('domainAttachInstance(--apply): a re-point parks the old address at the blackhole, AFTER wiring the new one', async () => {
|
|
1864
|
+
const savedPub = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1865
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1866
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1867
|
+
try {
|
|
1868
|
+
const { deps, cmds, envs, cleared } = attachRecorder({ apply: true });
|
|
1869
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, released_domains: ['typo.example.com'] }, deps);
|
|
1870
|
+
assert.equal(r.ok, true);
|
|
1871
|
+
const hooks = cmds.map((c, i) => i).filter((i) => cmds[i] === '/opt/infra/box-dns-cloudflare.sh');
|
|
1872
|
+
assert.equal(hooks.length, 2, 'one UPSERT for the new address, one RELEASE for the old');
|
|
1873
|
+
assert.equal(envs[hooks[0]].BOX_HOSTNAME, 'spike.cloudbongos.com');
|
|
1874
|
+
assert.equal(envs[hooks[0]].BOX_IP, '203.0.113.5', 'the new address is wired first');
|
|
1875
|
+
assert.equal(envs[hooks[1]].BOX_HOSTNAME, 'typo.example.com');
|
|
1876
|
+
assert.equal(envs[hooks[1]].BOX_IP, '192.0.2.1', 'RFC 5737 TEST-NET-1 — guaranteed never routable');
|
|
1877
|
+
assert.equal(envs[hooks[1]].BOX_PROXIED, '0', 'a parked record must never ride the CF edge');
|
|
1878
|
+
assert.equal(envs[hooks[1]].CLOUDFLARE_ZONE, 'example.com', 'the zone is derived from the OLD host, not the row');
|
|
1879
|
+
const caddy = cmds.findIndex((c) => /reload caddy/.test(c));
|
|
1880
|
+
assert.ok(caddy >= 0 && caddy < hooks[1], 'the old vhost is gone before its record is parked');
|
|
1881
|
+
assert.deepEqual(cleared, [18], 'the queue is drained only once every address on it was released');
|
|
1882
|
+
} finally { P.CONFIG.publicIp = savedPub; P.CONFIG.dnsHook = savedHook; }
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
await ta('domainAttachInstance(--apply): a release the hook REFUSES keeps the queue — ok:true, the re-point still stands', async () => {
|
|
1886
|
+
const savedPub = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1887
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1888
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1889
|
+
try {
|
|
1890
|
+
const { deps, cleared } = attachRecorder({ apply: true });
|
|
1891
|
+
let seen = 0;
|
|
1892
|
+
const inner = deps.exec;
|
|
1893
|
+
deps.exec = (cmd, opts) => {
|
|
1894
|
+
if (cmd === '/opt/infra/box-dns-cloudflare.sh' && ++seen === 2) throw new Error('cloudflare said no');
|
|
1895
|
+
return inner(cmd, opts);
|
|
1896
|
+
};
|
|
1897
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, released_domains: ['typo.example.com'] }, deps);
|
|
1898
|
+
assert.equal(r.ok, true, 'a failed release must never fail a re-point that already landed');
|
|
1899
|
+
assert.equal(r.releaseFailed, 'typo.example.com');
|
|
1900
|
+
assert.deepEqual(cleared, [], 'the queue survives, so the next attach retries the release');
|
|
1901
|
+
} finally { P.CONFIG.publicIp = savedPub; P.CONFIG.dnsHook = savedHook; }
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
await ta('domainAttachInstance(--apply): no DNS hook → the release is named for the operator, and the queue is kept', async () => {
|
|
1905
|
+
const savedHook = P.CONFIG.dnsHook; P.CONFIG.dnsHook = '';
|
|
1906
|
+
try {
|
|
1907
|
+
const lines = [];
|
|
1908
|
+
const { deps, cleared } = attachRecorder({ apply: true });
|
|
1909
|
+
deps.log = (m) => lines.push(String(m));
|
|
1910
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, released_domains: ['typo.example.com'] }, deps);
|
|
1911
|
+
assert.equal(r.ok, true);
|
|
1912
|
+
assert.ok(lines.some((l) => /typo\.example\.com/.test(l) && /BOX_DNS_HOOK unset/.test(l)),
|
|
1913
|
+
'the outstanding manual step is SAID, not swallowed');
|
|
1914
|
+
assert.deepEqual(cleared, [], 'nothing was released, so nothing is marked released');
|
|
1915
|
+
} finally { P.CONFIG.dnsHook = savedHook; }
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1748
1918
|
await ta('domainAttachInstance: no domain recorded → ok:false, nothing runs', async () => {
|
|
1749
1919
|
const { deps, cmds } = attachRecorder();
|
|
1750
1920
|
const r = await P.domainAttachInstance({ ...activeStandalone, domain: null }, deps);
|
|
@@ -1797,7 +1967,10 @@ await ta('cmdRunIntents (--apply) routes a domain-attach intent to the attach le
|
|
|
1797
1967
|
claimNextOAuthManifest: async () => null,
|
|
1798
1968
|
resolveOAuthManifest: async () => {},
|
|
1799
1969
|
},
|
|
1800
|
-
|
|
1970
|
+
// the attach leg patches web.env IN PLACE, so a `cat` must return a real file
|
|
1971
|
+
// — an empty one is a deliberate refusal (task 1003237)
|
|
1972
|
+
exec: (cmd) => ({ ok: true, stdout: /\bcat\b.*web\.env/.test(String(cmd)) ? 'PORT=3010\n' : '' }),
|
|
1973
|
+
writeFile: () => ({ ok: true }), doApi: null,
|
|
1801
1974
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1802
1975
|
};
|
|
1803
1976
|
const r = await P.cmdRunIntents(deps);
|
|
@@ -318,12 +318,14 @@ await ta('re-pointing AWAY from the platform-assigned address → recorded + dom
|
|
|
318
318
|
assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
-
await ta('a genuine bring-your-own address
|
|
321
|
+
await ta('a genuine bring-your-own address RE-POINTS, and the one it leaves is queued for release (task 1003237)', async () => {
|
|
322
322
|
reset();
|
|
323
323
|
provisioning.getInstanceById = async (_db, id) => freshRow({ id, domain: 'spike.example.com', status: 'active' });
|
|
324
324
|
const res = await post('/provisioning/instances/41/domain', { domain: 'another.example.com' });
|
|
325
|
-
assert.equal(res.status,
|
|
326
|
-
assert.equal(res.body.
|
|
325
|
+
assert.equal(res.status, 200, res.raw);
|
|
326
|
+
assert.equal(res.body.domain, 'another.example.com');
|
|
327
|
+
assert.equal(res.body.replaced, 'spike.example.com',
|
|
328
|
+
'the owner decision of 2026-08-28: a mistyped BYO address is correctable, and the old one is let go');
|
|
327
329
|
});
|
|
328
330
|
|
|
329
331
|
server.close();
|