@bongos/core 1.19.238 → 1.19.239
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 +40 -30
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0177-goal-membership-kind.md +96 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -5
- package/docs/api-reference.md +2 -2
- package/docs/canonical-permissions.md +3 -1
- package/migrations/core_220_goal_members_membership_kind.sql +100 -0
- package/modules/hall-ui/public/goals-page.js +15 -13
- package/modules/hall-ui/public/goals.css +2 -2
- package/modules/lifecycle/db.js +40 -32
- package/modules/lifecycle/goal-authz.js +10 -7
- package/modules/lifecycle/routes/done-when.js +3 -3
- package/modules/lifecycle/routes/goals.js +43 -36
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +68 -0
- package/src/module-api.js +3 -1
- package/tests/criterion_authoring_gate.mjs +4 -4
- package/tests/goal_conflicts.mjs +3 -3
- package/tests/{goal_member_roles.mjs → goal_member_kinds.mjs} +89 -80
- package/tests/goal_membership_requests.mjs +10 -10
- package/tests/goal_routes.mjs +8 -8
- package/tests/goal_tier.mjs +10 -10
package/scripts/gds/fitness.js
CHANGED
|
@@ -1741,6 +1741,71 @@ function vocabFilesToScan() {
|
|
|
1741
1741
|
return out;
|
|
1742
1742
|
}
|
|
1743
1743
|
|
|
1744
|
+
// ── Check 19b — the FOURTH "role": a goal membership is not a craft either ──
|
|
1745
|
+
//
|
|
1746
|
+
// task 1003013 / ADR 0177. ADR 0174 reserved `role` for the three crafts and R07
|
|
1747
|
+
// (checkVocabularyWall above) enforces it on the GOVERNMENT surface — deliberately
|
|
1748
|
+
// only there, because `builders.system_role` is a core machine-identity column and
|
|
1749
|
+
// Discord's API owns its own "role" noun. Goal membership was a fourth,
|
|
1750
|
+
// independent use of the word (`goal_members.role` = 'lead' | 'member'), which is
|
|
1751
|
+
// exactly why it survived the rename: the rule was silent on it, as designed.
|
|
1752
|
+
//
|
|
1753
|
+
// So this is a SECOND narrow wall rather than a widened first one — same reason the
|
|
1754
|
+
// first was scoped: the honest unit is a SURFACE, not the repo. The surface here is
|
|
1755
|
+
// the goal-membership one: the routes and pure gate that decide it, the hall page
|
|
1756
|
+
// that renders it, and (line-scoped) the membership SQL in the module's db.js. The
|
|
1757
|
+
// noun on it is `membership_kind`.
|
|
1758
|
+
//
|
|
1759
|
+
// Naming the rule stays legal — a line that cites ADR 0174 / ADR 0177 / task
|
|
1760
|
+
// 1003013 is meta-commentary about the ban, the same principle as
|
|
1761
|
+
// VOCAB_HISTORICAL_REF above. `role="…"` is stripped first: that is HTML's ARIA
|
|
1762
|
+
// attribute, a different word spelled the same.
|
|
1763
|
+
const GOAL_VOCAB_SURFACE = [
|
|
1764
|
+
'modules/lifecycle/routes/goals.js',
|
|
1765
|
+
'modules/lifecycle/goal-authz.js',
|
|
1766
|
+
'modules/lifecycle/routes/done-when.js',
|
|
1767
|
+
'modules/hall-ui/public/goals-page.js',
|
|
1768
|
+
'modules/hall-ui/public/goals.html',
|
|
1769
|
+
'modules/hall-ui/public/goals.css',
|
|
1770
|
+
];
|
|
1771
|
+
// modules/lifecycle/db.js is 5k+ lines and legitimately says "role" about ranks
|
|
1772
|
+
// (the rank-sync port, builders.system_role). Scanning it whole would need an
|
|
1773
|
+
// allowlist longer than the rule; scanning the LINES THAT NAME THE TABLE catches
|
|
1774
|
+
// the only thing that can regress here — a membership query reaching for the old
|
|
1775
|
+
// column — with no false positives to excuse.
|
|
1776
|
+
const GOAL_VOCAB_LINE_SCOPED = [{ file: 'modules/lifecycle/db.js', mustMention: /goal_members/ }];
|
|
1777
|
+
const GOAL_VOCAB_CITATION = /ADR 017[47]|task 1003013/;
|
|
1778
|
+
|
|
1779
|
+
function checkGoalMembershipVocabulary() {
|
|
1780
|
+
const violations = [];
|
|
1781
|
+
let scanned = 0;
|
|
1782
|
+
const scanFile = (rel, lineFilter = null) => {
|
|
1783
|
+
const abs = path.join(CORE_ROOT, rel);
|
|
1784
|
+
let src;
|
|
1785
|
+
try { src = fs.readFileSync(abs, 'utf8'); } catch { return; }
|
|
1786
|
+
scanned += 1;
|
|
1787
|
+
src.split('\n').forEach((line, i) => {
|
|
1788
|
+
if (lineFilter && !lineFilter.test(line)) return;
|
|
1789
|
+
const scanLine = line.replace(/\srole\s*=\s*("[^"]*"|'[^']*')/g, ' ');
|
|
1790
|
+
if (!/\brole/i.test(scanLine)) return;
|
|
1791
|
+
if (GOAL_VOCAB_CITATION.test(scanLine)) return;
|
|
1792
|
+
violations.push(`${rel}:${i + 1}: "role" — a goal membership is 'membership_kind' (ADR 0174 reserves "role" for the crafts; ADR 0177). → ${line.trim().slice(0, 90)}`);
|
|
1793
|
+
});
|
|
1794
|
+
};
|
|
1795
|
+
for (const rel of GOAL_VOCAB_SURFACE) scanFile(rel);
|
|
1796
|
+
for (const { file, mustMention } of GOAL_VOCAB_LINE_SCOPED) scanFile(file, mustMention);
|
|
1797
|
+
return {
|
|
1798
|
+
name: 'a goal membership is a "membership_kind", never a role (ADR 0177)',
|
|
1799
|
+
ok: violations.length === 0,
|
|
1800
|
+
hardFail: violations.length > 0,
|
|
1801
|
+
violations,
|
|
1802
|
+
warnings: [],
|
|
1803
|
+
note: `${scanned} goal-membership-surface file(s) scanned (${GOAL_VOCAB_SURFACE.length} whole + `
|
|
1804
|
+
+ `${GOAL_VOCAB_LINE_SCOPED.length} line-scoped to the lines naming goal_members); no allowlist — `
|
|
1805
|
+
+ 'a line that CITES the rule is the only exemption.',
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1744
1809
|
function checkVocabularyWall() {
|
|
1745
1810
|
const violations = [];
|
|
1746
1811
|
let scanned = 0;
|
|
@@ -1799,6 +1864,7 @@ const CHECKS = [
|
|
|
1799
1864
|
checkCoreRequiresExcludedModule, // Check 16b — task 2135: core→excluded-module require guard (R89 split)
|
|
1800
1865
|
checkErrorEnvelope, // Check 17 — BV1 R03 / task 1990: no route hand-rolls a flat error (ADR 0116)
|
|
1801
1866
|
checkVocabularyWall, // Check 19 — ADR 0174 / goal 1000068 R07: "role" is a craft, never a rank
|
|
1867
|
+
checkGoalMembershipVocabulary, // Check 19b — ADR 0177 / task 1003013: the FOURTH "role" (goal membership)
|
|
1802
1868
|
checkWriteRoutesValidated, // Check 18 — BV1 R12 / task 1999: every body-reading write route validates (ADR 0118)
|
|
1803
1869
|
];
|
|
1804
1870
|
|
|
@@ -1830,6 +1896,8 @@ if (require.main === module) main();
|
|
|
1830
1896
|
module.exports = {
|
|
1831
1897
|
checkVocabularyWall,
|
|
1832
1898
|
VOCAB_ALLOW,
|
|
1899
|
+
checkGoalMembershipVocabulary,
|
|
1900
|
+
GOAL_VOCAB_SURFACE,
|
|
1833
1901
|
runAll,
|
|
1834
1902
|
resolvesToHostGame,
|
|
1835
1903
|
checkCoreHostBoundary,
|
package/src/module-api.js
CHANGED
|
@@ -903,8 +903,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
903
903
|
// landed since 1.19.236 with no explicit bump. run 32164526454. (task 1002620)
|
|
904
904
|
// 1.19.238 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
905
905
|
// landed since 1.19.237 with no explicit bump. run 32172843539. (task 1002620)
|
|
906
|
+
// 1.19.239 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
907
|
+
// landed since 1.19.238 with no explicit bump. run 32174983930. (task 1002620)
|
|
906
908
|
// ---------------------------------------------------------------------------
|
|
907
|
-
const CORE_VERSION = '1.19.
|
|
909
|
+
const CORE_VERSION = '1.19.239'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
908
910
|
|
|
909
911
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
910
912
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -50,8 +50,8 @@ const G_ACHIEVED = { id: 43, version_id: 'BONGOS-V1', status: 'achieved', create
|
|
|
50
50
|
const G_ARCHIVED = { id: 44, version_id: 'BONGOS-V1', status: 'archived', created_by: OWNER };
|
|
51
51
|
const G_SYSTEM = { id: 45, version_id: 'BONGOS-V1', status: 'open', created_by: null };
|
|
52
52
|
|
|
53
|
-
const LEAD = {
|
|
54
|
-
const PLAIN = {
|
|
53
|
+
const LEAD = { membership_kind: 'lead' };
|
|
54
|
+
const PLAIN = { membership_kind: 'member' };
|
|
55
55
|
|
|
56
56
|
const ask = (o) => authorizeCriterionCreate({ version: V_BUILDING, actorRank: 'metic', ...o });
|
|
57
57
|
|
|
@@ -62,12 +62,12 @@ await test('the goal OWNER may author their goal\'s criterion', () => {
|
|
|
62
62
|
assert.equal(d.ok, true);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
await test('owner authority is created_by, not the
|
|
65
|
+
await test('owner authority is created_by, not the membership row — an owner with no membership still passes', () => {
|
|
66
66
|
const d = ask({ goal: G_OPEN, actorId: OWNER, actorMembership: null });
|
|
67
67
|
assert.equal(d.ok, true, 'created_by is the fixed authority (goal-authz isGoalOwner)');
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
await test('a MANAGER (goal_members.
|
|
70
|
+
await test('a MANAGER (goal_members.membership_kind=lead) may author', () => {
|
|
71
71
|
const d = ask({ goal: G_OPEN, actorId: MANAGER, actorMembership: LEAD });
|
|
72
72
|
assert.equal(d.ok, true);
|
|
73
73
|
});
|
package/tests/goal_conflicts.mjs
CHANGED
|
@@ -162,8 +162,8 @@ await test('detector: no inputs → no conflicts (and never throws)', () => {
|
|
|
162
162
|
// =========================================================================
|
|
163
163
|
|
|
164
164
|
const OPEN_GOAL = { id: 7, status: 'open', created_by: '1' };
|
|
165
|
-
const manager = (builderId) => ({ goal_id: 7, builder_id: builderId,
|
|
166
|
-
const member = (builderId) => ({ goal_id: 7, builder_id: builderId,
|
|
165
|
+
const manager = (builderId) => ({ goal_id: 7, builder_id: builderId, membership_kind: 'lead' });
|
|
166
|
+
const member = (builderId) => ({ goal_id: 7, builder_id: builderId, membership_kind: 'member' });
|
|
167
167
|
|
|
168
168
|
await test('gate: owner / manager / archon pass; ordering goal_not_found → goal_not_open → not_owner_or_manager', () => {
|
|
169
169
|
for (const actor of [
|
|
@@ -182,7 +182,7 @@ await test('gate: owner / manager / archon pass; ordering goal_not_found → goa
|
|
|
182
182
|
assert.equal(denied.body.error, 'not_owner_or_manager', 'a plain member cannot manage');
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
-
await test('gate: owner authority is created_by, not the
|
|
185
|
+
await test('gate: owner authority is created_by, not the membership row', () => {
|
|
186
186
|
const d = authorizeGoalManagement({ goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: member(1) });
|
|
187
187
|
assert.deepEqual(d, { ok: true });
|
|
188
188
|
});
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
// tests/
|
|
1
|
+
// tests/goal_member_kinds.mjs — the goal owner/manager authority model (task 1735).
|
|
2
2
|
//
|
|
3
3
|
// The model: the OWNER is goals.created_by — the fixed authority, set at create
|
|
4
|
-
// and moved only via POST /goals/:id/transfer. MANAGER is the goal_members.
|
|
4
|
+
// and moved only via POST /goals/:id/transfer. MANAGER is the goal_members.membership_kind
|
|
5
5
|
// 'lead' value (the hall renders it "manager"), owner-equivalent EXCEPT that a
|
|
6
6
|
// manager cannot demote a fellow manager (owner-only) and cannot transfer
|
|
7
7
|
// ownership. The owner's own row is never demotable. This file pins, in the
|
|
8
8
|
// goal_routes pattern:
|
|
9
9
|
//
|
|
10
|
-
// 1. the PURE ordered gates (
|
|
10
|
+
// 1. the PURE ordered gates (authorizeMembershipKindChange +
|
|
11
11
|
// authorizeOwnershipTransfer) — every rejection, DB-free, ordering
|
|
12
12
|
// included (the authorizeGoalTaskCreate precedent);
|
|
13
13
|
// 2. the request-validation schema of the PATCH route;
|
|
14
14
|
// 3. the db helpers' SQL contracts via the SQL-aware fake pool —
|
|
15
|
-
// getGoalMember /
|
|
15
|
+
// getGoalMember / setGoalMembershipKind (the atomic owner-demotion guard) /
|
|
16
16
|
// transferGoalOwnership (atomic created_by move + lead upsert);
|
|
17
17
|
// createGoal's creator-becomes-owner CTE lives in tests/goal_tier.mjs;
|
|
18
18
|
// 4. the route-rank PINS: both routes stay 'any-builder' (the wall is
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
// B manager promotes a member → manager → ALLOW (owner-equivalent)
|
|
27
27
|
// C owner demotes a manager → member → ALLOW
|
|
28
28
|
// D manager demotes a fellow manager → 403 owner_only_demotion
|
|
29
|
-
// E owner/Archon demotes the OWNER's row → 409
|
|
30
|
-
// F plain member tries any
|
|
31
|
-
// G non-member Metic tries any
|
|
29
|
+
// E owner/Archon demotes the OWNER's row → 409 owner_membership_fixed
|
|
30
|
+
// F plain member tries any membership change → 403 not_owner_or_manager
|
|
31
|
+
// G non-member Metic tries any membership change → 403 not_owner_or_manager
|
|
32
32
|
// H Archon (not a member) promotes and demotes → ALLOW (the Archon arm)
|
|
33
33
|
// I target isn't a member → 404 not_a_member
|
|
34
34
|
// J goal doesn't exist → 404 goal_not_found
|
|
35
35
|
// K goal not open (achieved/archived) → 409 goal_not_open
|
|
36
|
-
// L
|
|
36
|
+
// L membership already as requested → no-op (ok, changed:false)
|
|
37
37
|
// O1 ordering: unauthorized actor + non-member target → not_owner_or_manager (no membership leak)
|
|
38
38
|
// O2 ordering: closed goal short-circuits everything → goal_not_open
|
|
39
39
|
//
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
// T6 transfer to the current owner → no-op
|
|
48
48
|
// T7 closed goal / unknown goal → 409 / 404
|
|
49
49
|
//
|
|
50
|
-
// Run: node tests/
|
|
50
|
+
// Run: node tests/goal_member_kinds.mjs
|
|
51
51
|
|
|
52
52
|
import { strict as assert } from 'node:assert';
|
|
53
53
|
import { createRequire } from 'node:module';
|
|
@@ -57,8 +57,8 @@ process.env.NODE_ENV = 'test';
|
|
|
57
57
|
|
|
58
58
|
const require = createRequire(import.meta.url);
|
|
59
59
|
const { validate } = require('../src/bongos/routes/_helpers.js');
|
|
60
|
-
const {
|
|
61
|
-
const { getGoalMember,
|
|
60
|
+
const { authorizeMembershipKindChange, authorizeOwnershipTransfer } = require('../modules/lifecycle/routes/goals.js');
|
|
61
|
+
const { getGoalMember, setGoalMembershipKind, transferGoalOwnership } = require('../modules/lifecycle/db.js');
|
|
62
62
|
const { MODULE_EXPECTED_RANKS } = require('../src/bongos/route-rank-check.js');
|
|
63
63
|
|
|
64
64
|
const { test, summary } = makeRunner();
|
|
@@ -74,49 +74,49 @@ const find = (queries, re) => queries.find((q) => re.test(String(q.sql).replace(
|
|
|
74
74
|
// must compare loosely (String-normalized), exactly as the route receives it.
|
|
75
75
|
const OPEN_GOAL = { id: 7, status: 'open', created_by: '1' };
|
|
76
76
|
const ORPHAN_GOAL = { id: 8, status: 'open', created_by: null }; // migration-160 default
|
|
77
|
-
const manager = (builderId) => ({ goal_id: 7, builder_id: builderId,
|
|
78
|
-
const member = (builderId) => ({ goal_id: 7, builder_id: builderId,
|
|
77
|
+
const manager = (builderId) => ({ goal_id: 7, builder_id: builderId, membership_kind: 'lead', joined_at: 'T0' });
|
|
78
|
+
const member = (builderId) => ({ goal_id: 7, builder_id: builderId, membership_kind: 'member', joined_at: 'T1' });
|
|
79
79
|
|
|
80
80
|
// =========================================================================
|
|
81
|
-
// 1a.
|
|
81
|
+
// 1a. authorizeMembershipKindChange — the membership-change matrix
|
|
82
82
|
// =========================================================================
|
|
83
83
|
|
|
84
84
|
await test('A: the owner promotes a member → manager — ALLOW', () => {
|
|
85
|
-
const d =
|
|
85
|
+
const d = authorizeMembershipKindChange({
|
|
86
86
|
goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: manager(1),
|
|
87
|
-
targetMembership: member(2), targetId: 2,
|
|
87
|
+
targetMembership: member(2), targetId: 2, membershipKind: 'lead',
|
|
88
88
|
});
|
|
89
89
|
assert.deepEqual(d, { ok: true });
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
await test('A2: owner authority is created_by, not the
|
|
93
|
-
const d =
|
|
92
|
+
await test('A2: owner authority is created_by, not the membership row — owner with a plain member row still promotes', () => {
|
|
93
|
+
const d = authorizeMembershipKindChange({
|
|
94
94
|
goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: member(1),
|
|
95
|
-
targetMembership: member(2), targetId: 2,
|
|
95
|
+
targetMembership: member(2), targetId: 2, membershipKind: 'lead',
|
|
96
96
|
});
|
|
97
97
|
assert.deepEqual(d, { ok: true });
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
await test('B: a manager promotes a member → manager — ALLOW (owner-equivalent)', () => {
|
|
101
|
-
const d =
|
|
101
|
+
const d = authorizeMembershipKindChange({
|
|
102
102
|
goal: OPEN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
|
|
103
|
-
targetMembership: member(2), targetId: 2,
|
|
103
|
+
targetMembership: member(2), targetId: 2, membershipKind: 'lead',
|
|
104
104
|
});
|
|
105
105
|
assert.deepEqual(d, { ok: true });
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
await test('C: the owner demotes a manager → member — ALLOW', () => {
|
|
109
|
-
const d =
|
|
109
|
+
const d = authorizeMembershipKindChange({
|
|
110
110
|
goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: manager(1),
|
|
111
|
-
targetMembership: manager(3), targetId: 3,
|
|
111
|
+
targetMembership: manager(3), targetId: 3, membershipKind: 'member',
|
|
112
112
|
});
|
|
113
113
|
assert.deepEqual(d, { ok: true });
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
await test('D: a manager cannot demote a fellow manager — 403 owner_only_demotion', () => {
|
|
117
|
-
const d =
|
|
117
|
+
const d = authorizeMembershipKindChange({
|
|
118
118
|
goal: OPEN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
|
|
119
|
-
targetMembership: manager(4), targetId: 4,
|
|
119
|
+
targetMembership: manager(4), targetId: 4, membershipKind: 'member',
|
|
120
120
|
});
|
|
121
121
|
assert.equal(d.ok, false);
|
|
122
122
|
assert.equal(d.status, 403);
|
|
@@ -124,32 +124,32 @@ await test('D: a manager cannot demote a fellow manager — 403 owner_only_demot
|
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
await test('D2: a manager cannot demote themselves either — same owner-only rule', () => {
|
|
127
|
-
const d =
|
|
127
|
+
const d = authorizeMembershipKindChange({
|
|
128
128
|
goal: OPEN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
|
|
129
|
-
targetMembership: manager(3), targetId: 3,
|
|
129
|
+
targetMembership: manager(3), targetId: 3, membershipKind: 'member',
|
|
130
130
|
});
|
|
131
131
|
assert.equal(d.body.error, 'owner_only_demotion');
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
await test('E: the OWNER row is never demotable — 409
|
|
134
|
+
await test('E: the OWNER row is never demotable — 409 owner_membership_fixed (even self / Archon)', () => {
|
|
135
135
|
for (const actor of [
|
|
136
136
|
{ actorId: 1, actorRank: 'metic', actorMembership: manager(1) }, // owner themselves
|
|
137
137
|
{ actorId: 99, actorRank: 'archon', actorMembership: null }, // an Archon
|
|
138
138
|
]) {
|
|
139
|
-
const d =
|
|
139
|
+
const d = authorizeMembershipKindChange({
|
|
140
140
|
goal: OPEN_GOAL, ...actor,
|
|
141
|
-
targetMembership: manager(1), targetId: 1,
|
|
141
|
+
targetMembership: manager(1), targetId: 1, membershipKind: 'member',
|
|
142
142
|
});
|
|
143
143
|
assert.equal(d.ok, false, `${actor.actorRank} demoting the owner must refuse`);
|
|
144
144
|
assert.equal(d.status, 409);
|
|
145
|
-
assert.equal(d.body.error, '
|
|
145
|
+
assert.equal(d.body.error, 'owner_membership_fixed');
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
await test('F: a plain member cannot change
|
|
150
|
-
const d =
|
|
149
|
+
await test('F: a plain member cannot change memberships — 403 not_owner_or_manager', () => {
|
|
150
|
+
const d = authorizeMembershipKindChange({
|
|
151
151
|
goal: OPEN_GOAL, actorId: 2, actorRank: 'metic', actorMembership: member(2),
|
|
152
|
-
targetMembership: member(5), targetId: 5,
|
|
152
|
+
targetMembership: member(5), targetId: 5, membershipKind: 'lead',
|
|
153
153
|
});
|
|
154
154
|
assert.equal(d.ok, false);
|
|
155
155
|
assert.equal(d.status, 403);
|
|
@@ -157,38 +157,38 @@ await test('F: a plain member cannot change roles — 403 not_owner_or_manager',
|
|
|
157
157
|
});
|
|
158
158
|
|
|
159
159
|
await test('G: a non-member (whatever their rank below archon) — 403 not_owner_or_manager', () => {
|
|
160
|
-
const d =
|
|
160
|
+
const d = authorizeMembershipKindChange({
|
|
161
161
|
goal: OPEN_GOAL, actorId: 9, actorRank: 'metic', actorMembership: null,
|
|
162
|
-
targetMembership: member(2), targetId: 2,
|
|
162
|
+
targetMembership: member(2), targetId: 2, membershipKind: 'lead',
|
|
163
163
|
});
|
|
164
164
|
assert.equal(d.body.error, 'not_owner_or_manager');
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
await test('H: an Archon who is not a member may promote AND demote — the Archon arm', () => {
|
|
168
|
-
const promote =
|
|
168
|
+
const promote = authorizeMembershipKindChange({
|
|
169
169
|
goal: OPEN_GOAL, actorId: 99, actorRank: 'archon', actorMembership: null,
|
|
170
|
-
targetMembership: member(2), targetId: 2,
|
|
170
|
+
targetMembership: member(2), targetId: 2, membershipKind: 'lead',
|
|
171
171
|
});
|
|
172
172
|
assert.deepEqual(promote, { ok: true });
|
|
173
|
-
const demote =
|
|
173
|
+
const demote = authorizeMembershipKindChange({
|
|
174
174
|
goal: OPEN_GOAL, actorId: 99, actorRank: 'archon', actorMembership: null,
|
|
175
|
-
targetMembership: manager(3), targetId: 3,
|
|
175
|
+
targetMembership: manager(3), targetId: 3, membershipKind: 'member',
|
|
176
176
|
});
|
|
177
177
|
assert.deepEqual(demote, { ok: true });
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
await test('H2: an ownerless goal (created_by NULL) is Archon-run — a manager there still cannot demote', () => {
|
|
181
|
-
const d =
|
|
181
|
+
const d = authorizeMembershipKindChange({
|
|
182
182
|
goal: ORPHAN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
|
|
183
|
-
targetMembership: manager(4), targetId: 4,
|
|
183
|
+
targetMembership: manager(4), targetId: 4, membershipKind: 'member',
|
|
184
184
|
});
|
|
185
185
|
assert.equal(d.body.error, 'owner_only_demotion', 'no owner exists, so only an Archon demotes');
|
|
186
186
|
});
|
|
187
187
|
|
|
188
188
|
await test('I: target is not a member — 404 not_a_member', () => {
|
|
189
|
-
const d =
|
|
189
|
+
const d = authorizeMembershipKindChange({
|
|
190
190
|
goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: manager(1),
|
|
191
|
-
targetMembership: null, targetId: 5,
|
|
191
|
+
targetMembership: null, targetId: 5, membershipKind: 'lead',
|
|
192
192
|
});
|
|
193
193
|
assert.equal(d.ok, false);
|
|
194
194
|
assert.equal(d.status, 404);
|
|
@@ -196,19 +196,19 @@ await test('I: target is not a member — 404 not_a_member', () => {
|
|
|
196
196
|
});
|
|
197
197
|
|
|
198
198
|
await test('J: unknown goal — 404 goal_not_found (fail-closed on missing input)', () => {
|
|
199
|
-
const d =
|
|
199
|
+
const d = authorizeMembershipKindChange({
|
|
200
200
|
goal: null, actorId: 99, actorRank: 'archon', actorMembership: null,
|
|
201
|
-
targetMembership: null, targetId: 2,
|
|
201
|
+
targetMembership: null, targetId: 2, membershipKind: 'lead',
|
|
202
202
|
});
|
|
203
203
|
assert.equal(d.status, 404);
|
|
204
204
|
assert.equal(d.body.error, 'goal_not_found');
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
-
await test('K: a non-open goal refuses
|
|
207
|
+
await test('K: a non-open goal refuses membership changes — 409 goal_not_open', () => {
|
|
208
208
|
for (const status of ['achieved', 'archived']) {
|
|
209
|
-
const d =
|
|
209
|
+
const d = authorizeMembershipKindChange({
|
|
210
210
|
goal: { id: 7, status, created_by: '1' }, actorId: 1, actorRank: 'metic',
|
|
211
|
-
actorMembership: manager(1), targetMembership: member(2), targetId: 2,
|
|
211
|
+
actorMembership: manager(1), targetMembership: member(2), targetId: 2, membershipKind: 'lead',
|
|
212
212
|
});
|
|
213
213
|
assert.equal(d.ok, false, `status=${status} must refuse`);
|
|
214
214
|
assert.equal(d.status, 409);
|
|
@@ -216,34 +216,34 @@ await test('K: a non-open goal refuses role changes — 409 goal_not_open', () =
|
|
|
216
216
|
}
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
-
await test('L:
|
|
220
|
-
const d =
|
|
219
|
+
await test('L: membership already as requested — no-op, not an error (idempotent PATCH)', () => {
|
|
220
|
+
const d = authorizeMembershipKindChange({
|
|
221
221
|
goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: manager(1),
|
|
222
|
-
targetMembership: manager(3), targetId: 3,
|
|
222
|
+
targetMembership: manager(3), targetId: 3, membershipKind: 'lead',
|
|
223
223
|
});
|
|
224
224
|
assert.deepEqual(d, { ok: true, noop: true });
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
await test('L2: the no-op wins over the demotion rules — re-demoting an already-member target', () => {
|
|
228
|
-
const d =
|
|
228
|
+
const d = authorizeMembershipKindChange({
|
|
229
229
|
goal: OPEN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
|
|
230
|
-
targetMembership: member(2), targetId: 2,
|
|
230
|
+
targetMembership: member(2), targetId: 2, membershipKind: 'member',
|
|
231
231
|
});
|
|
232
232
|
assert.deepEqual(d, { ok: true, noop: true }, 'a manager "demoting" a member to member is a no-op, not owner_only_demotion');
|
|
233
233
|
});
|
|
234
234
|
|
|
235
235
|
await test('O1: ordering — an unauthorized actor learns nothing about the target', () => {
|
|
236
|
-
const d =
|
|
236
|
+
const d = authorizeMembershipKindChange({
|
|
237
237
|
goal: OPEN_GOAL, actorId: 2, actorRank: 'metic', actorMembership: member(2),
|
|
238
|
-
targetMembership: null, targetId: 5,
|
|
238
|
+
targetMembership: null, targetId: 5, membershipKind: 'lead',
|
|
239
239
|
});
|
|
240
240
|
assert.equal(d.body.error, 'not_owner_or_manager', 'authority is checked BEFORE the target lookup');
|
|
241
241
|
});
|
|
242
242
|
|
|
243
243
|
await test('O2: ordering — a closed goal short-circuits before authority', () => {
|
|
244
|
-
const d =
|
|
244
|
+
const d = authorizeMembershipKindChange({
|
|
245
245
|
goal: { id: 7, status: 'archived', created_by: '1' }, actorId: 2, actorRank: 'metic',
|
|
246
|
-
actorMembership: member(2), targetMembership: null, targetId: 5,
|
|
246
|
+
actorMembership: member(2), targetMembership: null, targetId: 5, membershipKind: 'lead',
|
|
247
247
|
});
|
|
248
248
|
assert.equal(d.body.error, 'goal_not_open');
|
|
249
249
|
});
|
|
@@ -316,27 +316,36 @@ await test('T7: unknown goal → 404; closed goal → 409 (fail-closed ordering)
|
|
|
316
316
|
// =========================================================================
|
|
317
317
|
// 2. request-validation schema — the EXACT schema declared in goals.js's
|
|
318
318
|
// PATCH /goals/:id/members/:builderId; keep in sync if the route changes.
|
|
319
|
-
// (
|
|
319
|
+
// (the lead|member vocabulary is an in-handler check → 400 bad_membership_kind, and
|
|
320
320
|
// the transfer route coerces builder_id in-handler — the /members precedent.)
|
|
321
321
|
// =========================================================================
|
|
322
322
|
|
|
323
|
-
const
|
|
324
|
-
|
|
323
|
+
const PATCH_MEMBER_SCHEMA = {
|
|
324
|
+
membership_kind: { required: true, type: 'string' },
|
|
325
325
|
};
|
|
326
326
|
|
|
327
|
-
await test('PATCH schema:
|
|
328
|
-
const r = validate({},
|
|
329
|
-
assert.ok(r && r.details.some((d) => d.field === '
|
|
327
|
+
await test('PATCH schema: membership_kind required', () => {
|
|
328
|
+
const r = validate({}, PATCH_MEMBER_SCHEMA);
|
|
329
|
+
assert.ok(r && r.details.some((d) => d.field === 'membership_kind' && d.reason === 'missing'));
|
|
330
330
|
});
|
|
331
331
|
|
|
332
|
-
await test('PATCH schema: non-string
|
|
333
|
-
const r = validate({
|
|
334
|
-
assert.ok(r && r.details.some((d) => d.field === '
|
|
332
|
+
await test('PATCH schema: non-string membership_kind → wrong_type', () => {
|
|
333
|
+
const r = validate({ membership_kind: 42 }, PATCH_MEMBER_SCHEMA);
|
|
334
|
+
assert.ok(r && r.details.some((d) => d.field === 'membership_kind' && d.reason === 'wrong_type'));
|
|
335
335
|
});
|
|
336
336
|
|
|
337
|
-
await test('PATCH schema:
|
|
338
|
-
assert.equal(validate({
|
|
339
|
-
assert.equal(validate({
|
|
337
|
+
await test('PATCH schema: membership_kind=lead / =member pass', () => {
|
|
338
|
+
assert.equal(validate({ membership_kind: 'lead' }, PATCH_MEMBER_SCHEMA), null);
|
|
339
|
+
assert.equal(validate({ membership_kind: 'member' }, PATCH_MEMBER_SCHEMA), null);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await test('the RESERVED WORD is gone from the goal-membership surface (task 1003013, ADR 0174)', () => {
|
|
343
|
+
// A body that still says `role` no longer satisfies the schema — the rename is
|
|
344
|
+
// a contract change, not an alias, so this pins the ABSENCE of the old field
|
|
345
|
+
// (the ADR 0174 lesson: a shim that outlives its release is a lie).
|
|
346
|
+
const r = validate({ role: 'lead' }, PATCH_MEMBER_SCHEMA);
|
|
347
|
+
assert.ok(r && r.details.some((d) => d.field === 'membership_kind' && d.reason === 'missing'),
|
|
348
|
+
'the old field name buys nothing — membership_kind is required');
|
|
340
349
|
});
|
|
341
350
|
|
|
342
351
|
await test('transfer builder_id coercion contract: int + numeric string pass, junk rejected', () => {
|
|
@@ -350,7 +359,7 @@ await test('transfer builder_id coercion contract: int + numeric string pass, ju
|
|
|
350
359
|
// 3. db helpers — SQL contracts via the SQL-aware fake pool
|
|
351
360
|
// =========================================================================
|
|
352
361
|
|
|
353
|
-
const MEMBER_ROW = { goal_id: 7, builder_id: 42,
|
|
362
|
+
const MEMBER_ROW = { goal_id: 7, builder_id: 42, membership_kind: 'lead', joined_at: 'T0' };
|
|
354
363
|
const GOAL_ROW = {
|
|
355
364
|
id: 7, version_id: 'BONGOS-V1', title: 'g', description: null, scope_modules: [],
|
|
356
365
|
status: 'open', created_by: 5, succeeded_by_goal_id: null, created_at: 'T0', updated_at: 'T1',
|
|
@@ -360,16 +369,16 @@ await test('getGoalMember: SELECT by (goal_id, builder_id), returns the row or n
|
|
|
360
369
|
const pool = fakePool((s) => (/FROM goal_members WHERE goal_id = \$1 AND builder_id = \$2/.test(s) ? { rows: [MEMBER_ROW] } : null));
|
|
361
370
|
const out = await getGoalMember({ goalId: 7, builderId: 42 }, { pool });
|
|
362
371
|
assert.deepEqual(out, MEMBER_ROW);
|
|
363
|
-
const sel = find(pool.queries, /SELECT goal_id, builder_id,
|
|
372
|
+
const sel = find(pool.queries, /SELECT goal_id, builder_id, membership_kind, joined_at FROM goal_members/);
|
|
364
373
|
assert.deepEqual(sel.params, [7, 42]);
|
|
365
374
|
|
|
366
375
|
const empty = fakePool(() => ({ rows: [] }));
|
|
367
376
|
assert.equal(await getGoalMember({ goalId: 7, builderId: 99 }, { pool: empty }), null);
|
|
368
377
|
});
|
|
369
378
|
|
|
370
|
-
await test('
|
|
379
|
+
await test('setGoalMembershipKind: the UPDATE carries the atomic owner-demotion guard', async () => {
|
|
371
380
|
const pool = fakePool((s) => (/UPDATE goal_members/.test(s) ? { rows: [MEMBER_ROW] } : null));
|
|
372
|
-
const out = await
|
|
381
|
+
const out = await setGoalMembershipKind({ goalId: 7, builderId: 42, membershipKind: 'lead' }, { pool });
|
|
373
382
|
assert.deepEqual(out, MEMBER_ROW);
|
|
374
383
|
const upd = find(pool.queries, /UPDATE goal_members/);
|
|
375
384
|
assert.deepEqual(upd.params, [7, 42, 'lead']);
|
|
@@ -378,12 +387,12 @@ await test('setGoalMemberRole: the UPDATE carries the atomic owner-demotion guar
|
|
|
378
387
|
const sql = String(upd.sql).replace(/\s+/g, ' ');
|
|
379
388
|
assert.match(sql, /\$3 = 'lead' OR gm\.builder_id IS DISTINCT FROM \(SELECT g\.created_by FROM goals g WHERE g\.id = \$1\)/,
|
|
380
389
|
'the owner-demotion guard is IN the statement, not only in the route gate');
|
|
381
|
-
assert.match(sql, /RETURNING goal_id, builder_id,
|
|
390
|
+
assert.match(sql, /RETURNING goal_id, builder_id, membership_kind, joined_at/);
|
|
382
391
|
});
|
|
383
392
|
|
|
384
|
-
await test('
|
|
393
|
+
await test('setGoalMembershipKind: guard blocked / no such member → null (route answers 409)', async () => {
|
|
385
394
|
const pool = fakePool(() => ({ rows: [] }));
|
|
386
|
-
assert.equal(await
|
|
395
|
+
assert.equal(await setGoalMembershipKind({ goalId: 7, builderId: 42, membershipKind: 'member' }, { pool }), null);
|
|
387
396
|
});
|
|
388
397
|
|
|
389
398
|
await test('transferGoalOwnership: created_by moves and the new owner upserts to lead, one statement', async () => {
|
|
@@ -393,9 +402,9 @@ await test('transferGoalOwnership: created_by moves and the new owner upserts to
|
|
|
393
402
|
const upd = find(pool.queries, /UPDATE goals SET created_by = \$2/);
|
|
394
403
|
assert.deepEqual(upd.params, [7, 5]);
|
|
395
404
|
const sql = String(upd.sql).replace(/\s+/g, ' ');
|
|
396
|
-
assert.match(sql, /INSERT INTO goal_members \(goal_id, builder_id,
|
|
405
|
+
assert.match(sql, /INSERT INTO goal_members \(goal_id, builder_id, membership_kind\)/,
|
|
397
406
|
'the new owner\'s membership rides in the same statement');
|
|
398
|
-
assert.match(sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET
|
|
407
|
+
assert.match(sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET membership_kind = 'lead'/,
|
|
399
408
|
'an existing membership upgrades to lead rather than erroring');
|
|
400
409
|
assert.match(sql, /updated_at = now\(\)/);
|
|
401
410
|
});
|
|
@@ -409,7 +418,7 @@ await test('transferGoalOwnership: unknown goal → null', async () => {
|
|
|
409
418
|
// 4. the route-rank pins — the wall is in-handler, the pins keep it that way
|
|
410
419
|
// =========================================================================
|
|
411
420
|
|
|
412
|
-
await test('route-rank pins:
|
|
421
|
+
await test('route-rank pins: membership change + transfer stay any-builder (in-handler walls)', () => {
|
|
413
422
|
const pins = MODULE_EXPECTED_RANKS.lifecycle;
|
|
414
423
|
assert.equal(pins['PATCH /goals/:id/members/:builderId'], 'any-builder',
|
|
415
424
|
'the owner/manager wall is in-handler — a requireRank re-gate must be a red build');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// tests/goal_membership_requests.mjs — private goals: the visibility flag + the
|
|
2
2
|
// invite / join-request queue db layer (task 1951 / ADR 0112). DB-free: the
|
|
3
|
-
// SQL-aware fake pool (the
|
|
3
|
+
// SQL-aware fake pool (the goal_member_kinds.mjs precedent) pins each helper's
|
|
4
4
|
// SQL contract + return shaping. The route authorization gates are task 1952's
|
|
5
5
|
// tests; this file pins the query layer under them.
|
|
6
6
|
//
|
|
@@ -139,7 +139,7 @@ await test('resolveMembershipRequest: a lost race (nothing pending) → null', a
|
|
|
139
139
|
// without a membership.
|
|
140
140
|
await test('acceptMembershipRequestAndAddMember: one atomic CTE; returns { request, member }', async () => {
|
|
141
141
|
const REQ = { ...REQUEST_ROW, status: 'accepted' };
|
|
142
|
-
const MEMBER = { goal_id: 1, builder_id: 2,
|
|
142
|
+
const MEMBER = { goal_id: 1, builder_id: 2, membership_kind: 'member', joined_at: 'T2' };
|
|
143
143
|
const pool = fakePool((s) => (/WITH resolved AS/.test(s) ? { rows: [{ request: REQ, member: MEMBER }] } : null));
|
|
144
144
|
const out = await acceptMembershipRequestAndAddMember({ id: 10, resolvedBy: 5 }, { pool });
|
|
145
145
|
assert.deepEqual(out, { request: REQ, member: MEMBER });
|
|
@@ -179,7 +179,7 @@ await test('listInboxFor: shapes {invitations, joinRequests, myPendingRequests}'
|
|
|
179
179
|
await test('listInboxFor: joinRequests gates on owner (created_by) OR manager (lead)', async () => {
|
|
180
180
|
const pool = fakePool(() => ({ rows: [] }));
|
|
181
181
|
await listInboxFor(95, { pool });
|
|
182
|
-
const jq = find(pool.queries, /r\.kind = 'request'.*g\.created_by = \$1.*
|
|
182
|
+
const jq = find(pool.queries, /r\.kind = 'request'.*g\.created_by = \$1.*membership_kind = 'lead'/);
|
|
183
183
|
assert.ok(jq, 'a join-request only reaches an owner or a manager of the goal');
|
|
184
184
|
// and it must not leak: the same $1 builder id feeds every bucket.
|
|
185
185
|
for (const q of pool.queries) assert.deepEqual(q.params, [95]);
|
|
@@ -207,30 +207,30 @@ await test('listPendingRequestsForGoal: pending both-kinds for one goal', async
|
|
|
207
207
|
// stamped 'rejected' on someone who is in fact a member.
|
|
208
208
|
// =========================================================================
|
|
209
209
|
|
|
210
|
-
const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42,
|
|
210
|
+
const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42, membership_kind: 'member', joined_at: 'T0' };
|
|
211
211
|
const addMemberPool = () => fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [ADD_MEMBER_ROW] } : null));
|
|
212
212
|
|
|
213
213
|
// join-cancels-request: the joiner arrives under their own steam.
|
|
214
214
|
await test('addGoalMember: a join cancels the joiner\'s pending ask in ONE statement', async () => {
|
|
215
215
|
const pool = addMemberPool();
|
|
216
|
-
const out = await addGoalMember({ goalId: 7, builderId: 42,
|
|
216
|
+
const out = await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'member', resolvedBy: 42 }, { pool });
|
|
217
217
|
assert.deepEqual(out, ADD_MEMBER_ROW, 'still returns the member row — the route response shape is unchanged');
|
|
218
218
|
assert.equal(pool.queries.length, 1, 'ONE statement: the cancel commits with the membership write or not at all');
|
|
219
219
|
const q = find(pool.queries, /^WITH added AS/);
|
|
220
|
-
assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET
|
|
221
|
-
'the membership upsert is the first arm,
|
|
220
|
+
assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET membership_kind = goal_members\.membership_kind/.test(norm(q.sql)),
|
|
221
|
+
'the membership upsert is the first arm, membership_kind still preserved (task 1002689)');
|
|
222
222
|
assert.ok(/UPDATE goal_membership_requests[\s\S]*SET status = 'cancelled', resolved_by = \$4/.test(norm(q.sql)),
|
|
223
223
|
'the second arm closes the ask as cancelled, attributed to the actor');
|
|
224
224
|
assert.ok(/WHERE goal_id = \$1 AND builder_id = \$2 AND status = 'pending'/.test(norm(q.sql)),
|
|
225
225
|
'scoped to THIS goal+builder, and only a pending row');
|
|
226
|
-
assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder,
|
|
226
|
+
assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder, membership_kind, resolvedBy — in that order');
|
|
227
227
|
});
|
|
228
228
|
|
|
229
229
|
// add-cancels-request: an owner/manager/Archon admits them directly. Same
|
|
230
230
|
// statement; resolvedBy is the ADDER, so the audit trail names who admitted them.
|
|
231
231
|
await test('addGoalMember: a direct member-add cancels the target\'s pending ask, attributed to the adder', async () => {
|
|
232
232
|
const pool = addMemberPool();
|
|
233
|
-
await addGoalMember({ goalId: 7, builderId: 42,
|
|
233
|
+
await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'lead', resolvedBy: 99 }, { pool });
|
|
234
234
|
const q = find(pool.queries, /^WITH added AS/);
|
|
235
235
|
assert.deepEqual(q.params, [7, 42, 'lead', 99],
|
|
236
236
|
'resolved_by is the ADDER (99), not the target (42) — who admitted them is the audit fact');
|
|
@@ -425,7 +425,7 @@ await test('routes: invitation-respond declines before the goal-state gate', ()
|
|
|
425
425
|
// authorizeJoinRequestResponse — owner|manager|Archon resolves a join-request.
|
|
426
426
|
const OWNER_GOAL = { id: 5, status: 'open', created_by: '1', visibility: 'private' };
|
|
427
427
|
const REQUEST = { id: 8, goal_id: '5', builder_id: '2', kind: 'request', status: 'pending' };
|
|
428
|
-
const mgr = (bid) => ({ goal_id: 5, builder_id: bid,
|
|
428
|
+
const mgr = (bid) => ({ goal_id: 5, builder_id: bid, membership_kind: 'lead' });
|
|
429
429
|
await test('authorizeJoinRequestResponse: non-authority actor → 403 (before any request lookup — no leak)', () => {
|
|
430
430
|
const d = authorizeJoinRequestResponse({ goal: OWNER_GOAL, actorId: 7, actorRank: 'metic', actorMembership: null, request: REQUEST, goalId: 5 });
|
|
431
431
|
assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');
|