@bongos/core 1.19.999 → 1.19.1000

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 (86) hide show
  1. package/.bongos-core.json +105 -90
  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 +154 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/copy-inventory.md +18 -13
  13. package/docs/copy-registry.json +70 -15
  14. package/docs/module-api-changelog.md +2 -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/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
  25. package/modules/provisioning/onboard-plan.js +1 -1
  26. package/modules/provisioning/paid-shape-gate.js +28 -15
  27. package/modules/provisioning/provisioning.js +18 -14
  28. package/modules/provisioning/routes/repo-private.js +1 -1
  29. package/modules/provisioning/tests/liveness.mjs +16 -8
  30. package/modules/provisioning/tests/provisioning.mjs +1 -1
  31. package/modules/public-landing/public/projects.html +26 -27
  32. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/scripts/gds/init.js +12 -4
  36. package/scripts/gds/onboard.js +1 -1
  37. package/scripts/gds/provision-capacity.js +6 -1
  38. package/scripts/gds/provision-config.js +6 -3
  39. package/scripts/gds/provision-core-upgrade.js +8 -8
  40. package/scripts/gds/provision-disconnect.js +2 -2
  41. package/scripts/gds/provision-process.js +2 -2
  42. package/scripts/gds/provision-repo-private.js +1 -1
  43. package/scripts/gds/provision-repo.js +13 -3
  44. package/scripts/gds/provision-teardown.js +2 -2
  45. package/scripts/gds/provision-units.js +2 -2
  46. package/scripts/gds/provision.js +6 -6
  47. package/src/module-api.js +1 -1
  48. package/tests/collab_page.mjs +140 -7
  49. package/tests/core_upgrade_door.mjs +1 -1
  50. package/tests/core_upgrade_runner.mjs +20 -20
  51. package/tests/deploy_page_projects.mjs +5 -5
  52. package/tests/hall_collab_world.mjs +6 -2
  53. package/tests/help_requests.mjs +201 -6
  54. package/tests/hub_disconnect_ui.mjs +4 -1
  55. package/tests/init.mjs +6 -6
  56. package/tests/onboard.mjs +5 -4
  57. package/tests/project_catalog_backfill.mjs +1 -1
  58. package/tests/project_catalog_bridge.mjs +2 -2
  59. package/tests/project_catalog_routes.mjs +4 -4
  60. package/tests/projects_hub.mjs +1 -1
  61. package/tests/projects_hub_pre_uat.mjs +22 -5
  62. package/tests/projects_hub_repo_private.mjs +1 -1
  63. package/tests/provision.mjs +32 -32
  64. package/tests/provision_byo_host.mjs +1 -1
  65. package/tests/provision_caddy_wiring.mjs +1 -1
  66. package/tests/provision_disconnect.mjs +3 -3
  67. package/tests/provision_repo_private.mjs +1 -1
  68. package/tests/provision_restart.mjs +5 -5
  69. package/tests/provision_settings_apply.mjs +4 -4
  70. package/tests/provision_teardown_address.mjs +2 -2
  71. package/tests/provisioning_capacity.mjs +40 -34
  72. package/tests/provisioning_capacity_gate.mjs +5 -12
  73. package/tests/provisioning_default_domain.mjs +1 -1
  74. package/tests/provisioning_domain_attach.mjs +4 -4
  75. package/tests/provisioning_domain_unique.mjs +2 -2
  76. package/tests/provisioning_error_note_scope.mjs +1 -1
  77. package/tests/provisioning_module_selection.mjs +5 -5
  78. package/tests/provisioning_onboard_mode.mjs +2 -2
  79. package/tests/provisioning_paid_shape_gate.mjs +57 -44
  80. package/tests/provisioning_repo_private_route.mjs +1 -1
  81. package/tests/provisioning_restart.mjs +11 -4
  82. package/tests/provisioning_settings_apply.mjs +12 -5
  83. package/tests/provisioning_settings_env.mjs +1 -1
  84. package/tests/provisioning_teardown_intent.mjs +3 -3
  85. package/tests/public_landing_projects.mjs +13 -6
  86. package/tests/upgrade.mjs +2 -2
@@ -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
@@ -78,7 +78,11 @@ test('the restart door appears ONLY where it has a reason to exist, and mirrors
78
78
  assert.match(body, /inst\.last_verified_up === false/, 'the probe verdict is the trigger — a healthy card grows no Restart button');
79
79
  assert.doesNotMatch(body, /last_verified_up === null|!inst\.last_verified_up/,
80
80
  'null (never probed) is deliberately NOT the same claim as false — publicInstance keeps the two apart');
81
- assert.match(body, /inst\.hosting_shape !== 'dedicated'/, 'RESTART_SHAPES — the runner has no exec path to a droplet');
81
+ // The shape term is gone: it excluded 'dedicated', which ADR 0327 decision 4 removed and
82
+ // task 1004182 folded into 'cloud-host'. A `!== 'dedicated'` test against a value no row
83
+ // can hold is always true, so keeping it would have been a condition that reads like a
84
+ // guard and guards nothing.
85
+ assert.doesNotMatch(body, /hosting_shape/, 'no shape term survives — the one it excluded no longer exists');
82
86
  assert.match(body, /!inst\.open_intent/, 'the strip tells the story while work is in flight');
83
87
  });
