@bongos/core 1.19.999 → 1.19.1001

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 (89) hide show
  1. package/.bongos-core.json +116 -91
  2. package/.claude/skills/collab-review/SKILL.md +24 -13
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +155 -5
  11. package/docs/api-reference.md +6 -4
  12. package/docs/copy-inventory.md +18 -13
  13. package/docs/copy-registry.json +70 -15
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/hall-ui/public/collab-lib.js +79 -0
  16. package/modules/hall-ui/public/collab.css +60 -0
  17. package/modules/hall-ui/public/collab.js +134 -5
  18. package/modules/lifecycle/help-requests.js +118 -8
  19. package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
  20. package/modules/lifecycle/routes/help-requests.js +93 -13
  21. package/modules/provisioning/capacity.js +3 -3
  22. package/modules/provisioning/control-plane-guard.js +1 -1
  23. package/modules/provisioning/core-upgrade.js +7 -7
  24. package/modules/provisioning/cost-ledger.js +74 -0
  25. package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
  26. package/modules/provisioning/onboard-plan.js +1 -1
  27. package/modules/provisioning/paid-shape-gate.js +28 -15
  28. package/modules/provisioning/provisioning.js +20 -39
  29. package/modules/provisioning/routes/provisioning.js +4 -3
  30. package/modules/provisioning/routes/repo-private.js +1 -1
  31. package/modules/provisioning/tests/liveness.mjs +16 -8
  32. package/modules/provisioning/tests/provisioning.mjs +1 -1
  33. package/modules/public-landing/public/projects.html +26 -27
  34. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/init.js +12 -4
  38. package/scripts/gds/onboard.js +1 -1
  39. package/scripts/gds/provision-capacity.js +6 -1
  40. package/scripts/gds/provision-config.js +6 -3
  41. package/scripts/gds/provision-core-upgrade.js +8 -8
  42. package/scripts/gds/provision-disconnect.js +2 -2
  43. package/scripts/gds/provision-process.js +2 -2
  44. package/scripts/gds/provision-repo-private.js +1 -1
  45. package/scripts/gds/provision-repo.js +13 -3
  46. package/scripts/gds/provision-teardown.js +2 -2
  47. package/scripts/gds/provision-units.js +2 -2
  48. package/scripts/gds/provision.js +6 -6
  49. package/src/module-api.js +1 -1
  50. package/tests/collab_page.mjs +140 -7
  51. package/tests/core_upgrade_door.mjs +1 -1
  52. package/tests/core_upgrade_runner.mjs +20 -20
  53. package/tests/deploy_page_projects.mjs +5 -5
  54. package/tests/hall_collab_world.mjs +6 -2
  55. package/tests/help_requests.mjs +201 -6
  56. package/tests/hub_disconnect_ui.mjs +4 -1
  57. package/tests/init.mjs +6 -6
  58. package/tests/onboard.mjs +5 -4
  59. package/tests/project_catalog_backfill.mjs +1 -1
  60. package/tests/project_catalog_bridge.mjs +2 -2
  61. package/tests/project_catalog_routes.mjs +4 -4
  62. package/tests/projects_hub.mjs +1 -1
  63. package/tests/projects_hub_pre_uat.mjs +22 -5
  64. package/tests/projects_hub_repo_private.mjs +1 -1
  65. package/tests/provision.mjs +32 -32
  66. package/tests/provision_byo_host.mjs +1 -1
  67. package/tests/provision_caddy_wiring.mjs +1 -1
  68. package/tests/provision_disconnect.mjs +3 -3
  69. package/tests/provision_repo_private.mjs +1 -1
  70. package/tests/provision_restart.mjs +5 -5
  71. package/tests/provision_settings_apply.mjs +4 -4
  72. package/tests/provision_teardown_address.mjs +2 -2
  73. package/tests/provisioning_capacity.mjs +40 -34
  74. package/tests/provisioning_capacity_gate.mjs +5 -12
  75. package/tests/provisioning_cost_ledger.mjs +98 -0
  76. package/tests/provisioning_default_domain.mjs +1 -1
  77. package/tests/provisioning_domain_attach.mjs +4 -4
  78. package/tests/provisioning_domain_unique.mjs +2 -2
  79. package/tests/provisioning_error_note_scope.mjs +1 -1
  80. package/tests/provisioning_module_selection.mjs +5 -5
  81. package/tests/provisioning_onboard_mode.mjs +2 -2
  82. package/tests/provisioning_paid_shape_gate.mjs +57 -44
  83. package/tests/provisioning_repo_private_route.mjs +1 -1
  84. package/tests/provisioning_restart.mjs +11 -4
  85. package/tests/provisioning_settings_apply.mjs +12 -5
  86. package/tests/provisioning_settings_env.mjs +1 -1
  87. package/tests/provisioning_teardown_intent.mjs +3 -3
  88. package/tests/public_landing_projects.mjs +13 -6
  89. package/tests/upgrade.mjs +2 -2
@@ -264,7 +264,7 @@ await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that br
264
264
  // Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
265
265
  // No assertion about FLAGS could have caught it, so this one resolves the composed
266
266
  // cwd + relative CLI against the real filesystem, for every shape the door admits.
267
- for (const shape of ['control-plane', 'standalone', 'co-tenant']) {
267
+ for (const shape of ['control-plane', 'cloud-host', 'co-tenant']) {
268
268
  const d = makeDeps({ served: '1.19.762' });
269
269
  await R.coreUpgradeInstance(inst({ hosting_shape: shape, slug: 'hermeslines-marketing', port: 3011 }), d, intent());
270
270
  const call = d.ranWith.find((c) => /upgrade\.js/.test(c.cmd));
@@ -278,7 +278,7 @@ await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that br
278
278
 
279
279
  await t('a standalone tenant is NAMED with --instance, because the CLI no longer runs inside it', async () => {
280
280
  const d = makeDeps({ served: '1.19.762' });
281
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'hermeslines-marketing', port: 3011 }), d, intent());
281
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'hermeslines-marketing', port: 3011 }), d, intent());
282
282
  const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
