@bongos/core 1.19.332 → 1.19.333

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 (58) hide show
  1. package/.bongos-core.json +160 -40
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/scripts/gds/box-infra.js +280 -0
  26. package/scripts/gds/box.js +1 -245
  27. package/scripts/gds/fitness-checks-identity.js +320 -0
  28. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  29. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  30. package/scripts/gds/fitness-lib.js +82 -0
  31. package/scripts/gds/fitness.js +55 -668
  32. package/scripts/gds/provision-config.js +204 -0
  33. package/scripts/gds/provision-net.js +221 -0
  34. package/scripts/gds/provision-repo.js +849 -0
  35. package/scripts/gds/provision-units.js +262 -0
  36. package/scripts/gds/provision.js +4 -1395
  37. package/src/bongos/auth-admission.js +264 -0
  38. package/src/bongos/auth-config.js +151 -0
  39. package/src/bongos/auth-discord.js +198 -0
  40. package/src/bongos/auth-github.js +336 -0
  41. package/src/bongos/auth-idp.js +330 -0
  42. package/src/bongos/auth.js +5 -1100
  43. package/src/module-api.js +3 -1
  44. package/tests/auth_admission_gate.mjs +3 -1
  45. package/tests/est_advisory.mjs +3 -1
  46. package/tests/goal_advisory.mjs +3 -2
  47. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  48. package/tests/goal_subtitle.mjs +5 -2
  49. package/tests/goals_card_view.mjs +10 -3
  50. package/tests/grader_generated_provenance.mjs +3 -1
  51. package/tests/hall_palette.mjs +3 -1
  52. package/tests/helpers.mjs +58 -25
  53. package/tests/lib_sh_resolution.mjs +3 -1
  54. package/tests/membership_kind_lockstep.mjs +3 -1
  55. package/tests/migration_allocation.mjs +5 -2
  56. package/tests/patch_gate_ordering.mjs +3 -1
  57. package/tests/rank_tier_single_source.mjs +16 -3
  58. package/tests/reward_gate.mjs +3 -2
@@ -117,6 +117,12 @@
117
117
  <!-- ADR 0112 (task 1954): the home-page goal-membership inbox — invitations to
118
118
  accept + join-requests to approve. Self-registers; hidden when empty. -->
119
119
  <script src="/builders/goal-inbox.js?v=2026-07-10-goal-inbox" defer></script>
120
+ <!-- task 1003206: the hall RENDERERS, carved out of builders.js when it crossed
121
+ the 1,500-line ratchet. Must load BEFORE builders.js, which calls
122
+ window.OTBHallRender(ctx) once during its own setup. The module
123
+ hall-widget tags the server injects still anchor on builders.js's tag
124
+ below (it matches by filename), so they land between the two. -->
125
+ <script src="/builders/hall-render.js?v=2026-08-24-hallrender1" defer></script>
120
126
  <script src="/builders/builders.js?v=2026-06-27-r75" defer></script>
121
127
  <script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
122
128
  </body>
