@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.
- package/.bongos-core.json +173 -43
- package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +202 -202
- package/docs/copy-registry.json +384 -384
- package/modules/grading/grader-config.js +98 -0
- package/modules/grading/grader-model.js +150 -0
- package/modules/grading/grader-prompt.js +139 -0
- package/modules/grading/grader-score.js +706 -0
- package/modules/grading/grader-subagent.js +328 -0
- package/modules/grading/grader.js +5 -1287
- package/modules/hall-ui/public/builders.js +19 -1182
- package/modules/hall-ui/public/goals-page.js +11 -997
- package/modules/hall-ui/public/goals-render.js +1032 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/hall-render.js +1211 -0
- package/modules/hall-ui/public/index.html +6 -0
- package/modules/lifecycle/routes/goal-route-authz.js +400 -0
- package/modules/lifecycle/routes/goals.js +1 -367
- package/modules/lifecycle/routes/task-route-helpers.js +219 -0
- package/modules/lifecycle/routes/task-write-routes.js +542 -0
- package/modules/lifecycle/routes/tasks.js +3 -696
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-infra.js +280 -0
- package/scripts/gds/box.js +1 -245
- package/scripts/gds/fitness-checks-identity.js +320 -0
- package/scripts/gds/fitness-checks-vocabulary.js +230 -0
- package/scripts/gds/fitness-checks-write-validation.js +144 -0
- package/scripts/gds/fitness-lib.js +82 -0
- package/scripts/gds/fitness.js +55 -668
- package/scripts/gds/provision-config.js +204 -0
- package/scripts/gds/provision-net.js +221 -0
- package/scripts/gds/provision-repo.js +849 -0
- package/scripts/gds/provision-units.js +262 -0
- package/scripts/gds/provision.js +4 -1395
- package/src/bongos/auth-admission.js +264 -0
- package/src/bongos/auth-config.js +151 -0
- package/src/bongos/auth-discord.js +198 -0
- package/src/bongos/auth-github.js +336 -0
- package/src/bongos/auth-idp.js +330 -0
- package/src/bongos/auth.js +5 -1100
- package/src/bongos/routes/auth.js +54 -10
- package/src/module-api.js +5 -1
- package/tests/auth_admission_gate.mjs +3 -1
- package/tests/auth_web_recovery.mjs +63 -0
- package/tests/est_advisory.mjs +3 -1
- package/tests/goal_advisory.mjs +3 -2
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
- package/tests/goal_subtitle.mjs +5 -2
- package/tests/goals_card_view.mjs +10 -3
- package/tests/grader_generated_provenance.mjs +3 -1
- package/tests/hall_palette.mjs +3 -1
- package/tests/helpers.mjs +58 -25
- package/tests/lib_sh_resolution.mjs +3 -1
- package/tests/membership_kind_lockstep.mjs +3 -1
- package/tests/migration_allocation.mjs +5 -2
- package/tests/patch_gate_ordering.mjs +3 -1
- package/tests/rank_tier_single_source.mjs +16 -3
- 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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
+
});
|
package/tests/est_advisory.mjs
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/tests/goal_advisory.mjs
CHANGED
|
@@ -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
|
-
|
|
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).
|
package/tests/goal_subtitle.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/tests/hall_palette.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
127
|
+
// ── carved-family source readers (tasks 1003204–1003206, goal 1000079) ────────
|
|
128
128
|
//
|
|
129
|
-
//
|
|
130
|
-
// db.js
|
|
131
|
-
// tests assert things about
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|