84
88
 
@@ -237,7 +241,7 @@ test('no wall-clock promises, no operator vocabulary on the stranger’s path',
237
241
  assert.doesNotMatch(HUB, /about five minutes/, 'the empty state');
238
242
  assert.doesNotMatch(HUB, /a runner does the real work/, 'the mine fine print');
239
243
  assert.doesNotMatch(SCRIPT, /pinned platform core/, 'the review Shape row');
240
- /* The wizard creates hosting_shape 'standalone', whose owner-facing name became
244
+ /* The wizard creates hosting_shape 'cloud-host', whose owner-facing name became
241
245
  "cloud host" at ADR 0323 — "its own server" was ambiguous between that shape and
242
246
  a dedicated one, and said nothing about WHO runs it. The assertion still guards the
243
247
  same property this test is about: owner vocabulary, and no wall-clock promise. */
@@ -443,7 +447,14 @@ test('decision 6 — the manage section is "Take it offline", and the teardown t
443
447
  assert.doesNotMatch(HUB, /Delete the project/, 'the word "Delete" no longer promises an erase anywhere on the page');
444
448
  const truth = fn('teardownTruth');
445
449
  // scoped to THIS control since task 1003249: the page now also carries the true delete
446
- assert.equal((truth.match(/until you bring it back\. Taking it offline erases nothing\.'/g) || []).length, 2, 'both hosting shapes end on the same sentence');
450
+ // Was 2 — one per hosting-shape branch, asserting they ENDED THE SAME WAY. Task 1004182
451
+ // left one branch (ADR 0327 decision 4 removed 'dedicated'), so the count is 1 and the
452
+ // "same sentence" property is now structural rather than checked. What the owner's
453
+ // decision 6 actually demands is that the reassurance is there and is the LAST thing said,
454
+ // so assert that instead of a branch count that only records how many shapes exist.
455
+ 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');
456
+ assert.ok(!truth.slice(truth.indexOf('Taking it offline erases nothing.')).includes('return '),
457
+ 'and it is the LAST thing teardownTruth returns — no arm answers after the reassurance');
447
458
  });
448
459
 
449
460
  test('decision 5 — the Type step says where the type can be changed (task 1003504)', () => {
@@ -568,8 +579,14 @@ test('teardown copy is state-aware: a queued project is told nothing was built',
568
579
  const truth = fn('teardownTruth');
569
580
  assert.match(truth, /if \(inst && inst\.status === 'requested'\) return 'Nothing has been stood up yet — this cancels the standup\. Anything already on GitHub is untouched, and the name and web address stay reserved for this project\.';/);
570
581
  const arm = truth.indexOf("inst.status === 'requested'");
571
- const shape = truth.indexOf("hosting_shape === 'dedicated'");
572
- assert.ok(arm > 0 && arm < shape, 'the pre-standup arm comes before the shape arms');
582
+ assert.ok(arm > 0, 'the pre-standup arm is present');
583
+ // This used to assert the pre-standup arm came BEFORE the shape arms. There are no shape
584
+ // arms left — 'dedicated' was the only shape teardownTruth branched on, and task 1004182
585
+ // removed it — so the ordering has nothing to order. What still matters is that the
586
+ // pre-standup case is answered FIRST and then returns, rather than falling through to the
587
+ // built-world text for a project that was never stood up.
588
+ assert.ok(arm < truth.indexOf('The server goes away'), 'the pre-standup arm still precedes the built-world text');
589
+ assert.doesNotMatch(truth, /hosting_shape/, 'teardown truth no longer varies by shape');
573
590
  assert.doesNotMatch(truth, /status === 'error'|status === 'provisioning'/, 'error and provisioning keep today’s text — a failed run may already have built things');
574
591
  });
575
592
 
@@ -67,7 +67,7 @@ function world({ reply = RECEIPT } = {}) {
67
67
  return { ctx, els, calls, done };
68
68
  }
69
69
 
70
- const LIVE = { id: 18, status: 'active', hosting_shape: 'standalone', target_ref: 'o/demo', open_intent: null };
70
+ const LIVE = { id: 18, status: 'active', hosting_shape: 'cloud-host', target_ref: 'o/demo', open_intent: null };
71
71
  const PUBLIC = { private: false, admin: true };
72
72
 
73
73
  console.log('the door:');