@bongos/core 1.21.32 → 1.21.34

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 +68 -43
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0338-modules-travel-through-a-bongos-hosted-store.md +7 -0
  8. package/docs/api/openapi.json +96 -3
  9. package/docs/api-reference.md +3 -2
  10. package/docs/architecture.md +1 -0
  11. package/docs/copy-inventory.md +29 -25
  12. package/docs/copy-registry.json +65 -29
  13. package/docs/design/craft-mode-direction.md +69 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +5 -1
  16. package/docs/page-readings.json +270 -201
  17. package/modules/government/catalog.js +3 -0
  18. package/modules/government/migrations/government_025_module_delist.sql +36 -0
  19. package/modules/hall-ui/public/modules.js +8 -1
  20. package/modules/hall-ui/public/street-desk.js +285 -0
  21. package/modules/hall-ui/public/street-inside.js +8 -4
  22. package/modules/hall-ui/public/street.css +59 -0
  23. package/modules/hall-ui/public/street.html +9 -3
  24. package/modules/hall-ui/public/street.js +219 -17
  25. package/modules/hall-ui/public/street.states.json +83 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +16 -0
  29. package/scripts/gds/module.js +21 -5
  30. package/src/bongos/module-store.js +31 -0
  31. package/src/bongos/route-rank-check.js +3 -0
  32. package/src/bongos/routes/modules.js +35 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/hall_catalog_world.mjs +1 -1
  35. package/tests/hall_matter_card.mjs +1 -1
  36. package/tests/hall_street.mjs +1 -1
  37. package/tests/hall_street_amend.mjs +1 -1
  38. package/tests/hall_street_desk.mjs +328 -0
  39. package/tests/hall_street_inside.mjs +1 -1
  40. package/tests/module_catalog.mjs +34 -6
  41. package/tests/module_store_delist.mjs +146 -0
@@ -258,7 +258,7 @@ async function bootPage({ feeds, posts = {} }) {
258
258
  };
259
259
  const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite });
260
260
  ctx.globalThis = ctx;
261
- for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
261
+ for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street.js']) vm.runInContext(read(f), ctx);
262
262
  await window.GovStreetPage.ready;
263
263
  return { window, byId, calls, focused };
264
264
  }
@@ -376,7 +376,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
376
376
  assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
377
377
  for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
378
378
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
379
- assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-amend', 'street'], 'the hall shell (task 1004544), the shared kit, then the card (task 1004528), the interiors (task 1004526) and the drafted amendment (task 1004530)');
379
+ assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the shared kit, then the card (task 1004528), the interiors (task 1004526), the floor desk (task 1004558) and the drafted amendment (task 1004530)');
380
380
  assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
381
381
  });
382
382
 
@@ -253,7 +253,7 @@ async function bootPage({ hash = '#building=review', cons = constitution(), answ
253
253
  };
254
254
  const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map, setTimeout, clearTimeout });
255
255
  ctx.globalThis = ctx;
256
- for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-amend.js', 'street.js']) vm.runInContext(read(f), ctx);
256
+ for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street-amend.js', 'street.js']) vm.runInContext(read(f), ctx);
257
257
  await window.GovStreetPage.ready;
258
258
  await tick();
259
259
  return { window, byId, focused, gets, posts, history };
