@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
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.82",
3
+ "version": "1.21.84",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.82",
9
+ "version": "1.21.84",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.82",
3
+ "version": "1.21.84",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9318,5 +9318,17 @@
9318
9318
  "id": "1004481",
9319
9319
  "text": "Custom ranks can now be deleted from the Government page, so trying a recommended rank set is no longer a permanent choice. A rank someone still holds can't be deleted, and the page says who holds it. The four built-in ranks c"
9320
9320
  }
9321
+ ],
9322
+ "1.21.83": [
9323
+ {
9324
+ "id": "1004646",
9325
+ "text": "Project invites now reach people on cloudbongos.com. Before, an invite sent by GitHub username was silently lost if the person had never signed in to a project through cloudbongos.com, which is almost every new invitee, so the"
9326
+ }
9327
+ ],
9328
+ "1.21.84": [
9329
+ {
9330
+ "id": "1004629",
9331
+ "text": "New projects now ask how finished work should ship: Auto (recommended) or Manual. The answer becomes the project's ship mode, which the owner can change later on the settings page."
9332
+ }
9321
9333
  ]
9322
9334
  }
@@ -226,14 +226,26 @@ function parseShipMode(raw) {
226
226
  // resolveShipMode() — the project's ship mode and where it came from.
227
227
  // Returns { mode, source, chosen, invalid }:
228
228
  // mode — always 'auto' or 'manual'
229
- // source — 'project_settings' | 'default'
229
+ // source — 'project_settings' | 'wizard' | 'default'
230
230
  // chosen — true only when someone actually SET a usable value. "Never chosen" is
231
231
  // a real answer the first-ship prompt (task 1004630) asks about, so it
232
232
  // must not be confused with "chose the default".
233
233
  // invalid — null, or { where, raw, reason } for a stored value that is unusable.
234
234
  // A database that cannot be read answers the default rather than failing a ship —
235
235
  // the same reporting posture as resolveRotDays.
236
- async function resolveShipMode() {
236
+ //
237
+ // 'wizard' (task 1004629): the founder picked a mode in the project-creation wizard,
238
+ // which reaches this instance as the server-only branding value founding.shipMode
239
+ // (the hub cannot write this database). It counts as chosen, but only while no row
240
+ // exists: the settings page and the CLI write the row, so a later change always wins.
241
+ // `deps.branding` is the test seam; production reads the process's own pack.
242
+ function wizardShipMode(deps) {
243
+ let pack;
244
+ try { pack = deps.branding ? deps.branding() : require('../branding').branding(); } catch (_) { return null; }
245
+ return parseShipMode(pack && pack.founding && pack.founding.shipMode).mode || null;
246
+ }
247
+
248
+ async function resolveShipMode(deps = {}) {
237
249
  let row = null;
238
250
  try {
239
251
  row = await getSetting(SETTING_KEYS.SHIP_MODE);
@@ -251,6 +263,8 @@ async function resolveShipMode() {
251
263
  invalid: { where: `project_settings['${SETTING_KEYS.SHIP_MODE}']`, raw: String(row.value), reason: got.reason },
252
264
  };
253
265
  }
266
+ const fromWizard = wizardShipMode(deps);
267
+ if (fromWizard) return { mode: fromWizard, source: 'wizard', chosen: true, invalid: null };
254
268
  return { mode: DEFAULT_SHIP_MODE, source: 'default', chosen: false, invalid: null };
255
269
  }
256
270
 
package/src/branding.js CHANGED
@@ -103,6 +103,9 @@ const ENV_OVERRIDES = [
103
103
  ['FOUNDING_BIRTH', ['founding', 'birth']],
104
104
  ['DEMO_PEOPLE', ['founding', 'demoPeople']],
105
105
  ['DEMO_HOURS', ['founding', 'demoHours']],
106
+ // The ship mode the founder picked in the wizard (task 1004629), empty when none was.
107
+ // resolveShipMode reads it only while project_settings['ship.mode'] is unset.
108
+ ['SHIP_MODE', ['founding', 'shipMode']],
106
109
  ];
107
110
 
108
111
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.82'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.84'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,204 @@
1
+ // tests/hub_invite_badge.mjs — what is waiting on you, said where you already look
2
+ // (task 1004646; the owner's report of 2026-10-08 that invitations "aren't obvious").
3
+ //
4
+ // The REAL assets/invite-badge.js, executed in a node:vm under a small DOM stub: the
5
+ // count on the identity chip and on the profile band's Invitations item, the line on
6
+ // the mine view, nothing at all when nothing waits or nobody is signed in, and no
7
+ // read at all on a child instance (its Invitations item is stripped as apex-only).
8
+ // Then the pages: every page that wears the hub's bar loads it.
9
+ //
10
+ // Run: node --test tests/hub_invite_badge.mjs
11
+
12
+ import test from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import vm from 'node:vm';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
21
+ const BADGE = fs.readFileSync(path.join(LANDING, 'assets', 'invite-badge.js'), 'utf8');
22
+
23
+ // ── a DOM just big enough for this file ──────────────────────────────────────
24
+
25
+ class El {
26
+ constructor(tag, attrs = {}) {
27
+ this.tagName = tag.toUpperCase();
28
+ this.children = [];
29
+ this.parentNode = null;
30
+ this.attrs = { ...attrs };
31
+ this.className = attrs.class || '';
32
+ this.id = attrs.id || '';
33
+ this._text = '';
34
+ }
35
+ get firstChild() { return this.children[0] || null; }
36
+ get lastChild() { return this.children[this.children.length - 1] || null; }
37
+ get nextSibling() { const k = this.parentNode.children; return k[k.indexOf(this) + 1] || null; }
38
+ set textContent(v) { this.children = []; this._text = String(v); }
39
+ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
40
+ setAttribute(k, v) { this.attrs[k] = String(v); }
41
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
42
+ set href(v) { this.attrs.href = v; }
43
+ get href() { return this.attrs.href; }
44
+ appendChild(c) { return this.insertBefore(c, null); }
45
+ insertBefore(c, ref) {
46
+ if (c.parentNode) c.remove();
47
+ c.parentNode = this;
48
+ const i = ref ? this.children.indexOf(ref) : -1;
49
+ if (i < 0) this.children.push(c); else this.children.splice(i, 0, c);
50
+ return c;
51
+ }
52
+ remove() { const p = this.parentNode; if (p) p.children.splice(p.children.indexOf(this), 1); this.parentNode = null; }
53
+ *walk() { for (const c of this.children) { yield c; yield* c.walk(); } }
54
+ matches(sel) {
55
+ if (sel === '.invBadge') return this.className.split(' ').includes('invBadge');
56
+ if (sel === '.hero') return this.className === 'hero';
57
+ if (sel === 'svg.cv') return this.tagName === 'SVG' && this.className === 'cv';
58
+ if (sel === 'a[href="/invitations"]') return this.tagName === 'A' && this.attrs.href === '/invitations';
59
+ throw new Error(`stub selector: ${sel}`);
60
+ }
61
+ querySelector(sel) { for (const n of this.walk()) if (n.matches(sel)) return n; return null; }
62
+ }
63
+
64
+ function world({ child = false, mineView = false } = {}) {
65
+ const body = new El('body');
66
+ const who = body.appendChild(new El('button', { id: 'who', class: 'trig who' }));
67
+ who.appendChild(new El('span', { class: 'whoName' }));
68
+ who.appendChild(new El('svg', { class: 'cv' }));
69
+ const band = body.appendChild(new El('div', { id: 'menuProfile' }));
70
+ const mine = band.appendChild(new El('a', { href: '/projects?view=mine' }));
71
+ mine.textContent = 'My projects';
72
+ let inv = null;
73
+ if (!child) { inv = band.appendChild(new El('a', { href: '/invitations', class: 'bandT' })); inv.textContent = 'Invitations'; }
74
+ let section = null;
75
+ if (mineView) {
76
+ const view = body.appendChild(new El('section', { id: 'view-mine' }));
77
+ section = view.appendChild(new El('div', { class: 'hero' }));
78
+ section.appendChild(new El('h1')).textContent = 'Your projects';
79
+ section.appendChild(new El('p', { class: 'lede' }));
80
+ view.appendChild(new El('ul', { id: 'projectsList' }));
81
+ }
82
+ const byId = (id) => { for (const n of body.walk()) if (n.id === id) return n; return null; };
83
+ const document = { getElementById: byId, createElement: (t) => new El(t) };
84
+ return { body, who, inv, section, document, byId };
85
+ }
86
+
87
+ async function run(w, answers) {
88
+ const asked = [];
89
+ const fetch = async (url) => {
90
+ asked.push(url);
91
+ const a = answers[url];
92
+ if (a === undefined) return { ok: false, status: 404, json: async () => ({}) };
93
+ if (a instanceof Error) throw a;
94
+ return { ok: a.status === undefined, status: a.status || 200, json: async () => a };
95
+ };
96
+ const window = {};
97
+ vm.runInNewContext(BADGE, { window, document: w.document, fetch, Promise });
98
+ await window.InviteBadge.refresh();
99
+ return { asked, window };
100
+ }
101
+
102
+ const INVITES = '/api/bongos/my-projects/invites';
103
+ const GUILDS = '/api/bongos/me/guild-requests';
104
+ const badgeOf = (el) => el && el.querySelector('.invBadge');
105
+
106
+ // ── the count ────────────────────────────────────────────────────────────────
107
+
108
+ test('project invites and guild asks add up, on the chip and on the Invitations item', async () => {
109
+ const w = world();
110
+ await run(w, {
111
+ [INVITES]: { invites: [{ client_id: 'a' }, { client_id: 'b' }] },
112
+ [GUILDS]: { incoming: [{ id: '1' }], owner_incoming: [{ id: '2' }], outgoing: [{ id: '3' }] },
113
+ });
114
+ for (const host of [w.who, w.inv]) {
115
+ const b = badgeOf(host);
116
+ assert.ok(b, 'a badge is drawn');
117
+ assert.equal(b.firstChild.textContent, '4', 'two project invites, a guild invite and a request to a guild you own');
118
+ assert.equal(b.firstChild.getAttribute('aria-hidden'), 'true');
119
+ assert.equal(b.lastChild.className, 'vh');
120
+ assert.equal(b.lastChild.textContent, ', 4 invitations waiting', 'a screen reader hears what the number means');
121
+ }
122
+ assert.equal(w.who.children.indexOf(badgeOf(w.who)), 1, 'on the chip, before its chevron');
123
+ });
124
+
125
+ test('one invitation reads in the singular, and a large count is capped', async () => {
126
+ let w = world();
127
+ await run(w, { [INVITES]: { invites: [{}] }, [GUILDS]: {} });
128
+ assert.equal(badgeOf(w.inv).lastChild.textContent, ', 1 invitation waiting');
129
+ w = world();
130
+ await run(w, { [INVITES]: { invites: Array.from({ length: 120 }, () => ({})) }, [GUILDS]: {} });
131
+ assert.equal(badgeOf(w.who).firstChild.textContent, '99+');
132
+ });
133
+
134
+ test('the mine view says it in a line that links to the invitations page', async () => {
135
+ const w = world({ mineView: true });
136
+ await run(w, { [INVITES]: { invites: [{}, {}] }, [GUILDS]: {} });
137
+ const p = w.byId('mineInvitesWaiting');
138
+ assert.ok(p, 'the line is drawn');
139
+ assert.equal(w.section.children[2], p, 'at the top of the view, under its heading and lede — not below a long list');
140
+ assert.equal(p.firstChild.tagName, 'A');
141
+ assert.equal(p.firstChild.href, '/invitations');
142
+ assert.equal(p.textContent, 'You have 2 invitations waiting →');
143
+ });
144
+
145
+ test('nothing waiting, signed out, or a failed read: nothing is drawn', async () => {
146
+ for (const answers of [
147
+ { [INVITES]: { invites: [] }, [GUILDS]: { incoming: [], owner_incoming: [] } },
148
+ { [INVITES]: { status: 401 }, [GUILDS]: { status: 401 } },
149
+ { [INVITES]: new Error('offline'), [GUILDS]: new Error('offline') },
150
+ ]) {
151
+ const w = world({ mineView: true });
152
+ await run(w, answers);
153
+ assert.equal(badgeOf(w.who), null);
154
+ assert.equal(badgeOf(w.inv), null);
155
+ assert.equal(w.byId('mineInvitesWaiting'), null);
156
+ }
157
+ });
158
+
159
+ test('a refresh after an answer takes the badge and the line away again', async () => {
160
+ const w = world({ mineView: true });
161
+ const answers = { [INVITES]: { invites: [{}] }, [GUILDS]: {} };
162
+ const { window } = await run(w, answers);
163
+ assert.ok(badgeOf(w.who));
164
+ window.InviteBadge.paint(0);
165
+ assert.equal(badgeOf(w.who), null);
166
+ assert.equal(badgeOf(w.inv), null);
167
+ assert.equal(w.byId('mineInvitesWaiting'), null);
168
+ });
169
+
170
+ test('a child instance, with no Invitations item, reads nothing', async () => {
171
+ const w = world({ child: true });
172
+ const { asked } = await run(w, { [INVITES]: { invites: [{}] }, [GUILDS]: {} });
173
+ assert.deepEqual(asked, [], 'the hub-only routes are never asked');
174
+ assert.equal(badgeOf(w.who), null);
175
+ });
176
+
177
+ test('the file builds nodes only and makes only its two reads', () => {
178
+ assert.doesNotMatch(BADGE, /innerHTML|insertAdjacentHTML|outerHTML/);
179
+ const calls = [...BADGE.matchAll(/readCount\('([^']+)'/g)].map((m) => m[1]);
180
+ assert.deepEqual(calls, ['/my-projects/invites', '/me/guild-requests']);
181
+ });
182
+
183
+ // ── the pages ────────────────────────────────────────────────────────────────
184
+
185
+ test('every page that wears the hub bar loads it once, before the Artist review tag', () => {
186
+ for (const page of ['projects', 'account', 'invitations', 'guild', 'privacy', 'terms', 'contact']) {
187
+ const html = fs.readFileSync(path.join(LANDING, `${page}.html`), 'utf8');
188
+ const tags = html.match(/<script src="\/assets\/invite-badge\.js"><\/script>/g) || [];
189
+ assert.equal(tags.length, 1, `${page}: one tag`);
190
+ assert.ok(html.indexOf(tags[0]) < html.lastIndexOf('<script src="/assets/artist-review.js"'), `${page}: before the review button`);
191
+ }
192
+ });
193
+
194
+ test('the hub page repaints the count after it rebuilds the chip, and the invitations page after an answer', () => {
195
+ const hub = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
196
+ assert.match(hub, /renderWho\(\);\r?\n[^\n]*\r?\n\s*if \(window\.InviteBadge\) window\.InviteBadge\.refresh\(\);/);
197
+ const inv = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8');
198
+ assert.equal((inv.match(/recount\(\);/g) || []).length, 4, 'a decline, a guild decline, a guild accept and a request accepted');
199
+ });
200
+
201
+ test('the badge is the accent with its own ink, the .btn pairing', () => {
202
+ const css = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
203
+ assert.match(css, /\.invBadge\{[^}]*background:var\(--accent\);color:var\(--accent-on\);/);
204
+ });
@@ -55,6 +55,8 @@ const pool = {
55
55
  }
56
56
  if (/^\s*INSERT INTO platform_identity_project_memberships/.test(sql)) return { rows: [{ id: '1', membership_kind: 'pending' }] };
57
57
  if (/^\s*DELETE FROM platform_identity_project_memberships/.test(sql)) return { rowCount: 1, rows: [] };
58
+ if (/^\s*INSERT INTO platform_identity_parked_invite_notices/.test(sql)) return { rowCount: 1, rows: [] };
59
+ if (/^\s*DELETE FROM platform_identity_parked_invite_notices/.test(sql)) return { rowCount: 0, rows: [] };
58
60
  throw new Error(`unexpected statement: ${sql.trim().slice(0, 60)}`);
59
61
  },
60
62
  };
@@ -119,13 +121,15 @@ test('clear drops ONLY a pending row on the calling project', async () => {
119
121
  assert.equal(res.status, 200);
120
122
  assert.deepEqual(res.body, { ok: true });
121
123
  const w = writes();
122
- assert.equal(w.length, 1);
123
- assert.match(w[0].sql, /^\s*DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/,
124
+ assert.equal(w.length, 2, 'the parked notice under the login, then the pending row on the account');
125
+ assert.match(w[0].sql, /^\s*DELETE FROM platform_identity_parked_invite_notices WHERE client_id = \$1 AND login_lower = \$2/);
126
+ assert.deepEqual(w[0].params, ['mercury', 'octo']);
127
+ assert.match(w[1].sql, /^\s*DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/,
124
128
  'a rescind can never remove someone who has signed in');
125
- assert.deepEqual(w[0].params, ['mercury', '4242']);
129
+ assert.deepEqual(w[1].params, ['mercury', '4242']);
126
130
  });
127
131
 
128
- test('a login with no hub account writes nothing — and the answer is byte-identical', async () => {
132
+ test('a login with no hub account is PARKED by login — and the answer is byte-identical (task 1004646)', async () => {
129
133
  // The project must not learn from this route which GitHub logins have platform accounts.
130
134
  for (const route of ['/sso/invites/notify', '/sso/invites/clear']) {
131
135
  calls = [];
@@ -133,9 +137,60 @@ test('a login with no hub account writes nothing — and the answer is byte-iden
133
137
  const unknown = await notice(route, { github_login: 'nobody-here' });
134
138
  assert.deepEqual(unknown, known, `${route}: same status, same body`);
135
139
  }
140
+ // Before task 1004646 this wrote nothing, so a new invitee — no account until they sign
141
+ // in to a project through the hub — never saw the invite. Now it waits under the login.
136
142
  calls = [];
137
- await notice('/sso/invites/notify', { github_login: 'nobody-here' });
138
- assert.equal(writes().length, 0);
143
+ await notice('/sso/invites/notify', { github_login: '@Nobody-Here' });
144
+ const w = writes();
145
+ assert.equal(w.length, 1);
146
+ assert.match(w[0].sql, /^\s*INSERT INTO platform_identity_parked_invite_notices \(client_id, login_lower\)/);
147
+ assert.match(w[0].sql, /ON CONFLICT \(client_id, login_lower\) DO UPDATE SET noted_at = now\(\)/, 'a re-invite restarts its 30 days in place');
148
+ assert.deepEqual(w[0].params, ['mercury', 'nobody-here'], 'the calling project, and the login folded: no @, lower case');
149
+ assert.equal(w.filter((x) => /project_memberships/.test(x.sql)).length, 0, 'no membership row for a login with no account');
150
+ });
151
+
152
+ test('the invitations read lists a parked notice for whoever signs in with that login', async () => {
153
+ const seen = [];
154
+ const readPool = {
155
+ async query(text, params = []) {
156
+ const sql = String(text);
157
+ seen.push({ sql, params });
158
+ if (/FROM platform_identity_project_memberships m\s+LEFT JOIN/.test(sql)) {
159
+ return { rows: [{ client_id: 'mercury', invited_at: 't2', project_name: 'Mercury', project_origin: 'https://mercury.test' }] };
160
+ }
161
+ if (/FROM platform_identity_parked_invite_notices n/.test(sql)) {
162
+ return { rows: [
163
+ { client_id: 'mercury', invited_at: 't1', project_name: 'Mercury', project_origin: 'https://mercury.test' },
164
+ { client_id: 'venus', invited_at: 't0', project_name: 'Venus', project_origin: 'https://venus.test' },
165
+ ] };
166
+ }
167
+ throw new Error(`unexpected statement: ${sql.trim().slice(0, 60)}`);
168
+ },
169
+ };
170
+ const out = await inviteNotices.listInvitesForBuilder({ githubId: '4242', githubLogin: 'Octo' }, { pool: readPool });
171
+ assert.deepEqual(out.map((r) => r.client_id), ['mercury', 'venus'], 'one row per project: the pending row wins over its parked twin');
172
+ const parked = seen.find((c) => /parked_invite_notices/.test(c.sql));
173
+ assert.deepEqual(parked.params, ['octo', 30, '4242'], 'keyed on the SESSION login, folded; the 30 days; the session account');
174
+ assert.match(parked.sql, /n\.noted_at > now\(\) - make_interval\(days => \$2\)/, 'a lapsed invite is not shown (ADR 0356)');
175
+ assert.match(parked.sql, /NOT EXISTS \([\s\S]*m\.github_id = \$3 AND m\.membership_kind <> 'pending'\)/,
176
+ 'a project the reader already builds on is not listed: signing in there accepted it');
177
+ });
178
+
179
+ test('a decline drops the parked notice too, keyed on the session login', async () => {
180
+ const seen = [];
181
+ const p = { async query(text, params) { seen.push({ sql: String(text), params }); return { rowCount: 1, rows: [] }; } };
182
+ const out = await inviteNotices.removeParkedInvite({ clientId: 'venus', githubLogin: 'Octo' }, { pool: p });
183
+ assert.deepEqual(out, { removed: true });
184
+ assert.deepEqual(seen[0].params, ['venus', 'octo']);
185
+ assert.deepEqual(await inviteNotices.removeParkedInvite({ clientId: 'venus', githubLogin: '' }, { pool: p }), { removed: false });
186
+ assert.equal(seen.length, 1, 'no login, no statement');
187
+ });
188
+
189
+ test('the invitations routes read and decline through the parked notices', () => {
190
+ const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/my-projects.js'), 'utf8');
191
+ assert.match(src, /inviteNotices\.listInvitesForBuilder\(\s*\{ githubId: req\.builder\.github_id, githubLogin: req\.builder\.github_login \}/);
192
+ assert.match(src, /inviteNotices\.removeParkedInvite\(\{ clientId, githubLogin: req\.builder\.github_login \}/);
193
+ assert.match(src, /if \(!out\.removed && !parked\.removed\) return res\.fail\('no_pending_invite', 404\)/);
139
194
  });
140
195
 
141
196
  test('only an authenticated, active, credentialed project may speak — and writes nothing otherwise', async () => {
@@ -207,7 +207,7 @@ for (const p of PAGES) {
207
207
  const styles = [...html.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
208
208
  assert.doesNotMatch(styles, /\.(shell|bar|barMark|barLeft|barRight|trig|who|signinDoor|menuBand|band[A-Z]\w*)\b[^{}]*\{/, 'no bar or band rule in the page');
209
209
  // then the Artist review button's loader (task 1004499), the page's last tag
210
- assert.match(html, /<\/footer>\n<script src="\/assets\/hub-bar\.js"><\/script>\n<!-- [^\n]*-->\n<script src="\/assets\/artist-review\.js" data-hall="\{\{buildersOrigin\}\}"><\/script>\n<\/body>/, 'one shared script, after the markup it drives');
210
+ assert.match(html, /<\/footer>\n<script src="\/assets\/hub-bar\.js"><\/script>\n<script src="\/assets\/invite-badge\.js"><\/script>\n<!-- [^\n]*-->\n<script src="\/assets\/artist-review\.js" data-hall="\{\{buildersOrigin\}\}"><\/script>\n<\/body>/, 'the shared bar and its waiting count (task 1004646), after the markup they drive');
211
211
  assert.doesNotMatch(html, /<script>/, 'and no inline copy of the bands');
212
212
  assert.match(html, /<a data-link-hall href="\{\{buildersOrigin\}\}">Builders' hall<\/a><\/nav><\/footer>/, 'the hall keeps a door in the footer row now the bar has no Hall pill, as on /account');
213
213
  });
@@ -729,7 +729,7 @@ test('the About answers travel on the create request, and an empty one is OMITTE
729
729
  // wizard PREFILLED, and the sentence is never prefilled, so it has no row there — its
730
730
  // cost is stated on its own field (pinned above)
731
731
  assert.doesNotMatch(SCRIPT, /function renderReview\(|\$\('reviewList'\)/, 'no ledger review is left');
732
- assert.match(SCRIPT, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run'\];/,
732
+ assert.match(SCRIPT, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship'\];/,
733
733
  'the review rows are the seven prefilled answers');
734
734
  });
735
735
 
@@ -168,7 +168,7 @@ test('the row is real markup: Render and Decide later, Decide later the default'
168
168
 
169
169
  test("the row sits in the review, between Address and Extras, and its checks are the old step's (task 1004504)", () => {
170
170
  assert.match(HUB, /var panels = \{ 0: 'panel-gate', 1: 'panel-fork', 2: 'panel-what', 3: 'panel-checks', 4: 'panel-review', 5: 'panel-look', 6: 'panel-done' \};/);
171
- assert.match(HUB, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run'\];/);
171
+ assert.match(HUB, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship'\];/);
172
172
  assert.doesNotMatch(HUB, /<li[^>]*data-step="\d+"/, 'no rail beat names the step any more');
173
173
  assert.match(HUB, /if \(step === 4\) \{[^}]*syncAppStep\(\); syncRenderSetup\(\);\r?\n\s*paintSetup\(\);\r?\n\s*\}/, 'entering the review repaints the row, Render setup included');
174
174
  assert.match(HUB, /if \(key === 'app'\) \{ syncAppStep\(\); syncRenderSetup\(\); \}/, 'opening the row repaints it too');
@@ -78,6 +78,7 @@ ${NEUTRAL_PREFIX}_LOGO_URL=
78
78
  ${NEUTRAL_PREFIX}_FOUNDING_BIRTH=
79
79
  ${NEUTRAL_PREFIX}_DEMO_PEOPLE=
80
80
  ${NEUTRAL_PREFIX}_DEMO_HOURS=
81
+ ${NEUTRAL_PREFIX}_SHIP_MODE=
81
82
  ${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
82
83
  `;
83
84
 
@@ -148,6 +149,8 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
148
149
  [`${NEUTRAL_PREFIX}_LOOK_ACCENT`]: '', [`${NEUTRAL_PREFIX}_LOGO_URL`]: '',
149
150
  // the birth marker and a demo's box (task 1004506), empty on an un-stamped row
150
151
  [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
152
+ // the wizard's ship mode (task 1004629), empty when none was picked
153
+ [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '',
151
154
  // where the hall's Update button goes (task 1004608), empty when no hub origin resolves
152
155
  [`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
153
156
  });
@@ -250,7 +253,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
250
253
  assert.equal(r.ok, true);
251
254
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
252
255
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
253
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS, .*_HUB_UPGRADE_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
256
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS, .*_SHIP_MODE, .*_HUB_UPGRADE_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
254
257
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
255
258
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
256
259
  });
@@ -127,6 +127,8 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
127
127
  // The birth marker and a demo's box (task 1004506): empty on a row the create
128
128
  // route never stamped, which is every row made before it shipped (spec D8).
129
129
  CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
130
+ // The wizard's ship mode (task 1004629): empty when none was picked.
131
+ CLOUDBONGOS_SHIP_MODE: '',
130
132
  });
131
133
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
132
134
  // of the comparison is that the standup lines carry exactly the same key set.
@@ -135,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
135
137
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
136
138
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
137
139
  X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
138
- X_FOUNDING_BIRTH: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '',
140
+ X_FOUNDING_BIRTH: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '',
139
141
  }, 'defaults, explicitly');
140
142
  });
141
143
 
@@ -377,13 +377,13 @@ test('rewards are hidden for a solo founder: the real table hides the row, and t
377
377
  assert.equal(w.run("rowApplies('rewards')"), false, 'the REAL softLimitsFor hides reward_intents for one-and-one');
378
378
  assert.equal(w.row('rewards').hidden, true, 'and the row goes with it');
379
379
  assert.ok(!w.run('unconfirmedRows()').includes('rewards'), 'nothing to confirm for a question never asked');
380
- assert.equal(w.run('unconfirmedRows()').length, 6, 'six rows apply');
380
+ assert.equal(w.run('unconfirmedRows()').length, 7, 'seven rows apply');
381
381
  assert.equal(w.run('createDetail()').reward_intents, undefined);
382
382
  w.change('team');
383
383
  w.q('[data-phys-solo="many"]')[0].click();
384
384
  await w.settle();
385
385
  assert.equal(w.row('rewards').hidden, false, 'a team brings the question back, once the table is asked again');
386
- assert.equal(w.run('unconfirmedRows()').length, 7);
386
+ assert.equal(w.run('unconfirmedRows()').length, 8);
387
387
  });
388
388
 
389
389
  test('rewards: a greyed option is disabled and leaves the list, and each reason is said once', async () => {
@@ -482,9 +482,9 @@ test('one "Looks right, continue" confirms every row that applies, each through
482
482
  assert.equal(w.go().disabled, false, 'Continue is never locked: it is the confirmation');
483
483
  assert.equal(w.go().textContent, 'Looks right, continue');
484
484
  assert.equal(w.row('code').querySelector('[data-row-ok]'), null, 'no row carries a Looks right of its own');
485
- assert.equal(w.run('unconfirmedRows()').length, 7, 'nothing is confirmed before the founder says so');
485
+ assert.equal(w.run('unconfirmedRows()').length, 8, 'nothing is confirmed before the founder says so');
486
486
  assert.equal(w.run('validateStep(4)'), true, 'one Continue confirms them all');
487
- for (const key of ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run']) assert.equal(w.state.confirmed[key], true, key);
487
+ for (const key of ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship']) assert.equal(w.state.confirmed[key], true, key);
488
488
  assert.equal(w.state.modules && plain(w.state.modules).join(), 'discord', 'confirming the extras is the answer the create sends');
489
489
  w.change('team');
490
490
  assert.equal(w.state.confirmed.team, undefined, 'Edit takes a row back');
@@ -629,7 +629,7 @@ test('no path reaches Look without the founder confirming the address: a prefill
629
629
  assert.equal(w.state.confirmed.address, undefined, `${label}: the prefills leave the address unconfirmed`);
630
630
  assert.equal(w.run("rowFixed('address')"), false, `${label}: the address is never a fixed row`);
631
631
  assert.equal(w.row('address').querySelector('[data-row-chg]').hidden, false, `${label}: it can always be changed`);
632
- for (const key of ['code', 'app', 'extras', 'team', 'rewards', 'run']) if (w.run(`rowApplies('${key}')`)) w.ok(key);
632
+ for (const key of ['code', 'app', 'extras', 'team', 'rewards', 'run', 'ship']) if (w.run(`rowApplies('${key}')`)) w.ok(key);
633
633
  assert.deepEqual(plain(w.run('unconfirmedRows()')), ['address'], `${label}: every other row confirmed, the address still asked`);
634
634
  // the address is on the page the founder confirms (task 1004550): only their own
635
635
  // "Looks right, continue" — or Done on the row — confirms it, and its check still runs