@bongos/core 1.19.996 → 1.19.997

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,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.996",
6
- "core_contract": "1.19.996",
7
- "source_commit": "5929da0385a4022562ebd43a5a71d81400e45836",
5
+ "core_version": "1.19.997",
6
+ "core_contract": "1.19.997",
7
+ "source_commit": "e3e7048278119785fc3a9a9ca14edefba9ecae54",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T23:40:05.075Z",
9
+ "built_at": "2026-09-24T00:11:45.316Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 520,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2405,
14
+ "functional_verbatim": 2406,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2951,
20
- "tree_sha256": "cd26f1916edf3136e53591dc18c37ed45254dd805880bf4b25bc1af1c59baf93",
19
+ "file_count": 2952,
20
+ "tree_sha256": "ce61953a922e8482ee9341329d06ba7a71aa4367e0449c4d9a9990de9ff82e23",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2157,7 +2157,7 @@
2157
2157
  {
2158
2158
  "path": "docs/api/openapi.json",
2159
2159
  "mode": "0000644",
2160
- "sha256": "8b1f2a53680ae8b68be5d54a82664413fa29186fb580825f5a1b0e3ce3677525"
2160
+ "sha256": "734016d0505821010e06c197ea1987e3eb4e9700d45ad32d982a5b9cd9da5aed"
2161
2161
  },
