@bongos/core 1.21.74 → 1.21.76

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 (48) hide show
  1. package/.bongos-core.json +78 -48
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +5 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +12 -0
  8. package/docs/api/openapi.json +81 -3
  9. package/docs/api-reference.md +5 -3
  10. package/docs/copy-inventory.md +45 -42
  11. package/docs/copy-registry.json +76 -49
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/modules-contract.md +1 -0
  14. package/docs/page-inventory.json +16 -8
  15. package/docs/page-readings.json +1542 -1522
  16. package/modules/hall-ui/public/craft-mode.js +3 -1
  17. package/modules/hall-ui/public/page-notes.css +150 -0
  18. package/modules/hall-ui/public/page-notes.js +30 -5
  19. package/modules/hall-ui/public/studio.css +15 -0
  20. package/modules/hall-ui/public/studio.html +6 -0
  21. package/modules/hall-ui/public/studio.js +38 -4
  22. package/modules/hall-ui/public/style.css +1 -116
  23. package/modules/page-notes/db.js +19 -0
  24. package/modules/page-notes/migrations/page_notes_002_surface_index.sql +12 -0
  25. package/modules/page-notes/notes.js +25 -2
  26. package/modules/page-notes/routes/page-notes.js +9 -0
  27. package/modules/public-landing/public/account.html +2 -0
  28. package/modules/public-landing/public/assets/artist-review.js +107 -0
  29. package/modules/public-landing/public/contact.html +2 -0
  30. package/modules/public-landing/public/guild.html +2 -0
  31. package/modules/public-landing/public/index.html +2 -0
  32. package/modules/public-landing/public/invitations.html +2 -0
  33. package/modules/public-landing/public/privacy.html +2 -0
  34. package/modules/public-landing/public/projects.html +2 -0
  35. package/modules/public-landing/public/terms.html +2 -0
  36. package/modules/ui-design/kit/serve.js +32 -0
  37. package/package-lock.json +2 -2
  38. package/package.json +1 -1
  39. package/release-notes.json +12 -0
  40. package/src/bongos/module-listing.js +85 -0
  41. package/src/bongos/routes/modules.js +15 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/artist_review.mjs +231 -0
  44. package/tests/hall_studio_home.mjs +39 -1
  45. package/tests/landing_legal_pages.mjs +2 -1
  46. package/tests/module_store_listing.mjs +156 -0
  47. package/tests/page_notes_db.mjs +28 -1
  48. package/tests/page_notes_panel.mjs +9 -3
