@bongos/core 1.21.32 → 1.21.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.bongos-core.json +68 -43
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0338-modules-travel-through-a-bongos-hosted-store.md +7 -0
  8. package/docs/api/openapi.json +96 -3
  9. package/docs/api-reference.md +3 -2
  10. package/docs/architecture.md +1 -0
  11. package/docs/copy-inventory.md +29 -25
  12. package/docs/copy-registry.json +65 -29
  13. package/docs/design/craft-mode-direction.md +69 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +5 -1
  16. package/docs/page-readings.json +270 -201
  17. package/modules/government/catalog.js +3 -0
  18. package/modules/government/migrations/government_025_module_delist.sql +36 -0
  19. package/modules/hall-ui/public/modules.js +8 -1
  20. package/modules/hall-ui/public/street-desk.js +285 -0
  21. package/modules/hall-ui/public/street-inside.js +8 -4
  22. package/modules/hall-ui/public/street.css +59 -0
  23. package/modules/hall-ui/public/street.html +9 -3
  24. package/modules/hall-ui/public/street.js +219 -17
  25. package/modules/hall-ui/public/street.states.json +83 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +16 -0
  29. package/scripts/gds/module.js +21 -5
  30. package/src/bongos/module-store.js +31 -0
  31. package/src/bongos/route-rank-check.js +3 -0
  32. package/src/bongos/routes/modules.js +35 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/hall_catalog_world.mjs +1 -1
  35. package/tests/hall_matter_card.mjs +1 -1
  36. package/tests/hall_street.mjs +1 -1
  37. package/tests/hall_street_amend.mjs +1 -1
  38. package/tests/hall_street_desk.mjs +328 -0
  39. package/tests/hall_street_inside.mjs +1 -1
  40. package/tests/module_catalog.mjs +34 -6
  41. package/tests/module_store_delist.mjs +146 -0
@@ -41,6 +41,16 @@
41
41
  // request, says the answer in a toast, re-reads the feeds so the pins, the
42
42
  // readings and the evening follow, and marks the building that answered (a CSS
43
43
  // class for 1.6 s: it rises or lights, and the matter's pin settles).
44
+ //
45
+ // THE FLOOR DESK (task 1004558). The chosen floor's block is the desk from the
46
+ // round 2 board (Desks.dc.html): GovDesk.deskModel() (street-desk.js) gives the
47
+ // header, tiles, rows, quiet state and record line under the line rule, and
48
+ // this file draws it. A row's buttons send the matter card's own request
49
+ // (GovMatterCard.request, the same routes), so acting happens in place. The
50
+ // game layer: the today meter in the street header (one pip per decision, kept
51
+ // for the day in this browser), a row that clears away (a CSS class, then the
52
+ // redraw), a cleared floor that lights its windows and shows a tick in the
53
+ // drawing, and the toast after every act.
44
54
 
