@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.
- package/.bongos-core.json +105 -90
- package/.claude/skills/collab-review/SKILL.md +24 -13
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +154 -4
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +18 -13
- package/docs/copy-registry.json +70 -15
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/collab-lib.js +79 -0
- package/modules/hall-ui/public/collab.css +60 -0
- package/modules/hall-ui/public/collab.js +134 -5
- package/modules/lifecycle/help-requests.js +118 -8
- package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
- package/modules/lifecycle/routes/help-requests.js +93 -13
- package/modules/provisioning/capacity.js +3 -3
- package/modules/provisioning/control-plane-guard.js +1 -1
- package/modules/provisioning/core-upgrade.js +7 -7
- package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
- package/modules/provisioning/onboard-plan.js +1 -1
- package/modules/provisioning/paid-shape-gate.js +28 -15
- package/modules/provisioning/provisioning.js +18 -14
- package/modules/provisioning/routes/repo-private.js +1 -1
- package/modules/provisioning/tests/liveness.mjs +16 -8
- package/modules/provisioning/tests/provisioning.mjs +1 -1
- package/modules/public-landing/public/projects.html +26 -27
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/init.js +12 -4
- package/scripts/gds/onboard.js +1 -1
- package/scripts/gds/provision-capacity.js +6 -1
- package/scripts/gds/provision-config.js +6 -3
- package/scripts/gds/provision-core-upgrade.js +8 -8
- package/scripts/gds/provision-disconnect.js +2 -2
- package/scripts/gds/provision-process.js +2 -2
- package/scripts/gds/provision-repo-private.js +1 -1
- package/scripts/gds/provision-repo.js +13 -3
- package/scripts/gds/provision-teardown.js +2 -2
- package/scripts/gds/provision-units.js +2 -2
- package/scripts/gds/provision.js +6 -6
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +140 -7
- package/tests/core_upgrade_door.mjs +1 -1
- package/tests/core_upgrade_runner.mjs +20 -20
- package/tests/deploy_page_projects.mjs +5 -5
- package/tests/hall_collab_world.mjs +6 -2
- package/tests/help_requests.mjs +201 -6
- package/tests/hub_disconnect_ui.mjs +4 -1
- package/tests/init.mjs +6 -6
- package/tests/onboard.mjs +5 -4
- package/tests/project_catalog_backfill.mjs +1 -1
- package/tests/project_catalog_bridge.mjs +2 -2
- package/tests/project_catalog_routes.mjs +4 -4
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +22 -5
- package/tests/projects_hub_repo_private.mjs +1 -1
- package/tests/provision.mjs +32 -32
- package/tests/provision_byo_host.mjs +1 -1
- package/tests/provision_caddy_wiring.mjs +1 -1
- package/tests/provision_disconnect.mjs +3 -3
- package/tests/provision_repo_private.mjs +1 -1
- package/tests/provision_restart.mjs +5 -5
- package/tests/provision_settings_apply.mjs +4 -4
- package/tests/provision_teardown_address.mjs +2 -2
- package/tests/provisioning_capacity.mjs +40 -34
- package/tests/provisioning_capacity_gate.mjs +5 -12
- package/tests/provisioning_default_domain.mjs +1 -1
- package/tests/provisioning_domain_attach.mjs +4 -4
- package/tests/provisioning_domain_unique.mjs +2 -2
- package/tests/provisioning_error_note_scope.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +5 -5
- package/tests/provisioning_onboard_mode.mjs +2 -2
- package/tests/provisioning_paid_shape_gate.mjs +57 -44
- package/tests/provisioning_repo_private_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +11 -4
- package/tests/provisioning_settings_apply.mjs +12 -5
- package/tests/provisioning_settings_env.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +3 -3
- package/tests/public_landing_projects.mjs +13 -6
- 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
|
-
|
|
112
|
-
|
|
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', () => {
|
package/tests/help_requests.mjs
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
244
|
-
assert.deepEqual(full, { slug: 'cloud-bongos', hosting_shape: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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, '
|
|
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, ['
|
|
133
|
-
'
|
|
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, '
|
|
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, '
|
|
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: '
|
|
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: '
|
|
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 = ['
|
|
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: '
|
|
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 '
|
|
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: '
|
|
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: '
|
|
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);
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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: '
|
|
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:');
|