283
283
  assert.match(cmd, /--instance \S*hermeslines-marketing/, 'the target is named explicitly');
284
284
  assert.ok(cmd.includes('--service hermeslines-marketing'), 'and its own unit is still the one restarted');
@@ -475,7 +475,7 @@ await t('the control plane upgrades as the operator account, never as root', asy
475
475
 
476
476
  await t('a tenant upgrades as its OWN account, which is also its Postgres login role', async () => {
477
477
  const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
478
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
478
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
479
479
  const cmd = upgradeCall(d);
480
480
  assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
481
481
  assert.doesNotMatch(cmd, /sudo -u appsvc /, 'one tenant must never be upgraded under another account');
@@ -487,7 +487,7 @@ await t('the database is NAMED, not discovered — an adopted repo has no core a
487
487
  // upgrade died with "cannot determine which database to migrate".
488
488
  const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
489
489
  await R.coreUpgradeInstance(
490
- inst({ hosting_shape: 'standalone', slug: 'hermeslines-marketing', db_name: 'hermeslines-marketing', port: 3011 }), d, intent());
490
+ inst({ hosting_shape: 'cloud-host', slug: 'hermeslines-marketing', db_name: 'hermeslines-marketing', port: 3011 }), d, intent());
491
491
  assert.match(upgradeCall(d), /env PGDATABASE=hermeslines-marketing /, 'the runner passes the name the control plane already holds');
492
492
  });
493
493
 
@@ -502,7 +502,7 @@ await t('an UNPRIVILEGED runner still adds no sudo and no env at all', async ()
502
502
  // it so the fix above cannot quietly start requiring sudo where there is none.
503
503
  const d = makeDeps({ served: '1.19.762' });
504
504
  d.privileged = false;
505
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
505
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
506
506
  const cmd = upgradeCall(d);
507
507
  assert.doesNotMatch(cmd, /sudo/, 'no escalation when the runner is not privileged');
508
508
  assert.match(cmd, /^node \S*scripts\/gds\/upgrade\.js /, 'it still invokes the CLI directly');
@@ -531,7 +531,7 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
531
531
  // Everything deciding whether the upgrade is ALLOWED must be identical; only the tail
532
532
  // that describes what happens AFTER the pin moves may differ, because a dry run stops
533
533
  // before it.
534
- const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'standalone', db_name: 'test4' };
534
+ const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host', db_name: 'test4' };
535
535
  const move = R.upgradeInvocation(box, '1.19.921', { privileged: true, requestedBy: '42' });
536
536
  const peek = R.upgradeInvocation(box, '1.19.921', { privileged: true, dryRun: true });
537
537
  for (const shared of ['sudo -u bongos-test4 ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry']) {
@@ -552,13 +552,13 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
552
552
  await t('an invalid slug is refused at the BUILDER, not left to a helper to throw', () => {
553
553
  // The slug reaches a shell through instanceUser()/dbName(). Depending on another
554
554
  // function's internals for that is one refactor away from an injection path.
555
- assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: 'standalone' }, '1.19.921', {}),
555
+ assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: 'cloud-host' }, '1.19.921', {}),
556
556
  /invalid slug/);
557
557
  });
558
558
 
559
559
  await t('a dry run that fails WITHOUT a recognisable marker is still a refusal, never a pass', () => {
560
560
  const deps = { privileged: false, exec: () => ({ ok: false, stdout: 'something went sideways' }) };
561
- const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: 'standalone' }, '1.19.921', deps);
561
+ const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host' }, '1.19.921', deps);
562
562
  assert.equal(r.ok, false, 'a non-zero exit is a refusal whatever it printed');
563
563
  assert.ok(r.error);
564
564
  });
@@ -716,7 +716,7 @@ await t('a tenant WITH its own account runs as that account — the security mod
716
716
  // task 1003369: a distinct uid per instance is what stops a co-tenant reading a
717
717
  // sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it.
718
718
  const d = acctDeps({ accountExists: true });
719
- const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
719
+ const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
720
720
  assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'bongos-test4', fellBack: false });
721
721
  });
722
722
 
@@ -726,7 +726,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
726
726
  // instance's own unit already says User=lars — but it IS a provisioning gap, so it
727
727
  // is reported rather than silently papered over.
728
728
  const d = acctDeps({ accountExists: false });
729
- const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
729
+ const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
730
730
  assert.equal(r.runAs, 'appsvc');
731
731
  assert.equal(r.fellBack, true);
732
732
  assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
