@bongos/core 1.19.528 → 1.19.530
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 +40 -25
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0243-a-bundle-adjustment-belongs-to-the-bundle-not-the-advice-about-it.md +159 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -3
- package/docs/copy-inventory.md +219 -219
- package/docs/copy-registry.json +282 -282
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/recommendations.js +18 -57
- package/modules/provisioning/routes/callback-page.js +159 -0
- package/modules/provisioning/routes/provisioning.js +10 -138
- package/modules/provisioning/starter-bundles.js +143 -4
- package/modules/public-landing/public/projects.html +55 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/design_tokens_sync.mjs +1 -1
- package/tests/projects_hub_module_picker.mjs +217 -4
- package/tests/provisioning_callback_page.mjs +74 -0
- package/tests/provisioning_module_selection.mjs +64 -4
- package/tests/provisioning_starter_bundles.mjs +90 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// tests/provisioning_callback_page.mjs — the pin for the extracted callback-page
|
|
2
|
+
// module (task 1003570).
|
|
3
|
+
//
|
|
4
|
+
// WHY. `esc` and `callbackPage` were carried inside modules/provisioning/routes/
|
|
5
|
+
// provisioning.js and were only ever exercised THROUGH the route
|
|
6
|
+
// (tests/provisioning_github_app_routes.mjs mounts the router and reads the served
|
|
7
|
+
// HTML). Once they became a module of their own they needed a pin of their own, or
|
|
8
|
+
// the extraction would be a surface nothing tests directly: this file requires
|
|
9
|
+
// callback-page.js and asserts the escaping contract and that both page variants
|
|
10
|
+
// carry the ESCAPED values, so a later edit that drops an esc() call — the XSS shape
|
|
11
|
+
// this helper exists to prevent — fails here rather than in a served page.
|
|
12
|
+
//
|
|
13
|
+
// DB-free and network-free: callback-page.js reaches core only for branding().
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test --test-reporter=tap tests/provisioning_callback_page.mjs
|
|
16
|
+
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
|
|
20
|
+
// ESM named imports, deliberately — knip credits a named CJS export to a
|
|
21
|
+
// destructured/named binding but not to a property read off a createRequire
|
|
22
|
+
// namespace, and these two exports have no other reader outside the route file.
|
|
23
|
+
import { esc, callbackPage } from '../modules/provisioning/routes/callback-page.js';
|
|
24
|
+
|
|
25
|
+
test('esc replaces every HTML-significant character, and nothing else', () => {
|
|
26
|
+
assert.equal(esc('&<>"\''), '&<>"'');
|
|
27
|
+
assert.equal(esc('<script>alert(1)</script>'), '<script>alert(1)</script>');
|
|
28
|
+
// repeats are all replaced, not just the first
|
|
29
|
+
assert.equal(esc('<<>>'), '<<>>');
|
|
30
|
+
// ordinary text passes through untouched
|
|
31
|
+
assert.equal(esc('mercury.example.com'), 'mercury.example.com');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('esc renders an absent value as the empty string, never "null"/"undefined"', () => {
|
|
35
|
+
assert.equal(esc(null), '');
|
|
36
|
+
assert.equal(esc(undefined), '');
|
|
37
|
+
assert.equal(esc(0), '0');
|
|
38
|
+
assert.equal(esc(false), 'false');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('the progress variant escapes the title + domain it interpolates', () => {
|
|
42
|
+
const html = callbackPage({
|
|
43
|
+
ok: true,
|
|
44
|
+
title: 'Setting up <your> instance',
|
|
45
|
+
state: 'GOODSTATE',
|
|
46
|
+
domain: 'evil".example.com',
|
|
47
|
+
});
|
|
48
|
+
assert.match(html, /<title>Setting up <your> instance<\/title>/);
|
|
49
|
+
assert.match(html, /evil"\.example\.com/, 'the domain rides through esc');
|
|
50
|
+
assert.doesNotMatch(html, /Setting up <your> instance/, 'no raw title anywhere on the page');
|
|
51
|
+
assert.doesNotMatch(html, /https:\/\/evil"\.example\.com/, 'no unescaped domain in an href');
|
|
52
|
+
// it IS the progress variant: the stepper + the config block the client script reads
|
|
53
|
+
assert.match(html, /id="steps"/);
|
|
54
|
+
assert.match(html, /__CB_SETUP__=\{"state":"GOODSTATE","domain":"evil\\"\.example\.com"\}/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the message variant escapes the title but passes server-authored body HTML through', () => {
|
|
58
|
+
const html = callbackPage({
|
|
59
|
+
ok: false,
|
|
60
|
+
title: 'This setup link has <expired>',
|
|
61
|
+
body: '<p>Ask the owner for a fresh link.</p>',
|
|
62
|
+
});
|
|
63
|
+
assert.match(html, /<h1>This setup link has <expired><\/h1>/);
|
|
64
|
+
assert.match(html, /<div class="sub"><p>Ask the owner for a fresh link\.<\/p><\/div>/,
|
|
65
|
+
'body is trusted server-authored HTML — the route composes it, a request never does');
|
|
66
|
+
assert.doesNotMatch(html, /id="steps"/, 'no stepper without ok + state + domain');
|
|
67
|
+
assert.match(html, /badge-warn">⚠️</, 'a failure wears the warning badge');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('ok without state or domain falls back to the message variant', () => {
|
|
71
|
+
const html = callbackPage({ ok: true, title: 'GitHub app created', body: '<p>Done.</p>' });
|
|
72
|
+
assert.doesNotMatch(html, /__CB_SETUP__/, 'nothing to poll for, so no client config block');
|
|
73
|
+
assert.match(html, /badge-warn">✓</, 'but it is still a success');
|
|
74
|
+
});
|
|
@@ -30,7 +30,7 @@ import http from 'node:http';
|
|
|
30
30
|
// ESM named imports off the CJS module on purpose — knip cannot trace a
|
|
31
31
|
// createRequire() import, so these would read as dead exports (the repo's knip
|
|
32
32
|
// learning; tests/provisioning_starter_bundles.mjs does the same).
|
|
33
|
-
import { ALWAYS_ON_CORE, bundleForProjectType } from '../modules/provisioning/starter-bundles.js';
|
|
33
|
+
import { ALWAYS_ON_CORE, bundleForProjectType, effectiveModules } from '../modules/provisioning/starter-bundles.js';
|
|
34
34
|
|
|
35
35
|
const require = createRequire(import.meta.url);
|
|
36
36
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
@@ -152,9 +152,13 @@ t('publicInstance: a row written before provisioning_014 reads as the bundle, no
|
|
|
152
152
|
|
|
153
153
|
let lastCreateArgs = null;
|
|
154
154
|
let existingRow = null;
|
|
155
|
+
// The row PATCH …/detail reads, and the row its UPDATE returned. Both null by
|
|
156
|
+
// default, so the POST /instances tests below see the same "no such id" world.
|
|
157
|
+
let detailRow = null;
|
|
158
|
+
let lastUpdatedRow = null;
|
|
155
159
|
provisioning.getInstanceBySlug = async () => existingRow;
|
|
156
160
|
provisioning.getInstanceByDomain = async () => null;
|
|
157
|
-
provisioning.getInstanceById = async () =>
|
|
161
|
+
provisioning.getInstanceById = async () => detailRow;
|
|
158
162
|
provisioning.createInstance = async (_db, args) => {
|
|
159
163
|
lastCreateArgs = args;
|
|
160
164
|
return existingRow
|
|
@@ -164,6 +168,18 @@ provisioning.createInstance = async (_db, args) => {
|
|
|
164
168
|
provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
165
169
|
provisioning.getOpenIntent = async () => null;
|
|
166
170
|
provisioning.recordEvent = async () => {};
|
|
171
|
+
// Mirrors the real statement: `detail = detail || $n::jsonb` MERGES the
|
|
172
|
+
// normalized answers onto what is already stored. The row the route then hands
|
|
173
|
+
// `effectiveModules` is that merged one, so a stub that returned the pre-patch
|
|
174
|
+
// row would hide exactly the staleness this test exists to catch.
|
|
175
|
+
provisioning.updateInstanceDetail = async (_db, _id, patch) => {
|
|
176
|
+
lastUpdatedRow = { ...detailRow };
|
|
177
|
+
if (patch.description !== undefined) lastUpdatedRow.description = patch.description;
|
|
178
|
+
if (patch.detail !== undefined) {
|
|
179
|
+
lastUpdatedRow.detail = { ...(detailRow.detail || {}), ...provisioning.normalizeProjectDetail(patch.detail) };
|
|
180
|
+
}
|
|
181
|
+
return lastUpdatedRow;
|
|
182
|
+
};
|
|
167
183
|
|
|
168
184
|
const api = require('../src/module-api.js');
|
|
169
185
|
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
@@ -185,11 +201,11 @@ const server = app.listen(0);
|
|
|
185
201
|
await new Promise((r) => server.once('listening', r));
|
|
186
202
|
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
187
203
|
|
|
188
|
-
function
|
|
204
|
+
function send(method, path, body) {
|
|
189
205
|
return new Promise((resolve, reject) => {
|
|
190
206
|
const payload = JSON.stringify(body);
|
|
191
207
|
const req = http.request(`${base}${path}`, {
|
|
192
|
-
method
|
|
208
|
+
method,
|
|
193
209
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
194
210
|
}, (res) => {
|
|
195
211
|
let d = '';
|
|
@@ -200,6 +216,8 @@ function post(path, body) {
|
|
|
200
216
|
req.end(payload);
|
|
201
217
|
});
|
|
202
218
|
}
|
|
219
|
+
const post = (path, body) => send('POST', path, body);
|
|
220
|
+
const patch = (path, body) => send('PATCH', path, body);
|
|
203
221
|
|
|
204
222
|
await ta('POST /instances: the picker\'s ids ride the create request through to storage', async () => {
|
|
205
223
|
// Arrange
|
|
@@ -281,6 +299,48 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
|
|
|
281
299
|
existingRow = null;
|
|
282
300
|
});
|
|
283
301
|
|
|
302
|
+
// ── PATCH /instances/:id/detail, over the same mounted router ──────────────
|
|
303
|
+
// The interview's answer MOVES a bundle-sourced project's modules (BV1.R22, ADR
|
|
304
|
+
// 0243), so the reply that takes the answer says what the project now has rather
|
|
305
|
+
// than making the caller re-read. openapi.json names `modules` in this response's
|
|
306
|
+
// `required` list; nothing kept that promise until here — only the pure
|
|
307
|
+
// `effectiveModules` was pinned (tests/provisioning_starter_bundles.mjs).
|
|
308
|
+
|
|
309
|
+
await ta('PATCH /detail: the reply carries modules, resolved from the row the UPDATE returned', async () => {
|
|
310
|
+
// Arrange — a game project that skipped the picker, so its modules still
|
|
311
|
+
// resolve from the bundle and an interview answer can still move them.
|
|
312
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
313
|
+
lastUpdatedRow = null;
|
|
314
|
+
|
|
315
|
+
// Act — the owner says they are building it alone
|
|
316
|
+
const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
|
|
317
|
+
|
|
318
|
+
// Assert — the field is there, and it is the POST-patch resolution: a route
|
|
319
|
+
// that answered off `inst` instead of `updated` would still say 'dev-box'.
|
|
320
|
+
assert.equal(res.status, 200, res.raw);
|
|
321
|
+
assert.ok(lastUpdatedRow, 'the route ran the update');
|
|
322
|
+
assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow));
|
|
323
|
+
assert.deepEqual(res.body.modules.selected, ['discord'], 'solo drops the shared dev box out of the game bundle');
|
|
324
|
+
assert.equal(res.body.modules.source, 'bundle');
|
|
325
|
+
assert.ok(res.body.modules.adjustments.some((a) => a.module === 'dev-box'), 'and the change carries its reason');
|
|
326
|
+
detailRow = null; lastUpdatedRow = null;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await ta('PATCH /detail: a description-only patch answers with modules too — the field is not conditional', async () => {
|
|
330
|
+
// openapi lists `modules` as REQUIRED, so every 200 off this route carries it,
|
|
331
|
+
// including the ones that moved nothing a bundle reads.
|
|
332
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'research', modules: ['agents', 'discord'], detail: {} };
|
|
333
|
+
lastUpdatedRow = null;
|
|
334
|
+
|
|
335
|
+
const res = await patch('/provisioning/instances/41/detail', { description: 'A study of small things.' });
|
|
336
|
+
|
|
337
|
+
assert.equal(res.status, 200, res.raw);
|
|
338
|
+
assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow));
|
|
339
|
+
assert.deepEqual(res.body.modules.selected, ['agents', 'discord'], 'a chosen set is not re-derived from the type');
|
|
340
|
+
assert.equal(res.body.modules.source, 'chosen');
|
|
341
|
+
detailRow = null; lastUpdatedRow = null;
|
|
342
|
+
});
|
|
343
|
+
|
|
284
344
|
server.close();
|
|
285
345
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
286
346
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -37,8 +37,11 @@ import {
|
|
|
37
37
|
OPTIONAL_MODULES,
|
|
38
38
|
STARTER_BUNDLES,
|
|
39
39
|
HUB_ONLY_MODULES,
|
|
40
|
+
RULED_TEAM_SHAPES,
|
|
40
41
|
bundleForProjectType,
|
|
41
42
|
allStarterBundles,
|
|
43
|
+
adjustBundle,
|
|
44
|
+
starterBundleAnswerTable,
|
|
42
45
|
normalizeModuleSelection,
|
|
43
46
|
effectiveModules,
|
|
44
47
|
} from '../modules/provisioning/starter-bundles.js';
|
|
@@ -259,8 +262,60 @@ test('GET /provisioning/starter-bundles is public and serves the resolved bundle
|
|
|
259
262
|
// Assert
|
|
260
263
|
assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
|
|
261
264
|
assert.deepEqual(res.body.core, [...ALWAYS_ON_CORE]);
|
|
262
|
-
|
|
265
|
+
// `starterBundleAnswerTable` since R22 (task 1002341): every bundle, plus the
|
|
266
|
+
// same bundle re-resolved for each About-step answer. It is a superset of
|
|
267
|
+
// `allStarterBundles()` — the untailored keys are byte-for-byte what they were.
|
|
268
|
+
assert.deepEqual(res.body.bundles, starterBundleAnswerTable());
|
|
263
269
|
assert.equal(res.body.bundles.length, provisioning.PROJECT_TYPES.length);
|
|
270
|
+
for (const [i, plain] of allStarterBundles().entries()) {
|
|
271
|
+
const { byTeamShape, ...rest } = res.body.bundles[i];
|
|
272
|
+
assert.deepEqual(rest, plain, `bundle ${plain.type} kept its pre-R22 shape`);
|
|
273
|
+
assert.ok(byTeamShape, `bundle ${plain.type} carries the tailored table`);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('every bundle carries a resolution for every team shape a rule can fire on', async () => {
|
|
278
|
+
// The picker preselects straight out of this table with no second read (ADR
|
|
279
|
+
// 0243), so a missing row is a panel that silently preselects the untailored
|
|
280
|
+
// set for an answer the owner did give.
|
|
281
|
+
const res = await get('/api/bongos/provisioning/starter-bundles');
|
|
282
|
+
|
|
283
|
+
for (const row of res.body.bundles) {
|
|
284
|
+
assert.deepEqual(Object.keys(row.byTeamShape).sort(), [...RULED_TEAM_SHAPES].sort(),
|
|
285
|
+
`${row.type} answers every team shape`);
|
|
286
|
+
for (const [shape, tailored] of Object.entries(row.byTeamShape)) {
|
|
287
|
+
assert.ok(Array.isArray(tailored.bundle), `${row.type}/${shape} names a module list`);
|
|
288
|
+
assert.ok(Array.isArray(tailored.adjustments), `${row.type}/${shape} names its adjustments`);
|
|
289
|
+
// never the always-on core, never a key off the catalog
|
|
290
|
+
for (const key of tailored.bundle) {
|
|
291
|
+
assert.ok(OPTIONAL_MODULES.some((m) => m.key === key),
|
|
292
|
+
`${row.type}/${shape} preselects "${key}", which is not a choice the picker offers`);
|
|
293
|
+
}
|
|
294
|
+
// an adjustment is a DELTA: it only claims what it actually changed
|
|
295
|
+
for (const adj of tailored.adjustments) {
|
|
296
|
+
const inBase = row.bundle.includes(adj.module);
|
|
297
|
+
const inNow = tailored.bundle.includes(adj.module);
|
|
298
|
+
assert.notEqual(inBase, inNow, `${row.type}/${shape}: "${adj.rule}" claims a change it did not make`);
|
|
299
|
+
assert.ok(adj.reason && adj.reason.length > 20, `${row.type}/${shape}: "${adj.rule}" needs an owner-facing reason`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('an adjusted bundle is in CATALOG order, like every other module list', () => {
|
|
306
|
+
// normalizeModuleSelection stores a chosen set in catalog order and the picker
|
|
307
|
+
// re-derives its toggles that way, so an appended adjustment would make one set
|
|
308
|
+
// of modules read as two different lists depending on whether the owner
|
|
309
|
+
// answered the picker or skipped it.
|
|
310
|
+
const order = OPTIONAL_MODULES.map((m) => m.key);
|
|
311
|
+
const sorted = (list) => [...list].sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
|
312
|
+
|
|
313
|
+
for (const preset of allStarterBundles()) {
|
|
314
|
+
for (const shape of RULED_TEAM_SHAPES) {
|
|
315
|
+
const { modules } = adjustBundle(preset.bundle, shape);
|
|
316
|
+
assert.deepEqual(modules, sorted(modules), `${preset.type}/${shape} is in catalog order`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
264
319
|
});
|
|
265
320
|
|
|
266
321
|
test('the route never reaches the database — the bundles are a declaration, not a query', async () => {
|
|
@@ -355,6 +410,40 @@ test('effectiveModules: an answered picker wins over the bundle, including "none
|
|
|
355
410
|
assert.equal(none.source, 'chosen');
|
|
356
411
|
});
|
|
357
412
|
|
|
413
|
+
test('effectiveModules: a skipped picker after an ANSWERED interview yields the TAILORED bundle', () => {
|
|
414
|
+
// BV1.R22 / ADR 0243 — the property ADR 0240 §2 leans the skip on ("the
|
|
415
|
+
// preselect and the fallback are the same declaration"), now that the panel
|
|
416
|
+
// preselects the team-shape-adjusted set. Without this the picker would show a
|
|
417
|
+
// solo owner one thing and the project would quietly get another.
|
|
418
|
+
const eff = effectiveModules({ type: 'game', modules: null, detail: { team_shape: 'solo' } });
|
|
419
|
+
|
|
420
|
+
assert.deepEqual(eff.selected, ['discord'], 'the shared environment the rule removes is not silently restored');
|
|
421
|
+
assert.equal(eff.source, 'bundle', 'and it is still an UNANSWERED set, not a stored one');
|
|
422
|
+
assert.deepEqual(eff.adjustments.map((a) => a.module), ['dev-box']);
|
|
423
|
+
assert.match(eff.adjustments[0].reason, /building this alone/, 'carrying the reason the panel showed');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("effectiveModules: the owner's own answer is never re-adjusted by a rule", () => {
|
|
427
|
+
// A rule corrects a PRESELECT the owner did not make. Once they have toggled,
|
|
428
|
+
// re-applying it would overrule them with an answer they had already refused.
|
|
429
|
+
const eff = effectiveModules({ type: 'game', modules: ['dev-box'], detail: { team_shape: 'solo' } });
|
|
430
|
+
|
|
431
|
+
assert.deepEqual(eff.selected, ['dev-box'], 'solo removes dev-box, and the owner put it back');
|
|
432
|
+
assert.equal(eff.source, 'chosen');
|
|
433
|
+
assert.deepEqual(eff.adjustments, [], 'and no reason is handed back for a choice they made themselves');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('effectiveModules: a detail that says nothing usable adjusts nothing', () => {
|
|
437
|
+
// {} (skipped), a shape that has left the vocabulary, and a non-object column
|
|
438
|
+
// value all read as NO ANSWER — normalizeProjectDetail's rule, reached without
|
|
439
|
+
// importing it.
|
|
440
|
+
for (const detail of [undefined, null, {}, { team_shape: null }, { team_shape: 'a-huge-corporation' }, 'not-an-object', []]) {
|
|
441
|
+
const eff = effectiveModules({ type: 'game', modules: null, detail });
|
|
442
|
+
assert.deepEqual(eff.selected, ['dev-box', 'discord'], `detail=${JSON.stringify(detail)} falls back to the type's bundle`);
|
|
443
|
+
assert.deepEqual(eff.adjustments, []);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
358
447
|
test('effectiveModules: an undeclared type resolves to the core, never a guess', () => {
|
|
359
448
|
for (const row of [{ type: null, modules: null }, { type: 'not-sure', modules: null }, {}]) {
|
|
360
449
|
const eff = effectiveModules(row);
|