@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 +21 -16
- package/docs/api/openapi.json +2 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/lifecycle/routes/goal-route-authz.js +15 -1
- package/modules/lifecycle/routes/goals.js +12 -12
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/prove-strict-versioning.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_archive_disposition.mjs +2 -2
- package/tests/goal_owner_manager_powers.mjs +90 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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":
|
|
14
|
+
"functional_verbatim": 2406,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
6090
|
+
"sha256": "89d282e6c5110ae698484ddab698a72f27c6fe509fc97ff628326fe42e79483d"
|
|
6091
6091
|
},
|
|
6092
6092
|
{
|
|
6093
6093
|
"path": "modules/lifecycle/routes/goals.js",
|
|
6094
6094
|
"mode": "0000644",
|
|
6095
|
-
"sha256": "
|
|
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": "
|
|
8190
|
+
"sha256": "93a81b5f6bc16e21a3ad6abd52490ccfbefe0f2d11af8dfd6273f03dd0c91ca0"
|
|
8191
8191
|
},
|
|
8192
8192
|
{
|
|
8193
8193
|
"path": "package.json",
|
|
8194
8194
|
"mode": "0000644",
|
|
8195
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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",
|
package/docs/api/openapi.json
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
|
1121
|
-
return res.fail('
|
|
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
|
|
1245
|
-
return res.fail('
|
|
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:
|
|
1261
|
-
// (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the
|
|
1262
|
-
// POST /goals create gate). A
|
|
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
|
|
1292
|
-
return res.fail('
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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('
|
|
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-
|
|
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();
|