@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
|
@@ -61,373 +61,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
|
|
|
61
61
|
// proceed; a false result means "not a member" → the route should 403. This is the
|
|
62
62
|
// membership half of the wall; the protected-module rank check is enforced per-route
|
|
63
63
|
// (scope is never self-granted).
|
|
64
|
-
|
|
65
|
-
if (builder.rank === 'archon') return true;
|
|
66
|
-
return db.isGoalMember({ goalId, builderId: builder.id });
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// authorizeGoalTaskCreate — the ORDERED, FAIL-CLOSED authorization decision for
|
|
70
|
-
// POST /goals/:id/tasks (BV1.R60 / ADR 0086 §3): the single point that lets a
|
|
71
|
-
// lower-rank builder author their OWN work safely. PURE + exported (the
|
|
72
|
-
// shouldRefuseSessionClaim precedent in claims.js) so every rejection is pinned
|
|
73
|
-
// by a DB-free unit test AND reused by the BV1.R61 abuse matrix — no live route
|
|
74
|
-
// or DB needed.
|
|
75
|
-
//
|
|
76
|
-
// The route fetches goal / version / membership, then calls this; it rejects on
|
|
77
|
-
// the FIRST failure (fail-closed — a missing/ambiguous input denies):
|
|
78
|
-
// goal_not_found → goal_not_open → not_a_member → version_not_found →
|
|
79
|
-
// version_closed → (scope-subset + protected-path, via goalScopeCheck.check).
|
|
80
|
-
// The scope+protected wall is goal-scope-check.check() VERBATIM (R59) — itself
|
|
81
|
-
// ordered + fail-closed (empty-scope → empty-touches → unknown-scope →
|
|
82
|
-
// scope-subset → protected-path) — never re-implemented here. `isMember` is the
|
|
83
|
-
// resolved "member OR Archon" boolean the route computes (isMemberOrArchon).
|
|
84
|
-
//
|
|
85
|
-
// This is the CREATE-time filter: it sees only DECLARED touches[] (advisory — git
|
|
86
|
-
// merge is the real collision detector, ADR 0049), so it is deliberately the FIRST
|
|
87
|
-
// filter, NOT the last. The ship-time gates stay authoritative on the REAL diff
|
|
88
|
-
// (grader permission/route-rank pre-passes + serverSideGuards, gate-review
|
|
89
|
-
// ESCALATE floor, post-push main-audit — ADR 0086 §7). Returns { ok:true } or
|
|
90
|
-
// { ok:false, status, body } (the exact HTTP status + JSON the route sends).
|
|
91
|
-
function authorizeGoalTaskCreate({ goal, version, isMember, builderRank, touches }) {
|
|
92
|
-
if (!goal) {
|
|
93
|
-
return { ok: false, status: 404, body: { error: 'goal_not_found' } };
|
|
94
|
-
}
|
|
95
|
-
if (goal.status !== 'open') {
|
|
96
|
-
return { ok: false, status: 409, body: {
|
|
97
|
-
error: 'goal_not_open', status: goal.status,
|
|
98
|
-
message: 'Tasks can only be authored under an open goal.',
|
|
99
|
-
} };
|
|
100
|
-
}
|
|
101
|
-
if (!isMember) {
|
|
102
|
-
return { ok: false, status: 403, body: {
|
|
103
|
-
error: 'not_a_member',
|
|
104
|
-
message: 'Join the goal (or be an Archon) to author tasks within it.',
|
|
105
|
-
} };
|
|
106
|
-
}
|
|
107
|
-
if (!version) {
|
|
108
|
-
return { ok: false, status: 404, body: {
|
|
109
|
-
error: 'version_not_found',
|
|
110
|
-
message: `The goal's version '${goal.version_id}' no longer exists.`,
|
|
111
|
-
} };
|
|
112
|
-
}
|
|
113
|
-
if (version.status === 'shipped') {
|
|
114
|
-
return { ok: false, status: 409, body: {
|
|
115
|
-
error: 'version_closed',
|
|
116
|
-
message: `Version '${goal.version_id}' has shipped — it is closed history and cannot take new tasks.`,
|
|
117
|
-
} };
|
|
118
|
-
}
|
|
119
|
-
// The wall — scope-subset + protected-path in one pure call (R59). It bounds
|
|
120
|
-
// EVERYONE by the goal's modules (scope-subset), and additionally blocks a
|
|
121
|
-
// sub-Metic member from authoring into the protected core (protected-path),
|
|
122
|
-
// reusing the SINGLE protected roster (permission-path-check) — no drift.
|
|
123
|
-
const gate = goalScopeCheck.check(goal.scope_modules, touches, builderRank);
|
|
124
|
-
if (!gate.ok) {
|
|
125
|
-
return { ok: false, status: 403, body: {
|
|
126
|
-
error: gate.code, reason: gate.reason, details: gate.details,
|
|
127
|
-
} };
|
|
128
|
-
}
|
|
129
|
-
return { ok: true };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// The goal AUTHORITY MODEL (owner vs manager) now lives in ../goal-authz.js —
|
|
133
|
-
// isGoalOwner + authorizeGoalManagement are imported at the top of this file and
|
|
134
|
-
// re-exported at the bottom (task 1002538 / ADR 0154).
|
|
135
|
-
|
|
136
|
-
// authorizeMembershipKindChange — the ORDERED, FAIL-CLOSED authorization decision
|
|
137
|
-
// for PATCH /goals/:id/members/:builderId (task 1735). Today the only assignment
|
|
138
|
-
// path is the Archon admission route; this gate lets the goal's own authority
|
|
139
|
-
// manage memberships without an Archon in the loop:
|
|
140
|
-
//
|
|
141
|
-
// THE FIELD IS `membership_kind` (task 1003013, ADR 0177): ADR 0174 reserves the
|
|
142
|
-
// old word for the three crafts — Artist, Ideator, Builder — and a goal membership
|
|
143
|
-
// is a per-goal position, neither a craft nor a rank.
|
|
144
|
-
|
|
145
|
-
// - the owner, a manager, or an Archon may promote member→lead(manager);
|
|
146
|
-
// - ONLY the owner (or an Archon) may demote lead(manager)→member;
|
|
147
|
-
// - the OWNER's row can never be demoted — owner authority is created_by, not
|
|
148
|
-
// the membership value, so a demoted owner row would just lie to the list.
|
|
149
|
-
// PURE + exported (the authorizeGoalTaskCreate precedent above) so every
|
|
150
|
-
// rejection is pinned by a DB-free unit test. The route fetches goal /
|
|
151
|
-
// memberships, then calls this; it rejects on the FIRST failure (fail-closed —
|
|
152
|
-
// a missing/ambiguous input denies):
|
|
153
|
-
// goal_not_found → goal_not_open → not_owner_or_manager → not_a_member
|
|
154
|
-
// (target) → no-op same-kind → owner_only_demotion → owner_membership_fixed.
|
|
155
|
-
// Authority (not_owner_or_manager) is checked BEFORE the target lookup so an
|
|
156
|
-
// unauthorized actor never learns who is or isn't a member (the O1 no-leak
|
|
157
|
-
// ordering precedent in the abuse matrix). The DB helper (setGoalMembershipKind)
|
|
158
|
-
// re-asserts the owner-demotion guard atomically in the UPDATE itself — this
|
|
159
|
-
// gate is the testable contract, the SQL guard is the backstop.
|
|
160
|
-
// Returns { ok:true }, { ok:true, noop:true } (already as requested), or
|
|
161
|
-
// { ok:false, status, body } (the exact HTTP status + JSON the route sends).
|
|
162
|
-
function authorizeMembershipKindChange({ goal, actorId, actorRank, actorMembership, targetMembership, targetId, membershipKind }) {
|
|
163
|
-
if (!goal) {
|
|
164
|
-
return { ok: false, status: 404, body: { error: 'goal_not_found' } };
|
|
165
|
-
}
|
|
166
|
-
if (goal.status !== 'open') {
|
|
167
|
-
return { ok: false, status: 409, body: {
|
|
168
|
-
error: 'goal_not_open', status: goal.status,
|
|
169
|
-
message: 'Memberships can only be changed on an open goal.',
|
|
170
|
-
} };
|
|
171
|
-
}
|
|
172
|
-
const actorIsOwner = isGoalOwner(goal, actorId);
|
|
173
|
-
const actorIsManager = !!actorMembership && actorMembership.membership_kind === 'lead';
|
|
174
|
-
if (actorRank !== 'archon' && !actorIsOwner && !actorIsManager) {
|
|
175
|
-
return { ok: false, status: 403, body: {
|
|
176
|
-
error: 'not_owner_or_manager',
|
|
177
|
-
message: 'Only this goal\'s owner or a manager (or an Archon) may change memberships.',
|
|
178
|
-
} };
|
|
179
|
-
}
|
|
180
|
-
if (!targetMembership) {
|
|
181
|
-
return { ok: false, status: 404, body: {
|
|
182
|
-
error: 'not_a_member',
|
|
183
|
-
message: 'That builder is not a member of this goal — admit them first.',
|
|
184
|
-
} };
|
|
185
|
-
}
|
|
186
|
-
if (targetMembership.membership_kind === membershipKind) {
|
|
187
|
-
return { ok: true, noop: true };
|
|
188
|
-
}
|
|
189
|
-
if (membershipKind === 'member') {
|
|
190
|
-
// A demotion. Managers promote; only the owner (or an Archon) demotes.
|
|
191
|
-
if (actorRank !== 'archon' && !actorIsOwner) {
|
|
192
|
-
return { ok: false, status: 403, body: {
|
|
193
|
-
error: 'owner_only_demotion',
|
|
194
|
-
message: 'Only the goal owner (or an Archon) may demote a manager.',
|
|
195
|
-
} };
|
|
196
|
-
}
|
|
197
|
-
if (isGoalOwner(goal, targetId)) {
|
|
198
|
-
return { ok: false, status: 409, body: {
|
|
199
|
-
error: 'owner_membership_fixed',
|
|
200
|
-
message: 'The goal owner cannot be demoted — transfer ownership first.',
|
|
201
|
-
} };
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return { ok: true };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// authorizeOwnershipTransfer — the ORDERED, FAIL-CLOSED decision for
|
|
208
|
-
// POST /goals/:id/transfer (task 1735): the owner hands the goal to another
|
|
209
|
-
// builder (the old owner keeps their manager row). Ownership is creator-
|
|
210
|
-
// equivalent authority and goal CREATION is Metic+ — so the transfer target
|
|
211
|
-
// must be Metic+ too (fail-closed; this also keeps the protected-scope
|
|
212
|
-
// membership wall intact, since a protected-scope goal requires Metic+ members).
|
|
213
|
-
// Ordering: goal_not_found → goal_not_open → not_owner → builder_not_found →
|
|
214
|
-
// metic_required_for_ownership → no-op self-transfer.
|
|
215
|
-
function authorizeOwnershipTransfer({ goal, actorId, actorRank, targetBuilder }) {
|
|
216
|
-
if (!goal) {
|
|
217
|
-
return { ok: false, status: 404, body: { error: 'goal_not_found' } };
|
|
218
|
-
}
|
|
219
|
-
if (goal.status !== 'open') {
|
|
220
|
-
return { ok: false, status: 409, body: {
|
|
221
|
-
error: 'goal_not_open', status: goal.status,
|
|
222
|
-
message: 'Ownership can only be transferred on an open goal.',
|
|
223
|
-
} };
|
|
224
|
-
}
|
|
225
|
-
if (actorRank !== 'archon' && !isGoalOwner(goal, actorId)) {
|
|
226
|
-
return { ok: false, status: 403, body: {
|
|
227
|
-
error: 'not_owner',
|
|
228
|
-
message: 'Only the goal owner (or an Archon) may transfer ownership.',
|
|
229
|
-
} };
|
|
230
|
-
}
|
|
231
|
-
if (!targetBuilder) {
|
|
232
|
-
return { ok: false, status: 404, body: { error: 'builder_not_found' } };
|
|
233
|
-
}
|
|
234
|
-
if (!METIC_PLUS.includes(targetBuilder.rank)) {
|
|
235
|
-
return { ok: false, status: 403, body: {
|
|
236
|
-
error: 'metic_required_for_ownership',
|
|
237
|
-
message: 'Goal ownership is creator-equivalent authority — the new owner must be Metic+.',
|
|
238
|
-
} };
|
|
239
|
-
}
|
|
240
|
-
if (isGoalOwner(goal, targetBuilder.id)) {
|
|
241
|
-
return { ok: true, noop: true };
|
|
242
|
-
}
|
|
243
|
-
return { ok: true };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// -------------------------------------------------------------------------
|
|
247
|
-
// Private goals: the invite / join-request queue (task 1952 / ADR 0112). A
|
|
248
|
-
// private goal is join-GATED (not hidden): a non-member can't self-join — they
|
|
249
|
-
// are INVITED (a consented, pending row they accept) or REQUEST to join (an
|
|
250
|
-
// owner/manager/Archon approves). Both directions live in goal_membership_requests
|
|
251
|
-
// (kind='invite'|'request'); the resolve is atomic (db.resolveMembershipRequest
|
|
252
|
-
// moves only from status='pending', so a re-submit can't double-add a member).
|
|
253
|
-
// -------------------------------------------------------------------------
|
|
254
|
-
|
|
255
|
-
// protectedScopeAdmissionBlock — the SINGLE protected-scope admission rule reused
|
|
256
|
-
// by BOTH new admission paths (invite-create and join-request-approve) AND the
|
|
257
|
-
// existing POST /goals/:id/members (whose inline checks this generalizes). A
|
|
258
|
-
// protected-scope goal admits members only via an Archon, and only Metic+ builders
|
|
259
|
-
// — sub-Metic membership inside protected scope is deferred to ADR 0086's Phase-5
|
|
260
|
-
// ADR. Centralized so the protected wall can never drift between paths (the SEC
|
|
261
|
-
// #863 single-roster lesson). Returns { status, body } to send, or null to proceed.
|
|
262
|
-
function protectedScopeAdmissionBlock({ scopeProtected, actorRank, targetRank }) {
|
|
263
|
-
if (!scopeProtected) return null;
|
|
264
|
-
if (actorRank !== 'archon') {
|
|
265
|
-
return { status: 403, body: {
|
|
266
|
-
error: 'archon_required_for_protected_scope',
|
|
267
|
-
message: 'This goal is scoped to a protected module; only an Archon may admit members.',
|
|
268
|
-
} };
|
|
269
|
-
}
|
|
270
|
-
if (!METIC_PLUS.includes(targetRank)) {
|
|
271
|
-
return { status: 403, body: {
|
|
272
|
-
error: 'metic_required_for_protected_scope',
|
|
273
|
-
message: 'A protected-scope goal requires a Metic+ member; sub-Metic membership is deferred to the Phase-5 ADR.',
|
|
274
|
-
} };
|
|
275
|
-
}
|
|
276
|
-
return null;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// authorizeCreateJoinRequest — the ORDERED, FAIL-CLOSED gate for
|
|
280
|
-
// POST /goals/:id/requests (task 1952): a builder asks to join. A request only
|
|
281
|
-
// makes sense where self-join is gated — a PUBLIC, NON-protected goal is open-join,
|
|
282
|
-
// so a request there is refused with a "just join" hint. Ordering:
|
|
283
|
-
// goal_not_found → goal_not_open → already_a_member → goal_is_open (public+unprotected).
|
|
284
|
-
// isMember is the caller's own membership; scopeProtected is scopeIncludesProtected(goal).
|
|
285
|
-
// PURE + exported (the authorizeGoalTaskCreate precedent).
|
|
286
|
-
function authorizeCreateJoinRequest({ goal, isMember, scopeProtected }) {
|
|
287
|
-
if (!goal) return { ok: false, status: 404, body: { error: 'goal_not_found' } };
|
|
288
|
-
if (goal.status !== 'open') {
|
|
289
|
-
return { ok: false, status: 409, body: {
|
|
290
|
-
error: 'goal_not_open', status: goal.status,
|
|
291
|
-
message: 'You can only request to join an open goal.',
|
|
292
|
-
} };
|
|
293
|
-
}
|
|
294
|
-
if (isMember) {
|
|
295
|
-
return { ok: false, status: 409, body: {
|
|
296
|
-
error: 'already_a_member', message: 'You are already a member of this goal.',
|
|
297
|
-
} };
|
|
298
|
-
}
|
|
299
|
-
if (goal.visibility !== 'private' && !scopeProtected) {
|
|
300
|
-
return { ok: false, status: 409, body: {
|
|
301
|
-
error: 'goal_is_open',
|
|
302
|
-
message: 'This goal is open — just join it (POST /goals/:id/join); no request needed.',
|
|
303
|
-
} };
|
|
304
|
-
}
|
|
305
|
-
return { ok: true };
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// authorizeInvitationResponse — the ORDERED, FAIL-CLOSED gate for
|
|
309
|
-
// POST /goals/:id/invitations/:reqId/respond (task 1952): ONLY the invited builder
|
|
310
|
-
// may accept/decline their OWN pending invite. Containment first — the row must
|
|
311
|
-
// belong to THIS goal and be an 'invite' — so the invitation route can never touch
|
|
312
|
-
// a join-request row. Ordering: request_not_found → wrong_goal → not_an_invitation
|
|
313
|
-
// → not_pending → not_your_invitation. PURE + exported.
|
|
314
|
-
function authorizeInvitationResponse({ request, goalId, actorId }) {
|
|
315
|
-
if (!request) return { ok: false, status: 404, body: { error: 'request_not_found' } };
|
|
316
|
-
if (String(request.goal_id) !== String(goalId)) {
|
|
317
|
-
return { ok: false, status: 404, body: { error: 'request_not_found', message: 'No such invitation on this goal.' } };
|
|
318
|
-
}
|
|
319
|
-
if (request.kind !== 'invite') {
|
|
320
|
-
return { ok: false, status: 400, body: { error: 'not_an_invitation', message: 'That row is a join-request, not an invitation.' } };
|
|
321
|
-
}
|
|
322
|
-
if (request.status !== 'pending') {
|
|
323
|
-
return { ok: false, status: 409, body: { error: 'not_pending', status: request.status } };
|
|
324
|
-
}
|
|
325
|
-
if (String(request.builder_id) !== String(actorId)) {
|
|
326
|
-
return { ok: false, status: 403, body: {
|
|
327
|
-
error: 'not_your_invitation',
|
|
328
|
-
message: 'Only the invited builder may accept or decline this invitation.',
|
|
329
|
-
} };
|
|
330
|
-
}
|
|
331
|
-
return { ok: true };
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// authorizeInvitationAccept — the ORDERED, FAIL-CLOSED gate for the ACCEPT half of
|
|
335
|
-
// POST /goals/:id/invitations/:reqId/respond, run against the GOAL after
|
|
336
|
-
// authorizeInvitationResponse has cleared the request row (task 1002887).
|
|
337
|
-
//
|
|
338
|
-
// Accepting is an ADMISSION, so it needs the same goal-state floor every other
|
|
339
|
-
// admission path already has: the create side requires an open goal
|
|
340
|
-
// (authorizeGoalManagement) and the join-request approve side inherits that check
|
|
341
|
-
// verbatim — only invitation-accept was missing it, so an invite issued while a
|
|
342
|
-
// goal was open could still add a member AFTER the goal was achieved or archived.
|
|
343
|
-
// DECLINE is deliberately NOT gated here: clearing a stale invitation out of your
|
|
344
|
-
// inbox is cleanup, not admission (the route returns before calling this).
|
|
345
|
-
// Ordering mirrors the sibling routes: goal_not_found → goal_not_open →
|
|
346
|
-
// metic_required_for_protected_scope (the pre-existing protected-scope membership
|
|
347
|
-
// floor, re-asserted against the ACCEPTING builder's current rank because the goal
|
|
348
|
-
// may have been re-scoped since the invite; the actor-is-Archon half of
|
|
349
|
-
// protectedScopeAdmissionBlock does not apply — the invitee is consenting).
|
|
350
|
-
//
|
|
351
|
-
// Unlike its {status, body} siblings this returns a res.fail SPEC ({ code, status,
|
|
352
|
-
// message, details }) because the accept branch already answers through res.fail —
|
|
353
|
-
// routing it through the envelope keeps the existing metic_required_for_protected_scope
|
|
354
|
-
// response byte-identical while still being PURE + exported for DB-free tests.
|
|
355
|
-
function authorizeInvitationAccept({ goal, scopeProtected, actorRank }) {
|
|
356
|
-
if (!goal) {
|
|
357
|
-
return { ok: false, code: 'goal_not_found', status: 404,
|
|
358
|
-
message: 'The goal behind this invitation no longer exists.' };
|
|
359
|
-
}
|
|
360
|
-
if (goal.status !== 'open') {
|
|
361
|
-
return { ok: false, code: 'goal_not_open', status: 409,
|
|
362
|
-
message: 'This goal is no longer open — an invitation can only be accepted while it is. You can still decline it.',
|
|
363
|
-
details: { status: goal.status } };
|
|
364
|
-
}
|
|
365
|
-
if (scopeProtected && !METIC_PLUS.includes(actorRank)) {
|
|
366
|
-
return { ok: false, code: 'metic_required_for_protected_scope', status: 403,
|
|
367
|
-
message: 'This goal is scoped to a protected module and requires a Metic+ member.' };
|
|
368
|
-
}
|
|
369
|
-
return { ok: true };
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// authorizeJoinRequestResponse — the gate for POST /goals/:id/requests/:reqId/respond
|
|
373
|
-
// (task 1952): an owner|manager|Archon approves/denies a pending join-request. The
|
|
374
|
-
// authority prefix is authorizeGoalManagement VERBATIM (goal exists/open + owner|
|
|
375
|
-
// manager|Archon — checked BEFORE the request lookup so an unauthorized actor never
|
|
376
|
-
// learns whether a request exists, the O1 no-leak ordering). Then request
|
|
377
|
-
// containment + state: request_not_found → wrong_goal → not_a_join_request →
|
|
378
|
-
// not_pending. PURE + exported.
|
|
379
|
-
function authorizeJoinRequestResponse({ goal, actorId, actorRank, actorMembership, request, goalId }) {
|
|
380
|
-
const mgmt = authorizeGoalManagement({ goal, actorId, actorRank, actorMembership });
|
|
381
|
-
if (!mgmt.ok) return mgmt;
|
|
382
|
-
if (!request) return { ok: false, status: 404, body: { error: 'request_not_found' } };
|
|
383
|
-
if (String(request.goal_id) !== String(goalId)) {
|
|
384
|
-
return { ok: false, status: 404, body: { error: 'request_not_found', message: 'No such join-request on this goal.' } };
|
|
385
|
-
}
|
|
386
|
-
if (request.kind !== 'request') {
|
|
387
|
-
return { ok: false, status: 400, body: { error: 'not_a_join_request', message: 'That row is an invitation, not a join-request.' } };
|
|
388
|
-
}
|
|
389
|
-
if (request.status !== 'pending') {
|
|
390
|
-
return { ok: false, status: 409, body: { error: 'not_pending', status: request.status } };
|
|
391
|
-
}
|
|
392
|
-
return { ok: true };
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// authorizeWithdrawJoinRequest — the gate for POST /goals/:id/requests/:reqId/withdraw
|
|
396
|
-
// (task 1953): ONLY the requester may withdraw their OWN pending join-request — a
|
|
397
|
-
// symmetric mirror of authorizeInvitationResponse (the invitee resolves their own
|
|
398
|
-
// invite; here the requester resolves their own request, self-served instead of
|
|
399
|
-
// waiting on an owner/manager deny). Ordering: request_not_found → wrong_goal →
|
|
400
|
-
// not_a_join_request → not_pending → not_your_request. PURE + exported.
|
|
401
|
-
function authorizeWithdrawJoinRequest({ request, goalId, actorId }) {
|
|
402
|
-
if (!request) return { ok: false, status: 404, body: { error: 'request_not_found' } };
|
|
403
|
-
if (String(request.goal_id) !== String(goalId)) {
|
|
404
|
-
return { ok: false, status: 404, body: { error: 'request_not_found', message: 'No such request on this goal.' } };
|
|
405
|
-
}
|
|
406
|
-
if (request.kind !== 'request') {
|
|
407
|
-
return { ok: false, status: 400, body: { error: 'not_a_join_request', message: 'That row is an invitation, not a join-request.' } };
|
|
408
|
-
}
|
|
409
|
-
if (request.status !== 'pending') {
|
|
410
|
-
return { ok: false, status: 409, body: { error: 'not_pending', status: request.status } };
|
|
411
|
-
}
|
|
412
|
-
if (String(request.builder_id) !== String(actorId)) {
|
|
413
|
-
return { ok: false, status: 403, body: {
|
|
414
|
-
error: 'not_your_request',
|
|
415
|
-
message: 'Only the requester may withdraw their own join-request.',
|
|
416
|
-
} };
|
|
417
|
-
}
|
|
418
|
-
return { ok: true };
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// decorateGoalScope — additive, derived scope_protected flag on goal payloads
|
|
422
|
-
// (task 1002689). A protected-scope goal is join-gated like a private one
|
|
423
|
-
// (Archon-mediated membership, ADR 0086 §3), but the hall cannot re-derive the
|
|
424
|
-
// protected roster client-side (module-scope-map is server-only) — so the
|
|
425
|
-
// list/detail reads carry the boolean and the UI renders the request-to-join
|
|
426
|
-
// path instead of a dead-end Join button. Never persisted; a pure map.
|
|
427
|
-
function decorateGoalScope(goal) {
|
|
428
|
-
return goal ? { ...goal, scope_protected: scopeIncludesProtected(goal.scope_modules) } : goal;
|
|
429
|
-
}
|
|
430
|
-
|
|
64
|
+
const { authorizeCreateJoinRequest, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
431
65
|
module.exports = function buildGoalsRouter() {
|
|
432
66
|
const router = express.Router();
|
|
433
67
|
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// modules/lifecycle/routes/task-route-helpers.js — the small predicates and
|
|
2
|
+
// gates the task routes share: #NNN de-hashing, migration detection, the grader
|
|
3
|
+
// bypass switch, task ownership, verify-task detection, gate-trust attestation
|
|
4
|
+
// and the peer-vote net.
|
|
5
|
+
//
|
|
6
|
+
// Carved out of modules/lifecycle/routes/tasks.js by task 1003206 (goal 1000079,
|
|
7
|
+
// the R03 de-monolith plan). Pulled out FIRST so the route groups that follow can
|
|
8
|
+
// share them without either side requiring the other — the split has to stay a
|
|
9
|
+
// DAG. A STRUCTURAL move: bodies unchanged, and tasks.js re-exports the ones its
|
|
10
|
+
// tests import.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const api = require('../../../src/module-api');
|
|
16
|
+
const db = require('../db');
|
|
17
|
+
const githubPush = require('../github-push');
|
|
18
|
+
const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
19
|
+
const { pool } = api;
|
|
20
|
+
|
|
21
|
+
function deHashRefs(text) {
|
|
22
|
+
return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// tasks.discipline — orthogonal to kind. See migrations 014 + 086.
|
|
26
|
+
// VALID_DISCIPLINES (engineer = builds code · artist = makes visual assets ·
|
|
27
|
+
// ideator = thinks/writes/scopes · unclassified = un-triaged) is imported above
|
|
28
|
+
// from ../task-classifier — the single source (D1).
|
|
29
|
+
|
|
30
|
+
// ?fit= on /tasks/claimable — narrows to tasks the autonomous overnight
|
|
31
|
+
// session-runner (#358) may pick up. See db.listClaimableTasks.
|
|
32
|
+
const VALID_FITS = new Set(['autonomous', 'parallel']);
|
|
33
|
+
|
|
34
|
+
// V3.R48 (#267): auto-detect schema-change tasks so the touches[] scanner
|
|
35
|
+
// reserves a migration number even when the builder forgets to list
|
|
36
|
+
// 'migrations/'. The keyword set is intentionally conservative — it matches
|
|
37
|
+
// whole tokens ("migration"/"migrations", "schema") and the two SQL phrases
|
|
38
|
+
// that almost always imply a migration, so unrelated words ("schematic",
|
|
39
|
+
// "reschema", a button color) don't false-positive. Case-insensitive.
|
|
40
|
+
//
|
|
41
|
+
// Migration-keyword detection. POST /tasks uses this to set the
|
|
42
|
+
// tasks.needs_migration boolean (migration 081, touches[] cleanup [1/8] /
|
|
43
|
+
// task 876), which is what the claim-time allocator now keys off — replacing
|
|
44
|
+
// the old "inject 'migrations/' into touches[]" sentinel. Conservative,
|
|
45
|
+
// whole-token, case-insensitive. The former scripts/gds/capture.js mirror was
|
|
46
|
+
// deleted with the same task: captured ideas have no touches[] and the server
|
|
47
|
+
// re-detects on create, so the CLI copy earned its keep no longer.
|
|
48
|
+
const MIGRATION_KEYWORD_RE =
|
|
49
|
+
/\bmigrations?\b|\bschema\b|\balter\s+table\b|\bcreate\s+table\b/i;
|
|
50
|
+
|
|
51
|
+
// task 1002648 (idea 1000642): compounds where the keyword is qualified by a
|
|
52
|
+
// NON-database noun are refactor/API language, not schema work, and are blanked
|
|
53
|
+
// before the keyword test. Two families surfaced in the wild: "gate/route/code
|
|
54
|
+
// migration" (code-migration phrasing) and "request/PATCH/:NNN schema"
|
|
55
|
+
// (API-shape phrasing — the task-1002614 false positive, which reserved
|
|
56
|
+
// migration 213 for zero schema change). Bare "migration"/"schema" and every
|
|
57
|
+
// DB-shaped compound ("schema migration", "DB schema") still flag.
|
|
58
|
+
const NON_DB_COMPOUND_RE = new RegExp(
|
|
59
|
+
[
|
|
60
|
+
/\b(?:gate|route|code)s?[-\s]+migrations?\b/.source,
|
|
61
|
+
/\bmigrations?\s+of\s+(?:gate|route|code)s?\b/.source,
|
|
62
|
+
/\b(?:request|response|body|validation|patch|post|put|delete|json|openapi|api|error)[-\s]+schemas?\b/.source,
|
|
63
|
+
/:\d+[-\s]+schemas?\b/.source,
|
|
64
|
+
].join('|'),
|
|
65
|
+
'gi'
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
function detectsMigration(...texts) {
|
|
69
|
+
return texts.some(
|
|
70
|
+
(t) => typeof t === 'string' && MIGRATION_KEYWORD_RE.test(t.replace(NON_DB_COMPOUND_RE, ' '))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- peer votes (V3.R25 #245) ---------------------------------------------
|
|
75
|
+
// The peer-vote SQL lives here (inline, via the route module's `pool`) rather
|
|
76
|
+
// than in db.js on purpose: it's a self-contained feature, declared in this
|
|
77
|
+
// task's touches[] (migrations/, this file, modules/hall-ui/), and keeping it
|
|
78
|
+
// out of the large shared db.js avoids a parallel-safety conflict on that file.
|
|
79
|
+
// Same deliberate-locality call as the MIGRATION_KEYWORD_RE mirror above.
|
|
80
|
+
|
|
81
|
+
// VOTABLE_TASK_STATUSES — a task is votable once it's verified work the project
|
|
82
|
+
// keeps (confirmed = past the grader, not yet paid; or shipped = deployed + paid);
|
|
83
|
+
// backlog/ready/completed are not. Imported above from ../task-classifier (D1).
|
|
84
|
+
|
|
85
|
+
// #853 (ADR 0041): temporary, default-OFF global grader bypass. When the env
|
|
86
|
+
// GRADER_BYPASS_ENABLED is exactly '1', the subagent quality grader is treated
|
|
87
|
+
// as unavailable and any builder may land work via /tasks/:id/confirm (normally
|
|
88
|
+
// archon-only). Read live on each request — flipping the env (+ redeploy) is the
|
|
89
|
+
// on/off switch; no caching, so turning it off re-arms the trust boundary at
|
|
90
|
+
// once. Exported so /me can surface the same flag to ship.js.
|
|
91
|
+
function graderBypassEnabled() {
|
|
92
|
+
return process.env.GRADER_BYPASS_ENABLED === '1';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// SECURITY (#871): claim-ownership gate for the lifecycle-advancing routes
|
|
96
|
+
// (/grade, /confirm, /ship). These are requireBuilder-only (the grade is meant
|
|
97
|
+
// to be an objective subagent output; the supervisor hot path must not need an
|
|
98
|
+
// Archon), which — combined with applyGrade/confirmTask crediting work — let any
|
|
99
|
+
// authenticated builder act on ANOTHER builder's task:
|
|
100
|
+
// * /grade (requireBuilder, no rank gate): a builder could POST a fabricated
|
|
101
|
+
// passing grade on someone else's `completed` task. Pre-#871 applyGrade then
|
|
102
|
+
// credited the CALLER → cross-builder credit theft + ship-attribution hijack
|
|
103
|
+
// + a full bypass of the quality grader.
|
|
104
|
+
// * /confirm: archon-only normally, but the GRADER_BYPASS killswitch (ON
|
|
105
|
+
// during the grader outage) relaxes it to ANY builder, so anyone could
|
|
106
|
+
// force-confirm anyone's `completed` work past the (absent) grader.
|
|
107
|
+
// * /ship: any-builder supervisor hot path (so /merge-mode can land the queue).
|
|
108
|
+
//
|
|
109
|
+
// Allow when: the caller is an Archon (full authority / --regrade); OR — for
|
|
110
|
+
// /ship only (allowSupervisor) — the caller is Metic, the trusted rank that runs
|
|
111
|
+
// /merge-mode to sweep the confirmed queue across builders; OR the caller is the
|
|
112
|
+
// task's most-recent claim holder (the builder who did the work).
|
|
113
|
+
//
|
|
114
|
+
// A NEVER-CLAIMED task (degenerate state, e.g. status set directly via SQL) is
|
|
115
|
+
// NOT an open door: the Archon and supervisor early-returns above already cover
|
|
116
|
+
// the legitimate actors who land such a task. By the time we read the claim
|
|
117
|
+
// holder, the caller is necessarily a non-Archon (and not a supervisor Metic) —
|
|
118
|
+
// so a missing claim must FAIL CLOSED with the same 403, not silently ALLOW.
|
|
119
|
+
// (L2 residual, red-team 2026-06-12: combined with the GRADER_BYPASS relaxation
|
|
120
|
+
// on /confirm, the old "no claim → ALLOW" let any builder confirm a claimless
|
|
121
|
+
// `completed` task and have the credit fall to themselves.) An Archon (or the
|
|
122
|
+
// /merge-mode supervisor) can still land a claimless task — they pass above.
|
|
123
|
+
// Returns true if allowed; otherwise writes a 403 and returns false.
|
|
124
|
+
async function gateTaskOwnership(req, res, taskId, { allowSupervisor = false } = {}) {
|
|
125
|
+
const rank = req.builder && req.builder.rank;
|
|
126
|
+
if (rank === 'archon') return true;
|
|
127
|
+
if (allowSupervisor && rank === 'metic') return true;
|
|
128
|
+
const holder = await db.getLastClaimHolder(taskId);
|
|
129
|
+
if (holder && String(holder.builder_id) === String(req.builder.id)) return true;
|
|
130
|
+
// task 1002614 (map N-2): a mistyped id used to earn the same not_your_task
|
|
131
|
+
// 403 as someone else's work — a misattribution that sent builders debugging
|
|
132
|
+
// ownership. An ABSENT task is the clean 404 an Archon already saw (they pass
|
|
133
|
+
// above and hit the handler's own lookup). A never-claimed task that EXISTS
|
|
134
|
+
// still fails closed with the 403 below (the L2 credit-theft block).
|
|
135
|
+
if (!holder && !(await db.getTask(taskId))) {
|
|
136
|
+
res.fail('task_not_found', 404);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
res.fail('not_your_task', { status: 403, message: 'Only the task\'s claim holder (or an Archon) may grade/confirm/ship it.' });
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// task 1053 (F): a verification task — one whose deliverable IS a verification
|
|
144
|
+
// (e.g. a hardware-verify run). Confirming one requires an explicit attestation
|
|
145
|
+
// that the verify actually passed, so a hardware-verify can't be confirmed on a
|
|
146
|
+
// passing code-grade before the verify was even performed (the #1027 trap).
|
|
147
|
+
// Keyed on the explicit kind (set by the planner), NOT a title regex — a task
|
|
148
|
+
// ABOUT verify work would false-positive on text. Pure + exported for tests.
|
|
149
|
+
function isVerifyTask(task) {
|
|
150
|
+
return !!task && task.kind === 'verify';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// task 1139 / 1169 (ADR 0058): the trustless server rank-attestation behind the
|
|
154
|
+
// gate-review CI check. Only the SERVER can set it — it holds the authenticated
|
|
155
|
+
// builder's server-resolved rank AND the App/PAT credential — so CI can trust it
|
|
156
|
+
// where a PR-author-controlled signal (a PR title) would be spoofable. A Metic+
|
|
157
|
+
// author's gate-author-trust=success clears a NEEDS_TRUST gate surface with no
|
|
158
|
+
// owner click; below Metic gets no attestation (the gate escalates → owner review).
|
|
159
|
+
//
|
|
160
|
+
// rankAttestsGateTrust is the PURE rank decision (no network) — exported for tests.
|
|
161
|
+
function rankAttestsGateTrust(rank) {
|
|
162
|
+
const { tierOf, MIN_TIER } = api.permissionPathCheck;
|
|
163
|
+
const tier = tierOf(rank);
|
|
164
|
+
return tier != null && tier >= MIN_TIER;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// postGateAuthorTrust — set (or skip) the gate-author-trust commit status for an
|
|
168
|
+
// author's rank on a pushed SHA. Shared by the server-mediated publish path
|
|
169
|
+
// (POST /tasks/:id/publish-branch) and the laptop ci-land path's attest endpoint
|
|
170
|
+
// (POST /tasks/:id/attest-gate). Best-effort + never throws: a failed/skipped
|
|
171
|
+
// status post leaves the PR un-attested, which ESCALATES (the safe direction — no
|
|
172
|
+
// rank bypass without a real server signal). Returns { attested, reason } for UX.
|
|
173
|
+
async function postGateAuthorTrust({ rank, sha, taskId }) {
|
|
174
|
+
if (!rankAttestsGateTrust(rank)) return { attested: false, reason: 'rank_below_metic' };
|
|
175
|
+
try {
|
|
176
|
+
const ok = await githubPush.setCommitStatus({
|
|
177
|
+
sha,
|
|
178
|
+
state: 'success',
|
|
179
|
+
context: 'gate-author-trust',
|
|
180
|
+
description: `Metic+ author (${rank}) — task ${taskId}`,
|
|
181
|
+
});
|
|
182
|
+
return { attested: !!ok, reason: ok ? null : 'status_post_failed' };
|
|
183
|
+
} catch (_) {
|
|
184
|
+
return { attested: false, reason: 'status_post_failed' };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function peerVoteNet(taskId) {
|
|
189
|
+
const { rows } = await pool.query(
|
|
190
|
+
`SELECT COALESCE(SUM(vote), 0)::int AS net, COUNT(*)::int AS total
|
|
191
|
+
FROM peer_votes WHERE target_task_id = $1`,
|
|
192
|
+
[taskId]
|
|
193
|
+
);
|
|
194
|
+
return { net: rows[0].net, total: rows[0].total };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// #514: bad_kind responses carried a `valid` array but no human-readable line,
|
|
198
|
+
// so a caller (or CLI that only prints .message) couldn't tell at a glance what
|
|
199
|
+
// went wrong — the #506 session burned a turn guessing kind=chore. Add a
|
|
200
|
+
// .message that spells out the allowed values while keeping the valid[] array.
|
|
201
|
+
function badKind(res) {
|
|
202
|
+
const valid = Array.from(VALID_KINDS);
|
|
203
|
+
return res.fail('bad_kind', { status: 400, message: `kind must be one of: ${valid.join(', ')}`, details: { valid } });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
MIGRATION_KEYWORD_RE,
|
|
209
|
+
VALID_FITS,
|
|
210
|
+
badKind,
|
|
211
|
+
deHashRefs,
|
|
212
|
+
detectsMigration,
|
|
213
|
+
gateTaskOwnership,
|
|
214
|
+
graderBypassEnabled,
|
|
215
|
+
isVerifyTask,
|
|
216
|
+
peerVoteNet,
|
|
217
|
+
postGateAuthorTrust,
|
|
218
|
+
rankAttestsGateTrust,
|
|
219
|
+
};
|