@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.
Files changed (33) hide show
  1. package/.bongos-core.json +53 -38
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/adr/0177-goal-membership-kind.md +96 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +5 -5
  6. package/docs/api-reference.md +2 -2
  7. package/docs/canonical-permissions.md +3 -1
  8. package/docs/file-map.md +1 -1
  9. package/migrations/core_220_goal_members_membership_kind.sql +100 -0
  10. package/modules/discord/board-broadcast.js +118 -11
  11. package/modules/discord/db.js +44 -0
  12. package/modules/discord/migrations/discord_001_board_messages.sql +50 -0
  13. package/modules/discord/module.json +1 -0
  14. package/modules/discord/routes/discord.js +6 -0
  15. package/modules/discord/webhook.js +53 -8
  16. package/modules/government/board.js +35 -0
  17. package/modules/hall-ui/public/goals-page.js +15 -13
  18. package/modules/hall-ui/public/goals.css +2 -2
  19. package/modules/lifecycle/db.js +40 -32
  20. package/modules/lifecycle/goal-authz.js +10 -7
  21. package/modules/lifecycle/routes/done-when.js +3 -3
  22. package/modules/lifecycle/routes/goals.js +43 -36
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/scripts/gds/fitness.js +68 -0
  26. package/src/module-api.js +5 -1
  27. package/tests/criterion_authoring_gate.mjs +4 -4
  28. package/tests/discord_board_broadcast.mjs +168 -0
  29. package/tests/goal_conflicts.mjs +3 -3
  30. package/tests/{goal_member_roles.mjs → goal_member_kinds.mjs} +89 -80
  31. package/tests/goal_membership_requests.mjs +10 -10
  32. package/tests/goal_routes.mjs +8 -8
  33. package/tests/goal_tier.mjs +10 -10
@@ -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 goal_member_roles.mjs precedent) pins each helper's
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, role: 'member', joined_at: 'T2' };
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.*role = 'lead'/);
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, role: 'member', joined_at: 'T0' };
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, role: 'member', resolvedBy: 42 }, { pool });
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 role = goal_members\.role/.test(norm(q.sql)),
221
- 'the membership upsert is the first arm, role still preserved (task 1002689)');
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, role, resolvedBy — in that order');
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, role: 'lead', resolvedBy: 99 }, { pool });
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, role: 'lead' });
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');
@@ -42,10 +42,10 @@ const POST_GOALS_SCHEMA = {
42
42
  scope_modules: { type: 'array', maxItems: 64, itemsType: 'string' },
43
43
  succeeds_goal_id: { type: 'integer', min: 1 }, // BV1.R64 rescope carry-forward
44
44
  };
45
- // /members validates only `role` declaratively; builder_id is coerced in-handler
45
+ // /members validates only `membership_kind` declaratively; builder_id is coerced in-handler
46
46
  // (accepts a number OR a numeric string — ids round-trip through JSON as strings).
47
47
  const POST_MEMBERS_SCHEMA = {
48
- role: { type: 'string' },
48
+ membership_kind: { type: 'string' },
49
49
  };
50
50
  // POST /goals/:id/scope (BV1.R64 ask-to-widen) — `add` is the module list; the
51
51
  // empty-array, unknown-key, and protected-module checks are in-handler.
