@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
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.239",
|
|
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.239",
|
|
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.239",
|
|
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",
|
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
|
@@ -901,8 +901,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
901
901
|
// landed since 1.19.235 with no explicit bump. run 32162811851. (task 1002620)
|
|
902
902
|
// 1.19.237 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
903
903
|
// landed since 1.19.236 with no explicit bump. run 32164526454. (task 1002620)
|
|
904
|
+
// 1.19.238 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
904
908
|
// ---------------------------------------------------------------------------
|
|
905
|
-
const CORE_VERSION = '1.19.
|
|
909
|
+
const CORE_VERSION = '1.19.239'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
906
910
|
|
|
907
911
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
908
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
|
});
|