@bongos/core 1.19.332 → 1.19.334

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 (62) hide show
  1. package/.bongos-core.json +173 -43
  2. package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/api/openapi.json +2 -2
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +202 -202
  7. package/docs/copy-registry.json +384 -384
  8. package/modules/grading/grader-config.js +98 -0
  9. package/modules/grading/grader-model.js +150 -0
  10. package/modules/grading/grader-prompt.js +139 -0
  11. package/modules/grading/grader-score.js +706 -0
  12. package/modules/grading/grader-subagent.js +328 -0
  13. package/modules/grading/grader.js +5 -1287
  14. package/modules/hall-ui/public/builders.js +19 -1182
  15. package/modules/hall-ui/public/goals-page.js +11 -997
  16. package/modules/hall-ui/public/goals-render.js +1032 -0
  17. package/modules/hall-ui/public/goals.html +4 -0
  18. package/modules/hall-ui/public/hall-render.js +1211 -0
  19. package/modules/hall-ui/public/index.html +6 -0
  20. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  21. package/modules/lifecycle/routes/goals.js +1 -367
  22. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  23. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  24. package/modules/lifecycle/routes/tasks.js +3 -696
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/bongos/routes/auth.js +54 -10
  46. package/src/module-api.js +5 -1
  47. package/tests/auth_admission_gate.mjs +3 -1
  48. package/tests/auth_web_recovery.mjs +63 -0
  49. package/tests/est_advisory.mjs +3 -1
  50. package/tests/goal_advisory.mjs +3 -2
  51. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  52. package/tests/goal_subtitle.mjs +5 -2
  53. package/tests/goals_card_view.mjs +10 -3
  54. package/tests/grader_generated_provenance.mjs +3 -1
  55. package/tests/hall_palette.mjs +3 -1
  56. package/tests/helpers.mjs +58 -25
  57. package/tests/lib_sh_resolution.mjs +3 -1
  58. package/tests/membership_kind_lockstep.mjs +3 -1
  59. package/tests/migration_allocation.mjs +5 -2
  60. package/tests/patch_gate_ordering.mjs +3 -1
  61. package/tests/rank_tier_single_source.mjs +16 -3
  62. 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
- async function isMemberOrArchon(builder, goalId) {
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
+ };