@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.
@@ -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 (task
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 /healthz answer is tiny; read no more than this of a body a builder's host chose.
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 doFetch(url, {
201
- method: 'GET',
202
- // manual: a redirect must never re-aim the probe (SSRF pivot) — and a
203
- // healthz that redirects isn't answering anyway.
204
- redirect: 'manual',
205
- signal: AbortSignal.timeout(timeoutMs),
206
- headers: { accept: 'application/json, text/plain;q=0.9, */*;q=0.8' },
207
- ...(dispatcher ? { dispatcher } : {}),
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': 'application/json' }); res.end('{"ok":true}'); return; }
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('{"ok":true}'); return; }
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: false }, 'the pinned address is what gets connected');
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.131",
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.131",
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.131",
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",
@@ -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.131'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');