45
55
  (function () {
46
56
  'use strict';
@@ -70,6 +80,11 @@
70
80
  var data = { docket: null, goals: [], rollups: [], earnings: null, loaded: false, tasks: {}, room: null, constitution: null };
71
81
  var card = { cv: null, form: null, error: '', busy: false, fresh: null, answer: null };
72
82
  var view = { open: null, opener: null, model: null, scale: 1, mode: null, inside: null, insideModel: null };
83
+ // the desk's own state: rows you acted on this visit, what you did per floor,
84
+ // the floors you cleared, the row whose facts or chips are open
85
+ var desk = { done: {}, mine: {}, cleared: {}, open: null, ask: null, busy: false, roomAsked: false, model: null };
86
+ var TODAY_KEY = 'gov-desk-today';
87
+ var today = { day: '', n: 0 };
73
88
 
74
89
  // ---------- light / dark ----------
75
90
  var chosen = null;
@@ -343,6 +358,7 @@
343
358
  var eveLine = $('streetEveningLine');
344
359
  if (eveLine) eveLine.textContent = m.eveningLine;
345
360
 
361
+ drawToday();
346
362
  $('hudNeeds').textContent = m.hud.needs;
347
363
  $('hudMoving').textContent = m.hud.moving;
348
364
  $('hudShare').textContent = m.hud.share;
@@ -392,7 +408,18 @@
392
408
 
393
409
  function drawBuilding(mode) {
394
410
  var st = view.inside;
395
- var bv = I.buildingModel({ key: st.key, docket: data.docket, goals: data.goals, mode: mode, now: Date.now(), floor: st.floor, drawer: st.drawer, picks: st.picks });
411
+ var D = window.GovDesk;
412
+ // a building opens on the floor that needs you first (the desk's work)
413
+ var startFloor = st.floor == null && D && data.docket ? D.firstNeeds(st.key, data.docket) : null;
414
+ var cleared = [];
415
+ Object.keys(desk.cleared).forEach(function (k) { var p = k.split(':'); if (p[0] === st.key) cleared.push(Number(p[1])); });
416
+ var bv = I.buildingModel({ key: st.key, docket: data.docket, goals: data.goals, mode: mode, now: Date.now(), floor: st.floor == null ? startFloor : st.floor, drawer: st.drawer, picks: st.picks, cleared: cleared });
417
+ // a sitting's ballot is read once, so its row can vote in place
418
+ var C = window.GovMatterCard;
419
+ if (!desk.roomAsked && !data.room && C && data.docket && data.docket.matters.some(function (x) { return x && x.building === st.key && C.needsRoom(x); })) {
420
+ desk.roomAsked = true;
421
+ loadRoom().then(function () { if (view.inside) draw(); });
422
+ }
396
423
  view.insideModel = bv;
397
424
  setText('insideKicker', bv.kicker);
398
425
  show('insidePanel', true); show('insideDHead', false); show('insideLegend', false);
@@ -481,7 +508,7 @@
481
508
  // the chosen floor: two blocks that take turns, so the block re-enters on every pick
482
509
  var on = $(bv.flipA ? 'insideOpenA' : 'insideOpenB'), off = $(bv.flipA ? 'insideOpenB' : 'insideOpenA');
483
510
  if (off) { off.hidden = true; clear(off); }
484
- if (on) { on.hidden = false; fillOpen(on, bv); }
511
+ if (on) { on.hidden = false; fillDesk(on, bv); }
485
512
  setText('insideOnFile', bv.onFile);
486
513
  var drawers = $('insideDrawers'); clear(drawers);
487
514
  bv.drawers.forEach(function (dw) {
@@ -505,28 +532,201 @@
505
532
  });
506
533
  }
507
534
 
508
- function fillOpen(el, bv) {
535
+ // ---------- the floor desk (task 1004558) ----------
536
+ function icon(d, f, size) {
537
+ var svg = svgEl('svg');
538
+ svg.setAttribute('width', String(size)); svg.setAttribute('height', String(size)); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true');
539
+ var a = svgEl('path'); a.setAttribute('d', d || '');
540
+ svg.appendChild(a);
541
+ if (f != null) { var b = svgEl('path'); b.setAttribute('d', f || ''); svg.appendChild(b); }
542
+ return svg;
543
+ }
544
+ function chipBtn(cls, text, onClick) {
545
+ var b = node('button', 'desk-chip ' + cls, text);
546
+ b.setAttribute('type', 'button');
547
+ if (desk.busy) b.setAttribute('disabled', '');
548
+ b.addEventListener('click', onClick);
549
+ return b;
550
+ }
551
+ function fillDesk(el, bv) {
509
552
  clear(el);
553
+ var D = window.GovDesk, st = view.inside;
554
+ if (!D || !st) return;
555
+ var k = st.key + ':' + bv.selFloor;
556
+ var dm = D.deskModel({ key: st.key, floor: bv.selFloor, docket: data.docket, goals: (data.goals || []).filter(function (g) { return g && g.status === 'open'; }), now: Date.now(), room: data.room, constitution: data.constitution, done: desk.done, mine: desk.mine[k] || [], cleared: !!desk.cleared[k], today: today.n, sym: bv.sym });
557
+ desk.model = dm;
558
+ el.className = 'inside-open desk' + (dm.quiet ? ' desk--quiet' : '');
559
+ el.setAttribute('data-desk', dm.key);
560
+ // the header: the floor's sign, its name, its status
510
561
  var head = node('div', 'inside-open__head');
511
562
  var plate = node('span', 'inside-plate');
512
- var svg = svgEl('svg');
513
- svg.setAttribute('width', '40'); svg.setAttribute('height', '40'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true');
514
- var a = svgEl('path'); a.setAttribute('d', bv.sym.d || '');
515
- var b = svgEl('path'); b.setAttribute('d', bv.sym.f || '');
516
- svg.appendChild(a); svg.appendChild(b);
517
- plate.appendChild(svg);
563
+ plate.appendChild(icon(bv.sym.d, bv.sym.f, 34));
518
564
  head.appendChild(plate);
519
565
  var words = node('div', 'inside-open__words');
520
- words.appendChild(node('span', 'inside-open__kicker', bv.openKicker));
521
- words.appendChild(node('h3', 'inside-open__title', bv.openTitle));
566
+ words.appendChild(node('span', 'inside-open__kicker', 'floor ' + (bv.selFloor + 1)));
567
+ words.appendChild(node('h3', 'inside-open__title', dm.name));
568
+ words.appendChild(node('span', 'desk-status' + (dm.hot ? ' desk-status--hot' : ''), dm.status));
522
569
  head.appendChild(words);
523
570
  el.appendChild(head);
524
- bv.openLines.forEach(function (l) {
525
- var row = node('div', 'inside-line');
526
- row.appendChild(node('span', '', l.text));
527
- row.appendChild(node('span', 'inside-line__meta', l.meta));
528
- el.appendChild(row);
571
+ // up to three tiles, none of them a zero
572
+ if (dm.tiles.length) {
573
+ var tiles = node('div', 'desk-tiles');
574
+ dm.tiles.forEach(function (t) {
575
+ var tl = node('div', 'desk-tile');
576
+ tl.appendChild(node('span', 'desk-tile__v', t.v));
577
+ tl.appendChild(node('span', 'desk-tile__l', t.l));
578
+ tiles.appendChild(tl);
579
+ });
580
+ el.appendChild(tiles);
581
+ }
582
+ if (dm.quiet) {
583
+ // one line, lit: never an empty list
584
+ var q = node('div', 'desk-quiet' + (dm.cleared ? ' desk-quiet--cleared' : ''));
585
+ var tick = icon('M7.5 12.2l3 3 6-6.5', null, 44);
586
+ tick.setAttribute('class', 'desk-quiet__tick');
587
+ var ring = svgEl('circle'); ring.setAttribute('cx', '12'); ring.setAttribute('cy', '12'); ring.setAttribute('r', '9.5');
588
+ tick.appendChild(ring);
589
+ q.appendChild(tick);
590
+ q.appendChild(node('span', 'desk-quiet__line', dm.clearLine));
591
+ q.appendChild(node('span', 'desk-quiet__sub', dm.clearSub));
592
+ el.appendChild(q);
593
+ } else {
594
+ var list = node('div', 'desk-rows');
595
+ dm.rows.forEach(function (r) { list.appendChild(deskRow(r)); });
596
+ el.appendChild(list);
597
+ }
598
+ // the record line: what you just did here, then what this building closed
599
+ var rec = node('div', 'desk-record');
600
+ rec.appendChild(node('span', 'desk-record__label', 'record'));
601
+ if (!dm.record.length) rec.appendChild(node('span', 'desk-record__item', 'nothing yet'));
602
+ dm.record.forEach(function (x, i) { rec.appendChild(node('span', 'desk-record__item' + (x.mine ? ' desk-record__item--mine' : '') + (x.mine && i === 0 ? ' desk-pop' : ''), x.text)); });
603
+ el.appendChild(rec);
604
+ }
605
+ function deskRow(r) {
606
+ var row = node('div', 'desk-row' + (r.needs ? ' desk-row--needs' : ''));
607
+ row.setAttribute('data-row', r.key);
608
+ var main = node('div', 'desk-row__main');
609
+ main.appendChild(node('span', 'desk-ini', r.ini));
610
+ var name = node('button', 'desk-name');
611
+ name.setAttribute('type', 'button');
612
+ name.setAttribute('aria-expanded', desk.open === r.key ? 'true' : 'false');
613
+ name.setAttribute('data-matter', r.matter);
614
+ name.appendChild(node('span', 'desk-name__t', r.name));
615
+ name.appendChild(node('span', 'desk-name__f', r.fact));
616
+ name.addEventListener('click', function () { desk.open = desk.open === r.key ? null : r.key; desk.ask = null; draw(); focusRow(r.key, 'name'); });
617
+ main.appendChild(name);
618
+ var acts = node('span', 'desk-acts');
619
+ if (r.wait) acts.appendChild(node('span', 'desk-wait', r.wait));
620
+ // the primary button sits rightmost, as on the board
621
+ r.actions.slice().reverse().forEach(function (a) {
622
+ acts.appendChild(chipBtn('desk-chip--' + a.tone, a.label, function () {
623
+ if (a.go) { window.location.href = a.go; return; }
624
+ if (a.ask) { desk.ask = desk.ask === r.key ? null : r.key; desk.open = null; draw(); focusRow(r.key, 'chip'); return; }
625
+ deskAct(r, a.key, {});
626
+ }));
529
627
  });
628
+ main.appendChild(acts);
629
+ row.appendChild(main);
630
+ if (desk.open === r.key) {
631
+ var facts = node('div', 'desk-facts');
632
+ r.facts.forEach(function (f) { facts.appendChild(node('span', 'desk-fact', f)); });
633
+ row.appendChild(facts);
634
+ }
635
+ if (desk.ask === r.key && r.chips) {
636
+ var chips = node('div', 'desk-choices');
637
+ chips.appendChild(node('span', 'desk-choices__ask', r.chips.ask));
638
+ r.chips.options.forEach(function (c) { chips.appendChild(chipBtn('desk-chip--choice', c.label, function () { deskAct(r, r.chips.for, c.input); })); });
639
+ row.appendChild(chips);
640
+ }
641
+ return row;
642
+ }
643
+ function deskRowsEl() {
644
+ var st = view.insideModel;
645
+ var on = $(st && st.flipA ? 'insideOpenA' : 'insideOpenB');
646
+ var kids = on ? on.children : [];
647
+ for (var i = 0; i < kids.length; i++) if (/\bdesk-rows\b/.test(kids[i].className)) return kids[i];
648
+ return null;
649
+ }
650
+ function focusRow(key, what) {
651
+ var list = deskRowsEl();
652
+ var rows = list ? list.children : [];
653
+ for (var i = 0; i < rows.length; i++) {
654
+ if (rows[i].getAttribute('data-row') !== key) continue;
655
+ var main = rows[i].children[0];
656
+ var target = what === 'chip' && rows[i].children[1] ? rows[i].children[1].children[1] : main && main.children[1];
657
+ if (target && target.focus) target.focus();
658
+ return;
659
+ }
660
+ }
661
+ // The row clears away (a class the stylesheet animates), then the desk redraws without it.
662
+ function clearRow(key, then) {
663
+ var list = deskRowsEl();
664
+ var rows = list ? list.children : [];
665
+ for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-row') === key) rows[i].className += ' desk-row--gone';
666
+ if (typeof setTimeout !== 'function') return then();
667
+ setTimeout(then, 320);
668
+ }
669
+ var DONE_WORDS = { for: 'voted yes', object: 'voted no', fine: 'let in', refuse: 'turned away', release: 'released', hold: 'held' };
670
+ // One act from a row: checked, sent through the card's own request (the
671
+ // server's permission checks decide), then the row clears, the floor counts
672
+ // it, the meter takes a pip and the toast says what happened. An act that
673
+ // records nothing (hold) clears the row for this visit and says it still waits.
674
+ function deskAct(r, key, input) {
675
+ var C = window.GovMatterCard, st = view.inside;
676
+ if (!C || !st || !r.card || desk.busy) return Promise.resolve(false);
677
+ var refused = C.check(r.card, key, input);
678
+ if (refused) { say(refused); return Promise.resolve(false); }
679
+ var k = st.key + ':' + (view.insideModel ? view.insideModel.selFloor : 0);
680
+ var req = C.request(r.card, key, input);
681
+ var settle = function (body) {
682
+ var a = C.answer(r.card, key, body);
683
+ desk.mine[k] = [DONE_WORDS[key] + ' ' + r.name].concat(desk.mine[k] || []);
684
+ desk.done[r.matter] = true;
685
+ desk.open = null; desk.ask = null;
686
+ if (desk.model && !desk.model.rows.some(function (x) { return x.matter !== r.matter; })) desk.cleared[k] = true;
687
+ if (st.floor == null && view.insideModel) st.floor = view.insideModel.selFloor;
688
+ bumpToday();
689
+ if (a.building && a.kind) card.answer = { building: a.building, matter: r.matter, kind: a.kind, at: Date.now() };
690
+ say(a.toast || 'Done.');
691
+ };
692
+ if (!req) return new Promise(function (res) { clearRow(r.key, function () { settle({}); draw(); res(true); }); });
693
+ desk.busy = true;
694
+ draw();
695
+ return send(req).then(function (out) {
696
+ desk.busy = false;
697
+ if (!out.ok) { say(C.refusal(out.status, out.body)); draw(); return false; }
698
+ return new Promise(function (res) {
699
+ clearRow(r.key, function () {
700
+ settle(out.body);
701
+ draw();
702
+ data.room = null; desk.roomAsked = false;
703
+ load().then(function () { res(true); });
704
+ });
705
+ });
706
+ });
707
+ }
708
+ // the today meter: decisions made today, kept for the day in this browser
709
+ function dayOf() { return new Date().toISOString().slice(0, 10); }
710
+ function readToday() {
711
+ var t = { day: dayOf(), n: 0 };
712
+ try {
713
+ var raw = window.localStorage ? JSON.parse(window.localStorage.getItem(TODAY_KEY) || 'null') : null;
714
+ if (raw && raw.day === t.day && isFinite(raw.n)) t.n = Math.max(0, Math.floor(raw.n));
715
+ } catch (e) { /* storage blocked: the meter starts at zero */ }
716
+ return t;
717
+ }
718
+ function bumpToday() {
719
+ if (today.day !== dayOf()) today = { day: dayOf(), n: 0 };
720
+ today.n += 1;
721
+ try { if (window.localStorage) window.localStorage.setItem(TODAY_KEY, JSON.stringify(today)); } catch (e) { /* the count still shows for this visit */ }
722
+ }
723
+ function drawToday() {
724
+ var D = window.GovDesk, pipsEl = $('streetTodayPips');
725
+ if (!D || !pipsEl) return;
726
+ var t = D.todayPips(today.n);
727
+ clear(pipsEl);
728
+ t.pips.forEach(function (p) { pipsEl.appendChild(node('span', 'street-today__pip' + (p.on ? ' street-today__pip--on' : '') + (p.fresh ? ' desk-pop' : ''))); });
729
+ setText('streetTodayN', t.text);
530
730
  }
531
731
 
532
732
  function pickFloor(i) {
@@ -534,6 +734,7 @@
534
734
  if (!st) return;
535
735
  st.floor = i;
536
736
  st.picks = (st.picks || 0) + 1;
737
+ desk.open = null; desk.ask = null;
537
738
  draw();
538
739
  var floors = $('insideFloors');
539
740
  var kids = (floors && floors.children) || [];
@@ -929,11 +1130,12 @@
929
1130
  if (win) win.addEventListener('scroll', placeCard);
930
1131
  if (media && media.addEventListener) media.addEventListener('change', function () { if (!storedTheme()) { applyTheme(); draw(); } });
931
1132
  painted = applyTheme();
1133
+ today = readToday();
932
1134
  draw();
933
1135
  watchShellTheme();
934
1136
  return load();
935
1137
  }
936
1138
 
937
- window.GovStreetPage = { boot: boot, draw: draw, load: load, fit: fit, openCard: openCard, closeCard: closeCard, actOn: actOn, pickFloor: pickFloor, goStreet: goStreet, amend: amend, fileDraft: fileDraft, FEEDS: FEEDS, THEME_KEY: THEME_KEY };
1139
+ window.GovStreetPage = { boot: boot, draw: draw, load: load, fit: fit, openCard: openCard, closeCard: closeCard, actOn: actOn, pickFloor: pickFloor, deskAct: deskAct, goStreet: goStreet, amend: amend, fileDraft: fileDraft, FEEDS: FEEDS, THEME_KEY: THEME_KEY };
938
1140
  window.GovStreetPage.ready = boot();
939
1141
  }());
@@ -373,6 +373,89 @@
373
373
  "#insideLegend"
374
374
  ]
375
375
  }
376
+ },
377
+ "desk": {
378
+ "auth": true,
379
+ "_": "The floor desk (task 1004558): inside People the building opens on the floor that needs you, and the desk shows its sign, name and orange status, the tiles, the to-do rows with their two buttons, and the record line; the street header carries the today meter.",
380
+ "url": "/builders/street#building=people",
381
+ "actions": [
382
+ [
383
+ "wait",
384
+ 900
385
+ ],
386
+ [
387
+ "clickIfVisible",
388
+ "[data-craft-close]"
389
+ ],
390
+ [
391
+ "wait",
392
+ 300
393
+ ]
394
+ ],
395
+ "expect": {
396
+ "visible": [
397
+ ".desk-rows",
398
+ ".desk-status--hot",
399
+ ".desk-record",
400
+ "#streetTodayPips"
401
+ ]
402
+ }
403
+ },
404
+ "desk-facts": {
405
+ "auth": true,
406
+ "_": "A row name tapped: at most three facts open in place under it.",
407
+ "url": "/builders/street#building=people",
408
+ "actions": [
409
+ [
410
+ "wait",
411
+ 900
412
+ ],
413
+ [
414
+ "clickIfVisible",
415
+ "[data-craft-close]"
416
+ ],
417
+ [
418
+ "wait",
419
+ 300
420
+ ],
421
+ [
422
+ "click",
423
+ ".desk-name"
424
+ ],
425
+ [
426
+ "wait",
427
+ 500
428
+ ]
429
+ ],
430
+ "expect": {
431
+ "visible": [
432
+ ".desk-facts"
433
+ ]
434
+ }
435
+ },
436
+ "desk-quiet": {
437
+ "auth": true,
438
+ "_": "A quiet floor: one line with the lights on, never an empty list.",
439
+ "url": "/builders/street#building=review",
440
+ "actions": [
441
+ [
442
+ "wait",
443
+ 900
444
+ ],
445
+ [
446
+ "clickIfVisible",
447
+ "[data-craft-close]"
448
+ ],
449
+ [
450
+ "wait",
451
+ 300
452
+ ]
453
+ ],
454
+ "expect": {
455
+ "visible": [
456
+ ".desk-quiet"
457
+ ]
458
+ }
376
459
  }
377
460
  },
