@bongos/core 1.20.55 → 1.20.56
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 +76 -36
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +54 -3
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +45 -41
- package/docs/copy-registry.json +83 -47
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +13 -5
- package/docs/page-readings.json +102 -103
- package/modules/hall-ui/public/founding-mark.js +67 -0
- package/modules/hall-ui/public/hall-widgets.js +31 -0
- package/modules/hall-ui/public/index-founding.states.json +13 -0
- package/modules/hall-ui/public/index.html +2 -1
- package/modules/hall-ui/public/profile.html +2 -1
- package/modules/hall-ui/public/profile.js +18 -0
- package/modules/hall-ui/public/ranks-founding.states.json +12 -0
- package/modules/hall-ui/public/ranks.html +11 -1
- package/modules/hall-ui/public/ranks.js +25 -0
- package/modules/hall-ui/public/roster.html +1 -0
- package/modules/hall-ui/public/roster.js +18 -2
- package/modules/hall-ui/public/style.css +35 -0
- package/modules/hall-ui/records/founding-mark.md +17 -0
- package/modules/onboarding/founding-builders.js +127 -0
- package/modules/onboarding/founding-mode.js +27 -3
- package/modules/onboarding/routes/onboarding.js +11 -0
- package/modules/ui-design/kit/fixtures/founding-builders-sample.json +9 -0
- package/modules/ui-design/kit/fixtures/founding-builders.json +4 -0
- package/modules/ui-design/kit/serve.js +4 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/founding_builders.mjs +263 -0
package/release-notes.json
CHANGED
|
@@ -8447,5 +8447,11 @@
|
|
|
8447
8447
|
"id": "1004478",
|
|
8448
8448
|
"text": "The recommended ranks on the Government page now say in plain words what each one can do, for example \"Check finished work, vote on tasks, and sort incoming ideas\", instead of listing system codes. The confirmation after addin"
|
|
8449
8449
|
}
|
|
8450
|
+
],
|
|
8451
|
+
"1.20.56": [
|
|
8452
|
+
{
|
|
8453
|
+
"id": "1004424",
|
|
8454
|
+
"text": "Everyone who joins a project while it is being founded now becomes a founding builder, numbered in the order they arrived, and the owner is marked founder. The mark shows as a small seed badge on the builders list and on their"
|
|
8455
|
+
}
|
|
8450
8456
|
]
|
|
8451
8457
|
}
|
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.20.
|
|
78
|
+
const CORE_VERSION = '1.20.56'; // 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,263 @@
|
|
|
1
|
+
// tests/founding_builders.mjs — THE FOUNDING MARK (task 1004424 / BV2.PS10, goal 1000121).
|
|
2
|
+
//
|
|
3
|
+
// Everyone who joins while a project is founding becomes a founding builder, numbered in
|
|
4
|
+
// join order, with the stage they joined at; the founder is "founder". It is a mark, not
|
|
5
|
+
// a rank (spec D1). Done-when: joiners during genesis are numbered and marked on all
|
|
6
|
+
// four surfaces (roster, profile, ranks, the founding wall on Home), and joiners after
|
|
7
|
+
// it are not. The blend rule rides along: a project that was never founded must render
|
|
8
|
+
// every one of those surfaces exactly as before.
|
|
9
|
+
//
|
|
10
|
+
// The server half is RUN (the pure shaper, the read over a fake pool, the write-once
|
|
11
|
+
// stamps setFoundingMode now keeps). The client half is RUN too: founding-mark.js and
|
|
12
|
+
// the real roster.js / hall-widgets.js in a vm, so the row and the wall are the bytes a
|
|
13
|
+
// browser would get, not a copy of them.
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
26
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
27
|
+
const fb = require('../modules/onboarding/founding-builders.js');
|
|
28
|
+
const fm = require('../modules/onboarding/founding-mode.js');
|
|
29
|
+
|
|
30
|
+
const START = '2026-10-01T00:00:00Z';
|
|
31
|
+
const STAMPS = {
|
|
32
|
+
decide: '2026-10-01T00:00:00Z',
|
|
33
|
+
scope: '2026-10-02T00:00:00Z',
|
|
34
|
+
strategize: '2026-10-05T00:00:00Z',
|
|
35
|
+
};
|
|
36
|
+
const B = (id, created_at, extra = {}) => ({ id, created_at, display_name: `b${id}`, github_login: `b${id}`, rank: 'xenos', ...extra });
|
|
37
|
+
|
|
38
|
+
// ── the shaper ───────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
test('a project that was never founded has no marks at all', () => {
|
|
41
|
+
assert.deepEqual(fb.shapeFoundingBuilders({ startedAt: null, founderId: 1, builders: [B(1, START)] }), []);
|
|
42
|
+
assert.deepEqual(fb.shapeFoundingBuilders({}), []);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('joiners during genesis are numbered in join order from #2; the founder is "founder"', () => {
|
|
46
|
+
const marks = fb.shapeFoundingBuilders({
|
|
47
|
+
startedAt: START, stageAt: STAMPS, founderId: 1,
|
|
48
|
+
// deliberately out of order: the order is join time, not the array or the id
|
|
49
|
+
builders: [B(9, '2026-10-06T09:00:00Z'), B(1, '2026-09-20T00:00:00Z', { rank: 'archon' }), B(4, '2026-10-03T12:00:00Z')],
|
|
50
|
+
});
|
|
51
|
+
assert.deepEqual(marks.map((m) => [m.builder_id, m.number, m.founder]), [[1, 1, true], [4, 2, false], [9, 3, false]]);
|
|
52
|
+
assert.equal(marks[0].rank, 'archon', 'the rank rides along for the ranks page');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('each joiner records the stage they joined at and the day of genesis', () => {
|
|
56
|
+
const marks = fb.shapeFoundingBuilders({
|
|
57
|
+
startedAt: START, stageAt: STAMPS, founderId: 1,
|
|
58
|
+
builders: [B(1, START), B(2, '2026-10-01T05:00:00Z'), B(3, '2026-10-03T00:00:00Z'), B(4, '2026-10-06T00:00:00Z')],
|
|
59
|
+
});
|
|
60
|
+
assert.deepEqual(marks.slice(1).map((m) => [m.stage, m.day]), [['decide', 1], ['scope', 3], ['strategize', 6]]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('joiners after genesis carry no mark; builders from before it carry none either', () => {
|
|
64
|
+
const marks = fb.shapeFoundingBuilders({
|
|
65
|
+
startedAt: START, endedAt: '2026-10-10T00:00:00Z', stageAt: STAMPS, founderId: 1,
|
|
66
|
+
builders: [B(1, '2026-01-01T00:00:00Z'), B(2, '2026-06-01T00:00:00Z'), B(3, '2026-10-04T00:00:00Z'), B(4, '2026-10-10T00:00:00Z'), B(5, '2026-11-01T00:00:00Z')],
|
|
67
|
+
});
|
|
68
|
+
assert.deepEqual(marks.map((m) => m.builder_id), [1, 3], 'only the founder and the in-window joiner');
|
|
69
|
+
assert.equal(marks[1].number, 2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('stageAt walks the stamps in genesis order, and an unstamped history is decide', () => {
|
|
73
|
+
assert.equal(fb.stageAt('2026-10-04T00:00:00Z', STAMPS), 'scope');
|
|
74
|
+
assert.equal(fb.stageAt('2026-10-04T00:00:00Z', {}), 'decide');
|
|
75
|
+
assert.equal(fb.stageAt('not a date', STAMPS), 'decide');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── the read ─────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function settingsStore(values) {
|
|
81
|
+
return async (key) => (values[key] != null ? { key, value: values[key] } : null);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test('the read asks for the founder and the window, and shapes what comes back', async () => {
|
|
85
|
+
const calls = [];
|
|
86
|
+
const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [B(1, START), B(2, '2026-10-02T06:00:00Z')] }; } };
|
|
87
|
+
const marks = await fb.foundingBuildersFor({
|
|
88
|
+
getSetting: settingsStore({ [fm.KEYS.STARTED_AT]: START, [fm.stageAtKey('scope')]: STAMPS.scope }),
|
|
89
|
+
getFoundingBuilder: async () => ({ id: 1 }),
|
|
90
|
+
pool,
|
|
91
|
+
});
|
|
92
|
+
assert.equal(calls.length, 1);
|
|
93
|
+
assert.deepEqual(calls[0].params, [1, START, null], 'founder id, window start, open end');
|
|
94
|
+
assert.match(calls[0].sql, /system_role IS NULL/, 'system principals are never founding builders');
|
|
95
|
+
assert.deepEqual(marks.map((m) => [m.builder_id, m.number, m.stage]), [[1, 1, 'decide'], [2, 2, 'scope']]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the read never queries a project that was never founded, and never throws', async () => {
|
|
99
|
+
let queried = false;
|
|
100
|
+
const pool = { query: async () => { queried = true; return { rows: [] }; } };
|
|
101
|
+
assert.deepEqual(await fb.foundingBuildersFor({ getSetting: settingsStore({}), getFoundingBuilder: async () => ({ id: 1 }), pool }), []);
|
|
102
|
+
assert.equal(queried, false);
|
|
103
|
+
const broken = { query: async () => { throw new Error('db down'); } };
|
|
104
|
+
assert.deepEqual(await fb.foundingBuildersFor({
|
|
105
|
+
getSetting: settingsStore({ [fm.KEYS.STARTED_AT]: START }), getFoundingBuilder: async () => null, pool: broken,
|
|
106
|
+
}), []);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── the founding record setFoundingMode keeps ───────────────────────────────
|
|
110
|
+
|
|
111
|
+
test('setFoundingMode stamps each stage once, and ended_at on the first switch-off', async () => {
|
|
112
|
+
const store = new Map();
|
|
113
|
+
const deps = {
|
|
114
|
+
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
115
|
+
setSetting: async (k, v) => { store.set(k, String(v)); return { key: k, value: String(v) }; },
|
|
116
|
+
now: new Date('2026-10-01T00:00:00Z'),
|
|
117
|
+
};
|
|
118
|
+
await fm.setFoundingMode({ active: true }, 1, deps);
|
|
119
|
+
assert.equal(store.get(fm.stageAtKey('decide')), '2026-10-01T00:00:00.000Z', 'starting with no stage stamps decide');
|
|
120
|
+
deps.now = new Date('2026-10-03T00:00:00Z');
|
|
121
|
+
await fm.setFoundingMode({ stage: 'scope' }, 1, deps);
|
|
122
|
+
deps.now = new Date('2026-10-04T00:00:00Z');
|
|
123
|
+
await fm.setFoundingMode({ stage: 'scope' }, 1, deps);
|
|
124
|
+
assert.equal(store.get(fm.stageAtKey('scope')), '2026-10-03T00:00:00.000Z', 'a stage stamp is write-once');
|
|
125
|
+
await fm.setFoundingMode({ active: false }, 1, deps);
|
|
126
|
+
deps.now = new Date('2026-10-09T00:00:00Z');
|
|
127
|
+
await fm.setFoundingMode({ active: false }, 1, deps);
|
|
128
|
+
assert.equal(store.get(fm.KEYS.ENDED_AT), '2026-10-04T00:00:00.000Z', 'ended_at is write-once');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('switching off a project that never started founding stamps no end', async () => {
|
|
132
|
+
const store = new Map();
|
|
133
|
+
await fm.setFoundingMode({ active: false }, 1, {
|
|
134
|
+
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
135
|
+
setSetting: async (k, v) => { store.set(k, String(v)); },
|
|
136
|
+
});
|
|
137
|
+
assert.equal(store.has(fm.KEYS.ENDED_AT), false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── the route ────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
test('the onboarding router serves GET /founding/builders behind a signed-in builder', () => {
|
|
143
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'onboarding', 'routes', 'onboarding.js'), 'utf8');
|
|
144
|
+
assert.match(src, /router\.get\('\/founding\/builders', api\.requireBuilder,/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── the client, RUN ──────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
150
|
+
const MARKS = [
|
|
151
|
+
{ builder_id: 1, number: 1, founder: true, stage: 'decide', day: 1, display_name: 'Ada', rank: 'archon' },
|
|
152
|
+
{ builder_id: 7, number: 2, founder: false, stage: 'scope', day: 3, display_name: 'Avery <x>', rank: 'xenos' },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
function sandboxWith(marks, extra = {}) {
|
|
156
|
+
const sandbox = {
|
|
157
|
+
window: { OTB: { escapeHtml, fmtInt: (n) => String(n == null ? 0 : n), rankLabel: (r) => r } },
|
|
158
|
+
fetch: (url) => Promise.resolve(url.includes('/founding/builders')
|
|
159
|
+
? (marks ? { ok: true, json: () => Promise.resolve({ builders: marks }) } : { ok: false, json: () => Promise.resolve({}) })
|
|
160
|
+
: { ok: true, json: () => Promise.resolve({ leaderboard: [{ id: 1, display_name: 'Ada' }, { id: 7, display_name: 'Avery' }, { id: 8, display_name: 'Rae' }] }) }),
|
|
161
|
+
console,
|
|
162
|
+
Promise,
|
|
163
|
+
...extra,
|
|
164
|
+
};
|
|
165
|
+
vm.createContext(sandbox);
|
|
166
|
+
vm.runInContext(read('founding-mark.js'), sandbox, { filename: 'founding-mark.js' });
|
|
167
|
+
return sandbox;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test('the chip says founder / founding · #N, escapes, and explains itself in its title', () => {
|
|
171
|
+
const { window } = sandboxWith(MARKS);
|
|
172
|
+
const F = window.OTBFounding;
|
|
173
|
+
assert.match(F.chipHtml(MARKS[0]), />founder<\/span>$/);
|
|
174
|
+
assert.match(F.chipHtml(MARKS[1]), /founding · #2/);
|
|
175
|
+
assert.match(F.chipHtml(MARKS[1]), /title="Founding builder #2 — Joined at scope, day 3 of genesis\. A mark for good; it grants nothing\."/);
|
|
176
|
+
assert.equal(F.chipHtml(null), '');
|
|
177
|
+
assert.equal(F.joinedLine(MARKS[0]), 'Founded the project');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a failed or signed-out founding read is an empty list, never an error', async () => {
|
|
181
|
+
const { window } = sandboxWith(null);
|
|
182
|
+
assert.equal((await window.OTBFounding.load()).length, 0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
async function rosterRows(marks) {
|
|
186
|
+
const sandbox = sandboxWith(marks);
|
|
187
|
+
let rowFn = null;
|
|
188
|
+
Object.assign(sandbox.window, {
|
|
189
|
+
OTBBranding: { currencyLabel: () => 'Credits' },
|
|
190
|
+
OTBKit: {
|
|
191
|
+
esc: escapeHtml, makePager: () => ({ page: 1 }), makeFilterBar: () => {}, paginate: (xs) => xs,
|
|
192
|
+
makeLedger: (cfg) => { rowFn = cfg.rowHtml; return { render() {} }; },
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
sandbox.document = { getElementById: () => ({ innerHTML: '', className: '', textContent: '', hidden: false }) };
|
|
196
|
+
if (!marks) delete sandbox.window.OTBFounding; // a page without the script at all
|
|
197
|
+
vm.runInContext(read('roster.js'), sandbox, { filename: 'roster.js' });
|
|
198
|
+
await new Promise((r) => setImmediate(r));
|
|
199
|
+
assert.ok(rowFn, 'roster.js mounted its ledger');
|
|
200
|
+
return rowFn;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
test('the roster marks the founding builders and leaves everyone else exactly as before', async () => {
|
|
204
|
+
const withMarks = await rosterRows(MARKS);
|
|
205
|
+
const without = await rosterRows(null);
|
|
206
|
+
assert.match(withMarks({ id: 7, display_name: 'Avery' }), /class="lb-founding"><span class="founding-mark"[^>]*>.*founding · #2/);
|
|
207
|
+
assert.match(withMarks({ id: 1, display_name: 'Ada' }), />founder<\/span>/);
|
|
208
|
+
assert.equal(withMarks({ id: 8, display_name: 'Rae' }), without({ id: 8, display_name: 'Rae' }), 'an unmarked row is byte-identical');
|
|
209
|
+
assert.doesNotMatch(without({ id: 7, display_name: 'Avery' }), /founding/, 'no founding record, no mark');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
function wall(marks) {
|
|
213
|
+
const sandbox = sandboxWith(marks);
|
|
214
|
+
const widgets = [];
|
|
215
|
+
sandbox.window.OTB.contributeHallWidget = (w) => { widgets.push(w); return w; };
|
|
216
|
+
sandbox.window.__BRANDING__ = {};
|
|
217
|
+
sandbox.window.OTBKit = { pagerBarHtml: () => '' }; // later widgets' skeletons ask the kit for a pager bar
|
|
218
|
+
vm.runInContext(read('hall-widgets.js'), sandbox, { filename: 'hall-widgets.js' });
|
|
219
|
+
const w = widgets.find((x) => x.id === 'home-founding');
|
|
220
|
+
const list = { innerHTML: '' };
|
|
221
|
+
const host = { innerHTML: '', hidden: true, querySelector: () => list };
|
|
222
|
+
return { w, host, list };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test('the founding wall registers on Home, inside the budgeted main zone, hidden by default', () => {
|
|
226
|
+
const { w } = wall(MARKS);
|
|
227
|
+
assert.ok(w, 'a home-founding widget is registered');
|
|
228
|
+
assert.equal(w.view, 'home');
|
|
229
|
+
assert.equal(w.zone, 'home-main');
|
|
230
|
+
assert.equal(w.hiddenByDefault, true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('the wall shows the founder first and each joiner with number and stage', async () => {
|
|
234
|
+
const { w, host, list } = wall(MARKS);
|
|
235
|
+
w.mount(host);
|
|
236
|
+
await new Promise((r) => setImmediate(r));
|
|
237
|
+
assert.equal(host.hidden, false);
|
|
238
|
+
const items = list.innerHTML.split('</li>').filter(Boolean);
|
|
239
|
+
assert.equal(items.length, 2);
|
|
240
|
+
assert.match(items[0], /Ada<\/a><span class="founding-wall__sub">founder/);
|
|
241
|
+
assert.match(items[1], /Avery <x><\/a><span class="founding-wall__sub">#2 · scope/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('a project that was never founded keeps the wall hidden', async () => {
|
|
245
|
+
const { w, host } = wall([]);
|
|
246
|
+
w.mount(host);
|
|
247
|
+
await new Promise((r) => setImmediate(r));
|
|
248
|
+
assert.equal(host.hidden, true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('the profile and the ranks page read the same list, and every page loads the script first', () => {
|
|
252
|
+
const profile = read('profile.js');
|
|
253
|
+
const ranks = read('ranks.js');
|
|
254
|
+
assert.match(profile, /renderHead\(builder\);\s*\n\s*renderFoundingMark\(builder\);/);
|
|
255
|
+
assert.match(ranks, /render\(builder \|\| \{\}\);\s*\n\s*renderFounding\(\);/);
|
|
256
|
+
assert.match(read('ranks.html'), /id="founding-scroll"[^>]*hidden/, 'the ranks section is hidden in the markup');
|
|
257
|
+
for (const [html, page] of [['roster.html', 'roster.js'], ['profile.html', 'profile.js'], ['ranks.html', 'ranks.js'], ['index.html', 'hall-widgets.js']]) {
|
|
258
|
+
const src = read(html);
|
|
259
|
+
const a = src.indexOf('/builders/founding-mark.js');
|
|
260
|
+
const b = src.indexOf(`/builders/${page}`);
|
|
261
|
+
assert.ok(a > 0 && a < b, `${html} loads founding-mark.js before ${page}`);
|
|
262
|
+
}
|
|
263
|
+
});
|