@bongos/core 1.19.407 → 1.19.409

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.
@@ -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,
@@ -30,6 +30,7 @@ const express = require('express');
30
30
  const crypto = require('node:crypto');
31
31
 
32
32
  const auth = require('../auth');
33
+ const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
33
34
  const db = require('../db');
34
35
  const { pairStore } = require('../app-pair');
35
36
  const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
@@ -376,7 +377,27 @@ const DISCORD_REDIRECT_URI = `${DISCORD_REDIRECT_ORIGIN}/api/gds/auth/discord/ca
376
377
  // sign-in started on a builders.* subdomain must still use the apex.
377
378
  const WEB_REDIRECT_ORIGIN = process.env.GDS_PUBLIC_ORIGIN || configuredOAuthOrigin();
378
379
 
380
+ // The ship-time hub push (task 1002803, ADR 0171 amendment R01) lives here
381
+ // because this file already owns the OTHER trigger: the federated sign-in flows
382
+ // below are what fire the check-in + rollup today, and the reporter they call is
383
+ // this router's own auth-admission sibling. `task.shipped` is an EVENT, not a
384
+ // port — many listeners may react to a ship and none of them returns anything
385
+ // (the discord/sessions listener precedent). off→on guarded so a test that
386
+ // re-instantiates the router never stacks duplicate listeners.
387
+ let _shipRollupListenerOff = null;
388
+ function registerSeams() {
389
+ if (_shipRollupListenerOff) _shipRollupListenerOff();
390
+ // Fire-and-forget: reportActivityRollupForShippedTask swallows every error and
391
+ // no-ops on an unfederated instance, so the .catch is belt-and-braces against a
392
+ // future edit letting one escape into an unhandledRejection (the sign-in
393
+ // chain's reasoning, auth-idp.js).
394
+ _shipRollupListenerOff = seams.on('task.shipped', (task) => {
395
+ reportActivityRollupForShippedTask(task).catch(() => {});
396
+ });
397
+ }
398
+
379
399
  module.exports = function buildAuthRouter() {
400
+ registerSeams();
380
401
  const router = express.Router();
381
402
 
382
403
  // /auth/device — CLI flow
package/src/module-api.js CHANGED
@@ -1,22 +1,13 @@
1
1
  // src/module-api.js — THE PUBLISHED, SEMVER'D DOORWAY (ADR 0083 / design 01§3).
2
- //
3
- // This is the ONLY core file a module may `require`. It re-exports the kernel
4
- // capabilities a module legitimately needs; everything else in the core is
5
- // off-limits to modules. The one-way rule (enforced by the fitness checks in
6
- // BV1.R40 once modules exist):
7
- //
8
- // module ──require──▶ src/module-api.js ──▶ kernel
9
- // core ──✗──────────────────────────────▶ module (forbidden)
10
- // module ──✗──────────────────────────────▶ module (forbidden; use a seam)
11
- //
12
- // Adding to this surface is a MINOR semver bump (safe — existing modules keep
13
- // working). Changing or removing an export is a MAJOR bump (breaking). Each
14
- // module declares the `coreVersion` it supports; `bongos upgrade` (BV1.R50)
15
- // pre-checks every enabled module against CORE_VERSION below.
16
- //
17
- // Introduced by the doorway task (BV1.R37 / task 1406). The seam registry is in
18
- // src/module-seams.js (hardened in BV1.R39 / task 1408); the loader that mounts
19
- // modules behind kernel-composed auth is BV1.R41 / task 1410.
2
+ // The ONLY core file a module may `require`: it re-exports the kernel capabilities a
3
+ // module legitimately needs; everything else in the core is off-limits. The one-way
4
+ // rule (fitness-enforced, BV1.R40): module ──require──▶ src/module-api.js ──▶ kernel;
5
+ // core ──✗──▶ module and module ──✗──▶ module are forbidden (use a seam).
6
+ // Adding an export is a MINOR semver bump (safe); changing or removing one is MAJOR.
7
+ // Each module declares the `coreVersion` it supports; `bongos upgrade` (BV1.R50)
8
+ // pre-checks every enabled module against CORE_VERSION below. Introduced by BV1.R37
9
+ // (task 1406); seams in src/module-seams.js (BV1.R39 / task 1408); the loader that
10
+ // mounts modules behind kernel-composed auth is BV1.R41 / task 1410.
20
11
 
21
12
  const auth = require('./bongos/auth');
22
13
  const util = require('node:util');
@@ -1254,8 +1245,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1254
1245
  // landed since 1.19.405 with no explicit bump. run 33213981964. (task 1002620)
1255
1246
  // 1.19.407 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1256
1247
  // landed since 1.19.406 with no explicit bump. run 33214451873. (task 1002620)
1248
+ // 1.19.408 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1249
+ // landed since 1.19.407 with no explicit bump. run 33216756796. (task 1002620)
1250
+ // 1.19.409 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1251
+ // landed since 1.19.408 with no explicit bump. run 33217065997. (task 1002620)
1257
1252
  // ---------------------------------------------------------------------------
1258
- const CORE_VERSION = '1.19.407'; // CI auto-patch carrier (ADR 0161); see changelog above
1253
+ const CORE_VERSION = '1.19.409'; // CI auto-patch carrier (ADR 0161); see changelog above
1259
1254
 
1260
1255
  // A namespaced logger so a module's log lines are attributable + consistent.
1261
1256
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,373 @@
1
+ // tests/hub_recent_ships.mjs — task 1002803 (P07, goal 1000045 "public builder
2
+ // profiles"; profile-page spec "The page spec" row 4 + ADR 0171 amendment R01).
3
+ //
4
+ // "Recent works" reaches the hub by PUSH, not pull: the hub has no `tasks` table
5
+ // for other projects, so each project reports at most 3 of a builder's latest
6
+ // shipped works on POST /sso/activity/rollup. Three deliverables, and each one
7
+ // has a failure mode that a code read does not catch:
8
+ //
9
+ // 1. THE CAP IS THE HUB'S, NOT THE SENDER'S. A reporting instance is
10
+ // third-party self-report (ADR 0171 D7). If the cap lived only in the
11
+ // sender, an old, buggy or hostile client would set the size of a public
12
+ // profile section. So the cap, the field shape and the link bound are all
13
+ // asserted against what upsertActivity BINDS, not against what was passed.
14
+ // 2. A SHIP LINK IS THE ONE FREE-TEXT URL ON A PUBLIC PORTFOLIO. It may only
15
+ // point back into the reporting client's own registered origin, or it is a
16
+ // phishing hop published under someone else's name.
17
+ // 3. THE ORDERING IS THE WHOLE POINT OF THE SEQUENCING FIX. The hub refuses a
18
+ // rollup without a membership row, and the check-in is what creates it —
19
+ // so asserting "both fired" would still pass while a first sign-in raced
20
+ // itself. This asserts the rollup has NOT fired while the check-in is still
21
+ // in flight, which is the only shape that fails when the chain is broken.
22
+ //
23
+ // Plus the privacy edges the goal turns on: a work title is at least as
24
+ // identifying as the project name it came from, so it may only surface where
25
+ // that name may (a non-public project stays counted-but-unnamed, ships and all),
26
+ // and hide_stats suppresses the whole cross-project read including recent_ships.
27
+ //
28
+ // DB-free: every DB function takes an injectable pool and is driven by a
29
+ // recording fake (the modules/platform-identity/tests/platform-identity.mjs
30
+ // idiom); the core halves pin require.cache (the tests/profile_route.mjs idiom)
31
+ // so the REAL handlers and the REAL chain run against spies.
32
+ //
33
+ // Run: node tests/hub_recent_ships.mjs
34
+
35
+ import { strict as assert } from 'node:assert';
36
+ import { createRequire } from 'node:module';
37
+ import path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ const require = createRequire(import.meta.url);
41
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
42
+
43
+ let passed = 0, failed = 0;
44
+ async function t(name, fn) {
45
+ try { await fn(); passed++; console.log(` ok ${name}`); }
46
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
47
+ }
48
+
49
+ // A recording fake pool: answers by SQL shape and records every call, so a test
50
+ // asserts the params the real query actually bound.
51
+ function mockPool(answer = () => ({ rows: [] })) {
52
+ const calls = [];
53
+ return {
54
+ calls,
55
+ query: async (text, params = []) => {
56
+ calls.push({ text: String(text), params });
57
+ return answer(String(text), params) || { rows: [] };
58
+ },
59
+ find: (re) => calls.find((c) => re.test(c.text)),
60
+ };
61
+ }
62
+
63
+ const pi = require('../modules/platform-identity/platform-identity.js');
64
+
65
+ const ORIGIN = 'https://p07.example';
66
+ const ship = (over = {}) => ({
67
+ id: 1002803, title: 'A shipped work', shipped_at: '2026-08-28T10:00:00.000Z',
68
+ url: `${ORIGIN}/builders#/task/1002803`, ...over,
69
+ });
70
+ // What upsertActivity BOUND as recent_ships — the jsonb param, parsed back.
71
+ async function storedShips(recentShips, clientOrigin = ORIGIN) {
72
+ const pool = mockPool(() => ({ rows: [{ id: 1 }] }));
73
+ await pi.upsertActivity({ clientId: 'c1', githubId: 42, recentShips, clientOrigin }, { pool });
74
+ const call = pool.find(/INSERT INTO platform_identity_builder_project_activity/);
75
+ assert.ok(call, 'the activity upsert ran');
76
+ const raw = call.params[7];
77
+ assert.equal(typeof raw, 'string', 'jsonb is bound as a JSON string, not a JS array (pg would emit a Postgres array literal)');
78
+ return JSON.parse(raw);
79
+ }
80
+
81
+ console.log('\n1. the cap and the shape are the HUB\'s, not the sender\'s:');
82
+
83
+ await t('a client reporting 5 works stores exactly 3', async () => {
84
+ const five = [1, 2, 3, 4, 5].map((n) => ship({ id: n, title: `work ${n}` }));
85
+ const out = await storedShips(five);
86
+ assert.equal(out.length, 3);
87
+ assert.deepEqual(out.map((s) => s.id), [1, 2, 3], 'the first three, in reported order');
88
+ });
89
+
90
+ await t('no works stores an empty list, never null and never a stray shape', async () => {
91
+ for (const nothing of [[], undefined, null, 'nope', { id: 1 }]) {
92
+ const out = await storedShips(nothing);
93
+ assert.deepEqual(out, [], `${JSON.stringify(nothing) ?? 'undefined'} → []`);
94
+ }
95
+ });
96
+
97
+ await t('a stored work carries exactly id + title + shipped_at + url', async () => {
98
+ const [s] = await storedShips([ship({ extra: 'DO-NOT-STORE' })]);
99
+ assert.deepEqual(Object.keys(s).sort(), ['id', 'shipped_at', 'title', 'url']);
100
+ assert.equal(s.id, 1002803);
101
+ assert.equal(s.title, 'A shipped work');
102
+ assert.equal(s.shipped_at, '2026-08-28T10:00:00.000Z');
103
+ });
104
+
105
+ await t('a work with no usable id or title is dropped, not stored half-empty', async () => {
106
+ const out = await storedShips([
107
+ ship({ id: 'not-a-number' }),
108
+ ship({ id: 7, title: ' ' }),
109
+ ship({ id: -1 }),
110
+ ship({ id: 9, title: 'kept' }),
111
+ ]);
112
+ assert.deepEqual(out.map((s) => s.id), [9]);
113
+ });
114
+
115
+ await t('an unparseable date nulls only the date, never the work', async () => {
116
+ const [s] = await storedShips([ship({ shipped_at: 'yesterday-ish' })]);
117
+ assert.equal(s.shipped_at, null);
118
+ assert.equal(s.id, 1002803, 'the work still publishes without a date');
119
+ });
120
+
121
+ await t('a title longer than the cap is truncated, not rejected', async () => {
122
+ const [s] = await storedShips([ship({ title: 'x'.repeat(400) })]);
123
+ assert.equal(s.title.length, 200);
124
+ });
125
+
126
+ console.log('\n2. a ship link may only point back into the reporting instance:');
127
+
128
+ for (const [label, url] of [
129
+ ['another site', 'https://evil.example/phish'],
130
+ ['a javascript: scheme', 'javascript:alert(1)'],
131
+ ['embedded credentials', 'https://user:pw@p07.example/x'],
132
+ ['a lookalike suffix', 'https://notp07.example/x'],
133
+ ['a non-string', 12345],
134
+ // The mirror of the subdomain rule is NOT offered: on a shared multi-tenant
135
+ // apex it would let a client registered at foo.github.io publish links on bare
136
+ // github.io. The bound is downward only.
137
+ ['an ancestor of the registered origin', 'https://example/x'],
138
+ ]) {
139
+ await t(`${label} stores no link (the work still publishes)`, async () => {
140
+ const [s] = await storedShips([ship({ url })]);
141
+ assert.equal(s.url, null);
142
+ assert.equal(s.id, 1002803);
143
+ });
144
+ }
145
+
146
+ await t('the client\'s own origin and a subdomain of it are kept', async () => {
147
+ const same = await storedShips([ship({ url: `${ORIGIN}/builders#/task/1` })]);
148
+ assert.equal(same[0].url, `${ORIGIN}/builders#/task/1`);
149
+ const sub = await storedShips([ship({ url: 'https://builders.p07.example/builders#/task/1' })]);
150
+ assert.equal(sub[0].url, 'https://builders.p07.example/builders#/task/1', 'an instance hall on a subdomain of its registered origin');
151
+ // …but never UP. A client registered on a subdomain may not publish links on
152
+ // the apex above it — that apex may be shared (the github.io shape).
153
+ const up = await storedShips([ship({ url: 'https://p07.example/x' })], 'https://builders.p07.example');
154
+ assert.equal(up[0].url, null, 'the bound is downward only');
155
+ });
156
+
157
+ console.log('\n3. POST /sso/activity/rollup accepts the field and hands the hub the trusted origin:');
158
+
159
+ await t('the route passes recent_ships through and takes the origin from the CLIENT row', async () => {
160
+ const SECRET = 'p07-secret';
161
+ const rollupPool = mockPool((sql) => {
162
+ if (/FROM platform_identity_sso_clients/.test(sql)) {
163
+ return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: ORIGIN }] };
164
+ }
165
+ if (/FROM platform_identity_project_memberships/.test(sql)) {
166
+ return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member' }] };
167
+ }
168
+ return { rows: [{ id: 1 }] };
169
+ });
170
+ const helpers = require('../src/bongos/routes/_helpers.js');
171
+ const moduleApiPath = require.resolve('../src/module-api.js');
172
+ const realApi = require('../src/module-api.js');
173
+ require.cache[moduleApiPath] = {
174
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
175
+ exports: {
176
+ pool: rollupPool,
177
+ validateOrRespond: helpers.validateOrRespond,
178
+ resolveSession: async () => null,
179
+ logger: () => ({ info() {}, warn() {}, error() {} }),
180
+ LIVE_RANK_LADDER: realApi.LIVE_RANK_LADDER,
181
+ corsPublicGet: () => (_q, _s, next) => next(),
182
+ on: () => () => {},
183
+ hasProvider: () => false,
184
+ registerProvider: () => {},
185
+ },
186
+ };
187
+ const ssoRoutes = require('../modules/platform-identity/routes/sso.js');
188
+ const router = ssoRoutes();
189
+ const layer = router.stack.find((l) => l.route && l.route.path === '/sso/activity/rollup');
190
+ assert.ok(layer, 'the rollup route is mounted');
191
+
192
+ const res = { body: null, failure: null };
193
+ res.json = (b) => { res.body = b; return res; };
194
+ res.fail = (code, ...rest) => { res.failure = { code, rest }; return res; };
195
+ const req = {
196
+ body: {
197
+ client_id: 'c1', client_secret: SECRET, github_id: 42,
198
+ credits: 10, tasks_shipped: 2, karma: 1, rank: 'metic', disciplines: ['engineer'],
199
+ // Five, so the HUB's cap is what decides — a sender that ignores its own
200
+ // cap must not widen the section.
201
+ recent_ships: [1, 2, 3, 4, 5].map((n) => ship({ id: n, title: `work ${n}`, url: `${ORIGIN}/builders#/task/${n}` })),
202
+ },
203
+ };
204
+ await layer.route.stack[0].handle(req, res, () => {});
205
+ assert.equal(res.failure, null, `no failure (validateOrRespond runs in STRICT mode, so an unknown recent_ships would 400 here): ${JSON.stringify(res.failure)}`);
206
+ assert.deepEqual(res.body, { ok: true });
207
+
208
+ const insert = rollupPool.find(/INSERT INTO platform_identity_builder_project_activity/);
209
+ const stored = JSON.parse(insert.params[7]);
210
+ assert.equal(stored.length, 3, 'the hub capped what the sender did not');
211
+ assert.equal(stored[0].url, `${ORIGIN}/builders#/task/1`, 'links pass the origin bound taken from the authenticated client row');
212
+ });
213
+
214
+ console.log('\n4. a work title surfaces only where the project name may:');
215
+
216
+ await t('getRollupForBuilder attaches recent_ships to publicly-named projects only', async () => {
217
+ const pool = mockPool((sql) => {
218
+ if (/WITH agg AS/.test(sql)) {
219
+ return { rows: [{ projects: 2, credits: 5, works_shipped: 2, karma: 0, last_active: null, disciplines: ['engineer'] }] };
220
+ }
221
+ return {
222
+ rows: [
223
+ { name: 'Public Project', origin: ORIGIN, rank_held: 'metic', is_public: true, recent_ships: [ship()] },
224
+ { name: 'SECRET-PROJECT', origin: 'https://priv.example', rank_held: 'thetes', is_public: false, recent_ships: [ship({ id: 5, title: 'SECRET-WORK' })] },
225
+ ],
226
+ };
227
+ });
228
+ const out = await pi.getRollupForBuilder(42, { pool });
229
+ assert.equal(out.projects.length, 1);
230
+ assert.equal(out.more_projects, 1);
231
+ assert.deepEqual(out.projects[0].recent_ships, [ship()]);
232
+ const serialized = JSON.stringify(out);
233
+ assert.ok(!serialized.includes('SECRET-WORK'), 'a non-public project\'s work titles never leave the hub');
234
+ assert.ok(!serialized.includes('SECRET-PROJECT'), 'nor does its name (the pre-existing rule this one follows)');
235
+ });
236
+
237
+ await t('a project that reported no works reads as an empty list, not null', async () => {
238
+ const pool = mockPool((sql) => {
239
+ if (/WITH agg AS/.test(sql)) return { rows: [{ projects: 1, credits: 0, works_shipped: 0, karma: 0, last_active: null, disciplines: null }] };
240
+ return { rows: [{ name: 'P', origin: ORIGIN, rank_held: null, is_public: true, recent_ships: null }] };
241
+ });
242
+ const out = await pi.getRollupForBuilder(42, { pool });
243
+ assert.deepEqual(out.projects[0].recent_ships, []);
244
+ });
245
+
246
+ await t('hide_stats suppresses the whole cross-project read, recent_ships included', async () => {
247
+ for (const acct of [
248
+ { profile_state: 'active', hide_stats: true },
249
+ { profile_state: 'provisional', hide_stats: false },
250
+ ]) {
251
+ const pool = mockPool(() => ({ rows: [acct] }));
252
+ assert.equal(await pi.getPublicProfileExtras(42, { pool }), null, JSON.stringify(acct));
253
+ assert.ok(!pool.find(/WITH agg AS/), 'the rollup query never even runs — no partial numbers, no leak by subtraction');
254
+ }
255
+ });
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // The core halves. Everything below pins require.cache BEFORE the core files
259
+ // load, so the REAL handlers run against spies.
260
+ // ---------------------------------------------------------------------------
261
+
262
+ const admissionPath = require.resolve('../src/bongos/auth-admission.js');
263
+ const realAdmission = require('../src/bongos/auth-admission.js');
264
+ const spy = { shipPushes: [], order: [] };
265
+ // A mutable exports object: section 6 re-points the two reporters at spies
266
+ // after section 5 has used the real ones.
267
+ const admissionExports = {
268
+ ...realAdmission,
269
+ reportActivityRollupForShippedTask: async (task) => { spy.shipPushes.push(task); },
270
+ };
271
+ require.cache[admissionPath] = {
272
+ id: admissionPath, filename: admissionPath, loaded: true, exports: admissionExports,
273
+ };
274
+
275
+ console.log('\n5. the ship-time push — hub numbers stop being as stale as the last sign-in:');
276
+
277
+ await t('a task reaching shipped fires the rollup push exactly once', async () => {
278
+ const seams = require('../src/module-seams.js');
279
+ const buildAuthRouter = require('../src/bongos/routes/auth.js');
280
+ buildAuthRouter();
281
+ spy.shipPushes.length = 0;
282
+ const invoked = await seams.emitAsync('task.shipped', { id: 1002803, shipped_by: 7 });
283
+ assert.ok(invoked >= 1, 'the core auth router registered a task.shipped listener');
284
+ assert.equal(spy.shipPushes.length, 1, 'the ship push fired once');
285
+ assert.equal(spy.shipPushes[0].shipped_by, 7);
286
+ });
287
+
288
+ await t('re-instantiating the router does not stack a second listener', async () => {
289
+ const seams = require('../src/module-seams.js');
290
+ const buildAuthRouter = require('../src/bongos/routes/auth.js');
291
+ buildAuthRouter();
292
+ buildAuthRouter();
293
+ spy.shipPushes.length = 0;
294
+ await seams.emitAsync('task.shipped', { id: 1, shipped_by: 7 });
295
+ assert.equal(spy.shipPushes.length, 1, 'off→on guarded — a ship is pushed once, not once per router build');
296
+ });
297
+
298
+ console.log('\n6. the check-in is AWAITED before the rollup (ADR 0171 D7 sequencing):');
299
+
300
+ await t('a federated sign-in does not report activity until its membership check-in has resolved', async () => {
301
+ // Re-point the reporters at ordering spies, then load a FRESH auth-idp so it
302
+ // destructures these rather than the real ones section 5 left in place.
303
+ let releaseCheckIn;
304
+ const checkInLanded = new Promise((r) => { releaseCheckIn = r; });
305
+ admissionExports.reportMembershipCheckIn = async () => {
306
+ spy.order.push('check-in:start');
307
+ await checkInLanded;
308
+ spy.order.push('check-in:done');
309
+ };
310
+ admissionExports.reportActivityRollup = async () => { spy.order.push('rollup'); };
311
+ admissionExports.openEnrollmentEnabled = () => true;
312
+ admissionExports.maybeSeatFirstAdmin = async (b) => b;
313
+ admissionExports.safeSyncRankRole = async () => {};
314
+
315
+ const pin = (rel, exports) => {
316
+ const p = require.resolve(rel);
317
+ require.cache[p] = { id: p, filename: p, loaded: true, exports };
318
+ return p;
319
+ };
320
+ pin('../src/bongos/db-kernel.js', {
321
+ RANKS: ['xenos', 'thetes', 'metic', 'archon'],
322
+ upsertBuilder: async () => ({ id: 7, github_id: 42, rank: 'metic' }),
323
+ getBuilderByGithubId: async () => ({ id: 7, github_id: 42, rank: 'metic' }),
324
+ getBuilderById: async () => ({ id: 7, github_id: 42, rank: 'metic' }),
325
+ createSession: async () => ({}),
326
+ lookupSession: async () => null,
327
+ seatFirstAdminIfUnclaimed: async () => null,
328
+ hasInvitedAccessRequest: async () => true,
329
+ accessStateByLogin: async () => ({}),
330
+ });
331
+ pin('../src/bongos/pool.js', { pool: { query: async () => ({ rows: [] }) } });
332
+ pin('../src/bongos/auth-config.js', {
333
+ loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'c1', clientSecret: 's' }),
334
+ });
335
+ pin('../src/bongos/auth-github.js', { newSessionToken: () => 'tok' });
336
+ delete require.cache[require.resolve('../src/bongos/auth-idp.js')];
337
+
338
+ const realFetch = globalThis.fetch;
339
+ globalThis.fetch = async () => ({
340
+ ok: true,
341
+ json: async () => ({ github_id: 42, github_login: 'p07', display_name: 'P07', avatar_url: null }),
342
+ text: async () => '',
343
+ });
344
+ try {
345
+ const idp = require('../src/bongos/auth-idp.js');
346
+ await idp.completeIdpFlow({ code: 'x', redirectUri: 'https://p07.example/cb' });
347
+ // The sign-in has RETURNED and the check-in is still in flight. If the two
348
+ // pushes were fired side by side, the rollup would already be in `order`
349
+ // here — and on a first sign-in it would reach a hub with no membership row.
350
+ await new Promise((r) => setImmediate(r));
351
+ assert.deepEqual(spy.order, ['check-in:start'], `rollup must not have fired yet, saw ${JSON.stringify(spy.order)}`);
352
+ releaseCheckIn();
353
+ await new Promise((r) => setImmediate(r));
354
+ await new Promise((r) => setImmediate(r));
355
+ assert.deepEqual(spy.order, ['check-in:start', 'check-in:done', 'rollup'], 'the rollup runs only after the check-in resolved');
356
+ } finally {
357
+ globalThis.fetch = realFetch;
358
+ }
359
+ });
360
+
361
+ await t('the CLI device exchange chains the same way (both federated sign-ins, one rule)', async () => {
362
+ // completeDeviceExchange needs a hub-signed Ed25519 assertion to reach its
363
+ // chain, which is a different test's subject — so this half is pinned at the
364
+ // source, normalised to LF first (this checkout reads back CRLF, and an
365
+ // anchored multi-line match silently finds nothing otherwise).
366
+ const fs = require('node:fs');
367
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth-idp.js'), 'utf8').replace(/\r\n/g, '\n');
368
+ const chains = src.match(/reportMembershipCheckIn\([\s\S]{0,400}?\n\s*\.then\(\(\) => reportActivityRollup\(/g) || [];
369
+ assert.equal(chains.length, 2, 'both the web flow and the device exchange chain the rollup after the check-in');
370
+ });
371
+
372
+ console.log(`\nhub_recent_ships.mjs: ${passed} passed, ${failed} failed`);
373
+ process.exit(failed ? 1 : 0);