378
461
  "reducedMotion": {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.32",
3
+ "version": "1.21.34",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.32",
9
+ "version": "1.21.34",
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.32",
3
+ "version": "1.21.34",
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",
@@ -8948,5 +8948,21 @@
8948
8948
  "id": "1004551",
8949
8949
  "text": "An Archon can star a version on the Roadmap as the project's focus; it persists across reloads, and everyone else sees the star read-only (the server refuses non-Archons)."
8950
8950
  }
8951
+ ],
8952
+ "1.21.33": [
8953
+ {
8954
+ "id": "1003797",
8955
+ "text": "Senior builders can now take a module out of the store, with a reason. Nobody can newly install or update it, but anyone who already has it keeps it working and is told no more updates will come."
8956
+ },
8957
+ {
8958
+ "id": "1004494",
8959
+ "text": "The look for each craft mode is decided. Artistry, Ideating and Governing each get their own colour (moonlight, star gold, city blue) on the mode switch, the tab you are on and the top bar, the mode's name beside the page titl"
8960
+ }
8961
+ ],
8962
+ "1.21.34": [
8963
+ {
8964
+ "id": "1004558",
8965
+ "text": "Inside every building in the governor city, the floor you pick is now a desk you can act from, built from the new floor desks design. It shows the floor's name and how many things wait on you, up to three short numbers, and on"
8966
+ }
8951
8967
  ]
