@bongos/core 1.19.346 → 1.19.348
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +57 -22
- package/docs/copy-inventory.md +49 -37
- package/docs/copy-registry.json +170 -42
- package/modules/hall-ui/public/collab-lib.js +77 -0
- package/modules/hall-ui/public/collab.css +36 -0
- package/modules/hall-ui/public/collab.html +66 -0
- package/modules/hall-ui/public/collab.js +139 -0
- package/modules/hall-ui/public/idea.js +9 -1
- package/modules/hall-ui/public/ideas.js +17 -5
- package/modules/hall-ui/public/shell.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/client-baseurl-guard.js +116 -0
- package/scripts/gds/deploy-divergence.js +34 -3
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/update-channel.js +121 -5
- package/src/bongos/serve-internal.js +4 -1
- package/src/module-api.js +5 -1
- package/tests/client_baseurl_guard.mjs +122 -0
- package/tests/collab_page.mjs +172 -0
- package/tests/hall_nav.mjs +7 -1
- package/tests/idea_hall_composer.mjs +11 -0
- package/tests/update_channel.mjs +125 -0
|
@@ -0,0 +1,66 @@
|
|
|
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>Collab — {{worldName}}</title>
|
|
7
|
+
<meta name="description" content="Requests for help addressed to your craft or to you by name, and the asks you have open — with how long each has been waiting.">
|
|
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 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
12
|
+
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
13
|
+
<link rel="stylesheet" href="/builders/collab.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body data-page="collab" data-page-title="Collab">
|
|
16
|
+
|
|
17
|
+
<div class="app">
|
|
18
|
+
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
19
|
+
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
20
|
+
<div class="app-body">
|
|
21
|
+
<header class="app-topbar" id="app-topbar"></header>
|
|
22
|
+
<div class="app-content">
|
|
23
|
+
<div class="page-head">
|
|
24
|
+
<p class="page-head__sub" id="cb-sub">Loading…</p>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<main>
|
|
28
|
+
<!-- ASKED OF YOU comes first: it is the half where someone is waiting on a
|
|
29
|
+
human. Your own asks are below, because they are a status check, not a
|
|
30
|
+
call to act. -->
|
|
31
|
+
<section class="scroll" aria-labelledby="cb-for-me-h">
|
|
32
|
+
<h2 class="scroll__h" id="cb-for-me-h">Asked of you</h2>
|
|
33
|
+
<p class="scroll__note" id="cb-for-me-note">
|
|
34
|
+
Requests addressed to your craft, or to you by name. Answering is optional
|
|
35
|
+
and declining costs nothing — nobody is assigned by being asked.
|
|
36
|
+
</p>
|
|
37
|
+
<ul class="cb-list" id="cb-for-me"></ul>
|
|
38
|
+
<p class="cb-empty" id="cb-for-me-empty" hidden>
|
|
39
|
+
Nothing is waiting on you. Requests reach you by your craft — set yours in
|
|
40
|
+
<a href="/builders/settings">Settings</a> if this stays empty.
|
|
41
|
+
</p>
|
|
42
|
+
</section>
|
|
43
|
+
|
|
44
|
+
<section class="scroll" aria-labelledby="cb-mine-h">
|
|
45
|
+
<h2 class="scroll__h" id="cb-mine-h">Your asks</h2>
|
|
46
|
+
<p class="scroll__note">
|
|
47
|
+
Only you can settle your own request — mark it answered when someone
|
|
48
|
+
helped, or withdraw it when you no longer need it.
|
|
49
|
+
</p>
|
|
50
|
+
<ul class="cb-list" id="cb-mine"></ul>
|
|
51
|
+
<p class="cb-empty" id="cb-mine-empty" hidden>You have no open asks.</p>
|
|
52
|
+
</section>
|
|
53
|
+
</main>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
59
|
+
|
|
60
|
+
<script src="/builders/shell.js?v=2026-07-02-shell1"></script>
|
|
61
|
+
<script src="/builders/palette.js" defer></script>
|
|
62
|
+
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
63
|
+
<script src="/builders/collab-lib.js?v=2026-08-24-c02"></script>
|
|
64
|
+
<script src="/builders/collab.js?v=2026-08-24-c02"></script>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// modules/hall-ui/public/collab.js — the Collab page (/builders/collab).
|
|
2
|
+
// Task 1003172 / C02 of goal 1000075, criterion collab-page-matches-my-craft.
|
|
3
|
+
//
|
|
4
|
+
// The hall surface for C01's help requests. Two sections, in this order on
|
|
5
|
+
// purpose: what is ASKED OF YOU first, because someone is waiting on a human
|
|
6
|
+
// answer, then YOUR OWN ASKS, which are a status check rather than a call to act.
|
|
7
|
+
//
|
|
8
|
+
// THE PART THAT IS EASY TO SKIP, and the criterion names it explicitly: a request
|
|
9
|
+
// nobody takes must be VISIBLY AGING, not silently dead. Sorting the oldest to the
|
|
10
|
+
// top does not satisfy that — the oldest then looks exactly like the newest, only
|
|
11
|
+
// lower. So every row carries an age BAND with its own label and styling, and the
|
|
12
|
+
// rules for it live in collab-lib.js where a test can execute them.
|
|
13
|
+
//
|
|
14
|
+
// MATCHING IS THE SERVER'S JOB, not this file's. GET /help-requests/for-me derives
|
|
15
|
+
// the viewer's crafts from their own profile; sending a craft from here would be
|
|
16
|
+
// the privacy hole C01's grader found (a ?craft= param that let anyone read
|
|
17
|
+
// another craft's requests). This page never names a craft in a request.
|
|
18
|
+
(() => {
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
// NO baseUrl OVERRIDE. The generated client's methods (api.helpRequests.*, api.me.*)
|
|
22
|
+
// emit API-RELATIVE paths like '/help-requests/for-me'. Passing baseUrl:'' means
|
|
23
|
+
// "these paths are already absolute" and sends them to the site root, where nothing
|
|
24
|
+
// is mounted — every call 404s. Letting it default resolves them against
|
|
25
|
+
// '/api/bongos/v1' (api-prefix.js VERSIONED_API_PREFIX), which is where the router
|
|
26
|
+
// actually mounts. Pages that DO pass baseUrl:'' must build full paths themselves
|
|
27
|
+
// via api.request('GET', `${API}/...`) — the two styles must never be mixed.
|
|
28
|
+
const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });
|
|
29
|
+
const { escapeHtml, $, toast } = window.OTB;
|
|
30
|
+
const L = window.OTBCollabLib;
|
|
31
|
+
|
|
32
|
+
let settling = false;
|
|
33
|
+
|
|
34
|
+
function ageChip(req, nowMs) {
|
|
35
|
+
const band = L.ageBand(req.created_at, nowMs);
|
|
36
|
+
return `<span class="cb-age cb-age--${band.id}" title="Asked ${escapeHtml(String(req.created_at || ''))}">${escapeHtml(band.label)}</span>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function contextChip(req) {
|
|
40
|
+
const ctx = L.contextOf(req);
|
|
41
|
+
if (!ctx) return '';
|
|
42
|
+
return `<a class="cb-ctx" href="${escapeHtml(ctx.href)}">${escapeHtml(ctx.kind)} #${escapeHtml(String(ctx.id))}</a>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A row for the "asked of you" list. There is deliberately NO "claim this"
|
|
46
|
+
// button: answering is a conversation, and inventing a reply thread here would
|
|
47
|
+
// be reimplementing the Agora, which this goal explicitly forbids (C06 owns the
|
|
48
|
+
// chat mirror). What the page owes a helper is the request, its age, its
|
|
49
|
+
// context, and who to answer.
|
|
50
|
+
function forMeRow(req, viewerId, nowMs) {
|
|
51
|
+
const who = L.addressedTo(req, viewerId);
|
|
52
|
+
return `<li class="cb-item cb-item--${L.ageBand(req.created_at, nowMs).id}">
|
|
53
|
+
<p class="cb-item__stuck">${escapeHtml(req.what_is_stuck || '')}</p>
|
|
54
|
+
<p class="cb-item__meta">
|
|
55
|
+
<span class="cb-who">${escapeHtml(who)}</span>
|
|
56
|
+
${contextChip(req)}
|
|
57
|
+
${ageChip(req, nowMs)}
|
|
58
|
+
</p>
|
|
59
|
+
</li>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Your own ask, with the two settle actions. Only the asker can settle — the
|
|
63
|
+
// server enforces it in the UPDATE's WHERE, this is just the affordance.
|
|
64
|
+
function mineRow(req, nowMs) {
|
|
65
|
+
return `<li class="cb-item cb-item--${L.ageBand(req.created_at, nowMs).id}" data-id="${escapeHtml(String(req.id))}">
|
|
66
|
+
<p class="cb-item__stuck">${escapeHtml(req.what_is_stuck || '')}</p>
|
|
67
|
+
<p class="cb-item__meta">
|
|
68
|
+
<span class="cb-who">${escapeHtml(L.addressedTo(req, null))}</span>
|
|
69
|
+
${contextChip(req)}
|
|
70
|
+
${ageChip(req, nowMs)}
|
|
71
|
+
</p>
|
|
72
|
+
<p class="cb-item__actions">
|
|
73
|
+
<button class="btn btn--sm" data-settle="answered" data-id="${escapeHtml(String(req.id))}">Someone helped</button>
|
|
74
|
+
<button class="btn btn--sm btn--quiet" data-settle="withdrawn" data-id="${escapeHtml(String(req.id))}">Withdraw</button>
|
|
75
|
+
</p>
|
|
76
|
+
</li>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderList(hostSel, emptySel, rows, html) {
|
|
80
|
+
const host = $(hostSel);
|
|
81
|
+
const empty = $(emptySel);
|
|
82
|
+
if (!host) return;
|
|
83
|
+
host.innerHTML = rows.map(html).join('');
|
|
84
|
+
if (empty) empty.hidden = rows.length > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function load() {
|
|
88
|
+
const nowMs = Date.now();
|
|
89
|
+
const [forMe, mine, me] = await Promise.all([
|
|
90
|
+
api.helpRequests.getHelpRequestsForMe({}),
|
|
91
|
+
api.helpRequests.getHelpRequestsMine({ query: { status: 'open' } }),
|
|
92
|
+
api.me.getMe ? api.me.getMe({}) : Promise.resolve({ ok: false }),
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const viewerId = me && me.ok && me.data && me.data.builder ? me.data.builder.id : null;
|
|
96
|
+
|
|
97
|
+
const forMeRows = ((forMe.ok && forMe.data && forMe.data.help_requests) || []).slice().sort(L.byAgeOldestFirst);
|
|
98
|
+
const mineRows = ((mine.ok && mine.data && mine.data.help_requests) || []).slice().sort(L.byAgeOldestFirst);
|
|
99
|
+
|
|
100
|
+
renderList('#cb-for-me', '#cb-for-me-empty', forMeRows, (r) => forMeRow(r, viewerId, nowMs));
|
|
101
|
+
renderList('#cb-mine', '#cb-mine-empty', mineRows, (r) => mineRow(r, nowMs));
|
|
102
|
+
|
|
103
|
+
const sub = $('#cb-sub');
|
|
104
|
+
if (sub) {
|
|
105
|
+
const crafts = (forMe.ok && forMe.data && forMe.data.crafts) || [];
|
|
106
|
+
sub.textContent = crafts.length
|
|
107
|
+
? `Matching your craft: ${crafts.join(', ')}.`
|
|
108
|
+
: 'You have not set a craft, so only requests addressed to you by name reach you.';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function settle(id, status) {
|
|
113
|
+
if (settling) return;
|
|
114
|
+
settling = true;
|
|
115
|
+
try {
|
|
116
|
+
const r = await api.helpRequests.patchHelpRequestsId({ id, body: { status } });
|
|
117
|
+
if (!r.ok) {
|
|
118
|
+
toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not update that request.');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
toast(status === 'answered' ? 'Marked answered.' : 'Withdrawn.');
|
|
122
|
+
await load();
|
|
123
|
+
} finally {
|
|
124
|
+
settling = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
document.addEventListener('click', (ev) => {
|
|
129
|
+
const btn = ev.target && ev.target.closest ? ev.target.closest('[data-settle]') : null;
|
|
130
|
+
if (!btn) return;
|
|
131
|
+
ev.preventDefault();
|
|
132
|
+
settle(Number(btn.getAttribute('data-id')), btn.getAttribute('data-settle'));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
load().catch(() => {
|
|
136
|
+
const sub = $('#cb-sub');
|
|
137
|
+
if (sub) sub.textContent = 'Could not load requests.';
|
|
138
|
+
});
|
|
139
|
+
})();
|
|
@@ -39,7 +39,15 @@
|
|
|
39
39
|
'use strict';
|
|
40
40
|
|
|
41
41
|
const API = '/api/gds';
|
|
42
|
-
|
|
42
|
+
// NO baseUrl OVERRIDE — this page calls the GENERATED namespaced methods
|
|
43
|
+
// (api.inbox.*, api.me.*), whose path templates are API-RELATIVE ('/inbox/template').
|
|
44
|
+
// baseUrl:'' means "these paths are already absolute" and passes them through
|
|
45
|
+
// unchanged, which sent every request to the SITE ROOT and 404'd — this page was
|
|
46
|
+
// dead in production from the day it shipped. Letting it default resolves them
|
|
47
|
+
// against '/api/bongos/v1' (api-prefix.js VERSIONED_API_PREFIX), where the router
|
|
48
|
+
// actually mounts. A page that DOES pass baseUrl:'' must build full paths itself
|
|
49
|
+
// via api.request('GET', `${API}/...`) as work.js/copy-desk.js do — never mix the two.
|
|
50
|
+
const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });
|
|
43
51
|
const { escapeHtml, $, fmtDate } = window.OTB;
|
|
44
52
|
|
|
45
53
|
const ideaId = () => {
|
|
@@ -44,7 +44,15 @@
|
|
|
44
44
|
(() => {
|
|
45
45
|
'use strict';
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// NO baseUrl OVERRIDE — this page calls the GENERATED namespaced methods
|
|
48
|
+
// (api.inbox.*, api.me.*), whose path templates are API-RELATIVE ('/inbox/template').
|
|
49
|
+
// baseUrl:'' means "these paths are already absolute" and passes them through
|
|
50
|
+
// unchanged, which sent every request to the SITE ROOT and 404'd — this page was
|
|
51
|
+
// dead in production from the day it shipped. Letting it default resolves them
|
|
52
|
+
// against '/api/bongos/v1' (api-prefix.js VERSIONED_API_PREFIX), where the router
|
|
53
|
+
// actually mounts. A page that DOES pass baseUrl:'' must build full paths itself
|
|
54
|
+
// via api.request('GET', `${API}/...`) as work.js/copy-desk.js do — never mix the two.
|
|
55
|
+
const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });
|
|
48
56
|
const { escapeHtml, $, toast } = window.OTB;
|
|
49
57
|
|
|
50
58
|
// One key per THING BEING WRITTEN. A new idea and a spark being developed are
|
|
@@ -417,6 +425,14 @@
|
|
|
417
425
|
}
|
|
418
426
|
|
|
419
427
|
async function main() {
|
|
428
|
+
// THE QUEUE IS LOADED FIRST, AND UNCONDITIONALLY. The composer and the spark
|
|
429
|
+
// queue are independent surfaces that happen to share a page: the queue needs
|
|
430
|
+
// no template. Loading it after the template check meant one failed fetch took
|
|
431
|
+
// BOTH down — the reported symptom was "Could not load the idea template" AND a
|
|
432
|
+
// queue stuck on "Loading…" forever, which read as two bugs and was one.
|
|
433
|
+
// A surface that does not depend on the failure must not be hostage to it.
|
|
434
|
+
loadQueue();
|
|
435
|
+
|
|
420
436
|
const r = await api.inbox.getInboxTemplate({});
|
|
421
437
|
if (!r.ok || !r.data || !r.data.template) {
|
|
422
438
|
$('#fi-sub').textContent = 'Could not load the idea template. Reload, or file from the terminal with capture.js.';
|
|
@@ -434,10 +450,6 @@
|
|
|
434
450
|
$('#fi-form').addEventListener('submit', file);
|
|
435
451
|
$('#fi-clear').addEventListener('click', discard);
|
|
436
452
|
|
|
437
|
-
// The queue loads on every visit, develop mode or not: having just picked one
|
|
438
|
-
// up, seeing what else is waiting is the natural next thing.
|
|
439
|
-
loadQueue();
|
|
440
|
-
|
|
441
453
|
if (DEVELOP_ID) {
|
|
442
454
|
const d = await api.inbox.getInboxId({ id: DEVELOP_ID });
|
|
443
455
|
if (!d.ok || !d.data || !d.data.idea) {
|
|
@@ -153,6 +153,10 @@
|
|
|
153
153
|
{ id: 'profile', label: 'Profile', icon: 'profile', href: '/profile', gate: 'signed' },
|
|
154
154
|
] },
|
|
155
155
|
{ group: 'Community', gate: 'member', items: [
|
|
156
|
+
// task 1003172 (C02, goal 1000075): help requests addressed to you. In
|
|
157
|
+
// Community because the group is picked by WHOSE data it is, and this is
|
|
158
|
+
// other builders reaching you — not your own numbers (that is You).
|
|
159
|
+
{ id: 'collab', label: 'Collab', icon: 'leaderboard', href: '/collab', gate: 'member' },
|
|
156
160
|
{ id: 'leaderboard', label: 'Leaderboard', icon: 'leaderboard', href: '/#leaderboard', view: 'leaderboard', gate: 'member' },
|
|
157
161
|
// task 1002577: the cross-project builder directory (/scouting graduated
|
|
158
162
|
// into the hall). Gated to MATCH its data — /api/gds/scouting checks
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.348",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.348",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.348",
|
|
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",
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/client-baseurl-guard.js — a web page must not send its API calls to
|
|
3
|
+
// the site root (task 1003263).
|
|
4
|
+
//
|
|
5
|
+
// THE BUG THIS EXISTS TO STOP. The generated client (clients/bongos-client) exposes
|
|
6
|
+
// two call styles, and mixing them is silent and fatal:
|
|
7
|
+
//
|
|
8
|
+
// NAMESPACED api.inbox.getInboxTemplate({}) → path template '/inbox/template',
|
|
9
|
+
// API-RELATIVE, needs the API base
|
|
10
|
+
// ESCAPE HATCH api.request('GET', `${API}/me`) → caller supplies the FULL path
|
|
11
|
+
//
|
|
12
|
+
// createClient documents `baseUrl: ''` as "paths are already absolute" and passes
|
|
13
|
+
// them through unchanged. So a page that sets baseUrl:'' AND calls a namespaced
|
|
14
|
+
// method requests '/inbox/template' — the SITE ROOT, where nothing is mounted — and
|
|
15
|
+
// 404s on every call. The router mounts at '/api/bongos/v1' (api-prefix.js
|
|
16
|
+
// VERSIONED_API_PREFIX), which is exactly the client's own default.
|
|
17
|
+
//
|
|
18
|
+
// WHY A FITNESS CHECK AND NOT A TEST. Three pages shipped this defect — ideas.js and
|
|
19
|
+
// idea.js were dead in production from the day they shipped, and collab.js was caught
|
|
20
|
+
// in review one commit before it would have been. Every page test stubs the client
|
|
21
|
+
// method directly, so URL construction is never exercised; tests/idea_hall_composer.mjs
|
|
22
|
+
// even asserted with a regex against the source text, which passes happily on a broken
|
|
23
|
+
// URL. 54 green tests over pages that 404 on every call. The defect is invisible to the
|
|
24
|
+
// test style this repo uses for hall pages, so it needs a guard at a different altitude.
|
|
25
|
+
//
|
|
26
|
+
// STATIC AND CONSERVATIVE. It flags only the unambiguous combination — an explicit
|
|
27
|
+
// empty baseUrl in a createClient call, in a file that also makes a namespaced call.
|
|
28
|
+
// A page using the escape hatch with baseUrl:'' is CORRECT and stays silent (work.js,
|
|
29
|
+
// copy-desk.js, goal-inbox.js and most of the hall do exactly that).
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { execFileSync } = require('node:child_process');
|
|
36
|
+
|
|
37
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
38
|
+
const NAME = 'a web page sends its API calls to the API, not the site root (task 1003263)';
|
|
39
|
+
|
|
40
|
+
// createClient({ ... baseUrl: '' ... }) — an EXPLICIT empty base. Omitting baseUrl is
|
|
41
|
+
// the correct fix and must not match; only the literal empty string is the defect.
|
|
42
|
+
const EMPTY_BASEURL = /createClient\(\s*\{[^}]*baseUrl\s*:\s*(''|""|`\s*`)/;
|
|
43
|
+
|
|
44
|
+
// api.<namespace>.<method>( — the generated namespaced style. `api.request(` is the
|
|
45
|
+
// escape hatch and is deliberately NOT matched: with it, baseUrl:'' is correct.
|
|
46
|
+
const NAMESPACED_CALL = /\bapi\s*\.\s*(?!request\b)[A-Za-z_$][\w$]*\s*\.\s*[A-Za-z_$][\w$]*\s*\(/;
|
|
47
|
+
|
|
48
|
+
// Every tracked browser-served script. Server code never calls createClient this way.
|
|
49
|
+
function trackedPageScripts() {
|
|
50
|
+
try {
|
|
51
|
+
const out = execFileSync('git', ['ls-files', 'modules/*/public/*.js', 'public/*.js'],
|
|
52
|
+
{ cwd: REPO_ROOT, encoding: 'utf8' });
|
|
53
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scanPages(files, read) {
|
|
60
|
+
const violations = [];
|
|
61
|
+
let scanned = 0;
|
|
62
|
+
let usingClient = 0;
|
|
63
|
+
for (const rel of files) {
|
|
64
|
+
const src = read(rel);
|
|
65
|
+
if (src == null) continue;
|
|
66
|
+
scanned += 1;
|
|
67
|
+
if (!/createClient\(/.test(src)) continue;
|
|
68
|
+
usingClient += 1;
|
|
69
|
+
if (!EMPTY_BASEURL.test(src)) continue;
|
|
70
|
+
if (!NAMESPACED_CALL.test(src)) continue; // escape-hatch page — baseUrl:'' is right
|
|
71
|
+
const m = NAMESPACED_CALL.exec(src);
|
|
72
|
+
violations.push(
|
|
73
|
+
`${rel} sets baseUrl:'' AND calls the generated client (${m[0].replace(/\s+/g, '')}…). `
|
|
74
|
+
+ "Those paths are API-relative, so baseUrl:'' sends them to the site root and every "
|
|
75
|
+
+ 'call 404s. Drop the baseUrl override, or switch every call to '
|
|
76
|
+
+ "api.request('METHOD', `${API}/…`) with a full path.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return { violations, scanned, usingClient };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function checkClientBaseUrl({ files, read } = {}) {
|
|
83
|
+
const list = files || trackedPageScripts();
|
|
84
|
+
if (!list.length) {
|
|
85
|
+
return {
|
|
86
|
+
name: NAME, ok: false, hardFail: true,
|
|
87
|
+
violations: ['scan defect — enumerated 0 tracked page scripts; a broken enumeration, not a clean result.'],
|
|
88
|
+
warnings: [], note: 'static scan of browser-served scripts for a root-bound API client.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const reader = read || ((rel) => {
|
|
92
|
+
try { return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { return null; }
|
|
93
|
+
});
|
|
94
|
+
const { violations, scanned, usingClient } = scanPages(list, reader);
|
|
95
|
+
return {
|
|
96
|
+
name: NAME,
|
|
97
|
+
ok: violations.length === 0,
|
|
98
|
+
hardFail: violations.length > 0,
|
|
99
|
+
violations,
|
|
100
|
+
warnings: [],
|
|
101
|
+
note: `${scanned} browser-served script(s) scanned, ${usingClient} building a client: none may combine `
|
|
102
|
+
+ "baseUrl:'' with a generated namespaced call (api.<ns>.<method>()) — that pairing 404s every request. "
|
|
103
|
+
+ "baseUrl:'' with api.request() and a full path is the correct escape-hatch style and is not flagged.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function main() {
|
|
108
|
+
const r = checkClientBaseUrl();
|
|
109
|
+
for (const v of r.violations) console.error(`✗ ${v}`);
|
|
110
|
+
console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
|
|
111
|
+
process.exit(r.hardFail ? 1 : 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (require.main === module) main();
|
|
115
|
+
|
|
116
|
+
module.exports = { checkClientBaseUrl, scanPages, trackedPageScripts, EMPTY_BASEURL, NAMESPACED_CALL };
|
|
@@ -160,6 +160,28 @@ async function fetchLive(base, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// Is the automation that should have PREVENTED this divergence itself alive? (task 1003158)
|
|
164
|
+
//
|
|
165
|
+
// This script detects the SYMPTOM — live is behind main — and only ever at a moment a human is
|
|
166
|
+
// already looking. What went unwatched is the auto-upgrade subscription itself: when it stopped,
|
|
167
|
+
// nothing said so, and cloudbongos.com sat 36 releases behind for days. Reading the roster here means
|
|
168
|
+
// a dead upgrader is reported in the same breath as the staleness it was supposed to prevent.
|
|
169
|
+
//
|
|
170
|
+
// Speaks ONLY when a subscription manifest is actually present. An ordinary core checkout has none by
|
|
171
|
+
// design (ADR 0136 — it ships empty), and alarming about that every session is exactly the noise that
|
|
172
|
+
// gets a real warning scrolled past. Present-but-broken, or present-and-subscribing-nothing, is the
|
|
173
|
+
// signal worth having: someone meant to enroll instances here and none are being upgraded.
|
|
174
|
+
function subscriptionHealth(dir = REPO_ROOT) {
|
|
175
|
+
try {
|
|
176
|
+
const uc = require('./update-channel');
|
|
177
|
+
const roster = uc.loadSubscriptions({ instanceDir: dir });
|
|
178
|
+
if (!roster.present) return null;
|
|
179
|
+
return uc.describeRoster(roster, { controlPlane: true });
|
|
180
|
+
} catch {
|
|
181
|
+
return null; // never let the health probe break the divergence check it rides along with
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
163
185
|
async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
164
186
|
const target = base || arg('--url') || API_BASE;
|
|
165
187
|
const local = coreVersionAt(null);
|
|
@@ -173,7 +195,9 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
|
173
195
|
const unreleased = unreleasedSince(mainRef);
|
|
174
196
|
const live = await fetchLive(target, timeoutMs);
|
|
175
197
|
|
|
176
|
-
|
|
198
|
+
const subscriptions = subscriptionHealth();
|
|
199
|
+
|
|
200
|
+
if (live.error) return { ok: false, undetermined: true, reason: `could not read ${live.url}: ${live.error}`, target, local, onMain, unreleased, subscriptions };
|
|
177
201
|
|
|
178
202
|
const behind = cmpSemver(live.coreVersion, onMain) < 0;
|
|
179
203
|
const ahead = cmpSemver(live.coreVersion, onMain) > 0;
|
|
@@ -181,7 +205,7 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
|
181
205
|
|
|
182
206
|
return {
|
|
183
207
|
ok: !behind && !hasUnreleased,
|
|
184
|
-
target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased,
|
|
208
|
+
target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased, subscriptions,
|
|
185
209
|
};
|
|
186
210
|
}
|
|
187
211
|
|
|
@@ -232,6 +256,13 @@ function report(r, { quiet = false } = {}) {
|
|
|
232
256
|
if (r.ahead) {
|
|
233
257
|
say(` note: live (${r.live.coreVersion}) is AHEAD of ${r.mainRef} (${r.onMain}) — this checkout is stale; fetch.`);
|
|
234
258
|
}
|
|
259
|
+
// The watcher's watcher: if this checkout carries a subscription roster and that roster is not
|
|
260
|
+
// healthy, the unattended upgrade is not going to close the gap above on its own.
|
|
261
|
+
if (r.subscriptions && r.subscriptions.level !== 'ok') {
|
|
262
|
+
say('');
|
|
263
|
+
say(` ${r.subscriptions.level === 'alarm' ? '⚠ AUTO-UPGRADE' : 'note: auto-upgrade'} — ${r.subscriptions.message}`);
|
|
264
|
+
if (r.behind) say(' So the gap above will NOT close by itself.');
|
|
265
|
+
}
|
|
235
266
|
say('');
|
|
236
267
|
say(' Merging to main deploys nothing by design. "shipped" means "merged" —');
|
|
237
268
|
say(' it does not mean users can see it.');
|
|
@@ -317,4 +348,4 @@ if (require.main === module) {
|
|
|
317
348
|
});
|
|
318
349
|
}
|
|
319
350
|
|
|
320
|
-
module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver };
|
|
351
|
+
module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver, subscriptionHealth };
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1307,6 +1307,7 @@ const CHECKS = [
|
|
|
1307
1307
|
require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
|
|
1308
1308
|
require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
|
|
1309
1309
|
require('./baseline-staleness.js').checkBaselineFloors, // Check 26 — task 1003210: a shrink-only baseline that never shrinks is unmanaged debt; a floor exempting one must say why
|
|
1310
|
+
require('./client-baseurl-guard.js').checkClientBaseUrl, // Check 27 — task 1003263: a page must not combine baseUrl:'' with a generated namespaced call (that pairing 404s every request; three pages shipped it, two of them dead in prod)
|
|
1310
1311
|
];
|
|
1311
1312
|
|
|
1312
1313
|
function runAll() {
|