@bongos/core 1.20.34 → 1.20.36

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 (35) hide show
  1. package/.bongos-core.json +49 -39
  2. package/.claude/skills/ask-for-help/SKILL.md +5 -3
  3. package/.claude/skills/collab-review/SKILL.md +8 -5
  4. package/clients/bongos-client/index.d.ts +1 -1
  5. package/docs/api/openapi.json +3 -2
  6. package/docs/copy-inventory.md +68 -56
  7. package/docs/copy-registry.json +255 -117
  8. package/docs/module-api-changelog.md +4 -0
  9. package/docs/page-readings.json +17 -17
  10. package/modules/hall-ui/public/approval-queue.js +1 -1
  11. package/modules/hall-ui/public/collab-lib.js +10 -1
  12. package/modules/hall-ui/public/collab.js +47 -10
  13. package/modules/hall-ui/public/deploy.js +231 -11
  14. package/modules/hall-ui/public/project-settings.css +28 -0
  15. package/modules/hall-ui/public/studio.js +7 -7
  16. package/modules/lifecycle/help-requests.js +35 -2
  17. package/modules/lifecycle/migrations/lifecycle_016_help_request_reopen.sql +23 -0
  18. package/modules/lifecycle/routes/help-requests.js +15 -1
  19. package/modules/provisioning/core-upgrade.js +4 -1
  20. package/modules/provisioning/render-standup.js +4 -1
  21. package/modules/public-landing/public/projects.html +16 -7
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/release-notes.json +19 -0
  25. package/src/module-api.js +1 -1
  26. package/tests/collab_page.mjs +27 -1
  27. package/tests/core_upgrade_door.mjs +8 -1
  28. package/tests/deploy_page_projects.mjs +165 -1
  29. package/tests/hall_approval_queue.mjs +5 -1
  30. package/tests/hall_client_request_shape.mjs +103 -0
  31. package/tests/hall_studio_home.mjs +7 -1
  32. package/tests/hall_studio_world.mjs +5 -1
  33. package/tests/help_requests.mjs +54 -1
  34. package/tests/projects_hub_app_step.mjs +42 -0
  35. package/tests/provisioning_render_route.mjs +7 -0