8952
8968
  }
@@ -433,7 +433,7 @@ function clip(s, n) {
433
433
 
434
434
  // Pure renderer — every input arrives as a parameter (the tests pass catalog
435
435
  // rows straight in), so it touches no disk and no config.
436
- function formatCatalog(rows, { catalogOnly = false, query = '', core = null } = {}) {
436
+ function formatCatalog(rows, { catalogOnly = false, query = '', core = null, delisted = new Set() } = {}) {
437
437
  const scoped = catalogOnly ? catalog.catalogRows(rows) : rows;
438
438
  const shown = catalog.searchCatalog(scoped, query);
439
439
  const counts = catalog.summarize(rows);
@@ -458,11 +458,14 @@ function formatCatalog(rows, { catalogOnly = false, query = '', core = null } =
458
458
  // An inferred credit is marked so it never reads as something the author
459
459
  // typed — the catalog's whole job is honest attribution (ADR 0107 §2).
460
460
  const suffix = r.credit.inferred ? '*' : '';
461
- const upkeep = (r.maintenance && r.maintenance.status) || 'undeclared';
461
+ const upkeep = ((r.maintenance && r.maintenance.status) || 'undeclared') + (delisted.has(r.key) ? ' · DELISTED' : '');
462
462
  out.push(` ${pad(mark, 5)}${pad(clip(r.key, 19), 20)}${pad(clip(r.version || '—', 8), 9)}${pad(r.source, 10)}${pad(clip((r.credit.author || '—') + suffix, 19), 20)}${pad(clip((r.credit.origin || '—') + suffix, 25), 26)}${pad(clip(r.license || '—', 11), 12)}${upkeep}`);
463
463
  }
464
464
  out.push('');
465
465
  out.push(' [x] enabled here · [ ] available, not enabled');
466
+ if (shown.some((r) => delisted.has(r.key))) {
467
+ out.push(' DELISTED = taken out of the store: your copy keeps working, but no more versions will come.');
468
+ }
466
469
  if (shown.some((r) => r.credit.inferred)) {
467
470
  out.push(` * credit not declared in module.json — shown as the platform (${catalog.CORE_PROJECT_NAME}), which ships it.`);
468
471
  }
@@ -470,7 +473,18 @@ function formatCatalog(rows, { catalogOnly = false, query = '', core = null } =
470
473
  return out.join('\n');
471
474
  }
472
475
 
473
- function cmdList(args, { log = console.log, errlog = console.error, gather = gatherCatalog } = {}) {
476
+ // Which installed store modules the store has delisted (task 1003797). Only modules
477
+ // built into this instance's tree can be store installs, so a core checkout asks
478
+ // nothing. Fail-soft like the upgrade check: offline, or no session, lists as before.
479
+ async function gatherDelisted(rows, { checkStore = gatherUpdates } = {}) {
480
+ const held = rows.filter((r) => r.source === 'instance');
481
+ if (!held.length) return new Set();
482
+ const found = await checkStore(held);
483
+ if (!found.checked) return new Set();
484
+ return new Set(Object.keys(found.latest).filter((k) => found.latest[k] && found.latest[k].status === 'delisted'));
485
+ }
486
+
487
+ async function cmdList(args, { log = console.log, errlog = console.error, gather = gatherCatalog, checkStore = gatherUpdates } = {}) {
474
488
  const catalogOnly = args.includes('--catalog');
475
489
  const json = args.includes('--json');
476
490
  const sIdx = args.indexOf('--search');
@@ -484,14 +498,16 @@ function cmdList(args, { log = console.log, errlog = console.error, gather = gat
484
498
  try { data = gather(); }
485
499
  catch (e) { errlog(`bongos module list: ${e.message}`); return 1; }
486
500
 
501
+ const delisted = await gatherDelisted(data.rows, { checkStore });
502
+
487
503
  if (json) {
488
504
  const scoped = catalogOnly ? catalog.catalogRows(data.rows) : data.rows;
489
- const modules = catalog.searchCatalog(scoped, query);
505
+ const modules = catalog.searchCatalog(scoped, query).map((m) => ({ ...m, store_delisted: delisted.has(m.key) }));
490
506
  log(JSON.stringify({ core: data.core, summary: catalog.summarize(data.rows), modules }, null, 2));
491
507
  return 0;
492
508
  }
493
509
 
494
- log(formatCatalog(data.rows, { catalogOnly, query, core: data.core }));
510
+ log(formatCatalog(data.rows, { catalogOnly, query, core: data.core, delisted }));
495
511
  for (const s of data.skipped) {
496
512
  errlog(` ! ${s.key}: not listed — ${s.reason}`);
497
513
  }
@@ -232,6 +232,36 @@ async function latestVersions(keys, { db } = {}) {
232
232
  return out;
233
233
  }
234
234
 
235
+ // Delist a module (task 1003797, ADR 0338 D2): it leaves the catalog, takes no new
236
+ // acquisitions or versions, and serves no further downloads — every read above
237
+ // already refuses on status 'delisted', so this one UPDATE is the whole act. It
238
+ // touches nothing else: no entitlement is revoked, no installed copy is reached,
239
+ // and no score row is written (a delist is not a score, ADR 0359). One-way here;
240
+ // there is no relist route yet.
241
+ // Returns { ok: true, module } or { ok: false, status, code, message }.
242
+ async function delistModule(key, { db } = {}) {
243
+ const pool = db || require('./pool').pool;
244
+ const { rows: [mod] } = await pool.query(
245
+ `UPDATE store_modules SET status = 'delisted', delisted_at = now()
246
+ WHERE module_key = $1 AND status = 'listed'
247
+ RETURNING module_key, title, status, delisted_at`, [key]);
248
+ if (mod) return { ok: true, module: mod };
249
+ const { rows: [have] } = await pool.query(
250
+ 'SELECT module_key, status, delisted_at FROM store_modules WHERE module_key = $1', [key]);
251
+ if (!have) return { ok: false, status: 404, code: 'module_not_found', message: `The store has no module "${key}".` };
252
+ return { ok: false, status: 409, code: 'already_delisted', message: `"${key}" is already delisted.` };
253
+ }
254
+
255
+ // Which of these module keys the store has delisted, as a Set — so the hall's
256
+ // Modules tab can mark an installed store module that will get no more versions.
257
+ async function delistedKeySet(keys, { db } = {}) {
258
+ if (!keys.length) return new Set();
259
+ const pool = db || require('./pool').pool;
260
+ const { rows } = await pool.query(
261
+ "SELECT module_key FROM store_modules WHERE module_key = ANY($1) AND status = 'delisted'", [keys]);
262
+ return new Set(rows.map((r) => r.module_key));
263
+ }
264
+
235
265
  // The tarball on disk for a version row. artifact_path is relative to the store dir
236
266
  // (relativeArtifactPath), and artifactPath() re-validates key + version, so a row
237
267
  // can never point a read outside the store.
@@ -246,4 +276,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
246
276
  module.exports = {
247
277
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
248
278
  isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
279
+ delistModule, delistedKeySet,
249
280
  };
@@ -240,6 +240,9 @@ const EXPECTED_RANKS = {
240
240
  // Re-running a store version's assessment (task 1003796, ADR 0359) spends a Docs
241
241
  // grader call and re-opens the Security gate, so it is pinned to its own atom.
242
242
  'POST /store/modules/:key/versions/:version/reassess': 'perm:module.assessment.rerun',
243
+ // Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
244
+ // it for every instance, so it is pinned to its own atom.
245
+ 'POST /store/modules/:key/delist': 'perm:module.delist',
243
246
  };
244
247
 
245
248
  // The authz-core route pins (ADR 0174). Paths and the gating atom were renamed
@@ -35,6 +35,9 @@
35
35
  // GET /store/modules/:key/versions/:version/howto
36
36
  // the HOWTO.md that version shipped with, as text for
37
37
  // the hall's how-to page (task 1004366, ADR 0347).
38
+ // POST /store/modules/:key/delist
39
+ // Metic+ takes a module out of the store; holders
40
+ // keep it (task 1003797, ADR 0338 D2).
38
41
  //
39
42
  // Read is open to any signed-in builder (same gate as the atlas/primer pages);
40
43
  // every write (enable/disable, submit) is metic+archon, same rank as
@@ -85,6 +88,7 @@ const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } =
85
88
  const {
86
89
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
87
90
  resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
91
+ delistModule, delistedKeySet,
88
92
  } = require('../module-store');
89
93
  const { KEY_RE } = require('../../module-loader/manifest-schema');
90
94
  const { parseVersion } = require('../../module-loader/semver');
@@ -204,6 +208,11 @@ module.exports = function buildModulesRouter() {
204
208
  let published = new Set();
205
209
  try { published = await publishedVersionSet(all.map((m) => m.key)); }
206
210
  catch (e) { log.warn({ err: e.message }, 'store versions unreadable for the modules list'); }
211
+ // Which installed store modules the store has delisted (task 1003797), so the row
212
+ // says it keeps working but gets no more versions (ADR 0338 D2). Same tolerance.
213
+ let delistedKeys = new Set();
214
+ try { delistedKeys = await delistedKeySet(all.filter((m) => m.source === 'instance').map((m) => m.key)); }
215
+ catch (e) { log.warn({ err: e.message }, 'store status unreadable for the modules list'); }
207
216
  for (const m of all) {
208
217
  m.enabled_source = sources[m.key] || 'default';
209
218
  m.override = byKey.has(m.key)
@@ -214,6 +223,7 @@ module.exports = function buildModulesRouter() {
214
223
  m.store_howto = m.source === 'instance' && m.version && published.has(`${m.key}@${m.version}`)
215
224
  ? `/builders/modules?howto=${encodeURIComponent(m.key)}&version=${encodeURIComponent(String(m.version))}`
216
225
  : null;
226
+ m.store_delisted = m.source === 'instance' && delistedKeys.has(m.key);
217
227
  }
218
228
  res.json({
219
229
  modules: all,
@@ -446,6 +456,31 @@ module.exports = function buildModulesRouter() {
446
456
  res.status(202).json({ ok: true, queued: true, module_key: key, version, version_id: r.version.id });
447
457
  }));
448
458
 
459
+ // POST /api/bongos/store/modules/:key/delist — a Metic+ person takes a module out
460
+ // of the store (task 1003797, ADR 0338 D2). It leaves the catalog, cannot be newly
461
+ // acquired, takes no new versions and serves no further downloads: every store
462
+ // read already refuses a delisted key, so this flips the one status. What it never
463
+ // does: revoke an entitlement, reach into an instance that already holds the
464
+ // module (that copy keeps working; `bongos upgrade` and the hall tell the holder
465
+ // no more versions will come), or touch a score (ADR 0359). A reason is required;
466
+ // the audit middleware records it with who asked. There is no relist route yet.
467
+ router.post('/store/modules/:key/delist', auth.requireBuilder, auth.requirePermission('module.delist'),
468
+ asyncHandler('POST /store/modules/:key/delist', async (req, res) => {
469
+ if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 2000 } })) return;
470
+ const { key } = req.params;
471
+ if (!KEY_RE.test(key)) {
472
+ return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
473
+ }
474
+ const reason = String(req.body?.reason || '').trim();
475
+ if (!reason) {
476
+ return res.fail('reason_required', { status: 400, message: 'Say why this module is leaving the store, in reason. People who already hold it keep it.' });
477
+ }
478
+ const r = await delistModule(key);
479
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
480
+ log.info({ key, builderId: req.builder.id }, `${key} delisted from the store`);
481
+ res.json({ ok: true, module_key: key, status: r.module.status, delisted_at: r.module.delisted_at });
482
+ }));
483
+
449
484
  // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
450
485
  // read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
451
486
  // like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
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.32'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.34'; // 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');
@@ -58,7 +58,7 @@ test('modules: the row is the oversight row — title with the mono key, clamped
58
58
  assert.match(js, /<span class="ov-row__title">\$\{escapeHtml\(m\.title \|\| m\.key\)\}<span class="ov-mono">\$\{escapeHtml\(m\.key\)\}<\/span>/, 'the key rides inside the title in mono');
59
59
  assert.match(js, /class="ov-row__sub mod-row__desc"/, 'the description is the sub line');
60
60
  assert.match(js, /<details class="mod-more"/, 'a details disclosure opens the rest');
61
- assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}<\/div>/, 'provenance, credit, upkeep and (for a store version, task 1004366) the how-to link are the facts line');
61
+ assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}<\/div>/, 'provenance, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill are the facts line');
62
62
  assert.match(js, /<div class="ov-row__right mod-row__right">\$\{rightHtml\(m\)\}<\/div>/, 'the right column is one function');
63
63
  const css = read('modules.css');
64
64
  assert.match(css, /\.mod-row__desc \{[^}]*-webkit-line-clamp: 2/, 'the description clamps to two lines');