2162
2162
  {
2163
2163
  "path": "docs/architecture.md",
@@ -2717,7 +2717,7 @@
2717
2717
  {
2718
2718
  "path": "docs/module-api-changelog.md",
2719
2719
  "mode": "0000644",
2720
- "sha256": "950ea34b2bb315e54fc02aa22753b3370ee009ab06727238002aa3a54e78782e"
2720
+ "sha256": "1b16fcc54d5a5c01d44d93ff307eef44ddec9a993bd81db0d41db4b71f4e5654"
2721
2721
  },
2722
2722
  {
2723
2723
  "path": "docs/modules-contract.md",
@@ -6087,12 +6087,12 @@
6087
6087
  {
6088
6088
  "path": "modules/lifecycle/routes/goal-route-authz.js",
6089
6089
  "mode": "0000644",
6090
- "sha256": "3b4fb0168b29fb0df1260c23c5b14d96576f00f587dd5c188fe17a712e3dffaa"
6090
+ "sha256": "89d282e6c5110ae698484ddab698a72f27c6fe509fc97ff628326fe42e79483d"
6091
6091
  },
6092
6092
  {
6093
6093
  "path": "modules/lifecycle/routes/goals.js",
6094
6094
  "mode": "0000644",
6095
- "sha256": "91039df9ca263ad34bda8e230b8e52f2099e6114a2a9eb4bae2b6b21210994c8"
6095
+ "sha256": "8bd3b013fa96d062f15386e53f43dcdb918baf619c2beb5cf5333ef7052475f3"
6096
6096
  },
6097
6097
  {
6098
6098
  "path": "modules/lifecycle/routes/help-requests.js",
@@ -8187,12 +8187,12 @@
8187
8187
  {
8188
8188
  "path": "package-lock.json",
8189
8189
  "mode": "0000644",
8190
- "sha256": "6d4291f9e503ac170998451d6607d15f831d39cd43aaa2443ada021e10386f13"
8190
+ "sha256": "93a81b5f6bc16e21a3ad6abd52490ccfbefe0f2d11af8dfd6273f03dd0c91ca0"
8191
8191
  },
8192
8192
  {
8193
8193
  "path": "package.json",
8194
8194
  "mode": "0000644",
8195
- "sha256": "50946900cdbd241bfe7a8ac94d4b50b65a9adb622f2debdd2048354a2ead38e4"
8195
+ "sha256": "fe2540636dec8118f88d3c037c991fb3f9f784a72edec9569ecce1f09ec4ab97"
8196
8196
  },
8197
8197
  {
8198
8198
  "path": "public-docs/index.html",
@@ -9127,7 +9127,7 @@
9127
9127
  {
9128
9128
  "path": "scripts/gds/prove-strict-versioning.js",
9129
9129
  "mode": "0000644",
9130
- "sha256": "b0e2c14720e8258eba4d4ef7f69cc2bcbb26c3be312deb891383dce7a1dbf71e"
9130
+ "sha256": "65bb758becd002ae830a8338ec6ba0bc21ec5ef91ad564c85a2a94b7ab6ffbbc"
9131
9131
  },
9132
9132
  {
9133
9133
  "path": "scripts/gds/provision-byo-host.js",
@@ -10237,7 +10237,7 @@
10237
10237
  {
10238
10238
  "path": "src/module-api.js",
10239
10239
  "mode": "0000644",
10240
- "sha256": "899926ec43a4f0087125deada1bf430aa5f34bd1b3f32ab65ff2ffea981431a3"
10240
+ "sha256": "aa4d373556efe35c070f90b0ff308b302fbb9887f06cd3655d0fb9df8b157faa"
10241
10241
  },
10242
10242
  {
10243
10243
  "path": "src/module-loader/catalog.js",
@@ -11662,7 +11662,7 @@
11662
11662
  {
11663
11663
  "path": "tests/goal_archive_disposition.mjs",
11664
11664
  "mode": "0000644",
11665
- "sha256": "bf0baad459ee2e103079a53570ca4998bb3ba66451ad0257478dd236118c8ce6"
11665
+ "sha256": "de5b561ffe78ead5f6a1ad08066aaa2672627f441f8602fd46e1fdac453512e0"
11666
11666
  },
11667
11667
  {
11668
11668
  "path": "tests/goal_archive_hall.mjs",
@@ -11769,6 +11769,11 @@
11769
11769
  "mode": "0000644",
11770
11770
  "sha256": "c93422d76ff7f0107190c6de1954f110e92a18913fabbcdea2b4f67693d538e6"
11771
11771
  },
11772
+ {
11773
+ "path": "tests/goal_owner_manager_powers.mjs",
11774
+ "mode": "0000644",
11775
+ "sha256": "9853fe32906b842e6c78dd024e33bee5c00649ed2de2f6b593d736d0155e7832"
11776
+ },
11772
11777
  {
11773
11778
  "path": "tests/goal_routes.mjs",
11774
11779
  "mode": "0000644",
@@ -6127,7 +6127,7 @@
6127
6127
  "goals"
6128
6128
  ],
6129
6129
  "summary": "POST /goals/:id/archive",
6130
- "description": "POST /goals/:id/archive — retire a goal (open|achieved → archived). Archived goals are re-openable (below). This is the lifecycle disposition the /goal-review skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64). rank: metic+archon — a goal member (Metic+) or an Archon dispositions the goal.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.archive` (all required).",
6130
+ "description": "POST /goals/:id/archive — retire a goal (open|achieved → archived). Archived goals are re-openable (below). This is the lifecycle disposition the /goal-review skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64). rank: metic+archon + owner|manager|Archon (task 1001739); archive is reversible, so a manager may.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.archive` (all required).",
6131
6131
  "x-rank": "metic+archon",
6132
6132
  "x-source": "modules/lifecycle/routes/goals.js",
6133
6133
  "x-permissions": [
@@ -7134,7 +7134,7 @@
7134
7134
  "goals"
7135
7135
  ],
7136
7136
  "summary": "POST /goals/:id/scope",
7137
- "description": "POST /goals/:id/scope — \"ask to widen\": add module(s) to a goal's scope_modules (ADR 0086 §4 / BV1.R64). Scope is NEVER self-granted, so this is the gated approve-and-apply: requireRank('metic','archon') + the caller must be a member (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the POST /goals create gate). A sub-Metic member therefore cannot widen at all — they ask a Metic+/Archon out-of-band, exactly as joining a protected goal works (R57). The goal must be open. Body: { add: [\"module-key\", ...] }; idempotent (set-union). rank: metic+archon — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.scope.widen` (all required).",
7137
+ "description": "POST /goals/:id/scope — \"ask to widen\": add module(s) to a goal's scope_modules (ADR 0086 §4 / BV1.R64). Scope is NEVER self-granted, so this is the gated approve-and-apply: the goal.scope.widen permission + the caller must be the goal's owner or a manager (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the POST /goals create gate). A plain member therefore cannot widen at all — they ask a Metic+/Archon out-of-band, exactly as joining a protected goal works (R57). The goal must be open. Body: { add: [\"module-key\", ...] }; idempotent (set-union). rank: metic+archon + owner|manager|Archon (task 1001739) — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.scope.widen` (all required).",
7138
7138
  "x-rank": "metic+archon",
7139
7139
  "x-source": "modules/lifecycle/routes/goals.js",
7140
7140
  "x-permissions": [
@@ -2466,5 +2466,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2466
2466
  landed since 1.19.994 with no explicit bump. run 35933457545. (task 1002620)
2467
2467
  1.19.996 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2468
2468
  landed since 1.19.995 with no explicit bump. run 35934673169. (task 1002620)
2469
+ 1.19.997 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2470
+ landed since 1.19.996 with no explicit bump. run 35937246865. (task 1002620)
2469
2471
  ---------------------------------------------------------------------------
2470
2472
  ```
@@ -14,10 +14,23 @@ const api = require('../../../src/module-api');
14
14
  const db = require('../db');
15
15
  const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
16
16
  const goalScopeCheck = api.goalScopeCheck;
17
- const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
17
+ const { isGoalOwner, isGoalOwnerOrManager, authorizeGoalManagement } = require('../goal-authz');
18
18
  const { versionAcceptsNewWork } = require('../db-tasks.js');
19
19
  const METIC_PLUS = ['metic', 'archon'];
20
20
 
21
+ // isOwnerManagerOrArchon — a goal's OWN authority (task 1001739): owner (created_by),
22
+ // manager ('lead' membership) or Archon. Gates archive / reopen / scope-widen; a plain
23
+ // member holds none of these, and no route deletes a goal. An OWNERLESS goal
24
+ // (created_by NULL — the migration-160 system goals) has no owner or manager to
25
+ // ask, so any member keeps the power they had before; only Archons would otherwise
26
+ // be left able to disposition it.
27
+ async function isOwnerManagerOrArchon(builder, goal) {
28
+ if (builder.rank === 'archon') return true;
29
+ if (goal.created_by == null) return db.isGoalMember({ goalId: goal.id, builderId: builder.id });
30
+ const actorMembership = await db.getGoalMember({ goalId: goal.id, builderId: builder.id });
31
+ return isGoalOwnerOrManager({ goal, actorId: builder.id, actorRank: builder.rank, actorMembership });
32
+ }
33
+
21
34
  async function isMemberOrArchon(builder, goalId) {
22
35
  if (builder.rank === 'archon') return true;
23
36
  return db.isGoalMember({ goalId, builderId: builder.id });
@@ -666,6 +679,7 @@ module.exports = {
666
679
  decorateGoalScope,
667
680
  goalNotOpenRefusal,
668
681
  isMemberOrArchon,
682
+ isOwnerManagerOrArchon,
669
683
  planGoalArchiveDispositions,
670
684
  protectedScopeAdmissionBlock,
671
685
  };
@@ -73,7 +73,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
73
73
  // proceed; a false result means "not a member" → the route should 403. This is the
74
74
  // membership half of the wall; the protected-module rank check is enforced per-route
75
75
  // (scope is never self-granted).
76
- const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, goalNotOpenRefusal, isMemberOrArchon, planGoalArchiveDispositions, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
76
+ const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, goalNotOpenRefusal, isMemberOrArchon, isOwnerManagerOrArchon, planGoalArchiveDispositions, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
77
77
  // task 1003208: structured logging (pino via the doorway) — was console.*.
78
78
  const log = api.logger('lifecycle');
79
79
  module.exports = function buildGoalsRouter() {
@@ -1099,7 +1099,7 @@ module.exports = function buildGoalsRouter() {
1099
1099
  // POST /goals/:id/archive — retire a goal (open|achieved → archived). Archived
1100
1100
  // goals are re-openable (below). This is the lifecycle disposition the /goal-review
1101
1101
  // skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64).
1102
- // rank: metic+archon — a goal member (Metic+) or an Archon dispositions the goal.
1102
+ // rank: metic+archon + owner|manager|Archon (task 1001739); archive is reversible, so a manager may.
1103
1103
  router.post('/goals/:id/archive', auth.requireBuilder, auth.requirePermission('goal.archive'), async (req, res) => {
1104
1104
  // ADR 0118 / R12: every write route that reads a body declares it, so an
1105
1105
  // unknown field is REJECTED rather than silently ignored — a typo'd
@@ -1117,8 +1117,8 @@ module.exports = function buildGoalsRouter() {
1117
1117
  try {
1118
1118
  const goal = await db.getGoal(id);
1119
1119
  if (!goal) return res.fail('goal_not_found', 404);
1120
- if (!(await isMemberOrArchon(req.builder, id))) {
1121
- return res.fail('not_a_member', { status: 403, message: 'Join the goal (or be an Archon) to disposition it.' });
1120
+ if (!(await isOwnerManagerOrArchon(req.builder, goal))) {
1121
+ return res.fail('not_owner_or_manager', { status: 403, message: 'Only the goal owner or a manager (or an Archon) may archive it.' });
1122
1122
  }
1123
1123
  if (goal.status === 'archived') {
1124
1124
  return res.fail('already_archived', 409);
@@ -1241,8 +1241,8 @@ module.exports = function buildGoalsRouter() {
1241
1241
  try {
1242
1242
  const goal = await db.getGoal(id);
1243
1243
  if (!goal) return res.fail('goal_not_found', 404);
1244
- if (!(await isMemberOrArchon(req.builder, id))) {
1245
- return res.fail('not_a_member', { status: 403, message: 'Join the goal (or be an Archon) to reopen it.' });
1244
+ if (!(await isOwnerManagerOrArchon(req.builder, goal))) {
1245
+ return res.fail('not_owner_or_manager', { status: 403, message: 'Only the goal owner or a manager (or an Archon) may reopen it.' });
1246
1246
  }
1247
1247
  if (goal.status !== 'archived') {
1248
1248
  return res.fail('not_archived', 409, { status: goal.status });
@@ -1257,12 +1257,12 @@ module.exports = function buildGoalsRouter() {
1257
1257
 
1258
1258
  // POST /goals/:id/scope — "ask to widen": add module(s) to a goal's scope_modules
1259
1259
  // (ADR 0086 §4 / BV1.R64). Scope is NEVER self-granted, so this is the gated
1260
- // approve-and-apply: requireRank('metic','archon') + the caller must be a member
1261
- // (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the
1262
- // POST /goals create gate). A sub-Metic member therefore cannot widen at all — they
1260
+ // approve-and-apply: the goal.scope.widen permission + the caller must be the goal's
1261
+ // owner or a manager (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the
1262
+ // POST /goals create gate). A plain member therefore cannot widen at all — they
1263
1263
  // ask a Metic+/Archon out-of-band, exactly as joining a protected goal works (R57).
1264
1264
  // The goal must be open. Body: { add: ["module-key", ...] }; idempotent (set-union).
1265
- // rank: metic+archon — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.
1265
+ // rank: metic+archon + owner|manager|Archon (task 1001739) — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.
1266
1266
  router.post('/goals/:id/scope', auth.requireBuilder, auth.requirePermission('goal.scope.widen'), async (req, res) => {
1267
1267
  const id = parseId(req, res, { code: 'bad_goal_id' });
1268
1268
  if (id === null) return;
@@ -1288,8 +1288,8 @@ module.exports = function buildGoalsRouter() {
1288
1288
  if (goal.status !== 'open') {
1289
1289
  return res.fail('goal_not_open', 409, { status: goal.status });
1290
1290
  }
1291
- if (!(await isMemberOrArchon(req.builder, id))) {
1292
- return res.fail('not_a_member', { status: 403, message: 'Join the goal (or be an Archon) to widen its scope.' });
1291
+ if (!(await isOwnerManagerOrArchon(req.builder, goal))) {
1292
+ return res.fail('not_owner_or_manager', { status: 403, message: 'Only the goal owner or a manager (or an Archon) may widen its scope.' });
1293
1293
  }
1294
1294
  const updated = await db.addGoalScopeModules({ goalId: id, modules: add });
1295
1295
  return res.json({ ok: true, goal: updated });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.996",
3
+ "version": "1.19.997",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.996",
9
+ "version": "1.19.997",
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.996",
3
+ "version": "1.19.997",
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",
@@ -216,7 +216,7 @@ async function runProbes(ctx) {
216
216
  const r = await probePost(`/goals/${target.id}/archive`, {});
217
217
  const after = (await get(`/goals/${target.id}`)).goal;
218
218
  out.push({ absent: r.status === 404, id: 'P2', rule: `R10 — bare archive of goal ${target.id} (holds ${held.get(String(target.id))})`,
219
- status: r.status, refused: r.status === 409, error: r.why, mutated: after.status !== 'open' });
219
+ status: r.status, refused: r.status === 409 || r.status === 403, error: r.why, mutated: after.status !== 'open' });
220
220
  }
221
221
 
222
222
  // P3 — R12: a version holding open goals refuses a close with no dispositions.
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.996'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.997'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -109,12 +109,12 @@ test('the refusal RETURNS the tasks, so the caller can decide without a second f
109
109
  });
110
110
 
111
111
  test('the check runs AFTER identity/authorization and BEFORE the status write', () => {
112
- const auth = ARCHIVE.indexOf('isMemberOrArchon');
112
+ const auth = ARCHIVE.indexOf('isOwnerManagerOrArchon');
113
113
  const already = ARCHIVE.indexOf("'already_archived'");
114
114
  const gate = ARCHIVE.indexOf('openTasksInGoal');
115
115
  const write = ARCHIVE.indexOf('db.setGoalStatus');
116
116
  assert.ok(auth !== -1 && already !== -1 && gate !== -1 && write !== -1, 'all four landmarks present');
117
- assert.ok(auth < gate, 'a non-member must not learn what a goal is holding');
117
+ assert.ok(auth < gate, 'a non-owner must not learn what a goal is holding');
118
118
  assert.ok(already < gate, 're-archiving an archived goal keeps its own answer');
119
119
  assert.ok(gate < write, 'a refusal after the write is not a refusal');
120
120
  });
@@ -0,0 +1,90 @@
1
+ // tests/goal_owner_manager_powers.mjs — archive / reopen / scope-widen belong to a
2
+ // goal's own authority (task 1001739): the owner (created_by), a manager (a 'lead'
3
+ // membership) or an Archon. A plain member — who used to pass — is refused 403
4
+ // not_owner_or_manager. Drives the REAL router with the db layer stubbed.
5
+ //
6
+ // Run: node tests/goal_owner_manager_powers.mjs
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { createRequire } from 'node:module';
10
+ const require = createRequire(import.meta.url);
11
+ process.env.NODE_ENV = 'test';
12
+
13
+ const express = require('express');
14
+ const api = require('../src/module-api.js');
15
+ const db = require('../modules/lifecycle/db.js');
16
+
17
+ let WHO = { id: '10', rank: 'metic' };
18
+ api.requireBuilder = (req, _res, next) => { req.builder = WHO; next(); };
19
+ api.requirePermission = () => (req, _res, next) => next();
20
+
21
+ const OWNER = '10', MANAGER = '11', MEMBER = '12';
22
+ const GOALS = {
23
+ 1: { id: 1, status: 'open', title: 'g', version_id: 'V', created_by: OWNER, scope_modules: [] },
24
+ 3: { id: 3, status: 'open', title: 'system', version_id: 'V', created_by: null, scope_modules: [] },
25
+ 2: { id: 2, status: 'archived', title: 'a', version_id: 'V', created_by: OWNER, scope_modules: [] },
26
+ };
27
+ const KINDS = { [MANAGER]: 'lead', [MEMBER]: 'member' };
28
+ db.getGoal = async (id) => GOALS[Number(id)] ?? null;
29
+ db.getGoalMember = async ({ builderId }) => (KINDS[builderId] ? { builder_id: builderId, membership_kind: KINDS[builderId] } : null);
30
+ db.isGoalMember = async ({ builderId }) => !!KINDS[builderId] || builderId === OWNER;
31
+ db.getVersion = async () => null;
32
+ db.openTasksInGoal = async () => ({ total: 0, tasks: [] });
33
+ db.archiveGoalWithDispositions = async ({ goalId }) => ({ goal: { ...GOALS[goalId], status: 'archived' }, applied: [] });
34
+ db.setGoalStatus = async ({ goalId, status }) => ({ ...GOALS[goalId], status });
35
+ db.addGoalScopeModules = async ({ goalId }) => GOALS[goalId];
36
+
37
+ const app = express();
38
+ app.use(express.json());
39
+ app.use((req, res, next) => {
40
+ res.fail = (code, o, d) => { const x = typeof o === 'number' ? { status: o } : (o || {}); return res.status(x.status || 400).json({ error: code, details: x.details ?? d }); };
41
+ next();
42
+ });
43
+ app.use('/api/gds', require('../modules/lifecycle/routes/goals.js')());
44
+ const server = app.listen(0);
45
+ await new Promise(r => server.once('listening', r));
46
+ const base = `http://127.0.0.1:${server.address().port}/api/gds`;
47
+ const post = async (p, body) => {
48
+ const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
49
+ return { status: r.status, body: await r.json().catch(() => null) };
50
+ };
51
+ const calls = [
52
+ ['/goals/1/archive', {}],
53
+ ['/goals/2/reopen', {}],
54
+ ['/goals/1/scope', { add: ['hall-ui'] }],
55
+ ];
56
+ let n = 0;
57
+ for (const [who, label, expectOk] of [
58
+ [{ id: OWNER, rank: 'metic' }, 'owner', true],
59
+ [{ id: MANAGER, rank: 'metic' }, 'manager', true],
60
+ [{ id: '99', rank: 'archon' }, 'archon', true],
61
+ [{ id: MEMBER, rank: 'metic' }, 'plain member', false],
62
+ [{ id: '77', rank: 'metic' }, 'non-member', false],
63
+ ]) {
64
+ WHO = who;
65
+ for (const [path, body] of calls) {
66
+ const r = await post(path, body);
67
+ if (expectOk) assert.equal(r.status, 200, `${label} ${path} → ${r.status} ${JSON.stringify(r.body)}`);
68
+ else { assert.equal(r.status, 403, `${label} ${path}`); assert.equal(r.body.error, 'not_owner_or_manager'); }
69
+ n++;
70
+ }
71
+ }
72
+ // An OWNERLESS goal (created_by NULL, the system catch-alls): a member keeps the
73
+ // power, a non-member has none — otherwise only an Archon could ever disposition it.
74
+ WHO = { id: MEMBER, rank: 'metic' };
75
+ assert.equal((await post('/goals/3/archive')).status, 200); n++;
76
+ WHO = { id: '77', rank: 'metic' };
77
+ assert.equal((await post('/goals/3/archive')).status, 403); n++;
78
+
79
+ // POST /goals creation gate: a protected-module scope is Archon-only.
80
+ WHO = { id: OWNER, rank: 'metic' };
81
+ let c = await post('/goals', { version_id: 'V', title: 't', scope_modules: ['kernel'] });
82
+ assert.equal(c.status, 403); assert.equal(c.body.error, 'archon_required_for_protected_scope'); n++;
83
+ c = await post('/goals', { version_id: 'V', title: 't', scope_modules: ['hall-ui'], admit_reason: 'because reasons' });
84
+ assert.equal(c.body.error, 'archon_required_for_admission'); n++;
85
+ WHO = { id: '99', rank: 'archon' };
86
+ c = await post('/goals', { version_id: 'V', title: 't', scope_modules: ['kernel'] });
87
+ assert.notEqual(c.body && c.body.error, 'archon_required_for_protected_scope'); n++;
88
+
89
+ console.log(`${n} owner/manager/member checks passed against the real router.`);
90
+ server.close();