@@ -122,15 +122,15 @@ await test('POST /goals/:id/scope: a non-string add item is rejected', () => {
122
122
  // ---- POST /goals/:id/members schema ----
123
123
  // builder_id is coerced via Number() + Number.isInteger in-handler (a number or a
124
124
  // numeric string passes; a non-numeric string → 400 bad_builder_id), verified
125
- // live against Postgres. Here we pin the declarative half (role).
126
- await test('POST /members: role optional, valid when present', () => {
125
+ // live against Postgres. Here we pin the declarative half (membership_kind).
126
+ await test('POST /members: membership_kind optional, valid when present', () => {
127
127
  assert.equal(validate({}, POST_MEMBERS_SCHEMA), null);
128
- assert.equal(validate({ role: 'lead' }, POST_MEMBERS_SCHEMA), null);
128
+ assert.equal(validate({ membership_kind: 'lead' }, POST_MEMBERS_SCHEMA), null);
129
129
  });
130
130
 
131
- await test('POST /members: non-string role → wrong_type', () => {
132
- const r = validate({ role: 42 }, POST_MEMBERS_SCHEMA);
133
- assert.ok(r && r.details.some((d) => d.field === 'role' && d.reason === 'wrong_type'));
131
+ await test('POST /members: non-string membership_kind → wrong_type', () => {
132
+ const r = validate({ membership_kind: 42 }, POST_MEMBERS_SCHEMA);
133
+ assert.ok(r && r.details.some((d) => d.field === 'membership_kind' && d.reason === 'wrong_type'));
134
134
  });
135
135
 
136
136
  await test('builder_id coercion contract: Number() accepts int + numeric string, rejects junk', () => {
@@ -49,7 +49,7 @@ const GOAL_ROW = {
49
49
  scope_modules: ['gds-core'], status: 'open', created_by: 42,
50
50
  succeeded_by_goal_id: null, sort_order: 0, created_at: 'T0', updated_at: 'T0',
51
51
  };
52
- const MEMBER_ROW = { goal_id: 7, builder_id: 42, role: 'member', joined_at: 'T0' };
52
+ const MEMBER_ROW = { goal_id: 7, builder_id: 42, membership_kind: 'member', joined_at: 'T0' };
53
53
 
54
54
  // =========================================================================
55
55
  // createGoal
@@ -76,7 +76,7 @@ await test('createGoal: the creator becomes the first lead in the same statement
76
76
  const pool = fakePool((s) => (/INSERT INTO goals/.test(s) ? { rows: [GOAL_ROW] } : null));
77
77
  await createGoal({ versionId: 'V1', title: 'g', createdBy: 42 }, { pool });
78
78
  const ins = find(pool.queries, /INSERT INTO goals/);
79
- assert.match(ins.sql, /INSERT INTO goal_members \(goal_id, builder_id, role\)/,
79
+ assert.match(ins.sql, /INSERT INTO goal_members \(goal_id, builder_id, membership_kind\)/,
80
80
  'the member insert is part of the createGoal statement');
81
81
  assert.match(ins.sql, /'lead'/, 'the creator is inserted as lead, not member');
82
82
  assert.match(ins.sql, /WHERE \$6::bigint IS NOT NULL/,
@@ -157,19 +157,19 @@ await test('setGoalStatus: unknown goal returns null', async () => {
157
157
  // task 1002888 wrapped the bare upsert in a CTE that also cancels any pending
158
158
  // membership request, so the statement now STARTS with `WITH added AS (` — the
159
159
  // INSERT is the first arm. The pending-ask cancellation itself is pinned in
160
- // tests/goal_membership_requests.mjs; here we only re-anchor the role contract.
161
- await test('addGoalMember: upsert PRESERVES the existing role (re-join cannot demote a lead)', async () => {
162
- const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [{ ...MEMBER_ROW, role: 'lead' }] } : null));
163
- const out = await addGoalMember({ goalId: 7, builderId: 42, role: 'member' }, { pool });
164
- assert.equal(out.role, 'lead', 'an existing lead row survives a role:member re-add');
160
+ // tests/goal_membership_requests.mjs; here we only re-anchor the membership contract.
161
+ await test('addGoalMember: upsert PRESERVES the existing membership_kind (re-join cannot demote a lead)', async () => {
162
+ const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [{ ...MEMBER_ROW, membership_kind: 'lead' }] } : null));
163
+ const out = await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'member' }, { pool });
164
+ assert.equal(out.membership_kind, 'lead', 'an existing lead row survives a membership_kind:member re-add');
165
165
  const ins = find(pool.queries, /^WITH added AS/);
166
166
  assert.deepEqual(ins.params, [7, 42, 'member', null], 'resolvedBy defaults to null when no actor is supplied');
167
167
  assert.match(ins.sql, /INSERT INTO goal_members/, 'the membership write is the first CTE arm');
168
- assert.match(ins.sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET role = goal_members\.role/,
169
- 'conflict keeps the stored role — role changes go through PATCH /goals/:id/members/:builderId only (task 1002689)');
168
+ assert.match(ins.sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET membership_kind = goal_members\.membership_kind/,
169
+ 'conflict keeps the stored membership_kind — changes go through PATCH /goals/:id/members/:builderId only (task 1002689)');
170
170
  });
171
171
 
172
- await test('addGoalMember: role defaults to member', async () => {
172
+ await test('addGoalMember: membershipKind defaults to member', async () => {
173
173
  const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [MEMBER_ROW] } : null));
174
174
  await addGoalMember({ goalId: 7, builderId: 42 }, { pool });
175
175
  assert.equal(find(pool.queries, /^WITH added AS/).params[2], 'member');