@bongos/core 1.19.331 → 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.
- package/.bongos-core.json +163 -43
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +202 -202
- package/docs/copy-registry.json +384 -384
- package/modules/grading/grader-config.js +98 -0
- package/modules/grading/grader-model.js +150 -0
- package/modules/grading/grader-prompt.js +139 -0
- package/modules/grading/grader-score.js +706 -0
- package/modules/grading/grader-subagent.js +328 -0
- package/modules/grading/grader.js +5 -1287
- package/modules/hall-ui/public/builders.js +19 -1182
- package/modules/hall-ui/public/goals-page.js +11 -997
- package/modules/hall-ui/public/goals-render.js +1032 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/hall-render.js +1211 -0
- package/modules/hall-ui/public/index.html +6 -0
- package/modules/lifecycle/routes/goal-route-authz.js +400 -0
- package/modules/lifecycle/routes/goals.js +1 -367
- package/modules/lifecycle/routes/task-route-helpers.js +219 -0
- package/modules/lifecycle/routes/task-write-routes.js +542 -0
- package/modules/lifecycle/routes/tasks.js +3 -696
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +4 -0
- package/public-landing/projects.html +103 -23
- package/scripts/gds/box-infra.js +280 -0
- package/scripts/gds/box.js +1 -245
- package/scripts/gds/fitness-checks-identity.js +320 -0
- package/scripts/gds/fitness-checks-vocabulary.js +230 -0
- package/scripts/gds/fitness-checks-write-validation.js +144 -0
- package/scripts/gds/fitness-lib.js +82 -0
- package/scripts/gds/fitness.js +55 -668
- package/scripts/gds/provision-config.js +204 -0
- package/scripts/gds/provision-net.js +221 -0
- package/scripts/gds/provision-repo.js +849 -0
- package/scripts/gds/provision-units.js +262 -0
- package/scripts/gds/provision.js +4 -1395
- package/src/bongos/auth-admission.js +264 -0
- package/src/bongos/auth-config.js +151 -0
- package/src/bongos/auth-discord.js +198 -0
- package/src/bongos/auth-github.js +336 -0
- package/src/bongos/auth-idp.js +330 -0
- package/src/bongos/auth.js +5 -1100
- package/src/module-api.js +5 -1
- package/tests/auth_admission_gate.mjs +3 -1
- package/tests/est_advisory.mjs +3 -1
- package/tests/goal_advisory.mjs +3 -2
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
- package/tests/goal_subtitle.mjs +5 -2
- package/tests/goals_card_view.mjs +10 -3
- package/tests/grader_generated_provenance.mjs +3 -1
- package/tests/hall_palette.mjs +3 -1
- package/tests/helpers.mjs +58 -25
- package/tests/lib_sh_resolution.mjs +3 -1
- package/tests/membership_kind_lockstep.mjs +3 -1
- package/tests/migration_allocation.mjs +5 -2
- package/tests/patch_gate_ordering.mjs +3 -1
- package/tests/projects_hub_pre_uat.mjs +53 -0
- package/tests/rank_tier_single_source.mjs +16 -3
- 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
|
+
};
|