@bongos/core 1.19.408 → 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 +50 -35
- 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 +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +124 -4
- package/docs/api-reference.md +5 -3
- 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/modules/platform-identity/migrations/platform_identity_012_recent_ships.sql +40 -0
- package/modules/platform-identity/platform-identity.js +87 -6
- package/modules/platform-identity/routes/sso.js +10 -0
- package/modules/platform-identity/tests/platform-identity.mjs +7 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-admission.js +89 -8
- package/src/bongos/route-rank-check.js +6 -0
- package/src/bongos/routes/auth.js +21 -0
- package/src/module-api.js +5 -1
- package/tests/goal_membership_requests.mjs +154 -1
- package/tests/hub_recent_ships.mjs +373 -0
- 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;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- platform_identity_012_recent_ships.sql — the hub's "recent works" store
|
|
2
|
+
-- (BV1 goal 1000045 P07, task 1002803; profile-page spec "The page spec" row 4 +
|
|
3
|
+
-- ADR 0171 D7).
|
|
4
|
+
--
|
|
5
|
+
-- WHY A COLUMN AND NOT A TABLE. The hub has no `tasks` table for other projects,
|
|
6
|
+
-- so a builder's last few shipped works can only reach it as data the project
|
|
7
|
+
-- PUSHES. The existing pull pattern (modules/platform-identity/project-stats.js)
|
|
8
|
+
-- fans out to each project's public API — fine for a builder count on a catalog
|
|
9
|
+
-- page, wrong once per profile view, and useless for a CLI-only project that
|
|
10
|
+
-- serves no public API at all. The push already exists: POST /sso/activity/rollup
|
|
11
|
+
-- upserts exactly one row per (client_id, github_id), refreshed on every federated
|
|
12
|
+
-- sign-in AND (task 1002803) on every ship. A capped list of at most 3 works
|
|
13
|
+
-- belongs ON that row: it is written and read as one unit, it is never queried
|
|
14
|
+
-- across rows, and a child table would add a join and a delete-orphan path to
|
|
15
|
+
-- store nine values.
|
|
16
|
+
--
|
|
17
|
+
-- WHAT IS IN IT. At most 3 objects, each `{ id, title, shipped_at, url }` —
|
|
18
|
+
-- sanitized in upsertActivity BEFORE the write (the rank/disciplines precedent,
|
|
19
|
+
-- ADR 0171 D7: a reporting instance is third-party self-report and is never
|
|
20
|
+
-- trusted to respect its own cap). `url` may only point back into the reporting
|
|
21
|
+
-- client's own registered origin, so a ship link on a public portfolio can never
|
|
22
|
+
-- be turned into a phishing hop. `'[]'` — never NULL — so every reader gets a
|
|
23
|
+
-- list without a coalesce.
|
|
24
|
+
--
|
|
25
|
+
-- DESCRIPTIVE ANALYTICS ONLY (ADR 0016 / 0141): a ship link is a label, never an
|
|
26
|
+
-- access grant. Reading it confers nothing on the instance it points at.
|
|
27
|
+
--
|
|
28
|
+
-- Additive + `platform_identity_`-namespaced (ADR 0083 §5). Forward-safe: ADD
|
|
29
|
+
-- COLUMN with a DEFAULT, so the previous code release still runs against this
|
|
30
|
+
-- schema (it simply never writes the column). Idempotent.
|
|
31
|
+
|
|
32
|
+
BEGIN;
|
|
33
|
+
|
|
34
|
+
ALTER TABLE platform_identity_builder_project_activity
|
|
35
|
+
ADD COLUMN IF NOT EXISTS recent_ships jsonb NOT NULL DEFAULT '[]'::jsonb;
|
|
36
|
+
|
|
37
|
+
COMMENT ON COLUMN platform_identity_builder_project_activity.recent_ships IS
|
|
38
|
+
'Up to 3 recently shipped works this project reported for this builder, pushed on POST /sso/activity/rollup (task 1002803): [{id, title, shipped_at, url}]. Third-party self-report — capped, type-checked and same-origin-checked in upsertActivity, never trusted as written. Surfaced only on a project the catalog names publicly (getRollupForBuilder), so a non-public project''s work titles stay counted-but-unnamed like its name does.';
|
|
39
|
+
|
|
40
|
+
COMMIT;
|
|
@@ -532,25 +532,96 @@ async function setClientStatus(clientId, status, { pool = defaultPool } = {}) {
|
|
|
532
532
|
const MAX_ROLLUP_DISCIPLINES = 8;
|
|
533
533
|
const MAX_DISCIPLINE_LENGTH = 32;
|
|
534
534
|
|
|
535
|
+
// "Recent works" caps (task 1002803). The hub has no tasks table for other
|
|
536
|
+
// projects, so a builder's last few shipped works arrive on the rollup push
|
|
537
|
+
// instead of being fanned out for at read time. THREE PER PROJECT, hard-capped
|
|
538
|
+
// HERE and not only at the sender: the cap is a property of what the hub will
|
|
539
|
+
// store, and a reporting instance is third-party self-report that may be old,
|
|
540
|
+
// buggy or hostile.
|
|
541
|
+
const MAX_RECENT_SHIPS = 3;
|
|
542
|
+
const MAX_SHIP_TITLE_LENGTH = 200;
|
|
543
|
+
const MAX_SHIP_URL_LENGTH = 500;
|
|
544
|
+
// How far into a pushed array the hub will even LOOK. Validating first and
|
|
545
|
+
// capping second is what stops a few malformed entries from crowding the good
|
|
546
|
+
// ones out of the three slots; bounding the scan is what stops an unbounded
|
|
547
|
+
// array from buying unbounded work with one request.
|
|
548
|
+
const MAX_RECENT_SHIPS_SCANNED = 20;
|
|
549
|
+
|
|
550
|
+
// May this ship link be stored? A ship link is the ONLY free-text URL on a public
|
|
551
|
+
// builder portfolio, which makes it the one place a client-secret holder could
|
|
552
|
+
// plant a phishing hop under someone else's name. So it must point AT OR BELOW
|
|
553
|
+
// the instance that reported it: the client's own REGISTERED origin host, or a
|
|
554
|
+
// subdomain of it (an instance's hall may live at builders.<domain> while its
|
|
555
|
+
// registered origin is <domain>). Anything else — another site, a non-http
|
|
556
|
+
// scheme, embedded credentials — stores no link rather than a wrong one.
|
|
557
|
+
//
|
|
558
|
+
// DOWNWARD ONLY, deliberately. The mirror rule ("the link host is an ANCESTOR of
|
|
559
|
+
// the registered origin") looks symmetric and is not: on a shared multi-tenant
|
|
560
|
+
// apex it would let a client registered at foo.github.io publish links on bare
|
|
561
|
+
// github.io, which it does not control. Nothing needs the upward direction — a
|
|
562
|
+
// registered origin is the instance's own public origin — so it is not offered.
|
|
563
|
+
function shipUrlWithinClientOrigin(raw, clientOrigin) {
|
|
564
|
+
if (typeof raw !== 'string' || !raw || raw.length > MAX_SHIP_URL_LENGTH) return null;
|
|
565
|
+
let u, o;
|
|
566
|
+
try { u = new URL(raw); o = new URL(String(clientOrigin || '')); } catch { return null; }
|
|
567
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
568
|
+
if (u.username || u.password) return null;
|
|
569
|
+
const host = u.hostname.toLowerCase(), registered = o.hostname.toLowerCase();
|
|
570
|
+
if (host !== registered && !host.endsWith(`.${registered}`)) return null;
|
|
571
|
+
return raw;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Shape and bound the pushed recent_ships[] before it is stored (the
|
|
575
|
+
// upsertActivity rank/disciplines precedent, ADR 0171 D7 — validation lives next
|
|
576
|
+
// to the data where no route can forget it). Returns AT MOST MAX_RECENT_SHIPS
|
|
577
|
+
// entries of exactly `{ id, title, shipped_at, url }`; a row with no usable id or
|
|
578
|
+
// title is dropped entirely rather than stored half-empty, and a bad date or an
|
|
579
|
+
// off-origin link becomes null on an otherwise good row. Always an array, never
|
|
580
|
+
// null — every reader gets a list without a coalesce.
|
|
581
|
+
function sanitizeRecentShips(raw, clientOrigin) {
|
|
582
|
+
return (Array.isArray(raw) ? raw : [])
|
|
583
|
+
.slice(0, MAX_RECENT_SHIPS_SCANNED)
|
|
584
|
+
.filter((s) => s && typeof s === 'object' && !Array.isArray(s))
|
|
585
|
+
.map((s) => {
|
|
586
|
+
const id = Number(s.id);
|
|
587
|
+
const at = typeof s.shipped_at === 'string' ? Date.parse(s.shipped_at) : NaN;
|
|
588
|
+
return {
|
|
589
|
+
id: Number.isSafeInteger(id) && id > 0 ? id : null,
|
|
590
|
+
title: typeof s.title === 'string' && s.title.trim() ? s.title.slice(0, MAX_SHIP_TITLE_LENGTH) : null,
|
|
591
|
+
shipped_at: Number.isFinite(at) ? new Date(at).toISOString() : null,
|
|
592
|
+
url: shipUrlWithinClientOrigin(s.url, clientOrigin),
|
|
593
|
+
};
|
|
594
|
+
})
|
|
595
|
+
.filter((s) => s.id !== null && s.title !== null)
|
|
596
|
+
.slice(0, MAX_RECENT_SHIPS);
|
|
597
|
+
}
|
|
598
|
+
|
|
535
599
|
// Upsert one project's rollup for one builder (keyed by client_id + github_id).
|
|
536
600
|
// Descriptive analytics — never authority. Refreshes last_active on each report.
|
|
537
|
-
|
|
601
|
+
// `clientOrigin` is the AUTHENTICATED client's registered origin (never a
|
|
602
|
+
// caller-named one) and exists only to bound the ship links — see
|
|
603
|
+
// shipUrlWithinClientOrigin.
|
|
604
|
+
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null }, { pool = defaultPool } = {}) {
|
|
538
605
|
// api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
|
|
539
606
|
// lazy getter over db-kernel's canonical four-rank ladder).
|
|
540
607
|
const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
|
|
541
608
|
const disc = (Array.isArray(disciplines) ? disciplines : [])
|
|
542
609
|
.filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
|
|
543
610
|
.slice(0, MAX_ROLLUP_DISCIPLINES);
|
|
611
|
+
const ships = sanitizeRecentShips(recentShips, clientOrigin);
|
|
544
612
|
const { rows } = await pool.query(
|
|
545
613
|
`INSERT INTO platform_identity_builder_project_activity
|
|
546
|
-
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active, updated_at)
|
|
547
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
|
614
|
+
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at)
|
|
615
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())
|
|
548
616
|
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
549
617
|
credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
|
|
550
618
|
rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
|
|
619
|
+
recent_ships = EXCLUDED.recent_ships,
|
|
551
620
|
last_active = now(), updated_at = now()
|
|
552
621
|
RETURNING id, client_id, github_id`,
|
|
553
|
-
|
|
622
|
+
// jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
|
|
623
|
+
// Postgres ARRAY literal, which the jsonb column would reject.
|
|
624
|
+
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships)],
|
|
554
625
|
);
|
|
555
626
|
return rows[0];
|
|
556
627
|
}
|
|
@@ -653,7 +724,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
653
724
|
);
|
|
654
725
|
const agg = aggRows[0];
|
|
655
726
|
const { rows: projRows } = await pool.query(
|
|
656
|
-
`SELECT p.name, p.origin, a.rank_held,
|
|
727
|
+
`SELECT p.name, p.origin, a.rank_held, a.recent_ships,
|
|
657
728
|
(p.id IS NOT NULL AND p.featured AND p.status = 'active') AS is_public
|
|
658
729
|
FROM platform_identity_builder_project_activity a
|
|
659
730
|
LEFT JOIN platform_identity_sso_clients c ON c.client_id = a.client_id
|
|
@@ -662,9 +733,19 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
662
733
|
ORDER BY a.last_active DESC`,
|
|
663
734
|
[githubId],
|
|
664
735
|
);
|
|
736
|
+
// recent_ships rides the project row it belongs to (task 1002803) — which is
|
|
737
|
+
// also what keeps it honest: a work title is at least as identifying as the
|
|
738
|
+
// project name it came from, so it may only appear where the name already
|
|
739
|
+
// may. A non-public project stays counted in more_projects and its work
|
|
740
|
+
// titles never leave the hub, exactly as its name doesn't.
|
|
665
741
|
const named = projRows
|
|
666
742
|
.filter((r) => r.is_public)
|
|
667
|
-
.map((r) => ({
|
|
743
|
+
.map((r) => ({
|
|
744
|
+
name: r.name,
|
|
745
|
+
origin: r.origin,
|
|
746
|
+
rank_held: r.rank_held,
|
|
747
|
+
recent_ships: Array.isArray(r.recent_ships) ? r.recent_ships : [],
|
|
748
|
+
}));
|
|
668
749
|
return {
|
|
669
750
|
totals: {
|
|
670
751
|
projects: agg.projects,
|
|
@@ -426,6 +426,11 @@ module.exports = function ssoRoutes() {
|
|
|
426
426
|
karma: { type: 'number' },
|
|
427
427
|
rank: { type: 'string', maxLength: 32 },
|
|
428
428
|
disciplines: {},
|
|
429
|
+
// "Recent works" (task 1002803): up to 3 of this builder's latest shipped
|
|
430
|
+
// works on THIS project — [{ id, title, shipped_at, url }]. Left untyped
|
|
431
|
+
// here, like disciplines: the shape is checked and hard-capped in
|
|
432
|
+
// upsertActivity, next to the data, so no route can forget it.
|
|
433
|
+
recent_ships: {},
|
|
429
434
|
})) return;
|
|
430
435
|
const b = req.body || {};
|
|
431
436
|
try {
|
|
@@ -456,6 +461,11 @@ module.exports = function ssoRoutes() {
|
|
|
456
461
|
karma: b.karma,
|
|
457
462
|
rank: b.rank || null,
|
|
458
463
|
disciplines: Array.isArray(b.disciplines) ? b.disciplines : [],
|
|
464
|
+
recentShips: b.recent_ships,
|
|
465
|
+
// The AUTHENTICATED client's registered origin — the bound every pushed
|
|
466
|
+
// ship link must fall inside (task 1002803). Taken from the client row,
|
|
467
|
+
// never from the body, for the same reason client_id is.
|
|
468
|
+
clientOrigin: client.origin,
|
|
459
469
|
}, { pool });
|
|
460
470
|
res.json({ ok: true });
|
|
461
471
|
} catch (err) {
|
|
@@ -494,7 +494,10 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
|
|
|
494
494
|
const c = pool.calls[0];
|
|
495
495
|
assert.match(c.text, /INSERT INTO platform_identity_builder_project_activity/);
|
|
496
496
|
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
497
|
-
|
|
497
|
+
// recent_ships (task 1002803) rides the same row as a jsonb STRING param —
|
|
498
|
+
// an empty list when the sender reported none, never NULL. Its own cap,
|
|
499
|
+
// shape and link bound are covered by tests/hub_recent_ships.mjs.
|
|
500
|
+
assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]']);
|
|
498
501
|
});
|
|
499
502
|
|
|
500
503
|
await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {
|
|
@@ -597,7 +600,9 @@ await ta('getRollupForBuilder aggregates un-fanned + names only publicly-catalog
|
|
|
597
600
|
assert.deepEqual(out.totals, { projects: 2, credits: 150, works_shipped: 7, karma: 4 });
|
|
598
601
|
assert.deepEqual(out.disciplines, ['engineer', 'ui']);
|
|
599
602
|
assert.equal(out.projects.length, 1, 'only the public catalog row is named');
|
|
600
|
-
|
|
603
|
+
// recent_ships rides the project row it belongs to (task 1002803), so a work
|
|
604
|
+
// title can only surface where the project name already may.
|
|
605
|
+
assert.deepEqual(out.projects[0], { name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic', recent_ships: [] });
|
|
601
606
|
assert.equal(out.more_projects, 1, 'the non-public project is counted, never named');
|
|
602
607
|
// The whitelist holds structurally: client_id never appears in the shape.
|
|
603
608
|
assert.ok(!JSON.stringify(out).includes('client_id'), 'client_id must not cross the port');
|
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",
|
|
@@ -77,36 +77,98 @@ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, ava
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// How many recently shipped works ride the rollup (task 1002803). The hub caps
|
|
81
|
+
// this again on arrival — a sender is never trusted to respect its own cap — so
|
|
82
|
+
// this constant is the polite half of the contract, not the enforcement.
|
|
83
|
+
const ROLLUP_RECENT_SHIPS = 3;
|
|
84
|
+
|
|
85
|
+
// A public deep link to one task in THIS instance's builders' hall, for the
|
|
86
|
+
// "recent works" list on the builder's cross-project profile (task 1002803). It
|
|
87
|
+
// is built from branding's STABLE public hall origin, never an env API base: the
|
|
88
|
+
// link is stored on another host and rendered to strangers, so a local dev
|
|
89
|
+
// override must never end up on a public portfolio. No origin (an instance that
|
|
90
|
+
// never declared one) → no link, and the work still reports with its title.
|
|
91
|
+
function hallTaskUrl(taskId) {
|
|
92
|
+
try {
|
|
93
|
+
const d = (branding().domains) || {};
|
|
94
|
+
const origin = String(d.buildersOrigin || d.publicOrigin || '').replace(/\/+$/, '');
|
|
95
|
+
return origin ? `${origin}/builders#/task/${taskId}` : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// This builder's most recent shipped works, for the rollup's recent_ships[]
|
|
102
|
+
// (task 1002803). The predicate is deliberately IDENTICAL to the lifecycle
|
|
103
|
+
// module's recentShippedTasksByBuilder — the read behind this instance's own
|
|
104
|
+
// unauthenticated GET /builders/:id/profile — so the push can never publish a
|
|
105
|
+
// work the instance does not already publish itself. (Neither surface filters by
|
|
106
|
+
// goal visibility today: ADR 0112's secrecy covers a private goal's OWN title,
|
|
107
|
+
// not the titles of tasks inside it. Widening or narrowing that is a policy
|
|
108
|
+
// change for the profile route, not something to fork here.) Kernel file, so it
|
|
109
|
+
// reads `pool` directly rather than importing the lifecycle module (ADR 0091 §1).
|
|
110
|
+
// Covered by core_228's idx_tasks_shipped_by_shipped_at — (shipped_by,
|
|
111
|
+
// shipped_at DESC) partial on status='shipped', which is this predicate exactly.
|
|
112
|
+
async function recentShipsForRollup(builderId) {
|
|
113
|
+
const { rows } = await pool.query(
|
|
114
|
+
`SELECT id, title, shipped_at
|
|
115
|
+
FROM tasks
|
|
116
|
+
WHERE shipped_by = $1
|
|
117
|
+
AND status = 'shipped'
|
|
118
|
+
AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
|
|
119
|
+
ORDER BY shipped_at DESC NULLS LAST
|
|
120
|
+
LIMIT $2`,
|
|
121
|
+
[builderId, ROLLUP_RECENT_SHIPS],
|
|
122
|
+
);
|
|
123
|
+
return rows.map((r) => ({
|
|
124
|
+
id: Number(r.id),
|
|
125
|
+
title: r.title,
|
|
126
|
+
shipped_at: r.shipped_at ? new Date(r.shipped_at).toISOString() : null,
|
|
127
|
+
url: hallTaskUrl(r.id),
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
80
131
|
// Best-effort cross-project activity rollup to the hub (ADR 0141 §4): report this
|
|
81
|
-
// builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
132
|
+
// builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines,
|
|
133
|
+
// and since task 1002803 their last few shipped works) for the cross-project
|
|
134
|
+
// profile + scouting directory. Fires on every federated sign-in AND on every
|
|
135
|
+
// ship (reportActivityRollupForShippedTask). Fully swallowed + no-op when not
|
|
136
|
+
// federated — never fails a sign-in or a ship.
|
|
137
|
+
//
|
|
138
|
+
// `githubId` is OPTIONAL: a sign-in already holds the hub-supplied id, while the
|
|
139
|
+
// ship path holds only a builder id, and the snapshot SELECT below can answer
|
|
140
|
+
// both — so the ship path costs one query, not two. A non-numeric principal (a
|
|
141
|
+
// `system:*` machine id) is dropped here rather than sent for the hub to reject:
|
|
142
|
+
// it has no public profile and never will.
|
|
143
|
+
async function reportActivityRollup({ builderId, githubId = null }) {
|
|
86
144
|
const idp = loadIdpConfig();
|
|
87
145
|
if (!idp) return;
|
|
88
146
|
try {
|
|
89
147
|
// Snapshot the builder's talent signals directly from the DB (auth.js is a
|
|
90
148
|
// kernel file — it uses pool, never a non-kernel domain module like metrics.js).
|
|
91
149
|
const b = await pool.query(
|
|
92
|
-
'SELECT total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
|
|
150
|
+
'SELECT github_id, total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
|
|
93
151
|
[builderId],
|
|
94
152
|
);
|
|
95
153
|
if (!b.rows[0]) return;
|
|
96
|
-
const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
|
|
97
154
|
const row = b.rows[0];
|
|
155
|
+
const ghId = githubId ?? row.github_id;
|
|
156
|
+
if (!/^\d+$/.test(String(ghId ?? ''))) return;
|
|
157
|
+
const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
|
|
158
|
+
const recentShips = await recentShipsForRollup(builderId);
|
|
98
159
|
await fetch(`${idp.origin}/api/gds/sso/activity/rollup`, {
|
|
99
160
|
method: 'POST',
|
|
100
161
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
101
162
|
body: JSON.stringify({
|
|
102
163
|
client_id: idp.clientId,
|
|
103
164
|
client_secret: idp.clientSecret,
|
|
104
|
-
github_id:
|
|
165
|
+
github_id: ghId,
|
|
105
166
|
credits: Number(row.total_credits) || 0,
|
|
106
167
|
tasks_shipped: shipped.rows[0] ? shipped.rows[0].n : 0,
|
|
107
168
|
karma: Number(row.karma) || 0,
|
|
108
169
|
rank: row.rank || null,
|
|
109
170
|
disciplines: Array.isArray(row.preferred_disciplines) ? row.preferred_disciplines : [],
|
|
171
|
+
recent_ships: recentShips,
|
|
110
172
|
}),
|
|
111
173
|
});
|
|
112
174
|
} catch (err) {
|
|
@@ -114,6 +176,24 @@ async function reportActivityRollup({ builderId, githubId }) {
|
|
|
114
176
|
}
|
|
115
177
|
}
|
|
116
178
|
|
|
179
|
+
// The SHIP-TIME rollup push (task 1002803, ADR 0171 amendment R01). Until now the
|
|
180
|
+
// rollup fired only on a federated sign-in, so a builder who signs in once a month
|
|
181
|
+
// had a cross-project profile up to a month stale — showing old credit and ship
|
|
182
|
+
// counts with nothing on the page saying so. A ship is the event that CHANGES
|
|
183
|
+
// those numbers, so it re-reports them; nothing else about the push differs.
|
|
184
|
+
//
|
|
185
|
+
// Wired as a `task.shipped` listener in src/bongos/routes/auth.js, so it runs
|
|
186
|
+
// once per ship whichever path won the confirmed→shipped flip. Best-effort in the
|
|
187
|
+
// same sense as the sign-in push: it resolves nothing the caller waits on, and a
|
|
188
|
+
// hub outage can never fail a ship. The builder's github_id is resolved by the
|
|
189
|
+
// rollup's own snapshot SELECT (which now returns it), so this adds no lookup of
|
|
190
|
+
// its own — and a machine principal is dropped there, not sent to the hub.
|
|
191
|
+
async function reportActivityRollupForShippedTask(task) {
|
|
192
|
+
const builderId = task && task.shipped_by;
|
|
193
|
+
if (!builderId) return;
|
|
194
|
+
await reportActivityRollup({ builderId });
|
|
195
|
+
}
|
|
196
|
+
|
|
117
197
|
// Admission posture (SR-19 / task #1000). BOTH enrollment paths — the web hall
|
|
118
198
|
// sign-in (completeWebFlow) and the CLI device flow (pollDeviceFlow) — are
|
|
119
199
|
// "invite-gated by default": a FIRST-TIME GitHub account is admitted only if an
|
|
@@ -329,6 +409,7 @@ module.exports = {
|
|
|
329
409
|
membershipKind,
|
|
330
410
|
openEnrollmentEnabled,
|
|
331
411
|
reportActivityRollup,
|
|
412
|
+
reportActivityRollupForShippedTask,
|
|
332
413
|
reportMembershipCheckIn,
|
|
333
414
|
safeSyncRankRole,
|
|
334
415
|
webAdmissionStatus,
|
|
@@ -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+
|