@bongos/core 1.21.82 → 1.21.84

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 (41) hide show
  1. package/.bongos-core.json +63 -43
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md +1 -0
  4. package/docs/api/openapi.json +8 -1
  5. package/docs/api-reference.md +2 -2
  6. package/docs/copy-inventory.md +32 -28
  7. package/docs/copy-registry.json +70 -34
  8. package/docs/module-api-changelog.md +4 -0
  9. package/docs/page-inventory.json +9 -1
  10. package/docs/page-readings.json +657 -648
  11. package/modules/platform-identity/invite-notices.js +78 -10
  12. package/modules/platform-identity/migrations/platform_identity_031_parked_invite_notices.sql +45 -0
  13. package/modules/platform-identity/routes/my-projects.js +7 -3
  14. package/modules/provisioning/birth.js +28 -1
  15. package/modules/provisioning/routes/provisioning.js +3 -1
  16. package/modules/public-landing/public/account.html +1 -0
  17. package/modules/public-landing/public/assets/cosmos.css +8 -0
  18. package/modules/public-landing/public/assets/invite-badge.js +91 -0
  19. package/modules/public-landing/public/contact.html +1 -0
  20. package/modules/public-landing/public/guild.html +1 -0
  21. package/modules/public-landing/public/invitations.html +7 -2
  22. package/modules/public-landing/public/privacy.html +1 -0
  23. package/modules/public-landing/public/projects.html +49 -5
  24. package/modules/public-landing/public/projects.states.json +1 -0
  25. package/modules/public-landing/public/terms.html +1 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +12 -0
  29. package/src/bongos/project-settings.js +16 -2
  30. package/src/branding.js +3 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/hub_invite_badge.mjs +204 -0
  33. package/tests/hub_invite_notices.mjs +61 -6
  34. package/tests/landing_legal_pages.mjs +1 -1
  35. package/tests/projects_hub.mjs +1 -1
  36. package/tests/projects_hub_app_step.mjs +1 -1
  37. package/tests/provision_settings_apply.mjs +4 -1
  38. package/tests/provisioning_settings_env.mjs +3 -1
  39. package/tests/wizard_physics_more.mjs +5 -5
  40. package/tests/wizard_ship_mode.mjs +187 -0
  41. package/tests/wizard_six_screens.mjs +3 -2
