@bongos/core 1.19.214 → 1.19.216

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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.214",
6
- "core_contract": "1.19.214",
7
- "source_commit": "33a353b2b8d41b5d78870cf04e367ba35270db01",
5
+ "core_version": "1.19.216",
6
+ "core_contract": "1.19.216",
7
+ "source_commit": "332217b9ce9425651b7e9cc00f7c10370283a3c5",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-17T23:48:10.122Z",
9
+ "built_at": "2026-08-18T00:28:05.306Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 310,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1633,
20
- "tree_sha256": "42e8f882bc77acfe1def79c56518e80678fb0bd4d4b635f94d013e55474b1ac1",
20
+ "tree_sha256": "ef48b6c7019bc69b448588ddba70fd4dad3efe642c319e131914d0719faa13a2",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1372,7 +1372,7 @@
1372
1372
  {
1373
1373
  "path": "docs/api/openapi.json",
1374
1374
  "mode": "0000644",
1375
- "sha256": "274bd57fc6c0b83aba02cedb5cc6daa2fca4a9d74ae5268e527a81ebc10b50e0"
1375
+ "sha256": "675d873f35c886145c80d954b885692ef0608b4e5b1b843943611f59260eceed"
1376
1376
  },
1377
1377
  {
1378
1378
  "path": "docs/architecture.md",
@@ -3652,7 +3652,7 @@
3652
3652
  {
3653
3653
  "path": "modules/lifecycle/routes/goals.js",
3654
3654
  "mode": "0000644",
3655
- "sha256": "9e963e289039a6b1c64b17deae161895876831de20e4e06b947ea3b3f49afdf6"
3655
+ "sha256": "8763c40223e27e9d6fc65ea76442e479301407ba70e77b9582c5ffcd09f17a11"
3656
3656
  },
3657
3657
  {
3658
3658
  "path": "modules/lifecycle/routes/lifecycle.js",
@@ -4282,12 +4282,12 @@
4282
4282
  {
4283
4283
  "path": "package-lock.json",
4284
4284
  "mode": "0000644",
4285
- "sha256": "02845f76d2382f9e1568f039c838a34505297ae619cdb9cdbe5d8b583cd1dd40"
4285
+ "sha256": "e3ec1b7c73d0f97c739441cbfb445bf1272ab8188908a40c5f5b20b503e0aecf"
4286
4286
  },
4287
4287
  {
4288
4288
  "path": "package.json",
4289
4289
  "mode": "0000644",
4290
- "sha256": "6abe7a4d894357f207a16ab1735c642f46e2f5905d3845c436b6df438f66a3ac"
4290
+ "sha256": "f6e3bf39a631c8a79e69291876119717c4f25acf8ad15144bfd845452e3a4ec2"
4291
4291
  },
4292
4292
  {
4293
4293
  "path": "public-docs/index.html",
@@ -5197,7 +5197,7 @@
5197
5197
  {
5198
5198
  "path": "scripts/gds/preflight.js",
5199
5199
  "mode": "0000644",
5200
- "sha256": "b56478bfcec12f59370cd019ee5e89f33300f50556327d389789446f17782f04"
5200
+ "sha256": "bd57bb676f0ed6c426129a912727c4ecd363a1197373588fe24f4d48727a75dc"
5201
5201
  },
5202
5202
  {
5203
5203
  "path": "scripts/gds/provision.js",
@@ -5902,7 +5902,7 @@
5902
5902
  {
5903
5903
  "path": "src/module-api.js",
5904
5904
  "mode": "0000644",
5905
- "sha256": "53487d418120e292c09bc2f647581d2541fbf7966052420827b8d6c49c77940d"
5905
+ "sha256": "1b0b819f43443acd16ef719721610c5eb1d4077a083f5b40f272b7e176ab203a"
5906
5906
  },
5907
5907
  {
5908
5908
  "path": "src/module-loader/catalog.js",
@@ -6797,7 +6797,7 @@
6797
6797
  {
6798
6798
  "path": "tests/goal_membership_requests.mjs",
6799
6799
  "mode": "0000644",
6800
- "sha256": "660dd4143c21de0fe81b4507f3cb9f0731e5722e6c77806de8327156b18198f6"
6800
+ "sha256": "59253dac0064ae1a9c7dd67ef87c1d32ff924d3c3dadf7cdb9553dbc76f88eb0"
6801
6801
  },
6802
6802
  {
6803
6803
  "path": "tests/goal_routes.mjs",
@@ -7497,7 +7497,7 @@
7497
7497
  {
7498
7498
  "path": "tests/preflight_checks.mjs",
7499
7499
  "mode": "0000644",
7500
- "sha256": "dcf007165fa70a3ad4ebc3109fda78495af2482e7b5fb1ebfa713cf9f0cb4413"
7500
+ "sha256": "9f16091a8feeaef9e4350104dbc9671fab6f2b53f99fd82f798c77800e780b61"
7501
7501
  },
7502
7502
  {
7503
7503
  "path": "tests/preview_server.mjs",
@@ -4276,7 +4276,7 @@
4276
4276
  "goals"
4277
4277
  ],
4278
4278
  "summary": "POST /goals/:id/invitations/:reqId/respond",
4279
- "description": "POST /goals/:id/invitations/:reqId/respond { action: accept|decline } — ONLY the invited builder resolves their OWN pending invite (task 1952). accept → they're added as a member; decline → the row is rejected. A protected-scope goal still requires a Metic+ member, re-checked against the accepting builder's current rank (the goal could have been re-scoped after the invite). rank: any-builder (requireBuilder) + the in-handler authorizeInvitationResponse.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
4279
+ "description": "POST /goals/:id/invitations/:reqId/respond { action: accept|decline } — ONLY the invited builder resolves their OWN pending invite (task 1952). accept → they're added as a member (only while the goal is still OPEN, and a protected-scope goal still requires a Metic+ member — both re-checked against the CURRENT goal row by authorizeInvitationAccept, task 1002887); decline → the row is rejected, allowed whatever the goal's state, since that is inbox cleanup rather than admission. rank: any-builder (requireBuilder) + the in-handler authorizeInvitationResponse.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
4280
4280
  "x-rank": "any-builder",
4281
4281
  "x-source": "modules/lifecycle/routes/goals.js",
4282
4282
  "parameters": [
@@ -324,6 +324,44 @@ function authorizeInvitationResponse({ request, goalId, actorId }) {
324
324
  return { ok: true };
325
325
  }
326
326
 
327
+ // authorizeInvitationAccept — the ORDERED, FAIL-CLOSED gate for the ACCEPT half of
328
+ // POST /goals/:id/invitations/:reqId/respond, run against the GOAL after
329
+ // authorizeInvitationResponse has cleared the request row (task 1002887).
330
+ //
331
+ // Accepting is an ADMISSION, so it needs the same goal-state floor every other
332
+ // admission path already has: the create side requires an open goal
333
+ // (authorizeGoalManagement) and the join-request approve side inherits that check
334
+ // verbatim — only invitation-accept was missing it, so an invite issued while a
335
+ // goal was open could still add a member AFTER the goal was achieved or archived.
336
+ // DECLINE is deliberately NOT gated here: clearing a stale invitation out of your
337
+ // inbox is cleanup, not admission (the route returns before calling this).
338
+ // Ordering mirrors the sibling routes: goal_not_found → goal_not_open →
339
+ // metic_required_for_protected_scope (the pre-existing protected-scope membership
340
+ // floor, re-asserted against the ACCEPTING builder's current rank because the goal
341
+ // may have been re-scoped since the invite; the actor-is-Archon half of
342
+ // protectedScopeAdmissionBlock does not apply — the invitee is consenting).
343
+ //
344
+ // Unlike its {status, body} siblings this returns a res.fail SPEC ({ code, status,
345
+ // message, details }) because the accept branch already answers through res.fail —
346
+ // routing it through the envelope keeps the existing metic_required_for_protected_scope
347
+ // response byte-identical while still being PURE + exported for DB-free tests.
348
+ function authorizeInvitationAccept({ goal, scopeProtected, actorRank }) {
349
+ if (!goal) {
350
+ return { ok: false, code: 'goal_not_found', status: 404,
351
+ message: 'The goal behind this invitation no longer exists.' };
352
+ }
353
+ if (goal.status !== 'open') {
354
+ return { ok: false, code: 'goal_not_open', status: 409,
355
+ message: 'This goal is no longer open — an invitation can only be accepted while it is. You can still decline it.',
356
+ details: { status: goal.status } };
357
+ }
358
+ if (scopeProtected && !METIC_PLUS.includes(actorRank)) {
359
+ return { ok: false, code: 'metic_required_for_protected_scope', status: 403,
360
+ message: 'This goal is scoped to a protected module and requires a Metic+ member.' };
361
+ }
362
+ return { ok: true };
363
+ }
364
+
327
365
  // authorizeJoinRequestResponse — the gate for POST /goals/:id/requests/:reqId/respond
328
366
  // (task 1952): an owner|manager|Archon approves/denies a pending join-request. The
329
367
  // authority prefix is authorizeGoalManagement VERBATIM (goal exists/open + owner|
@@ -789,9 +827,10 @@ module.exports = function buildGoalsRouter() {
789
827
 
790
828
  // POST /goals/:id/invitations/:reqId/respond { action: accept|decline } — ONLY the
791
829
  // invited builder resolves their OWN pending invite (task 1952). accept → they're
792
- // added as a member; decline → the row is rejected. A protected-scope goal still
793
- // requires a Metic+ member, re-checked against the accepting builder's current rank
794
- // (the goal could have been re-scoped after the invite).
830
+ // added as a member (only while the goal is still OPEN, and a protected-scope goal
831
+ // still requires a Metic+ member — both re-checked against the CURRENT goal row by
832
+ // authorizeInvitationAccept, task 1002887); decline → the row is rejected, allowed
833
+ // whatever the goal's state, since that is inbox cleanup rather than admission.
795
834
  // rank: any-builder (requireBuilder) + the in-handler authorizeInvitationResponse.
796
835
  router.post('/goals/:id/invitations/:reqId/respond', auth.requireBuilder, async (req, res) => {
797
836
  const id = parseId(req, res, { code: 'bad_goal_id' });
@@ -814,13 +853,14 @@ module.exports = function buildGoalsRouter() {
814
853
  return res.json({ ok: true, action, request: resolved });
815
854
  }
816
855
 
817
- // accept — re-assert the protected-scope Metic+ membership invariant against
818
- // the accepting builder's CURRENT rank (the goal may have been re-scoped since
819
- // the invite). The invitee is consenting, so the actor-is-Archon half of the
820
- // admission rule does NOT apply here — only the Metic+ membership floor does.
856
+ // accept — the goal-state + protected-scope floor, read against the goal's
857
+ // CURRENT row (fail-closed if it is gone). See authorizeInvitationAccept.
821
858
  const goal = await db.getGoal(id);
822
- if (goal && scopeIncludesProtected(goal.scope_modules) && !METIC_PLUS.includes(req.builder.rank)) {
823
- return res.fail('metic_required_for_protected_scope', { status: 403, message: 'This goal is scoped to a protected module and requires a Metic+ member.' });
859
+ const admit = authorizeInvitationAccept({
860
+ goal, scopeProtected: !!goal && scopeIncludesProtected(goal.scope_modules), actorRank: req.builder.rank,
861
+ });
862
+ if (!admit.ok) {
863
+ return res.fail(admit.code, { status: admit.status, message: admit.message, details: admit.details });
824
864
  }
825
865
  // Resolve + add atomically (one statement) — never an 'accepted' invite
826
866
  // with no membership (the task-1952 review fix).
@@ -1505,6 +1545,9 @@ module.exports.RESOLVE_ACTIONS = RESOLVE_ACTIONS;
1505
1545
  module.exports.protectedScopeAdmissionBlock = protectedScopeAdmissionBlock;
1506
1546
  module.exports.authorizeCreateJoinRequest = authorizeCreateJoinRequest;
1507
1547
  module.exports.authorizeInvitationResponse = authorizeInvitationResponse;
1548
+ // task 1002887: the goal-state floor on the ACCEPT half of the invitation-respond
1549
+ // route — exported for tests/goal_membership_requests.mjs, same precedent.
1550
+ module.exports.authorizeInvitationAccept = authorizeInvitationAccept;
1508
1551
  module.exports.authorizeJoinRequestResponse = authorizeJoinRequestResponse;
1509
1552
  // task 1953: the withdraw gate — exported for tests/goal_membership_requests.mjs,
1510
1553
  // same precedent as its siblings above.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.214",
3
+ "version": "1.19.216",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.214",
9
+ "version": "1.19.216",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.214",
3
+ "version": "1.19.216",
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",
@@ -8,8 +8,9 @@
8
8
  // on a bare machine it died with `node: command not found`.
9
9
  // 2. deps installed — a missing node_modules is why a ship later blew up with
10
10
  // `Cannot find module 'pg'`.
11
- // 3. `claude` launchable — the grader spawns it per worker; without it every ship
12
- // failed grading with SUBAGENT_SPAWN_FAILED.
11
+ // 3. `claude` usable — the grader spawns it per worker. Not launchable ⇒ every
12
+ // ship failed grading with SUBAGENT_SPAWN_FAILED; launchable
13
+ // but LOGGED OUT ⇒ SUBAGENT_NONZERO_EXIT (task 1003080).
13
14
  // Each finding carries a copy-paste fix.
14
15
  //
15
16
  // Before this, those checks lived only inside `bongos doctor` (doctor.js) and were
@@ -31,6 +32,10 @@ const { spawnSync } = require('node:child_process');
31
32
  const REPO_ROOT = nodePath.resolve(__dirname, '..', '..');
32
33
  const MIN_NODE_MAJOR = 22;
33
34
 
35
+ // Env that authenticates the CLI with no interactive login — a headless box or a
36
+ // CI runner. ci-grade.js reads the same two to decide whether it can grade at all.
37
+ const HEADLESS_AUTH_ENV = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
38
+
34
39
  // The node_modules-present check, factored out (task 1002403) so a HOT-PATH
35
40
  // caller — /builder-start runs on every session open — can reuse THIS exact
36
41
  // finding without paying for the `claude --version` spawn the full
@@ -49,6 +54,55 @@ function depsFinding({ repoRoot = REPO_ROOT, fs = nodeFs } = {}) {
49
54
  };
50
55
  }
51
56
 
57
+ // The AUTH half of the `claude CLI` row (task 1003080). Split out to keep
58
+ // preflightChecks readable — the depsFinding precedent. Pure: spawn/env injected.
59
+ //
60
+ // WHY IT EXISTS. `claude --version` exits 0 while the CLI is LOGGED OUT, so the
61
+ // launchable probe alone reported "the grader can spawn its workers" on a box
62
+ // where every worker died on spawn. The grader runs one `claude -p` per panel
63
+ // worker; logged out, each exits 1 in ~5s, so all four record
64
+ // SUBAGENT_NONZERO_EXIT, the panel reports `unavailable`, and the builder gets a
65
+ // 0/10 "grade failed" card with an empty issues[] — while doctor, the one tool
66
+ // they're told to run, printed a green row. (Observed on task 1002459: two
67
+ // consecutive outage rounds, cost_usd null on every worker, while the rest of the
68
+ // fleet graded 21/21 the same day.)
69
+ //
70
+ // `claude auth status --json` answers `{ loggedIn: bool }` from local credentials
71
+ // — no token spend, no model call. An unverifiable answer (an older CLI with no
72
+ // `auth` subcommand, or any unparseable output) deliberately stays OK with the
73
+ // caveat named: a working older box must never be turned red by a probe it can't
74
+ // answer.
75
+ function claudeAuthFinding({ bin, via, ver, env, spawn, platform }) {
76
+ const envVar = HEADLESS_AUTH_ENV.find((k) => env[k]);
77
+ if (envVar) {
78
+ return { level: 'ok', title: 'claude CLI', detail: `launches${via}: ${ver}; authenticated via ${envVar} — the grader can spawn its workers.` };
79
+ }
80
+
81
+ let a;
82
+ try {
83
+ a = spawn(bin, ['auth', 'status', '--json'], { encoding: 'utf8', shell: platform === 'win32', windowsHide: true, timeout: 15000 });
84
+ } catch (err) { a = { error: err }; }
85
+
86
+ let loggedIn = null; // null ⇒ the CLI didn't answer in a shape we understand
87
+ try {
88
+ const parsed = JSON.parse(String((a && a.stdout) || '').trim());
89
+ if (typeof parsed.loggedIn === 'boolean') loggedIn = parsed.loggedIn;
90
+ } catch (_) { /* older CLI without `auth status`, or non-JSON output */ }
91
+
92
+ if (loggedIn === false) {
93
+ return {
94
+ level: 'fail',
95
+ title: 'claude CLI',
96
+ detail: `launches${via} (${ver}) but is not logged in — the grader spawns one \`claude -p\` per panel worker and each exits 1 immediately, so every ship fails grading with SUBAGENT_NONZERO_EXIT and a 0/10 card carrying no findings.`,
97
+ fix: `Run \`${bin} auth login\` (or \`${bin}\`, then /login). On a headless box, set ${HEADLESS_AUTH_ENV.join(' or ')} instead.`,
98
+ };
99
+ }
100
+ if (loggedIn === true) {
101
+ return { level: 'ok', title: 'claude CLI', detail: `launches${via}: ${ver}, logged in — the grader can spawn its workers.` };
102
+ }
103
+ return { level: 'ok', title: 'claude CLI', detail: `launches${via}: ${ver} — the grader can spawn its workers (login state not verified: this CLI has no \`auth status\`).` };
104
+ }
105
+
52
106
  // preflightChecks(deps) → [{ level:'ok'|'warn'|'fail', title, detail, fix? }]
53
107
  // No printing, no process.exit — callers (setup, doctor, the test) format it.
54
108
  function preflightChecks({
@@ -87,7 +141,10 @@ function preflightChecks({
87
141
  } catch (err) { r = { error: err }; }
88
142
  if (r && !r.error && r.status === 0) {
89
143
  const ver = String(r.stdout || '').trim().split('\n')[0] || 'present';
90
- out.push({ level: 'ok', title: 'claude CLI', detail: `launches${via}: ${ver} — the grader can spawn its workers.` });
144
+ // task 1003080: launchable is only HALF this leg — `--version` exits 0 while
145
+ // the CLI is logged out, so this row used to go green at the exact moment
146
+ // the thing it checks is broken. claudeAuthFinding proves auth too.
147
+ out.push(claudeAuthFinding({ bin, via, ver, env, spawn, platform }));
91
148
  } else {
92
149
  const why = r && r.error ? (r.error.code || r.error.message) : `exited ${r ? r.status : '?'}`;
93
150
  out.push({
@@ -116,7 +173,7 @@ function formatPreflight(results, { color = false, heading = false } = {}) {
116
173
  return lines.join('\n');
117
174
  }
118
175
 
119
- module.exports = { preflightChecks, depsFinding, formatPreflight, REPO_ROOT, MIN_NODE_MAJOR };
176
+ module.exports = { preflightChecks, depsFinding, claudeAuthFinding, formatPreflight, REPO_ROOT, MIN_NODE_MAJOR, HEADLESS_AUTH_ENV };
120
177
 
121
178
  // Standalone: `node scripts/gds/preflight.js` — exit 1 if any hard fail.
122
179
  if (require.main === module) {
package/src/module-api.js CHANGED
@@ -855,8 +855,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
855
855
  // landed since 1.19.212 with no explicit bump. run 32081051255. (task 1002620)
856
856
  // 1.19.214 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
857
857
  // landed since 1.19.213 with no explicit bump. run 32081917908. (task 1002620)
858
+ // 1.19.215 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
859
+ // landed since 1.19.214 with no explicit bump. run 32084187695. (task 1002620)
860
+ // 1.19.216 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
861
+ // landed since 1.19.215 with no explicit bump. run 32084584347. (task 1002620)
858
862
  // ---------------------------------------------------------------------------
859
- const CORE_VERSION = '1.19.214'; // CI auto-patch carrier (ADR 0161); see changelog above
863
+ const CORE_VERSION = '1.19.216'; // CI auto-patch carrier (ADR 0161); see changelog above
860
864
 
861
865
  // A namespaced logger so a module's log lines are attributable + consistent.
862
866
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -15,6 +15,9 @@
15
15
  // • listPendingRequestsForGoal — pending both-kinds for one goal.
16
16
  // • addGoalMember (task 1002888) — arriving as a member by ANY route cancels
17
17
  // that builder's still-pending ask, atomically and kind-agnostically.
18
+ // • authorizeInvitationAccept (task 1002887) — accepting an invite re-checks the
19
+ // goal is still OPEN (a stale invite can't admit to an achieved/archived goal);
20
+ // declining stays allowed, so the route declines before ever reading the goal.
18
21
  // • the migration is additive + private-safe (default 'public', partial unique).
19
22
 
20
23
  import { strict as assert } from 'node:assert';
@@ -42,6 +45,7 @@ const {
42
45
  protectedScopeAdmissionBlock,
43
46
  authorizeCreateJoinRequest,
44
47
  authorizeInvitationResponse,
48
+ authorizeInvitationAccept,
45
49
  authorizeJoinRequestResponse,
46
50
  authorizeWithdrawJoinRequest,
47
51
  decorateGoalScope,
@@ -370,6 +374,54 @@ await test('authorizeInvitationResponse: the invitee (string vs number id) → A
370
374
  assert.deepEqual(authorizeInvitationResponse({ request: INVITE, goalId: '5', actorId: 2 }), { ok: true });
371
375
  });
372
376
 
377
+ // authorizeInvitationAccept (task 1002887) — the goal-state floor on ACCEPT only.
378
+ const OPEN_GOAL = { id: 5, status: 'open', scope_modules: null };
379
+ await test('authorizeInvitationAccept: open, unprotected goal → ALLOW', () => {
380
+ assert.deepEqual(authorizeInvitationAccept({ goal: OPEN_GOAL, scopeProtected: false, actorRank: 'thetes' }), { ok: true });
381
+ });
382
+ await test('authorizeInvitationAccept: the goal is gone → 404 (fail-closed, never a member of nothing)', () => {
383
+ const d = authorizeInvitationAccept({ goal: null, scopeProtected: false, actorRank: 'archon' });
384
+ assert.equal(d.ok, false); assert.equal(d.status, 404); assert.equal(d.code, 'goal_not_found');
385
+ });
386
+ for (const status of ['achieved', 'archived']) {
387
+ await test(`authorizeInvitationAccept: ${status} goal → 409 goal_not_open (a stale invite can't admit)`, () => {
388
+ const d = authorizeInvitationAccept({ goal: { ...OPEN_GOAL, status }, scopeProtected: false, actorRank: 'archon' });
389
+ assert.equal(d.ok, false); assert.equal(d.status, 409); assert.equal(d.code, 'goal_not_open');
390
+ assert.deepEqual(d.details, { status }, 'carries the blocking status, like every sibling goal_not_open');
391
+ });
392
+ }
393
+ await test('authorizeInvitationAccept: goal-state is checked BEFORE scope (ordering, like the join route)', () => {
394
+ const d = authorizeInvitationAccept({ goal: { ...OPEN_GOAL, status: 'archived' }, scopeProtected: true, actorRank: 'thetes' });
395
+ assert.equal(d.code, 'goal_not_open', 'a closed goal refuses on state, not on the scope floor');
396
+ });
397
+ await test('authorizeInvitationAccept: open + protected + sub-Metic invitee → 403 metic_required (unchanged)', () => {
398
+ const d = authorizeInvitationAccept({ goal: OPEN_GOAL, scopeProtected: true, actorRank: 'thetes' });
399
+ assert.equal(d.status, 403); assert.equal(d.code, 'metic_required_for_protected_scope');
400
+ assert.equal(d.message, 'This goal is scoped to a protected module and requires a Metic+ member.');
401
+ assert.equal(d.details, undefined, 'no details — the res.fail envelope stays byte-identical to the pre-1002887 response');
402
+ });
403
+ await test('authorizeInvitationAccept: open + protected + Metic invitee → ALLOW (consent, no Archon actor needed)', () => {
404
+ assert.deepEqual(authorizeInvitationAccept({ goal: OPEN_GOAL, scopeProtected: true, actorRank: 'metic' }), { ok: true });
405
+ });
406
+
407
+ // DECLINE stays allowed on a closed goal — clearing a stale invitation out of your
408
+ // inbox is cleanup, not admission. The route's decline branch must therefore return
409
+ // BEFORE the goal is ever fetched; pinned at the source level (the addGoalMember
410
+ // resolvedBy precedent above), since the live route is exercised in goal_routes.mjs.
411
+ await test('routes: invitation-respond declines before the goal-state gate', () => {
412
+ const here = dirname(fileURLToPath(import.meta.url));
413
+ const src = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goals.js'), 'utf8');
414
+ const handler = src.slice(src.indexOf("router.post('/goals/:id/invitations/:reqId/respond'"));
415
+ const body = handler.slice(0, handler.indexOf('\n });'));
416
+ const declineAt = body.indexOf("if (action === 'decline')");
417
+ const gateAt = body.indexOf('authorizeInvitationAccept({');
418
+ assert.ok(declineAt > 0 && gateAt > 0, 'both branches present in the respond handler');
419
+ assert.ok(declineAt < gateAt, 'decline resolves and returns before the accept-only goal gate');
420
+ assert.ok(body.indexOf('db.getGoal(') > declineAt, 'decline never even reads the goal');
421
+ assert.ok(gateAt < body.indexOf('db.acceptMembershipRequestAndAddMember('),
422
+ 'the gate runs before the membership add');
423
+ });
424
+
373
425
  // authorizeJoinRequestResponse — owner|manager|Archon resolves a join-request.
374
426
  const OWNER_GOAL = { id: 5, status: 'open', created_by: '1', visibility: 'private' };
375
427
  const REQUEST = { id: 8, goal_id: '5', builder_id: '2', kind: 'request', status: 'pending' };
@@ -70,3 +70,77 @@ test('formatPreflight renders glyphs + fixes and stays plain (no ANSI) by defaul
70
70
  assert.match(s, /→ .*npm ci/);
71
71
  assert.doesNotMatch(s, /\x1b\[/); // color:false ⇒ no ANSI escapes
72
72
  });
73
+
74
+ // --------------------------------------------------------------------------
75
+ // claude CLI AUTHENTICATION (task 1003080)
76
+ // --------------------------------------------------------------------------
77
+ // `--version` exits 0 while the CLI is logged out, so the launchable probe
78
+ // alone false-greened the exact failure it exists to catch: every grader worker
79
+ // exits 1 on spawn (SUBAGENT_NONZERO_EXIT) and the builder gets a 0/10 card with
80
+ // no findings. These cover the four auth shapes.
81
+
82
+ // A spawn stub that answers `--version` and `auth status --json` differently.
83
+ // authJson === null ⇒ the CLI has no `auth` subcommand (older build): non-JSON
84
+ // on stdout, non-zero exit.
85
+ const spawnWithAuth = (authJson) => (_bin, args) => {
86
+ if (args && args[0] === 'auth') {
87
+ return authJson === null
88
+ ? { status: 1, stdout: '', stderr: "error: unknown command 'auth'\n" }
89
+ : { status: 0, stdout: `${JSON.stringify(authJson)}\n` };
90
+ }
91
+ return { status: 0, stdout: '2.1.234 (Claude Code)\n' };
92
+ };
93
+
94
+ test('claude launches but is LOGGED OUT → fail naming SUBAGENT_NONZERO_EXIT + the login fix', () => {
95
+ const r = preflightChecks({
96
+ nodeVersion: 'v22.0.0', fs: fsWith(true), env: {},
97
+ spawn: spawnWithAuth({ loggedIn: false }),
98
+ });
99
+ const claude = find(r, 'claude CLI');
100
+ assert.equal(claude.level, 'fail');
101
+ assert.match(claude.detail, /not logged in/i);
102
+ assert.match(claude.detail, /SUBAGENT_NONZERO_EXIT/);
103
+ assert.match(claude.fix, /auth login/);
104
+ });
105
+
106
+ test('claude logged in → ok', () => {
107
+ const r = preflightChecks({
108
+ nodeVersion: 'v22.0.0', fs: fsWith(true), env: {},
109
+ spawn: spawnWithAuth({ loggedIn: true, authMethod: 'claude.ai' }),
110
+ });
111
+ const claude = find(r, 'claude CLI');
112
+ assert.equal(claude.level, 'ok');
113
+ assert.match(claude.detail, /2\.1\.234/);
114
+ });
115
+
116
+ test('a headless auth token in env → ok, and the auth probe is never spawned', () => {
117
+ const seen = [];
118
+ const spy = (_bin, args) => { seen.push((args || []).join(' ')); return { status: 0, stdout: '2.1.234\n' }; };
119
+ const r = preflightChecks({
120
+ nodeVersion: 'v22.0.0', fs: fsWith(true), spawn: spy,
121
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'sk-ant-oat-xxx' },
122
+ });
123
+ const claude = find(r, 'claude CLI');
124
+ assert.equal(claude.level, 'ok');
125
+ assert.match(claude.detail, /CLAUDE_CODE_OAUTH_TOKEN/);
126
+ assert.deepEqual(seen, ['--version']);
127
+ });
128
+
129
+ test('an older CLI without `auth status` stays ok, but says auth was not verified', () => {
130
+ const r = preflightChecks({
131
+ nodeVersion: 'v22.0.0', fs: fsWith(true), env: {},
132
+ spawn: spawnWithAuth(null),
133
+ });
134
+ const claude = find(r, 'claude CLI');
135
+ assert.equal(claude.level, 'ok');
136
+ assert.match(claude.detail, /not verif/i);
137
+ });
138
+
139
+ test('a thrown auth spawn is treated as unverifiable, never crashes', () => {
140
+ const spawnThrows = (_bin, args) => {
141
+ if (args && args[0] === 'auth') throw new Error('boom');
142
+ return { status: 0, stdout: '2.1.234\n' };
143
+ };
144
+ const r = preflightChecks({ nodeVersion: 'v22.0.0', fs: fsWith(true), env: {}, spawn: spawnThrows });
145
+ assert.equal(find(r, 'claude CLI').level, 'ok');
146
+ });