@bongos/core 1.19.826 → 1.19.828

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.
@@ -0,0 +1,110 @@
1
+ // blockers-lib.js — the ONE blocker-row renderer (task 1003455, goal 1000089).
2
+ //
3
+ // GET /blockers (modules/ideas/routes/blockers.js) had zero consumers — not the
4
+ // hall, not status-ui — and the only blocker a builder could ever see was
5
+ // GET /blockers/:id, reachable only by typing #/blocker/N into the address bar.
6
+ // Owner decision 2026-08-30: BOTH surfaces, from ONE code path — the open-blocker
7
+ // queue gets a real page (modules/hall-ui/public/blockers.js), and open blockers
8
+ // also become line items in the existing Needs-attention inbox
9
+ // (modules/hall-ui/public/goal-inbox.js), because hall-ui/CLAUDE.md's placement
10
+ // rule says an action awaiting a human is an inbox line, never a new Home card.
11
+ //
12
+ // This file is the shared middle: the goals-lib.js / goalRenderFacts precedent —
13
+ // ONE facts source that both callers render from their own DOM shape, so the two
14
+ // surfaces cannot drift apart the way ~10 hand-rolled pagers did before the kit.
15
+ // The two callers do not share a visual family (the inbox nests inside
16
+ // .ginbox__item, the page's rows are the oversight family's .ov-row), so — unlike
17
+ // goalRenderFacts, whose two callers share one family and one fixed class — the
18
+ // class names are the caller's own, passed in `opts`; only the FACTS (title, the
19
+ // filed date, the note, which tasks it blocks) are shared.
20
+ //
21
+ // Pure: no window.OTB, no DOM, no fetch — Node-require-safe like goals-lib.js /
22
+ // hall-kit.js, so a test can run the real arithmetic instead of agreeing with a
23
+ // copy of it.
24
+ (function (root, factory) {
25
+ var mod = factory();
26
+ if (typeof module !== 'undefined' && module.exports) module.exports = mod;
27
+ else root.OTBBlockersLib = mod;
28
+ }(typeof self !== 'undefined' ? self : this, function () {
29
+ 'use strict';
30
+
31
+ function esc(s) {
32
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
33
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
34
+ });
35
+ }
36
+
37
+ function fmtDate(iso) {
38
+ if (!iso) return '';
39
+ var d = new Date(iso);
40
+ if (isNaN(d.getTime())) return '';
41
+ return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
42
+ }
43
+
44
+ // A blocker's body_md is the owner's own words, quoted the way goal-inbox.js's
45
+ // invitation/join rows already quote a note (the SAME convention, not a new
46
+ // one). It can run to a paragraph, and the row is a summary, so it is clipped —
47
+ // CSS line-clamp handles visual overflow on both surfaces, this just keeps one
48
+ // enormous blocker from making the row's own text node absurd.
49
+ function noteHtml(bodyMd, noteClass) {
50
+ var t = String(bodyMd || '').trim();
51
+ if (!t) return '';
52
+ var clipped = t.length > 240 ? t.slice(0, 239).replace(/\s+$/, '') + '…' : t;
53
+ return '<p class="' + esc(noteClass) + '">“' + esc(clipped) + '”</p>';
54
+ }
55
+
56
+ // `taskHref(id)` is the caller's own link builder (window.OTBTaskDetail.href on
57
+ // a live page) — kept OUT of this pure module the way hall-kit.js stays
58
+ // date-agnostic: the lib never learns what a task URL looks like.
59
+ function blockingLine(b, taskHref) {
60
+ var ids = Array.isArray(b.blocking_task_ids) ? b.blocking_task_ids : [];
61
+ if (!ids.length) return 'Blocking nothing yet';
62
+ var links = ids.map(function (id) {
63
+ var label = '#' + id;
64
+ return taskHref ? '<a href="' + esc(taskHref(id)) + '">' + esc(label) + '</a>' : esc(label);
65
+ }).join(', ');
66
+ var noun = ids.length === 1 ? 'task' : 'tasks';
67
+ return 'Blocking ' + ids.length + ' ' + noun + ': ' + links;
68
+ }
69
+
70
+ // blockerFacts(b, opts) — the shared render. `opts`:
71
+ // titleClass (default 'ov-row__title')
72
+ // noteClass (default 'ov-row__sub')
73
+ // whenClass (default 'ov-row__when')
74
+ // blockerHref(id) — the title's own link (both surfaces point at the SAME
75
+ // #/blocker/N overlay task-detail.js already renders —
76
+ // the "reachable only by typing the URL" bug this task
77
+ // fixes is exactly that overlay having no door INTO it).
78
+ // taskHref(id) — see blockingLine above.
79
+ // Returns { titleHtml, noteHtml, factsHtml } — fragments, not a wrapped row;
80
+ // each caller nests them in its OWN <li> (goal-inbox.js's .ginbox__item,
81
+ // blockers.js's .ov-row), the goalRenderFacts idiom.
82
+ function blockerFacts(b, opts) {
83
+ opts = opts || {};
84
+ var titleClass = opts.titleClass || 'ov-row__title';
85
+ var noteClass = opts.noteClass || 'ov-row__sub';
86
+ var whenClass = opts.whenClass || 'ov-row__when';
87
+ var blockerHref = opts.blockerHref || null;
88
+ var taskHref = opts.taskHref || null;
89
+
90
+ var titleText = esc(b.title || ('Blocker #' + b.id));
91
+ var titleHtml = blockerHref
92
+ ? '<a class="' + esc(titleClass) + '" href="' + esc(blockerHref(b.id)) + '">' + titleText + '</a>'
93
+ : '<span class="' + esc(titleClass) + '">' + titleText + '</span>';
94
+
95
+ // ONE fact line, `·`-joined — the watch.js convention (its box/report rows
96
+ // join multiple facts into a single .ov-row__when span the same way) rather
97
+ // than several sibling spans, which need a flex-gapped parent to space
98
+ // themselves and the attention-inbox context (.ginbox__sub) is not one.
99
+ var facts = 'Filed ' + esc(fmtDate(b.created_at)) + ' · ' + blockingLine(b, taskHref);
100
+ var factsHtml = '<span class="' + esc(whenClass) + '">' + facts + '</span>';
101
+
102
+ return {
103
+ titleHtml: titleHtml,
104
+ noteHtml: noteHtml(b.body_md, noteClass),
105
+ factsHtml: factsHtml,
106
+ };
107
+ }
108
+
109
+ return { esc: esc, fmtDate: fmtDate, blockingLine: blockingLine, blockerFacts: blockerFacts };
110
+ }));
@@ -0,0 +1,65 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Blockers — {{worldName}}</title>
7
+ <meta name="description" content="What is stuck on the owner right now: open blockers and the work they hold up.">
8
+ <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <!-- task 1003455: the row shape (title + facts) is the oversight family's,
13
+ the same archetype collab/copy-desk/watch/gate already ride. -->
14
+ <link rel="stylesheet" href="/builders/oversight.css">
15
+ </head>
16
+ <body data-page="blockers" data-page-title="Blockers">
17
+
18
+ <div class="app">
19
+ <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
20
+ <div class="app-scrim" id="app-scrim" hidden></div>
21
+ <div class="app-body">
22
+ <header class="app-topbar" id="app-topbar"></header>
23
+ <div class="app-content">
24
+ <div class="page-head page-head--board">
25
+ <h1 class="page-head__h">Blockers</h1>
26
+ <p class="page-head__sub" id="blockers-sub">Things stuck on the owner — a credential, a purchase, a product call only they can make. Resolving one un-sticks every task waiting on it automatically.</p>
27
+ </div>
28
+
29
+ <main id="hall-main">
30
+ <!-- task 1003455 (goal 1000089): the queue GET /blockers had no page for
31
+ — the only door in used to be typing #/blocker/N into the address
32
+ bar. This is the read half; filing/resolving/linking is task 1003456. -->
33
+ <section class="scroll" id="blockers-scroll" aria-labelledby="blockers-h">
34
+ <h2 id="blockers-h" class="scroll__h">Open blockers<span class="count-pill" id="blockers-count" hidden></span></h2>
35
+ <div id="blockers-body">
36
+ <p class="profile__placeholder">Loading…</p>
37
+ </div>
38
+ </section>
39
+ </main>
40
+
41
+ <footer class="app-foot" id="app-foot"></footer>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
47
+
48
+ <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
49
+ chrome exists before the page script runs. -->
50
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
51
+ <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
52
+ <script src="/builders/palette.js" defer></script>
53
+ <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
54
+ branding pack's currency label, loaded BEFORE blockers.js. -->
55
+ <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
56
+ <script src="/builders/brand-apply.js"></script>
57
+ <script src="/builders/hall-kit.js"></script>
58
+ <!-- task-detail.js still owns the #/blocker/N deep-link overlay every row's
59
+ title opens into — this page needs its link-builder (OTBTaskDetail.href)
60
+ for the "blocking N tasks" facts line, but not the overlay's own render. -->
61
+ <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
62
+ <script src="/builders/blockers-lib.js"></script>
63
+ <script src="/builders/blockers.js"></script>
64
+ </body>
65
+ </html>
@@ -0,0 +1,88 @@
1
+ // blockers.js — the Blockers page: the full open-blocker queue (task 1003455,
2
+ // goal 1000089).
3
+ //
4
+ // GET /blockers (modules/ideas/routes/blockers.js) is PUBLIC and had ZERO hall
5
+ // consumers before this task — the only blocker a builder could ever see was the
6
+ // #/blocker/N overlay task-detail.js already renders, reachable only by typing
7
+ // the id into the address bar. Owner decision 2026-08-30: BOTH surfaces, from
8
+ // ONE code path — this page is the full queue (a long list has nowhere else to
9
+ // live), and goal-inbox.js's attention-inbox slice is the capped summary. Both
10
+ // render from blockers-lib.js's blockerFacts(), so the two cannot drift apart
11
+ // the way ~10 hand-rolled pagers did before the kit (hall-ui/CLAUDE.md).
12
+ //
13
+ // Read-only: filing, resolving and linking a blocker to a task are task 1003456.
14
+ // Deliberately UNGATED (tests/hall_page_gate_map.mjs) — its one data source is
15
+ // public, the roster/drachmae precedent: a page must not be gated tighter than
16
+ // the API it fronts.
17
+ (function () {
18
+ 'use strict';
19
+ var kit = window.OTBKit;
20
+ var lib = window.OTBBlockersLib;
21
+ var API = '/api/bongos';
22
+ var body = document.getElementById('blockers-body');
23
+ var countPill = document.getElementById('blockers-count');
24
+ var sub = document.getElementById('blockers-sub');
25
+
26
+ var taskHref = function (id) {
27
+ return window.OTBTaskDetail ? window.OTBTaskDetail.href(id) : ('/task/' + encodeURIComponent(id));
28
+ };
29
+ var blockerHref = function (id) { return '#/blocker/' + encodeURIComponent(id); };
30
+
31
+ var all = [];
32
+
33
+ function rowHtml(b) {
34
+ var facts = lib.blockerFacts(b, {
35
+ titleClass: 'ov-row__title',
36
+ noteClass: 'ov-row__sub',
37
+ whenClass: 'ov-row__when',
38
+ blockerHref: blockerHref,
39
+ taskHref: taskHref,
40
+ });
41
+ return '<li class="ov-row" data-id="' + lib.esc(String(b.id)) + '">'
42
+ + '<div class="ov-row__main">'
43
+ + facts.titleHtml
44
+ + facts.noteHtml
45
+ + '<div class="ov-row__facts">' + facts.factsHtml + '</div>'
46
+ + '</div>'
47
+ + '</li>';
48
+ }
49
+
50
+ var pager = kit.makePager('blockers', 'hall-blockers-per-page', { defaultPerPage: 25 });
51
+ var listEl = null;
52
+
53
+ function rerender() {
54
+ var slice = kit.paginate(all, pager);
55
+ listEl.innerHTML = slice.length
56
+ ? slice.map(rowHtml).join('')
57
+ : kit.emptyStateHtml('Nothing is stuck on the owner right now.');
58
+ }
59
+
60
+ function mountBlockers(rows) {
61
+ all = Array.isArray(rows) ? rows : [];
62
+ if (countPill) { countPill.textContent = String(all.length); countPill.hidden = all.length === 0; }
63
+ if (sub) {
64
+ sub.textContent = all.length
65
+ ? (all.length === 1 ? 'One thing' : all.length + ' things') + ' stuck on the owner — a credential, a '
66
+ + 'purchase, a product call only they can make. Resolving one un-sticks every task waiting on it automatically.'
67
+ : 'Nothing is stuck on the owner right now.';
68
+ }
69
+ body.innerHTML = '<ul class="ov-rows" id="blockers-list"></ul>' + kit.pagerBarHtml(pager.prefix, { label: 'blockers' });
70
+ listEl = document.getElementById('blockers-list');
71
+ kit.wirePager(pager, rerender);
72
+ rerender();
73
+ }
74
+
75
+ function notice(msg) {
76
+ body.innerHTML = '';
77
+ body.className = 'note';
78
+ body.textContent = msg;
79
+ }
80
+
81
+ fetch(API + '/blockers?limit=200', { headers: { Accept: 'application/json' } })
82
+ .then(function (r) {
83
+ if (!r.ok) throw new Error('http ' + r.status);
84
+ return r.json();
85
+ })
86
+ .then(function (data) { mountBlockers(data && data.blockers); })
87
+ .catch(function () { notice('Couldn’t load the blocker queue right now.'); });
88
+ }());
@@ -384,6 +384,73 @@
384
384
  });