@@ -0,0 +1,107 @@
1
+ /* assets/artist-review.js — THE ARTIST REVIEW BUTTON on the public site (task
2
+ 1004499, goal 1000095 WA6; owner decisions 2026-10-04).
3
+
4
+ A signed-in builder looking at a live public page (the landing, /projects,
5
+ /account and the footer pages) writes UI feedback about it without leaving it.
6
+ The button sits in the hub bar beside the identity chip and opens the hall's
7
+ own feedback panel (modules/hall-ui/public/page-notes.js, the Artistry
8
+ palette), so a review written here is the same doc as one written in the hall:
9
+ split by an AI into ideas filed under the reviewer, and kept as that page's
10
+ design reference. Its page id is site:<page>; the Studio lists them.
11
+
12
+ Signed out there is no button: GET /me answers 401 and nothing is drawn. Any
13
+ signed-in builder sees it, in any craft mode — the button is cosmetic, the
14
+ panel's routes hold the walls (ADR 0016).
15
+
16
+ The page names the hall's origin on this tag (data-hall="{{buildersOrigin}}",
17
+ stamped server-side), so the ideas the panel links to open in the hall, which
18
+ on the hub is another host. A ?note=N link opens that doc in place. */
19
+ (function () {
20
+ 'use strict';
21
+ var PANEL = '/modules/hall-ui/public/';
22
+ // The hall palette's own icon (craft-mode.js ICONS.artistry), built as nodes:
23
+ // this surface writes no innerHTML.
24
+ var SVG_NS = 'http://www.w3.org/2000/svg';
25
+ var ICON_PATHS = [
26
+ ['M8 2a6 6 0 1 0 0 12c.9 0 1.4-.6 1.4-1.3 0-.8-.6-1-.6-1.7 0-.6.5-1 1.2-1h1.6A2.4 2.4 0 0 0 14 7.6C14 4.5 11.3 2 8 2Z', null],
27
+ ['M5 6.2h.01M7.6 4.6h.01M10.4 5.2h.01M4.6 9h.01', '2.2'],
28
+ ];
29
+ function icon() {
30
+ var svg = document.createElementNS(SVG_NS, 'svg');
31
+ [['viewBox', '0 0 16 16'], ['fill', 'none'], ['stroke', 'currentColor'], ['stroke-width', '1.4'], ['stroke-linecap', 'round'], ['stroke-linejoin', 'round'], ['aria-hidden', 'true']]
32
+ .forEach(function (a) { svg.setAttribute(a[0], a[1]); });
33
+ ICON_PATHS.forEach(function (p) {
34
+ var path = document.createElementNS(SVG_NS, 'path');
35
+ path.setAttribute('d', p[0]);
36
+ if (p[1]) path.setAttribute('stroke-width', p[1]);
37
+ svg.appendChild(path);
38
+ });
39
+ return svg;
40
+ }
41
+ var tag = document.currentScript;
42
+ var hall = (tag && tag.getAttribute('data-hall')) || '';
43
+
44
+ function linkSheet() {
45
+ if (document.getElementById('arv-sheet')) return;
46
+ var l = document.createElement('link');
47
+ l.id = 'arv-sheet';
48
+ l.rel = 'stylesheet';
49
+ l.href = PANEL + 'page-notes.css';
50
+ document.head.appendChild(l);
51
+ }
52
+ var loading = null;
53
+ // The panel learns it is on the public site, and where the hall is, from here.
54
+ function ready(api) { api.configure({ surface: 'site', hall: hall }); return api; }
55
+ function loadPanel() {
56
+ if (window.OTBPageNotes) return Promise.resolve(ready(window.OTBPageNotes));
57
+ if (loading) return loading;
58
+ loading = new Promise(function (resolve, reject) {
59
+ var s = document.createElement('script');
60
+ s.src = PANEL + 'page-notes.js';
61
+ s.onload = function () { if (window.OTBPageNotes) resolve(ready(window.OTBPageNotes)); else reject(new Error('panel did not load')); };
62
+ s.onerror = function () { loading = null; reject(new Error('panel did not load')); };
63
+ document.head.appendChild(s);
64
+ });
65
+ return loading;
66
+ }
67
+ // The hall's palette (craft-mode.js TOOLS.artistry) wears the same two names;
68
+ // the two pages share no script, so tests/artist_review.mjs holds them equal.
69
+ var LABEL = 'Artist review: write feedback on this page';
70
+ var SHORT = 'Review';
71
+ function drawButton() {
72
+ var bar = document.querySelector('.barRight');
73
+ if (!bar || document.getElementById('arv-btn')) return null;
74
+ var b = document.createElement('button');
75
+ b.type = 'button';
76
+ b.id = 'arv-btn';
77
+ b.className = 'arv-btn';
78
+ b.setAttribute('aria-label', LABEL);
79
+ b.title = LABEL;
80
+ var label = document.createElement('span');
81
+ label.className = 'arv-btn__label';
82
+ label.textContent = SHORT;
83
+ b.appendChild(icon());
84
+ b.appendChild(label);
85
+ b.addEventListener('click', function () {
86
+ loadPanel().then(function (api) { api.open(); }, function () { b.setAttribute('aria-disabled', 'true'); });
87
+ });
88
+ var who = document.getElementById('who');
89
+ if (who && who.parentNode === bar) bar.insertBefore(b, who);
90
+ else bar.appendChild(b);
91
+ return b;
92
+ }
93
+
94
+ function signedIn(me) {
95
+ if (!me) return;
96
+ linkSheet();
97
+ drawButton();
98
+ var note = /[?&]note=([1-9]\d{0,17})(?:&|$)/.exec(window.location.search || '');
99
+ if (note) loadPanel().then(function (api) { api.openNote(note[1]); }, function () { /* the page still works */ });
100
+ }
101
+
102
+ fetch('/api/bongos/me', { method: 'GET', credentials: 'same-origin', headers: { Accept: 'application/json' } })
103
+ .then(function (r) { return r.ok ? r.json().catch(function () { return null; }) : null; })
104
+ .then(function (d) { signedIn(d ? (d.builder || d.me || d) : null); }, function () { /* signed out or offline: no button */ });
105
+
106
+ window.OTBArtistReview = { signedIn: signedIn };
107
+ })();
@@ -120,5 +120,7 @@
120
120
 
121
121
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact" aria-current="page">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
122
122
  <script src="/assets/hub-bar.js"></script>
123
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
124
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
123
125
  </body>
124
126
  </html>
@@ -629,5 +629,7 @@
629
629
  load();
630
630
  })();
631
631
  </script>
632
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
633
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
632
634
  </body>
633
635
  </html>
@@ -1261,5 +1261,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
1261
1261
  });
1262
1262
  })();
1263
1263
  </script>
1264
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
1265
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
1264
1266
  </body>
1265
1267
  </html>
@@ -961,5 +961,7 @@
961
961
  });
