@bongos/core 1.19.332 → 1.19.334

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.bongos-core.json +173 -43
  2. package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/api/openapi.json +2 -2
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +202 -202
  7. package/docs/copy-registry.json +384 -384
  8. package/modules/grading/grader-config.js +98 -0
  9. package/modules/grading/grader-model.js +150 -0
  10. package/modules/grading/grader-prompt.js +139 -0
  11. package/modules/grading/grader-score.js +706 -0
  12. package/modules/grading/grader-subagent.js +328 -0
  13. package/modules/grading/grader.js +5 -1287
  14. package/modules/hall-ui/public/builders.js +19 -1182
  15. package/modules/hall-ui/public/goals-page.js +11 -997
  16. package/modules/hall-ui/public/goals-render.js +1032 -0
  17. package/modules/hall-ui/public/goals.html +4 -0
  18. package/modules/hall-ui/public/hall-render.js +1211 -0
  19. package/modules/hall-ui/public/index.html +6 -0
  20. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  21. package/modules/lifecycle/routes/goals.js +1 -367
  22. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  23. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  24. package/modules/lifecycle/routes/tasks.js +3 -696
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/bongos/routes/auth.js +54 -10
  46. package/src/module-api.js +5 -1
  47. package/tests/auth_admission_gate.mjs +3 -1
  48. package/tests/auth_web_recovery.mjs +63 -0
  49. package/tests/est_advisory.mjs +3 -1
  50. package/tests/goal_advisory.mjs +3 -2
  51. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  52. package/tests/goal_subtitle.mjs +5 -2
  53. package/tests/goals_card_view.mjs +10 -3
  54. package/tests/grader_generated_provenance.mjs +3 -1
  55. package/tests/hall_palette.mjs +3 -1
  56. package/tests/helpers.mjs +58 -25
  57. package/tests/lib_sh_resolution.mjs +3 -1
  58. package/tests/membership_kind_lockstep.mjs +3 -1
  59. package/tests/migration_allocation.mjs +5 -2
  60. package/tests/patch_gate_ordering.mjs +3 -1
  61. package/tests/rank_tier_single_source.mjs +16 -3
  62. package/tests/reward_gate.mjs +3 -2
@@ -21,6 +21,9 @@ function escapeHtml(s) {
21
21
  // subtitle — replaces the default header sub-line
22
22
  // cta — { href, label } for the primary button (default: retry sign-in)
23
23
  // note — replaces the default troubleshooting footnote
24
+ // startHref — the retry sign-in URL (default: a bare /auth/web/start); the
25
+ // callback passes recoveryStartHref(req) so the founder's return
26
+ // target + repo-scope opt-in survive the error (task 1003234)
24
27
  // The instance's own word for the admitting rank, for per-branch error strings
25
28
  // (task 1002616, the 1439 follow-up): core error surfaces never hardcode
26
29
  // "Archon" — a non-OTB instance names its own role (branding rankLabels.archon;
@@ -51,7 +54,7 @@ function renderAuthErrorPage(heading, detail, opts = {}) {
51
54
  catch { return AUTH_ERROR_FALLBACK; }
52
55
  })();
53
56
  const subtitle = opts.subtitle || copy.subtitle;
54
- const cta = opts.cta || { href: '/api/gds/auth/web/start', label: copy.ctaLabel };
57
+ const cta = opts.cta || { href: opts.startHref || '/api/gds/auth/web/start', label: copy.ctaLabel };
55
58
  const note = opts.note || "If this keeps happening, the GitHub OAuth app's authorized callback URL may be misconfigured. See docs/recipes/ops-gotchas.md (GitHub OAuth section).";
56
59
  // The instance name in the tab title comes from the branding pack, never a
57
60
  // hardcoded host brand — a non-OTB instance sees its own name (ADR 0062 §3).