385
385
  }
386
386
 
387
+ // ---- open blockers (task 1003455, goal 1000089) ---------------------------
388
+ //
389
+ // GET /blockers is public and had ZERO hall consumers — the only blocker a
390
+ // builder could ever see was the #/blocker/N overlay task-detail.js already
391
+ // renders, reachable only by typing the id into the address bar. This slice
392
+ // + the /blockers page (blockers.js) are ONE code path: both render from
393
+ // blockers-lib.js's blockerFacts(), loaded before this file (index.html).
394
+ const MAX_BLOCKER_ROWS = 5;
395
+
396
+ function blockerRowHtml(b) {
397
+ const lib = window.OTBBlockersLib;
398
+ const facts = lib.blockerFacts(b, {
399
+ titleClass: 'ginbox__goal',
400
+ noteClass: 'ginbox__note',
401
+ whenClass: 'ginbox__sub',
402
+ // The SAME overlay task-detail.js has rendered since task 1003066's
403
+ // predecessor — this slice is what gives it its first door in.
404
+ blockerHref: (id) => `#/blocker/${encodeURIComponent(id)}`,
405
+ taskHref: (id) => (window.OTBTaskDetail ? window.OTBTaskDetail.href(id) : `/task/${encodeURIComponent(id)}`),
406
+ });
407
+ return `<li class="ginbox__item">
408
+ <div class="ginbox__body">
409
+ ${facts.titleHtml}
410
+ ${facts.factsHtml}
411
+ ${facts.noteHtml}
412
+ </div>
413
+ </li>`;
414
+ }
415
+
416
+ function renderBlockers(data) {
417
+ const box = host && host.querySelector('#attention-blockers');
418
+ if (!box) return;
419
+ const rows = (data && Array.isArray(data.blockers)) ? data.blockers : [];
420
+ if (!rows.length) {
421
+ box.innerHTML = '';
422
+ window.OTBAttention?.refresh();
423
+ return;
424
+ }
425
+ const total = (data && data.page && Number.isFinite(data.page.total)) ? data.page.total : rows.length;
426
+ const more = total > rows.length
427
+ ? `<p class="ginbox__pending">…${total - rows.length} more — <a href="/blockers">open the queue</a>.</p>`
428
+ : '';
429
+ box.innerHTML = '<h3 class="ginbox__sec-h">Stuck on the owner '
430
+ + `<span class="count-pill">${total}</span></h3>`
431
+ + `<ul class="ginbox">${rows.map(blockerRowHtml).join('')}</ul>${more}`;
432
+ window.OTBAttention?.refresh();
433
+ }
434
+
435
+ // task 1003448's "Home bounded reads" rule: ask the server for exactly the
436
+ // rows the card will show (limit=MAX_BLOCKER_ROWS) and read `page.total` back,
437
+ // rather than fetching every open blocker and slicing it client-side — the
438
+ // Next-up / triage-card bug this file's own CLAUDE.md names as the shape to
439
+ // avoid.
440
+ function loadBlockers() {
441
+ return getJSON(`${API}/blockers?limit=${MAX_BLOCKER_ROWS}`)
442
+ .then((data) => renderBlockers(data))
443
+ .catch((err) => {
444
+ const box = host && host.querySelector('#attention-blockers');
445
+ if (box) box.innerHTML = '';
446
+ window.OTBAttention?.refresh();
447
+ if (err.code !== 'auth-needed' && err.code !== 'not-found') {
448
+ console.warn('[hall] blockers feed unavailable — the "Stuck on the owner" section is empty '
449
+ + 'because the request failed, NOT because nothing is open:', err);
450
+ }
451
+ });
452
+ }
453
+
387
454
  // task 2169: this widget IS the "Needs your attention" card — ONE capped