@@ -0,0 +1,187 @@
1
+ // tests/wizard_ship_mode.mjs — the project-creation wizard asks how finished work ships,
2
+ // and the new project is born with that answer (task 1004629, ADR 0369).
3
+ //
4
+ // Pinned here, end to end:
5
+ // 1. the wizard's review has a Shipping row: Auto first and checked, one plain sentence
6
+ // each, "you can change it later", and the create request carries ship_mode;
7
+ // 2. the create route accepts only auto|manual and stamps it, write-once, on the row it
8
+ // just made (modules/provisioning/birth.js);
9
+ // 3. the stamp rides the new instance's web.env as <PREFIX>_SHIP_MODE, which
10
+ // src/branding.js maps onto the server-only founding.shipMode;
11
+ // 4. the instance's resolveShipMode reads it as a CHOSEN mode while no project_settings
12
+ // row exists, and a row (the settings page, the CLI) always wins.
13
+ //
14
+ // Run: node --test tests/wizard_ship_mode.mjs
15
+ import { strict as assert } from 'node:assert';
16
+ import { test, after } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+ import { execFileSync } from 'node:child_process';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import vm from 'node:vm';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
27
+
28
+ // Destructured from literal relative paths, so the dead-code scan (knip) can trace them.
29
+ const { SHIP_MODE_KEY, stampShipMode, BIRTH_COMPANION_ENV } = require('../modules/provisioning/birth.js');
30
+ const { resolveShipMode } = require('../src/bongos/project-settings.js');
31
+ const provisioning = require('../modules/provisioning/provisioning.js');
32
+ const poolMod = require('../src/bongos/pool.js');
33
+
34
+ function slice(start, end, what) {
35
+ const a = HUB.indexOf(start), b = HUB.indexOf(end, a);
36
+ assert.ok(a > 0 && b > a, `slice for ${what} not found — a rename moved it`);
37
+ return HUB.slice(a, b);
38
+ }
39
+
40
+ // ── 1. the wizard ──────────────────────────────────────────────────────────
41
+
42
+ test('the review asks how finished work ships: Auto (recommended) first and checked, Manual second', () => {
43
+ const row = slice('<div class="setRow" data-row="ship">', '<div class="hint" id="h-review">', 'the ship row');
44
+ const auto = row.indexOf('value="auto"'), manual = row.indexOf('value="manual"');
45
+ assert.ok(auto > 0 && manual > auto, 'Auto comes first');
46
+ assert.match(row, /<input type="radio" name="shipMode" value="auto" checked>/, 'and is the pick until the founder says otherwise');
47
+ assert.match(row, /<b>Auto \(recommended\)<\/b>/);
48
+ assert.match(row, /<b>Manual<\/b>/);
49
+ assert.match(row, /ships finished work on its own/, 'one plain sentence for Auto');
50
+ assert.match(row, /waits for you to approve it/, 'and one for Manual');
51
+ });
52
+
53
+ test('the row says what was picked and that it can be changed later; an older draft reads Auto', () => {
54
+ const fns = slice(' /* the ship row (task 1004629)', ' var ROWS = [', 'the ship choice');
55
+ const ctx = { state: {} };
56
+ vm.runInNewContext(`${fns}; this.choice = shipModeChoice;`, ctx);
57
+ assert.equal(ctx.choice(), 'auto', 'a draft with no shipMode is Auto');
58
+ ctx.state.shipMode = 'manual';
59
+ assert.equal(ctx.choice(), 'manual');
60
+ ctx.state.shipMode = 'yolo';
61
+ assert.equal(ctx.choice(), 'auto', 'anything but manual is Auto');
62
+ const say = slice(" if (key === 'ship') {", " return { v: '', s: '' };", "the ship row's words");
63
+ assert.match(say, /v: 'Auto \(recommended\)'/);
64
+ assert.match(say, /v: 'Manual'/);
65
+ assert.equal((say.match(/You can change this later in your project’s settings\./g) || []).length, 2, 'both say it can be changed later');
66
+ });
67
+
68
+ test('the draft remembers the pick, and Create sends it as ship_mode', () => {
69
+ assert.match(HUB, /appPlatform: 'later', shipMode: 'auto', physics: null/, 'the initial state starts on Auto');
70
+ assert.equal((HUB.match(/shipMode: 'auto'/g) || []).length, 2, 'and so does the reset (the two literals stay equal)');
71
+ assert.match(HUB, /if \(type === 'change' && ev\.target\.name === 'shipMode'\) \{ state\.shipMode = ev\.target\.value; saveDraft\(\); \}/);
72
+ assert.match(HUB, /ship_mode: shipModeChoice\(\)\n {10}\}\)/, 'the create request carries it');
73
+ });
74
+
75
+ // ── 2. the stamp ───────────────────────────────────────────────────────────
76
+
77
+ test('stampShipMode writes a valid answer once, and writes nothing for none or junk', async () => {
78
+ const seen = [];
79
+ const db = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } };
80
+ assert.equal(await stampShipMode(db, 41, 'manual'), true);
81
+ assert.equal(seen.length, 1);
82
+ assert.match(seen[0].sql, /UPDATE provisioning_instances/);
83
+ assert.match(seen[0].sql, /WHERE id = \$1 AND NOT \(COALESCE\(settings, '\{\}'::jsonb\) \? \$2::text\)/, 'write-once');
84
+ assert.deepEqual(seen[0].params, [41, SHIP_MODE_KEY, 'manual']);
85
+ assert.equal(await stampShipMode(db, 41, undefined), false, 'no answer: the project asks at its first ship instead');
86
+ assert.equal(await stampShipMode(db, 41, 'yolo'), false);
87
+ assert.equal(seen.length, 1, 'nothing written for either');
88
+ });
89
+
90
+ test('the stamp is not a setting: no client patch can write it and it never projects as one', () => {
91
+ assert.ok(!(SHIP_MODE_KEY in provisioning.SETTINGS_VOCAB));
92
+ assert.ok(!(SHIP_MODE_KEY in provisioning.PLANET_VOCAB));
93
+ assert.ok(!(SHIP_MODE_KEY in provisioning.effectiveSettings({ settings: { [SHIP_MODE_KEY]: 'manual' } })));
94
+ });
95
+
96
+ // ── 3. the transport ───────────────────────────────────────────────────────
97
+
98
+ test('the stamped mode rides web.env as SHIP_MODE; an unstamped or mangled row sends empty', () => {
99
+ assert.equal(typeof BIRTH_COMPANION_ENV.SHIP_MODE, 'function');
100
+ assert.equal(provisioning.settingsEnvVars({ settings: { ship_mode: 'manual' } }, 'X').X_SHIP_MODE, 'manual');
101
+ assert.equal(provisioning.settingsEnvVars({ settings: { ship_mode: 'auto' } }, 'X').X_SHIP_MODE, 'auto');
102
+ assert.equal(provisioning.settingsEnvVars({ settings: {} }, 'X').X_SHIP_MODE, '');
103
+ assert.equal(provisioning.settingsEnvVars({ settings: { ship_mode: 'sometimes' } }, 'X').X_SHIP_MODE, '');
104
+ });
105
+
106
+ test('the instance reads it into founding.shipMode, and never publishes it to the client', () => {
107
+ const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
108
+ "const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({m:(r.founding||{}).shipMode||null,c:'founding' in b.clientBranding(r)}))"],
109
+ { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env } }));
110
+ assert.deepEqual(read({ CLOUDBONGOS_SHIP_MODE: 'manual' }), { m: 'manual', c: false });
111
+ assert.deepEqual(read({ CLOUDBONGOS_SHIP_MODE: '' }), { m: null, c: false }, 'the empty line every other instance carries is nothing');
112
+ });
113
+
114
+ // ── 4. the instance's own resolver ─────────────────────────────────────────
115
+
116
+ async function withRow(value, fn) {
117
+ const pool = poolMod.pool;
118
+ const saved = pool.query;
119
+ pool.query = async () => ({ rows: value == null ? [] : [{ key: 'ship.mode', value, updated_at: null, updated_by: 1 }] });
120
+ try { return await fn(); } finally { pool.query = saved; }
121
+ }
122
+ const packWith = (shipMode) => () => ({ founding: { shipMode } });
123
+
124
+ test('a wizard pick counts as chosen while no setting row exists', async () => {
125
+ const r = await withRow(null, () => resolveShipMode({ branding: packWith('manual') }));
126
+ assert.deepEqual(r, { mode: 'manual', source: 'wizard', chosen: true, invalid: null });
127
+ const auto = await withRow(null, () => resolveShipMode({ branding: packWith('auto') }));
128
+ assert.equal(auto.chosen, true, 'picking Auto in the wizard is a choice, so the first ship does not ask again');
129
+ });
130
+
131
+ test('the settings page (a row) always wins over the wizard; junk or no pick is the default, unchosen', async () => {
132
+ const r = await withRow('auto', () => resolveShipMode({ branding: packWith('manual') }));
133
+ assert.equal(r.mode, 'auto');
134
+ assert.equal(r.source, 'project_settings');
135
+ assert.deepEqual(await withRow(null, () => resolveShipMode({ branding: packWith('') })), { mode: 'auto', source: 'default', chosen: false, invalid: null });
136
+ assert.deepEqual(await withRow(null, () => resolveShipMode({ branding: packWith('maybe') })), { mode: 'auto', source: 'default', chosen: false, invalid: null });
137
+ assert.equal((await withRow(null, () => resolveShipMode({ branding: () => { throw new Error('no pack'); } }))).source, 'default', 'a pack that cannot be read is no pick');
138
+ });
139
+
140
+ // ── 2b. the create route ───────────────────────────────────────────────────
141
+
142
+ const api = require('../src/module-api.js');
143
+ const birth = require('../modules/provisioning/birth.js');
144
+ provisioning.getInstanceBySlug = async () => null;
145
+ provisioning.getInstanceByDomain = async () => null;
146
+ birth.stampBirth = async () => {};
147
+ provisioning.createInstance = async (_db, args) => ({
148
+ instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host', detail: {} }, created: true,
149
+ });
150
+ provisioning.enqueueIntent = async (_db, _id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
151
+ provisioning.getOpenIntent = async () => null;
152
+ provisioning.recordEvent = async () => {};
153
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
154
+ const stamped = [];
155
+ api.pool.query = async (sql, params) => { if (/jsonb_build_object/.test(sql) && params[1] === SHIP_MODE_KEY) stamped.push(params[2]); return { rows: [] }; };
156
+
157
+ const express = require('express');
158
+ const app = express();
159
+ app.use(express.json());
160
+ app.use((_req, res, next) => {
161
+ res.fail = (code, opts) => {
162
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
163
+ res.status(status).json({ error: { code, message: opts && opts.message } });
164
+ };
165
+ next();
166
+ });
167
+ app.use('/api/bongos', require('../modules/provisioning/routes/provisioning.js')());
168
+ const server = app.listen(0);
169
+ after(() => server.close());
170
+ async function create(body) {
171
+ await new Promise((r) => (server.listening ? r() : server.once('listening', r)));
172
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/bongos/provisioning/instances`, {
173
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
174
+ });
175
+ return { status: res.status, body: await res.json() };
176
+ }
177
+
178
+ test('the create route stamps the pick on the new row, and refuses anything but auto|manual', async () => {
179
+ stamped.length = 0;
180
+ const r = await create({ slug: 'ships-by-hand', hosting_shape: 'byo-host', ship_mode: 'manual' });
181
+ assert.equal(r.status, 201, JSON.stringify(r.body));
182
+ assert.deepEqual(stamped, ['manual']);
183
+ stamped.length = 0;
184
+ assert.equal((await create({ slug: 'no-pick', hosting_shape: 'byo-host' })).status, 201, 'the answer is optional (an API caller)');
185
+ assert.deepEqual(stamped, [], 'and none is stamped when none is given');
186
+ assert.equal((await create({ slug: 'bad-pick', hosting_shape: 'byo-host', ship_mode: 'sometimes' })).status, 400);
187
+ });
@@ -81,10 +81,10 @@ test('planet words survive only as the light touch: the fork and the alive momen
81
81
  assert.match(SCRIPT, /if \(inst\.last_verified_up\) \{\n\s*\/\*[^*]*\*\/\n\s*\$\('done-h'\)\.textContent =/, 'said only once the project has answered (Live is a verified claim)');
82
82
  });
83
83
 
84
- test('the review cannot be skipped: seven rows, one Edit each, and one Continue that confirms them all (task 1004550)', () => {
84
+ test('the review cannot be skipped: eight rows, one Edit each, and one Continue that confirms them all (task 1004550)', () => {
85
85
  const p = panel('panel-review');
86
86
  const rows = [...p.matchAll(/<div class="setRow" data-row="([a-z]+)">/g)].map((m) => m[1]);
87
- assert.deepEqual(rows, ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run']);
87
+ assert.deepEqual(rows, ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship']);
88
88
  for (const key of rows) {
89
89
  const r = (p.match(new RegExp(`<div class="setRow" data-row="${key}">[\\s\\S]*?<span class="acts">[^\\n]*\\n`)) || [''])[0];
90
90
  assert.match(r, /<span class="acts"><button type="button" class="rowChg" data-row-chg aria-expanded="false">Edit<\/button><\/span>/, `${key}: one Edit, nothing else`);
@@ -110,6 +110,7 @@ test('nothing the old steps could do is lost: their controls are moved whole int
110
110
  has('extras', ['modQ', 'modCards', 'modWhy', 'modCoreNote']);
111
111
  has('team', ['physTeam', 'physStartN', 'physExpected', 'teamCards']);
112
112
  has('run', ['physSuite']);
113
+ has('ship', ['shipOpts']);
113
114
  for (const id of ['physCountry', 'physRegion', 'physAges', 'physIntent', 'physForms', 'physHaveEntity', 'checksStop', 'physStopMsg', 'physFromAccount']) {
114
115
  assert.match(panel('panel-checks'), new RegExp(`id="${id}"`), `the quick checks host #${id}`);
115
116
  }