@@ -0,0 +1,328 @@
1
+ // tests/hall_street_desk.mjs — THE FLOOR DESK AND THE LINE RULE (task 1004558,
2
+ // goal 1000125; round 2 of the Governor City).
3
+ //
4
+ // WHAT IT GUARDS. The chosen floor of a building is the desk from the round 2
5
+ // board (docs/design/mocks/governor-city/Desks.dc.html), built by
6
+ // GovDesk.deskModel() (street-desk.js) and drawn by street.js. What can go wrong:
7
+ // * THE LINE RULE breaking on real data: a row name over three words, a
8
+ // second fact, a third button, a tile that reads zero or carries a
9
+ // sentence, a status that is orange when nothing needs you, an empty list
10
+ // where a quiet floor should be one lit line. Pinned by running EVERY
11
+ // building and EVERY floor of a deliberately long-winded docket through
12
+ // GovDesk.breaks() — the test that fails when the docket produces a line
13
+ // the rule does not allow;
14
+ // * fit() drifting from the board's own fit() (run from the board's
15
+ // Component class) or the limits drifting from the board's numbers;
16
+ // * a row's button sending anything but the matter card's own request to
17
+ // the existing route, or a reason arriving as free text instead of a chip;
18
+ // * the game layer going quiet: the act that does not clear its row, count a
19
+ // pip on the today meter, light the cleared floor with a tick, or say a toast.
20
+ //
21
+ // Run: node --test tests/hall_street_desk.mjs
22
+
23
+ import { test } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import vm from 'node:vm';
28
+ import { createRequire } from 'node:module';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
33
+ const require = createRequire(import.meta.url);
34
+ require(path.join(PUB, 'city-draw.js'));
35
+ const S = require(path.join(PUB, 'street-draw.js'));
36
+ const I = require(path.join(PUB, 'street-inside.js'));
37
+ const C = require(path.join(PUB, 'street-card.js'));
38
+ const D = require(path.join(PUB, 'street-desk.js'));
39
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
40
+ const NOW = Date.parse('2026-10-03T12:00:00.000Z');
41
+ const j = (v) => JSON.parse(JSON.stringify(v));
42
+
43
+ // the board's own Component class, run beside the desk
44
+ function board() {
45
+ const src = fs.readFileSync(path.join(ROOT, 'docs/design/mocks/governor-city/Desks.dc.html'), 'utf8');
46
+ const script = src.split('data-dc-script')[1].split('>').slice(1).join('>').split('</script>')[0];
47
+ const DCLogic = class { constructor(p) { this.props = p || {}; this.state = {}; } setState(s) { Object.assign(this.state, s); } };
48
+ const Comp = new Function('DCLogic', 'setTimeout', 'clearTimeout', `${script}\nreturn Component;`)(DCLogic, () => 0, () => {});
49
+ return new Comp({ mode: 'dark' });
50
+ }
51
+
52
+ // ── a long-winded docket: every source, long words everywhere ─────────────────
53
+ const LONG = 'The extraordinarily long-winded proposal to reorganise every single floor of the hall before winter';
54
+ const SOURCES = [['board', 'charter'], ['blockers', 'planning'], ['access_requests', 'people'], ['goal_join_requests', 'people'], ['override_requests', 'release'], ['removal_proposals', 'disputes'], ['redteam_reports', 'disputes'], ['criteria_uat', 'review'], ['core_update', 'release'], ['gate_approvals', 'release'], ['autobongos_fence', 'automation'], ['module_rechecks', 'release'], ['grade_appeals', 'disputes'], ['standing_rules', 'rules'], ['treasury_spending', 'treasury']];
55
+ function matter(over = {}) {
56
+ return { id: 'blockers:1', source: 'blockers', building: 'planning', rule: 'majority', state: 'needs', title: 'A matter', short: 'a matter', where: 'Planning', opened_at: '2026-09-29T10:00:00.000Z', deadline: null, proposer: null, tally: null, href: null, ref: null, ...over };
57
+ }
58
+ function docket(matters, over = {}) {
59
+ const counts = { needs: 0, moving: 0, settled: 0 };
60
+ matters.forEach((m) => { counts[m.state] += 1; });
61
+ return {
62
+ viewer: { id: '95' }, counts, line: '', matters,
63
+ buildings: S.CIVIC_KEYS.map((key) => ({ key, name: key[0].toUpperCase() + key.slice(1), group: '', counts: {}, matter_ids: [] })),
64
+ sources: SOURCES.map(([source, building]) => ({ source, building, label: source, status: 'live', count: 0, reason: null })),
65
+ readings: ['flow', 'promises', 'money', 'people', 'fairness', 'rules'].map((key) => ({ key, label: key, building: 'people', status: 'live', value: 0, figure: key === 'money' ? '$1,234,567.89 left this month' : '0 people', line: LONG, hot: false, reason: null })),
66
+ floors: Object.values(I.PLAN).flatMap((p) => p.floors).map((f) => f[2] && f[2].floor).filter(Boolean).map((key, i) => ({ key, building: key.split('.')[0], status: 'live', count: i % 3 === 0 ? '0' : i % 3 === 1 ? '1,204,331' : '', line: i % 2 ? LONG : '12 things that the floor counts', lines: [[LONG, 'today']], hot: i % 4 === 0, reason: null })),
67
+ history: S.CIVIC_KEYS.flatMap((b) => [0, 1, 2, 3, 4].map((n) => ({ id: `h:${b}:${n}`, source: 'government', building: b, title: LONG, at: '2026-09-2' + n + 'T10:00:00.000Z', outcome: 'passed after a very long sitting' }))),
68
+ ...over,
69
+ };
70
+ }
71
+ function longMatters() {
72
+ const out = [];
73
+ let n = 0;
74
+ for (const [source, building] of SOURCES) {
75
+ for (const state of ['needs', 'moving', 'settled']) {
76
+ n += 1;
77
+ out.push(matter({ id: `${source}:${n}`, source, building, state, title: `${LONG} ${n}`, short: 'a matter with a very long short description', where: 'Somewhere far away in the hall, vouched for by everyone', proposer: { name: 'Maximiliana Theodora Alexandrovna-Wellington III', login: 'maxi' }, deadline: '2026-10-05T10:00:00.000Z', opened_at: `2026-0${(n % 9) + 1}-1${n % 9}T10:00:00.000Z`, href: source === 'goal_join_requests' ? '/builders/goal/42' : source === 'core_update' ? '/settings#software-update' : null, tally: source === 'board' ? { open: 3, needs: 'needs 4 of 5' } : null }));
78
+ }
79
+ }
80
+ out.push(matter({ id: 'board:900', source: 'board', building: 'planning', short: 'idea to ratify', title: LONG, state: 'needs' }));
81
+ return out;
82
+ }
83
+ const ROOM = { open: [{ id: '1', subject_type: 'constitutional_amendment', subject: { title: LONG, author_id: '7' }, votes: [] }, { id: '900', subject_type: 'full_idea', subject: { title: LONG }, votes: [] }], closed: [] };
84
+
85
+ function everyDesk(dk, extra = {}) {
86
+ const out = [];
87
+ for (const key of S.CIVIC_KEYS) {
88
+ I.PLAN[key].floors.forEach((_f, floor) => out.push(D.deskModel({ key, floor, docket: dk, goals: [{ id: '1', title: LONG, status: 'open' }], now: NOW, room: ROOM, viewerId: '95', ...extra })));
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // ── the line rule ─────────────────────────────────────────────────────────────
94
+
95
+ test('fit() is the board\'s fit(), with a word cap on top', () => {
96
+ const b = board();
97
+ for (const [s, n] of [['Governor city', 20], [LONG, 20], [LONG, 44], ['Ana Ruiz', 18], ['exactly twenty chars', 20], [' spaced out ', 30]]) {
98
+ assert.equal(D.fit(s, n), b.fit(String(s).replace(/\s+/g, ' ').trim(), n), `${s} @ ${n}`);
99
+ }
100
+ assert.equal(D.fit('Raise the hosting limit', 20, 3), 'Raise the hosting…', 'a cut by words says so');
101
+ assert.equal(D.fit('Ana Ruiz', 20, 3), 'Ana Ruiz', 'nothing cut, nothing marked');
102
+ for (const n of [5, 12, 20]) for (const w of [1, 2, 3]) assert.ok(D.fit(LONG, n, w).length <= n, 'never longer than its cap');
103
+ });
104
+
105
+ test('the limits are the board\'s numbers: name 20, fact 26, facts 24 (three), status 44, record 30 (four), three tiles, ten pips', () => {
106
+ const L = D.LIMITS;
107
+ assert.deepEqual([L.name, L.nameWords, L.fact, L.factLen, L.facts, L.actions, L.status, L.recordLen, L.record, L.tiles, L.tileWords, L.todayTarget], [20, 3, 26, 24, 3, 2, 44, 30, 4, 3, 2, 10]);
108
+ const src = fs.readFileSync(path.join(ROOT, 'docs/design/mocks/governor-city/Desks.dc.html'), 'utf8');
109
+ for (const pin of ['this.fit(r.t, 20)', 'this.fit(r.f, 26)', 'this.fit(t, 24)', '.slice(0, 3)', "this.fit('nothing needs you · ' + F.quiet, 44)", "this.fit(t + ' · now', 30)", '.slice(0, 4)', 'const target = 10']) assert.ok(src.includes(pin), `the board says ${pin}`);
110
+ });
111
+
112
+ test('THE LINE RULE: no row, tile or status any docket produces breaks it, on every floor of every building', () => {
113
+ const dk = docket(longMatters());
114
+ const desks = everyDesk(dk);
115
+ assert.equal(desks.length, S.CIVIC_KEYS.reduce((a, k) => a + I.PLAN[k].floors.length, 0));
116
+ const broken = desks.flatMap((d) => D.breaks(d));
117
+ assert.deepEqual(broken, []);
118
+ // the stress docket really reaches every part of the rule
119
+ const rows = desks.flatMap((d) => d.rows);
120
+ assert.ok(rows.length >= 30, 'rows were drawn');
121
+ assert.ok(rows.some((r) => r.actions.length === 2) && rows.some((r) => r.chips) && rows.some((r) => r.wait), 'two buttons, chips and a wait pill were all exercised');
122
+ assert.ok(desks.some((d) => d.quiet), 'a quiet floor was exercised');
123
+ assert.ok(desks.some((d) => d.tiles.length === 3), 'three tiles were exercised');
124
+ assert.ok(desks.some((d) => d.hot), 'an orange status was exercised');
125
+ const moving = everyDesk(docket(longMatters().filter((m) => m.state !== 'needs')));
126
+ assert.ok(moving.some((d) => !d.hot && d.rows.length), 'a grey status over rows was exercised');
127
+ assert.deepEqual(moving.flatMap((d) => D.breaks(d)), []);
128
+ assert.ok(rows.some((r) => r.name.endsWith('…')), 'a long title was cut, and says so');
129
+ // the same docket with acts done, floors cleared, and with nothing readable
130
+ assert.deepEqual(everyDesk(dk, { done: Object.fromEntries(dk.matters.map((m) => [m.id, true])), cleared: true, mine: [LONG, 'let in Ana'], today: 123456 }).flatMap((d) => D.breaks(d)), []);
131
+ assert.deepEqual(everyDesk(null).flatMap((d) => D.breaks(d)), []);
132
+ assert.deepEqual(everyDesk(docket([])).flatMap((d) => D.breaks(d)), []);
133
+ });
134
+
135
+ test('breaks() names every way a desk can break the rule (so the rule test can fail)', () => {
136
+ const dk = docket(longMatters());
137
+ const good = D.deskModel({ key: 'people', floor: 4, docket: dk, now: NOW });
138
+ assert.deepEqual(D.breaks(good), []);
139
+ const bad = j(good);
140
+ bad.rows[0].name = 'Four words in here';
141
+ bad.rows[0].fact = 'x'.repeat(27);
142
+ bad.rows[0].actions = [{ label: 'a' }, { label: 'b' }, { label: 'c' }];
143
+ bad.rows[0].facts = ['a', 'b', 'c', 'd'];
144
+ bad.tiles = [{ v: '0', l: 'need you' }, { v: '3', l: 'three whole words' }, { v: '4', l: 'ok' }, { v: '5', l: 'ok' }];
145
+ bad.status = 'x'.repeat(45);
146
+ bad.hot = false;
147
+ const why = D.breaks(bad).join('\n');
148
+ for (const w of ['row name', 'row fact', 'more than two buttons', 'row facts', 'tile value', 'tile label', 'more than three tiles', 'status too long', 'orange only when']) assert.match(why, new RegExp(w), w);
149
+ const empty = j(good); empty.rows = []; empty.quiet = false;
150
+ assert.match(D.breaks(empty).join(), /quiet and rows disagree/, 'never an empty list');
151
+ });
152
+
153
+ // ── which matters sit on which floor ──────────────────────────────────────────
154
+
155
+ test('a floor holds its own source; the ideas floor holds the open ideas; the rest sit at the front desk; settled is the record', () => {
156
+ const ms = [
157
+ matter({ id: 'access_requests:1', source: 'access_requests', building: 'people', state: 'needs' }),
158
+ matter({ id: 'access_requests:2', source: 'access_requests', building: 'people', state: 'settled' }),
159
+ matter({ id: 'board:5', source: 'board', building: 'people', state: 'needs' }),
160
+ matter({ id: 'board:6', source: 'board', building: 'planning', short: 'idea to ratify', state: 'moving' }),
161
+ ];
162
+ const dk = docket(ms);
163
+ const at = (k, f) => D.floorMatters(k, f, dk).map((m) => m.id);
164
+ assert.deepEqual(at('people', 4), ['access_requests:1'], 'Waiting to join: the open request only');
165
+ assert.deepEqual(at('people', 0), ['board:5'], 'a sitting sits at the front desk');
166
+ assert.deepEqual(at('planning', 2), ['board:6'], 'Ideas to ratify');
167
+ assert.deepEqual(at('planning', 0), [], 'an idea is not also at the front desk');
168
+ assert.equal(D.firstNeeds('people', dk), 0);
169
+ assert.equal(D.firstNeeds('rules', dk), null);
170
+ });
171
+
172
+ // ── the acts are the card's own requests ──────────────────────────────────────
173
+
174
+ test('a row\'s buttons are the card\'s acts under the board\'s words, and send the card\'s own request', () => {
175
+ const ms = [
176
+ matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 } }),
177
+ matter({ id: 'goal_join_requests:12', source: 'goal_join_requests', building: 'people', state: 'needs', proposer: { name: 'Kai Mo' }, href: '/builders/goal/42', ref: { id: 12 } }),
178
+ matter({ id: 'override_requests:13', source: 'override_requests', building: 'release', state: 'needs', ref: { id: 13 } }),
179
+ matter({ id: 'gate_approvals:14', source: 'gate_approvals', building: 'release', state: 'needs', ref: { id: 14 } }),
180
+ matter({ id: 'board:1', source: 'board', building: 'charter', state: 'needs', ref: { type: 'board_item', id: '1' } }),
181
+ ];
182
+ const dk = docket(ms);
183
+ const row = (k, f) => D.rowsFor(k, f, dk, { room: ROOM, now: NOW, viewerId: '95' })[0];
184
+ const ana = row('people', 4);
185
+ assert.equal(ana.name, 'Ana Ruiz', 'a person is named by their name');
186
+ assert.deepEqual(ana.actions.map((a) => [a.key, a.label, a.tone]), [['fine', 'let in', 'primary'], ['refuse', 'not now', 'quiet']]);
187
+ assert.deepEqual(j(C.request(ana.card, 'fine')), { method: 'PATCH', url: '/api/bongos/access-requests/11', body: { status: 'invited' } });
188
+ assert.deepEqual(j(C.request(row('people', 6).card, 'refuse')), { method: 'POST', url: '/api/bongos/goals/42/requests/12/respond', body: { action: 'deny' } });
189
+ const rel = D.rowsFor('release', 0, dk, { now: NOW });
190
+ assert.deepEqual(rel[0].actions.map((a) => a.label), ['release', 'hold']);
191
+ assert.equal(C.request(rel[0].card, 'release').url, '/api/bongos/override-requests/13/decide');
192
+ assert.equal(C.request(D.rowsFor('release', 5, dk, { now: NOW })[0].card, 'release').url, '/api/bongos/gate-approvals/14/approve');
193
+ // a vote: yes and no, and the no's reason is a chip, never free text
194
+ const vote = row('charter', 0);
195
+ assert.deepEqual(vote.actions.map((a) => [a.key, a.label]), [['for', 'yes'], ['object', 'no']]);
196
+ assert.equal(vote.chips.for, 'object');
197
+ assert.deepEqual(vote.chips.options.map((c) => c.key), C.SECTIONS.map((s) => s.key), 'every chip is a section the server accepts');
198
+ const chip = vote.chips.options[0];
199
+ assert.equal(C.check(vote.card, 'object', chip.input), null, 'one tap is a complete objection');
200
+ assert.deepEqual(j(C.request(vote.card, 'object', chip.input).body), { direction: 'no', reason_md: chip.input.reason, section_key: 'scope' });
201
+ // before the sitting is read the row waits, never guesses a ballot
202
+ assert.equal(D.rowsFor('charter', 0, dk, { now: NOW })[0].wait, 'reading…');
203
+ });
204
+
205
+ // ── the page: the desk acts in place, and the game layer answers ──────────────
206
+
207
+ function fakeDom() {
208
+ const byId = new Map();
209
+ const focused = { el: null };
210
+ const mk = (tag) => ({
211
+ tagName: tag, attrs: {}, kids: [], className: '', listeners: {}, _text: '', hidden: false,
212
+ style: { setProperty(k, v) { this[k] = v; } },
213
+ clientWidth: 1440, clientHeight: 708, offsetLeft: 0, scrollLeft: 0, parentNode: null,
214
+ get children() { const a = this.kids.slice(); a[Symbol.isConcatSpreadable] = false; return a; },
215
+ get firstChild() { return this.kids[0] || null; },
216
+ setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
217
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
218
+ appendChild(c) { this.kids.push(c); c.parentNode = this; return c; },
219
+ removeChild(c) { this.kids = this.kids.filter((x) => x !== c); return c; },
220
+ addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
221
+ fire(t) { (this.listeners[t] || []).forEach((fn) => fn({ type: t, preventDefault() {} })); },
222
+ focus() { focused.el = this; },
223
+ set textContent(v) { this._text = String(v); this.kids = []; },
224
+ get textContent() { return this._text; },
225
+ get innerHTML() { throw new Error('no innerHTML'); },
226
+ set innerHTML(v) { throw new Error('no innerHTML'); },
227
+ });
228
+ const html = read('street.html').replace(/<!--[\s\S]*?-->/g, '');
229
+ for (const id of [...html.matchAll(/id="([A-Za-z]+)"/g)].map((m) => m[1])) {
230
+ const el = mk('x');
231
+ el.setAttribute('id', id);
232
+ const tag = new RegExp(`<[^>]*\\bid="${id}"[^>]*>`).exec(html);
233
+ if (tag && /\shidden(?=[\s>])/.test(tag[0])) el.hidden = true;
234
+ }
235
+ return { document: { documentElement: mk('html'), getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_n, t) => mk(t) }, byId, focused };
236
+ }
237
+ async function bootPage(hash, ms, { refuse = false } = {}) {
238
+ const { document, byId, focused } = fakeDom();
239
+ const sent = [];
240
+ const store = new Map();
241
+ let matters = ms.slice();
242
+ const feeds = () => ({ '/api/bongos/government/docket': docket(matters), '/api/bongos/goals': { goals: [] }, '/api/bongos/goals/rollups': { rollups: [] } });
243
+ const location = { hash, href: '' };
244
+ const window = {
245
+ innerWidth: 1440, innerHeight: 900, location,
246
+ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
247
+ matchMedia: () => ({ matches: true, addEventListener() {} }),
248
+ addEventListener() {}, history: { pushState() {} },
249
+ };
250
+ const fetch = (url, init) => {
251
+ if (init && init.method && init.method !== 'GET') {
252
+ sent.push({ method: init.method, url, body: JSON.parse(init.body || '{}') });
253
+ if (refuse) return Promise.resolve({ ok: false, status: 409, json: async () => ({ error: { code: 'not_pending' } }) });
254
+ matters = matters.filter((m) => !url.includes('/' + String(m.id).split(':')[1]));
255
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({ ok: true }) });
256
+ }
257
+ const body = feeds()[url];
258
+ return Promise.resolve(body === undefined ? { ok: false, json: async () => ({}) } : { ok: true, json: async () => body });
259
+ };
260
+ const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map, Set });
261
+ ctx.globalThis = ctx;
262
+ for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street.js']) vm.runInContext(read(f), ctx);
263
+ await window.GovStreetPage.ready;
264
+ await new Promise((r) => setTimeout(r, 0));
265
+ const deskEl = () => (byId.get('insideOpenA').hidden ? byId.get('insideOpenB') : byId.get('insideOpenA'));
266
+ const part = (cls) => deskEl().children.find((c) => c.className.split(' ').includes(cls));
267
+ return { window, byId, focused, sent, store, deskEl, part };
268
+ }
269
+
270
+ test('the page: the desk acts in place — the row clears, the floor lights with a tick, the meter takes a pip, the toast speaks', async () => {
271
+ const ms = [matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 }, opened_at: '2026-10-01T12:00:00.000Z' })];
272
+ const p = await bootPage('#building=people', ms);
273
+ const html = read('street.html');
274
+ assert.ok(html.indexOf('/builders/street-desk.js?') > html.indexOf('/builders/street-inside.js?') && html.indexOf('/builders/street-desk.js?') < html.indexOf('/builders/street.js?'), 'the desk loads after the interior and before the page');
275
+ assert.equal(p.byId.get('insideFloors').children[4].attrs['aria-pressed'], 'true', 'the building opens on the floor that needs you');
276
+ const head = p.deskEl().children[0].children[1];
277
+ assert.deepEqual([head.children[1].textContent, head.children[2].textContent, head.children[2].className], ['Waiting to join', '1 waiting · oldest 2 days', 'desk-status desk-status--hot']);
278
+ assert.deepEqual(p.part('desk-tiles').children.map((t) => [t.children[0].textContent, t.children[1].textContent]), [['1', 'waiting'], ['5', 'on record']], 'the first tile is the count in the floor noun, as on the board; a zero tile (nothing moving) is hidden');
279
+ const row = p.part('desk-rows').children[0];
280
+ const name = row.children[0].children[1];
281
+ assert.equal(name.children[0].textContent, 'Ana Ruiz');
282
+ name.fire('click');
283
+ const opened = p.part('desk-rows').children[0];
284
+ assert.equal(opened.children[0].children[1].attrs['aria-expanded'], 'true');
285
+ assert.ok(opened.children[1].children.length >= 1 && opened.children[1].children.length <= 3, 'at most three facts open in place');
286
+ const acts = opened.children[0].children[2].children;
287
+ assert.deepEqual(acts.map((b) => b.textContent), ['not now', 'let in'], 'the primary sits rightmost');
288
+ acts[1].fire('click');
289
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
290
+ assert.deepEqual(p.sent, [{ method: 'PATCH', url: '/api/bongos/access-requests/11', body: { status: 'invited' } }], 'the card\'s own route, nothing else');
291
+ assert.match(p.byId.get('streetToast').textContent, /Ana Ruiz is invited in/);
292
+ assert.equal(p.byId.get('streetToast').hidden, false);
293
+ assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
294
+ assert.equal(p.byId.get('streetTodayPips').children.filter((x) => x.className.includes('--on')).length, 1, 'one pip per decision');
295
+ assert.equal(JSON.parse(p.store.get('gov-desk-today')).n, 1, 'the day keeps its count');
296
+ const quiet = p.part('desk-quiet');
297
+ assert.ok(quiet && quiet.className.includes('desk-quiet--cleared'), 'a cleared floor is one lit line, never an empty list');
298
+ assert.deepEqual(quiet.children.slice(1).map((x) => x.textContent), ['Everyone is in', 'lit up · 1 decided today']);
299
+ assert.equal(p.part('desk-rows'), undefined);
300
+ assert.equal(p.part('desk-record').children[1].textContent, 'let in Ana Ruiz · now', 'the record line starts with what you did');
301
+ assert.equal(p.byId.get('insideFloors').children[4].attrs['aria-pressed'], 'true', 'you stay on the floor you cleared');
302
+ // the drawing: the cleared floor's windows lit and its tick drawn
303
+ const bv = I.buildingModel({ key: 'people', docket: docket([]), goals: [], mode: 'dark', now: NOW, floor: 4, cleared: [4] });
304
+ const plain = I.buildingModel({ key: 'people', docket: docket([]), goals: [], mode: 'dark', now: NOW, floor: 0 });
305
+ assert.ok(bv.shapes.some((s) => /l6 6 12-13$/.test(s.d)), 'a tick on the cleared floor');
306
+ assert.ok(!plain.shapes.some((s) => /l6 6 12-13$/.test(s.d)), 'no tick on a floor nobody cleared');
307
+ });
308
+
309
+ test('the page: a refused act keeps the row and says why; no pip is counted', async () => {
310
+ const ms = [matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 } })];
311
+ const p = await bootPage('#building=people', ms, { refuse: true });
312
+ p.part('desk-rows').children[0].children[0].children[2].children[1].fire('click');
313
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
314
+ assert.equal(p.byId.get('streetToast').textContent, 'Someone already decided this.');
315
+ assert.equal(p.part('desk-rows').children.length, 1);
316
+ assert.equal(p.byId.get('streetTodayN').textContent, '0 decided');
317
+ });
318
+
319
+ test('the stylesheet: the desk in tokens only, its motion dies under reduced motion, the chips clear 24px', () => {
320
+ const css = read('street.css').replace(/\/\*[\s\S]*?\*\//g, '');
321
+ const desk = css.split('\n').filter((l) => /^\.(desk|street-today)/.test(l)).join('\n');
322
+ assert.ok(desk.length > 0);
323
+ assert.doesNotMatch(desk, /#[0-9a-f]{3,8}\b|rgba?\(/i, 'no literal colours on the desk');
324
+ assert.match(css, /\.desk-chip \{[^}]*height: 34px/);
325
+ assert.match(css, /\.desk-row--gone \{[^}]*animation: desk-gone/);
326
+ assert.match(css, /@media \(prefers-reduced-motion: reduce\) \{[^}]*animation: none !important; transition: none !important;/);
327
+ assert.match(read('street.html'), /id="streetTodayPips"/);
328
+ });
@@ -508,7 +508,7 @@ async function bootPage({ hash = '', feeds = {}, innerWidth = 1440 } = {}) {
508
508
  };
509
509
  const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map });