388
455
  // summary hosting every actionable item class: goal invitations +
389
456
  // join-requests (this file) and ideas awaiting triage (builders.js renders
@@ -417,6 +484,12 @@
417
484
  // triage. Its rows carry the shared .ginbox__item class, so refresh()
418
485
  // counts them with no change to the selector below.
419
486
  '<div id="attention-nod"></div>' +
487
+ // task 1003455 (goal 1000089): open blockers — things stuck on the owner
488
+ // (modules/ideas/blockers.js). AFTER nod, BEFORE rot: like every slice
489
+ // above it, a blocker is genuinely waiting on a human, but unlike them it
490
+ // is project-wide rather than addressed to this viewer, so it sits with
491
+ // rot's standing-question tail rather than the personal asks at the top.
492
+ '<div id="attention-blockers"></div>' +
420
493
  // task 1003279 (goal 1000072): work going quiet — a goal or task untouched
421
494
  // past the project's rot timer, with the owner's four verbs on it. LAST in
422
495
  // the card on purpose: every slice above is somebody waiting on YOU right
@@ -426,6 +499,7 @@
426
499
  host.addEventListener('click', onClick);
427
500
  load();
428
501
  loadNodQueue();
502
+ loadBlockers();
429
503
  loadRot();
430
504
  // task 1002802 (P06): the profile slice lives in its own file (builders.js is