@@ -0,0 +1,103 @@
1
+ // tests/hall_client_request_shape.mjs — every write a hall page sends through the
2
+ // generated client's escape hatch carries its payload UNDER `body` (task 1004458).
3
+ //
4
+ // `api.request(method, path, opts)` reads `opts.body` as the JSON payload and
5
+ // nothing else. Handing it the payload itself fails two quiet ways, and both
6
+ // shipped: `{ decision }` (the mingle buttons) and `{ reason }` / `{ version_id,
7
+ // … }` (the studio's verdicts) have no `body` key, so the request goes out EMPTY;
8
+ // and `{ body: text }` (the Collab reply) sends a bare JSON string, which the
9
+ // server's strict parser refuses as `bad_json`. Neither is visible until someone
10
+ // presses the button on the live site.
11
+ //
12
+ // So this RUNS the real client against a recording fetch to pin what `body`
13
+ // means, then scans every hall-side call for an options object whose keys are
14
+ // not options.
15
+ import { strict as assert } from 'node:assert';
16
+ import { test } from 'node:test';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const OPTION_KEYS = new Set(['body', 'query', 'headers', 'hasBody', 'cache']);
23
+
24
+ function client() {
25
+ const sent = [];
26
+ const win = {};
27
+ const src = fs.readFileSync(path.join(ROOT, 'clients', 'bongos-client', 'bongos-client.global.js'), 'utf8');
28
+ new Function('window', 'globalThis', 'self', src)(win, win, win);
29
+ const fetch = async (url, init) => { sent.push({ url, init }); return { ok: true, status: 200, text: async () => '{}' }; };
30
+ return { sent, api: win.BongosClient.createClient({ fetch, throwOnError: false }) };
31
+ }
32
+
33
+ test('the escape hatch sends opts.body as the payload, and nothing else', async () => {
34
+ const { sent, api } = client();
35
+ await api.request('POST', '/x', { body: { body: 'hello' } });
36
+ assert.equal(sent[0].init.body, '{"body":"hello"}', 'a nested object arrives as an object');
37
+ await api.request('POST', '/x', { decision: 'accepted' });
38
+ assert.equal(sent[1].init.body, undefined, 'a payload passed as options is DROPPED — this is the trap');
39
+ });
40
+
41
+ // Every `api.request('POST'|'PATCH'|'PUT'|'DELETE', …)` in a hall-side file.
42
+ function hallFiles() {
43
+ const out = [];
44
+ for (const mod of fs.readdirSync(path.join(ROOT, 'modules'))) {
45
+ const dir = path.join(ROOT, 'modules', mod, 'public');
46
+ if (!fs.existsSync(dir)) continue;
47
+ for (const f of fs.readdirSync(dir)) if (f.endsWith('.js')) out.push(path.join(dir, f));
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // The top-level arguments of the call starting at `open` (the index of its "(").
53
+ function argsAt(src, open) {
54
+ const args = [];
55
+ let depth = 0; let cur = ''; let quote = null;
56
+ for (let i = open + 1; i < src.length; i++) {
57
+ const c = src[i];
58
+ if (quote) { cur += c; if (c === '\\') { cur += src[++i]; } else if (c === quote) quote = null; continue; }
59
+ if (c === '"' || c === "'" || c === '`') { quote = c; cur += c; continue; }
60
+ if ('({['.includes(c)) depth++;
61
+ if (')}]'.includes(c)) { if (depth === 0) { args.push(cur.trim()); return args; } depth--; }
62
+ if (c === ',' && depth === 0) { args.push(cur.trim()); cur = ''; continue; }
63
+ cur += c;
64
+ }
65
+ return args;
66
+ }
67
+
68
+ // The keys of an object literal's top level: `{ a, b: 1, ...c }` → ['a', 'b', '...'].
69
+ function topKeys(obj) {
70
+ const inner = obj.trim().replace(/^\{/, '').replace(/\}$/, '');
71
+ return argsAt(`(${inner})`, 0).filter(Boolean).map((part) => {
72
+ if (part.startsWith('...')) return '...';
73
+ const m = /^([A-Za-z_$][\w$]*)\s*(?::|$)/.exec(part);
74
+ return m ? m[1] : part;
75
+ });
76
+ }
77
+
78
+ test('no hall page hands the client a payload where it expects options', () => {
79
+ const bad = [];
80
+ let seen = 0;
81
+ for (const file of hallFiles()) {
82
+ const src = fs.readFileSync(file, 'utf8');
83
+ const re = /\bapi\.request\(\s*'(POST|PATCH|PUT|DELETE)'/g;
84
+ let m;
85
+ while ((m = re.exec(src))) {
86
+ seen++;
87
+ const args = argsAt(src, src.indexOf('(', m.index));
88
+ const where = `${path.relative(ROOT, file)}:${src.slice(0, m.index).split('\n').length}`;
89
+ const opts = args[2];
90
+ if (!opts) continue;
91
+ if (!opts.startsWith('{')) { bad.push(`${where} passes \`${opts}\` — wrap it as { body: … }`); continue; }
92
+ const unknown = topKeys(opts).filter((k) => !OPTION_KEYS.has(k));
93
+ if (unknown.length) bad.push(`${where} passes ${unknown.join(', ')} as options — they are dropped; nest them under body`);
94
+ }
95
+ }
96
+ assert.ok(seen > 10, `the scan found the calls it is guarding (${seen})`);
97
+ assert.deepEqual(bad, [], bad.join('\n'));
98
+ });
99
+
100
+ test('the Collab reply nests its `body` field inside the payload', () => {
101
+ const js = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'collab.js'), 'utf8');
102
+ assert.match(js, /\/replies`, \{ body: \{ body: v\.body \} \}\)/, 'a bare string is refused by the server as bad_json');
103
+ });
@@ -130,7 +130,13 @@ function bootStudio({ answers = {}, signedIn = true } = {}) {
130
130
  execCommand() {},
131
131
  };
132
132
  const client = {
133
- request: async (method, url, body) => {
133
+ // The real client takes an OPTIONS object and sends `opts.body` as the JSON
134
+ // payload (task 1004458 moved every hall write onto that shape). Record the
135
+ // payload, not the options, so assertions read what the server would receive.
136
+ request: async (method, url, opts) => {
137
+ // Strictly `opts.body`, like the real client: a bare payload passed as the
138
+ // third argument arrives as undefined, which is the dropped-payload bug 1004458 fixed.
139
+ const body = opts ? opts.body : undefined;
134
140
  requests.push({ method, url, body });
135
141
  const key = `${method} ${url.replace(/^\/api\/bongos/, '')}`;
136
142
  if (key === 'GET /me') return signedIn ? ok({ builder: { id: ME, display_name: 'Artist A', rank: 'metic' } }) : { ok: false, status: 401, data: {} };
@@ -239,7 +239,11 @@ async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags =
239
239
  return els.get(id);
240
240
  };
241
241
  const calls = [];
242
- const request = async (method, url, body) => {
242
+ // The REAL client's shape: request(method, path, { body }). This fake once took
243
+ // the payload as the third argument, which is exactly the mistake the studio
244
+ // made — so every verdict here was green while each one sent nothing (task 1004458).
245
+ const request = async (method, url, opts) => {
246
+ const body = opts && opts.body;
243
247
  calls.push({ method, url, body });
244
248
  if (method !== 'GET') {
245
249
  const custom = onWrite && onWrite({ method, url, body });
@@ -276,8 +276,10 @@ test('the settle refusal no longer lets you enumerate ids', () => {
276
276
  const from = routes.indexOf("'PATCH /help-requests/:id'");
277
277
  const patch = routes.slice(from, routes.indexOf('help_request_update_failed', from));
278
278
  assert.ok(!/getHelpRequest/.test(patch), 'no existence lookup in the refusal path');
279
+ // One refusal per WRITE: the settle's, and since task 1004458 the reopen's.
280
+ // Each covers absent / not yours / wrong state in one sentence.
279
281
  const reasons = patch.match(/reason: '[^']+'/g) || [];
280
- assert.equal(reasons.length, 1, `exactly one refusal reason, got ${reasons}`);
282
+ assert.equal(reasons.length, 2, `exactly one refusal reason per write, got ${reasons}`);
281
283
  });
282
284
 
283
285
  test('both reads are capped', () => {
@@ -504,3 +506,54 @@ test('a reply to an ask that never existed is a named 404, not an FK 500', () =>
504
506
  const line = routes.slice(routes.lastIndexOf('router.post', idx), idx);
505
507
  assert.match(line, /api\.requireBuilder/, 'signed in, like every other route here');
506
508
  });
509
+
510
+ // ---- reopen (task 1004458) ------------------------------------------------
511
+ // RUN against a recording pool, like the settle tests above: the rule is the
512
+ // WHERE, and a source grep would agree with a WHERE that had lost a clause.
513
+ async function reopenSql(args) {
514
+ const calls = [];
515
+ const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 1 }] }; } };
516
+ const row = await help.reopenHelpRequest(args, { pool });
517
+ return { ...calls[0], row };
518
+ }
519
+
520
+ test('only the ASKER reopens, and only a settled ask', async () => {
521
+ const c = await reopenSql({ id: 5, builderId: 7 });
522
+ const where = c.sql.slice(c.sql.indexOf('WHERE'), c.sql.indexOf('RETURNING'));
523
+ assert.match(where, /requested_by = \$2/, 'the asker, bound, in the WHERE');
524
+ assert.ok(!/needs_builder_id|needs_craft/.test(where), 'the addressee has no branch here');
525
+ assert.ok(!/\bOR\b/.test(where), `a pure conjunction, so no clause can be bypassed: ${where}`);
526
+ assert.match(where, /status <> 'open'/, 'an open ask is not reopened again');
527
+ assert.deepEqual(c.params, [5, 7]);
528
+ });
529
+
530
+ test('reopening clears who settled it and marks it reopened', async () => {
531
+ const c = await reopenSql({ id: 5, builderId: 7 });
532
+ assert.match(c.sql, /status = 'open'/);
533
+ assert.match(c.sql, /settled_by = NULL/, 'it no longer reads as closed by someone');
534
+ assert.match(c.sql, /settled_reason = NULL/, 'nor as closed by the ledger');
535
+ assert.match(c.sql, /reopened_at = now\(\)/, 'the fact the ledger settle reads to leave it alone');
536
+ });
537
+
538
+ test('a REOPENED ask is never closed again by its task having shipped', async () => {
539
+ const calls = [];
540
+ const pool = { query: async (sql) => { calls.push(sql); return { rows: [] }; } };
541
+ await help.settleFromLedger({ pool });
542
+ assert.match(calls[0], /h\.reopened_at IS NULL/, 'the asker said the shipped task did not answer it');
543
+ });
544
+
545
+ test('PATCH routes status open to the asker-only reopen, never to the settle', () => {
546
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
547
+ const idx = routes.indexOf("'PATCH /help-requests/:id'");
548
+ const body = routes.slice(idx, routes.indexOf('errorCode', idx));
549
+ assert.match(body, /enum: \['answered', 'withdrawn', 'open'\]/);
550
+ assert.match(body, /req\.body\.status === 'open'[\s\S]*?reopenHelpRequest\(\{ id, builderId: req\.builder\.id \}\)/,
551
+ 'the reopen takes the caller from the session, and no crafts at all');
552
+ assert.ok(body.indexOf('reopenHelpRequest') < body.indexOf('setHelpRequestStatus'), 'and returns before the settle runs');
553
+ });
554
+
555
+ test('the reopen migration adds the column and alters no core table', () => {
556
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_016_help_request_reopen.sql');
557
+ assert.match(sql, /ALTER TABLE lifecycle_help_requests\s+ADD COLUMN IF NOT EXISTS reopened_at timestamptz/);
558
+ assert.ok(!/ALTER TABLE (?!lifecycle_)/.test(sql));
559
+ });
@@ -283,6 +283,48 @@ test('Next needs a complete answer: a mode, then a start command for a native ru
283
283
  assert.equal(w.validateRender(), true, 'docker needs no commands');
284
284
  });
285
285
 
286
+ // task 1004457 — found live: the ask stayed on screen after the command was typed.
287
+ test('typing a start command clears the "how your app starts" ask at once', async () => {
288
+ const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
289
+ w.typeKey(KEY);
290
+ await w.check();
291
+ await w.pickWorkspace(0);
292
+ w.pickService({ value: 'new' });
293
+ assert.equal(w.validateRender(), false);
294
+ assert.match(w.$('h-render').className, /err/);
295
+ w.$('f-renderStart').value = ' ';
296
+ w.$('renderNew').fire('input', { target: w.$('f-renderStart') });
297
+ assert.match(w.$('h-render').textContent, /how your app starts/, 'blank is still no answer');
298
+ w.$('f-renderStart').value = 'npm start';
299
+ w.$('renderNew').fire('input', { target: w.$('f-renderStart') });
300
+ assert.doesNotMatch(w.$('h-render').className, /err/);
301
+ assert.doesNotMatch(w.$('h-render').textContent, /how your app starts/);
302
+ });
303
+
304
+ test('the empty command boxes read as examples, not as filled-in values', () => {
305
+ assert.match(HUB, /id="f-renderBuild"[^>]*placeholder="for example: npm ci"/);
306
+ assert.match(HUB, /id="f-renderStart"[^>]*placeholder="for example: npm start"/);
307
+ });
308
+
309
+ // task 1004457 — owner call 2026-09-30: offer Render's Free web plan, never preselected.
310
+ test('Free is on the plan list with its catch spelled out, and Starter stays the default', async () => {
311
+ const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
312
+ w.typeKey(KEY);
313
+ await w.check();
314
+ await w.pickWorkspace(0);
315
+ w.pickService({ value: 'new' });
316
+ const opts = w.$('f-renderPlan').children;
317
+ assert.deepEqual(opts.map((o) => o.value), ['free', 'starter', 'standard', 'pro']);
318
+ assert.match(opts[0].textContent, /^Free — \$0, but it sleeps when idle/);
319
+ assert.equal(opts.find((o) => o.selected).value, 'starter', 'Free is never the preselected plan');
320
+ w.state.renderPlan = 'free'; w.state.renderStart = 'npm start';
321
+ assert.equal(w.renderRequestBody().web_plan, 'free');
322
+ const dbOpts = w.$('f-renderDbPlan').children.map((o) => o.value);
323
+ assert.equal(dbOpts.includes('free'), false, 'the database stays paid-only');
324
+ const r = load({ draft: { appPlatform: 'render', renderOwnerName: 'Mine', renderMode: 'greenfield', renderRuntime: 'node', renderPlan: 'free' } });
325
+ assert.equal(r.appRow(), 'Render, workspace Mine — a new app (Node.js, Free, $0, but it sleeps when idle and takes about a minute to wake up), no database. Render bills you directly.');
326
+ });
327
+
286
328
  test('what Create sends: greenfield carries the build and the plans, a link only the service', async () => {
287
329
  const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
288
330
  w.typeKey(KEY);
@@ -116,6 +116,13 @@ await ta('a missing project is 404', async () => {
116
116
  assert.equal((await post(PATH, GREEN)).status, 404);
117
117
  });
118
118
 
119
+ await ta('Render\'s Free web plan is accepted and recorded (task 1004457)', async () => {
120
+ reset();
121
+ const r = await post(PATH, { ...GREEN, web_plan: 'free' });
122
+ assert.equal(r.status, 200, r.raw);
123
+ assert.equal(enqueueCalls[0].appHost.webPlan, 'free');
124
+ });
125
+
119
126
  console.log('\nthe shape is checked first:');
120
127
  for (const [name, body, re] of [
121
128
  ['a key that is not a Render key', { ...GREEN, key: 'ghp_notrender' }, /Render API key/],