962
962
  })();
963
963
  </script>
964
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
965
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
964
966
  </body>
965
967
  </html>
@@ -129,5 +129,7 @@
129
129
 
130
130
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy" aria-current="page">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
131
131
  <script src="/assets/hub-bar.js"></script>
132
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
133
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
132
134
  </body>
133
135
  </html>
@@ -11174,5 +11174,7 @@ summary{min-height:24px;padding:3px 0;}
11174
11174
  .then(loadSelfShipped, loadSelfShipped);
11175
11175
  })();
11176
11176
  </script>
11177
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
11178
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
11177
11179
  </body>
11178
11180
  </html>
@@ -134,5 +134,7 @@
134
134
 
135
135
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms" aria-current="page">Terms</a><a href="/contact">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
136
136
  <script src="/assets/hub-bar.js"></script>
137
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
138
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
137
139
  </body>
138
140
  </html>
@@ -439,6 +439,33 @@ function handlePatch(res, body) {
439
439
  return json(res, 200, { handle: h, previous_handle: previous || null });
440
440
  }
441
441
 
442
+ // The page feedback panel's routes (task 1004499: the public site's Artist review
443
+ // button opens modules/hall-ui/public/page-notes.js), so a render can picture the
444
+ // panel open. One draft per page for the run; nothing is ever split or filed.
445
+ // SIMPLIFIED: drafts are keyed by page alone, where the real route keys them by
446
+ // (author, page) — the stub has one fixture builder, so the two agree here.
447
+ const pnDrafts = new Map();
448
+ function pageNotesRoute(req, res, rest, u) {
449
+ if (!rest.startsWith('/page-notes')) return false;
450
+ if (!AUTH) { json(res, 401, UNAUTH); return true; }
451
+ if (rest === '/page-notes/site') { json(res, 200, { notes: [] }); return true; }
452
+ if (rest === '/page-notes' && req.method === 'GET') { json(res, 200, { page_id: u.searchParams.get('page_id') || '', notes: [] }); return true; }
453
+ if (rest === '/page-notes' && req.method === 'POST') {
454
+ readBody(req, (body) => {
455
+ const key = String(body.page_id || '');
456
+ if (!pnDrafts.has(key)) pnDrafts.set(key, { id: String(pnDrafts.size + 1), page_id: key, page_path: body.page_path || '/', page_title: body.page_title || '', body_md: '', status: 'draft', idea_ids: [], author_name: 'builder' });
457
+ json(res, 200, { note: pnDrafts.get(key), images: [] });
458
+ });
459
+ return true;
460
+ }
461
+ const one = rest.match(/^\/page-notes\/(\d+)$/);
462
+ if (one && req.method === 'PATCH') {
463
+ readBody(req, (body) => { for (const n of pnDrafts.values()) if (n.id === one[1]) n.body_md = String(body.body_md || ''); json(res, 200, { ok: true }); });
464
+ return true;
465
+ }
466
+ return false;
467
+ }
468
+
442
469
  // The hub's guild page (task 1002301): GET /guilds/:slug (public), GET /guilds/:slug/members
443
470
  // and PATCH /guilds/:slug/membership (own-scope), answered from ONE set of guilds held for the
444
471
  // run (guilds.json, or the file a states file's stub.fixtures names), keyed by slug, each with