431
505
  // over the size budget); this card owns its div, so it boots it here.
@@ -121,6 +121,10 @@
121
121
  <script src="/builders/hall-widgets.js?v=2026-06-27-r75" defer></script>
122
122
  <!-- BV1.R78 (task 1694): the Goals widget registers into the same registry. -->
123
123
  <script src="/builders/goals.js?v=2026-07-01-goals" defer></script>
124
+ <!-- task 1003455: the shared blocker-row renderer (window.OTBBlockersLib), read by
125
+ both goal-inbox.js's attention-inbox slice below and the /blockers page.
126
+ Loaded BEFORE goal-inbox.js, which calls it while rendering. -->
127
+ <script src="/builders/blockers-lib.js" defer></script>
124
128
  <!-- ADR 0112 (task 1954): the home-page goal-membership inbox — invitations to
125
129
  accept + join-requests to approve. Self-registers; hidden when empty. -->
126
130
  <script src="/builders/goal-inbox.js?v=2026-07-10-goal-inbox" defer></script>
@@ -165,6 +165,15 @@
165
165
  // active-nav detection (#nav-work), the 'work' gate case, and the server
166
166
  // page-gate all keep working — only the visible label and order change.
167
167
  { id: 'work', label: 'Tasks', icon: 'work', href: '/work', gate: 'signed' },
