@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.
- package/.bongos-core.json +63 -43
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md +1 -0
- package/docs/api/openapi.json +8 -1
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +32 -28
- package/docs/copy-registry.json +70 -34
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +9 -1
- package/docs/page-readings.json +657 -648
- package/modules/platform-identity/invite-notices.js +78 -10
- package/modules/platform-identity/migrations/platform_identity_031_parked_invite_notices.sql +45 -0
- package/modules/platform-identity/routes/my-projects.js +7 -3
- package/modules/provisioning/birth.js +28 -1
- package/modules/provisioning/routes/provisioning.js +3 -1
- package/modules/public-landing/public/account.html +1 -0
- package/modules/public-landing/public/assets/cosmos.css +8 -0
- package/modules/public-landing/public/assets/invite-badge.js +91 -0
- package/modules/public-landing/public/contact.html +1 -0
- package/modules/public-landing/public/guild.html +1 -0
- package/modules/public-landing/public/invitations.html +7 -2
- package/modules/public-landing/public/privacy.html +1 -0
- package/modules/public-landing/public/projects.html +49 -5
- package/modules/public-landing/public/projects.states.json +1 -0
- package/modules/public-landing/public/terms.html +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/project-settings.js +16 -2
- package/src/branding.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/hub_invite_badge.mjs +204 -0
- package/tests/hub_invite_notices.mjs +61 -6
- package/tests/landing_legal_pages.mjs +1 -1
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_app_step.mjs +1 -1
- package/tests/provision_settings_apply.mjs +4 -1
- package/tests/provisioning_settings_env.mjs +3 -1
- package/tests/wizard_physics_more.mjs +5 -5
- package/tests/wizard_ship_mode.mjs +187 -0
- 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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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,
|
|
123
|
-
assert.match(w[0].sql, /^\s*DELETE FROM
|
|
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[
|
|
129
|
+
assert.deepEqual(w[1].params, ['mercury', '4242']);
|
|
126
130
|
});
|
|
127
131
|
|
|
128
|
-
test('a login with no hub account
|
|
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: '
|
|
138
|
-
|
|
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>/, '
|
|
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
|
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|