@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.
Files changed (32) hide show
  1. package/.bongos-core.json +50 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +124 -4
  8. package/docs/api-reference.md +5 -3
  9. package/docs/copy-inventory.md +43 -36
  10. package/docs/copy-registry.json +122 -39
  11. package/migrations/core_229_goals_accepting_requests.sql +37 -0
  12. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  13. package/modules/hall-ui/public/goals-page.js +26 -2
  14. package/modules/hall-ui/public/goals-render.js +35 -0
  15. package/modules/lifecycle/db-goals.js +22 -1
  16. package/modules/lifecycle/db.js +4 -1
  17. package/modules/lifecycle/goal-edits.js +40 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +51 -1
  19. package/modules/lifecycle/routes/goals.js +46 -1
  20. package/modules/platform-identity/migrations/platform_identity_012_recent_ships.sql +40 -0
  21. package/modules/platform-identity/platform-identity.js +87 -6
  22. package/modules/platform-identity/routes/sso.js +10 -0
  23. package/modules/platform-identity/tests/platform-identity.mjs +7 -2
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/src/bongos/auth-admission.js +89 -8
  27. package/src/bongos/route-rank-check.js +6 -0
  28. package/src/bongos/routes/auth.js +21 -0
  29. package/src/module-api.js +5 -1
  30. package/tests/goal_membership_requests.mjs +154 -1
  31. package/tests/hub_recent_ships.mjs +373 -0
  32. package/tests/prose_edits.mjs +8 -4
@@ -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
- async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [] }, { pool = defaultPool } = {}) {
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
- [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc],
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) => ({ name: r.name, origin: r.origin, rank_held: r.rank_held }));
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
- assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer']]);
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
- assert.deepEqual(out.projects[0], { name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic' });
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.408",
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.408",
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.408",
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
- // for the internal scouting directory. Fires on every federated sign-in ("on key
83
- // events"); true periodic reporting is a follow-up. Fully swallowed + no-op when
84
- // not federated — never fails a sign-in.
85
- async function reportActivityRollup({ builderId, githubId }) {
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: githubId,
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+