168
+ // task 1003455 (goal 1000089): the open-blocker queue — what's stuck on the
169
+ // owner. Lives with the project's own state (Roadmap/Goals/Tasks), not You
170
+ // or Community, because its subject is the project, not a person. Its one
171
+ // data source (GET /blockers) is deliberately public, like Roadmap's — but
172
+ // the NAV FLAG stays 'signed' on the roster/drachmae precedent: an ungated
173
+ // page can still gate cosmetically wider in the sidebar than the API it
174
+ // fronts, showing the link to signed-in builders while a stranger who
175
+ // already has the URL can still open it.
176
+ { id: 'blockers', label: 'Blockers', icon: 'admin', href: '/blockers', gate: 'signed' },
168
177
  // task 1003744 (goal 1000111): the Board Room lives HERE, in the project's
169
178
  // own group, and gates on the atom its page gates on.
170
179
  //
@@ -6,7 +6,8 @@
6
6
  // asked to join and when (ADR 0201 D1.3)
7
7
  // POST /my-projects/join — apply to a project: one click plus an optional
8
8
  // note in the applicant's own words (task 1002330).
9
- // Relayed; the project executes and decides.
9
+ // Relayed; the project executes and decides —
10
+ // unless the platform's record says it is unpublished.
10
11
  // POST /my-projects/:clientId/leave — self-service offboard (reflected to the hub)
