@bongos/core 1.19.237 → 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 +53 -38
- 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/docs/file-map.md +1 -1
- package/migrations/core_220_goal_members_membership_kind.sql +100 -0
- package/modules/discord/board-broadcast.js +118 -11
- package/modules/discord/db.js +44 -0
- package/modules/discord/migrations/discord_001_board_messages.sql +50 -0
- package/modules/discord/module.json +1 -0
- package/modules/discord/routes/discord.js +6 -0
- package/modules/discord/webhook.js +53 -8
- package/modules/government/board.js +35 -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 +5 -1
- package/tests/criterion_authoring_gate.mjs +4 -4
- package/tests/discord_board_broadcast.mjs +168 -0
- 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
|
@@ -119,3 +119,171 @@ test('an unlinked member\'s crafted display name is stripped of markdown and lin
|
|
|
119
119
|
assert.ok(!text.includes('@everyone'), 'no lookalike mention survives (the ping was already dead; now the text is too)');
|
|
120
120
|
assert.ok(text.includes('click me'), 'the words themselves remain — visible, honest, inert');
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
// ── R22 (task 1003035): the state mirror — closed, and the outcome ───────────
|
|
124
|
+
//
|
|
125
|
+
// The invariants R22 adds:
|
|
126
|
+
// 6. THE SAME MESSAGE CARRIES THE OUTCOME — the summons is EDITED, never
|
|
127
|
+
// appended to, so the channel can never show a stale open window beside a
|
|
128
|
+
// closed one. The id comes from the POST (`wait=true`) and is remembered.
|
|
129
|
+
// 7. BOTH OUTCOMES MIRROR — a return is mirrored exactly like a pass; only the
|
|
130
|
+
// pass CONTRACT event stays pass-only (the credit lane's payload).
|
|
131
|
+
// 8. STILL BEST-EFFORT, STILL OUTBOUND-ONLY — every failure is a named skip,
|
|
132
|
+
// an unannounced item posts fresh rather than going silent, and an edit
|
|
133
|
+
// never re-rings the board.
|
|
134
|
+
const { postBoardOutcome, composeOutcomeLine } = require(path.join(ROOT, 'modules', 'discord', 'board-broadcast.js'));
|
|
135
|
+
const closedPayload = (over = {}) => ({
|
|
136
|
+
item_id: '11', subject_type: 'full_idea', subject_id: '7',
|
|
137
|
+
outcome: 'passed', cause: 'vote', closed_at: '2026-08-18T12:00:00.000Z',
|
|
138
|
+
tally: { yes: 1, no: 0, total: 1 }, ...over,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the announcement is remembered at post time, so the close has something to edit', async () => {
|
|
142
|
+
let recorded = null;
|
|
143
|
+
const out = await postBoardAnnouncement(payload(), {
|
|
144
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
145
|
+
getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
|
|
146
|
+
post: async () => ({ ok: true, status: 200, message_id: '555000111' }),
|
|
147
|
+
recordMessage: async (row) => { recorded = row; },
|
|
148
|
+
log: () => {},
|
|
149
|
+
});
|
|
150
|
+
assert.equal(out.posted, true);
|
|
151
|
+
assert.deepEqual(recorded, { itemId: '11', channel: 'board_room', messageId: '555000111' });
|
|
152
|
+
assert.equal(out.recorded, '555000111');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a poster that returns no id, or a failed remember, still counts as announced', async () => {
|
|
156
|
+
const quiet = {
|
|
157
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
158
|
+
getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
|
|
159
|
+
log: () => {},
|
|
160
|
+
};
|
|
161
|
+
// The pre-R22 shape (no return value at all) must not become a failure.
|
|
162
|
+
assert.equal((await postBoardAnnouncement(payload(), { ...quiet, post: async () => {} })).posted, true);
|
|
163
|
+
const out = await postBoardAnnouncement(payload(), {
|
|
164
|
+
...quiet,
|
|
165
|
+
post: async () => ({ message: { id: '7' } }),
|
|
166
|
+
recordMessage: async () => { throw new Error('db down'); },
|
|
167
|
+
});
|
|
168
|
+
assert.equal(out.posted, true, 'the nudge is already in the channel — a lost id is not a failed announcement');
|
|
169
|
+
assert.equal(out.recorded, undefined);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('the close is EDITED onto the same message, and the edit is stamped', async () => {
|
|
173
|
+
let edited = null; let stamped = null;
|
|
174
|
+
const out = await postBoardOutcome(closedPayload({ tally: { yes: 2, no: 0, total: 2 } }), {
|
|
175
|
+
getMessage: async (itemId) => { assert.equal(itemId, '11'); return { item_id: '11', channel: 'board_room', message_id: '555000111' }; },
|
|
176
|
+
edit: async (channel, messageId, text) => { edited = { channel, messageId, text }; },
|
|
177
|
+
markEdited: async (itemId) => { stamped = itemId; },
|
|
178
|
+
log: () => {},
|
|
179
|
+
});
|
|
180
|
+
assert.deepEqual({ edited: out.edited, posted: out.posted, skipped: out.skipped }, { edited: true, posted: false, skipped: null });
|
|
181
|
+
assert.equal(edited.channel, 'board_room');
|
|
182
|
+
assert.equal(edited.messageId, '555000111');
|
|
183
|
+
assert.ok(edited.text.includes('RATIFIED'), 'the summons now reads as the outcome');
|
|
184
|
+
assert.ok(!edited.text.includes('your vote is wanted'), 'nothing stale survives the edit');
|
|
185
|
+
assert.ok(edited.text.includes('/government?item=11#board-room'), 'the record is still one click away');
|
|
186
|
+
assert.equal(stamped, '11', 'edited_at answers "is this window still shown as open?" without asking Discord');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a RETURN mirrors exactly like a pass — and names neither voter nor reason', async () => {
|
|
190
|
+
let text = null;
|
|
191
|
+
const out = await postBoardOutcome(closedPayload({ outcome: 'returned', cause: 'expiry', tally: { yes: 0, no: 1, total: 1 } }), {
|
|
192
|
+
getMessage: async () => ({ channel: 'board_room', message_id: '9' }),
|
|
193
|
+
edit: async (_c, _m, t) => { text = t; },
|
|
194
|
+
markEdited: async () => {},
|
|
195
|
+
log: () => {},
|
|
196
|
+
});
|
|
197
|
+
assert.equal(out.edited, true);
|
|
198
|
+
assert.ok(text.includes('RETURNED to its author'));
|
|
199
|
+
assert.ok(text.includes('1 objection'), 'singular reads right at one');
|
|
200
|
+
assert.ok(text.includes('the window ran out'), 'an expiry close says so');
|
|
201
|
+
assert.ok(text.includes('in the hall'), 'the reason + flagged section live where the idea is');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a zero-vote pass states what happened instead of reading like a bug', () => {
|
|
205
|
+
const line = composeOutcomeLine(closedPayload({ cause: 'expiry', tally: { yes: 0, no: 0, total: 0 } }));
|
|
206
|
+
assert.ok(line.includes('nobody objected before the window closed'), 'an absence is nulled, never a block (ADR 0175 §3)');
|
|
207
|
+
assert.ok(line.includes('RATIFIED'));
|
|
208
|
+
const amendment = composeOutcomeLine(closedPayload({ subject_type: 'constitutional_amendment', tally: { yes: 3, no: 0 } }));
|
|
209
|
+
assert.ok(amendment.includes('constitutional amendment'), 'the second subject is named honestly here too');
|
|
210
|
+
assert.ok(amendment.includes('3 in favour, 0 objections'));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('an unannounced item posts its outcome fresh rather than going silent', async () => {
|
|
214
|
+
let posted = null;
|
|
215
|
+
const out = await postBoardOutcome(closedPayload(), {
|
|
216
|
+
getMessage: async () => null,
|
|
217
|
+
post: async (channel, text) => { posted = { channel, text }; },
|
|
218
|
+
log: () => {},
|
|
219
|
+
});
|
|
220
|
+
assert.deepEqual({ edited: out.edited, posted: out.posted }, { edited: false, posted: true });
|
|
221
|
+
assert.equal(posted.channel, 'board_room');
|
|
222
|
+
assert.ok(posted.text.includes('RATIFIED'));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('every mirror failure is a NAMED skip — a close can never be failed by Discord', async () => {
|
|
226
|
+
assert.equal((await postBoardOutcome(null, { log: () => {} })).skipped, 'no_item');
|
|
227
|
+
assert.equal((await postBoardOutcome(closedPayload({ outcome: null }), { log: () => {} })).skipped, 'no_outcome');
|
|
228
|
+
const failed = await postBoardOutcome(closedPayload(), {
|
|
229
|
+
getMessage: async () => ({ channel: 'board_room', message_id: '9' }),
|
|
230
|
+
edit: async () => { throw new Error('discord down'); },
|
|
231
|
+
log: () => {},
|
|
232
|
+
});
|
|
233
|
+
assert.deepEqual({ edited: failed.edited, posted: failed.posted, skipped: failed.skipped },
|
|
234
|
+
{ edited: false, posted: false, skipped: 'mirror_failed' },
|
|
235
|
+
'a failed EDIT never falls back to a second message — the room must not get two notices');
|
|
236
|
+
// An unreadable bookkeeping read degrades to "unannounced", not to a throw.
|
|
237
|
+
const unread = await postBoardOutcome(closedPayload(), {
|
|
238
|
+
getMessage: async () => { throw new Error('db down'); },
|
|
239
|
+
post: async () => {},
|
|
240
|
+
log: () => {},
|
|
241
|
+
});
|
|
242
|
+
assert.equal(unread.posted, true);
|
|
243
|
+
// A stamp that fails leaves the mirrored close reported as mirrored.
|
|
244
|
+
const unstamped = await postBoardOutcome(closedPayload(), {
|
|
245
|
+
getMessage: async () => ({ channel: 'board_room', message_id: '9' }),
|
|
246
|
+
edit: async () => {},
|
|
247
|
+
markEdited: async () => { throw new Error('db down'); },
|
|
248
|
+
log: () => {},
|
|
249
|
+
});
|
|
250
|
+
assert.equal(unstamped.edited, true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('government emits the close for BOTH outcomes, after commit, and keeps the pass contract pass-only (source pins)', () => {
|
|
254
|
+
const board = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
|
|
255
|
+
const close = board.slice(board.indexOf('async function closeItem'));
|
|
256
|
+
assert.ok(close.indexOf('closeBoardItem') < close.indexOf('emitItemClosed(closed'),
|
|
257
|
+
'the mirror event fires AFTER the close commits — the row is the durable fact');
|
|
258
|
+
const passEmit = close.indexOf("emitAsync('board.item.passed'");
|
|
259
|
+
assert.ok(passEmit < close.indexOf('emitItemClosed(closed'),
|
|
260
|
+
"the credit lane's contract event still goes first");
|
|
261
|
+
const closedEmit = board.slice(board.indexOf('async function emitItemClosed'));
|
|
262
|
+
assert.match(closedEmit, /emitAsync\('board\.item\.closed'/, 'the event exists');
|
|
263
|
+
assert.match(closedEmit, /outcome: closed\.outcome/, 'it carries the outcome — a return mirrors too');
|
|
264
|
+
assert.match(closedEmit, /tally: \{/, 'it carries the tally so no consumer re-reads votes');
|
|
265
|
+
const passBlock = board.slice(board.indexOf('function passPayload'), board.indexOf('// Close an item'));
|
|
266
|
+
assert.ok(!/tally/.test(passBlock), 'the pass CONTRACT payload stays the seven fields — no tally (ADR 0175 / the pass-event spec)');
|
|
267
|
+
const routes = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'routes', 'discord.js'), 'utf8');
|
|
268
|
+
assert.match(routes, /api\.on\('board\.item\.closed', \(payload\) => boardBroadcast\.postBoardOutcome\(payload\)\)/,
|
|
269
|
+
'the discord module subscribes to the close as well');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('the poster asks for the message back, the edit rings NOBODY, and the mirror table is discord-owned', () => {
|
|
273
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'webhook.js'), 'utf8');
|
|
274
|
+
const core = src.slice(src.indexOf('async function postCore'), src.indexOf('// SECURITY (#871)'));
|
|
275
|
+
assert.match(core, /wait=true/, 'the POST asks Discord for the created message — that id is what R22 edits');
|
|
276
|
+
assert.match(core, /message_id: message && message\.id/, 'the id is surfaced to the caller');
|
|
277
|
+
const edit = src.slice(src.indexOf('async function editWebhook'));
|
|
278
|
+
assert.match(edit, /method: 'PATCH'/);
|
|
279
|
+
assert.match(edit, /\/messages\/\$\{encodeURIComponent\(String\(messageId\)\)\}/, 'a webhook may only edit its OWN message');
|
|
280
|
+
assert.match(edit, /allowed_mentions: \{ parse: \[\] \}/, 'an edit is an outcome notice, not a summons — it must never re-ring the board');
|
|
281
|
+
assert.ok(!/users/.test(edit), 'the mention exception stays scoped to the one post that means "your vote is wanted"');
|
|
282
|
+
const sql = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'migrations', 'discord_001_board_messages.sql'), 'utf8');
|
|
283
|
+
assert.match(sql, /CREATE TABLE IF NOT EXISTS discord_board_messages/);
|
|
284
|
+
assert.ok(!/REFERENCES government_board_items/.test(sql),
|
|
285
|
+
'no FK across the module boundary — the mirror never constrains government\'s schema');
|
|
286
|
+
assert.match(sql, /item_id\s+bigint\s+PRIMARY KEY/, 'one announcement per sitting, edited in place');
|
|
287
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'module.json'), 'utf8'));
|
|
288
|
+
assert.equal(manifest.contributes.migrations, true, 'the module now owns a migration and says so');
|
|
289
|
+
});
|
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');
|