@bongos/core 1.19.235 → 1.19.237
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 +35 -20
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/discord/board-broadcast.js +105 -0
- package/modules/discord/db.js +13 -0
- package/modules/discord/module.json +1 -1
- package/modules/discord/routes/discord.js +6 -0
- package/modules/discord/webhook.js +26 -6
- package/modules/government/board.js +34 -1
- package/modules/government/db.js +8 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/government.js +105 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/discord_board_broadcast.mjs +121 -0
- package/tests/government_board_view.mjs +4 -2
- package/tests/government_board_vote_ui.mjs +70 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.237",
|
|
6
|
+
"core_contract": "1.19.237",
|
|
7
|
+
"source_commit": "eaeb8fe89968c0626abe204eb28a8f30caf91a35",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-18T17:14:57.073Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 311,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1322,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1654,
|
|
20
|
+
"tree_sha256": "53eb12ad4fd9752bd82babaf455317c7f3600dc379b40042cdbd29db67bef408",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2687,7 +2687,7 @@
|
|
|
2687
2687
|
{
|
|
2688
2688
|
"path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
|
|
2689
2689
|
"mode": "0000644",
|
|
2690
|
-
"sha256": "
|
|
2690
|
+
"sha256": "5c99db7b6b1b59e595c3c6a7002cf7baa3b130a198213b007448d02a427883f1"
|
|
2691
2691
|
},
|
|
2692
2692
|
{
|
|
2693
2693
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -2739,6 +2739,11 @@
|
|
|
2739
2739
|
"mode": "0000644",
|
|
2740
2740
|
"sha256": "a3261a569a381ed4b19f6f5d32598a9cec09f62be51533fd6b7c5272d53b63d3"
|
|
2741
2741
|
},
|
|
2742
|
+
{
|
|
2743
|
+
"path": "modules/discord/board-broadcast.js",
|
|
2744
|
+
"mode": "0000644",
|
|
2745
|
+
"sha256": "545b65d5db473617de01f19fa3ee3f154373e7696f8549624e441e327181c4e6"
|
|
2746
|
+
},
|
|
2742
2747
|
{
|
|
2743
2748
|
"path": "modules/discord/bug-attachments.js",
|
|
2744
2749
|
"mode": "0000644",
|
|
@@ -2747,7 +2752,7 @@
|
|
|
2747
2752
|
{
|
|
2748
2753
|
"path": "modules/discord/db.js",
|
|
2749
2754
|
"mode": "0000644",
|
|
2750
|
-
"sha256": "
|
|
2755
|
+
"sha256": "79188ee4a6934be0783f18e718a025390e337abdf201c3a521e75b5cdf1e8c0b"
|
|
2751
2756
|
},
|
|
2752
2757
|
{
|
|
2753
2758
|
"path": "modules/discord/discord-approvals.js",
|
|
@@ -2797,7 +2802,7 @@
|
|
|
2797
2802
|
{
|
|
2798
2803
|
"path": "modules/discord/module.json",
|
|
2799
2804
|
"mode": "0000644",
|
|
2800
|
-
"sha256": "
|
|
2805
|
+
"sha256": "506d3c99714060d6fcce93e971546430692591a74df86260d04c07910a185831"
|
|
2801
2806
|
},
|
|
2802
2807
|
{
|
|
2803
2808
|
"path": "modules/discord/pollers/discord-bot.js",
|
|
@@ -2812,7 +2817,7 @@
|
|
|
2812
2817
|
{
|
|
2813
2818
|
"path": "modules/discord/routes/discord.js",
|
|
2814
2819
|
"mode": "0000644",
|
|
2815
|
-
"sha256": "
|
|
2820
|
+
"sha256": "a1de692d790297b2f2286c00980aa981e0ece68fbddabf882a4fcb140ff8132b"
|
|
2816
2821
|
},
|
|
2817
2822
|
{
|
|
2818
2823
|
"path": "modules/discord/ship-broadcast.js",
|
|
@@ -2822,7 +2827,7 @@
|
|
|
2822
2827
|
{
|
|
2823
2828
|
"path": "modules/discord/webhook.js",
|
|
2824
2829
|
"mode": "0000644",
|
|
2825
|
-
"sha256": "
|
|
2830
|
+
"sha256": "cd93f8e837f5fbf92777ff46114ef0ff7e0d3d19b96df750e4c159c1ed42c293"
|
|
2826
2831
|
},
|
|
2827
2832
|
{
|
|
2828
2833
|
"path": "modules/economy/CLAUDE.md",
|
|
@@ -2902,7 +2907,7 @@
|
|
|
2902
2907
|
{
|
|
2903
2908
|
"path": "modules/government/board.js",
|
|
2904
2909
|
"mode": "0000644",
|
|
2905
|
-
"sha256": "
|
|
2910
|
+
"sha256": "a73c72cb9101a74f0d01fbdb4b87101687b54fc876a98bcef6f3bffafc29b5ad"
|
|
2906
2911
|
},
|
|
2907
2912
|
{
|
|
2908
2913
|
"path": "modules/government/catalog.js",
|
|
@@ -2917,7 +2922,7 @@
|
|
|
2917
2922
|
{
|
|
2918
2923
|
"path": "modules/government/db.js",
|
|
2919
2924
|
"mode": "0000644",
|
|
2920
|
-
"sha256": "
|
|
2925
|
+
"sha256": "d50b12547863df08cf32c606fe91187e1239f5b0d339f9d34ed9f3b5403b3d15"
|
|
2921
2926
|
},
|
|
2922
2927
|
{
|
|
2923
2928
|
"path": "modules/government/migrations/governance_001_tables.sql",
|
|
@@ -3272,12 +3277,12 @@
|
|
|
3272
3277
|
{
|
|
3273
3278
|
"path": "modules/hall-ui/public/government.html",
|
|
3274
3279
|
"mode": "0000644",
|
|
3275
|
-
"sha256": "
|
|
3280
|
+
"sha256": "7962d925606ec7c33e9579ebe8bdc45ef29b35899bacfb18d36af71a314629e2"
|
|
3276
3281
|
},
|
|
3277
3282
|
{
|
|
3278
3283
|
"path": "modules/hall-ui/public/government.js",
|
|
3279
3284
|
"mode": "0000644",
|
|
3280
|
-
"sha256": "
|
|
3285
|
+
"sha256": "b1de692afb7abb2bd95a91a31d7c8d244c9a9f702c8ab3d73214bb23472614ec"
|
|
3281
3286
|
},
|
|
3282
3287
|
{
|
|
3283
3288
|
"path": "modules/hall-ui/public/hall-kit.js",
|
|
@@ -4322,12 +4327,12 @@
|
|
|
4322
4327
|
{
|
|
4323
4328
|
"path": "package-lock.json",
|
|
4324
4329
|
"mode": "0000644",
|
|
4325
|
-
"sha256": "
|
|
4330
|
+
"sha256": "0aa89af9e9b19d147dac4f2e227a5c83e551f017c214a93a1b8fdc1b06bbd181"
|
|
4326
4331
|
},
|
|
4327
4332
|
{
|
|
4328
4333
|
"path": "package.json",
|
|
4329
4334
|
"mode": "0000644",
|
|
4330
|
-
"sha256": "
|
|
4335
|
+
"sha256": "504c5178ba2a13a17ed7c187fcc07760980e1f34f0e9ded12b0e45f3c30d5d40"
|
|
4331
4336
|
},
|
|
4332
4337
|
{
|
|
4333
4338
|
"path": "public-docs/index.html",
|
|
@@ -5942,7 +5947,7 @@
|
|
|
5942
5947
|
{
|
|
5943
5948
|
"path": "src/module-api.js",
|
|
5944
5949
|
"mode": "0000644",
|
|
5945
|
-
"sha256": "
|
|
5950
|
+
"sha256": "87b7144a9581bd6f2877671b4624aed2bb872ad7456ec1d8208493a53e604150"
|
|
5946
5951
|
},
|
|
5947
5952
|
{
|
|
5948
5953
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6619,6 +6624,11 @@
|
|
|
6619
6624
|
"mode": "0000644",
|
|
6620
6625
|
"sha256": "9765fcdc48191281115dfaac650719ac8f84257b602d1e83c351e9b9ad6d2949"
|
|
6621
6626
|
},
|
|
6627
|
+
{
|
|
6628
|
+
"path": "tests/discord_board_broadcast.mjs",
|
|
6629
|
+
"mode": "0000644",
|
|
6630
|
+
"sha256": "d2b3406427e242c09a0913643ebe6726a6e738d3a3cf576d0de6e4fd21b0c1f9"
|
|
6631
|
+
},
|
|
6622
6632
|
{
|
|
6623
6633
|
"path": "tests/discord_bot.mjs",
|
|
6624
6634
|
"mode": "0000644",
|
|
@@ -6887,13 +6897,18 @@
|
|
|
6887
6897
|
{
|
|
6888
6898
|
"path": "tests/government_board_view.mjs",
|
|
6889
6899
|
"mode": "0000644",
|
|
6890
|
-
"sha256": "
|
|
6900
|
+
"sha256": "827d743080201f11b2282aaa77d37149aaddaddc42b2b444c260a2696a7727ca"
|
|
6891
6901
|
},
|
|
6892
6902
|
{
|
|
6893
6903
|
"path": "tests/government_board_vote.mjs",
|
|
6894
6904
|
"mode": "0000644",
|
|
6895
6905
|
"sha256": "c1b9b0963dde0c033bd0eb411b63032a51edaa3d91751100593b7e3b4999c017"
|
|
6896
6906
|
},
|
|
6907
|
+
{
|
|
6908
|
+
"path": "tests/government_board_vote_ui.mjs",
|
|
6909
|
+
"mode": "0000644",
|
|
6910
|
+
"sha256": "a9e46b10cf9b6c17550f22463d13caa0d4b63d87d4fc63d087dd14e4b95b8312"
|
|
6911
|
+
},
|
|
6897
6912
|
{
|
|
6898
6913
|
"path": "tests/government_board_window.mjs",
|
|
6899
6914
|
"mode": "0000644",
|
|
@@ -348,8 +348,14 @@ function createClient(opts = {}) {
|
|
|
348
348
|
postGovernmentAssignments: (args) => request("POST", "/government/assignments", { hasBody: true }, args),
|
|
349
349
|
// DELETE /government/assignments/{builderId}/{rankKey} — rank: archon — DELETE /government/assignments/:builderId/:rankKey
|
|
350
350
|
deleteGovernmentAssignmentsBuilderIdRankKey: (args) => request("DELETE", "/government/assignments/{builderId}/{rankKey}", { hasBody: false }, args),
|
|
351
|
+
// GET /government/board/items — rank: any-builder — GET /government/board/items
|
|
352
|
+
getGovernmentBoardItems: (args) => request("GET", "/government/board/items", { hasBody: false }, args),
|
|
353
|
+
// POST /government/board/items — rank: metic+archon — POST /government/board/items
|
|
354
|
+
postGovernmentBoardItems: (args) => request("POST", "/government/board/items", { hasBody: true }, args),
|
|
351
355
|
// POST /government/board/items/{itemId}/votes — rank: any-builder — POST /government/board/items/:itemId/votes
|
|
352
356
|
postGovernmentBoardItemsItemIdVotes: (args) => request("POST", "/government/board/items/{itemId}/votes", { hasBody: true }, args),
|
|
357
|
+
// GET /government/constitution — rank: any-builder — GET /government/constitution
|
|
358
|
+
getGovernmentConstitution: (args) => request("GET", "/government/constitution", { hasBody: false }, args),
|
|
353
359
|
// GET /government/permissions — rank: archon — GET /government/permissions
|
|
354
360
|
getGovernmentPermissions: (args) => request("GET", "/government/permissions", { hasBody: false }, args),
|
|
355
361
|
// GET /government/ranks — rank: archon — GET /government/ranks
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// modules/discord/board-broadcast.js — the Board Room announcer
|
|
2
|
+
// (BV1.R21 of goal 1000069, ADR 0175 §8).
|
|
3
|
+
//
|
|
4
|
+
// On window open, the board channel hears that a vote is wanted: the members
|
|
5
|
+
// under the item's SNAPSHOTTED constitution are @mentioned (the mention IS the
|
|
6
|
+
// "vote within X" notice, and Discord's own mobile app IS the mobile push — no
|
|
7
|
+
// new surface), with a deep link straight to the Board Room in the hall.
|
|
8
|
+
//
|
|
9
|
+
// DISCORD IS OUTBOUND-ONLY HERE (§8, and the C6 criterion): this posts, it
|
|
10
|
+
// reads nothing inbound and casts nothing — an objection needs a reason and a
|
|
11
|
+
// section, which a reaction cannot express, so the hall is the only voting
|
|
12
|
+
// surface and this message is a doorway to it.
|
|
13
|
+
//
|
|
14
|
+
// THE MENTION EXCEPTION: webhook.postWebhookMentions allowlists exactly the
|
|
15
|
+
// member ids the GOVERNMENT PORT resolved — server data, never message text —
|
|
16
|
+
// under the item's snapshot, so an amendment mid-sitting cannot change who is
|
|
17
|
+
// summoned to an already-open item. A member without a Discord link is named
|
|
18
|
+
// in text instead (visible, unpinged, honest). #871's parse:[] still stands:
|
|
19
|
+
// nothing typed into content can ever ring anyone.
|
|
20
|
+
//
|
|
21
|
+
// BEST-EFFORT BY DESIGN, like every broadcast in this module: it never throws,
|
|
22
|
+
// so a Discord hiccup can never fail a window open — the room in the hall is
|
|
23
|
+
// the durable surface, this is the nudge.
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const api = require('../../src/module-api');
|
|
28
|
+
const db = require('./db');
|
|
29
|
+
const webhook = require('./webhook');
|
|
30
|
+
|
|
31
|
+
// Compose + post the announcement. deps injectable for the DB-free tests:
|
|
32
|
+
// resolveMembers(constitution) → the government port's resolveBoardMembers
|
|
33
|
+
// getLink(builderId) → db.getDiscordLink
|
|
34
|
+
// post(channel, text, userIds) → webhook.postWebhookMentions
|
|
35
|
+
// Strip markdown-significant characters from a builder-editable name so a
|
|
36
|
+
// crafted display name cannot carry formatting, links or lookalike mentions on
|
|
37
|
+
// the back of a legitimate announcement. The PING vector is already closed by
|
|
38
|
+
// allowed_mentions; this closes the cosmetic/phishing one.
|
|
39
|
+
function plainName(name) {
|
|
40
|
+
// '.' is stripped too: Discord auto-links bare domains, so a name shaped
|
|
41
|
+
// like one must not survive as a clickable.
|
|
42
|
+
return String(name).replace(/[\`*_~|@#>\[\]()<:.\/]/g, '').trim().slice(0, 64) || 'a member';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function postBoardAnnouncement(payload, deps = {}) {
|
|
46
|
+
const result = { posted: false, skipped: null, mentioned: [], named: [] };
|
|
47
|
+
if (!payload || payload.item_id == null) { result.skipped = 'no_item'; return result; }
|
|
48
|
+
const log = deps.log || console.log;
|
|
49
|
+
try {
|
|
50
|
+
const government = deps.government !== undefined ? deps.government : (api.resolveOptional && api.resolveOptional('government'));
|
|
51
|
+
const resolveMembers = deps.resolveMembers
|
|
52
|
+
|| (government && government.resolveBoardMembers ? (c) => government.resolveBoardMembers(c) : null);
|
|
53
|
+
if (!resolveMembers) { result.skipped = 'no_government_port'; return result; }
|
|
54
|
+
const members = await resolveMembers(payload.constitution || {});
|
|
55
|
+
if (!members || !members.length) { result.skipped = 'empty_board'; return result; }
|
|
56
|
+
|
|
57
|
+
// ONE bulk query for the whole board's links — never a per-member await.
|
|
58
|
+
const getLinks = deps.getLinks
|
|
59
|
+
|| (async (ids) => db.getDiscordLinks(ids));
|
|
60
|
+
let links = new Map();
|
|
61
|
+
try { links = await getLinks(members.map((m) => m.builder_id)); } catch (_) { /* unlinked reads as unlinked */ }
|
|
62
|
+
const mentions = [];
|
|
63
|
+
for (const m of members) {
|
|
64
|
+
const link = links.get(String(m.builder_id));
|
|
65
|
+
const discordId = link && (link.discord_user_id || link.discordUserId);
|
|
66
|
+
if (discordId) {
|
|
67
|
+
result.mentioned.push(String(discordId));
|
|
68
|
+
mentions.push(`<@${discordId}>`);
|
|
69
|
+
} else {
|
|
70
|
+
// Named, visible, unpinged — an unlinked member still sees their name
|
|
71
|
+
// when they read the room; linking is their call, not a gate. Names are
|
|
72
|
+
// builder-editable profile text, so markdown-significant characters are
|
|
73
|
+
// stripped: a crafted display name must not carry links or formatting
|
|
74
|
+
// on the back of a legitimate announcement.
|
|
75
|
+
const safe = plainName(m.display_name || m.github_login || `builder ${m.builder_id}`);
|
|
76
|
+
result.named.push(safe);
|
|
77
|
+
mentions.push(safe);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const subject = payload.subject_type === 'constitutional_amendment'
|
|
82
|
+
? 'A constitutional amendment'
|
|
83
|
+
: 'A Full Idea';
|
|
84
|
+
const clock = payload.window_minutes
|
|
85
|
+
? `within ${payload.window_minutes} minutes`
|
|
86
|
+
: 'at your convenience — the sitting has no clock and closes on the first vote';
|
|
87
|
+
const origin = ((api.branding && api.branding().domains) || {}).buildersOrigin || '';
|
|
88
|
+
// Straight to THAT item: ?item=<id> — the Board Room panel reads it and
|
|
89
|
+
// scrolls to / highlights the named sitting (the hash stays the tab).
|
|
90
|
+
const link = `${origin ? origin.replace(/\/$/, '') : ''}/government?item=${encodeURIComponent(payload.item_id)}#board-room`;
|
|
91
|
+
const line = `\u{1F3DB}\u{FE0F} ${subject} is before the board (item ${payload.item_id}). `
|
|
92
|
+
+ `${mentions.join(' ')} — your vote is wanted ${clock}. Vote in the hall: ${link}`;
|
|
93
|
+
|
|
94
|
+
const post = deps.post || webhook.postWebhookMentions;
|
|
95
|
+
await post('board_room', line, result.mentioned);
|
|
96
|
+
result.posted = true;
|
|
97
|
+
log(`[discord] board announcement posted (item ${payload.item_id}; ${result.mentioned.length} mentioned, ${result.named.length} named)`);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
result.skipped = 'post_failed';
|
|
100
|
+
log(`[discord] board announcement skipped (window open unaffected): ${e && e.message}`);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { postBoardAnnouncement };
|
package/modules/discord/db.js
CHANGED
|
@@ -46,6 +46,18 @@ async function listDiscordLinks() {
|
|
|
46
46
|
return rows;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Bulk twin of getDiscordLink (R21): one query for a whole board's links —
|
|
50
|
+
// the announcer mentions every member, and a per-member await is an N+1 the
|
|
51
|
+
// grader rightly flagged. Returns a Map keyed by builder_id AS A STRING.
|
|
52
|
+
async function getDiscordLinks(builderIds) {
|
|
53
|
+
if (!builderIds || !builderIds.length) return new Map();
|
|
54
|
+
const { rows } = await pool.query(
|
|
55
|
+
`SELECT builder_id, discord_user_id FROM discord_links WHERE builder_id = ANY($1::bigint[])`,
|
|
56
|
+
[builderIds],
|
|
57
|
+
);
|
|
58
|
+
return new Map(rows.map((r) => [String(r.builder_id), r]));
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
async function getDiscordLink(builderId) {
|
|
50
62
|
const { rows } = await pool.query(
|
|
51
63
|
`SELECT builder_id, discord_user_id, linked_at FROM discord_links WHERE builder_id = $1`,
|
|
@@ -82,6 +94,7 @@ module.exports = {
|
|
|
82
94
|
getBuilderByDiscordUserId,
|
|
83
95
|
listDiscordLinks,
|
|
84
96
|
getDiscordLink,
|
|
97
|
+
getDiscordLinks,
|
|
85
98
|
countRecentDiscordBugFilings,
|
|
86
99
|
recordApplause,
|
|
87
100
|
// kernel db helpers, re-exported off the doorway (one `db.*` namespace)
|
|
@@ -17,6 +17,7 @@ const api = require('../../../src/module-api');
|
|
|
17
17
|
const auth = require('../auth');
|
|
18
18
|
const db = require('../db');
|
|
19
19
|
const shipBroadcast = require('../ship-broadcast');
|
|
20
|
+
const boardBroadcast = require('../board-broadcast');
|
|
20
21
|
const roleSync = require('../discord-role-sync');
|
|
21
22
|
|
|
22
23
|
// The module's kernel seams, registered when this factory runs (the loader runs
|
|
@@ -43,6 +44,7 @@ const roleSync = require('../discord-role-sync');
|
|
|
43
44
|
let _shipListenerOff = null;
|
|
44
45
|
let _strandListenerOff = null;
|
|
45
46
|
let _roleSyncListenerOff = null;
|
|
47
|
+
let _boardListenerOff = null;
|
|
46
48
|
function registerSeams() {
|
|
47
49
|
if (!api.hasProvider('discord.isLinked')) {
|
|
48
50
|
api.registerProvider('discord.isLinked', async (builderId) => !!(await db.getDiscordLink(builderId)));
|
|
@@ -58,6 +60,10 @@ function registerSeams() {
|
|
|
58
60
|
_shipListenerOff = api.on('task.shipped', (task) => shipBroadcast.postShipBroadcast(task));
|
|
59
61
|
if (_strandListenerOff) _strandListenerOff();
|
|
60
62
|
_strandListenerOff = api.on('task.strand-flagged', (info) => shipBroadcast.postStrandNotice(info));
|
|
63
|
+
// R21 (goal 1000069): a window opening in the Board Room summons its members
|
|
64
|
+
// — the government module emits, this module posts. Outbound only (§8).
|
|
65
|
+
if (_boardListenerOff) _boardListenerOff();
|
|
66
|
+
_boardListenerOff = api.on('board.item.opened', (payload) => boardBroadcast.postBoardAnnouncement(payload));
|
|
61
67
|
if (_roleSyncListenerOff) _roleSyncListenerOff();
|
|
62
68
|
_roleSyncListenerOff = api.on('builder.role-sync-needed', ({ builderId }) =>
|
|
63
69
|
roleSync.syncBuilderRole(builderId).catch((e) =>
|
|
@@ -27,6 +27,7 @@ const WEBHOOKS_PATH = api.configPath('discord-webhooks.json');
|
|
|
27
27
|
const CHANNELS = new Set([
|
|
28
28
|
'ship_news', 'ship_feed', 'security_ship_feed',
|
|
29
29
|
'member_approvals',
|
|
30
|
+
'board_room',
|
|
30
31
|
]);
|
|
31
32
|
|
|
32
33
|
function loadWebhooks() {
|
|
@@ -43,7 +44,11 @@ function loadWebhooks() {
|
|
|
43
44
|
return cfg;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
// One poster core; the two entry points differ ONLY in allowed_mentions.
|
|
48
|
+
// #871's rule holds in both: parse:[] always, so nothing typed INTO content can
|
|
49
|
+
// ever ring — postWebhookMentions adds an explicit server-resolved user
|
|
50
|
+
// allowlist and nothing else.
|
|
51
|
+
async function postCore(channel, text, allowedMentions) {
|
|
47
52
|
if (!CHANNELS.has(channel)) {
|
|
48
53
|
throw new Error(`Unknown Discord channel '${channel}'. Known: ${[...CHANNELS].join(', ')}`);
|
|
49
54
|
}
|
|
@@ -56,13 +61,10 @@ async function postWebhook(channel, text) {
|
|
|
56
61
|
}
|
|
57
62
|
// Discord hard-caps message content at 2000 chars.
|
|
58
63
|
const content = String(text).slice(0, 2000);
|
|
59
|
-
// SECURITY (#871): suppress ALL mention pings. The content is DB-sourced and
|
|
60
|
-
// attacker-influenceable; allowed_mentions:{parse:[]} renders text verbatim but
|
|
61
|
-
// fires zero notifications. (Discord defaults to parsing mentions when omitted.)
|
|
62
64
|
const res = await fetch(url, {
|
|
63
65
|
method: 'POST',
|
|
64
66
|
headers: { 'Content-Type': 'application/json' },
|
|
65
|
-
body: JSON.stringify({ content, allowed_mentions:
|
|
67
|
+
body: JSON.stringify({ content, allowed_mentions: allowedMentions }),
|
|
66
68
|
});
|
|
67
69
|
if (!res.ok) {
|
|
68
70
|
const body = await res.text().catch(() => '');
|
|
@@ -71,4 +73,22 @@ async function postWebhook(channel, text) {
|
|
|
71
73
|
return { ok: true, status: res.status }; // 204 No Content on success
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
// SECURITY (#871): suppress ALL mention pings. The content is DB-sourced and
|
|
77
|
+
// attacker-influenceable; allowed_mentions:{parse:[]} renders text verbatim but
|
|
78
|
+
// fires zero notifications. (Discord defaults to parsing mentions when omitted.)
|
|
79
|
+
async function postWebhook(channel, text) {
|
|
80
|
+
return postCore(channel, text, { parse: [] });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// postWebhookMentions — the ONE sanctioned exception to #871's blanket mention
|
|
84
|
+
// suppression (R21 of goal 1000069: "the mention IS the 'vote within X'
|
|
85
|
+
// notice"). Still parse:[] — nothing typed INTO the content can ever ping — but
|
|
86
|
+
// an explicit `users` allowlist lets exactly the named ids ring. The ids must
|
|
87
|
+
// come from the board-membership resolve (server data), NEVER from message
|
|
88
|
+
// text; the cap of 20 is a sanity bound far above any real board.
|
|
89
|
+
async function postWebhookMentions(channel, text, userIds = []) {
|
|
90
|
+
const users = [...new Set(userIds.filter((u) => /^\d+$/.test(String(u))))].slice(0, 20).map(String);
|
|
91
|
+
return postCore(channel, text, { parse: [], users });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { CHANNELS, WEBHOOKS_PATH, loadWebhooks, postWebhook, postWebhookMentions };
|
|
@@ -81,9 +81,31 @@ async function openWindowForFullIdea({ ideaId, completenessScore } = {}) {
|
|
|
81
81
|
windowMinutes: board.window_minutes,
|
|
82
82
|
});
|
|
83
83
|
if (!item) return { opened: false, reason: 'already_open', item: await db.getOpenBoardItem('full_idea', id) };
|
|
84
|
+
await emitWindowOpened(item);
|
|
84
85
|
return { opened: true, item };
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// The window-open courtesy event (R21's feed): emitAsync AFTER the row exists,
|
|
89
|
+
// for non-deciding reactions only — the Discord announcer mentions the members
|
|
90
|
+
// whose vote is wanted and deep-links the hall. Best-effort by construction
|
|
91
|
+
// (emitAsync isolates listener errors), and nothing that decides or pays may
|
|
92
|
+
// subscribe: the row is the durable fact, the event is a nudge.
|
|
93
|
+
async function emitWindowOpened(item) {
|
|
94
|
+
try {
|
|
95
|
+
if (api.emitAsync) {
|
|
96
|
+
await api.emitAsync('board.item.opened', {
|
|
97
|
+
item_id: String(item.id),
|
|
98
|
+
subject_type: item.subject_type,
|
|
99
|
+
subject_id: String(item.subject_id),
|
|
100
|
+
opened_by: item.opened_by == null ? null : String(item.opened_by),
|
|
101
|
+
closes_at: item.closes_at || null,
|
|
102
|
+
window_minutes: item.constitution ? item.constitution.window_minutes ?? null : null,
|
|
103
|
+
constitution: item.constitution || null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} catch (_) { /* a nudge that fails is a quiet sitting, never a failed open */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
// ── the vote (BV1.R08 of goal 1000069, ADR 0175 §§4/6/9) ────────────────────
|
|
88
110
|
//
|
|
89
111
|
// OPEN BALLOT, ONE VOTE, NO EDITING. A vote is member + direction + timestamp,
|
|
@@ -450,6 +472,7 @@ async function proposeAmendment({ proposal, rationaleMd = null, proposedBy } = {
|
|
|
450
472
|
windowMinutes: board.window_minutes,
|
|
451
473
|
});
|
|
452
474
|
// A fresh amendment id can never collide with the one-open-per-subject index.
|
|
475
|
+
if (item) await emitWindowOpened(item);
|
|
453
476
|
return { ok: true, amendment, item };
|
|
454
477
|
}
|
|
455
478
|
|
|
@@ -499,7 +522,17 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
|
|
|
499
522
|
}
|
|
500
523
|
const project = (item) => {
|
|
501
524
|
const subject = item.subject_type === 'full_idea'
|
|
502
|
-
? (() => {
|
|
525
|
+
? (() => {
|
|
526
|
+
const r = ideaById.get(String(item.subject_id));
|
|
527
|
+
return r ? {
|
|
528
|
+
title: r.title,
|
|
529
|
+
author_id: String(r.captured_by),
|
|
530
|
+
// R17: the voter decides with the idea in front of them.
|
|
531
|
+
field_values: r.field_values || null,
|
|
532
|
+
template_key: r.template_key || null,
|
|
533
|
+
completeness_score: r.completeness_score ?? null,
|
|
534
|
+
} : null;
|
|
535
|
+
})()
|
|
503
536
|
: (() => { const r = amendmentById.get(String(item.subject_id)); return r ? { proposed: r.proposed, rationale_md: r.rationale_md, author_id: r.proposed_by == null ? null : String(r.proposed_by) } : null; })();
|
|
504
537
|
return {
|
|
505
538
|
id: String(item.id),
|
package/modules/government/db.js
CHANGED
|
@@ -493,8 +493,15 @@ async function getVotesForItems(itemIds) {
|
|
|
493
493
|
// an amendment its proposed block + rationale.
|
|
494
494
|
async function getIdeaSubjects(ideaIds) {
|
|
495
495
|
if (!ideaIds || !ideaIds.length) return [];
|
|
496
|
+
// The five sections ride along (R17): the full idea must be IN FRONT of the
|
|
497
|
+
// voter while they decide — that context is the entire reason voting happens
|
|
498
|
+
// in the hall rather than on a reaction. LEFT JOIN: an idea whose grade row
|
|
499
|
+
// is somehow gone still shows its title.
|
|
496
500
|
const { rows } = await pool.query(
|
|
497
|
-
`SELECT id, title, captured_by
|
|
501
|
+
`SELECT i.id, i.title, i.captured_by, g.field_values, g.template_key, g.completeness_score
|
|
502
|
+
FROM idea_inbox i
|
|
503
|
+
LEFT JOIN ideas_grades g ON g.idea_id = i.id
|
|
504
|
+
WHERE i.id = ANY($1::bigint[])`,
|
|
498
505
|
[ideaIds],
|
|
499
506
|
);
|
|
500
507
|
return rows;
|
|
@@ -99,6 +99,6 @@
|
|
|
99
99
|
<script src="/builders/palette.js" defer></script>
|
|
100
100
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
101
101
|
<script src="/builders/hall-kit.js"></script>
|
|
102
|
-
<script src="/builders/government.js?v=2026-08-18-
|
|
102
|
+
<script src="/builders/government.js?v=2026-08-18-board-announce"></script>
|
|
103
103
|
</body>
|
|
104
104
|
</html>
|
|
@@ -80,6 +80,17 @@
|
|
|
80
80
|
unknown_permission: 'The permission catalog does not define that key.',
|
|
81
81
|
bad_rank_key: 'Rank keys are kebab-case: lowercase letters, digits, dashes.',
|
|
82
82
|
validation_failed: 'Missing or invalid fields.',
|
|
83
|
+
// the vote refusals (R17 over R08's route)
|
|
84
|
+
not_board_member: 'Only members of this item\u2019s board may vote on it \u2014 membership follows the rules that were in force when the window opened.',
|
|
85
|
+
anti_self_vote: 'You can\u2019t vote on your own idea \u2014 that\u2019s the line that keeps ratification honest.',
|
|
86
|
+
item_closed: 'This sitting has closed \u2014 refreshing.',
|
|
87
|
+
vote_window_expired: 'This sitting\u2019s clock has run out \u2014 the sweep is closing it.',
|
|
88
|
+
objection_needs_reason: 'An objection must say why \u2014 write the reason.',
|
|
89
|
+
objection_needs_section: 'An objection must point at exactly one section of the idea.',
|
|
90
|
+
yes_carries_nothing: 'A yes carries no reason or section \u2014 those belong to objections.',
|
|
91
|
+
bad_direction: 'A vote is yes or no \u2014 nothing else.',
|
|
92
|
+
item_not_found: 'That item no longer exists \u2014 refreshing.',
|
|
93
|
+
bad_item_id: 'That item id isn\u2019t valid.',
|
|
83
94
|
};
|
|
84
95
|
const friendlyError = (code) => ERROR_COPY[code] || null;
|
|
85
96
|
|
|
@@ -610,6 +621,76 @@
|
|
|
610
621
|
return `<div class="gov-section">${rows}</div>`;
|
|
611
622
|
}
|
|
612
623
|
|
|
624
|
+
// ---- the vote form (R17) -----------------------------------------------------
|
|
625
|
+
|
|
626
|
+
// The five section labels, mirrored from the base template declaration
|
|
627
|
+
// (modules/ideas/templates.js — the words the author saw when writing; a test
|
|
628
|
+
// holds the two equal, since browser code cannot import a server module).
|
|
629
|
+
const SECTION_LABELS = {
|
|
630
|
+
purpose: 'Purpose of Idea',
|
|
631
|
+
big_picture: 'What does the Big Picture look like',
|
|
632
|
+
why_had: 'Why did you have the Idea',
|
|
633
|
+
why_matters: 'Why does the Idea Matter',
|
|
634
|
+
fit: 'How does the Idea Fit into the big picture of the project',
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// The idea's five sections, IN FRONT of the voter — the entire reason voting
|
|
638
|
+
// happens in the hall rather than on a reaction. Rendered in template order.
|
|
639
|
+
function ideaSectionsHtml(item) {
|
|
640
|
+
const fv = item.subject && item.subject.field_values;
|
|
641
|
+
if (!fv) return '';
|
|
642
|
+
const rows = Object.keys(SECTION_LABELS).map((key) => `
|
|
643
|
+
<div class="gov-perm">
|
|
644
|
+
<div class="gov-perm__main">
|
|
645
|
+
<code class="gov-perm__key">${escapeHtml(SECTION_LABELS[key])}</code>
|
|
646
|
+
<div class="gov-perm__guards">${escapeHtml(String(fv[key] ?? ''))}</div>
|
|
647
|
+
</div>
|
|
648
|
+
</div>`).join('');
|
|
649
|
+
const score = item.subject.completeness_score;
|
|
650
|
+
return `<details class="gov-section"><summary class="gov-section__head">The idea, in full${score != null ? ` \u00b7 completeness ${escapeHtml(String(score))}/100` : ''}</summary>${rows}</details>`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function voteFormHtml(item, viewerId) {
|
|
654
|
+
if (item.closed_at) return '';
|
|
655
|
+
const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
|
|
656
|
+
if (mine) return '';
|
|
657
|
+
if (item.subject_type === 'full_idea' && item.subject && String(item.subject.author_id) === String(viewerId)) {
|
|
658
|
+
return `<p class="gov-grid-note">This is your idea \u2014 its author does not vote on it. The board decides; a return comes back to you with its reason.</p>`;
|
|
659
|
+
}
|
|
660
|
+
const sections = Object.keys(SECTION_LABELS).map((key) => `
|
|
661
|
+
<label class="gov-perm" style="cursor:pointer"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(SECTION_LABELS[key])}</span></label>`).join('');
|
|
662
|
+
return `<div class="gov-vote" data-item="${escapeHtml(item.id)}">
|
|
663
|
+
<button type="button" class="gov-btn" data-vote="yes">Vote yes</button>
|
|
664
|
+
<details class="gov-vote__no">
|
|
665
|
+
<summary class="gov-btn gov-btn--ghost">Object\u2026</summary>
|
|
666
|
+
<p class="gov-grid-note">An objection returns the idea to its author with your reason and the section it faults \u2014 the feedback they revise against.</p>
|
|
667
|
+
<textarea class="gov-input" data-vote-reason placeholder="Why \u2014 the reason the author will act on" maxlength="5000" rows="3"></textarea>
|
|
668
|
+
<div class="gov-vote__sections">${sections}</div>
|
|
669
|
+
<button type="button" class="gov-btn" data-vote="no">Cast objection</button>
|
|
670
|
+
</details>
|
|
671
|
+
</div>`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function castVoteFromRoom(itemId, direction, container) {
|
|
675
|
+
const body = { direction };
|
|
676
|
+
if (direction === 'no') {
|
|
677
|
+
const reason = (container.querySelector('[data-vote-reason]') || {}).value || '';
|
|
678
|
+
const picked = container.querySelector(`input[name="vote-section-${itemId}"]:checked`);
|
|
679
|
+
body.reason_md = reason;
|
|
680
|
+
if (picked) body.section_key = picked.value;
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
const out = await sendJson('POST', `/government/board/items/${encodeURIComponent(itemId)}/votes`, body);
|
|
684
|
+
toast(out.item_closed
|
|
685
|
+
? `Vote cast \u2014 the sitting closed: ${out.outcome}.`
|
|
686
|
+
: 'Vote cast.');
|
|
687
|
+
} catch (err) {
|
|
688
|
+
if (err && err.kind === '401') return;
|
|
689
|
+
toast(`Could not vote: ${err.message || err}`);
|
|
690
|
+
}
|
|
691
|
+
loadBoard(); // re-render either way — the room shows what stands
|
|
692
|
+
}
|
|
693
|
+
|
|
613
694
|
function itemCard(item, viewerId) {
|
|
614
695
|
const yes = item.votes.filter((v) => v.direction === 'yes').length;
|
|
615
696
|
const no = item.votes.filter((v) => v.direction === 'no').length;
|
|
@@ -623,12 +704,14 @@
|
|
|
623
704
|
const myLine = item.closed_at ? '' : (mine
|
|
624
705
|
? `<p class="gov-grid-note">You voted <strong>${escapeHtml(mine.direction)}</strong>. One vote per member — no changes.</p>`
|
|
625
706
|
: `<p class="gov-grid-note">You have not voted on this item. Casting arrives with the vote form.</p>`);
|
|
626
|
-
return `<section class="gov-section">
|
|
707
|
+
return `<section class="gov-section" id="board-item-${escapeHtml(item.id)}">
|
|
627
708
|
<div class="gov-section__head">${subjectLine(item)} ${state}</div>
|
|
628
709
|
${amendmentDetail}
|
|
629
710
|
<p class="gov-grid-note">tally: ${yes} yes · ${no} no — sitting under <code>${escapeHtml((item.constitution && item.constitution.pass_rule) || '?')}</code>, membership <code>${escapeHtml((item.constitution && item.constitution.membership) || '?')}</code></p>
|
|
630
711
|
${myLine}
|
|
712
|
+
${item.closed_at ? '' : ideaSectionsHtml(item)}
|
|
631
713
|
${ballotHtml(item, viewerId)}
|
|
714
|
+
${voteFormHtml(item, viewerId)}
|
|
632
715
|
</section>`;
|
|
633
716
|
}
|
|
634
717
|
|
|
@@ -649,6 +732,27 @@
|
|
|
649
732
|
(open || window.OTBKit.emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.'))
|
|
650
733
|
+ `<h2 class="gov-h">Closed sittings</h2>`
|
|
651
734
|
+ (closed || `<p class="gov-empty">No sitting has closed yet.</p>`);
|
|
735
|
+
// R21: the Discord announcement deep-links ?item=<id>#board-room — land the
|
|
736
|
+
// reader on THAT sitting. Query, not hash (the hash is the tab, makeTabs
|
|
737
|
+
// owns it); a missing or unknown id is simply the room as usual.
|
|
738
|
+
const wanted = new URLSearchParams(window.location.search).get('item');
|
|
739
|
+
if (wanted) {
|
|
740
|
+
const el = document.getElementById(`board-item-${wanted}`);
|
|
741
|
+
if (el) {
|
|
742
|
+
el.scrollIntoView({ block: 'start' });
|
|
743
|
+
el.style.outline = '2px solid var(--color-text-success, #4a4)';
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// R17: the cast buttons. One delegated listener; the SERVER refuses every
|
|
747
|
+
// illegitimate cast regardless (membership, anti-self, closed, expired) —
|
|
748
|
+
// the form is a courtesy, the route is the wall.
|
|
749
|
+
box.querySelectorAll('.gov-vote [data-vote]').forEach((btn) => {
|
|
750
|
+
btn.addEventListener('click', () => {
|
|
751
|
+
const wrap = btn.closest('.gov-vote');
|
|
752
|
+
btn.disabled = true;
|
|
753
|
+
castVoteFromRoom(wrap.dataset.item, btn.dataset.vote, wrap);
|
|
754
|
+
});
|
|
755
|
+
});
|
|
652
756
|
}
|
|
653
757
|
|
|
654
758
|
// ---- init ----------------------------------------------------------------
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.237",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.237",
|
|
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.19.
|
|
3
|
+
"version": "1.19.237",
|
|
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/src/module-api.js
CHANGED
|
@@ -897,8 +897,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
897
897
|
// landed since 1.19.233 with no explicit bump. run 32161368847. (task 1002620)
|
|
898
898
|
// 1.19.235 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
899
899
|
// landed since 1.19.234 with no explicit bump. run 32161981393. (task 1002620)
|
|
900
|
+
// 1.19.236 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
901
|
+
// landed since 1.19.235 with no explicit bump. run 32162811851. (task 1002620)
|
|
902
|
+
// 1.19.237 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
903
|
+
// landed since 1.19.236 with no explicit bump. run 32164526454. (task 1002620)
|
|
900
904
|
// ---------------------------------------------------------------------------
|
|
901
|
-
const CORE_VERSION = '1.19.
|
|
905
|
+
const CORE_VERSION = '1.19.237'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
902
906
|
|
|
903
907
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
904
908
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// tests/discord_board_broadcast.mjs — announce a window (BV1.R21 of goal
|
|
2
|
+
// 1000069, ADR 0175 §8 / criterion C6): mention the members, deep-link the hall.
|
|
3
|
+
//
|
|
4
|
+
// DB-free, deps-injected (the postShipBroadcast testing shape). The invariants:
|
|
5
|
+
// 1. THE MENTION ALLOWLIST IS EXACTLY THE LINKED MEMBERS — ids come from the
|
|
6
|
+
// government port's resolve under the item's SNAPSHOT, never from text;
|
|
7
|
+
// an unlinked member is NAMED, visible and unpinged.
|
|
8
|
+
// 2. OUTBOUND-ONLY AND BEST-EFFORT — a Discord hiccup, a missing port, or an
|
|
9
|
+
// empty board is a named skip, never a throw (a failed nudge must never
|
|
10
|
+
// fail a window open).
|
|
11
|
+
// 3. THE EMITTER FIRES ON FRESH OPENS ONLY — an idempotent re-open (the
|
|
12
|
+
// already_open path) summons nobody twice.
|
|
13
|
+
// 4. #871 SURVIVES — the poster keeps parse:[] and only rings the explicit
|
|
14
|
+
// user allowlist; content can never ping anyone on its own.
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
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 { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const { postBoardAnnouncement } = require(path.join(ROOT, 'modules', 'discord', 'board-broadcast.js'));
|
|
25
|
+
const webhook = require(path.join(ROOT, 'modules', 'discord', 'webhook.js'));
|
|
26
|
+
|
|
27
|
+
const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|
|
28
|
+
const payload = (over = {}) => ({
|
|
29
|
+
item_id: '11', subject_type: 'full_idea', subject_id: '7', opened_by: null,
|
|
30
|
+
closes_at: null, window_minutes: null, constitution: MONARCHY, ...over,
|
|
31
|
+
});
|
|
32
|
+
const member = (id, login) => ({ builder_id: String(id), github_login: login, display_name: login });
|
|
33
|
+
|
|
34
|
+
test('linked members are @mentioned on the allowlist; unlinked members are named, unpinged', async () => {
|
|
35
|
+
let posted = null;
|
|
36
|
+
const out = await postBoardAnnouncement(payload({ window_minutes: 90 }), {
|
|
37
|
+
resolveMembers: async (c) => { assert.deepEqual(c, MONARCHY, 'summoned under the item SNAPSHOT'); return [member(1, 'linked-one'), member(2, 'unlinked-two')]; },
|
|
38
|
+
getLinks: async (ids) => {
|
|
39
|
+
assert.deepEqual(ids, ['1', '2'], 'ONE bulk query for the whole board — never a per-member await');
|
|
40
|
+
return new Map([['1', { discord_user_id: '111222333' }]]);
|
|
41
|
+
},
|
|
42
|
+
post: async (channel, text, userIds) => { posted = { channel, text, userIds }; },
|
|
43
|
+
log: () => {},
|
|
44
|
+
});
|
|
45
|
+
assert.equal(out.posted, true);
|
|
46
|
+
assert.deepEqual(out.mentioned, ['111222333']);
|
|
47
|
+
assert.deepEqual(out.named, ['unlinked-two']);
|
|
48
|
+
assert.equal(posted.channel, 'board_room');
|
|
49
|
+
assert.deepEqual(posted.userIds, ['111222333'], 'the allowlist is EXACTLY the linked members — never parsed from text');
|
|
50
|
+
assert.ok(posted.text.includes('<@111222333>'));
|
|
51
|
+
assert.ok(posted.text.includes('unlinked-two'), 'the unlinked member is still named');
|
|
52
|
+
assert.ok(posted.text.includes('within 90 minutes'), 'the mention IS the vote-within-X notice');
|
|
53
|
+
assert.ok(posted.text.includes('/government?item=11#board-room'), 'deep-links THAT item — the panel scrolls to it');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a clockless sitting says so, and an amendment names its subject honestly', async () => {
|
|
57
|
+
let text = null;
|
|
58
|
+
await postBoardAnnouncement(payload({ subject_type: 'constitutional_amendment', subject_id: '5' }), {
|
|
59
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
60
|
+
getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
|
|
61
|
+
post: async (_c, t) => { text = t; },
|
|
62
|
+
log: () => {},
|
|
63
|
+
});
|
|
64
|
+
assert.ok(text.includes('constitutional amendment'));
|
|
65
|
+
assert.ok(text.includes('no clock'), 'a monarchy sitting has no deadline to claim');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('missing port, empty board, and a Discord failure are named skips — NEVER throws', async () => {
|
|
69
|
+
assert.deepEqual(await postBoardAnnouncement(payload(), { government: null, log: () => {} }),
|
|
70
|
+
{ posted: false, skipped: 'no_government_port', mentioned: [], named: [] });
|
|
71
|
+
assert.equal((await postBoardAnnouncement(payload(), { resolveMembers: async () => [], log: () => {} })).skipped, 'empty_board');
|
|
72
|
+
const out = await postBoardAnnouncement(payload(), {
|
|
73
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
74
|
+
getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
|
|
75
|
+
post: async () => { throw new Error('discord down'); },
|
|
76
|
+
log: () => {},
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual({ posted: out.posted, skipped: out.skipped }, { posted: false, skipped: 'post_failed' });
|
|
79
|
+
assert.equal((await postBoardAnnouncement(null, { log: () => {} })).skipped, 'no_item');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the emitter fires on FRESH opens only, and the listener is registered (source pins)', () => {
|
|
83
|
+
const board = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
|
|
84
|
+
const open = board.slice(board.indexOf('async function openWindowForFullIdea'), board.indexOf('async function emitWindowOpened'));
|
|
85
|
+
assert.ok(open.indexOf('already_open') < open.indexOf('emitWindowOpened(item)'),
|
|
86
|
+
'the idempotent re-open path returns BEFORE the emit — nobody is summoned twice');
|
|
87
|
+
assert.match(board, /emitAsync\('board\.item\.opened'/, 'the event exists');
|
|
88
|
+
const routes = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'routes', 'discord.js'), 'utf8');
|
|
89
|
+
assert.match(routes, /api\.on\('board\.item\.opened', \(payload\) => boardBroadcast\.postBoardAnnouncement\(payload\)\)/,
|
|
90
|
+
'the discord module subscribes — government emits, discord posts, nothing decides');
|
|
91
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'module.json'), 'utf8'));
|
|
92
|
+
assert.ok(manifest.consumes.includes('government'), 'the port consumption is declared');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('#871 survives: the mention poster keeps parse:[] and rings only the explicit allowlist', () => {
|
|
96
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'webhook.js'), 'utf8');
|
|
97
|
+
const block = src.slice(src.indexOf('async function postWebhookMentions'));
|
|
98
|
+
assert.match(block, /postCore\(channel, text, \{ parse: \[\], users \}\)/,
|
|
99
|
+
'content can never ping on its own — only the server-resolved ids ring');
|
|
100
|
+
const plain = src.slice(src.indexOf('async function postWebhook('), src.indexOf('async function postWebhookMentions'));
|
|
101
|
+
assert.match(plain, /postCore\(channel, text, \{ parse: \[\] \}\)/,
|
|
102
|
+
'the default poster stays fully suppressed — #871 verbatim');
|
|
103
|
+
assert.match(block, /slice\(0, 20\)/, 'the allowlist is sanity-bounded');
|
|
104
|
+
assert.match(block, /\^\\d\+\$/, 'only numeric snowflake ids survive the filter');
|
|
105
|
+
assert.ok(webhook.CHANNELS.has('board_room'), 'the channel key exists');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an unlinked member\'s crafted display name is stripped of markdown and links', async () => {
|
|
109
|
+
let text = null;
|
|
110
|
+
await postBoardAnnouncement(payload(), {
|
|
111
|
+
resolveMembers: async () => [{ builder_id: '3', github_login: 'x', display_name: '[click me](https://evil.example) **@everyone**' }],
|
|
112
|
+
getLinks: async () => new Map(),
|
|
113
|
+
post: async (_c, t) => { text = t; },
|
|
114
|
+
log: () => {},
|
|
115
|
+
});
|
|
116
|
+
assert.ok(!text.includes('['), 'no markdown link syntax survives');
|
|
117
|
+
assert.ok(!/\w\.\w/.test(text.replace(/Vote in the hall:.*$/,'')), 'no linkable residue survives (Discord auto-links bare domains)');
|
|
118
|
+
assert.ok(!text.includes('**'), 'no formatting survives');
|
|
119
|
+
assert.ok(!text.includes('@everyone'), 'no lookalike mention survives (the ping was already dead; now the text is too)');
|
|
120
|
+
assert.ok(text.includes('click me'), 'the words themselves remain — visible, honest, inert');
|
|
121
|
+
});
|
|
@@ -56,7 +56,7 @@ test('the assembled view: enrichment, the open ballot, the snapshot, the databas
|
|
|
56
56
|
},
|
|
57
57
|
getIdeaSubjects: async (ids) => {
|
|
58
58
|
assert.deepEqual(ids.map(String).sort(), ['6', '7']);
|
|
59
|
-
return [{ id: 7n, title: 'A big idea', captured_by: 9n }]; // idea 6 is GONE — deleted subject
|
|
59
|
+
return [{ id: 7n, title: 'A big idea', captured_by: 9n, field_values: { purpose: 'p' }, template_key: 'base-v1', completeness_score: 82 }]; // idea 6 is GONE — deleted subject
|
|
60
60
|
},
|
|
61
61
|
getAmendmentSubjects: async (ids) => {
|
|
62
62
|
assert.deepEqual(ids.map(String), ['5']);
|
|
@@ -68,7 +68,9 @@ test('the assembled view: enrichment, the open ballot, the snapshot, the databas
|
|
|
68
68
|
assert.equal(view.closed.length, 1);
|
|
69
69
|
|
|
70
70
|
const idea = view.open[0];
|
|
71
|
-
assert.deepEqual(idea.subject,
|
|
71
|
+
assert.deepEqual(idea.subject,
|
|
72
|
+
{ title: 'A big idea', author_id: '9', field_values: { purpose: 'p' }, template_key: 'base-v1', completeness_score: 82 },
|
|
73
|
+
'an idea shows its title, author, AND its five sections — the voter decides with the idea in front of them (R17)');
|
|
72
74
|
assert.equal(idea.remaining_seconds, 540, 'the DATABASE clock, passed through — the client never does clock math');
|
|
73
75
|
assert.deepEqual(idea.constitution, MONARCHY, 'the SNAPSHOT rides along — the room describes the rule the item runs under');
|
|
74
76
|
assert.equal(idea.votes.length, 1);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// tests/government_board_vote_ui.mjs — the vote UI (BV1.R17 of goal 1000069,
|
|
2
|
+
// criteria C2/C5): cast, reason box, five-section picker — with the full idea
|
|
3
|
+
// in front of the voter, which is the entire reason voting happens in the hall
|
|
4
|
+
// rather than on a Discord reaction.
|
|
5
|
+
//
|
|
6
|
+
// Browser code cannot be imported into node tests wholesale (it is an IIFE over
|
|
7
|
+
// the DOM), so these are SOURCE PINS over the exact seams that must not drift —
|
|
8
|
+
// the same technique the palette and hall audits use — plus one real cross-
|
|
9
|
+
// module equality: the section labels against the template declaration.
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
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
|
+
const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
20
|
+
const { BASE_TEMPLATE } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
|
|
21
|
+
const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
|
|
22
|
+
|
|
23
|
+
test('the five section labels ARE the template declaration — key and label, in order', () => {
|
|
24
|
+
const block = CLIENT.slice(CLIENT.indexOf('const SECTION_LABELS'), CLIENT.indexOf('function ideaSectionsHtml'));
|
|
25
|
+
const declared = [...block.matchAll(/(\w+): '([^']+)'/g)].map((m) => [m[1], m[2]]);
|
|
26
|
+
const template = BASE_TEMPLATE.fields.map((f) => [f.key, f.label]);
|
|
27
|
+
assert.deepEqual(declared, template,
|
|
28
|
+
'the voter reads the same five section names the author wrote under — held equal here because browser code cannot import the server module');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('the idea is in front of the voter: sections render from the subject, in template order', () => {
|
|
32
|
+
assert.match(CLIENT, /item\.subject\.field_values|subject && item\.subject\.field_values/,
|
|
33
|
+
'the sections come from the board view payload, never a second fetch');
|
|
34
|
+
assert.match(CLIENT, /Object\.keys\(SECTION_LABELS\)\.map/,
|
|
35
|
+
'rendered in the declared order — jsonb key order is never trusted');
|
|
36
|
+
const card = CLIENT.slice(CLIENT.indexOf('function itemCard'), CLIENT.indexOf('async function loadBoard'));
|
|
37
|
+
assert.match(card, /ideaSectionsHtml\(item\)/, 'the sections sit ON the item card, above the ballot');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('an objection needs the reason AND exactly one section; a yes sends neither', () => {
|
|
41
|
+
const cast = CLIENT.slice(CLIENT.indexOf('async function castVoteFromRoom'), CLIENT.indexOf('function itemCard'));
|
|
42
|
+
assert.match(cast, /if \(direction === 'no'\)/, 'reason + section are attached only to an objection');
|
|
43
|
+
assert.match(cast, /body\.reason_md = reason/, 'the reason box feeds reason_md');
|
|
44
|
+
assert.match(cast, /body\.section_key = picked\.value/, 'the picker feeds exactly one section_key');
|
|
45
|
+
assert.match(cast, /\/government\/board\/items\/.*\/votes/, 'casts into R08\'s route — the server is the wall');
|
|
46
|
+
assert.match(cast, /loadBoard\(\)/, 'the room re-renders either way — it shows what stands');
|
|
47
|
+
assert.match(cast, /item_closed/, 'a cast that CLOSED the sitting says so (first_ratifier / full turnout)');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('the author of a full_idea sees the anti-self note instead of a form — amendments do not', () => {
|
|
51
|
+
const form = CLIENT.slice(CLIENT.indexOf('function voteFormHtml'), CLIENT.indexOf('async function castVoteFromRoom'));
|
|
52
|
+
assert.match(form, /subject_type === 'full_idea'[^]*author_id.*viewerId/,
|
|
53
|
+
'the grey-out is SUBJECT-TYPED, mirroring castVote\'s carve-out — an amendment proposer may vote (§7)');
|
|
54
|
+
assert.match(form, /does not vote on it/, 'the note says why, not just no');
|
|
55
|
+
assert.match(form, /if \(mine\) return ''/, 'one vote per member — a cast voter gets no second form');
|
|
56
|
+
assert.match(form, /if \(item\.closed_at\) return ''/, 'closed sittings take no votes');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('every refusal castVote can return has friendly copy', () => {
|
|
60
|
+
// The route maps board.js's typed refusals to res.fail codes; the client's
|
|
61
|
+
// ERROR_COPY must name each one so a refused vote explains itself.
|
|
62
|
+
const source = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
|
|
63
|
+
const castBlock = source.slice(source.indexOf('function validateVote'), source.indexOf('// ── close + tally'));
|
|
64
|
+
const reasons = [...new Set([...castBlock.matchAll(/reason: '([a-z_]+)'/g)].map((m) => m[1]))];
|
|
65
|
+
assert.ok(reasons.length >= 6, `sanity: found castVote's refusal set (${reasons})`);
|
|
66
|
+
const copyBlock = CLIENT.slice(CLIENT.indexOf('const ERROR_COPY'), CLIENT.indexOf('const friendlyError'));
|
|
67
|
+
for (const r of reasons) {
|
|
68
|
+
assert.ok(copyBlock.includes(`${r}:`), `ERROR_COPY must explain '${r}'`);
|
|
69
|
+
}
|
|
70
|
+
});
|