11
12
  //
12
13
  // All gated on a live HUB session (requireBuilder). Authority stays LOCAL: JOIN only
@@ -24,9 +25,30 @@ const api = require('../../../src/module-api');
24
25
  const pi = require('../platform-identity');
25
26
  const projectStats = require('../project-stats');
26
27
  const applicationEcho = require('../application-echo');
28
+ const { projectPublishability } = require('../publish-gate');
27
29
 
28
30
  const { pool, requireBuilder, validateOrRespond, userAgent } = api;
29
31
 
32
+ // Dark matter, refused before the relay (task 1003578, ADR 0247 D4). The project's own
33
+ // door cannot lock itself — its instance never holds its description — so the hub asks the
34
+ // provisioning row, the one live copy, through a port resolved by name (ADR 0083; optional,
35
+ // the `provisioning.projectOwnedBy` precedent). Only a POSITIVE verdict refuses. No port, no
36
+ // row (a self-hosted or hand-seeded project) or a failed read is no verdict, and the request
37
+ // is relayed as before: relaying grants nothing, and refusing on a guess would tell an
38
+ // applicant something untrue about someone else's project.
39
+ const PUBLISH_IDENTITY_PORT = 'provisioning.projectPublishIdentity';
40
+ async function isDarkMatter(client) {
41
+ const identityOf = api.resolveOptional(PUBLISH_IDENTITY_PORT);
42
+ if (typeof identityOf !== 'function') return false;
43
+ try {
44
+ const identity = await identityOf({ slug: client.client_id, host: new URL(client.origin).hostname });
45
+ return !!identity && projectPublishability(identity).publishable === false;
46
+ } catch (e) {
47
+ api.logger('platform-identity').warn(`publish verdict read failed, relaying (non-fatal): ${e && e.message}`);
48
+ return false;
49
+ }
50
+ }
51
+
30
52
  // joinRelayOutcome — what the project's own admission surface answered, said in
