@bongos/core 1.21.131 → 1.21.133
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 +45 -25
- package/docs/copy-inventory.md +37 -22
- package/docs/copy-registry.json +187 -32
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +5 -2
- package/docs/page-readings.json +150 -149
- package/modules/hall-ui/public/goal-author-lib.js +129 -0
- package/modules/hall-ui/public/goal-author.js +197 -0
- package/modules/hall-ui/public/goals-page.js +3 -0
- package/modules/hall-ui/public/goals.css +13 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/goals.states.json +34 -1
- package/modules/hall-ui/records/goal-authoring.md +10 -0
- package/modules/provisioning/github-setup.js +2 -2
- package/modules/provisioning/pollers/liveness-sweep.js +29 -14
- package/modules/provisioning/tests/liveness.mjs +6 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/github_sign_in_seen.mjs +102 -14
- package/tests/goal_author_ui.mjs +210 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// goal-author.js — the Goals page's "author a goal / add a done-when criterion"
|
|
2
|
+
// panel (task 1003463, goal 1000089; ADR 0366 D1.2/D1.3 + D3). The hall could
|
|
3
|
+
// SATISFY a criterion and never AUTHOR one, and had no POST /goals call at all.
|
|
4
|
+
//
|
|
5
|
+
// Two forms, nothing else:
|
|
6
|
+
// · GOAL — a name, a one-line subtitle, an optional longer description, the
|
|
7
|
+
// version, an optional category. NO scope field: the goal is created with
|
|
8
|
+
// spec_task:true, so it arrives with a "Spec goal N" task that gates its other
|
|
9
|
+
// work, and the scope is set when that task is done. Before submit, the
|
|
10
|
+
// suggester's matching open goals are shown so reuse is a click.
|
|
11
|
+
// · CRITERION — the outcome in a sentence; the slug is derived from it.
|
|
12
|
+
//
|
|
13
|
+
// The authority walls stay on the SERVER and this file lets them speak: it sends
|
|
14
|
+
// what was typed and renders the refusal. It never hides the version a Metic
|
|
15
|
+
// "cannot" use or the goal a non-member "cannot" add to — pre-filtering would turn
|
|
16
|
+
// a clear refusal into a form that quietly lacks the option (ADR 0086, ADR 0154).
|
|
17
|
+
//
|
|
18
|
+
// Steps that are interviews — planning a version, inferring scope, drafting
|
|
19
|
+
// criteria or seed tasks, judging reuse, carrying forward — are deliberately
|
|
20
|
+
// absent (ADR 0366 D2). The pure half (bodies, slug, refusal copy) is
|
|
21
|
+
// goal-author-lib.js; this file is the DOM and the two calls.
|
|
22
|
+
(function () {
|
|
23
|
+
'use strict';
|
|
24
|
+
const lib = window.OTBGoalAuthor;
|
|
25
|
+
const { escapeHtml } = window.OTB;
|
|
26
|
+
|
|
27
|
+
// mount({ root, api, API, toast, getGoals }) — `api` is the page's typed client
|
|
28
|
+
// (api.request(method, path, { body }) → { status, ok, data }), `getGoals` the
|
|
29
|
+
// page's loaded goal rows (so the criterion form lists the goals already on the
|
|
30
|
+
// board without a second fetch).
|
|
31
|
+
function mount(opts) {
|
|
32
|
+
const { root, api, API, toast, getGoals } = opts;
|
|
33
|
+
if (!root || !lib) return;
|
|
34
|
+
let loaded = false;
|
|
35
|
+
let timer = null;
|
|
36
|
+
let seq = 0;
|
|
37
|
+
|
|
38
|
+
root.innerHTML = `
|
|
39
|
+
<button type="button" class="btn goal-author__open" id="ga-open" aria-expanded="false" aria-controls="ga-panel">New goal</button>
|
|
40
|
+
<section class="scroll goal-author" id="ga-panel" aria-labelledby="ga-h" hidden>
|
|
41
|
+
<h2 class="scroll__h" id="ga-h">Author a goal</h2>
|
|
42
|
+
<form id="ga-goal" class="goal-author__form" novalidate>
|
|
43
|
+
<label class="goal-author__field">Name
|
|
44
|
+
<input id="ga-title" type="text" maxlength="${lib.TITLE_MAX}" autocomplete="off" required>
|
|
45
|
+
</label>
|
|
46
|
+
<label class="goal-author__field">What it is for <span class="goal-author__hint">One line, under the name</span>
|
|
47
|
+
<input id="ga-subtitle" type="text" maxlength="${lib.TITLE_MAX}" autocomplete="off">
|
|
48
|
+
</label>
|
|
49
|
+
<label class="goal-author__field">Description <span class="goal-author__hint">optional</span>
|
|
50
|
+
<textarea id="ga-description" rows="3"></textarea>
|
|
51
|
+
</label>
|
|
52
|
+
<div class="goal-author__row">
|
|
53
|
+
<label class="goal-author__field">Version
|
|
54
|
+
<select id="ga-version" required><option value="">Loading…</option></select>
|
|
55
|
+
</label>
|
|
56
|
+
<label class="goal-author__field">Category <span class="goal-author__hint">optional</span>
|
|
57
|
+
<select id="ga-category"><option value="">— none —</option></select>
|
|
58
|
+
</label>
|
|
59
|
+
</div>
|
|
60
|
+
<div id="ga-suggest" class="goal-author__suggest" aria-live="polite" hidden></div>
|
|
61
|
+
<p class="goal-author__note">Scope is not asked for here: the goal gets a “Spec goal” task, and the scope is set when that task is done.</p>
|
|
62
|
+
<p class="goal-author__err" id="ga-goal-err" role="alert" hidden></p>
|
|
63
|
+
<button type="submit" class="btn btn--primary" id="ga-goal-go">Create goal</button>
|
|
64
|
+
</form>
|
|
65
|
+
|
|
66
|
+
<h2 class="scroll__h goal-author__h2" id="ga-crit-h">Add a done-when criterion</h2>
|
|
67
|
+
<form id="ga-crit" class="goal-author__form" novalidate aria-labelledby="ga-crit-h">
|
|
68
|
+
<label class="goal-author__field">Goal
|
|
69
|
+
<select id="ga-crit-goal" required><option value="">— pick a goal —</option></select>
|
|
70
|
+
</label>
|
|
71
|
+
<label class="goal-author__field">What will be true when it is done
|
|
72
|
+
<textarea id="ga-crit-text" rows="2"></textarea>
|
|
73
|
+
</label>
|
|
74
|
+
<p class="goal-author__note" id="ga-crit-slug" aria-live="polite"></p>
|
|
75
|
+
<p class="goal-author__err" id="ga-crit-err" role="alert" hidden></p>
|
|
76
|
+
<button type="submit" class="btn btn--primary" id="ga-crit-go">Add criterion</button>
|
|
77
|
+
</form>
|
|
78
|
+
</section>`;
|
|
79
|
+
|
|
80
|
+
const $q = (id) => root.querySelector(`#${id}`);
|
|
81
|
+
const panel = $q('ga-panel');
|
|
82
|
+
const openBtn = $q('ga-open');
|
|
83
|
+
|
|
84
|
+
const showErr = (id, text) => { const el = $q(id); el.textContent = text || ''; el.hidden = !text; };
|
|
85
|
+
|
|
86
|
+
async function get(path) {
|
|
87
|
+
try { return await api.request('GET', path, {}); } catch (_) { return { ok: false, status: 0, data: {} }; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fillGoalChoices() {
|
|
91
|
+
const sel = $q('ga-crit-goal');
|
|
92
|
+
const keep = sel.value;
|
|
93
|
+
const goals = (getGoals() || []).filter((g) => g && g.status === 'open');
|
|
94
|
+
sel.innerHTML = '<option value="">— pick a goal —</option>' + goals.map((g) =>
|
|
95
|
+
`<option value="${escapeHtml(String(g.id))}" data-version="${escapeHtml(String(g.version_id || ''))}">${escapeHtml(`${g.version_id || ''} · ${g.title}`)}</option>`).join('');
|
|
96
|
+
sel.value = keep;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadChoices() {
|
|
100
|
+
if (loaded) return;
|
|
101
|
+
const [v, c] = await Promise.all([get(`${API}/versions`), get(`${API}/work-categories`)]);
|
|
102
|
+
if (!v.ok) { showErr('ga-goal-err', 'Could not load the versions. Close and reopen this panel to try again.'); return; }
|
|
103
|
+
loaded = true;
|
|
104
|
+
const versions = lib.openVersions(v.data && v.data.versions);
|
|
105
|
+
const vsel = $q('ga-version');
|
|
106
|
+
vsel.innerHTML = '<option value="">— pick a version —</option>' + versions.map((x) =>
|
|
107
|
+
`<option value="${escapeHtml(x.id)}">${escapeHtml(x.label)}</option>`).join('');
|
|
108
|
+
const cats = (c.data && c.data.categories) || [];
|
|
109
|
+
$q('ga-category').innerHTML = '<option value="">— none —</option>' + cats.map((x) =>
|
|
110
|
+
`<option value="${escapeHtml(String(x.id))}">${escapeHtml(x.label)}</option>`).join('');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Reuse is a click: the suggester's open goals for this draft, linked to their
|
|
114
|
+
// detail view. Best-effort — a failed or empty read just hides the strip.
|
|
115
|
+
async function suggest() {
|
|
116
|
+
const box = $q('ga-suggest');
|
|
117
|
+
const version = $q('ga-version').value;
|
|
118
|
+
const title = $q('ga-title').value.trim();
|
|
119
|
+
const mine = ++seq;
|
|
120
|
+
if (!version || !title) { box.hidden = true; return; }
|
|
121
|
+
const r = await get(`${API}/goals/suggestions?version=${encodeURIComponent(version)}&title=${encodeURIComponent(title)}&kind=feature&limit=5`);
|
|
122
|
+
if (mine !== seq) return;
|
|
123
|
+
const rows = (r.ok && r.data && Array.isArray(r.data.suggestions)) ? r.data.suggestions : [];
|
|
124
|
+
if (!rows.length) { box.hidden = true; box.innerHTML = ''; return; }
|
|
125
|
+
box.innerHTML = '<p class="goal-author__hint">These open goals look related — use one instead?</p><ul>' + rows.map((g) =>
|
|
126
|
+
`<li><a href="#/goal/${encodeURIComponent(String(g.id))}">${escapeHtml(g.title || `Goal ${g.id}`)}</a></li>`).join('') + '</ul>';
|
|
127
|
+
box.hidden = false;
|
|
128
|
+
}
|
|
129
|
+
const suggestSoon = () => { clearTimeout(timer); timer = setTimeout(suggest, 350); };
|
|
130
|
+
|
|
131
|
+
function setOpen(open) {
|
|
132
|
+
panel.hidden = !open;
|
|
133
|
+
openBtn.setAttribute('aria-expanded', String(open));
|
|
134
|
+
if (open) { loadChoices(); fillGoalChoices(); $q('ga-title').focus(); }
|
|
135
|
+
}
|
|
136
|
+
openBtn.addEventListener('click', () => setOpen(panel.hidden));
|
|
137
|
+
|
|
138
|
+
$q('ga-title').addEventListener('input', suggestSoon);
|
|
139
|
+
$q('ga-version').addEventListener('change', suggestSoon);
|
|
140
|
+
|
|
141
|
+
$q('ga-goal').addEventListener('submit', async (e) => {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
showErr('ga-goal-err', '');
|
|
144
|
+
const fields = {
|
|
145
|
+
version_id: $q('ga-version').value, title: $q('ga-title').value,
|
|
146
|
+
subtitle: $q('ga-subtitle').value, description: $q('ga-description').value,
|
|
147
|
+
category_id: $q('ga-category').value,
|
|
148
|
+
};
|
|
149
|
+
const problem = lib.goalFormProblem(fields);
|
|
150
|
+
if (problem) return showErr('ga-goal-err', problem);
|
|
151
|
+
const btn = $q('ga-goal-go');
|
|
152
|
+
btn.disabled = true;
|
|
153
|
+
try {
|
|
154
|
+
const res = await api.request('POST', `${API}/goals`, { body: lib.buildGoalBody(fields) });
|
|
155
|
+
if (!res.ok) return showErr('ga-goal-err', lib.refusalText(res.status, res.data));
|
|
156
|
+
const goal = (res.data && res.data.goal) || {};
|
|
157
|
+
// The board derives everything from one boot read, so reload onto the new
|
|
158
|
+
// goal (a toast would be destroyed on the same tick).
|
|
159
|
+
if (goal.id != null) window.location.hash = `#/goal/${goal.id}`;
|
|
160
|
+
window.location.reload();
|
|
161
|
+
} catch (_) {
|
|
162
|
+
showErr('ga-goal-err', 'Could not reach the server. Try again in a moment.');
|
|
163
|
+
} finally { btn.disabled = false; }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const slugNote = () => {
|
|
167
|
+
const slug = lib.criterionSlug($q('ga-crit-text').value);
|
|
168
|
+
$q('ga-crit-slug').textContent = slug ? `Filed as: ${slug}` : '';
|
|
169
|
+
};
|
|
170
|
+
$q('ga-crit-text').addEventListener('input', slugNote);
|
|
171
|
+
|
|
172
|
+
$q('ga-crit').addEventListener('submit', async (e) => {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
showErr('ga-crit-err', '');
|
|
175
|
+
const sel = $q('ga-crit-goal');
|
|
176
|
+
const opt = sel.options[sel.selectedIndex];
|
|
177
|
+
const fields = {
|
|
178
|
+
goal_id: sel.value, version_id: opt ? opt.getAttribute('data-version') : '',
|
|
179
|
+
outcome: $q('ga-crit-text').value,
|
|
180
|
+
};
|
|
181
|
+
const problem = lib.criterionFormProblem(fields);
|
|
182
|
+
if (problem) return showErr('ga-crit-err', problem);
|
|
183
|
+
const req = lib.buildCriterionRequest(fields);
|
|
184
|
+
const btn = $q('ga-crit-go');
|
|
185
|
+
btn.disabled = true;
|
|
186
|
+
try {
|
|
187
|
+
const res = await api.request('POST', `${API}${req.path}`, { body: req.body });
|
|
188
|
+
if (!res.ok) return showErr('ga-crit-err', lib.refusalText(res.status, res.data));
|
|
189
|
+
window.location.reload();
|
|
190
|
+
} catch (_) {
|
|
191
|
+
showErr('ga-crit-err', 'Could not reach the server. Try again in a moment.');
|
|
192
|
+
} finally { btn.disabled = false; }
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
window.OTBGoalAuthorUI = { mount };
|
|
197
|
+
})();
|
|
@@ -842,6 +842,9 @@
|
|
|
842
842
|
}
|
|
843
843
|
|
|
844
844
|
async function boot() {
|
|
845
|
+
// task 1003463 — authoring a goal / criterion. Optional-called: a stale cached
|
|
846
|
+
// page without goal-author.js loses the button, never the board.
|
|
847
|
+
window.OTBGoalAuthorUI?.mount({ root: $('#goal-author-mount'), api, API, toast, getGoals: () => state.goals });
|
|
845
848
|
// Kit shimmer placeholders while the goal list loads (was one "…" div).
|
|
846
849
|
const grid = $('#goal-grid');
|
|
847
850
|
if (grid) {
|
|
@@ -563,3 +563,16 @@
|
|
|
563
563
|
.gf-brief__n { flex-shrink: 0; font-size: 12px; color: var(--ink-faint); }
|
|
564
564
|
.gf-brief__conflicts { margin: 10px 0 0; font-size: 13px; color: var(--ink-soft); }
|
|
565
565
|
.gf-brief__conflicts--live { color: var(--warn); font-weight: 600; }
|
|
566
|
+
|
|
567
|
+
/* task 1003463 — the author-a-goal / add-a-criterion panel (goal-author.js). */
|
|
568
|
+
.goal-author { margin-top: 12px; }
|
|
569
|
+
.goal-author__form { display: grid; gap: 12px; max-width: 640px; }
|
|
570
|
+
.goal-author__h2 { margin-top: 20px; }
|
|
571
|
+
.goal-author__field { display: grid; gap: 4px; }
|
|
572
|
+
.goal-author__field input, .goal-author__field select, .goal-author__field textarea { width: 100%; box-sizing: border-box; }
|
|
573
|
+
.goal-author__row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
|
|
574
|
+
.goal-author__hint, .goal-author__note { color: var(--ink-soft); font-size: 12.5px; margin: 0; }
|
|
575
|
+
.goal-author__suggest ul { margin: 4px 0 0; padding-left: 18px; }
|
|
576
|
+
.goal-author__suggest li a { display: inline-block; min-height: 24px; line-height: 24px; }
|
|
577
|
+
.goal-author__err { color: var(--danger); margin: 0; }
|
|
578
|
+
.goal-author__open { margin-top: 8px; }
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
<!-- task 1003479: the goal map has no nav slot of its own (the sky's rule —
|
|
38
38
|
a map that is reachable but not load-bearing), so this is its door. -->
|
|
39
39
|
<p class="page-head__sub"><a href="/builders/goal-map">See which goals wait on each other →</a></p>
|
|
40
|
+
<!-- task 1003463: author a goal / a done-when criterion (goal-author.js). -->
|
|
41
|
+
<div id="goal-author-mount"></div>
|
|
40
42
|
</div>
|
|
41
43
|
|
|
42
44
|
<main id="hall-main">
|
|
@@ -260,6 +262,8 @@
|
|
|
260
262
|
<!-- task 1004402: the criterion UAT pills, panel and sign-off (ADR 0351). Loaded
|
|
261
263
|
BEFORE goals-render.js, which reads window.OTBCriterionUat at factory time. -->
|
|
262
264
|
<script src="/builders/criterion-uat-ui.js?v=2026-09-30-uat2"></script>
|
|
265
|
+
<script src="/builders/goal-author-lib.js"></script>
|
|
266
|
+
<script src="/builders/goal-author.js"></script>
|
|
263
267
|
<script src="/builders/goals-render.js?v=2026-09-30-uat2"></script>
|
|
264
268
|
<script src="/builders/goals-page.js?v=2026-09-30-uat2"></script>
|
|
265
269
|
</body>
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"/api/(gds|bongos)/tasks",
|
|
15
15
|
"/api/(gds|bongos)/versions",
|
|
16
16
|
"/api/(gds|bongos)/inbox/",
|
|
17
|
-
"/api/(gds|bongos)/work-categories"
|
|
17
|
+
"/api/(gds|bongos)/work-categories",
|
|
18
|
+
"/api/(gds|bongos)/goals/suggestions"
|
|
18
19
|
],
|
|
19
20
|
"ignoreConsole": [
|
|
20
21
|
"/api/(gds|bongos)/",
|
|
@@ -184,6 +185,38 @@
|
|
|
184
185
|
"#goal-completion-full .uat-form"
|
|
185
186
|
]
|
|
186
187
|
}
|
|
188
|
+
},
|
|
189
|
+
"author": {
|
|
190
|
+
"auth": true,
|
|
191
|
+
"actions": [
|
|
192
|
+
[
|
|
193
|
+
"wait",
|
|
194
|
+
600
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
"click",
|
|
198
|
+
"#ga-open"
|
|
199
|
+
],
|
|
200
|
+
[
|
|
201
|
+
"wait",
|
|
202
|
+
300
|
|
203
|
+
]
|
|
204
|
+
],
|
|
205
|
+
"expect": {
|
|
206
|
+
"visible": [
|
|
207
|
+
"#ga-panel",
|
|
208
|
+
"#ga-title",
|
|
209
|
+
"#ga-version",
|
|
210
|
+
"#ga-crit-goal"
|
|
211
|
+
],
|
|
212
|
+
"attr": [
|
|
213
|
+
[
|
|
214
|
+
"#ga-open",
|
|
215
|
+
"aria-expanded",
|
|
216
|
+
"true"
|
|
217
|
+
]
|
|
218
|
+
]
|
|
219
|
+
}
|
|
187
220
|
}
|
|
188
221
|
},
|
|
189
222
|
"reducedMotion": {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Authoring a goal and a done-when criterion — on the Goals page (task 1003463)
|
|
2
|
+
|
|
3
|
+
**The Goals page can AUTHOR now, not only satisfy (task 1003463, goal 1000089; ADR 0366 D1.2/D1.3/D2/D3).** A "New goal" button under the page head opens one panel with two forms. Pure half `goal-author-lib.js` (`window.OTBGoalAuthor`), DOM half `goal-author.js` (`window.OTBGoalAuthorUI`, mounted from `goals-page.js` behind an optional call so a stale cached page loses the button, never the board).
|
|
4
|
+
|
|
5
|
+
- **Goal form = name + subtitle + description + version + category, and NO scope field.** It always sends `spec_task: true` (task 1004585): the goal arrives with a "Spec goal N" task that gates its other work and the scope is set when that task is done. The name and subtitle are separate boxes — the goal board's convention (goal 1000072) is that a title does not carry its own explanation.
|
|
6
|
+
- **Reuse is a click.** As the name is typed (350ms debounce, stale answers dropped) the suggester's open goals for that version (`GET /goals/suggestions`) are linked to their detail view above the submit button. Best-effort: a failed read hides the strip.
|
|
7
|
+
- **Criterion form = outcome text; the slug is derived from it** and shown ("Filed as: …"). A slug shaped like a position (C1…) is prefixed, because the server refuses those. The goal's own version rides in the path (`POST /versions/:id/done-when`).
|
|
8
|
+
- **The two authority walls are the server's and the form does not soften them.** It offers every open version and every open goal, sends what was typed, and prints the server's own refusal sentence (`refusalText` only fills in a bare code). Pre-filtering "versions a Metic cannot use" or "goals you do not manage" would turn a clear refusal into a form that quietly lacks the option — the reason ADR 0154's mask was removed in task 1002614. `tests/goal_author_ui.mjs` drives the form's own request through the real goals and done-when routers: a Metic's protected-scope goal is still 403 `archon_required_for_protected_scope`, a non-member's criterion is still 403 `goal_not_yours`, a manager's succeeds.
|
|
9
|
+
- **Deliberately absent (ADR 0366 D2, interview-shaped):** version planning, scope inference, drafting criteria or seed tasks, reuse judgement, carry-forward, and the Archon admission into a building version. The test asserts the UI source names none of `scope_modules`, `succeeds_goal_id`, `admit_reason`.
|
|
10
|
+
- A successful create reloads the page (to `#/goal/N` for a goal) rather than patching `state.goals` by hand: the board's rollups, rings and filters all derive from one boot read.
|
|
@@ -47,8 +47,8 @@ const SETUP_EVENTS = Object.freeze({
|
|
|
47
47
|
// the founder pressed "Pick up where it stopped" or "Check again" (task 1004652). Like an
|
|
48
48
|
// install report it re-opens the runner's checking window; it claims nothing itself.
|
|
49
49
|
checkRequested: 'github-app-check-requested',
|
|
50
|
-
// the liveness sweep saw the project's own /healthz answer auth_configured: true
|
|
51
|
-
// 1004410). Recorded once per attempt (recordSignInSeen); no detail.
|
|
50
|
+
// the liveness sweep saw the project's own /api/bongos/healthz answer auth_configured: true
|
|
51
|
+
// (task 1004410). Recorded once per attempt (recordSignInSeen); no detail.
|
|
52
52
|
signInSeen: 'github-sign-in-seen',
|
|
53
53
|
});
|
|
54
54
|
|
|
@@ -164,13 +164,20 @@ function probeReason(err) {
|
|
|
164
164
|
return err.message ? String(err.message).slice(0, 40) : 'error';
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// The
|
|
167
|
+
// The health answer is tiny; read no more than this of a body a builder's host chose.
|
|
168
168
|
const BODY_CAP_BYTES = 4096;
|
|
169
169
|
|
|
170
|
+
// auth_configured lives on the JSON health answer; the root /healthz says a plain "ok" and
|
|
171
|
+
// stays the up/down read, so a core without this route never reads as down (task 1004743).
|
|
172
|
+
const AUTH_HEALTHZ_PATH = '/api/bongos/healthz';
|
|
173
|
+
|
|
174
|
+
const is2xx = (res) => res.status >= 200 && res.status < 300;
|
|
175
|
+
|
|
170
176
|
// Did the answer say auth_configured: true (task 1004410)? Reads at most BODY_CAP_BYTES
|
|
171
|
-
// and answers false for anything else — a big, slow or non-JSON body is never "configured".
|
|
177
|
+
// and answers false for anything else — a non-2xx, big, slow or non-JSON body is never "configured".
|
|
172
178
|
async function readAuthConfigured(res) {
|
|
173
179
|
try {
|
|
180
|
+
if (!is2xx(res)) return false;
|
|
174
181
|
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
175
182
|
return typeof res.json === 'function' ? (await res.json()).auth_configured === true : false;
|
|
176
183
|
}
|
|
@@ -192,22 +199,30 @@ async function readAuthConfigured(res) {
|
|
|
192
199
|
|
|
193
200
|
// Knock once. Any 2xx = up; everything else (non-2xx, refused, DNS, TLS, timeout)
|
|
194
201
|
// = down with a reason. Never throws — a probe result is the whole point. An up answer
|
|
195
|
-
// also carries whether the project says its sign-in is configured (task 1004410)
|
|
202
|
+
// also carries whether the project says its sign-in is configured (task 1004410), asked
|
|
203
|
+
// on the same origin through the same pin and time budget; that read failing is only "no".
|
|
196
204
|
async function probeHealthz(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl, pin } = {}) {
|
|
197
205
|
const doFetch = fetchImpl || globalThis.fetch;
|
|
198
206
|
const dispatcher = pin && !fetchImpl ? pinnedDispatcher(pin, timeoutMs) : null;
|
|
207
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
208
|
+
const get = async (target) => doFetch(target, {
|
|
209
|
+
method: 'GET',
|
|
210
|
+
// manual: a redirect must never re-aim the probe (SSRF pivot) — and a
|
|
211
|
+
// healthz that redirects isn't answering anyway.
|
|
212
|
+
redirect: 'manual',
|
|
213
|
+
signal,
|
|
214
|
+
headers: { accept: 'application/json, text/plain;q=0.9, */*;q=0.8' },
|
|
215
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
216
|
+
});
|
|
199
217
|
try {
|
|
200
|
-
const res = await
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
});
|
|
209
|
-
if (res.status >= 200 && res.status < 300) return { up: true, authConfigured: await readAuthConfigured(res) };
|
|
210
|
-
return { up: false, reason: `http ${res.status}` };
|
|
218
|
+
const res = await get(url);
|
|
219
|
+
if (!is2xx(res)) return { up: false, reason: `http ${res.status}` };
|
|
220
|
+
if (res.body && typeof res.body.cancel === 'function') res.body.cancel().catch(() => {});
|
|
221
|
+
// An https answer with no pin reached an address the gate never vetted; ask it nothing more.
|
|
222
|
+
const askAuth = !!pin || !url.startsWith('https://');
|
|
223
|
+
const authConfigured = askAuth
|
|
224
|
+
&& await get(new URL(AUTH_HEALTHZ_PATH, url).href).then(readAuthConfigured, () => false);
|
|
225
|
+
return { up: true, authConfigured };
|
|
211
226
|
} catch (e) {
|
|
212
227
|
return { up: false, reason: probeReason(e) };
|
|
213
228
|
} finally {
|
|
@@ -115,7 +115,7 @@ console.log('\nsweepOnce (real loopback probes, recording fake db):');
|
|
|
115
115
|
|
|
116
116
|
await ta('an answering instance is UP and a refusing one is DOWN — both columns written, status never touched', async () => {
|
|
117
117
|
const server = http.createServer((req, res) => {
|
|
118
|
-
if (req.url === '/healthz') { res.writeHead(200, { 'content-type': '
|
|
118
|
+
if (req.url === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); res.end('ok'); return; }
|
|
119
119
|
res.writeHead(404); res.end();
|
|
120
120
|
});
|
|
121
121
|
await listen(server);
|
|
@@ -322,15 +322,17 @@ await ta('a rebinding nameserver cannot re-aim the probe — the pinned address
|
|
|
322
322
|
|
|
323
323
|
await ta('the pin is load-bearing on the REAL client: same URL, the pinned address decides where it lands', async () => {
|
|
324
324
|
const server = http.createServer((req, res) => {
|
|
325
|
-
if (req.url === '/healthz') { res.writeHead(200); res.end('
|
|
325
|
+
if (req.url === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); res.end('ok'); return; }
|
|
326
|
+
if (req.url === '/api/bongos/healthz') { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true,"auth_configured":true}'); return; }
|
|
326
327
|
res.writeHead(404); res.end();
|
|
327
328
|
});
|
|
328
329
|
await listen(server);
|
|
329
330
|
const url = `http://pinned.invalid:${server.address().port}/healthz`;
|
|
330
331
|
try {
|
|
331
|
-
// 'pinned.invalid' resolves nowhere; only the pin can make this connect
|
|
332
|
+
// 'pinned.invalid' resolves nowhere; only the pin can make this connect — for the sign-in
|
|
333
|
+
// read (task 1004743) as much as for the up/down one.
|
|
332
334
|
const hit = await liveness.probeHealthz(url, { timeoutMs: 3000, pin: { address: '127.0.0.1', family: 4 } });
|
|
333
|
-
assert.deepEqual(hit, { up: true, authConfigured:
|
|
335
|
+
assert.deepEqual(hit, { up: true, authConfigured: true }, 'the pinned address is what both reads connect to');
|
|
334
336
|
const miss = await liveness.probeHealthz(url, { timeoutMs: 3000, pin: { address: '127.0.0.2', family: 4 } });
|
|
335
337
|
assert.equal(miss.up, false, 'a different pin cannot reach the same server — the pin, not DNS, decides');
|
|
336
338
|
} finally {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.133",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.133",
|
|
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.
|
|
3
|
+
"version": "1.21.133",
|
|
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/release-notes.json
CHANGED
|
@@ -9788,5 +9788,17 @@
|
|
|
9788
9788
|
"id": "1004577",
|
|
9789
9789
|
"text": "Two projects whose GitHub sign-in actually worked no longer show it as failed; their setup checklists now point at the real next step."
|
|
9790
9790
|
}
|
|
9791
|
+
],
|
|
9792
|
+
"1.21.132": [
|
|
9793
|
+
{
|
|
9794
|
+
"id": "1004743",
|
|
9795
|
+
"text": "A project whose GitHub sign-in already works now gets that step ticked on its setup checklist automatically, within about five minutes."
|
|
9796
|
+
}
|
|
9797
|
+
],
|
|
9798
|
+
"1.21.133": [
|
|
9799
|
+
{
|
|
9800
|
+
"id": "1003463",
|
|
9801
|
+
"text": "The Goals page can now create a goal and add a done-when criterion to it."
|
|
9802
|
+
}
|
|
9791
9803
|
]
|
|
9792
9804
|
}
|
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.
|
|
78
|
+
const CORE_VERSION = '1.21.133'; // 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');
|