@@ -741,7 +741,7 @@ await t('the control plane is never probed — it has no instance account by des
741
741
 
742
742
  await t('the dry run is invoked as the account that actually exists', async () => {
743
743
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
744
- await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
744
+ await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
745
745
  const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
746
746
  assert.match(cmd, /sudo -u appsvc /, `must not sudo to an account that does not exist — got: ${cmd}`);
747
747
  assert.doesNotMatch(cmd, /bongos-test4/);
@@ -749,7 +749,7 @@ await t('the dry run is invoked as the account that actually exists', async () =
749
749
 
750
750
  await t('the MOVE probes identically, so preview and move cannot diverge on the account', async () => {
751
751
  const d = acctDeps({ accountExists: false });
752
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
752
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
753
753
  const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
754
754
  assert.match(cmd, /sudo -u appsvc /, 'the move follows the same probe as the preview');
755
755
  });
@@ -761,7 +761,7 @@ await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly wi
761
761
  // well exist. Only `no such user` is evidence.
762
762
  const d = makeDeps({ served: '1.19.762' });
763
763
  d.exec = () => ({ ok: true, stdout: '', stderr: 'id: cannot connect to name service\n', softFailed: true });
764
- const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
764
+ const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
765
765
  assert.equal(r.runAs, 'bongos-test4', 'the intended account is kept');
766
766
  assert.equal(r.fellBack, false);
767
767
  assert.equal(r.probeInconclusive, true, 'and the caller can tell it was not a clean answer');
@@ -776,7 +776,7 @@ await t('the probe uses the SAME executor the upgrade will use', () => {
776
776
  const d = makeDeps({ served: '1.19.762' });
777
777
  d.controlExec = (cmd) => { asked.control.push(cmd); return { ok: true, stdout: '1001\n' }; };
778
778
  d.exec = (cmd) => { asked.remote.push(cmd); return { ok: true, stdout: '', stderr: "id: 'x': no such user\n", softFailed: true }; };
779
- const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
779
+ const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
780
780
  assert.deepEqual(asked.remote, [], 'the remote executor was not asked');
781
781
  assert.equal(asked.control.length, 1, 'the control-plane executor was');
782
782
  assert.equal(r.runAs, 'bongos-test4', 'and its answer is the one that counts');
@@ -788,7 +788,7 @@ await t('a planning tick probes ONCE, not twice', async () => {
788
788
  // a subprocess per tick for nothing.
789
789
  const d = acctDeps({ accountExists: false });
790
790
  d.apply = false;
791
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
791
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
792
792
  assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 1,
793
793
  'exactly one probe: the dry run that actually executes');
794
794
  });
@@ -800,7 +800,7 @@ await t('an invalid slug is refused by THIS call site, not merely by the helper
800
800
  // helper's internals, so the assertion names the thrower.
801
801
  const d = acctDeps({});
802
802
  assert.throws(
803
- () => R.resolveRunAs({ hosting_shape: 'standalone', slug: 'bad slug;rm -rf /' }, d),
803
+ () => R.resolveRunAs({ hosting_shape: 'cloud-host', slug: 'bad slug;rm -rf /' }, d),
804
804
  /resolveRunAs: refusing to probe/,
805
805
  'the refusal must come from this function, not from instanceUser further down',
806
806
  );
@@ -809,7 +809,7 @@ await t('an invalid slug is refused by THIS call site, not merely by the helper
809
809
 
810
810
  await t('a refusal reports what the command SAID, not "did not complete"', async () => {
811
811
  const d = acctDeps({ softFail: true, dryRunStderr: 'sudo: unknown user bongos-test4\n' });
812
- const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
812
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
813
813
  assert.equal(v.ok, false);
814
814
  assert.match(v.error, /unknown user bongos-test4/,
815
815
  `the reason was in hand and must travel — got: ${JSON.stringify(v.error)}`);
@@ -817,21 +817,21 @@ await t('a refusal reports what the command SAID, not "did not complete"', async
817
817
 
818
818
  await t('a ✖ line still wins over the raw tail', async () => {
819
819
  const d = acctDeps({ softFail: true, dryRunStdout: 'noise\n ✖ already on 1.19.927 (pass --force to re-run)\n' });
820
- const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
820
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
821
821
  assert.match(v.error, /already on 1\.19\.927/);
822
822
  assert.doesNotMatch(v.error, /^noise/, 'the parsed refusal is preferred to the raw tail');
823
823
  });
824
824
 
825
825
  await t('a fallback is recorded on the verdict the owner reads', async () => {
826
826
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
827
- const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
827
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
828
828
  assert.equal(v.ran_as, 'appsvc');
829
829
  assert.match(v.account_note, /no bongos-test4 account yet/);
830
830
  });
831
831
 
832
832
  await t('and a silent failure STILL refuses rather than inventing a pass', async () => {
833
833
  const d = acctDeps({ softFail: true, dryRunStdout: '', dryRunStderr: '' });
834
- const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
834
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
835
835
  assert.equal(v.ok, false, 'a command that said nothing is not a pass');
836
836
  assert.match(v.error, /said nothing/);
837
837
  });
@@ -22,7 +22,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
22
  const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/deploy.js'), 'utf8');
23
23
 
24
24
  const preview = (id, slug, over = {}) => ({
25
- instance_id: id, slug, hosting_shape: 'standalone',
25
+ instance_id: id, slug, hosting_shape: 'cloud-host',
26
26
  served_version: '1.19.800', recommended_version: '1.19.871',
27
27
  behind: true, reading: 'ok', observed_at: new Date().toISOString(), open_intent: null,
28
28
  ...over,
@@ -30,11 +30,11 @@ const preview = (id, slug, over = {}) => ({
30
30
 
31
31
  // The fleet as the control plane really reports it: mostly torn down, a couple live.
32
32
  const FLEET = [
33
- { id: '31', slug: 'mercury', status: 'active', hosting_shape: 'standalone' },
33
+ { id: '31', slug: 'mercury', status: 'active', hosting_shape: 'cloud-host' },
34
34
  { id: '32', slug: 'hoodify-finance', status: 'active', hosting_shape: 'co-tenant' },
35
- { id: '33', slug: 'walk-lantern', status: 'torn_down', hosting_shape: 'standalone' },
36
- { id: '34', slug: 'testccc', status: 'torn_down', hosting_shape: 'standalone' },
37
- { id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: 'standalone' },
35
+ { id: '33', slug: 'walk-lantern', status: 'torn_down', hosting_shape: 'cloud-host' },
36
+ { id: '34', slug: 'testccc', status: 'torn_down', hosting_shape: 'cloud-host' },
37
+ { id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: 'cloud-host' },
38
38
  ];
39
39
 
40
40
  function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200, instanceOver = null, moveTries = 4, postStatus = 200, scope = null } = {}) {
@@ -108,8 +108,12 @@ test('the server names the people on a row through a join, and the writes stay b
108
108
  const body = src.slice(src.indexOf(`async function ${fn}`));
109
109
  assert.match(body.slice(0, 700), /SELECT \$\{LIST_COLS\} \$\{LIST_FROM\}/, `${fn} reads the joined columns`);
110
110
  }
111
- const upd = src.slice(src.indexOf('async function setHelpRequestStatus'));
112
- assert.match(upd.slice(0, 600), /RETURNING \$\{COLS\}/, 'the UPDATE cannot reach a joined alias, so it returns the bare columns');
111
+ // Sliced to the end of the function rather than a character count: task
112
+ // 1004216 widened who may settle, so the body grew past the old 600-char
113
+ // window and a fixed slice would report the property missing when it is there.
114
+ const from = src.indexOf('async function setHelpRequestStatus');
115
+ const upd = src.slice(from, src.indexOf('\n}', from));
116
+ assert.match(upd, /RETURNING \$\{COLS\}/, 'the UPDATE cannot reach a joined alias, so it returns the bare columns');
113
117
  });
114
118
 
115
119
  test('the first line is decided: the ask\'s first sentence is the title, the rest the sub, no word lost', () => {
@@ -141,11 +141,62 @@ test('the addressed read excludes your own requests', () => {
141
141
  assert.match(fn.slice(0, 700), /status = 'open'/, 'and settled requests are history');
142
142
  });
143
143
 
144
- test('only the ASKER can settle their own request', () => {
145
- const src = read('modules', 'lifecycle', 'help-requests.js');
146
- const fn = src.slice(src.indexOf('async function setHelpRequestStatus'));
147
- assert.match(fn.slice(0, 600), /requested_by = \$2/, 'ownership is in the WHERE, not in rank');
148
- assert.match(fn.slice(0, 600), /status = 'open'/, 'and a settled request cannot be re-settled');
144
+ // WHO MAY SETTLE — widened by task 1004216 / ADR 0330, and RUN rather than
145
+ // grepped, because the rule is now a shape (asker OR addressee, except for
146
+ // withdraw) that a source scan would agree with while the SQL said otherwise.
147
+ async function settleSql(args) {
148
+ const calls = [];
149
+ const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 1 }] }; } };
150
+ await help.setHelpRequestStatus(args, { pool });
151
+ return calls[0];
152
+ }
153
+
154
+ test('the asker still settles, and the rule lives in the WHERE rather than in rank', async () => {
155
+ const c = await settleSql({ id: 1, builderId: 7, status: 'answered', crafts: [] });
156
+ assert.match(c.sql, /requested_by = \$2/, 'ownership is in the WHERE, not in rank');
157
+ assert.match(c.sql, /status = 'open'/, 'and a settled request cannot be re-settled');
158
+ });
159
+
160
+ test('the ADDRESSEE can close an ask too — by name, or through a craft they belong to', async () => {
161
+ // The defect this fixes: a request addressed to someone who has just answered
162
+ // it on the page, and still cannot close it, is a surface arguing with itself.
163
+ const c = await settleSql({ id: 1, builderId: 7, status: 'answered', crafts: ['artist'] });
164
+ assert.match(c.sql, /needs_builder_id = \$2/, 'the person a request names');
165
+ assert.match(c.sql, /needs_craft = ANY\(\$5::text\[\]\)/, 'or a craft they belong to');
166
+ assert.deepEqual(c.params[4], ['artist'], 'and the crafts are bound, never interpolated');
167
+ assert.equal(c.params[3], false, 'the asker-only switch is off for answered');
168
+ });
169
+
170
+ test('WITHDRAW stays the asker\'s alone — an addressee cannot make an ask disappear', async () => {
171
+ // Withdrawing means "I no longer need this", which only the person who needed
172
+ // it can say. An addressee who wants rid of an ask answers it and closes it,
173
+ // in the open, with their name on both.
174
+ const c = await settleSql({ id: 1, builderId: 7, status: 'withdrawn', crafts: ['artist'] });
175
+ assert.equal(c.params[3], true, 'the asker-only switch is ON for withdrawn');
176
+ assert.match(c.sql, /\$4::boolean IS FALSE/, 'and it gates the addressee branch, not the asker one');
177
+ });
178
+
179
+ test('a craft the caller does not hold is filtered out before it binds', async () => {
180
+ // The same fail-closed rule the addressed read learned the hard way: a craft
181
+ // value that did not come from the caller's own profile must never widen a
182
+ // scope. Here it would let anyone close any craft-addressed ask.
183
+ const c = await settleSql({ id: 1, builderId: 7, status: 'answered', crafts: ['wizard', 'artist'] });
184
+ assert.deepEqual(c.params[4], ['artist'], 'an unknown craft is dropped, not passed through');
185
+ });
186
+
187
+ test('settling records WHO settled it', async () => {
188
+ const c = await settleSql({ id: 1, builderId: 7, status: 'answered', crafts: [] });
189
+ assert.match(c.sql, /settled_by = \$2/, 'now that two different people can close an ask, which one matters');
190
+ });
191
+
192
+ test('the route takes the caller\'s crafts from their PROFILE, never from the body', () => {
193
+ // C01's privacy hole was a caller-supplied craft widening a scope. The same
194
+ // value now decides who may CLOSE an ask, so it comes from the same place.
195
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
196
+ const idx = routes.indexOf("'PATCH /help-requests/:id'");
197
+ const body = routes.slice(idx, routes.indexOf('errorCode', idx));
198
+ assert.match(body, /craftsForBuilder\(req\.builder\.id\)/, 'read from the profile');
199
+ assert.ok(!/req\.body\.craft|req\.query\.craft/.test(body), 'and never from the caller');
149
200
  });
150
201
 
151
202
  // ---- it stands alone ------------------------------------------------------
@@ -217,7 +268,13 @@ test('the settle refusal no longer lets you enumerate ids', () => {
217
268
  // The comment claimed one refusal for three cases; the code looked the row up
218
269
  // and said which. Comment and code now agree.
219
270
  const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
220
- const patch = routes.slice(routes.indexOf("'PATCH /help-requests/:id'"));
271
+ // Scoped to the PATCH HANDLER, not to the rest of the file. Task 1004216 added
272
+ // the reply routes after it, and one of them looks a request up on purpose —
273
+ // a reply to an id that never existed should be a named 404 rather than an FK
274
+ // violation rendered as a 500. Slicing to the end of the file would read that
275
+ // lookup as this handler's and report a hole that is not here.
276
+ const from = routes.indexOf("'PATCH /help-requests/:id'");
277
+ const patch = routes.slice(from, routes.indexOf('help_request_update_failed', from));
221
278
  assert.ok(!/getHelpRequest/.test(patch), 'no existence lookup in the refusal path');
222
279
  const reasons = patch.match(/reason: '[^']+'/g) || [];
223
280
  assert.equal(reasons.length, 1, `exactly one refusal reason, got ${reasons}`);
@@ -309,3 +366,141 @@ test('a help request addressed to a non-existent builder is a named 400', () =>
309
366
  assert.match(routes, /builderExists/);
310
367
  assert.match(routes, /no_such_builder/, 'the refusal names the field, not an FK 500');
311
368
  });
369
+
370
+ // ---- replies (task 1004216, ADR 0330) --------------------------------------
371
+ //
372
+ // An ask could be filed and settled and never ANSWERED — the person being asked
373
+ // had no write at all, so the answer happened somewhere the project could not
374
+ // see, and the row sat open until its author remembered it.
375
+ test('a reply must say something, and cannot say more than an ask', () => {
376
+ const why = (input) => help.validateReply(input).errors.map((e) => e.reason);
377
+ assert.deepEqual(why({ body: 'the metrics stack was removed on the 15th' }), []);
378
+ assert.deepEqual(why({ body: ' ' }), ['required'], 'whitespace is not an answer');
379
+ assert.deepEqual(why({}), ['required']);
380
+ assert.deepEqual(why({ body: 'x'.repeat(help.MAX_REPLY + 1) }), ['too_long']);
381
+ assert.equal(help.MAX_REPLY, help.MAX_STUCK, 'an answer may be as long as the question');
382
+ });
383
+
384
+ test('a reply is stored trimmed, against the ask it answers', async () => {
385
+ const calls = [];
386
+ const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 5 }] }; } };
387
+ await help.createReply({ helpRequestId: 11, builderId: 3, body: ' moot, it was removed ' }, { pool });
388
+ assert.match(calls[0].sql, /INSERT INTO lifecycle_help_request_replies/);
389
+ assert.deepEqual(calls[0].params, [11, 3, 'moot, it was removed']);
390
+ });
391
+
392
+ test('a thread reads OLDEST FIRST — it is a conversation, not a queue', async () => {
393
+ const calls = [];
394
+ const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [] }; } };
395
+ await help.listReplies(11, { pool });
396
+ assert.match(calls[0].sql, /ORDER BY r\.created_at ASC, r\.id ASC/,
397
+ 'and ties break on id, so two replies in the same second keep their order');
398
+ assert.match(calls[0].sql, /builder_login|builder_name/, 'it names who said it');
399
+ });
400
+
401
+ test('every thread on a page is ONE query, not one per row', async () => {
402
+ // A list read renders a thread under every ask. Per-row fetching is what makes
403
+ // a page slow for reasons no single request explains.
404
+ const calls = [];
405
+ const pool = {
406
+ query: async (sql, params) => {
407
+ calls.push({ sql, params });
408
+ return { rows: [{ id: 1, help_request_id: '11', body: 'a' }, { id: 2, help_request_id: '12', body: 'b' }] };
409
+ },
410
+ };
411
+ const map = await help.repliesForRequests(['11', '12', '11'], { pool });
412
+ assert.equal(calls.length, 1, 'one round trip for the whole page');
413
+ assert.deepEqual(calls[0].params, [[11, 12]], 'de-duplicated, and bound as an array');
414
+ assert.equal(map.get('11').length, 1);
415
+ assert.equal(map.get('12').length, 1);
416
+ });
417
+
418
+ test('reading the replies of an unknown ask answers [] — a 404 there would be an id oracle', async () => {
419
+ // The asymmetry with POST (which DOES 404) is deliberate. This surface already
420
+ // learned once that an existence lookup on a refusal path lets a caller
421
+ // enumerate ids; a 404 on a GET rebuilds that oracle on the cheapest possible
422
+ // request. The POST can afford it because it is a write.
423
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
424
+ const idx = routes.indexOf("'GET /help-requests/:id/replies'");
425
+ assert.ok(idx > 0, 'the route exists');
426
+ const body = routes.slice(idx, routes.indexOf('errorCode', idx));
427
+ assert.ok(!/getHelpRequest|not_found/.test(body), 'no existence lookup, no 404');
428
+
429
+ const pool = { query: async () => ({ rows: [] }) };
430
+ assert.deepEqual(await help.listReplies(999999, { pool }), [], 'and the read itself is happy to find nothing');
431
+ });
432
+
433
+ test('the bulk thread read is BOUNDED, and bounded PER ASK', async () => {
434
+ // listReplies caps at MAX_ROWS; this one backs three list routes on the page's
435
+ // own load path, so an unbounded result set would be the one place a busy
436
+ // project could make Collab arrive by the megabyte (grader).
437
+ //
438
+ // Per-ask, not one global LIMIT: a global cap lets one busy ask eat the budget
439
+ // and hands the rows below it empty threads, which reads as "nobody answered" —
440
+ // the exact lie this surface exists to stop telling.
441
+ const calls = [];
442
+ const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [] }; } };
443
+ await help.repliesForRequests([11, 12], { pool });
444
+ assert.match(calls[0].sql, /ROW_NUMBER\(\) OVER \(PARTITION BY r\.help_request_id/, 'the window is per ask');
445
+ assert.match(calls[0].sql, new RegExp(`thread_pos <= ${help.MAX_THREAD_ON_LIST}`), 'and it is capped');
446
+ assert.ok(help.MAX_THREAD_ON_LIST > 0 && help.MAX_THREAD_ON_LIST <= help.MAX_ROWS);
447
+ // The order inside the window must match the single-thread read's, or the
448
+ // first N a list shows are a different N in a different order.
449
+ assert.match(calls[0].sql, /ORDER BY r\.created_at ASC, r\.id ASC\) AS thread_pos/);
450
+ });
451
+
452
+ test('asking for no threads asks the database nothing', async () => {
453
+ const pool = { query: async () => { throw new Error('must not query'); } };
454
+ const map = await help.repliesForRequests([], { pool });
455
+ assert.equal(map.size, 0);
456
+ });
457
+
458
+ test('a row without replies carries an EMPTY list, never a missing field', () => {
459
+ // A reader must never have to tell "no replies" apart from "this response does
460
+ // not carry replies" — the second is how a page decides to hide a thread that
461
+ // is really there.
462
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
463
+ assert.match(routes, /replies: threads\.get\(String\(r\.id\)\) \|\| \[\]/);
464
+ for (const route of ["'GET /help-requests/for-me'", "'GET /help-requests/mine'", "'GET /help-requests'"]) {
465
+ const idx = routes.indexOf(route);
466
+ assert.ok(idx > 0, `${route} exists`);
467
+ const body = routes.slice(idx, routes.indexOf('errorCode', idx));
468
+ assert.match(body, /withReplies\(/, `${route} carries its threads`);
469
+ }
470
+ });
471
+
472
+ test('there is no way to edit or delete a reply, at either layer', () => {
473
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
474
+ assert.ok(!/router\.(patch|put|delete)\('\/help-requests\/:id\/replies/.test(routes), 'no route');
475
+ const src = read('modules', 'lifecycle', 'help-requests.js');
476
+ assert.ok(!/function (update|delete|edit)Reply/i.test(src), 'no data function');
477
+ // And the table refuses regardless — the guarantee must not rest on the routes
478
+ // that happen to exist today.
479
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_011_help_request_replies.sql');
480
+ assert.match(sql, /BEFORE UPDATE OR DELETE ON lifecycle_help_request_replies/);
481
+ });
482
+
483
+ test('the FK RESTRICTS — append-only and ON DELETE CASCADE cannot both be true', () => {
484
+ // A real bug, caught by applying this migration to a live Postgres rather than
485
+ // by reading it. The first draft cascaded, because "a reply has no meaning
486
+ // without the ask it answers" reads right — but a cascade IS a plain DELETE on
487
+ // the replies table, the append-only trigger refuses it, and deleting a help
488
+ // request that had ever been answered failed outright with a trigger error.
489
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_011_help_request_replies.sql');
490
+ assert.match(sql, /REFERENCES lifecycle_help_requests\(id\) ON DELETE RESTRICT/);
491
+ assert.ok(!/ON DELETE CASCADE/.test(sql.replace(/^\s*--.*$/gm, '')),
492
+ 'no cascade in the statements — it would be refused by the trigger below it');
493
+ });
494
+
495
+ test('a reply to an ask that never existed is a named 404, not an FK 500', () => {
496
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
497
+ const idx = routes.indexOf("'POST /help-requests/:id/replies'");
498
+ assert.ok(idx > 0, 'the route exists');
499
+ const body = routes.slice(idx, routes.indexOf('errorCode', idx));
500
+ assert.match(body, /getHelpRequest\(id\)/);
501
+ assert.match(body, /help_request_not_found/);
502
+ // The guard sits on the router line, ahead of the handler's own label — the
503
+ // same place the board read's test looks for it.
504
+ const line = routes.slice(routes.lastIndexOf('router.post', idx), idx);
505
+ assert.match(line, /api\.requireBuilder/, 'signed in, like every other route here');
506
+ });
@@ -85,7 +85,10 @@ test('the render guards a half-typed name per project, names an open run, and of
85
85
 
86
86
  test('the ender no longer claims nothing on the page erases — it scopes that to itself and points at the card', () => {
87
87
  const truth = fn('teardownTruth');
88
- assert.equal((truth.match(/until you bring it back\. Taking it offline erases nothing\.'/g) || []).length, 2, 'both shapes');
88
+ // Was 2 — one per hosting-shape branch. Task 1004182 left one branch (ADR 0327 decision 4
89
+ // removed 'dedicated'), so the count is 1. The property this case is really about is that
90
+ // the ender's reassurance is scoped to ITSELF, which the assertions below carry.
91
+ assert.equal((truth.match(/until you bring it back\. Taking it offline erases nothing\.'/g) || []).length, 1, 'the built-world truth ends on the reassurance');
89
92
  assert.doesNotMatch(HUB, /Nothing here erases a project/);
90
93
  const delFold = (HUB.match(/<details class="finePrint" id="mDelWhat">[\s\S]*?<\/details>/) || [''])[0];
91
94
  assert.doesNotMatch(delFold, /no permanent-delete button/i, 'the capability exists now');
package/tests/init.mjs CHANGED
@@ -240,8 +240,8 @@ test('provisioningRequest: null unless opted in; builds the POST body when opted
240
240
  assert.equal(init.provisioningRequest(fullSpec()), null); // no block → not requested
241
241
  const co = init.provisioningRequest(provSpec({})); // empty block = opted in, defaults
242
242
  assert.deepEqual(co, { slug: 'cloud-bongos', hosting_shape: 'byo-host' });
243
- const full = init.provisioningRequest(provSpec({ shape: 'dedicated', domain: 'example.com', targetRef: 'do:main', tier: 's-1vcpu-2gb' }));
244
- assert.deepEqual(full, { slug: 'cloud-bongos', hosting_shape: 'dedicated', domain: 'example.com', target_ref: 'do:main', tier: 's-1vcpu-2gb' });
243
+ const full = init.provisioningRequest(provSpec({ shape: 'cloud-host', domain: 'example.com', targetRef: 'do:main', tier: 's-1vcpu-2gb' }));
244
+ assert.deepEqual(full, { slug: 'cloud-bongos', hosting_shape: 'cloud-host', domain: 'example.com', target_ref: 'do:main', tier: 's-1vcpu-2gb' });
245
245
  });
246
246
 
247
247
  test('provisioningRequest: unknown shape falls back to byo-host, the free front door', () => {
@@ -256,7 +256,7 @@ test('kickoffTasks: provisioning requested → production-topology is AUTOMATED
256
256
  firstAdmin: 'x', version: { id: 'V1', name: 'V1', done_when: 'x' } };
257
257
  const manual = init.kickoffTasks(init.normalizeSpec(baseSpec)); // no provisioning
258
258
  assert.ok(manual.some((t) => /Production topology/i.test(t.title)), 'manual keeps the production-topology task');
259
- const withProv = init.kickoffTasks(init.normalizeSpec({ ...baseSpec, provisioning: { shape: 'dedicated' } }));
259
+ const withProv = init.kickoffTasks(init.normalizeSpec({ ...baseSpec, provisioning: { shape: 'cloud-host' } }));
260
260
  assert.ok(!withProv.some((t) => /Production topology/i.test(t.title)), 'provisioning drops the production-topology task');
261
261
  assert.equal(withProv.length, manual.length - 1, 'exactly one fewer kickoff task');
262
262
  assert.ok(withProv.some((t) => /OAuth/i.test(t.title)), 'OAuth stays manual');
@@ -272,7 +272,7 @@ test('normalizeSpec: coerces the provisioning block; drops it when absent', () =
272
272
  });
273
273
 
274
274
  test('validateSpec: valid provisioning passes; flags an underivable slug', () => {
275
- assert.deepEqual(init.validateSpec(provSpec({ shape: 'dedicated' })), []);
275
+ assert.deepEqual(init.validateSpec(provSpec({ shape: 'cloud-host' })), []);
276
276
  // productName 'x' slugifies to a 1-char slug (< the module's 2-char floor) and no
277
277
  // explicit provisioning.slug overrides it → flagged before the server 400s.
278
278
  const bad = init.normalizeSpec({ identity: { productName: 'x' }, repo: { owner: 'o', name: 'n' }, firstAdmin: 'x',
@@ -642,12 +642,12 @@ async function runSeedTests() {
642
642
 
643
643
  await at('seedViaApi: provisioning success → POSTs the request, no manual topo task, provisioned:true', async () => {
644
644
  const api = fakeApi([['/provisioning/instances', { ok: true, status: 201, data: { instance: { cost_estimate_usd: 12 } } }]]);
645
- const out = await init.seedViaApi(provSpec({ shape: 'dedicated', domain: 'x.example.com', tier: 's-1vcpu-2gb' }), { log: () => {}, api });
645
+ const out = await init.seedViaApi(provSpec({ shape: 'cloud-host', domain: 'x.example.com', tier: 's-1vcpu-2gb' }), { log: () => {}, api });
646
646
  assert.equal(out.provisioned, true);
647
647
  const prov = api.calls.find((c) => c.path.includes('/provisioning/instances'));
648
648
  assert.ok(prov, 'POSTed the provisioning request');
649
649
  assert.equal(prov.body.slug, 'cloud-bongos');
650
- assert.equal(prov.body.hosting_shape, 'dedicated');
650
+ assert.equal(prov.body.hosting_shape, 'cloud-host');
651
651
  assert.ok(!findTaskPost(api, /Production topology/), 'production-topology NOT filed as a manual task');
652
652
  });
653
653
 
package/tests/onboard.mjs CHANGED
@@ -129,8 +129,9 @@ test('onboardPlan lists repo-auth as an AUTO, control-plane step between scaffol
129
129
  const step = plan.find((s) => s.key === 'repo-auth');
130
130
  assert.ok(step, 'the plan carries a repo-auth step');
131
131
  assert.equal(step.kind, 'auto', 'the runner mints + registers the key — no routine owner action');
132
- assert.deepEqual(step.shapes, ['standalone', 'co-tenant', 'dedicated'],
133
- 'self-host has no platform runner, so its plan must not promise this leg');
132
+ assert.deepEqual(step.shapes, ['cloud-host', 'co-tenant'],
133
+ 'byo-host has no platform runner, so its plan must not promise this leg');
134
+ assert.ok(!step.shapes.includes('byo-host'), 'named explicitly: the shape without a runner is the one that must be absent');
134
135
  assert.ok(keys.indexOf('scaffold') < keys.indexOf('repo-auth'), 'the checkout exists before it can be wired');
135
136
  assert.ok(keys.indexOf('repo-auth') < keys.indexOf('provision'), 'wired before the provision leg whose pull needs it');
136
137
  assert.match(step.label, /read-only deploy key/, 'the label says what the key can and cannot do');
@@ -253,13 +254,13 @@ test('buildInitSpec maps answers to a STANDALONE init spec (domain → origin +
253
254
  assert.equal(spec.repo.name, 'mercury');
254
255
  assert.equal(spec.firstAdmin, 'acme');
255
256
  assert.equal(spec.domains.publicOrigin, 'https://mercury.example.com');
256
- assert.equal(spec.provisioning.shape, 'standalone');
257
+ assert.equal(spec.provisioning.shape, 'cloud-host');
257
258
  assert.equal(spec.provisioning.slug, 'mercury');
258
259
  });
259
260
 
260
261
  test('buildInitSpec without a domain still defaults a standalone provisioning shape by slug', () => {
261
262
  const spec = ob.buildInitSpec({ productName: 'X', repoOwner: 'o', repoName: 'x', slug: 'x' });
262
- assert.equal(spec.provisioning.shape, 'standalone');
263
+ assert.equal(spec.provisioning.shape, 'cloud-host');
263
264
  assert.equal(spec.provisioning.domain, null);
264
265
  assert.equal(spec.domains, undefined);
265
266
  });
@@ -42,7 +42,7 @@ const feed = require('../modules/platform-identity/projects-feed.js');
42
42
  // something puts it there. Instance 18's shape — the one whose owner could not invite anyone.
43
43
  const STANDALONE = Object.freeze({
44
44
  id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
45
- hosting_shape: 'standalone', status: 'active', owner_builder_id: 42,
45
+ hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 42,
46
46
  description: 'A harbour for spikes.', type: 'research',
47
47
  settings: { visibility: 'public' },
48
48
  });
@@ -51,7 +51,7 @@ function mockPool(responses = []) {
51
51
  // R18 proof watched reach `publishable: true` and never reach the map.
52
52
  const STANDALONE = Object.freeze({
53
53
  id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
54
- hosting_shape: 'standalone', status: 'active', owner_builder_id: 42,
54
+ hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 42,
55
55
  description: 'A harbour for spikes.', type: 'research',
56
56
  settings: { visibility: 'public', joinability: 'apply' },
57
57
  });
@@ -78,7 +78,7 @@ test('a standalone instance projects a complete catalog entry', () => {
78
78
  test('the projection is BLIND to hosting shape — the old gate is what this task deletes', () => {
79
79
  // shouldFederate() refused anything that was not 'co-tenant', which made hosting topology
80
80
  // decide who is on the map. ADR 0182 D4 says the gate is IDENTITY. Three shapes, one row.
81
- const shapes = ['standalone', 'co-tenant', 'dedicated'].map((hosting_shape) =>
81
+ const shapes = ['cloud-host', 'co-tenant', 'dedicated'].map((hosting_shape) =>
82
82
  bridge.catalogEntryForInstance({ ...STANDALONE, hosting_shape }, { ownerGithubId: 9001 }));
83
83
  assert.deepEqual(shapes[0], shapes[1]);
84
84
  assert.deepEqual(shapes[1], shapes[2]);
@@ -38,7 +38,7 @@ const api = require('../src/module-api.js');
38
38
  const OWNER = { id: 7, github_login: 'owner', rank: 'thetes' };
39
39
  const BASE_ROW = Object.freeze({
40
40
  id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
41
- hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
41
+ hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 7,
42
42
  description: null, type: 'research', settings: { visibility: 'public' },
43
43
  released_domains: [], cost_estimate_usd: 0,
44
44
  });
@@ -130,10 +130,10 @@ function call(method, path, body) {
130
130
  beforeEach(() => { instance = { ...BASE_ROW }; published = []; healed = []; });
131
131
 
132
132
  test('CREATE catalogues the project at birth, whatever its hosting shape', async () => {
133
- // The wizard hard-codes hosting_shape 'standalone', which is exactly the shape
133
+ // The wizard hard-codes hosting_shape 'cloud-host', which is exactly the shape
134
134
  // shouldFederate() refused — so this is the call that ends "never catalogued at all".
135
135
  const res = await call('POST', '/provisioning/instances', {
136
- slug: 'spike-harbor', hosting_shape: 'standalone', description: 'A harbour for spikes.',
136
+ slug: 'spike-harbor', hosting_shape: 'cloud-host', description: 'A harbour for spikes.',
137
137
  });
138
138
  assert.equal(res.status, 201);
139
139
  assert.equal(published.length, 1);
@@ -145,7 +145,7 @@ test('CREATE catalogues even when the owner SKIPS the description', async () =>
145
145
  // Skipping is the supported default: the row exists, the publish gate keeps it dark
146
146
  // matter, and PATCH …/detail is the way back onto the map. The row has to be there for
147
147
  // that way back to reach anything.
148
- const res = await call('POST', '/provisioning/instances', { slug: 'spike-harbor', hosting_shape: 'standalone' });
148
+ const res = await call('POST', '/provisioning/instances', { slug: 'spike-harbor', hosting_shape: 'cloud-host' });
149
149
  assert.equal(res.status, 201);
150
150
  assert.equal(published.length, 1);
151
151
  assert.equal(published[0].row.description, null);
@@ -2186,7 +2186,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2186
2186
  assert.match(HUB, /<h2 class="stepsHead" id="doneStepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style (the id is where a skipped invite step hands focus on, task 1002346)');
2187
2187
  // what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
2188
2188
  assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
2189
- assert.match(SCRIPT, /hosting_shape: 'standalone',/, 'the shape is unchanged');
2189
+ assert.match(SCRIPT, /hosting_shape: 'cloud-host',/, 'the shape is unchanged');
2190
2190
  assert.match(SCRIPT, /target_ref: state\.owner \+ '\/' \+ state\.repo,/, 'the repo ref is unchanged');
2191
2191
  assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \? 'adopt' : 'greenfield'/, 'the onboard mode is unchanged');
2192
2192
  // the kit walks every step and files against the stub