31
53
  // the HUB's words (task 1003044). The project decides; the hub relays and must
32
54
  // never paint a refusal as "requested" — nor as "unreachable". A 2xx is a filed
@@ -172,6 +194,10 @@ module.exports = function myProjectsRoutes() {
172
194
  if (!client || client.status !== 'active' || !client.origin) {
173
195
  return res.fail('unknown_project', 404);
174
196
  }
197
+ if (await isDarkMatter(client)) {
198
+ return res.json({ ok: true, status: 'not_published', project: { name: client.name, origin: client.origin },
199
+ message: RELAY_SENTENCE.not_published });
200
+ }
175
201
  // Relay an access request to the project's OWN (public) admission endpoint —
176
202
  // the project decides (open-join, or request/invite per ADR 0112). We post to
177
203
  // the registry-trusted origin, never the caller's raw input.
@@ -14,6 +14,6 @@
14
14
  "migrations": true,
15
15
  "pollers": ["liveness-sweep", "catalog-backfill"]
16
16
  },
17
- "provides": ["provisioning.instanceState", "provisioning.projectPlanets", "provisioning.projectOwnedBy"],
17
+ "provides": ["provisioning.instanceState", "provisioning.projectPlanets", "provisioning.projectOwnedBy", "provisioning.projectPublishIdentity"],
18
18
  "consumes": []
19
19
  }
@@ -58,6 +58,8 @@ const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285,
58
58
  const { callbackPage } = require('./callback-page');
59
59
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
60
60
  const { badProjectDetail, badModuleSelection, badProjectType } = require('./body-validators');
61
+ // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
62
+ const { registerProvisioningSeams } = require('../seams');
61
63
  // task 1003208: structured logging (pino via the doorway) — was console.*.
62
64
  const log = api.logger('provisioning');
63
65
 
@@ -110,28 +112,6 @@ function ownerFromTargetRef(ref) {
110
112
  return m ? m[1] : null;
111
113
  }
112
114
 
113
- // Register this module's kernel seam (ADR 0083 / the registerBoxSeams precedent).
114
- // Idempotent — the loader calls this factory once at boot, but tests may
115
- // instantiate the router repeatedly; gating on hasProvider keeps the port
116
- // single-registered.
117
- // • PROVIDES port `provisioning.instanceState` (slug) → { status, … } | null.
118
- // Lets other code read an instance's provisioning status via
119
- // resolveOptional without importing this module (box.hasEverConnected pattern).
120
- // • PROVIDES port `provisioning.projectPlanets` (hosts[]) → { host: { planet_template,
121
- // planet_accent } } — the owners' planet choices (task 1003347), so the hub's
122
- // public featured feed can carry them without importing this module.
123
- // • PROVIDES port `provisioning.projectOwnedBy` ({ slug, host, builderId }) → boolean —
124
- // the OWNERSHIP fact behind a hub catalog row (task 1003580), so a hub surface can
125
- // own-scope a project without importing this module and without treating the hub's
126
- // `owner_github_id` columns as authority (ADR 0238). See projectOwnedBy for why the
127
- // question names both halves of the ADR 0246 projection.
128
- function registerProvisioningSeams() {
129
- if (api.hasProvider('provisioning.instanceState')) return;
130
- api.registerProvider('provisioning.instanceState', (slug) => provisioning.instanceStateForSlug(pool, slug));
131
- api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
132
- api.registerProvider('provisioning.projectOwnedBy', (target) => provisioning.projectOwnedBy(pool, target));
133
- }
134
-
135
115
  // Is this project on the public map, and if not, what is it still missing?
136
116
  // (R16, task 1002335 — the surface R15 pointed at by name.)
