@bongos/core 1.19.409 → 1.19.410
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 +35 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +122 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +43 -36
- package/docs/copy-registry.json +122 -39
- package/migrations/core_229_goals_accepting_requests.sql +37 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/goals-page.js +26 -2
- package/modules/hall-ui/public/goals-render.js +35 -0
- package/modules/lifecycle/db-goals.js +22 -1
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/goal-edits.js +40 -0
- package/modules/lifecycle/routes/goal-route-authz.js +51 -1
- package/modules/lifecycle/routes/goals.js +46 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +6 -0
- package/src/module-api.js +3 -1
- package/tests/goal_membership_requests.mjs +154 -1
- package/tests/prose_edits.mjs +8 -4
package/modules/lifecycle/db.js
CHANGED
|
@@ -90,6 +90,7 @@ const {
|
|
|
90
90
|
setGoalMembershipKind,
|
|
91
91
|
setGoalStatus,
|
|
92
92
|
setGoalSuccessor,
|
|
93
|
+
setGoalAcceptingRequests,
|
|
93
94
|
setGoalVisibility,
|
|
94
95
|
transferGoalOwnership,
|
|
95
96
|
} = require('./db-goals.js');
|
|
@@ -227,8 +228,10 @@ module.exports = {
|
|
|
227
228
|
rescopeTaskTouches,
|
|
228
229
|
addGoalScopeModules,
|
|
229
230
|
setGoalSuccessor,
|
|
230
|
-
// Private goals: visibility + the invite/join-request queue (task 1951 / ADR 0112)
|
|
231
|
+
// Private goals: visibility + the invite/join-request queue (task 1951 / ADR 0112),
|
|
232
|
+
// plus the D6 open-/closed-to-requests flag (task 1002971).
|
|
231
233
|
setGoalVisibility,
|
|
234
|
+
setGoalAcceptingRequests,
|
|
232
235
|
createMembershipRequest,
|
|
233
236
|
getMembershipRequest,
|
|
234
237
|
resolveMembershipRequest,
|
|
@@ -289,8 +289,48 @@ function goalVisibilityHandler({ db, authorizeGoalManagement, validateOrRespond,
|
|
|
289
289
|
};
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
// goalAcceptingRequestsHandler — PATCH /goals/:id/accepting-requests (task
|
|
293
|
+
// 1002971, spec docs/specs/bongos-v1-privacy-and-invites.md D6): a goal's own
|
|
294
|
+
// authority opens or closes the goal to new join-requests. Lives beside
|
|
295
|
+
// goalVisibilityHandler for the same reason that one does — a write to the goal's
|
|
296
|
+
// own row behind the IDENTICAL gate (authorizeGoalManagement) — and here rather
|
|
297
|
+
// than in routes/goals.js because that file is over the fitness oversize
|
|
298
|
+
// threshold and the ratchet is shrink-only.
|
|
299
|
+
//
|
|
300
|
+
// A SEPARATE route from PATCH /goals/:id/visibility on purpose: they are two
|
|
301
|
+
// different statements a lead makes ("who may see the door" vs "is the door
|
|
302
|
+
// taking knocks"), a lead flips the second far more often than the first, and
|
|
303
|
+
// folding them would make one careless request able to un-privatise a goal. The
|
|
304
|
+
// server does NOT refuse the write on a public goal — the flag is simply
|
|
305
|
+
// meaningless there (self-join needs no request) and the refusal gate is what
|
|
306
|
+
// reads visibility; that also means a goal can be pre-closed before going
|
|
307
|
+
// private without a two-step dance.
|
|
308
|
+
function goalAcceptingRequestsHandler({ db, authorizeGoalManagement, validateOrRespond, parseId }) {
|
|
309
|
+
return async function patchGoalAcceptingRequests(req, res) {
|
|
310
|
+
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
311
|
+
if (id === null) return;
|
|
312
|
+
if (validateOrRespond(req, res, { accepting_requests: { required: true, type: 'boolean' } })) return;
|
|
313
|
+
try {
|
|
314
|
+
const goal = await db.getGoal(id);
|
|
315
|
+
const actorMembership = goal
|
|
316
|
+
? await db.getGoalMember({ goalId: id, builderId: req.builder.id })
|
|
317
|
+
: null;
|
|
318
|
+
const decision = authorizeGoalManagement({
|
|
319
|
+
goal, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
|
|
320
|
+
});
|
|
321
|
+
if (!decision.ok) return res.status(decision.status).json(decision.body);
|
|
322
|
+
const updated = await db.setGoalAcceptingRequests({ goalId: id, acceptingRequests: req.body.accepting_requests });
|
|
323
|
+
return res.json({ ok: true, goal: updated });
|
|
324
|
+
} catch (err) {
|
|
325
|
+
proseLog().error({ err: err && err.message }, 'PATCH /goals/:id/accepting-requests failed');
|
|
326
|
+
return res.fail('accepting_requests_update_failed', { status: 500, message: 'internal error' });
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
292
331
|
module.exports = {
|
|
293
332
|
criterionProseRefusal,
|
|
333
|
+
goalAcceptingRequestsHandler,
|
|
294
334
|
logProseEdit,
|
|
295
335
|
recordProseEdit,
|
|
296
336
|
goalProseHandler,
|
|
@@ -236,9 +236,20 @@ function protectedScopeAdmissionBlock({ scopeProtected, actorRank, targetRank })
|
|
|
236
236
|
// POST /goals/:id/requests (task 1952): a builder asks to join. A request only
|
|
237
237
|
// makes sense where self-join is gated — a PUBLIC, NON-protected goal is open-join,
|
|
238
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)
|
|
239
|
+
// goal_not_found → goal_not_open → already_a_member → goal_is_open (public+unprotected)
|
|
240
|
+
// → not_accepting_requests (private + closed to applications).
|
|
240
241
|
// isMember is the caller's own membership; scopeProtected is scopeIncludesProtected(goal).
|
|
241
242
|
// PURE + exported (the authorizeGoalTaskCreate precedent).
|
|
243
|
+
//
|
|
244
|
+
// THE CLOSED-TO-REQUESTS ARM (task 1002971, spec D6): a private goal whose lead
|
|
245
|
+
// set accepting_requests=false is not taking applications, so the ask is refused
|
|
246
|
+
// with honest copy rather than queued where nobody will answer it. Deliberately
|
|
247
|
+
// narrowed to visibility='private' — D6 calls the flag meaningless on a public
|
|
248
|
+
// goal (self-join needs no request), so a PUBLIC protected-scope goal keeps
|
|
249
|
+
// today's Archon-mediated request door open whatever the column says. And it is
|
|
250
|
+
// checked HERE, not on the invite paths: closing applications is about
|
|
251
|
+
// unsolicited inbound, and a lead reaching out to a named builder is the
|
|
252
|
+
// deliberate act the flag exists to protect.
|
|
242
253
|
function authorizeCreateJoinRequest({ goal, isMember, scopeProtected }) {
|
|
243
254
|
if (!goal) return { ok: false, status: 404, body: { error: 'goal_not_found' } };
|
|
244
255
|
if (goal.status !== 'open') {
|
|
@@ -258,6 +269,12 @@ function authorizeCreateJoinRequest({ goal, isMember, scopeProtected }) {
|
|
|
258
269
|
message: 'This goal is open — just join it (POST /goals/:id/join); no request needed.',
|
|
259
270
|
} };
|
|
260
271
|
}
|
|
272
|
+
if (goal.visibility === 'private' && goal.accepting_requests === false) {
|
|
273
|
+
return { ok: false, status: 409, body: {
|
|
274
|
+
error: 'not_accepting_requests',
|
|
275
|
+
message: "This goal isn't taking requests right now.",
|
|
276
|
+
} };
|
|
277
|
+
}
|
|
261
278
|
return { ok: true };
|
|
262
279
|
}
|
|
263
280
|
|
|
@@ -374,6 +391,38 @@ function authorizeWithdrawJoinRequest({ request, goalId, actorId }) {
|
|
|
374
391
|
return { ok: true };
|
|
375
392
|
}
|
|
376
393
|
|
|
394
|
+
// authorizeRescindInvitation — the gate for POST /goals/:id/invitations/:reqId/rescind
|
|
395
|
+
// (task 1002971): an owner|manager|Archon takes back an invitation the goal SENT
|
|
396
|
+
// but nobody has answered yet. This is the MISSING HALF of core_188's 'cancelled'
|
|
397
|
+
// state, which that migration defined as "withdrawn/rescinded by the opener" —
|
|
398
|
+
// only the requester-withdraw half (authorizeWithdrawJoinRequest) was ever built,
|
|
399
|
+
// so a mis-addressed or regretted invite could only be un-sent by asking the
|
|
400
|
+
// invitee to decline it. The opener is a MANAGER here, not the row's builder_id,
|
|
401
|
+
// which is why the authority prefix is authorizeGoalManagement VERBATIM (the
|
|
402
|
+
// authorizeJoinRequestResponse shape) rather than the "is this your row" identity
|
|
403
|
+
// check its withdraw mirror uses — and it runs BEFORE the request lookup so an
|
|
404
|
+
// unauthorized actor never learns whether an invitation exists (the O1 no-leak
|
|
405
|
+
// ordering). Then request containment + state: request_not_found → wrong_goal →
|
|
406
|
+
// not_an_invitation → not_pending, so this route can never touch a join-request
|
|
407
|
+
// row. Resolving an already-resolved invite is refused (409 not_pending) rather
|
|
408
|
+
// than silently re-cancelled: db.resolveMembershipRequest moves only from
|
|
409
|
+
// status='pending', so a repeat rescind changes no state either way. PURE + exported.
|
|
410
|
+
function authorizeRescindInvitation({ goal, actorId, actorRank, actorMembership, request, goalId }) {
|
|
411
|
+
const mgmt = authorizeGoalManagement({ goal, actorId, actorRank, actorMembership });
|
|
412
|
+
if (!mgmt.ok) return mgmt;
|
|
413
|
+
if (!request) return { ok: false, status: 404, body: { error: 'request_not_found' } };
|
|
414
|
+
if (String(request.goal_id) !== String(goalId)) {
|
|
415
|
+
return { ok: false, status: 404, body: { error: 'request_not_found', message: 'No such invitation on this goal.' } };
|
|
416
|
+
}
|
|
417
|
+
if (request.kind !== 'invite') {
|
|
418
|
+
return { ok: false, status: 400, body: { error: 'not_an_invitation', message: 'That row is a join-request, not an invitation.' } };
|
|
419
|
+
}
|
|
420
|
+
if (request.status !== 'pending') {
|
|
421
|
+
return { ok: false, status: 409, body: { error: 'not_pending', status: request.status } };
|
|
422
|
+
}
|
|
423
|
+
return { ok: true };
|
|
424
|
+
}
|
|
425
|
+
|
|
377
426
|
// decorateGoalScope — additive, derived scope_protected flag on goal payloads
|
|
378
427
|
// (task 1002689). A protected-scope goal is join-gated like a private one
|
|
379
428
|
// (Archon-mediated membership, ADR 0086 §3), but the hall cannot re-derive the
|
|
@@ -393,6 +442,7 @@ module.exports = {
|
|
|
393
442
|
authorizeJoinRequestResponse,
|
|
394
443
|
authorizeMembershipKindChange,
|
|
395
444
|
authorizeOwnershipTransfer,
|
|
445
|
+
authorizeRescindInvitation,
|
|
396
446
|
authorizeWithdrawJoinRequest,
|
|
397
447
|
decorateGoalScope,
|
|
398
448
|
isMemberOrArchon,
|
|
@@ -63,7 +63,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
|
|
|
63
63
|
// proceed; a false result means "not a member" → the route should 403. This is the
|
|
64
64
|
// membership half of the wall; the protected-module rank check is enforced per-route
|
|
65
65
|
// (scope is never self-granted).
|
|
66
|
-
const { authorizeCreateJoinRequest, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
66
|
+
const { authorizeCreateJoinRequest, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
67
67
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
68
68
|
const log = api.logger('lifecycle');
|
|
69
69
|
module.exports = function buildGoalsRouter() {
|
|
@@ -334,6 +334,15 @@ module.exports = function buildGoalsRouter() {
|
|
|
334
334
|
// Pinned in route-rank-check.MODULE_EXPECTED_RANKS['lifecycle'].
|
|
335
335
|
router.patch('/goals/:id/visibility', auth.requireBuilder, goalEdits.goalVisibilityHandler({ db, authorizeGoalManagement, validateOrRespond, parseId, VALID_VISIBILITIES }));
|
|
336
336
|
|
|
337
|
+
// PATCH /goals/:id/accepting-requests { accepting_requests } — a private goal's
|
|
338
|
+
// lead closes or re-opens it to join-requests (task 1002971, spec D6). Closed
|
|
339
|
+
// refuses new asks with honest copy; INVITATIONS ARE UNAFFECTED, so a lead can
|
|
340
|
+
// still reach out. Handler in goal-edits.js beside the visibility toggle (the
|
|
341
|
+
// same gate, the same kind of write); only the registration lives here so
|
|
342
|
+
// route-rank-check still classifies it.
|
|
343
|
+
// rank: any-builder (requireBuilder) + the in-handler owner|manager|Archon gate.
|
|
344
|
+
router.patch('/goals/:id/accepting-requests', auth.requireBuilder, goalEdits.goalAcceptingRequestsHandler({ db, authorizeGoalManagement, validateOrRespond, parseId }));
|
|
345
|
+
|
|
337
346
|
// ---- the ADVISORY human category (task 1003275, criterion legibility-categories) ----
|
|
338
347
|
// A category ORIENTS a person and steers Claude; it authorises nothing. Nothing
|
|
339
348
|
// in this file, in claim eligibility, or in the claimable feed may branch on it —
|
|
@@ -605,6 +614,39 @@ module.exports = function buildGoalsRouter() {
|
|
|
605
614
|
}
|
|
606
615
|
});
|
|
607
616
|
|
|
617
|
+
// POST /goals/:id/invitations/:reqId/rescind — an owner|manager|Archon takes
|
|
618
|
+
// back a PENDING invitation the goal sent (task 1002971): the manager-side
|
|
619
|
+
// mirror of the requester's withdraw, and the missing half of core_188's
|
|
620
|
+
// 'cancelled' state ("withdrawn/rescinded by the opener" — only the requester
|
|
621
|
+
// half existed). Sets status='cancelled', kept as the audit trail like a deny's
|
|
622
|
+
// 'rejected'; never touches a join-request row.
|
|
623
|
+
// rank: any-builder (requireBuilder) + the in-handler authorizeRescindInvitation.
|
|
624
|
+
router.post('/goals/:id/invitations/:reqId/rescind', auth.requireBuilder, async (req, res) => {
|
|
625
|
+
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
626
|
+
if (id === null) return;
|
|
627
|
+
const reqId = parseId(req, res, { param: 'reqId', code: 'bad_request_id', positiveInt: true });
|
|
628
|
+
if (reqId === null) return;
|
|
629
|
+
try {
|
|
630
|
+
const goal = await db.getGoal(id);
|
|
631
|
+
const [actorMembership, request] = goal
|
|
632
|
+
? await Promise.all([
|
|
633
|
+
db.getGoalMember({ goalId: id, builderId: req.builder.id }),
|
|
634
|
+
db.getMembershipRequest(reqId),
|
|
635
|
+
])
|
|
636
|
+
: [null, null];
|
|
637
|
+
const decision = authorizeRescindInvitation({
|
|
638
|
+
goal, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership, request, goalId: id,
|
|
639
|
+
});
|
|
640
|
+
if (!decision.ok) return res.status(decision.status).json(decision.body);
|
|
641
|
+
const resolved = await db.resolveMembershipRequest({ id: reqId, status: 'cancelled', resolvedBy: req.builder.id });
|
|
642
|
+
if (!resolved) return res.fail('not_pending', { status: 409, message: 'The invitation changed underneath this rescind — re-read and retry.' });
|
|
643
|
+
return res.json({ ok: true, request: resolved });
|
|
644
|
+
} catch (err) {
|
|
645
|
+
log.error('[gds] POST /goals/:id/invitations/:reqId/rescind', err);
|
|
646
|
+
return res.fail('rescind_failed', { status: 500, message: 'internal error' });
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
608
650
|
// POST /goals/:id/members — the goal's own authority (owner | manager | Archon)
|
|
609
651
|
// invites a builder to the goal (task 1762: widened from Archon-only so a
|
|
610
652
|
// manager doesn't need an Archon in the loop for a normal goal). This stays the
|
|
@@ -1237,6 +1279,9 @@ module.exports.authorizeJoinRequestResponse = authorizeJoinRequestResponse;
|
|
|
1237
1279
|
// task 1953: the withdraw gate — exported for tests/goal_membership_requests.mjs,
|
|
1238
1280
|
// same precedent as its siblings above.
|
|
1239
1281
|
module.exports.authorizeWithdrawJoinRequest = authorizeWithdrawJoinRequest;
|
|
1282
|
+
// task 1002971: the manager-side rescind gate — the missing half of core_188's
|
|
1283
|
+
// 'cancelled' state; exported for tests/goal_membership_requests.mjs, same precedent.
|
|
1284
|
+
module.exports.authorizeRescindInvitation = authorizeRescindInvitation;
|
|
1240
1285
|
// task 1002689: the derived scope_protected payload decorator — exported for
|
|
1241
1286
|
// tests/goal_membership_requests.mjs (DB-free), same precedent.
|
|
1242
1287
|
module.exports.decorateGoalScope = decorateGoalScope;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.410",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.410",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.410",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -391,6 +391,12 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
391
391
|
// (authorizeWithdrawJoinRequest).
|
|
392
392
|
'GET /goals/:id/requests': 'any-builder',
|
|
393
393
|
'POST /goals/:id/requests/:reqId/withdraw': 'any-builder',
|
|
394
|
+
// task 1002971 (spec D6): the private-goal open/closed-to-requests toggle and
|
|
395
|
+
// the manager-side rescind of a pending invitation. Both walls are in-handler
|
|
396
|
+
// (authorizeGoalManagement / authorizeRescindInvitation), so both stay
|
|
397
|
+
// 'any-builder' — pinned so an accidental requireRank re-gate reds the build.
|
|
398
|
+
'PATCH /goals/:id/accepting-requests': 'any-builder',
|
|
399
|
+
'POST /goals/:id/invitations/:reqId/rescind': 'any-builder',
|
|
394
400
|
// criterion confirmation + goal auto-achieve (ADR 0086 §6) — the planner's close loop.
|
|
395
401
|
'POST /done-when/:criterionId/satisfy': 'metic+archon',
|
|
396
402
|
// runtime criterion create (ADR 0105 gap fix, task 1874) — re-ranked Archon→Metic+
|
package/src/module-api.js
CHANGED
|
@@ -1249,8 +1249,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1249
1249
|
// landed since 1.19.407 with no explicit bump. run 33216756796. (task 1002620)
|
|
1250
1250
|
// 1.19.409 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1251
1251
|
// landed since 1.19.408 with no explicit bump. run 33217065997. (task 1002620)
|
|
1252
|
+
// 1.19.410 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1253
|
+
// landed since 1.19.409 with no explicit bump. run 33217599338. (task 1002620)
|
|
1252
1254
|
// ---------------------------------------------------------------------------
|
|
1253
|
-
const CORE_VERSION = '1.19.
|
|
1255
|
+
const CORE_VERSION = '1.19.410'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1254
1256
|
|
|
1255
1257
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1256
1258
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
// goal is still OPEN (a stale invite can't admit to an achieved/archived goal);
|
|
20
20
|
// declining stays allowed, so the route declines before ever reading the goal.
|
|
21
21
|
// • the migration is additive + private-safe (default 'public', partial unique).
|
|
22
|
+
// • setGoalAcceptingRequests + the D6 closed-to-requests gate (task 1002971) —
|
|
23
|
+
// a private goal can stop taking join-requests WITHOUT stopping invitations.
|
|
24
|
+
// • authorizeRescindInvitation (task 1002971) — the manager-side rescind, the
|
|
25
|
+
// missing half of core_188's 'cancelled' state.
|
|
22
26
|
|
|
23
27
|
import { strict as assert } from 'node:assert';
|
|
24
28
|
import { createRequire } from 'node:module';
|
|
@@ -32,6 +36,7 @@ process.env.NODE_ENV = 'test';
|
|
|
32
36
|
const require = createRequire(import.meta.url);
|
|
33
37
|
const {
|
|
34
38
|
setGoalVisibility,
|
|
39
|
+
setGoalAcceptingRequests,
|
|
35
40
|
createMembershipRequest,
|
|
36
41
|
getMembershipRequest,
|
|
37
42
|
resolveMembershipRequest,
|
|
@@ -48,6 +53,8 @@ const {
|
|
|
48
53
|
authorizeInvitationAccept,
|
|
49
54
|
authorizeJoinRequestResponse,
|
|
50
55
|
authorizeWithdrawJoinRequest,
|
|
56
|
+
authorizeRescindInvitation,
|
|
57
|
+
authorizeGoalManagement,
|
|
51
58
|
decorateGoalScope,
|
|
52
59
|
} = require('../modules/lifecycle/routes/goals.js');
|
|
53
60
|
const { MODULE_EXPECTED_RANKS } = require('../src/bongos/route-rank-check.js');
|
|
@@ -76,6 +83,27 @@ await test('setGoalVisibility: writes visibility + returns the goal row', async
|
|
|
76
83
|
assert.deepEqual(q.params, [7, 'private']);
|
|
77
84
|
});
|
|
78
85
|
|
|
86
|
+
// =========================================================================
|
|
87
|
+
// setGoalAcceptingRequests (task 1002971, spec D6) — the same shape as its
|
|
88
|
+
// visibility sibling: the route owns WHO may call it, the helper just writes.
|
|
89
|
+
// =========================================================================
|
|
90
|
+
|
|
91
|
+
await test('setGoalAcceptingRequests: writes the flag + returns the goal row', async () => {
|
|
92
|
+
const GOAL = { id: 7, visibility: 'private', accepting_requests: false };
|
|
93
|
+
const pool = fakePool((s) => (/UPDATE goals SET accepting_requests = \$2/.test(s) ? { rows: [GOAL] } : null));
|
|
94
|
+
const g = await setGoalAcceptingRequests({ goalId: 7, acceptingRequests: false }, { pool });
|
|
95
|
+
assert.deepEqual(g, GOAL);
|
|
96
|
+
const q = find(pool.queries, /UPDATE goals SET accepting_requests/);
|
|
97
|
+
assert.deepEqual(q.params, [7, false]);
|
|
98
|
+
assert.ok(/RETURNING .*accepting_requests/.test(norm(q.sql)),
|
|
99
|
+
'the returned row carries the flag, so the hall can render the toggle state');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await test('setGoalAcceptingRequests: unknown goal → null', async () => {
|
|
103
|
+
const pool = fakePool(() => ({ rows: [] }));
|
|
104
|
+
assert.equal(await setGoalAcceptingRequests({ goalId: 999, acceptingRequests: true }, { pool }), null);
|
|
105
|
+
});
|
|
106
|
+
|
|
79
107
|
await test('setGoalVisibility: unknown goal → null', async () => {
|
|
80
108
|
const pool = fakePool(() => ({ rows: [] }));
|
|
81
109
|
assert.equal(await setGoalVisibility({ goalId: 999, visibility: 'public' }, { pool }), null);
|
|
@@ -302,6 +330,15 @@ await test('migration core_188: additive, defaults public, partial-unique pendin
|
|
|
302
330
|
'migration is purely additive');
|
|
303
331
|
});
|
|
304
332
|
|
|
333
|
+
await test('migration core_229: accepting_requests is additive and defaults TRUE', () => {
|
|
334
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
335
|
+
const sql = readFileSync(join(here, '..', 'migrations', 'core_229_goals_accepting_requests.sql'), 'utf8');
|
|
336
|
+
assert.ok(/ADD COLUMN IF NOT EXISTS accepting_requests boolean NOT NULL DEFAULT true/.test(sql),
|
|
337
|
+
'defaults true so every existing private goal keeps taking requests');
|
|
338
|
+
assert.ok(!/\bDROP\s+(TABLE|COLUMN|INDEX)\b|\bDELETE\s+FROM\b|\bTRUNCATE\b/i.test(sql),
|
|
339
|
+
'migration is purely additive');
|
|
340
|
+
});
|
|
341
|
+
|
|
305
342
|
// =========================================================================
|
|
306
343
|
// The pure route gates (task 1952) — DB-free, ordering included.
|
|
307
344
|
// =========================================================================
|
|
@@ -348,6 +385,45 @@ await test('authorizeCreateJoinRequest: public but PROTECTED scope → ALLOW (no
|
|
|
348
385
|
assert.deepEqual(authorizeCreateJoinRequest({ goal: { status: 'open', visibility: 'public' }, isMember: false, scopeProtected: true }), { ok: true });
|
|
349
386
|
});
|
|
350
387
|
|
|
388
|
+
// The D6 closed-to-requests arm (task 1002971). A private goal whose lead closed
|
|
389
|
+
// applications refuses the ask with honest copy instead of queueing it.
|
|
390
|
+
const CLOSED_PRIVATE = { id: 5, status: 'open', created_by: '1', visibility: 'private', accepting_requests: false };
|
|
391
|
+
await test('authorizeCreateJoinRequest: private + closed → 409 not_accepting_requests, honest copy', () => {
|
|
392
|
+
const d = authorizeCreateJoinRequest({ goal: CLOSED_PRIVATE, isMember: false, scopeProtected: false });
|
|
393
|
+
assert.equal(d.status, 409);
|
|
394
|
+
assert.equal(d.body.error, 'not_accepting_requests');
|
|
395
|
+
assert.equal(d.body.message, "This goal isn't taking requests right now.",
|
|
396
|
+
'the spec D6 copy verbatim — the refusal says why, it does not pretend the goal is gone');
|
|
397
|
+
});
|
|
398
|
+
await test('authorizeCreateJoinRequest: private + explicitly accepting → ALLOW', () => {
|
|
399
|
+
assert.deepEqual(authorizeCreateJoinRequest({ goal: { ...CLOSED_PRIVATE, accepting_requests: true }, isMember: false, scopeProtected: false }), { ok: true });
|
|
400
|
+
});
|
|
401
|
+
// A goal row read before the migration (or by an older release) has no such key.
|
|
402
|
+
// Only an explicit false closes the door — undefined must never read as closed.
|
|
403
|
+
await test('authorizeCreateJoinRequest: private, flag absent → ALLOW (fails OPEN, not closed)', () => {
|
|
404
|
+
assert.deepEqual(authorizeCreateJoinRequest({ goal: { status: 'open', visibility: 'private' }, isMember: false, scopeProtected: false }), { ok: true });
|
|
405
|
+
});
|
|
406
|
+
// D6 calls the flag meaningless on a public goal, so a PUBLIC protected-scope goal
|
|
407
|
+
// keeps its Archon-mediated request door open whatever the column says.
|
|
408
|
+
await test('authorizeCreateJoinRequest: PUBLIC protected goal + closed flag → ALLOW (flag is private-only)', () => {
|
|
409
|
+
assert.deepEqual(authorizeCreateJoinRequest({ goal: { status: 'open', visibility: 'public', accepting_requests: false }, isMember: false, scopeProtected: true }), { ok: true });
|
|
410
|
+
});
|
|
411
|
+
// The whole point of D6: closing applications is about unsolicited inbound. The
|
|
412
|
+
// invite path is gated by authorizeGoalManagement, which never reads the flag —
|
|
413
|
+
// so a lead can still invite into a goal that is closed to requests.
|
|
414
|
+
await test('closed to requests: INVITATIONS are unaffected — the invite gate still allows the owner', () => {
|
|
415
|
+
assert.deepEqual(authorizeGoalManagement({ goal: CLOSED_PRIVATE, actorId: '1', actorRank: 'metic', actorMembership: null }), { ok: true });
|
|
416
|
+
});
|
|
417
|
+
await test('routes: the invitation paths never read accepting_requests (only the request gate does)', () => {
|
|
418
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
419
|
+
const authz = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goal-route-authz.js'), 'utf8');
|
|
420
|
+
const gates = authz.match(/^function authorize\w+[\s\S]*?^}/gm) || [];
|
|
421
|
+
const reading = gates.filter((g) => /goal\.accepting_requests/.test(g));
|
|
422
|
+
assert.equal(reading.length, 1, 'exactly one gate reads the flag');
|
|
423
|
+
assert.ok(/^function authorizeCreateJoinRequest/.test(reading[0]),
|
|
424
|
+
'and it is the JOIN-REQUEST gate — invitations must stay unaffected (spec D6)');
|
|
425
|
+
});
|
|
426
|
+
|
|
351
427
|
// authorizeInvitationResponse — only the invitee resolves their own pending invite.
|
|
352
428
|
const INVITE = { id: 9, goal_id: '5', builder_id: '2', kind: 'invite', status: 'pending' };
|
|
353
429
|
await test('authorizeInvitationResponse: unknown request → 404', () => {
|
|
@@ -480,6 +556,80 @@ await test('authorizeWithdrawJoinRequest: the requester (string vs number id)
|
|
|
480
556
|
assert.deepEqual(authorizeWithdrawJoinRequest({ request: REQUEST_FOR_WITHDRAW, goalId: '5', actorId: 2 }), { ok: true });
|
|
481
557
|
});
|
|
482
558
|
|
|
559
|
+
// =========================================================================
|
|
560
|
+
// authorizeRescindInvitation (task 1002971) — the manager-side mirror of
|
|
561
|
+
// withdraw, and the missing half of core_188's 'cancelled' state. Authority is
|
|
562
|
+
// authorizeGoalManagement (owner|manager|Archon), checked BEFORE the request
|
|
563
|
+
// lookup so an unauthorized actor never learns an invitation exists.
|
|
564
|
+
// =========================================================================
|
|
565
|
+
|
|
566
|
+
const RESCIND_GOAL = { id: 5, status: 'open', created_by: '1', visibility: 'private' };
|
|
567
|
+
const PENDING_INVITE = { id: 12, goal_id: '5', builder_id: '2', kind: 'invite', status: 'pending' };
|
|
568
|
+
const rescind = (over = {}) => authorizeRescindInvitation({
|
|
569
|
+
goal: RESCIND_GOAL, actorId: '1', actorRank: 'metic', actorMembership: null,
|
|
570
|
+
request: PENDING_INVITE, goalId: 5, ...over,
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await test('authorizeRescindInvitation: the goal OWNER → ALLOW', () => {
|
|
574
|
+
assert.deepEqual(rescind(), { ok: true });
|
|
575
|
+
});
|
|
576
|
+
await test('authorizeRescindInvitation: a manager (lead) → ALLOW', () => {
|
|
577
|
+
assert.deepEqual(rescind({ actorId: '3', actorMembership: mgr(3) }), { ok: true });
|
|
578
|
+
});
|
|
579
|
+
await test('authorizeRescindInvitation: an Archon → ALLOW (acts as owner anywhere)', () => {
|
|
580
|
+
assert.deepEqual(rescind({ actorId: '99', actorRank: 'archon' }), { ok: true });
|
|
581
|
+
});
|
|
582
|
+
await test('authorizeRescindInvitation: a non-owner/manager → 403 (before any request lookup — no leak)', () => {
|
|
583
|
+
const d = rescind({ actorId: '7' });
|
|
584
|
+
assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');
|
|
585
|
+
const blind = authorizeRescindInvitation({
|
|
586
|
+
goal: RESCIND_GOAL, actorId: '7', actorRank: 'metic', actorMembership: null, request: null, goalId: 5,
|
|
587
|
+
});
|
|
588
|
+
assert.deepEqual(blind, d, 'the answer is identical whether or not the invitation exists');
|
|
589
|
+
});
|
|
590
|
+
await test('authorizeRescindInvitation: the INVITEE cannot rescind their own invitation → 403', () => {
|
|
591
|
+
const d = rescind({ actorId: '2' });
|
|
592
|
+
assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');
|
|
593
|
+
});
|
|
594
|
+
await test('authorizeRescindInvitation: unknown request → 404', () => {
|
|
595
|
+
const d = rescind({ request: null });
|
|
596
|
+
assert.equal(d.status, 404); assert.equal(d.body.error, 'request_not_found');
|
|
597
|
+
});
|
|
598
|
+
await test('authorizeRescindInvitation: invitation on a different goal → 404 (containment)', () => {
|
|
599
|
+
assert.equal(rescind({ request: { ...PENDING_INVITE, goal_id: '99' } }).status, 404);
|
|
600
|
+
});
|
|
601
|
+
await test('authorizeRescindInvitation: row is a join-request, not an invitation → 400', () => {
|
|
602
|
+
const d = rescind({ request: { ...PENDING_INVITE, kind: 'request' } });
|
|
603
|
+
assert.equal(d.status, 400); assert.equal(d.body.error, 'not_an_invitation');
|
|
604
|
+
});
|
|
605
|
+
// Rescinding an invite that is already resolved changes nothing: the gate refuses
|
|
606
|
+
// 409 not_pending, and db.resolveMembershipRequest moves only from 'pending', so a
|
|
607
|
+
// repeat rescind can never re-cancel an accepted membership.
|
|
608
|
+
await test('authorizeRescindInvitation: already resolved → 409 not_pending (no state change on a repeat)', () => {
|
|
609
|
+
for (const status of ['accepted', 'rejected', 'cancelled']) {
|
|
610
|
+
const d = rescind({ request: { ...PENDING_INVITE, status } });
|
|
611
|
+
assert.equal(d.status, 409); assert.equal(d.body.error, 'not_pending'); assert.equal(d.body.status, status);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
await test('authorizeRescindInvitation: a closed goal cannot be managed → 409 goal_not_open', () => {
|
|
615
|
+
const d = rescind({ goal: { ...RESCIND_GOAL, status: 'archived' } });
|
|
616
|
+
assert.equal(d.status, 409); assert.equal(d.body.error, 'goal_not_open');
|
|
617
|
+
});
|
|
618
|
+
// The route must land the state core_188 already defined for "rescinded by the
|
|
619
|
+
// opener" — not a second word for the same thing (source-pinned, the addGoalMember
|
|
620
|
+
// resolvedBy precedent; the live route is exercised in goal_routes.mjs).
|
|
621
|
+
await test('routes: the rescind route resolves to cancelled, attributed to the actor', () => {
|
|
622
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
623
|
+
const src = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goals.js'), 'utf8');
|
|
624
|
+
const handler = src.slice(src.indexOf("router.post('/goals/:id/invitations/:reqId/rescind'"));
|
|
625
|
+
assert.ok(handler.startsWith("router.post('/goals/:id/invitations/:reqId/rescind'"), 'the rescind route exists');
|
|
626
|
+
const body = handler.slice(0, handler.indexOf('\n });'));
|
|
627
|
+
assert.ok(/db\.resolveMembershipRequest\(\{ id: reqId, status: 'cancelled', resolvedBy: req\.builder\.id \}\)/.test(body),
|
|
628
|
+
"cancelled — core_188's 'withdrawn/rescinded by the opener' state, attributed to the rescinder");
|
|
629
|
+
assert.ok(body.indexOf('authorizeRescindInvitation({') < body.indexOf('db.resolveMembershipRequest('),
|
|
630
|
+
'the gate runs before the write');
|
|
631
|
+
});
|
|
632
|
+
|
|
483
633
|
// =========================================================================
|
|
484
634
|
// decorateGoalScope (task 1002689) — goal payloads carry a derived
|
|
485
635
|
// scope_protected flag so the hall can render the join-gate on a
|
|
@@ -509,7 +659,7 @@ await test('decorateGoalScope: returns a copy, never mutates the row', () => {
|
|
|
509
659
|
// stay 'any-builder'; an accidental requireRank re-gate must be a red build.
|
|
510
660
|
// =========================================================================
|
|
511
661
|
|
|
512
|
-
await test('route-rank pins: the
|
|
662
|
+
await test('route-rank pins: the ten private-goal routes are pinned any-builder', () => {
|
|
513
663
|
const pins = MODULE_EXPECTED_RANKS.lifecycle;
|
|
514
664
|
for (const r of [
|
|
515
665
|
'GET /goals/inbox',
|
|
@@ -520,6 +670,9 @@ await test('route-rank pins: the eight private-goal routes are pinned any-builde
|
|
|
520
670
|
'POST /goals/:id/invitations/:reqId/respond',
|
|
521
671
|
'GET /goals/:id/requests',
|
|
522
672
|
'POST /goals/:id/requests/:reqId/withdraw',
|
|
673
|
+
// task 1002971 — the D6 toggle + the manager-side rescind.
|
|
674
|
+
'PATCH /goals/:id/accepting-requests',
|
|
675
|
+
'POST /goals/:id/invitations/:reqId/rescind',
|
|
523
676
|
]) {
|
|
524
677
|
assert.equal(pins[r], 'any-builder', `${r} must be pinned any-builder (wall is in-handler)`);
|
|
525
678
|
}
|
package/tests/prose_edits.mjs
CHANGED
|
@@ -182,16 +182,20 @@ await test('ROUTE SHAPE: the author gate is fail-closed and uses the array form
|
|
|
182
182
|
assert.match(src, /captured_by != null/, 'and the author check treats a NULL author as not-you');
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
-
await test('ROUTE SHAPE:
|
|
186
|
-
//
|
|
187
|
-
// of operation behind the same gate; this pins that they still are.
|
|
185
|
+
await test('ROUTE SHAPE: every goal-edit handler shares ONE authority gate', () => {
|
|
186
|
+
// Each was moved into goal-edits.js precisely because they are the same kind
|
|
187
|
+
// of operation behind the same gate; this pins that they still are. The count
|
|
188
|
+
// grows with the file — task 1002971 added the D6 accepting-requests toggle,
|
|
189
|
+
// the third write to a goal's own row behind authorizeGoalManagement.
|
|
188
190
|
const src = readFileSync(new URL('../modules/lifecycle/goal-edits.js', import.meta.url), 'utf8');
|
|
189
|
-
assert.equal((src.match(/authorizeGoalManagement\(/g) || []).length,
|
|
191
|
+
assert.equal((src.match(/authorizeGoalManagement\(/g) || []).length, 3, 'prose + visibility + accepting-requests, all gated');
|
|
190
192
|
assert.equal(typeof goalEdits.goalProseHandler, 'function');
|
|
191
193
|
assert.equal(typeof goalEdits.goalVisibilityHandler, 'function');
|
|
194
|
+
assert.equal(typeof goalEdits.goalAcceptingRequestsHandler, 'function');
|
|
192
195
|
const routes = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
|
|
193
196
|
assert.match(routes, /router\.patch\('\/goals\/:id',/, 'the REGISTRATION stays in the route file so route-rank-check sees it');
|
|
194
197
|
assert.match(routes, /router\.patch\('\/goals\/:id\/visibility',/);
|
|
198
|
+
assert.match(routes, /router\.patch\('\/goals\/:id\/accepting-requests',/);
|
|
195
199
|
});
|
|
196
200
|
|
|
197
201
|
// ---- 5. THE HANDLERS ARE ACTUALLY INVOKED ---------------------------------
|