@@ -661,6 +688,7 @@ const server = http.createServer((req, res) => {
661
688
  starter-bundles.js declaration, so the picture the kit renders and the
662
689
  payload the route serves cannot drift apart silently. */
663
690
  if (rest === '/provisioning/starter-bundles') return json(res, 200, STARTER_BUNDLES_FIXTURE);
691
+ if (pageNotesRoute(req, res, rest, u)) return;
664
692
  if (rest === '/auth/logout') return json(res, 200, { ok: true });
665
693
  if (rest.startsWith('/auth/web/start')) { res.writeHead(302, { location: PREFIX }); return res.end(); }
666
694
  if (rest === '/instance') { const b = pages.size ? pages.values().next().value.brand : {}; return json(res, 200, { brand: { productName: (b && b.productName) || '', project: {} }, modules: {} }); }
@@ -672,6 +700,10 @@ const server = http.createServer((req, res) => {
672
700
  res.writeHead(200, { 'content-type': MIME['.js'], 'cache-control': 'no-store' });
673
701
  return res.end(bundle ? fs.readFileSync(bundle, 'utf8') : '/* stub: no client bundle in this checkout */');
674
702
  }
703
+ // an enabled module's browser assets, as serve-internal.js serves them on every host
704
+ // (task 1004499: the public site loads the hall's feedback panel from here)
705
+ const modAsset = p.match(/^\/modules\/([a-z][a-z0-9-]*)\/public\/(.+)$/);
706
+ if (modAsset) { const abs = fileUnder(path.join(ROOT, 'modules', modAsset[1], 'public'), modAsset[2]); if (abs) return sendFile(abs, res); }
675
707
  // docs/ raw (a mock's relative links resolve) and the surface dir by its checkout path
676
708
  if (p.startsWith('/docs/')) { const abs = fileUnder(DOCS, p.slice('/docs/'.length)); if (abs) return sendFile(abs, res); }
677
709
  const relSurface = path.relative(ROOT, DIR).split(path.sep).join('/');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.74",
3
+ "version": "1.21.76",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.74",
9
+ "version": "1.21.76",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.74",
3
+ "version": "1.21.76",
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",
@@ -9254,5 +9254,17 @@
9254
9254
  "id": "1004628",
9255
9255
  "text": "Projects now have a Ship mode setting. Auto, the default, means the AI ships finished work on its own. Manual means it asks first. Owners can switch it on the project settings page."
9256
9256
  }
9257
+ ],
9258
+ "1.21.75": [
9259
+ {
9260
+ "id": "1003807",
9261
+ "text": "The module store now only shows modules that are safe and good enough: each must pass the security check, and once it has real-world data it needs a score of at least 50 out of 100. Brand-new modules show with a 'New' label un"
9262
+ }
9263
+ ],
9264
+ "1.21.76": [
9265
+ {
9266
+ "id": "1004499",
9267
+ "text": "Artists can now review the public website while looking at it. A signed-in builder on cloudbongos.com's public pages (home, projects, account, guilds, invitations, privacy, terms, contact) sees a Review button in the top bar,"
9268
+ }
9257
9269
  ]
9258
9270
  }