@@ -100,7 +103,7 @@ function renderAuthErrorPage(heading, detail, opts = {}) {
100
103
  //
101
104
  // Branding resolves defensively, exactly as renderAuthErrorPage does — a
102
105
  // branding hiccup must degrade to neutral copy, never crash the page.
103
- function renderAccessPendingPage(login) {
106
+ function renderAccessPendingPage(login, startHref) {
104
107
  const worldName = (() => { try { return branding().identity?.worldName || 'The city'; } catch { return 'The city'; } })();
105
108
  const epigraph = (() => { try { return branding().copy?.authError?.epigraph || '— access —'; } catch { return '— access —'; } })();
106
109
  // The probe takes the login as a query param; everything interpolated into
@@ -124,7 +127,7 @@ function renderAccessPendingPage(login) {
124
127
  </header>
125
128
  <main>
126
129
  <section class="scroll">
127
- <div class="landing" id="pending" data-login="${safeLogin}">
130
+ <div class="landing" id="pending" data-login="${safeLogin}" data-start="${escapeHtml(startHref || '/api/gds/auth/web/start')}">
128
131
  <p>We're checking your access now. You'll be signed in automatically as soon as it clears — no need to refresh.</p>
129
132
  <p class="landing__note" id="pending-note">Checking…</p>
130
133
  </div>
@@ -135,11 +138,14 @@ function renderAccessPendingPage(login) {
135
138
  var el = document.getElementById('pending');
136
139
  var note = document.getElementById('pending-note');
137
140
  var login = el ? el.getAttribute('data-login') : '';
141
+ /* server-built from the still-valid handshake cookies (task 1003234): the
142
+ fresh sign-in keeps the wizard return + repo scope a founder started with */
143
+ var start = (el && el.getAttribute('data-start')) || '/api/gds/auth/web/start';
138
144
  var EVERY_MS = 3000;
139
145
  var GIVE_UP_MS = 5 * 60 * 1000;
140
146
  var started = Date.now();
141
147
 
142
- function land() { window.location.href = '/api/gds/auth/web/start'; }
148
+ function land() { window.location.href = start; }
143
149
 
144
150
  function tick() {
145
151
  if (Date.now() - started > GIVE_UP_MS) {
@@ -180,6 +186,18 @@ const RETURN_COOKIE = 'gds_return';
180
186
  // include repo") means an ordinary hall sign-in never stores a token, even for a
181
187
  // user who previously granted the broader scope (task 1002099 / ADR 0143).
182
188
  const REPOS_COOKIE = 'gds_oauth_repos';
189
+ // How long the three /auth/web/start handshake cookies live — the CSRF state,
190
+ // the return target and the repo-scope opt-in — and they MUST move together:
191
+ // a state that outlives its return cookie lands a founder on the default
192
+ // landing with no repo scope, and a return that outlives its state lands them
193
+ // on "State mismatch". They were 10 minutes each, the hall builder's
194
+ // timescale; a founder's first sign-in can include a GitHub SIGNUP (email
195
+ // verification and all) and came back past 10 minutes with all three gone
196
+ // (walk finding, task 1003234). 30 minutes is a security-posture change,
197
+ // deliberately modest: the state is still single-use (the callback clears it),
198
+ // still httpOnly + sameSite=lax, and the code GitHub hands back is still the
199
+ // bearer — only the window in which a legitimate handshake may complete grew.
200
+ const HANDSHAKE_COOKIE_MS = 30 * 60 * 1000;
183
201
 
184
202
  // The apex domain the instance is served under, read from the instance branding
185
203
  // pack's cookieDomain (".amazonprimea.com" → "amazonprimea.com",
@@ -253,6 +271,21 @@ function safeReturnTo(raw) {
253
271
  return null;
254
272
  }
255
273
 
274
+ // The sign-in-again door on every OAuth error page (and the pending page's
275
+ // auto-walk-in), rebuilt from the handshake cookies that are still valid: the
276
+ // return target and the repo-scope opt-in that /auth/web/start stashed. A bare
277
+ // /auth/web/start dropped both, so a founder who hit "State mismatch" from the
278
+ // wizard came back with no repository access and landed on the default
279
+ // landing (task 1003234). Same open-redirect allowlist as the callback.
280
+ function recoveryStartHref(req) {
281
+ const params = new URLSearchParams();
282
+ const returnTo = safeReturnTo(parseCookie(req, RETURN_COOKIE));
283
+ if (returnTo) params.set('return', returnTo);
284
+ if (parseCookie(req, REPOS_COOKIE) === '1') params.set('repos', '1');
285
+ const q = params.toString();
286
+ return '/api/gds/auth/web/start' + (q ? '?' + q : '');
287
+ }
288
+
256
289
  // The canonical sign-in origin, read from the instance branding pack (oauthOrigin,
257
290
  // else publicOrigin) so a non-OTB Cloud Bongos instance pins its OAuth redirects to
258
291
  // ITS OWN apex instead of a hardcoded "amazonprimea.com" (ADR 0062 §3/§5; the
@@ -422,7 +455,7 @@ module.exports = function buildAuthRouter() {
422
455
  httpOnly: true,
423
456
  sameSite: 'lax',
424
457
  secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
425
- maxAge: 10 * 60 * 1000,
458
+ maxAge: HANDSHAKE_COOKIE_MS,
426
459
  // Apex-scoped (#734) so the state survives the host switch: start may run
427
460
  // on the subdomain but the callback always lands on the apex.
428
461
  ...sessionCookieDomainOpts(req),
@@ -438,7 +471,7 @@ module.exports = function buildAuthRouter() {
438
471
  httpOnly: true,
439
472
  sameSite: 'lax',
440
473
  secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
441
- maxAge: 10 * 60 * 1000,
474
+ maxAge: HANDSHAKE_COOKIE_MS,
442
475
  ...sessionCookieDomainOpts(req),
443
476
  });
444
477
  }
@@ -453,7 +486,7 @@ module.exports = function buildAuthRouter() {
453
486
  httpOnly: true,
454
487
  sameSite: 'lax',
455
488
  secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
456
- maxAge: 10 * 60 * 1000,
489
+ maxAge: HANDSHAKE_COOKIE_MS,
457
490
  ...sessionCookieDomainOpts(req),
458
491
  });
459
492
  }
@@ -519,12 +552,14 @@ module.exports = function buildAuthRouter() {
519
552
  return res.status(400).type('text/html').send(renderAuthErrorPage(
520
553
  'GitHub refused the handshake',
521
554
  `GitHub returned the error code "${error}". This usually means the OAuth app's authorized callback URL doesn't include this server's /api/gds/auth/web/callback path.`,
555
+ { startHref: recoveryStartHref(req) },
522
556
  ));
523
557
  }
524
558
  if (!code || !state || state !== expectedState) {
525
559
  return res.status(400).type('text/html').send(renderAuthErrorPage(
526
560
  'State mismatch',
527
- 'The handshake-cookie did not match the state GitHub returned. This usually means the sign-in took too long (cookie expired after 10 minutes) or the request crossed an origin boundary. Start over and sign in again.',
561
+ `The handshake-cookie did not match the state GitHub returned. This usually means the sign-in took too long (the cookie expires after ${HANDSHAKE_COOKIE_MS / 60000} minutes) or the request crossed an origin boundary. Start over and sign in again.`,
562
+ { startHref: recoveryStartHref(req) },
528
563
  ));
529
564
  }
530
565
  try {
@@ -604,15 +639,21 @@ module.exports = function buildAuthRouter() {
604
639
  console.error('[gds] web/callback admission classify', probeErr);
605
640
  }
606
641
  if (expectedSoon) {
607
- return res.status(202).type('text/html').send(renderAccessPendingPage(err.login));
642
+ return res.status(202).type('text/html').send(renderAccessPendingPage(err.login, recoveryStartHref(req)));
608
643
  }
644
+ // The founder-shaped branch (only reachable with open enrollment OFF):
645
+ // a stranger who was on their way to create a project is told the
646
+ // approval brings them back to it — the return cookie says where.
647
+ const founderReturn = safeReturnTo(parseCookie(req, RETURN_COOKIE)) || '';
648
+ const onWayToProject = founderReturn.startsWith('/projects');
609
649
  return res.status(403).type('text/html').send(renderAuthErrorPage(
610
650
  'This account is not approved yet',
611
651
  `Signing in with GitHub is only for builders the ${admitterLabel()} has already approved. If you are new, request access at the builders’ hall — once the ${admitterLabel()} approves you, this sign-in will let you in. If you were just approved, it may take a moment to be marked invited; try again shortly.`,
612
652
  {
613
653
  subtitle: 'Sign-in is for approved builders. New here? Request access first.',
614
654
  cta: { href: '/builders', label: 'Go to the hall & request access' },
615
- note: `Already approved and still blocked? Ask the ${admitterLabel()} to confirm your request was marked “invited.”`,
655
+ note: (onWayToProject ? 'You were on your way to creating a project — once approved, sign in again and you land back in it. ' : '') +
656
+ `Already approved and still blocked? Ask the ${admitterLabel()} to confirm your request was marked “invited.”`,
616
657
  },
617
658
  ));
618
659
  }
@@ -620,6 +661,7 @@ module.exports = function buildAuthRouter() {
620
661
  res.status(500).type('text/html').send(renderAuthErrorPage(
621
662
  'The handshake threw an error',
622
663
  `The server tried to exchange the auth code for a session and something went wrong: ${err.message}. Try again; if it persists, ask the ${admitterLabel()} to investigate the server logs.`,
664
+ { startHref: recoveryStartHref(req) },
623
665
  ));
624
666
  }
625
667
  });
@@ -1080,6 +1122,8 @@ module.exports = function buildAuthRouter() {
1080
1122
  // cookie-domain decision — worth locking down independently of a running server.
1081
1123
  module.exports.safeReturnTo = safeReturnTo;
1082
1124
  module.exports.postLoginLanding = postLoginLanding;
1125
+ module.exports.recoveryStartHref = recoveryStartHref;
1126
+ module.exports.HANDSHAKE_COOKIE_MS = HANDSHAKE_COOKIE_MS;
1083
1127
  module.exports.sessionCookieDomainOpts = sessionCookieDomainOpts;
1084
1128
  module.exports.reqHost = reqHost;
1085
1129
  // The auth-error page renderer (task 1439): pure over the branding pack, so a
package/src/module-api.js CHANGED
@@ -1091,8 +1091,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1091
1091
  // landed since 1.19.330 with no explicit bump. run 32791877167. (task 1002620)
1092
1092
  // 1.19.332 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1093
1093
  // landed since 1.19.331 with no explicit bump. run 32793033453. (task 1002620)
1094
+ // 1.19.333 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1095
+ // landed since 1.19.332 with no explicit bump. run 32793666555. (task 1002620)
1096
+ // 1.19.334 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1097
+ // landed since 1.19.333 with no explicit bump. run 32794305277. (task 1002620)
1094
1098
  // ---------------------------------------------------------------------------
1095
- const CORE_VERSION = '1.19.332'; // CI auto-patch carrier (ADR 0161); see changelog above
1099
+ const CORE_VERSION = '1.19.334'; // CI auto-patch carrier (ADR 0161); see changelog above
1096
1100
 
1097
1101
  // A namespaced logger so a module's log lines are attributable + consistent.
1098
1102
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -13,6 +13,7 @@
13
13
  import { strict as assert } from 'node:assert';
14
14
  import { createRequire } from 'node:module';
15
15
  import { readFileSync } from 'node:fs';
16
+ import { authSource } from './helpers.mjs';
16
17
 
17
18
  const require = createRequire(import.meta.url);
18
19
  const auth = require('../src/bongos/auth.js');
@@ -150,7 +151,8 @@ t('no login passed (legacy callers) behaves exactly as before — BLOCKED', () =
150
151
  console.log('\nfirstTimeAdmissionBlocked — every call-site passes `login` (SR-19 non-drift, #1970):');
151
152
 
152
153
  t('completeWebFlow + pollDeviceFlow both pass `login` to the gate', () => {
153
- const src = readFileSync(new URL('../src/bongos/auth.js', import.meta.url), 'utf8');
154
+ // task 1003206: the auth flows are auth.js + auth-*.js now — read the family.
155
+ const src = authSource();
154
156
  const re = /firstTimeAdmissionBlocked\s*\(\s*\{[^}]*\}/g;
155
157
  const callSites = [];
156
158
  for (let m = re.exec(src); m; m = re.exec(src)) {
@@ -0,0 +1,63 @@
1
+ // tests/auth_web_recovery.mjs — founder-shaped OAuth recovery (task 1003234,
2
+ // goal 1000058). The walk found that every OAuth error page's "try again" door
3
+ // was a bare /auth/web/start — dropping the wizard's return target and the
4
+ // repo-scope opt-in — and that the three handshake cookies expired at 10
5
+ // minutes, i.e. mid GitHub signup. Both are pinned here: the pure helper by
6
+ // value, the call sites and the shared lifetime by reading the source.
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { test } from 'node:test';
10
+ import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const authRoutes = require(path.join(ROOT, 'src/bongos/routes/auth.js'));
18
+ const { recoveryStartHref, HANDSHAKE_COOKIE_MS } = authRoutes;
19
+ const SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/auth.js'), 'utf8').replace(/\r\n/g, '\n');
20
+ const req = (cookie) => ({ headers: cookie ? { cookie } : {} });
21
+
22
+ test('the retry door carries the wizard return and the repo-scope opt-in while their cookies are valid', () => {
23
+ assert.equal(recoveryStartHref(req('gds_return=%2Fprojects%3Fview%3Dnew; gds_oauth_repos=1')),
24
+ '/api/gds/auth/web/start?return=%2Fprojects%3Fview%3Dnew&repos=1');
25
+ assert.equal(recoveryStartHref(req('gds_return=%2Fprojects%3Fview%3Dnew')),
26
+ '/api/gds/auth/web/start?return=%2Fprojects%3Fview%3Dnew', 'a hall sign-in never asks for repos');
27
+ assert.equal(recoveryStartHref(req('gds_oauth_repos=1')), '/api/gds/auth/web/start?repos=1');
28
+ });
29
+
30
+ test('with nothing left to rebuild from, the door is the bare start — never a guess', () => {
31
+ assert.equal(recoveryStartHref(req('')), '/api/gds/auth/web/start');
32
+ assert.equal(recoveryStartHref(req('gds_session=abc')), '/api/gds/auth/web/start');
33
+ assert.equal(recoveryStartHref(req('gds_oauth_repos=0')), '/api/gds/auth/web/start', 'only the literal opt-in counts');
34
+ });
35
+
36
+ test('the door is under the same open-redirect allowlist as the callback', () => {
37
+ assert.equal(recoveryStartHref(req('gds_return=https%3A%2F%2Fevil.example%2Fx')), '/api/gds/auth/web/start');
38
+ assert.equal(recoveryStartHref(req('gds_return=%2F%2Fevil.example')), '/api/gds/auth/web/start');
39
+ assert.equal(recoveryStartHref(req('gds_return=javascript%3Aalert(1)')), '/api/gds/auth/web/start');
40
+ });
41
+
42
+ test('every OAuth error page and the pending page rebuild their door from the cookies', () => {
43
+ const start = SRC.indexOf("router.get('/auth/web/callback'");
44
+ const end = SRC.indexOf("router.get('/auth/discord/start'");
45
+ const callback = SRC.slice(start, end);
46
+ assert.equal((callback.match(/\{ startHref: recoveryStartHref\(req\) \}/g) || []).length, 3,
47
+ 'GitHub refused · State mismatch · handshake threw — three pages, three doors');
48
+ assert.match(callback, /renderAccessPendingPage\(err\.login, recoveryStartHref\(req\)\)/, 'the auto-walk-in lands where the founder was going');
49
+ assert.match(callback, /const onWayToProject = founderReturn\.startsWith\('\/projects'\);/, 'the founder-shaped NOT_APPROVED note');
50
+ assert.match(SRC, /const cta = opts\.cta \|\| \{ href: opts\.startHref \|\| '\/api\/gds\/auth\/web\/start', label: copy\.ctaLabel \};/);
51
+ assert.match(SRC, /data-start="\$\{escapeHtml\(startHref \|\| '\/api\/gds\/auth\/web\/start'\)\}"/, 'the pending page reads its start from a data attribute, never string-built into script');
52
+ assert.match(SRC, /function land\(\) \{ window\.location\.href = start; \}/);
53
+ });
54
+
55
+ test('the three handshake cookies share one lifetime, long enough for a GitHub signup', () => {
56
+ assert.equal(HANDSHAKE_COOKIE_MS, 30 * 60 * 1000);
57
+ const start = SRC.indexOf("router.get('/auth/web/start'");
58
+ const end = SRC.indexOf("router.get('/auth/web/admission-status'");
59
+ const block = SRC.slice(start, end);
60
+ assert.equal((block.match(/maxAge: HANDSHAKE_COOKIE_MS,/g) || []).length, 3, 'state · return · repos');
61
+ assert.doesNotMatch(block, /maxAge: 10 \* 60 \* 1000/, 'no handshake cookie is left on the old clock');
62
+ assert.match(SRC, /the cookie expires after \$\{HANDSHAKE_COOKIE_MS \/ 60000\} minutes/, 'the State-mismatch copy reads the same constant');
63
+ });
@@ -30,6 +30,7 @@
30
30
  import { strict as assert } from 'node:assert';
31
31
  import fs from 'node:fs';
32
32
  import { createRequire } from 'node:module';
33
+ import { taskRoutesSource } from './helpers.mjs';
33
34
 
34
35
  const require = createRequire(import.meta.url);
35
36
  const { adviseEstimate, pickRatio, buildEstAdvisory, MIN_MATERIAL_DELTA, SCALE_OK_RATIO } =
@@ -171,7 +172,8 @@ await t('the route attaches est_advisory to the 201 body, non-blocking', () => {
171
172
  // literal because modules/lifecycle/routes/tasks.js is over the size ratchet's
172
173
  // cap and main sits exactly AT the baseline — so this feature had to land at
173
174
  // net-zero lines in that file. Asserting the shape keeps the fold honest.
174
- const src = fs.readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
175
+ // task 1003206: the task router is tasks.js + task-*.js now — read the family.
176
+ const src = taskRoutesSource();
175
177
  const body201 = (src.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
176
178
  assert.ok(body201, 'found the 201 response body');
177
179
  assert.match(body201, /est_advisory:/, 'est_advisory rides the create response');
@@ -17,14 +17,15 @@ import { strict as assert } from 'node:assert';
17
17
  import { createRequire } from 'node:module';
18
18
  import { readFileSync } from 'node:fs';
19
19
  import http from 'node:http';
20
- import { makeRunner, lifecycleDbSource } from './helpers.mjs';
20
+ import { makeRunner, lifecycleDbSource, taskRoutesSource } from './helpers.mjs';
21
21
 
22
22
  process.env.NODE_ENV = 'test';
23
23
 
24
24
  const require = createRequire(import.meta.url);
25
25
  const { adviseGoal, candidateGoals, generalGoalTitle, buildGoalAdvisory } = require('../modules/lifecycle/goal-advisory.js');
26
26
 
27
- const tasksSrc = readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
27
+ // task 1003206: the task router is tasks.js + task-*.js now — read the family.
28
+ const tasksSrc = taskRoutesSource();
28
29
  // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
29
30
  const dbSrc = lifecycleDbSource();
30
31
  const { test, summary } = makeRunner();
@@ -40,6 +40,11 @@ const TASK_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/tas
40
40
  // task 1003135: the page's pure presentational helpers moved to goals-lib.js
41
41
  // (window.OTBGoalsLib) — run the REAL file too, same reasoning as task-lib above.
42
42
  const GOALS_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-lib.js'), 'utf8');
43
+ // task 1003206: the page's view painters moved to goals-render.js
44
+ // (window.OTBGoalsRender) — the second half of the same carve. Run the REAL file
45
+ // too, for the same reason: this is the only test that executes either board, so
46
+ // stubbing the renderers would let them rot unwatched.
47
+ const GOALS_RENDER_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-render.js'), 'utf8');
43
48
 
44
49
  const { test, summary } = makeRunner();
45
50
 
@@ -136,6 +141,7 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
136
141
  vm.createContext(sandbox);
137
142
  vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
138
143
  vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
144
+ vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
139
145
  vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
140
146
 
141
147
  // Let boot()'s promise chain settle (me → goals → details → route → render).
@@ -18,6 +18,7 @@ import { createRequire } from 'node:module';
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { goalsPageSource } from './helpers.mjs';
21
22
 
22
23
  const require = createRequire(import.meta.url);
23
24
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -179,9 +180,11 @@ test('an unset subtitle is hidden; a set one is shown trimmed', () => {
179
180
  });
180
181
 
181
182
  test('on a LIST the subtitle is a tooltip, never a second visible line', () => {
182
- const js = read('modules', 'hall-ui', 'public', 'goals-page.js');
183
+ // task 1003206: the row/card painters moved to goals-render.js — the second half
184
+ // of the carve goals-lib.js began — so read the family, not one filename.
185
+ const js = goalsPageSource();
183
186
  // The helper lives in goals-lib.js — the file that exists because
184
- // goals-page.js is over the excess-lines ratchet (task 1003135).
187
+ // goals-page.js was over the excess-lines ratchet (task 1003135).
185
188
  const lib = read('modules', 'hall-ui', 'public', 'goals-lib.js');
186
189
  assert.match(lib, /function subtitleAttr/);
187
190
  assert.match(lib, /escapeHtml\(t\)/, 'escaped, since it lands in an attribute');
@@ -12,10 +12,11 @@ import { strict as assert } from 'node:assert';
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { makeRunner } from './helpers.mjs';
15
+ import { makeRunner, goalsPageSource } from './helpers.mjs';
16
16
 
17
17
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
- const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-page.js'), 'utf8');
18
+ // task 1003206: the view painters moved to goals-render.js — read the family.
19
+ const SRC = goalsPageSource();
19
20
  const CSS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals.css'), 'utf8');
20
21
  const { test, summary } = makeRunner();
21
22
 
@@ -28,7 +29,13 @@ await test('both views render from ONE fact source (goalRenderFacts) so they can
28
29
  });
29
30
 
30
31
  await test('renderList forks on the f-goal-view toggle AFTER grouping/filter/sort (both views share them)', () => {
31
- const body = SRC.slice(SRC.indexOf('function renderList'), SRC.indexOf('drag-to-reorder: RETIRED'));
32
+ // task 1003206: renderList moved to goals-render.js while the 'drag-to-reorder:
33
+ // RETIRED' marker that used to bound it stayed in goals-page.js, so slicing
34
+ // between the two now spans two files (or inverts). Bound the body by the next
35
+ // top-level function instead — that IS what 'renderList's body' means.
36
+ const start = SRC.indexOf('function renderList');
37
+ const nextFn = SRC.indexOf('\n function ', start + 1);
38
+ const body = SRC.slice(start, nextFn > start ? nextFn : undefined);
32
39
  assert.match(body, /#f-goal-view/, 'the fork reads the toggle');
33
40
  const sortIdx = body.indexOf('sortGoals(');
34
41
  const forkIdx = body.indexOf('#f-goal-view');
@@ -36,6 +36,7 @@ import { createRequire } from 'node:module';
36
36
  import fs from 'node:fs';
37
37
  import path from 'node:path';
38
38
  import { fileURLToPath } from 'node:url';
39
+ import { fitnessSource } from './helpers.mjs';
39
40
 
40
41
  const require = createRequire(import.meta.url);
41
42
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -44,7 +45,8 @@ const prov = require(path.join(ROOT, 'modules/grading/generated-provenance.js'))
44
45
  // ---- 1. lockstep with the fitness gate ----
45
46
 
46
47
  test('the generator roster matches fitness.js checkGeneratedArtifactsFresh exactly', () => {
47
- const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/fitness.js'), 'utf8');
48
+ // task 1003206: the gate is fitness.js + fitness-*.js now — read the family.
49
+ const src = fitnessSource();
48
50
  const fn = src.slice(src.indexOf('function checkGeneratedArtifactsFresh'));
49
51
  const body = fn.slice(0, fn.indexOf('\nfunction ', 1));
50
52
 
@@ -18,6 +18,7 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import vm from 'node:vm';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { hallSource } from './helpers.mjs';
21
22
 
22
23
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
24
  const HALL = path.join(ROOT, 'modules/hall-ui/public');
@@ -90,7 +91,8 @@ function declaredHrefs(prefixTest) {
90
91
  }
91
92
 
92
93
  test('Standing section anchors exist in builders.js and are routable in shell.js', () => {
93
- const groups = [...read('builders.js').matchAll(/\{ key: '([a-z]+)', label: '[^']+' \}/g)].map((m) => m[1]);
94
+ // task 1003206: STANDING_GROUPS moved to hall-render.js — read the family.
95
+ const groups = [...hallSource().matchAll(/\{ key: '([a-z]+)', label: '[^']+' \}/g)].map((m) => m[1]);
94
96
  assert.ok(groups.length >= 4, `sanity: found STANDING_GROUPS (${groups})`);
95
97
  const declared = declaredHrefs((h) => h.startsWith('/#standing-')).map((h) => h.replace('/#', ''));
96
98
  assert.ok(declared.length, 'palette declares Standing sections');
package/tests/helpers.mjs CHANGED
@@ -124,34 +124,67 @@ export function requireHostModule(require, spec, note) {
124
124
  }
125
125
  }
126
126
 
127
- // lifecycleDbSource — the LIFECYCLE data layer as one source string.
127
+ // ── carved-family source readers (tasks 1003204–1003206, goal 1000079) ────────
128
128
  //
129
- // task 1003204 (goal 1000079) split the 5,748-line modules/lifecycle/db.js into
130
- // db.js (a pure re-export facade) + twelve cohesive db-*.js units. A pile of
131
- // tests assert things about "the data layer" by regexing its source — that the
132
- // claim paths gate on requires_rank, that a literal appears in exactly one
133
- // place, that dependency CRUD points at the right table. Those assertions were
134
- // always about the LAYER, not about one filename, so they read the whole family
135
- // here. Reading the family (rather than repointing each test at whichever db-*
136
- // unit happens to hold the code today) also means a future re-carve moves code
137
- // between units without silently disarming the assertion.
129
+ // The de-monolith work split four big files into a facade plus siblings:
130
+ // modules/lifecycle/db.js, scripts/gds/ship.js, scripts/gds/fitness.js and
131
+ // modules/lifecycle/routes/tasks.js. A pile of tests assert things about one of
132
+ // those SUBJECTS by regexing its source — that every claim path gates on
133
+ // requires_rank, that a literal appears in exactly one place, that a gate
134
+ // precedes the first write. Those assertions were always about the subject, not
135
+ // about a filename, so they read the whole family.
136
+ //
137
+ // Reading the family (rather than repointing each test at whichever sibling
138
+ // happens to hold the code today) also means a future re-carve can move code
139
+ // between siblings without silently disarming the assertion.
140
+ function familySource(dir, matches) {
141
+ return fs.readdirSync(dir).filter(matches).sort()
142
+ .map((f) => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n');
143
+ }
144
+
145
+ // The LIFECYCLE data layer: db.js (a re-export facade) + the twelve db-*.js units.
138
146
  export function lifecycleDbSource(modulesDir = path.join(ROOT, 'modules')) {
139
- const dir = path.join(modulesDir, 'lifecycle');
140
- const files = fs.readdirSync(dir)
141
- .filter((f) => f === 'db.js' || (f.startsWith('db-') && f.endsWith('.js')))
142
- .sort();
143
- return files.map((f) => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n');
147
+ return familySource(path.join(modulesDir, 'lifecycle'),
148
+ (f) => f === 'db.js' || (f.startsWith('db-') && f.endsWith('.js')));
144
149
  }
145
150
 
146
- // shipCliSource — the ship CLI as one source string.
147
- //
148
- // The companion to lifecycleDbSource above, for the same reason: task 1003205
149
- // carved the 4,613-line scripts/gds/ship.js into ship.js (a re-export facade +
150
- // the CLI entry point) plus ship-*.js phase modules. Tests that assert "the ship
151
- // CLI does X" by regexing its source mean the CLI as a whole, not one file.
151
+ // The SHIP CLI: ship.js (facade + entry point) + the ship-*.js phase modules.
152
152
  export function shipCliSource(scriptsDir = path.join(ROOT, 'scripts', 'gds')) {
153
- const files = fs.readdirSync(scriptsDir)
154
- .filter((f) => f === 'ship.js' || (f.startsWith('ship-') && f.endsWith('.js')))
155
- .sort();
156
- return files.map((f) => fs.readFileSync(path.join(scriptsDir, f), 'utf8')).join('\n');
153
+ return familySource(scriptsDir,
154
+ (f) => f === 'ship.js' || (f.startsWith('ship-') && f.endsWith('.js')));
155
+ }
156
+
157
+ // The architecture-fitness GATE: fitness.js (registry + runner), fitness-lib.js
158
+ // and the fitness-checks-*.js clusters. fitness-ratchets.js is deliberately in —
159
+ // it has always been a registered check module of the same gate.
160
+ export function fitnessSource(scriptsDir = path.join(ROOT, 'scripts', 'gds')) {
161
+ return familySource(scriptsDir,
162
+ (f) => f === 'fitness.js' || (f.startsWith('fitness-') && f.endsWith('.js')));
163
+ }
164
+
165
+ // The BUILDERS' HALL page script: builders.js (state, data loading, wiring) plus
166
+ // hall-render.js, the view painters task 1003206 carved out of it.
167
+ export function hallSource(pubDir = path.join(ROOT, 'modules', 'hall-ui', 'public')) {
168
+ return familySource(pubDir, (f) => f === 'builders.js' || f === 'hall-render.js');
169
+ }
170
+
171
+ // The GOALS page script: goals-page.js plus its two carved siblings — goals-lib.js
172
+ // (pure helpers, task 1003135) and goals-render.js (view painters, task 1003206).
173
+ export function goalsPageSource(pubDir = path.join(ROOT, 'modules', 'hall-ui', 'public')) {
174
+ return familySource(pubDir, (f) => ['goals-page.js', 'goals-lib.js', 'goals-render.js'].includes(f));
175
+ }
176
+
177
+ // The KERNEL IDENTITY surface: auth.js (session resolution, the middleware and the
178
+ // page gates) plus the auth-*.js flow modules (config, admission, GitHub, IdP,
179
+ // Discord).
180
+ export function authSource(srcDir = path.join(ROOT, 'src', 'bongos')) {
181
+ return familySource(srcDir,
182
+ (f) => f === 'auth.js' || (f.startsWith('auth-') && f.endsWith('.js')));
183
+ }
184
+
185
+ // The TASK ROUTER: routes/tasks.js plus the task-*.js route modules it registers
186
+ // (task-write-routes.js holds POST /tasks and PATCH /tasks/:id).
187
+ export function taskRoutesSource(modulesDir = path.join(ROOT, 'modules')) {
188
+ return familySource(path.join(modulesDir, 'lifecycle', 'routes'),
189
+ (f) => f === 'tasks.js' || (f.startsWith('task-') && f.endsWith('.js')));
157
190
  }
@@ -28,6 +28,7 @@ import { spawnSync } from 'node:child_process';
28
28
  import fs from 'node:fs';
29
29
  import path from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
+ import { fitnessSource } from './helpers.mjs';
31
32
 
32
33
  const require = createRequire(import.meta.url);
33
34
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -122,7 +123,8 @@ test('gds_api_base falls back to the instance, never a foreign host', (t) => {
122
123
  // ---- 3. the guard can now SEE a .sh re-hardcode ----------------------------
123
124
 
124
125
  test('the host-identity fitness scan covers .sh', () => {
125
- const src = fs.readFileSync(path.join(GDS, 'fitness.js'), 'utf8');
126
+ // task 1003206: the gate is fitness.js + fitness-*.js now — read the family.
127
+ const src = fitnessSource();
126
128
  assert.match(src, /IDENTITY_SCAN_EXTS = new Set\(\[[^\]]*'\.sh'/,
127
129
  'the scan extension set must include .sh, or no shell script is ever checked');
128
130
  });
@@ -36,6 +36,7 @@ import { test } from 'node:test';
36
36
  import fs from 'node:fs';
37
37
  import path from 'node:path';
38
38
  import { fileURLToPath } from 'node:url';
39
+ import { authSource } from './helpers.mjs';
39
40
 
40
41
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
42
  const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
@@ -101,7 +102,8 @@ test('the value mapping is one shared function, not repeated inline', () => {
101
102
  });
102
103
 
103
104
  test('the sender writes BOTH spellings, computing the kind once (the cross-version window)', () => {
104
- const auth = read('src/bongos/auth.js');
105
+ // task 1003206: the auth flows are auth.js + auth-*.js now — read the family.
106
+ const auth = authSource();
105
107
  const body = auth.slice(auth.indexOf('async function reportMembershipCheckIn'), auth.indexOf('async function reportMembershipCheckIn') + 2400);
106
108
  assert.match(body, /membership_kind: kind/, 'the new field goes on the wire');
107
109
  assert.match(body, /role: kind === 'member' \? 'builder' : kind/,
@@ -28,7 +28,7 @@ import { readFileSync } from 'node:fs';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { dirname, join } from 'node:path';
30
30
  import { createRequire } from 'node:module';
31
- import { lifecycleDbSource } from './helpers.mjs';
31
+ import { lifecycleDbSource, taskRoutesSource } from './helpers.mjs';
32
32
 
33
33
  const __dirname = dirname(fileURLToPath(import.meta.url));
34
34
  const repoRoot = join(__dirname, '..');
@@ -52,7 +52,8 @@ const migrationSql = read('migrations', '081_tasks_needs_migration.sql');
52
52
  // the tasks db slice + the tasks route carved to modules/lifecycle/ at BV1.R86.
53
53
  // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
54
54
  const dbSrc = lifecycleDbSource();
55
- const routesSrc = read('modules', 'lifecycle', 'routes', 'tasks.js');
55
+ // task 1003206: the task router is tasks.js + task-*.js now — read the family.
56
+ const routesSrc = taskRoutesSource();
56
57
  const captureSrc = read('scripts', 'gds', 'capture.js');
57
58
 
58
59
  // The allocator's trigger, reproduced as a pure predicate. This is the exact
@@ -148,6 +149,8 @@ function shouldReserveMigrationNumber(task) {
148
149
  // "migration"/"schema" compounds, and a wrong verdict must be correctable
149
150
  // via PATCH. Behavioral tests use the real exported detectsMigration.
150
151
  const require = createRequire(import.meta.url);
152
+ // task 1003206: detectsMigration moved to task-route-helpers.js; tasks.js still
153
+ // re-exports it, so this keeps exercising the router's own public surface.
151
154
  const { detectsMigration } = require('../modules/lifecycle/routes/tasks.js');
152
155
 
153
156
  await test('classifier ignores code-migration and API-schema phrasing (task 1002648)', () => {
@@ -18,8 +18,10 @@
18
18
  import assert from 'node:assert/strict';
19
19
  import { test } from 'node:test';
20
20
  import { readFileSync } from 'node:fs';
21
+ import { taskRoutesSource } from './helpers.mjs';
21
22
 
22
- const src = readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
23
+ // task 1003206: PATCH /tasks/:id now lives in task-write-routes.js — read the family.
24
+ const src = taskRoutesSource();
23
25
 
24
26
  // The PATCH handler slice: router.patch('/tasks/:id' … up to the next route.
25
27
  function patchSlice(source) {