137
117
  //
@@ -0,0 +1,54 @@
1
+ // modules/provisioning/seams.js — the kernel ports this module provides (ADR 0083 / the
2
+ // registerBoxSeams precedent). Moved out of routes/provisioning.js under task 1003578,
3
+ // because that file sits at the 1500-line fitness ratchet and a new port needed a line.
4
+ 'use strict';
5
+
6
+ const api = require('../../src/module-api');
7
+ const { pool } = api;
8
+ const provisioning = require('./provisioning');
9
+
10
+ // The `provisioning.projectPublishIdentity` port (task 1003578, ADR 0247 D4): the name and
11
+ // description the publish gate judges, for the control-plane project a hub catalog row was
12
+ // projected from. This row is the one LIVE copy of a control-plane project's description;
13
+ // the hub's catalog column is a cache (ADR 0182 D5), so the hub's join relay asks here.
14
+ //
15
+ // Keyed on BOTH halves of the ADR 0246 projection (`client_id = slug` at `https://<domain>`),
16
+ // the `projectOwnedBy` rule: a hand-seeded catalog row's name is squattable in this table,
17
+ // and a stranger's instance must not answer for it. The slug IS the name on this plane, so
18
+ // the mapping stays here and the rule stays in the hub. `null` = no row = NO verdict.
19
+ async function projectPublishIdentity(db, { slug, host } = {}) {
20
+ if (typeof slug !== 'string' || !slug) return null;
21
+ const { rows } = await db.query(
22
+ `SELECT slug, description FROM provisioning_instances
23
+ WHERE slug = $1 AND lower(domain) = $2
24
+ LIMIT 1`,
25
+ [slug, typeof host === 'string' ? host.toLowerCase() : ''],
26
+ );
27
+ return rows[0] ? { name: rows[0].slug, description: rows[0].description } : null;
28
+ }
29
+
30
+ // Idempotent — the loader calls the routes factory once at boot, but tests may
31
+ // instantiate the router repeatedly; gating on hasProvider keeps each port
32
+ // single-registered.
33
+ // • PROVIDES port `provisioning.instanceState` (slug) → { status, … } | null.
34
+ // Lets other code read an instance's provisioning status via
35
+ // resolveOptional without importing this module (box.hasEverConnected pattern).
36
+ // • PROVIDES port `provisioning.projectPlanets` (hosts[]) → { host: { planet_template,
37
+ // planet_accent } } — the owners' planet choices (task 1003347), so the hub's
38
+ // public featured feed can carry them without importing this module.
39
+ // • PROVIDES port `provisioning.projectOwnedBy` ({ slug, host, builderId }) → boolean —
40
+ // the OWNERSHIP fact behind a hub catalog row (task 1003580), so a hub surface can
41
+ // own-scope a project without importing this module and without treating the hub's
42
+ // `owner_github_id` columns as authority (ADR 0238). See projectOwnedBy for why the
43
+ // question names both halves of the ADR 0246 projection.
44
+ // • PROVIDES port `provisioning.projectPublishIdentity` ({ slug, host }) → { name,
45
+ // description } | null — see projectPublishIdentity above.
46
+ function registerProvisioningSeams() {
47
+ if (api.hasProvider('provisioning.instanceState')) return;
48
+ api.registerProvider('provisioning.instanceState', (slug) => provisioning.instanceStateForSlug(pool, slug));
49
+ api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
50
+ api.registerProvider('provisioning.projectOwnedBy', (target) => provisioning.projectOwnedBy(pool, target));
51
+ api.registerProvider('provisioning.projectPublishIdentity', (target) => projectPublishIdentity(pool, target));
52
+ }
53
+
54
+ module.exports = { registerProvisioningSeams, projectPublishIdentity };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.826",
3
+ "version": "1.19.828",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.826",
9
+ "version": "1.19.828",
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.826",
3
+ "version": "1.19.828",
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
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.826'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.828'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');