@@ -0,0 +1,85 @@
1
+ // src/bongos/module-listing.js — which store modules the catalog lists, under the
2
+ // quality floor (task 1003807, criterion wa5-curated-listing, ADR 0343 D2/D5).
3
+ //
4
+ // One module appears once, as its NEWEST published version (semver order), judged by
5
+ // that version's CURRENT score: the newest module_assessment_scores row for it
6
+ // (computed_at DESC, id DESC), whether computed or a Metic+ override (core_265).
7
+ // placeVersion is the whole rule and is pure, so tests drive it directly:
8
+ //
9
+ // excluded the Security gate has not passed (false, or not run yet), or the
10
+ // version has no score row at all. The gate is a hard exclusion (D2).
11
+ // new passed the gate but has no real-world data yet (is_new, D5). Listed
12
+ // with a "New" label and never ranked: the floor does not apply until
13
+ // it has a real score (owner decision, 2026-10-01).
14
+ // listed passed the gate and its overall score is at least the floor.
15
+ // excluded otherwise: scored below the floor, or no overall to compare.
16
+ //
17
+ // A delisted module (ADR 0338 D2) is never in the catalog, whatever its score.
18
+
19
+ const { parseVersion, cmp } = require('../module-loader/semver');
20
+
21
+ // The quality floor, 0-100. It lives here and nowhere else: it is part of Bongos
22
+ // itself, not a live setting, so no author, builder or instance can move it.
23
+ // Changing it is an owner-approved, reviewed task (owner decision, 2026-10-01).
24
+ // Authors read the rule in docs/modules-contract.md ("Getting listed").
25
+ const QUALITY_FLOOR = 50;
26
+
27
+ // Where one version stands. `score` is its current score row, or null.
28
+ // Returns { placement: 'listed' | 'new' | 'excluded', reason }.
29
+ function placeVersion(score, floor = QUALITY_FLOOR) {
30
+ if (!score) return { placement: 'excluded', reason: 'not_assessed' };
31
+ if (score.security_passed !== true) return { placement: 'excluded', reason: 'security_not_passed' };
32
+ if (score.is_new) return { placement: 'new', reason: 'new' };
33
+ if (score.overall == null) return { placement: 'excluded', reason: 'no_score' };
34
+ if (score.overall < floor) return { placement: 'excluded', reason: 'below_floor' };
35
+ return { placement: 'listed', reason: 'at_or_above_floor' };
36
+ }
37
+
38
+ // The newest version row per module key, in semver order (a version that does not
39
+ // parse is skipped, as latestVersions does in module-store.js).
40
+ function newestPerModule(rows) {
41
+ const out = new Map();
42
+ for (const r of rows) {
43
+ const next = parseVersion(r.version);
44
+ if (!next) continue;
45
+ const have = out.get(r.module_key);
46
+ if (have && cmp(parseVersion(have.version), next) >= 0) continue;
47
+ out.set(r.module_key, r);
48
+ }
49
+ return [...out.values()];
50
+ }
51
+
52
+ // The catalog: { floor, listed: [...], new: [...] }, each entry
53
+ // { key, title, version, published_at, overall, is_new }. Excluded versions are left
54
+ // out entirely: a module below the bar is not listed at all. Both lists are in key
55
+ // order; ranking is a separate task (1003808) and does not happen here.
56
+ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
57
+ const pool = db || require('./pool').pool;
58
+ const { rows: versions } = await pool.query(
59
+ `SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
60
+ FROM store_modules m JOIN store_module_versions v ON v.module_key = m.module_key
61
+ WHERE m.status = 'listed'`);
62
+ const newest = newestPerModule(versions);
63
+ const scores = new Map();
64
+ if (newest.length) {
65
+ const { rows } = await pool.query(
66
+ `SELECT DISTINCT ON (version_id) version_id, overall, security_passed, is_new
67
+ FROM module_assessment_scores
68
+ WHERE version_id = ANY($1)
69
+ ORDER BY version_id, computed_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
70
+ for (const s of rows) scores.set(String(s.version_id), s);
71
+ }
72
+ const out = { floor, listed: [], new: [] };
73
+ for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
74
+ const score = scores.get(String(r.version_id)) || null;
75
+ const { placement } = placeVersion(score, floor);
76
+ if (placement === 'excluded') continue;
77
+ out[placement].push({
78
+ key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
79
+ overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ module.exports = { QUALITY_FLOOR, placeVersion, listCatalog };
@@ -97,6 +97,7 @@ const log = require('../logger').logger.child({ src: 'modules' });
97
97
  // property access on a CommonJS namespace, so reaching these through one made them read
98
98
  // as unused exports and tripped the dead-code ratchet that held this branch out of main.
99
99
  const { listOverrides, setOverride } = require('../module-overrides');
100
+ const { listCatalog } = require('../module-listing');
100
101
 
101
102
  // A packaged submission embeds every file's content (base64) — cap the total
102
103
  // so a runaway/misbuilt module can't bloat the review-queue table. Generous
@@ -692,6 +693,20 @@ module.exports = function buildModulesRouter() {
692
693
  res.json({ latest });
693
694
  }));
694
695
 
696
+ // The store catalog under the quality floor (task 1003807, ADR 0343 D2/D5): each
697
+ // listed module's newest version, if it passed the Security gate and scores at or
698
+ // above the floor, or under "new" if it has no real-world data yet. Below the bar
699
+ // is not listed at all. The floor is fixed in module-listing.js and returned here.
700
+ //
701
+ // rank: any-builder — a read of public store metadata, like /store/modules/latest:
702
+ // no entitlement taken, nothing changed.
703
+ router.get('/store/modules', auth.requireBuilder,
704
+ asyncHandler('GET /store/modules', async (_req, res) => {
705
+ const listing = await listCatalog();
706
+ res.set('Cache-Control', 'no-store');
707
+ res.json(listing);
708
+ }));
709
+
695
710
  router.post('/store/modules/:key/acquired', auth.requireBuilder,
696
711
  asyncHandler('POST /store/modules/:key/acquired', async (req, res) => {
697
712
  const { key } = req.params;
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.74'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.76'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,231 @@
1
+ // tests/artist_review.mjs — the Artist review button on the public site (task
2
+ // 1004499, goal 1000095 WA6). Owner decisions 2026-10-04: the public pages plus
3
+ // the Studio; any signed-in builder sees it; it sits in the hub bar; the
4
+ // reviews are listed in the Studio.
5
+ //
6
+ // Four altitudes: the pure rules (a site doc is read in the Studio), the SQL,
7
+ // the route over a stubbed db through real HTTP, and the browser half — the
8
+ // REAL artist-review.js drawing the button (or nothing) and loading the REAL
9
+ // page-notes.js, whose page id and idea links follow the public surface.
10
+ import { test } from 'node:test';
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import http from 'node:http';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import vm from 'node:vm';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const N = require('../modules/page-notes/notes.js');
22
+ const LOADER = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public/assets/artist-review.js'), 'utf8');
23
+ const NOTES = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/page-notes.js'), 'utf8');
24
+ const realListForSurface = require('../modules/page-notes/db.js').listForSurface;
25
+ const PAGES = ['index', 'projects', 'account', 'privacy', 'terms', 'contact', 'guild', 'invitations'];
26
+
27
+ // ---------- pure ----------
28
+
29
+ test('a site doc is read in the Studio; a hall doc still on its own page', () => {
30
+ assert.equal(N.noteLink({ id: '5', page_id: 'site:projects', page_path: '/projects' }), '/builders/studio?note=5');
31
+ assert.equal(N.noteLink({ id: '6', page_id: 'builders:studio', page_path: '/studio' }), '/studio?note=6');
32
+ assert.equal(N.isSiteNote({ page_id: 'site:home' }), true);
33
+ assert.equal(N.isSiteNote({ page_id: 'builders:site' }), false, 'the surface, not a page named site');
34
+ assert.equal(N.normalizeDraft({ page_id: 'site:home', page_path: '/' }).ok, true, 'the site surface is a valid page id');
35
+ const body = N.ideaBody({ body: 'The hero is too dark.' }, { id: '5', page_id: 'site:home', page_title: 'Home' }, 'Ana');
36
+ assert.match(body, /Full reference doc: \[Home feedback\]\(\/builders\/studio\?note=5\)$/, 'every idea it becomes links into the hall');
37
+ });
38
+
39
+ test('the Studio list row: counts the ideas, carries the link, never the body', () => {
40
+ const r = N.siteListItem({ id: 9, page_id: 'site:terms', page_path: '/terms', page_title: 'Terms', status: 'filed', submitted_at: 't', idea_ids: [1, 2, 3], author_name: 'Bo', excerpt: 'x', body_md: 'SECRET' });
41
+ assert.deepEqual(r, { id: '9', page_id: 'site:terms', page_path: '/terms', page_title: 'Terms', status: 'filed', submitted_at: 't', idea_count: 3, author_name: 'Bo', excerpt: 'x', link: '/builders/studio?note=9' });
42
+ });
43
+
44
+ // ---------- SQL ----------
45
+
46
+ test('listForSurface reads submitted docs on that surface only, newest first', async () => {
47
+ let seen = null;
48
+ await realListForSurface('site', { limit: 20 }, async (text, params) => { seen = { text, params }; return { rows: [] }; });
49
+ assert.deepEqual(seen.params, ['site:%', 20]);
50
+ assert.match(seen.text, /n\.page_id LIKE \$1 AND n\.status <> 'draft'/);
51
+ assert.match(seen.text, /ORDER BY n\.submitted_at DESC/);
52
+ assert.doesNotMatch(seen.text, /\bn\.body_md\b(?!,)/, 'only an excerpt leaves the table');
53
+ });
54
+
55
+ // ---------- the route ----------
56
+
57
+ const db = require('../modules/page-notes/db.js');
58
+ const api = require('../src/module-api.js');
59
+ let viewer = { id: 9, rank: 'thetes' };
60
+ let asked = null;
61
+ db.listForSurface = async (surface, opts) => {
62
+ asked = { surface, opts };
63
+ return [{ id: 41, page_id: 'site:projects', page_path: '/projects', page_title: 'Projects', status: 'filed', submitted_at: '2026-10-04', idea_ids: [7], author_name: 'Ana', excerpt: 'Labels overlap.' }];
64
+ };
65
+ db.getNote = async () => null;
66
+ api.requireBuilder = (req, res, next) => { if (!viewer) return res.status(401).json({ error: { code: 'unauthenticated' } }); req.builder = viewer; next(); };
67
+ const express = require('express');
68
+ const app = express();
69
+ app.use((req, res, next) => { res.fail = (code, s) => res.status(typeof s === 'number' ? s : 400).json({ error: { code } }); next(); });
70
+ app.use(express.json());
71
+ app.use('/api/bongos', require('../modules/page-notes/routes/page-notes.js')());
72
+ const server = app.listen(0);
73
+ await new Promise((r) => server.once('listening', r));
74
+ test.after(() => server.close());
75
+ const get = (p) => new Promise((resolve, reject) => {
76
+ http.get(`http://127.0.0.1:${server.address().port}/api/bongos${p}`, (res) => {
77
+ let s = '';
78
+ res.on('data', (c) => { s += c; });
79
+ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(s || 'null'), headers: res.headers }));
80
+ }).on('error', reject);
81
+ });
82
+
83
+ test('GET /page-notes/site: any signed-in builder reads the list; a visitor nothing', async () => {
84
+ viewer = { id: 9, rank: 'thetes' };
85
+ const r = await get('/page-notes/site');
86
+ assert.equal(r.status, 200, 'answered by the list, never read as doc id "site"');
87
+ assert.deepEqual(asked, { surface: 'site', opts: { limit: 20 } });
88
+ assert.equal(r.headers['cache-control'], 'no-store');
89
+ assert.deepEqual(r.body.notes.map((n) => [n.id, n.idea_count, n.link]), [['41', 1, '/builders/studio?note=41']]);
90
+ viewer = null;
91
+ assert.equal((await get('/page-notes/site')).status, 401);
92
+ });
93
+
94
+ // ---------- the browser half ----------
95
+
96
+ function el(tag, id, cls) {
97
+ const attrs = {};
98
+ const listeners = {};
99
+ const e = {
100
+ tagName: tag, id: id || '', className: cls || '', children: [], parentNode: null, innerHTML: '', title: '', type: '', src: '', href: '', rel: '',
101
+ dataset: {},
102
+ setAttribute(k, v) { attrs[k] = String(v); if (k.startsWith('data-')) e.dataset[k.slice(5).replace(/-(\w)/g, (_m, c) => c.toUpperCase())] = String(v); },
103
+ getAttribute(k) { return k in attrs ? attrs[k] : null; },
104
+ appendChild(c) { c.parentNode = e; e.children.push(c); if (e.onAppend) e.onAppend(c); return c; },
105
+ insertBefore(c, ref) { c.parentNode = e; const at = e.children.indexOf(ref); if (at === -1) e.children.push(c); else e.children.splice(at, 0, c); return c; },
106
+ addEventListener(t, fn) { (listeners[t] ||= []).push(fn); },
107
+ click() { (listeners.click || []).forEach((fn) => fn({ target: e })); },
108
+ };
109
+ return e;
110
+ }
111
+ const tree = (r) => r.children.flatMap((c) => [c, ...tree(c)]);
112
+ const flush = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); };
113
+
114
+ function bootSite({ me = { builder: { id: 9, github_login: 'ana' } }, search = '', pathname = '/projects', hall = 'https://builders.example.com' } = {}) {
115
+ const body = el('BODY');
116
+ const head = el('HEAD');
117
+ const bar = body.appendChild(el('DIV', '', 'barRight'));
118
+ bar.appendChild(el('BUTTON', 'projTrig', 'trig'));
119
+ const who = bar.appendChild(el('BUTTON', 'who', 'trig who'));
120
+ const script = el('SCRIPT');
121
+ script.setAttribute('data-hall', hall);
122
+ const fetched = [];
123
+ const loaded = [];
124
+ const opened = [];
125
+ const configured = [];
126
+ const all = () => [head, body, ...tree(head), ...tree(body)];
127
+ const document = {
128
+ body, head, currentScript: script, title: 'Projects — Example',
129
+ getElementById: (id) => all().find((n) => n.id === id) || null,
130
+ querySelector: (sel) => (sel === '.barRight' ? bar : null),
131
+ createElement: (t) => el(t.toUpperCase()),
132
+ createElementNS: (_ns, t) => el(t),
133
+ };
134
+ const window = { location: { search, pathname }, document };
135
+ head.onAppend = (tag) => {
136
+ if (tag.tagName !== 'SCRIPT') return;
137
+ loaded.push(tag.src);
138
+ window.OTBPageNotes = { configure: (c) => configured.push(JSON.parse(JSON.stringify(c))), open: () => opened.push('open'), openNote: (id) => opened.push('note ' + id) };
139
+ tag.onload();
140
+ };
141
+ const fetch = async (url, opts) => {
142
+ fetched.push([url, opts.credentials]);
143
+ return me ? { ok: true, json: async () => me } : { ok: false, status: 401, json: async () => ({}) };
144
+ };
145
+ const sandbox = { window, document, fetch, Promise, setTimeout };
146
+ vm.createContext(sandbox);
147
+ vm.runInContext(LOADER, sandbox, { filename: 'artist-review.js' });
148
+ return { window, document, body, head, bar, who, fetched, loaded, opened, configured, sandbox };
149
+ }
150
+
151
+ test('SIGNED OUT: no button, no sheet, no panel — the page is untouched', async () => {
152
+ const s = bootSite({ me: null });
153
+ await flush();
154
+ assert.deepEqual(s.fetched, [['/api/bongos/me', 'same-origin']]);
155
+ assert.equal(s.document.getElementById('arv-btn'), null);
156
+ assert.equal(s.head.children.length, 0);
157
+ assert.deepEqual(s.configured, [], 'the panel is never told anything');
158
+ });
159
+
160
+ test('SIGNED IN: the Review button sits in the hub bar right before the identity chip, and opens the hall\'s panel', async () => {
161
+ const s = bootSite();
162
+ await flush();
163
+ const b = s.document.getElementById('arv-btn');
164
+ assert.ok(b, 'drawn');
165
+ assert.equal(b.parentNode, s.bar);
166
+ assert.equal(s.bar.children[s.bar.children.indexOf(b) + 1], s.who, 'beside the identity chip');
167
+ assert.equal(b.getAttribute('aria-label'), 'Artist review: write feedback on this page', 'the same name as the hall\'s palette');
168
+ assert.equal(b.innerHTML, '', 'built as nodes: this surface writes no innerHTML');
169
+ assert.equal(b.children[0].tagName, 'svg');
170
+ assert.deepEqual([b.children[1].className, b.children[1].textContent], ['arv-btn__label', 'Review']);
171
+ assert.doesNotMatch(LOADER, /\.innerHTML\s*=/);
172
+ assert.deepEqual(s.head.children.map((t) => t.href), ['/modules/hall-ui/public/page-notes.css'], 'the panel\'s own sheet, served from the hall module');
173
+ assert.deepEqual(s.loaded, [], 'the panel is not loaded until it is wanted');
174
+ b.click(); await flush();
175
+ b.click(); await flush();
176
+ assert.deepEqual(s.loaded, ['/modules/hall-ui/public/page-notes.js'], 'loaded once');
177
+ assert.deepEqual(s.opened, ['open', 'open']);
178
+ assert.deepEqual(s.configured[0], { surface: 'site', hall: 'https://builders.example.com' }, 'the panel is told, explicitly, it is on the public site and where the hall is');
179
+ });
180
+
181
+ test('a ?note=N link opens that doc in place; a hall origin that is not one is dropped', async () => {
182
+ const s = bootSite({ search: '?note=41', hall: 'javascript:alert(1)' });
183
+ await flush();
184
+ assert.deepEqual(s.opened, ['note 41']);
185
+ assert.equal(s.body.dataset.pnHall, undefined);
186
+ });
187
+
188
+ test('the panel names a public page as site:<first path segment>, and its ideas open in the hall', async () => {
189
+ const run = (pathname, hall = 'https://builders.example.com') => {
190
+ const s = bootSite({ pathname, hall });
191
+ return flush().then(() => {
192
+ vm.runInContext(NOTES, s.sandbox, { filename: 'page-notes.js' });
193
+ s.window.OTBPageNotes.configure({ surface: 'site', hall });
194
+ return s;
195
+ });
196
+ };
197
+ for (const [p, id] of [['/', 'site:home'], ['/projects', 'site:projects'], ['/account', 'site:account'], ['/privacy.html', 'site:privacy']]) {
198
+ const s = await run(p);
199
+ assert.equal(s.window.OTBPageNotes.page().id, id, p);
200
+ assert.equal(N.PAGE_ID_RE.test(id), true, 'the server takes it');
201
+ }
202
+ const s = await run('/projects');
203
+ s.document.title = 'Example — projects';
204
+ assert.equal(s.window.OTBPageNotes.page().title, 'Projects', 'named by its path, not the product name the title leads with');
205
+ assert.equal((await run('/')).window.OTBPageNotes.page().title, 'Home');
206
+ assert.match(NOTES, /esc\(ideaHref\(i\.id\)\)/, 'idea links go through the surface-aware href');
207
+ assert.equal(s.window.OTBPageNotes.ideaHref('7'), 'https://builders.example.com/builders/idea/7', 'an idea opens in the hall');
208
+ const bad = await run('/projects', 'javascript:alert(1)');
209
+ assert.equal(bad.window.OTBPageNotes.ideaHref('7'), '/builders/idea/7', 'a hall origin that is not one is dropped');
210
+ s.window.OTBPageNotes.configure({});
211
+ assert.equal(s.window.OTBPageNotes.page().id, 'builders:home', 'unconfigured, it is the hall panel it always was');
212
+ assert.equal(s.window.OTBPageNotes.ideaHref('7'), '/#/idea/7');
213
+ });
214
+
215
+ test('the public button and the hall palette wear one name', () => {
216
+ const craft = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/craft-mode.js'), 'utf8');
217
+ const tool = /id: 'palette', icon: 'artistry', short: '([^']+)', label: '([^']+)'/.exec(craft);
218
+ assert.ok(tool, 'the palette tool');
219
+ assert.ok(LOADER.includes("var SHORT = '" + tool[1] + "';"), 'the short name');
220
+ assert.ok(LOADER.includes("var LABEL = '" + tool[2] + "';"), 'the full name');
221
+ });
222
+
223
+ test('every public page carries the loader last, with the hall origin stamped server-side', () => {
224
+ for (const f of PAGES) {
225
+ const html = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public', f + '.html'), 'utf8').replace(/\r\n/g, '\n');
226
+ assert.match(html, /<script src="\/assets\/artist-review\.js" data-hall="\{\{buildersOrigin\}\}"><\/script>\n<\/body>/, f);
227
+ }
228
+ const serve = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
229
+ assert.match(serve, /\.split\('\{\{buildersOrigin\}\}'\)/, 'the token the tag carries is one the server fills');
230
+ assert.match(serve, /const MODULE_ASSET_RE = \/\^\\\/modules\\\/\(\[a-z\]\[a-z0-9-\]\*\)\\\/public\\\/\(\.\+\)\$\//, 'the panel is served at /modules/hall-ui/public/');
231
+ });