@@ -0,0 +1,400 @@
1
+ // modules/lifecycle/routes/goal-route-authz.js — the PURE authorization
2
+ // predicates behind the goal routes: who may create a task in a goal, change a
3
+ // membership kind, transfer ownership, and open/answer a join request or invite.
4
+ //
5
+ // Carved out of modules/lifecycle/routes/goals.js by task 1003206 (goal 1000079,
6
+ // the R03 de-monolith plan) to bring that router under the 1,500-line ratchet,
7
+ // continuing the carve goal-suggest.js / goal-edits.js began. A STRUCTURAL move:
8
+ // the predicates are unchanged and goals.js re-exports each one, so the routes
9
+ // and the tests that call these directly are untouched.
10
+
11
+ 'use strict';
12
+
13
+ const api = require('../../../src/module-api');
14
+ const db = require('../db');
15
+ const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
16
+ const goalScopeCheck = api.goalScopeCheck;
17
+ const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
18
+ const METIC_PLUS = ['metic', 'archon'];
19
+
20
+ async function isMemberOrArchon(builder, goalId) {
21
+ if (builder.rank === 'archon') return true;
22
+ return db.isGoalMember({ goalId, builderId: builder.id });
23
+ }
24
+
25
+ // authorizeGoalTaskCreate — the ORDERED, FAIL-CLOSED authorization decision for
26
+ // POST /goals/:id/tasks (BV1.R60 / ADR 0086 §3): the single point that lets a
27
+ // lower-rank builder author their OWN work safely. PURE + exported (the
28
+ // shouldRefuseSessionClaim precedent in claims.js) so every rejection is pinned
29
+ // by a DB-free unit test AND reused by the BV1.R61 abuse matrix — no live route
30
+ // or DB needed.
31
+ //
32
+ // The route fetches goal / version / membership, then calls this; it rejects on
33
+ // the FIRST failure (fail-closed — a missing/ambiguous input denies):
34
+ // goal_not_found → goal_not_open → not_a_member → version_not_found →
35
+ // version_closed → (scope-subset + protected-path, via goalScopeCheck.check).
36
+ // The scope+protected wall is goal-scope-check.check() VERBATIM (R59) — itself
37
+ // ordered + fail-closed (empty-scope → empty-touches → unknown-scope →
38
+ // scope-subset → protected-path) — never re-implemented here. `isMember` is the
39
+ // resolved "member OR Archon" boolean the route computes (isMemberOrArchon).
40
+ //
41
+ // This is the CREATE-time filter: it sees only DECLARED touches[] (advisory — git
42
+ // merge is the real collision detector, ADR 0049), so it is deliberately the FIRST
43
+ // filter, NOT the last. The ship-time gates stay authoritative on the REAL diff
44
+ // (grader permission/route-rank pre-passes + serverSideGuards, gate-review
45
+ // ESCALATE floor, post-push main-audit — ADR 0086 §7). Returns { ok:true } or
46
+ // { ok:false, status, body } (the exact HTTP status + JSON the route sends).
47
+ function authorizeGoalTaskCreate({ goal, version, isMember, builderRank, touches }) {
48
+ if (!goal) {
49
+ return { ok: false, status: 404, body: { error: 'goal_not_found' } };
50
+ }
51
+ if (goal.status !== 'open') {
52
+ return { ok: false, status: 409, body: {
53
+ error: 'goal_not_open', status: goal.status,
54
+ message: 'Tasks can only be authored under an open goal.',
55
+ } };
56
+ }
57
+ if (!isMember) {
58
+ return { ok: false, status: 403, body: {
59
+ error: 'not_a_member',
60
+ message: 'Join the goal (or be an Archon) to author tasks within it.',
61
+ } };
62
+ }
63
+ if (!version) {
64
+ return { ok: false, status: 404, body: {
65
+ error: 'version_not_found',
66
+ message: `The goal's version '${goal.version_id}' no longer exists.`,
67
+ } };
68
+ }
69
+ if (version.status === 'shipped') {
70
+ return { ok: false, status: 409, body: {
71
+ error: 'version_closed',
72
+ message: `Version '${goal.version_id}' has shipped — it is closed history and cannot take new tasks.`,
73
+ } };
74
+ }
75
+ // The wall — scope-subset + protected-path in one pure call (R59). It bounds
76
+ // EVERYONE by the goal's modules (scope-subset), and additionally blocks a
77
+ // sub-Metic member from authoring into the protected core (protected-path),
78
+ // reusing the SINGLE protected roster (permission-path-check) — no drift.
79
+ const gate = goalScopeCheck.check(goal.scope_modules, touches, builderRank);
80
+ if (!gate.ok) {
81
+ return { ok: false, status: 403, body: {
82
+ error: gate.code, reason: gate.reason, details: gate.details,
83
+ } };
84
+ }
85
+ return { ok: true };
86
+ }
87
+
88
+ // The goal AUTHORITY MODEL (owner vs manager) now lives in ../goal-authz.js —
89
+ // isGoalOwner + authorizeGoalManagement are imported at the top of this file and
90
+ // re-exported at the bottom (task 1002538 / ADR 0154).
91
+
92
+ // authorizeMembershipKindChange — the ORDERED, FAIL-CLOSED authorization decision
93
+ // for PATCH /goals/:id/members/:builderId (task 1735). Today the only assignment
94
+ // path is the Archon admission route; this gate lets the goal's own authority
95
+ // manage memberships without an Archon in the loop:
96
+ //
97
+ // THE FIELD IS `membership_kind` (task 1003013, ADR 0177): ADR 0174 reserves the
98
+ // old word for the three crafts — Artist, Ideator, Builder — and a goal membership
99
+ // is a per-goal position, neither a craft nor a rank.
100
+
101
+ // - the owner, a manager, or an Archon may promote member→lead(manager);
102
+ // - ONLY the owner (or an Archon) may demote lead(manager)→member;
103
+ // - the OWNER's row can never be demoted — owner authority is created_by, not
104
+ // the membership value, so a demoted owner row would just lie to the list.
105
+ // PURE + exported (the authorizeGoalTaskCreate precedent above) so every
106
+ // rejection is pinned by a DB-free unit test. The route fetches goal /
107
+ // memberships, then calls this; it rejects on the FIRST failure (fail-closed —
108
+ // a missing/ambiguous input denies):
109
+ // goal_not_found → goal_not_open → not_owner_or_manager → not_a_member
110
+ // (target) → no-op same-kind → owner_only_demotion → owner_membership_fixed.
111
+ // Authority (not_owner_or_manager) is checked BEFORE the target lookup so an
112
+ // unauthorized actor never learns who is or isn't a member (the O1 no-leak
113
+ // ordering precedent in the abuse matrix). The DB helper (setGoalMembershipKind)
114
+ // re-asserts the owner-demotion guard atomically in the UPDATE itself — this
115
+ // gate is the testable contract, the SQL guard is the backstop.
116
+ // Returns { ok:true }, { ok:true, noop:true } (already as requested), or
117
+ // { ok:false, status, body } (the exact HTTP status + JSON the route sends).
118
+ function authorizeMembershipKindChange({ goal, actorId, actorRank, actorMembership, targetMembership, targetId, membershipKind }) {
119
+ if (!goal) {
120
+ return { ok: false, status: 404, body: { error: 'goal_not_found' } };
121
+ }
122
+ if (goal.status !== 'open') {
123
+ return { ok: false, status: 409, body: {
124
+ error: 'goal_not_open', status: goal.status,
125
+ message: 'Memberships can only be changed on an open goal.',
126
+ } };
127
+ }
128
+ const actorIsOwner = isGoalOwner(goal, actorId);
129
+ const actorIsManager = !!actorMembership && actorMembership.membership_kind === 'lead';
130
+ if (actorRank !== 'archon' && !actorIsOwner && !actorIsManager) {
131
+ return { ok: false, status: 403, body: {
132
+ error: 'not_owner_or_manager',
133
+ message: 'Only this goal\'s owner or a manager (or an Archon) may change memberships.',
134
+ } };
135
+ }
136
+ if (!targetMembership) {
137
+ return { ok: false, status: 404, body: {
138
+ error: 'not_a_member',
139
+ message: 'That builder is not a member of this goal — admit them first.',
140
+ } };
141
+ }
142
+ if (targetMembership.membership_kind === membershipKind) {
143
+ return { ok: true, noop: true };
144
+ }
145
+ if (membershipKind === 'member') {
146
+ // A demotion. Managers promote; only the owner (or an Archon) demotes.
147
+ if (actorRank !== 'archon' && !actorIsOwner) {
148
+ return { ok: false, status: 403, body: {
149
+ error: 'owner_only_demotion',
150
+ message: 'Only the goal owner (or an Archon) may demote a manager.',
151
+ } };
152
+ }
153
+ if (isGoalOwner(goal, targetId)) {
154
+ return { ok: false, status: 409, body: {
155
+ error: 'owner_membership_fixed',
156
+ message: 'The goal owner cannot be demoted — transfer ownership first.',
157
+ } };
158
+ }
159
+ }
160
+ return { ok: true };
161
+ }
162
+
163
+ // authorizeOwnershipTransfer — the ORDERED, FAIL-CLOSED decision for
164
+ // POST /goals/:id/transfer (task 1735): the owner hands the goal to another
165
+ // builder (the old owner keeps their manager row). Ownership is creator-
166
+ // equivalent authority and goal CREATION is Metic+ — so the transfer target
167
+ // must be Metic+ too (fail-closed; this also keeps the protected-scope
168
+ // membership wall intact, since a protected-scope goal requires Metic+ members).
169
+ // Ordering: goal_not_found → goal_not_open → not_owner → builder_not_found →
170
+ // metic_required_for_ownership → no-op self-transfer.
171
+ function authorizeOwnershipTransfer({ goal, actorId, actorRank, targetBuilder }) {
172
+ if (!goal) {
173
+ return { ok: false, status: 404, body: { error: 'goal_not_found' } };
174
+ }
175
+ if (goal.status !== 'open') {
176
+ return { ok: false, status: 409, body: {
177
+ error: 'goal_not_open', status: goal.status,
178
+ message: 'Ownership can only be transferred on an open goal.',
179
+ } };
180
+ }
181
+ if (actorRank !== 'archon' && !isGoalOwner(goal, actorId)) {
182
+ return { ok: false, status: 403, body: {
183
+ error: 'not_owner',
184
+ message: 'Only the goal owner (or an Archon) may transfer ownership.',
185
+ } };
186
+ }
187
+ if (!targetBuilder) {
188
+ return { ok: false, status: 404, body: { error: 'builder_not_found' } };
189
+ }
190
+ if (!METIC_PLUS.includes(targetBuilder.rank)) {
191
+ return { ok: false, status: 403, body: {
192
+ error: 'metic_required_for_ownership',
193
+ message: 'Goal ownership is creator-equivalent authority — the new owner must be Metic+.',
194
+ } };
195
+ }
196
+ if (isGoalOwner(goal, targetBuilder.id)) {
197
+ return { ok: true, noop: true };
198
+ }
199
+ return { ok: true };
200
+ }
201
+
202
+ // -------------------------------------------------------------------------
203
+ // Private goals: the invite / join-request queue (task 1952 / ADR 0112). A
204
+ // private goal is join-GATED (not hidden): a non-member can't self-join — they
205
+ // are INVITED (a consented, pending row they accept) or REQUEST to join (an
206
+ // owner/manager/Archon approves). Both directions live in goal_membership_requests
207
+ // (kind='invite'|'request'); the resolve is atomic (db.resolveMembershipRequest
208
+ // moves only from status='pending', so a re-submit can't double-add a member).
209
+ // -------------------------------------------------------------------------
210
+
211
+ // protectedScopeAdmissionBlock — the SINGLE protected-scope admission rule reused
212
+ // by BOTH new admission paths (invite-create and join-request-approve) AND the
213
+ // existing POST /goals/:id/members (whose inline checks this generalizes). A
214
+ // protected-scope goal admits members only via an Archon, and only Metic+ builders
215
+ // — sub-Metic membership inside protected scope is deferred to ADR 0086's Phase-5
216
+ // ADR. Centralized so the protected wall can never drift between paths (the SEC
217
+ // #863 single-roster lesson). Returns { status, body } to send, or null to proceed.
218
+ function protectedScopeAdmissionBlock({ scopeProtected, actorRank, targetRank }) {
219
+ if (!scopeProtected) return null;
220
+ if (actorRank !== 'archon') {
221
+ return { status: 403, body: {
222
+ error: 'archon_required_for_protected_scope',
223
+ message: 'This goal is scoped to a protected module; only an Archon may admit members.',
224
+ } };
225
+ }
226
+ if (!METIC_PLUS.includes(targetRank)) {
227
+ return { status: 403, body: {
228
+ error: 'metic_required_for_protected_scope',
229
+ message: 'A protected-scope goal requires a Metic+ member; sub-Metic membership is deferred to the Phase-5 ADR.',
230
+ } };
231
+ }
232
+ return null;
233
+ }
234
+
235
+ // authorizeCreateJoinRequest — the ORDERED, FAIL-CLOSED gate for
236
+ // POST /goals/:id/requests (task 1952): a builder asks to join. A request only
237
+ // makes sense where self-join is gated — a PUBLIC, NON-protected goal is open-join,
238
+ // so a request there is refused with a "just join" hint. Ordering:
239
+ // goal_not_found → goal_not_open → already_a_member → goal_is_open (public+unprotected).
240
+ // isMember is the caller's own membership; scopeProtected is scopeIncludesProtected(goal).
241
+ // PURE + exported (the authorizeGoalTaskCreate precedent).
242
+ function authorizeCreateJoinRequest({ goal, isMember, scopeProtected }) {
243
+ if (!goal) return { ok: false, status: 404, body: { error: 'goal_not_found' } };
244
+ if (goal.status !== 'open') {
245
+ return { ok: false, status: 409, body: {
246
+ error: 'goal_not_open', status: goal.status,
247
+ message: 'You can only request to join an open goal.',
248
+ } };
249
+ }
250
+ if (isMember) {
251
+ return { ok: false, status: 409, body: {
252
+ error: 'already_a_member', message: 'You are already a member of this goal.',
253
+ } };
254
+ }
255
+ if (goal.visibility !== 'private' && !scopeProtected) {
256
+ return { ok: false, status: 409, body: {
257
+ error: 'goal_is_open',
258
+ message: 'This goal is open — just join it (POST /goals/:id/join); no request needed.',
259
+ } };
260
+ }
261
+ return { ok: true };
262
+ }
263
+
264
+ // authorizeInvitationResponse — the ORDERED, FAIL-CLOSED gate for
265
+ // POST /goals/:id/invitations/:reqId/respond (task 1952): ONLY the invited builder
266
+ // may accept/decline their OWN pending invite. Containment first — the row must
267
+ // belong to THIS goal and be an 'invite' — so the invitation route can never touch
268
+ // a join-request row. Ordering: request_not_found → wrong_goal → not_an_invitation
269
+ // → not_pending → not_your_invitation. PURE + exported.
270
+ function authorizeInvitationResponse({ request, goalId, actorId }) {
271
+ if (!request) return { ok: false, status: 404, body: { error: 'request_not_found' } };
272
+ if (String(request.goal_id) !== String(goalId)) {
273
+ return { ok: false, status: 404, body: { error: 'request_not_found', message: 'No such invitation on this goal.' } };
274
+ }
275
+ if (request.kind !== 'invite') {
276
+ return { ok: false, status: 400, body: { error: 'not_an_invitation', message: 'That row is a join-request, not an invitation.' } };
277
+ }
278
+ if (request.status !== 'pending') {
279
+ return { ok: false, status: 409, body: { error: 'not_pending', status: request.status } };
280
+ }
281
+ if (String(request.builder_id) !== String(actorId)) {
282
+ return { ok: false, status: 403, body: {
283
+ error: 'not_your_invitation',
284
+ message: 'Only the invited builder may accept or decline this invitation.',
285
+ } };
286
+ }
287
+ return { ok: true };
288
+ }
289
+
290
+ // authorizeInvitationAccept — the ORDERED, FAIL-CLOSED gate for the ACCEPT half of
291
+ // POST /goals/:id/invitations/:reqId/respond, run against the GOAL after
292
+ // authorizeInvitationResponse has cleared the request row (task 1002887).
293
+ //
294
+ // Accepting is an ADMISSION, so it needs the same goal-state floor every other
295
+ // admission path already has: the create side requires an open goal
296
+ // (authorizeGoalManagement) and the join-request approve side inherits that check
297
+ // verbatim — only invitation-accept was missing it, so an invite issued while a
298
+ // goal was open could still add a member AFTER the goal was achieved or archived.
299
+ // DECLINE is deliberately NOT gated here: clearing a stale invitation out of your
300
+ // inbox is cleanup, not admission (the route returns before calling this).
301
+ // Ordering mirrors the sibling routes: goal_not_found → goal_not_open →
302
+ // metic_required_for_protected_scope (the pre-existing protected-scope membership
303
+ // floor, re-asserted against the ACCEPTING builder's current rank because the goal
304
+ // may have been re-scoped since the invite; the actor-is-Archon half of
305
+ // protectedScopeAdmissionBlock does not apply — the invitee is consenting).
306
+ //
307
+ // Unlike its {status, body} siblings this returns a res.fail SPEC ({ code, status,
308
+ // message, details }) because the accept branch already answers through res.fail —
309
+ // routing it through the envelope keeps the existing metic_required_for_protected_scope
310
+ // response byte-identical while still being PURE + exported for DB-free tests.
311
+ function authorizeInvitationAccept({ goal, scopeProtected, actorRank }) {
312
+ if (!goal) {
313
+ return { ok: false, code: 'goal_not_found', status: 404,
314
+ message: 'The goal behind this invitation no longer exists.' };
315
+ }
316
+ if (goal.status !== 'open') {
317
+ return { ok: false, code: 'goal_not_open', status: 409,
318
+ message: 'This goal is no longer open — an invitation can only be accepted while it is. You can still decline it.',
319
+ details: { status: goal.status } };
320
+ }
321
+ if (scopeProtected && !METIC_PLUS.includes(actorRank)) {
322
+ return { ok: false, code: 'metic_required_for_protected_scope', status: 403,
323
+ message: 'This goal is scoped to a protected module and requires a Metic+ member.' };
324
+ }
325
+ return { ok: true };
326
+ }
327
+
328
+ // authorizeJoinRequestResponse — the gate for POST /goals/:id/requests/:reqId/respond
329
+ // (task 1952): an owner|manager|Archon approves/denies a pending join-request. The
330
+ // authority prefix is authorizeGoalManagement VERBATIM (goal exists/open + owner|
331
+ // manager|Archon — checked BEFORE the request lookup so an unauthorized actor never
332
+ // learns whether a request exists, the O1 no-leak ordering). Then request
333
+ // containment + state: request_not_found → wrong_goal → not_a_join_request →
334
+ // not_pending. PURE + exported.
335
+ function authorizeJoinRequestResponse({ goal, actorId, actorRank, actorMembership, request, goalId }) {
336
+ const mgmt = authorizeGoalManagement({ goal, actorId, actorRank, actorMembership });
337
+ if (!mgmt.ok) return mgmt;
338
+ if (!request) return { ok: false, status: 404, body: { error: 'request_not_found' } };
339
+ if (String(request.goal_id) !== String(goalId)) {
340
+ return { ok: false, status: 404, body: { error: 'request_not_found', message: 'No such join-request on this goal.' } };
341
+ }
342
+ if (request.kind !== 'request') {
343
+ return { ok: false, status: 400, body: { error: 'not_a_join_request', message: 'That row is an invitation, not a join-request.' } };
344
+ }
345
+ if (request.status !== 'pending') {
346
+ return { ok: false, status: 409, body: { error: 'not_pending', status: request.status } };
347
+ }
348
+ return { ok: true };
349
+ }
350
+
351
+ // authorizeWithdrawJoinRequest — the gate for POST /goals/:id/requests/:reqId/withdraw
352
+ // (task 1953): ONLY the requester may withdraw their OWN pending join-request — a
353
+ // symmetric mirror of authorizeInvitationResponse (the invitee resolves their own
354
+ // invite; here the requester resolves their own request, self-served instead of
355
+ // waiting on an owner/manager deny). Ordering: request_not_found → wrong_goal →
356
+ // not_a_join_request → not_pending → not_your_request. PURE + exported.
357
+ function authorizeWithdrawJoinRequest({ request, goalId, actorId }) {
358
+ if (!request) return { ok: false, status: 404, body: { error: 'request_not_found' } };
359
+ if (String(request.goal_id) !== String(goalId)) {
360
+ return { ok: false, status: 404, body: { error: 'request_not_found', message: 'No such request on this goal.' } };
361
+ }
362
+ if (request.kind !== 'request') {
363
+ return { ok: false, status: 400, body: { error: 'not_a_join_request', message: 'That row is an invitation, not a join-request.' } };
364
+ }
365
+ if (request.status !== 'pending') {
366
+ return { ok: false, status: 409, body: { error: 'not_pending', status: request.status } };
367
+ }
368
+ if (String(request.builder_id) !== String(actorId)) {
369
+ return { ok: false, status: 403, body: {
370
+ error: 'not_your_request',
371
+ message: 'Only the requester may withdraw their own join-request.',
372
+ } };
373
+ }
374
+ return { ok: true };
375
+ }
376
+
377
+ // decorateGoalScope — additive, derived scope_protected flag on goal payloads
378
+ // (task 1002689). A protected-scope goal is join-gated like a private one
379
+ // (Archon-mediated membership, ADR 0086 §3), but the hall cannot re-derive the
380
+ // protected roster client-side (module-scope-map is server-only) — so the
381
+ // list/detail reads carry the boolean and the UI renders the request-to-join
382
+ // path instead of a dead-end Join button. Never persisted; a pure map.
383
+ function decorateGoalScope(goal) {
384
+ return goal ? { ...goal, scope_protected: scopeIncludesProtected(goal.scope_modules) } : goal;
385
+ }
386
+
387
+
388
+ module.exports = {
389
+ authorizeCreateJoinRequest,
390
+ authorizeGoalTaskCreate,
391
+ authorizeInvitationAccept,
392
+ authorizeInvitationResponse,
393
+ authorizeJoinRequestResponse,
394
+ authorizeMembershipKindChange,
395
+ authorizeOwnershipTransfer,
396
+ authorizeWithdrawJoinRequest,
397
+ decorateGoalScope,
398
+ isMemberOrArchon,
399
+ protectedScopeAdmissionBlock,
400
+ };