510
510
  ctx.globalThis = ctx;
511
- for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
511
+ for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street.js']) vm.runInContext(read(f), ctx);
512
512
  await window.GovStreetPage.ready;
513
513
  await new Promise((r) => setTimeout(r, 0));
514
514
  const fireWin = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));
@@ -225,10 +225,11 @@ test('formatCatalog: a search with no hits says so instead of printing an empty
225
225
  assert.ok(!/KEY\s+SOURCE/.test(out), 'no header row over an empty result');
226
226
  });
227
227
 
228
- test('cmdList: --json emits the rows + summary, and honours --catalog/--search', () => {
228
+ test('cmdList: --json emits the rows + summary, and honours --catalog/--search', async () => {
229
229
  const lines = [];
230
- const code = mod.cmdList(['--json', '--catalog', '--search', 'example world'], {
230
+ const code = await mod.cmdList(['--json', '--catalog', '--search', 'example world'], {
231
231
  log: (s) => lines.push(s),
232
+ checkStore: async () => ({ checked: false, reason: 'offline' }),
232
233
  gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
233
234
  });
234
235
  assert.equal(code, 0);
@@ -239,17 +240,18 @@ test('cmdList: --json emits the rows + summary, and honours --catalog/--search',
239
240
  assert.deepEqual(payload.modules.map((m) => m.key), ['provisioning']);
240
241
  });
241
242
 
242
- test('cmdList: --search with no value is a usage error, not a silent empty listing', () => {
243
+ test('cmdList: --search with no value is a usage error, not a silent empty listing', async () => {
243
244
  const errs = [];
244
- const code = mod.cmdList(['--search'], { log: () => {}, errlog: (s) => errs.push(s), gather: () => { throw new Error('must not gather'); } });
245
+ const code = await mod.cmdList(['--search'], { log: () => {}, errlog: (s) => errs.push(s), gather: () => { throw new Error('must not gather'); } });
245
246
  assert.equal(code, 2);
246
247
  assert.match(errs.join('\n'), /usage: bongos module list/);
247
248
  });
248
249
 
249
- test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently dropped', () => {
250
+ test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently dropped', async () => {
250
251
  const errs = [];
251
- const code = mod.cmdList([], {
252
+ const code = await mod.cmdList([], {
252
253
  log: () => {},
254
+ checkStore: async () => ({ checked: false, reason: 'offline' }),
253
255
  errlog: (s) => errs.push(s),
254
256
  gather: () => ({ rows: sample(), skipped: [{ key: 'rotted', reason: 'coreVersion "^0.1.0" not satisfied by core 1.19.0' }], errors: [], core: '1.19.0' }),
255
257
  });
@@ -257,6 +259,32 @@ test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently
257
259
  assert.match(errs.join('\n'), /rotted: not listed — coreVersion/);
258
260
  });
259
261
 
262
+ test('cmdList: an installed store module the store delisted is marked, and only instance modules are asked about (task 1003797)', async () => {
263
+ const lines = [];
264
+ const asked = [];
265
+ const code = await mod.cmdList([], {
266
+ log: (s) => lines.push(s),
267
+ gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
268
+ checkStore: async (rows) => {
269
+ asked.push(...rows.map((r) => r.key));
270
+ return { checked: true, latest: Object.fromEntries(rows.map((r) => [r.key, { version: '9.9.9', status: 'delisted' }])) };
271
+ },
272
+ });
273
+ assert.equal(code, 0);
274
+ const instanceKeys = sample().filter((r) => r.source === 'instance').map((r) => r.key);
275
+ assert.deepEqual(asked, instanceKeys, 'a core module is never a store install, so it is not asked about');
276
+ const out = lines.join('\n');
277
+ assert.match(out, /DELISTED/);
278
+ assert.match(out, /your copy keeps working, but no more versions will come/);
279
+ const json = [];
280
+ await mod.cmdList(['--json'], {
281
+ log: (s) => json.push(s),
282
+ gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
283
+ checkStore: async () => ({ checked: false, reason: 'offline' }),
284
+ });
285
+ assert.ok(JSON.parse(json.join('\n')).modules.every((m) => m.store_delisted === false), 'offline: nothing is marked');
286
+ });
287
+
260
288
  // ---------------------------------------------------------------------------
261
289
  // The live tree — the catalog must actually describe THIS checkout
262
290
  // ---------------------------------------------------------------------------
@@ -0,0 +1,146 @@
1
+ // tests/module_store_delist.mjs — a Metic+ person can take a module out of the store,
2
+ // and a delisted module actually leaves it, while every instance that already holds
3
+ // it keeps it (task 1003797, ADR 0338 D2). Drives the real POST
4
+ // /store/modules/:key/delist handler off the real router, and the real store reads
5
+ // behind acquire / download / update checks, against a stand-in pool (no port, no
6
+ // real DB — the module_store_reassess.mjs harness).
7
+ //
8
+ // Run: node tests/module_store_delist.mjs
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { test } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+
20
+ const fresh = () => ({
21
+ modules: { weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed', delisted_at: null } },
22
+ versions: [{ id: 7, module_key: 'weather', version: '1.2.0', core_version: '^1.20.0', tree_sha256: 't', tarball_sha256: 'x', tarball_bytes: 10, artifact_path: 'weather/weather-1.2.0.tgz', published_at: '2026-09-30T00:00:00Z' }],
23
+ sql: [],
24
+ });
25
+ let state = fresh();
26
+ const query = async (sql, params = []) => {
27
+ state.sql.push(sql);
28
+ if (/^\s*UPDATE store_modules SET status = 'delisted'/.test(sql)) {
29
+ const m = state.modules[params[0]];
30
+ if (!m || m.status !== 'listed') return { rows: [] };
31
+ m.status = 'delisted'; m.delisted_at = '2026-10-01T00:00:00Z';
32
+ return { rows: [{ ...m }] };
33
+ }
34
+ if (/FROM store_modules WHERE module_key = ANY\(\$1\) AND status = 'delisted'/.test(sql)) {
35
+ return { rows: params[0].map((k) => state.modules[k]).filter((m) => m && m.status === 'delisted') };
36
+ }
37
+ if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
38
+ const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
39
+ }
40
+ if (/JOIN store_modules m/.test(sql)) {
41
+ return { rows: state.versions.filter((v) => params[0].includes(v.module_key)).map((v) => ({ ...v, status: state.modules[v.module_key].status })) };
42
+ }
43
+ if (/FROM store_module_versions WHERE module_key = \$1/.test(sql)) {
44
+ return { rows: state.versions.filter((v) => v.module_key === params[0]) };
45
+ }
46
+ return { rows: [] };
47
+ };
48
+ const pool = { connect: async () => ({ query, release() {} }), query };
49
+ const poolPath = require.resolve('../src/bongos/pool.js');
50
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
51
+
52
+ const store = require('../src/bongos/module-store.js');
53
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
54
+
55
+ const PATH = '/store/modules/:key/delist';
56
+ function handlerFor(router) {
57
+ const layer = router.stack.find((l) => l.route && l.route.path === PATH && l.route.methods.post);
58
+ assert.ok(layer, `no POST ${PATH} on the router`);
59
+ const stack = layer.route.stack;
60
+ return { handle: stack[stack.length - 1].handle, depth: stack.length };
61
+ }
62
+ async function invoke(handle, { key = 'weather', body = {} } = {}) {
63
+ const res = {
64
+ statusCode: 200, body: undefined,
65
+ json(b) { this.body = b; return this; },
66
+ status(c) { this.statusCode = c; return this; },
67
+ set() { return this; },
68
+ fail(code, opts, details) { const o = typeof opts === 'number' ? { status: opts, details } : (opts || {}); this.statusCode = o.status; this.body = { error: code, ...o }; return this; },
69
+ };
70
+ let nextErr = null;
71
+ await handle({ params: { key }, body, builder: { id: 3 } }, res, (err) => { nextErr = err; });
72
+ if (nextErr) throw nextErr;
73
+ return res;
74
+ }
75
+
76
+ const { handle, depth } = handlerFor(buildModulesRouter());
77
+
78
+ test('the route is gated by its own Metic+ atom, granted to metic and archon, and pinned', () => {
79
+ assert.ok(depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
80
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
81
+ assert.match(src, /router\.post\('\/store\/modules\/:key\/delist', auth\.requireBuilder, auth\.requirePermission\('module\.delist'\)/);
82
+ const { byKey } = require('../modules/government/catalog.js');
83
+ const p = byKey('module.delist');
84
+ assert.equal(p.floor, 'metic');
85
+ assert.equal(p.system, false);
86
+ const mig = fs.readFileSync(path.join(ROOT, 'modules/government/migrations/government_025_module_delist.sql'), 'utf8');
87
+ for (const rank of ['metic', 'archon']) assert.match(mig, new RegExp(`SELECT '${rank}', unnest\\(ARRAY\\[\\s*'module\\.delist'`));
88
+ const pins = fs.readFileSync(path.join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
89
+ assert.match(pins, /'POST \/store\/modules\/:key\/delist': 'perm:module\.delist'/);
90
+ });
91
+
92
+ test('no reason, no delist — and only `reason` is accepted', async () => {
93
+ state = fresh();
94
+ for (const body of [{}, { reason: '' }, { reason: ' ' }]) {
95
+ const res = await invoke(handle, { body });
96
+ assert.equal(res.statusCode, 400);
97
+ assert.equal(res.body.error, 'reason_required');
98
+ }
99
+ assert.equal((await invoke(handle, { body: { reason: 'r', revoke: true } })).statusCode, 400, 'unknown fields are refused');
100
+ assert.equal(state.modules.weather.status, 'listed');
101
+ });
102
+
103
+ test('a bad key is refused, an unknown module is 404, a second delist is 409', async () => {
104
+ state = fresh();
105
+ assert.equal((await invoke(handle, { key: 'Bad_Key', body: { reason: 'r' } })).statusCode, 400);
106
+ const missing = await invoke(handle, { key: 'nope', body: { reason: 'r' } });
107
+ assert.equal(missing.statusCode, 404);
108
+ assert.equal(missing.body.error, 'module_not_found');
109
+ assert.equal((await invoke(handle, { body: { reason: 'r' } })).statusCode, 200);
110
+ const again = await invoke(handle, { body: { reason: 'r' } });
111
+ assert.equal(again.statusCode, 409);
112
+ assert.equal(again.body.error, 'already_delisted');
113
+ });
114
+
115
+ test('a delisted module leaves the store: no new install, download, version or update', async () => {
116
+ state = fresh();
117
+ assert.equal((await store.resolveAcquirable('weather', { db: pool })).ok, true, 'installable before');
118
+ const res = await invoke(handle, { body: { reason: 'a security issue was reported' } });
119
+ assert.equal(res.statusCode, 200);
120
+ assert.equal(res.body.status, 'delisted');
121
+ assert.ok(res.body.delisted_at);
122
+ // acquire, tarball and acquired all go through resolveAcquirable.
123
+ const acq = await store.resolveAcquirable('weather', { db: pool });
124
+ assert.equal(acq.ok, false);
125
+ assert.equal(acq.code, 'module_delisted');
126
+ assert.equal((await store.resolveAcquirable('weather', { version: '1.2.0', db: pool })).code, 'module_delisted');
127
+ // `bongos upgrade` sees the status and tells the holder no more versions will come.
128
+ const latest = await store.latestVersions(['weather'], { db: pool });
129
+ assert.equal(latest.weather.status, 'delisted');
130
+ // The hall's Modules tab marks it.
131
+ assert.deepEqual([...(await store.delistedKeySet(['weather', 'other'], { db: pool }))], ['weather']);
132
+ });
133
+
134
+ test('a delist never touches holders, entitlements or scores (ADR 0338 D2, ADR 0359)', async () => {
135
+ state = fresh();
136
+ await invoke(handle, { body: { reason: 'r' } });
137
+ assert.ok(state.sql.every((q) => !/module_entitlements|module_assessment_scores|module_assessment_signals/.test(q)),
138
+ 'the delist writes no entitlement, score or signal row');
139
+ // An installed module loads from the instance's own disk: the loader never asks
140
+ // the store, so a delist cannot stop a held copy from mounting.
141
+ for (const f of fs.readdirSync(path.join(ROOT, 'src/module-loader'))) {
142
+ if (!f.endsWith('.js')) continue;
143
+ const src = fs.readFileSync(path.join(ROOT, 'src/module-loader', f), 'utf8');
144
+ assert.doesNotMatch(src, /store_modules|delisted|\/store\/modules/, `${f} must not consult the store`);
145
+ }
146
+ });