@bongos/core 1.19.238 → 1.19.240
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 +47 -32
- 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/scripts/gds/run-unit-tests.js +7 -1
- package/src/module-api.js +5 -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/tests/government_live_proof.mjs +72 -20
- package/tests/government_live_proof_shape.mjs +124 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// goals-page.js — the dedicated Goals page (task 1734, owner request from the
|
|
2
2
|
// task-1723 review): list every goal, then per-goal detail — completion card,
|
|
3
|
-
// members with lead (manager)
|
|
3
|
+
// members with lead (manager) standing, work to do vs completed, and a conflicts
|
|
4
4
|
// panel. Hash-routed (#/goal/N) inside one page; the server gate is
|
|
5
5
|
// SIGNEDIN_BUILDERS_PAGE_RE in serve-internal.js.
|
|
6
6
|
//
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// to the top-100-by-credits window).
|
|
19
19
|
// Writes: POST /goals/:id/join | /leave | /members (task 1762 — owner/manager
|
|
20
20
|
// invite, mirrors the server's authorizeGoalManagement gate; task 1877 picks
|
|
21
|
-
// the invitee from a dropdown instead of typing a GitHub login). Lead
|
|
21
|
+
// the invitee from a dropdown instead of typing a GitHub login). Lead
|
|
22
22
|
// management is task 1735.
|
|
23
23
|
(function () {
|
|
24
24
|
'use strict';
|
|
@@ -1190,7 +1190,7 @@
|
|
|
1190
1190
|
return `<li class="member">
|
|
1191
1191
|
<span class="member__avatar">${memberAvatar(m)}</span>
|
|
1192
1192
|
<span class="member__name">${escapeHtml(memberName(m))} ${m.rank ? `<small>· ${escapeHtml(rankLabel(m.rank))}</small>` : ''}</span>
|
|
1193
|
-
<span class="
|
|
1193
|
+
<span class="member-pill${m.membership_kind === 'lead' ? ' member-pill--lead' : ''}">${m.membership_kind === 'lead' ? 'Manager' : 'Member'}</span>
|
|
1194
1194
|
</li>`;
|
|
1195
1195
|
}
|
|
1196
1196
|
|
|
@@ -1221,7 +1221,7 @@
|
|
|
1221
1221
|
<span class="member__avatar">${memberAvatar(m)}</span>
|
|
1222
1222
|
<span class="member__name">${escapeHtml(memberName(m))} ${m.rank ? `<small>· ${escapeHtml(rankLabel(m.rank))}</small>` : ''}</span>
|
|
1223
1223
|
<span class="member__stats" title="Contribution to this goal">${statLine}</span>
|
|
1224
|
-
<span class="
|
|
1224
|
+
<span class="member-pill${m.membership_kind === 'lead' ? ' member-pill--lead' : ''}">${m.membership_kind === 'lead' ? 'Manager' : 'Member'}</span>
|
|
1225
1225
|
</li>`;
|
|
1226
1226
|
}
|
|
1227
1227
|
|
|
@@ -1231,7 +1231,7 @@
|
|
|
1231
1231
|
const members = d.members || [];
|
|
1232
1232
|
const lede = $('#goal-members-lede');
|
|
1233
1233
|
if (lede) {
|
|
1234
|
-
const leads = members.filter((m) => m.
|
|
1234
|
+
const leads = members.filter((m) => m.membership_kind === 'lead');
|
|
1235
1235
|
lede.textContent = leads.length
|
|
1236
1236
|
? 'Leads manage this goal’s scope and (soon) resolve its conflicts.'
|
|
1237
1237
|
: 'No lead assigned yet — an Archon can appoint one.';
|
|
@@ -1287,7 +1287,7 @@
|
|
|
1287
1287
|
const members = d.members || [];
|
|
1288
1288
|
const sat = crit.filter((c) => c.satisfied).length;
|
|
1289
1289
|
const joined = members.some((m) => sameId(m.builder_id, selfId()));
|
|
1290
|
-
const iAmLead = members.some((m) => sameId(m.builder_id, selfId()) && m.
|
|
1290
|
+
const iAmLead = members.some((m) => sameId(m.builder_id, selfId()) && m.membership_kind === 'lead');
|
|
1291
1291
|
|
|
1292
1292
|
const h = $('#goal-detail-h');
|
|
1293
1293
|
// task 2163 — surface the numeric goal id alongside the title (escaped title,
|
|
@@ -1367,7 +1367,7 @@
|
|
|
1367
1367
|
parts.push('<button class="btn-ghost" id="goal-leave-btn" type="button">Leave goal</button>');
|
|
1368
1368
|
} else if ((g.visibility === 'private' || g.scope_protected) && !isArchon()) {
|
|
1369
1369
|
if (state.myPendingRequest) {
|
|
1370
|
-
parts.push('<span class="
|
|
1370
|
+
parts.push('<span class="member-pill" title="Awaiting the owner/manager\'s approval">Requested</span>');
|
|
1371
1371
|
parts.push('<button class="btn-ghost" id="goal-withdraw-btn" type="button">Withdraw</button>');
|
|
1372
1372
|
} else {
|
|
1373
1373
|
parts.push('<button class="btn-ghost" id="goal-request-btn" type="button">🔒 Request to join</button>');
|
|
@@ -1471,7 +1471,7 @@
|
|
|
1471
1471
|
}
|
|
1472
1472
|
|
|
1473
1473
|
// task 1762/1877 — owner/manager invite control. Shows a compact form (a
|
|
1474
|
-
// builder dropdown +
|
|
1474
|
+
// builder dropdown + membership) in place of a button while open. Idempotent
|
|
1475
1475
|
// re-render: always rebuilds the box from scratch, so state.inviteOpen is the
|
|
1476
1476
|
// only thing that survives a renderDetail() call while the form is open.
|
|
1477
1477
|
function renderInviteBox(goalId, canInvite, members) {
|
|
@@ -1504,7 +1504,7 @@
|
|
|
1504
1504
|
${invitable.length ? `<option value="" disabled selected>Choose a builder…</option>${options}`
|
|
1505
1505
|
: '<option value="">Everyone is already a member</option>'}
|
|
1506
1506
|
</select>
|
|
1507
|
-
<select id="goal-invite-
|
|
1507
|
+
<select id="goal-invite-kind">
|
|
1508
1508
|
<option value="member">Member</option>
|
|
1509
1509
|
<option value="lead">Manager</option>
|
|
1510
1510
|
</select>
|
|
@@ -1518,14 +1518,16 @@
|
|
|
1518
1518
|
$('#goal-invite-form')?.addEventListener('submit', (ev) => {
|
|
1519
1519
|
ev.preventDefault();
|
|
1520
1520
|
const builderId = $('#goal-invite-builder')?.value;
|
|
1521
|
-
const
|
|
1522
|
-
if (builderId) inviteMember(goalId, builderId,
|
|
1521
|
+
const membershipKind = $('#goal-invite-kind')?.value || 'member';
|
|
1522
|
+
if (builderId) inviteMember(goalId, builderId, membershipKind);
|
|
1523
1523
|
});
|
|
1524
1524
|
}
|
|
1525
1525
|
|
|
1526
|
-
async function inviteMember(goalId, builderId,
|
|
1526
|
+
async function inviteMember(goalId, builderId, membershipKind) {
|
|
1527
1527
|
try {
|
|
1528
|
-
|
|
1528
|
+
// `membership_kind`, not `role` — ADR 0174 reserves that word for the three
|
|
1529
|
+
// crafts (task 1003013).
|
|
1530
|
+
await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/members`, { builder_id: Number(builderId), membership_kind: membershipKind });
|
|
1529
1531
|
toast('Invited.');
|
|
1530
1532
|
state.inviteOpen = false;
|
|
1531
1533
|
await loadDetail(goalId, { force: true });
|
|
@@ -173,8 +173,8 @@ a.goal-card:hover { text-decoration: none; border-color: var(--border-strong); b
|
|
|
173
173
|
.member__avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
|
174
174
|
.member__name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
175
175
|
.member__name small { color: var(--ink-faint); }
|
|
176
|
-
.
|
|
177
|
-
.
|
|
176
|
+
.member-pill { display: inline-flex; align-items: center; height: 20px; padding: 0 9px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--surface-2); color: var(--ink-soft); }
|
|
177
|
+
.member-pill--lead { background: var(--accent-weak); color: var(--accent-text); }
|
|
178
178
|
|
|
179
179
|
/* task 1762/1877 — owner/manager invite control (goal member admission),
|
|
180
180
|
collapsed behind "+ Invite" (disclosure, closed by default — task 2171). */
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -695,7 +695,7 @@ async function createGoal(
|
|
|
695
695
|
SELECT $1, $2, $3, $4, $5, $6, next_order.n FROM next_order
|
|
696
696
|
RETURNING ${GOAL_COLS}
|
|
697
697
|
), first_lead AS (
|
|
698
|
-
INSERT INTO goal_members (goal_id, builder_id,
|
|
698
|
+
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
699
699
|
SELECT g.id, $6::bigint, 'lead' FROM g WHERE $6::bigint IS NOT NULL
|
|
700
700
|
)
|
|
701
701
|
SELECT * FROM g`,
|
|
@@ -848,12 +848,18 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
|
|
|
848
848
|
}
|
|
849
849
|
|
|
850
850
|
// addGoalMember — join. Upserts on the (goal_id, builder_id) PK so a re-join is
|
|
851
|
-
// not a duplicate-key error, and the conflict arm PRESERVES the stored
|
|
852
|
-
// (task 1002689): overwriting it let POST /goals/:id/join (always
|
|
853
|
-
// and a POST /goals/:id/members re-add silently demote a lead —
|
|
854
|
-
// owner's row — bypassing
|
|
855
|
-
// guards.
|
|
856
|
-
// (the acceptMembershipRequestAndAddMember
|
|
851
|
+
// not a duplicate-key error, and the conflict arm PRESERVES the stored
|
|
852
|
+
// membership_kind (task 1002689): overwriting it let POST /goals/:id/join (always
|
|
853
|
+
// 'member') and a POST /goals/:id/members re-add silently demote a lead —
|
|
854
|
+
// including the owner's row — bypassing authorizeMembershipKindChange's
|
|
855
|
+
// owner-only-demotion guards. Changes go through PATCH
|
|
856
|
+
// /goals/:id/members/:builderId only (the acceptMembershipRequestAndAddMember
|
|
857
|
+
// precedent). membershipKind defaults to 'member'.
|
|
858
|
+
|
|
859
|
+
// THE COLUMN IS `membership_kind` (task 1003013, ADR 0177 — ADR 0174 reserves the
|
|
860
|
+
// other word for the three crafts). The legacy column still EXISTS for one
|
|
861
|
+
// release, kept in lockstep by core_220's trigger so a rolled-back deploy still
|
|
862
|
+
// reads a schema it understands: write the new name only, step 2 drops the old.
|
|
857
863
|
//
|
|
858
864
|
// ARRIVING AS A MEMBER CLOSES ANY PENDING ASK (task 1002888 / idea 1000688).
|
|
859
865
|
// The membership write used to be a bare upsert that never looked at
|
|
@@ -880,22 +886,22 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
|
|
|
880
886
|
// (Postgres runs a data-modifying CTE to completion whether or not the primary
|
|
881
887
|
// query reads it), and both see the same snapshot.
|
|
882
888
|
|
|
883
|
-
async function addGoalMember({ goalId, builderId,
|
|
889
|
+
async function addGoalMember({ goalId, builderId, membershipKind = 'member', resolvedBy = null }, deps = {}) {
|
|
884
890
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
885
891
|
const { rows } = await activePool.query(
|
|
886
892
|
`WITH added AS (
|
|
887
|
-
INSERT INTO goal_members (goal_id, builder_id,
|
|
893
|
+
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
888
894
|
VALUES ($1, $2, $3)
|
|
889
|
-
ON CONFLICT (goal_id, builder_id) DO UPDATE SET
|
|
890
|
-
RETURNING goal_id, builder_id,
|
|
895
|
+
ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = goal_members.membership_kind
|
|
896
|
+
RETURNING goal_id, builder_id, membership_kind, joined_at
|
|
891
897
|
), cancelled AS (
|
|
892
898
|
UPDATE goal_membership_requests
|
|
893
899
|
SET status = 'cancelled', resolved_by = $4, resolved_at = now(), updated_at = now()
|
|
894
900
|
WHERE goal_id = $1 AND builder_id = $2 AND status = 'pending'
|
|
895
901
|
RETURNING id
|
|
896
902
|
)
|
|
897
|
-
SELECT goal_id, builder_id,
|
|
898
|
-
[goalId, builderId,
|
|
903
|
+
SELECT goal_id, builder_id, membership_kind, joined_at FROM added`,
|
|
904
|
+
[goalId, builderId, membershipKind, resolvedBy]
|
|
899
905
|
);
|
|
900
906
|
return rows[0];
|
|
901
907
|
}
|
|
@@ -917,9 +923,9 @@ async function listGoalMembers(goalId, deps = {}) {
|
|
|
917
923
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
918
924
|
// Builder identity rides along (task 1734: the hall Goals page renders member
|
|
919
925
|
// names + lead badges) — additive columns, so every existing consumer that
|
|
920
|
-
// reads only {goal_id, builder_id,
|
|
926
|
+
// reads only {goal_id, builder_id, membership_kind} is untouched.
|
|
921
927
|
const { rows } = await activePool.query(
|
|
922
|
-
`SELECT gm.goal_id, gm.builder_id, gm.
|
|
928
|
+
`SELECT gm.goal_id, gm.builder_id, gm.membership_kind, gm.joined_at,
|
|
923
929
|
b.github_login, b.display_name, b.avatar_url, b.rank
|
|
924
930
|
FROM goal_members gm
|
|
925
931
|
LEFT JOIN builders b ON b.id = gm.builder_id
|
|
@@ -993,14 +999,15 @@ async function isGoalMember({ goalId, builderId }, deps = {}) {
|
|
|
993
999
|
return rows.length > 0;
|
|
994
1000
|
}
|
|
995
1001
|
|
|
996
|
-
// getGoalMember — one membership row incl.
|
|
997
|
-
// read behind PATCH /goals/:id/members/:builderId (task 1735): the
|
|
998
|
-
// the actor's and the target's
|
|
1002
|
+
// getGoalMember — one membership row incl. membership_kind, or null. The
|
|
1003
|
+
// lead-authority read behind PATCH /goals/:id/members/:builderId (task 1735): the
|
|
1004
|
+
// route needs the actor's and the target's membership_kind, not just the
|
|
1005
|
+
// isGoalMember boolean.
|
|
999
1006
|
|
|
1000
1007
|
async function getGoalMember({ goalId, builderId }, deps = {}) {
|
|
1001
1008
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
1002
1009
|
const { rows } = await activePool.query(
|
|
1003
|
-
`SELECT goal_id, builder_id,
|
|
1010
|
+
`SELECT goal_id, builder_id, membership_kind, joined_at
|
|
1004
1011
|
FROM goal_members
|
|
1005
1012
|
WHERE goal_id = $1 AND builder_id = $2`,
|
|
1006
1013
|
[goalId, builderId]
|
|
@@ -1008,24 +1015,24 @@ async function getGoalMember({ goalId, builderId }, deps = {}) {
|
|
|
1008
1015
|
return rows[0] ?? null;
|
|
1009
1016
|
}
|
|
1010
1017
|
|
|
1011
|
-
//
|
|
1018
|
+
// setGoalMembershipKind — apply a membership change with the owner guard IN the statement
|
|
1012
1019
|
// (task 1735): the goal OWNER (goals.created_by) is the fixed authority, so a
|
|
1013
1020
|
// demotion never applies to the owner's row even if two requests race past the
|
|
1014
1021
|
// route's pure gate (the attachParent "re-check atomically" posture). Returns
|
|
1015
1022
|
// the updated row, or null when nothing changed — no such member, or the guard
|
|
1016
1023
|
// blocked an owner demotion — which the route answers as a conflict.
|
|
1017
1024
|
|
|
1018
|
-
async function
|
|
1025
|
+
async function setGoalMembershipKind({ goalId, builderId, membershipKind }, deps = {}) {
|
|
1019
1026
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
1020
1027
|
const { rows } = await activePool.query(
|
|
1021
1028
|
`UPDATE goal_members gm
|
|
1022
|
-
SET
|
|
1029
|
+
SET membership_kind = $3
|
|
1023
1030
|
WHERE gm.goal_id = $1 AND gm.builder_id = $2
|
|
1024
1031
|
AND ($3 = 'lead'
|
|
1025
1032
|
OR gm.builder_id IS DISTINCT FROM
|
|
1026
1033
|
(SELECT g.created_by FROM goals g WHERE g.id = $1))
|
|
1027
|
-
RETURNING goal_id, builder_id,
|
|
1028
|
-
[goalId, builderId,
|
|
1034
|
+
RETURNING goal_id, builder_id, membership_kind, joined_at`,
|
|
1035
|
+
[goalId, builderId, membershipKind]
|
|
1029
1036
|
);
|
|
1030
1037
|
return rows[0] ?? null;
|
|
1031
1038
|
}
|
|
@@ -1047,9 +1054,9 @@ async function transferGoalOwnership({ goalId, newOwnerId }, deps = {}) {
|
|
|
1047
1054
|
WHERE id = $1
|
|
1048
1055
|
RETURNING ${GOAL_COLS}
|
|
1049
1056
|
), new_owner_member AS (
|
|
1050
|
-
INSERT INTO goal_members (goal_id, builder_id,
|
|
1057
|
+
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
1051
1058
|
SELECT g.id, $2, 'lead' FROM g
|
|
1052
|
-
ON CONFLICT (goal_id, builder_id) DO UPDATE SET
|
|
1059
|
+
ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = 'lead'
|
|
1053
1060
|
)
|
|
1054
1061
|
SELECT * FROM g`,
|
|
1055
1062
|
[goalId, newOwnerId]
|
|
@@ -1259,7 +1266,8 @@ async function resolveMembershipRequest({ id, status, resolvedBy = null }, deps
|
|
|
1259
1266
|
// WHERE status='pending' keeps it idempotent — a lost race (already resolved /
|
|
1260
1267
|
// gone) resolves nothing, adds nobody, and returns null. On the (near-impossible,
|
|
1261
1268
|
// since the create routes reject an existing member) membership conflict the
|
|
1262
|
-
// existing
|
|
1269
|
+
// existing membership_kind is PRESERVED, so an approve never silently demotes a
|
|
1270
|
+
// manager.
|
|
1263
1271
|
// Returns { request, member } or null.
|
|
1264
1272
|
async function acceptMembershipRequestAndAddMember({ id, resolvedBy = null }, deps = {}) {
|
|
1265
1273
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
@@ -1270,10 +1278,10 @@ async function acceptMembershipRequestAndAddMember({ id, resolvedBy = null }, de
|
|
|
1270
1278
|
WHERE id = $1 AND status = 'pending'
|
|
1271
1279
|
RETURNING ${MEMBERSHIP_REQUEST_COLS}
|
|
1272
1280
|
), added AS (
|
|
1273
|
-
INSERT INTO goal_members (goal_id, builder_id,
|
|
1281
|
+
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
1274
1282
|
SELECT goal_id, builder_id, 'member' FROM resolved
|
|
1275
|
-
ON CONFLICT (goal_id, builder_id) DO UPDATE SET
|
|
1276
|
-
RETURNING goal_id, builder_id,
|
|
1283
|
+
ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = goal_members.membership_kind
|
|
1284
|
+
RETURNING goal_id, builder_id, membership_kind, joined_at
|
|
1277
1285
|
)
|
|
1278
1286
|
SELECT (SELECT to_jsonb(r) FROM resolved r) AS request,
|
|
1279
1287
|
(SELECT to_jsonb(a) FROM added a) AS member`,
|
|
@@ -1320,7 +1328,7 @@ async function listInboxFor(builderId, deps = {}) {
|
|
|
1320
1328
|
WHERE r.kind = 'request' AND r.status = 'pending'
|
|
1321
1329
|
AND (g.created_by = $1
|
|
1322
1330
|
OR EXISTS (SELECT 1 FROM goal_members gm
|
|
1323
|
-
WHERE gm.goal_id = g.id AND gm.builder_id = $1 AND gm.
|
|
1331
|
+
WHERE gm.goal_id = g.id AND gm.builder_id = $1 AND gm.membership_kind = 'lead'))
|
|
1324
1332
|
ORDER BY r.created_at, r.id`,
|
|
1325
1333
|
[builderId]
|
|
1326
1334
|
),
|
|
@@ -5590,7 +5598,7 @@ module.exports = {
|
|
|
5590
5598
|
listGoalMembers,
|
|
5591
5599
|
isGoalMember,
|
|
5592
5600
|
getGoalMember,
|
|
5593
|
-
|
|
5601
|
+
setGoalMembershipKind,
|
|
5594
5602
|
transferGoalOwnership,
|
|
5595
5603
|
listActiveClaimsForTasks,
|
|
5596
5604
|
requestClaimRelease,
|
|
@@ -5,18 +5,21 @@
|
|
|
5
5
|
// that goal's criteria, and authorization logic must have exactly one definition.
|
|
6
6
|
// routes/goals.js re-exports authorizeGoalManagement (isGoalOwner it imports for
|
|
7
7
|
// internal use only), so its existing DB-free unit tests (goal_conflicts,
|
|
8
|
-
//
|
|
8
|
+
// goal_member_kinds, goal_membership_requests) are unchanged.
|
|
9
9
|
//
|
|
10
10
|
// A module sibling, like ../goal-conflicts — routes consume pure files here; no
|
|
11
11
|
// route requires another route.
|
|
12
12
|
|
|
13
13
|
// The goal AUTHORITY MODEL (task 1735). Two layers, deliberately different kinds:
|
|
14
|
-
// - OWNER — goals.created_by, the fixed authority. Not a
|
|
14
|
+
// - OWNER — goals.created_by, the fixed authority. Not a membership row: set at
|
|
15
15
|
// create time (the creator) and moves only via the explicit transfer route.
|
|
16
16
|
// The owner holds every goal power.
|
|
17
|
-
// - MANAGER — the goal_members.
|
|
18
|
-
// "manager"). Owner-equivalent EXCEPT: a manager cannot DEMOTE a fellow
|
|
19
|
-
// manager (owner-only), and cannot transfer ownership.
|
|
17
|
+
// - MANAGER — the goal_members.membership_kind 'lead' value (the hall renders
|
|
18
|
+
// it "manager"). Owner-equivalent EXCEPT: a manager cannot DEMOTE a fellow
|
|
19
|
+
// manager (owner-only), and cannot transfer ownership. The column is
|
|
20
|
+
// `membership_kind`, never `role` — ADR 0174 reserves that word for the three
|
|
21
|
+
// crafts, and a goal membership is neither a craft nor a rank (task 1003013,
|
|
22
|
+
// ADR 0177).
|
|
20
23
|
// A goal with created_by NULL (the migration-160 system defaults) has no owner;
|
|
21
24
|
// Archons act as one — the archon arm of both gates below.
|
|
22
25
|
function isGoalOwner(goal, builderId) {
|
|
@@ -32,12 +35,12 @@ function isGoalOwner(goal, builderId) {
|
|
|
32
35
|
function isGoalOwnerOrManager({ goal, actorId, actorRank, actorMembership }) {
|
|
33
36
|
if (actorRank === 'archon') return true;
|
|
34
37
|
if (!goal) return false;
|
|
35
|
-
return isGoalOwner(goal, actorId) || (!!actorMembership && actorMembership.
|
|
38
|
+
return isGoalOwner(goal, actorId) || (!!actorMembership && actorMembership.membership_kind === 'lead');
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
// authorizeGoalManagement — the shared ORDERED, FAIL-CLOSED prefix for actions
|
|
39
42
|
// a goal's own authority performs (task 1736: conflict resolution; the same
|
|
40
|
-
// three steps open
|
|
43
|
+
// three steps open authorizeMembershipKindChange): goal exists → goal open →
|
|
41
44
|
// actor is owner|manager|Archon. Returns { ok:true } or { ok:false, status,
|
|
42
45
|
// body }. PURE + exported, same precedent as its siblings.
|
|
43
46
|
function authorizeGoalManagement({ goal, actorId, actorRank, actorMembership }) {
|
|
@@ -16,7 +16,7 @@ const auth = api;
|
|
|
16
16
|
const doneWhen = require('../done-when');
|
|
17
17
|
const db = require('../db');
|
|
18
18
|
// The goal authority PREDICATE (ADR 0154) — the same owner|manager test routes/goals.js
|
|
19
|
-
// uses for visibility / invites /
|
|
19
|
+
// uses for visibility / invites / memberships, so "who may manage this goal" has
|
|
20
20
|
// one definition. A module sibling, like ../db.
|
|
21
21
|
const { isGoalOwnerOrManager } = require('../goal-authz');
|
|
22
22
|
const { validateOrRespond, parseId, asyncHandler, LIMITS } = api;
|
|
@@ -57,7 +57,7 @@ function authorizeCriterionCreate({ goal, version, actorId, actorRank, actorMemb
|
|
|
57
57
|
if (!isGoalOwnerOrManager({ goal, actorId, actorRank, actorMembership })) {
|
|
58
58
|
return { ok: false, status: 403, body: {
|
|
59
59
|
error: 'goal_not_yours',
|
|
60
|
-
message: `Criteria on goal ${goal.id} can be authored only by the goal's owner or a manager (ADR 0154). Ask them to add it — or to
|
|
60
|
+
message: `Criteria on goal ${goal.id} can be authored only by the goal's owner or a manager (ADR 0154). Ask them to add it — or to make you a manager.`,
|
|
61
61
|
} };
|
|
62
62
|
}
|
|
63
63
|
if (goal.status !== 'open') {
|
|
@@ -112,7 +112,7 @@ module.exports = function buildDoneWhenRouter() {
|
|
|
112
112
|
// which stays Archon.
|
|
113
113
|
// - So a non-Archon must name a goal_id (version-level criteria stay Archon),
|
|
114
114
|
// must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same
|
|
115
|
-
// predicate behind visibility/invite/
|
|
115
|
+
// predicate behind visibility/invite/membership management), and the goal must be
|
|
116
116
|
// open on a planning/building version. That bounds the blast radius to a
|
|
117
117
|
// workspace they already hold. An unauthorized caller gets an honest 403
|
|
118
118
|
// goal_not_yours (task 1002614 — goal reads are open to any builder, so
|
|
@@ -45,7 +45,7 @@ const goalConflicts = require('../goal-conflicts');
|
|
|
45
45
|
const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
|
|
46
46
|
|
|
47
47
|
const VALID_GOAL_STATUSES = ['open', 'achieved', 'archived'];
|
|
48
|
-
const
|
|
48
|
+
const VALID_MEMBERSHIP_KINDS = ['lead', 'member'];
|
|
49
49
|
const METIC_PLUS = ['metic', 'archon'];
|
|
50
50
|
const RESOLVE_ACTIONS = ['sequence', 'flag_release', 'rescope'];
|
|
51
51
|
// Private goals (task 1952 / ADR 0112): the visibility values, and the resolve
|
|
@@ -131,43 +131,48 @@ function authorizeGoalTaskCreate({ goal, version, isMember, builderRank, touches
|
|
|
131
131
|
// isGoalOwner + authorizeGoalManagement are imported at the top of this file and
|
|
132
132
|
// re-exported at the bottom (task 1002538 / ADR 0154).
|
|
133
133
|
|
|
134
|
-
//
|
|
135
|
-
// PATCH /goals/:id/members/:builderId (task 1735). Today the only
|
|
134
|
+
// authorizeMembershipKindChange — the ORDERED, FAIL-CLOSED authorization decision
|
|
135
|
+
// for PATCH /goals/:id/members/:builderId (task 1735). Today the only assignment
|
|
136
136
|
// path is the Archon admission route; this gate lets the goal's own authority
|
|
137
|
-
// manage
|
|
137
|
+
// manage memberships without an Archon in the loop:
|
|
138
|
+
//
|
|
139
|
+
// THE FIELD IS `membership_kind` (task 1003013, ADR 0177): ADR 0174 reserves the
|
|
140
|
+
// old word for the three crafts — Artist, Ideator, Builder — and a goal membership
|
|
141
|
+
// is a per-goal position, neither a craft nor a rank.
|
|
142
|
+
|
|
138
143
|
// - the owner, a manager, or an Archon may promote member→lead(manager);
|
|
139
144
|
// - ONLY the owner (or an Archon) may demote lead(manager)→member;
|
|
140
145
|
// - the OWNER's row can never be demoted — owner authority is created_by, not
|
|
141
|
-
// the
|
|
146
|
+
// the membership value, so a demoted owner row would just lie to the list.
|
|
142
147
|
// PURE + exported (the authorizeGoalTaskCreate precedent above) so every
|
|
143
148
|
// rejection is pinned by a DB-free unit test. The route fetches goal /
|
|
144
149
|
// memberships, then calls this; it rejects on the FIRST failure (fail-closed —
|
|
145
150
|
// a missing/ambiguous input denies):
|
|
146
151
|
// goal_not_found → goal_not_open → not_owner_or_manager → not_a_member
|
|
147
|
-
// (target) → no-op same-
|
|
152
|
+
// (target) → no-op same-kind → owner_only_demotion → owner_membership_fixed.
|
|
148
153
|
// Authority (not_owner_or_manager) is checked BEFORE the target lookup so an
|
|
149
154
|
// unauthorized actor never learns who is or isn't a member (the O1 no-leak
|
|
150
|
-
// ordering precedent in the abuse matrix). The DB helper (
|
|
155
|
+
// ordering precedent in the abuse matrix). The DB helper (setGoalMembershipKind)
|
|
151
156
|
// re-asserts the owner-demotion guard atomically in the UPDATE itself — this
|
|
152
157
|
// gate is the testable contract, the SQL guard is the backstop.
|
|
153
|
-
// Returns { ok:true }, { ok:true, noop:true } (
|
|
158
|
+
// Returns { ok:true }, { ok:true, noop:true } (already as requested), or
|
|
154
159
|
// { ok:false, status, body } (the exact HTTP status + JSON the route sends).
|
|
155
|
-
function
|
|
160
|
+
function authorizeMembershipKindChange({ goal, actorId, actorRank, actorMembership, targetMembership, targetId, membershipKind }) {
|
|
156
161
|
if (!goal) {
|
|
157
162
|
return { ok: false, status: 404, body: { error: 'goal_not_found' } };
|
|
158
163
|
}
|
|
159
164
|
if (goal.status !== 'open') {
|
|
160
165
|
return { ok: false, status: 409, body: {
|
|
161
166
|
error: 'goal_not_open', status: goal.status,
|
|
162
|
-
message: '
|
|
167
|
+
message: 'Memberships can only be changed on an open goal.',
|
|
163
168
|
} };
|
|
164
169
|
}
|
|
165
170
|
const actorIsOwner = isGoalOwner(goal, actorId);
|
|
166
|
-
const actorIsManager = !!actorMembership && actorMembership.
|
|
171
|
+
const actorIsManager = !!actorMembership && actorMembership.membership_kind === 'lead';
|
|
167
172
|
if (actorRank !== 'archon' && !actorIsOwner && !actorIsManager) {
|
|
168
173
|
return { ok: false, status: 403, body: {
|
|
169
174
|
error: 'not_owner_or_manager',
|
|
170
|
-
message: 'Only this goal\'s owner or a manager (or an Archon) may change
|
|
175
|
+
message: 'Only this goal\'s owner or a manager (or an Archon) may change memberships.',
|
|
171
176
|
} };
|
|
172
177
|
}
|
|
173
178
|
if (!targetMembership) {
|
|
@@ -176,10 +181,10 @@ function authorizeMemberRoleChange({ goal, actorId, actorRank, actorMembership,
|
|
|
176
181
|
message: 'That builder is not a member of this goal — admit them first.',
|
|
177
182
|
} };
|
|
178
183
|
}
|
|
179
|
-
if (targetMembership.
|
|
184
|
+
if (targetMembership.membership_kind === membershipKind) {
|
|
180
185
|
return { ok: true, noop: true };
|
|
181
186
|
}
|
|
182
|
-
if (
|
|
187
|
+
if (membershipKind === 'member') {
|
|
183
188
|
// A demotion. Managers promote; only the owner (or an Archon) demotes.
|
|
184
189
|
if (actorRank !== 'archon' && !actorIsOwner) {
|
|
185
190
|
return { ok: false, status: 403, body: {
|
|
@@ -189,7 +194,7 @@ function authorizeMemberRoleChange({ goal, actorId, actorRank, actorMembership,
|
|
|
189
194
|
}
|
|
190
195
|
if (isGoalOwner(goal, targetId)) {
|
|
191
196
|
return { ok: false, status: 409, body: {
|
|
192
|
-
error: '
|
|
197
|
+
error: 'owner_membership_fixed',
|
|
193
198
|
message: 'The goal owner cannot be demoted — transfer ownership first.',
|
|
194
199
|
} };
|
|
195
200
|
}
|
|
@@ -621,7 +626,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
621
626
|
// resolvedBy: the joiner — arriving as a member cancels their own still-
|
|
622
627
|
// pending ask on this goal, atomically with the membership write (task
|
|
623
628
|
// 1002888). Self-joining is the same intent as withdrawing the request.
|
|
624
|
-
const member = await db.addGoalMember({ goalId: id, builderId: req.builder.id,
|
|
629
|
+
const member = await db.addGoalMember({ goalId: id, builderId: req.builder.id, membershipKind: 'member', resolvedBy: req.builder.id });
|
|
625
630
|
return res.status(201).json({ ok: true, member });
|
|
626
631
|
} catch (err) {
|
|
627
632
|
console.error('[gds] POST /goals/:id/join', err);
|
|
@@ -941,7 +946,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
941
946
|
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
942
947
|
if (id === null) return;
|
|
943
948
|
if (validateOrRespond(req, res, {
|
|
944
|
-
|
|
949
|
+
membership_kind: { type: 'string' },
|
|
945
950
|
// builder_id accepts a JSON number OR a numeric string (coerced below);
|
|
946
951
|
// declared untyped so strict-mode accepts either form.
|
|
947
952
|
builder_id: {},
|
|
@@ -953,9 +958,9 @@ module.exports = function buildGoalsRouter() {
|
|
|
953
958
|
if (!Number.isInteger(builderId) || builderId <= 0) {
|
|
954
959
|
return res.fail('bad_builder_id', { status: 400, message: 'builder_id must be a positive integer.' });
|
|
955
960
|
}
|
|
956
|
-
const
|
|
957
|
-
if (!
|
|
958
|
-
return res.fail('
|
|
961
|
+
const membershipKind = req.body.membership_kind || 'member';
|
|
962
|
+
if (!VALID_MEMBERSHIP_KINDS.includes(membershipKind)) {
|
|
963
|
+
return res.fail('bad_membership_kind', 400, { valid: VALID_MEMBERSHIP_KINDS });
|
|
959
964
|
}
|
|
960
965
|
try {
|
|
961
966
|
const goal = await db.getGoal(id);
|
|
@@ -985,7 +990,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
985
990
|
// resolvedBy: the ADDER (not the target) — a direct add closes the target's
|
|
986
991
|
// pending ask, and the audit trail should name whoever admitted them
|
|
987
992
|
// (task 1002888).
|
|
988
|
-
const member = await db.addGoalMember({ goalId: id, builderId,
|
|
993
|
+
const member = await db.addGoalMember({ goalId: id, builderId, membershipKind, resolvedBy: req.builder.id });
|
|
989
994
|
return res.status(201).json({ ok: true, member });
|
|
990
995
|
} catch (err) {
|
|
991
996
|
console.error('[gds] POST /goals/:id/members', err);
|
|
@@ -993,12 +998,14 @@ module.exports = function buildGoalsRouter() {
|
|
|
993
998
|
}
|
|
994
999
|
});
|
|
995
1000
|
|
|
996
|
-
// PATCH /goals/:id/members/:builderId —
|
|
1001
|
+
// PATCH /goals/:id/members/:builderId — membership management by the goal's own
|
|
997
1002
|
// authority (task 1735): the owner, a manager, or an Archon promotes
|
|
998
1003
|
// member→lead(manager); ONLY the owner (or an Archon) demotes; the owner's
|
|
999
|
-
// row is never demotable. The
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1004
|
+
// row is never demotable. The body field is `membership_kind` (task 1003013 —
|
|
1005
|
+
// ADR 0174 reserves `role` for the three crafts). The decision is the pure
|
|
1006
|
+
// ordered gate above (authorizeMembershipKindChange); the DB write re-asserts
|
|
1007
|
+
// the owner-demotion guard atomically (setGoalMembershipKind), so a race
|
|
1008
|
+
// cannot demote an owner.
|
|
1002
1009
|
// rank: any-builder (requireBuilder) + the in-handler (owner | manager |
|
|
1003
1010
|
// Archon) gate — like the authoring keystone, the wall is in-handler, NOT a
|
|
1004
1011
|
// rank middleware. Pinned in route-rank-check.MODULE_EXPECTED_RANKS['lifecycle'].
|
|
@@ -1008,11 +1015,11 @@ module.exports = function buildGoalsRouter() {
|
|
|
1008
1015
|
const builderId = parseId(req, res, { param: 'builderId', code: 'bad_builder_id', positiveInt: true });
|
|
1009
1016
|
if (builderId === null) return;
|
|
1010
1017
|
if (validateOrRespond(req, res, {
|
|
1011
|
-
|
|
1018
|
+
membership_kind: { required: true, type: 'string' },
|
|
1012
1019
|
})) return;
|
|
1013
|
-
const
|
|
1014
|
-
if (!
|
|
1015
|
-
return res.fail('
|
|
1020
|
+
const membershipKind = req.body.membership_kind;
|
|
1021
|
+
if (!VALID_MEMBERSHIP_KINDS.includes(membershipKind)) {
|
|
1022
|
+
return res.fail('bad_membership_kind', 400, { valid: VALID_MEMBERSHIP_KINDS });
|
|
1016
1023
|
}
|
|
1017
1024
|
try {
|
|
1018
1025
|
const goal = await db.getGoal(id);
|
|
@@ -1023,14 +1030,14 @@ module.exports = function buildGoalsRouter() {
|
|
|
1023
1030
|
db.getGoalMember({ goalId: id, builderId }),
|
|
1024
1031
|
])
|
|
1025
1032
|
: [null, null];
|
|
1026
|
-
const decision =
|
|
1033
|
+
const decision = authorizeMembershipKindChange({
|
|
1027
1034
|
goal,
|
|
1028
1035
|
actorId: req.builder.id,
|
|
1029
1036
|
actorRank: req.builder.rank,
|
|
1030
1037
|
actorMembership,
|
|
1031
1038
|
targetMembership,
|
|
1032
1039
|
targetId: builderId,
|
|
1033
|
-
|
|
1040
|
+
membershipKind,
|
|
1034
1041
|
});
|
|
1035
1042
|
if (!decision.ok) {
|
|
1036
1043
|
return res.status(decision.status).json(decision.body);
|
|
@@ -1038,17 +1045,17 @@ module.exports = function buildGoalsRouter() {
|
|
|
1038
1045
|
if (decision.noop) {
|
|
1039
1046
|
return res.json({ ok: true, member: targetMembership, changed: false });
|
|
1040
1047
|
}
|
|
1041
|
-
const member = await db.
|
|
1048
|
+
const member = await db.setGoalMembershipKind({ goalId: id, builderId, membershipKind });
|
|
1042
1049
|
if (!member) {
|
|
1043
1050
|
// The atomic backstop fired: the membership vanished, or ownership
|
|
1044
1051
|
// moved onto the target between the gate's read and this write.
|
|
1045
1052
|
// Honest conflict, not silent success.
|
|
1046
|
-
return res.fail('
|
|
1053
|
+
return res.fail('membership_change_conflict', { status: 409, message: 'The goal membership changed underneath this request — re-read the goal and retry.' });
|
|
1047
1054
|
}
|
|
1048
1055
|
return res.json({ ok: true, member, changed: true });
|
|
1049
1056
|
} catch (err) {
|
|
1050
1057
|
console.error('[gds] PATCH /goals/:id/members/:builderId', err);
|
|
1051
|
-
return res.fail('
|
|
1058
|
+
return res.fail('membership_change_failed', { status: 500, message: 'internal error' });
|
|
1052
1059
|
}
|
|
1053
1060
|
});
|
|
1054
1061
|
|
|
@@ -1531,9 +1538,9 @@ module.exports = function buildGoalsRouter() {
|
|
|
1531
1538
|
// for reuse. The shouldRefuseSessionClaim (claims.js) precedent.
|
|
1532
1539
|
module.exports.authorizeGoalTaskCreate = authorizeGoalTaskCreate;
|
|
1533
1540
|
// task 1735: the pure ordered gates for PATCH /goals/:id/members/:builderId and
|
|
1534
|
-
// POST /goals/:id/transfer — exported for tests/
|
|
1541
|
+
// POST /goals/:id/transfer — exported for tests/goal_member_kinds.mjs (DB-free),
|
|
1535
1542
|
// same precedent.
|
|
1536
|
-
module.exports.
|
|
1543
|
+
module.exports.authorizeMembershipKindChange = authorizeMembershipKindChange;
|
|
1537
1544
|
module.exports.authorizeOwnershipTransfer = authorizeOwnershipTransfer;
|
|
1538
1545
|
// task 1736: the shared management prefix gate (conflict resolution) —
|
|
1539
1546
|
// exported for tests/goal_conflicts.mjs (DB-free), same precedent.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.240",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.240",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.240",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|