@bongos/core 1.19.168 → 1.19.170

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/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.168",
3
+ "version": "1.19.170",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.168",
9
+ "version": "1.19.170",
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.168",
3
+ "version": "1.19.170",
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",
@@ -75,7 +75,7 @@ const INTEGRATION = new Set([
75
75
  'consumer_layout_boot',
76
76
  'claim_gate_open_blocker', 'claim_gate_rebase', 'clawback_confirmed_backlog', 'discord_autojoin', 'discord_oauth',
77
77
  'governance_fold_bench', 'governance_live_proof', 'linkify_refs', 'memory_isolation',
78
- 'memory_pull', 'needs_rebase', 'parallel-load', 'reward_mode_cost_plus_only',
78
+ 'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent', 'reward_mode_cost_plus_only',
79
79
  'scouting_rollup_fanout', 'session_records', 'setup_smoke',
80
80
  'status-probe',
81
81
  ]);
@@ -764,9 +764,17 @@ async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
764
764
  expiresAt,
765
765
  });
766
766
  await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
767
- // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated web sign-in.
768
- reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: identity.display_name, avatarUrl: identity.avatar_url });
769
- reportActivityRollup({ builderId: builder.id, githubId: ghId });
767
+ // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
768
+ // federated web sign-in. The rollup is CHAINED after the check-in (ADR 0171 D7):
769
+ // the hub now requires a membership row before accepting activity, so a first
770
+ // sign-in's rollup must not race its own check-in. Both swallow all errors, so
771
+ // the chain never rejects and the sign-in never waits on it.
772
+ // Both helpers are async (always return a promise) and swallow their own
773
+ // errors; the .catch is belt-and-braces so this floating chain can never
774
+ // surface an unhandledRejection even if a future edit lets one throw.
775
+ reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: identity.display_name, avatarUrl: identity.avatar_url })
776
+ .then(() => reportActivityRollup({ builderId: builder.id, githubId: ghId }))
777
+ .catch(() => {});
770
778
  return { token, builder, expiresAt };
771
779
  }
772
780
 
@@ -935,9 +943,13 @@ async function completeDeviceExchange({ assertion, userAgent: ua }) {
935
943
  expiresAt: null,
936
944
  });
937
945
  await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
938
- // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated CLI sign-in.
939
- reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: payload.display_name, avatarUrl: payload.avatar_url });
940
- reportActivityRollup({ builderId: builder.id, githubId: ghId });
946
+ // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
947
+ // federated CLI sign-in. Rollup chained after check-in (ADR 0171 D7): the hub's
948
+ // membership requirement must not race a first sign-in; both swallow errors.
949
+ // Async + self-swallowing helpers; .catch is belt-and-braces (see web flow).
950
+ reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: payload.display_name, avatarUrl: payload.avatar_url })
951
+ .then(() => reportActivityRollup({ builderId: builder.id, githubId: ghId }))
952
+ .catch(() => {});
941
953
  return { token, builder };
942
954
  }
943
955
 
@@ -460,13 +460,17 @@ module.exports = function buildBuildersRouter() {
460
460
  const b = await db.getBuilderById(targetId);
461
461
  if (!b) return res.fail('builder_not_found', 404);
462
462
  // Recent ships + shipped-count read the lifecycle port; the achievement
463
- // shelf reads the reward port. Both resolveOptional — on a vanilla instance
464
- // without economy the achievement shelf degrades to empty, not a 500.
463
+ // shelf reads the reward port; the cross-project record reads the hub port
464
+ // (ADR 0171 D1 — registered only where the platform-identity module is
465
+ // enabled, i.e. the hub). All resolveOptional — on a vanilla instance the
466
+ // shelf degrades to empty and the cross_project section is absent, not a 500.
465
467
  const reward = seams.resolveOptional('reward');
466
- const [shippedCount, recentShips, achievements] = await Promise.all([
468
+ const hub = seams.resolveOptional('platform-identity.hub');
469
+ const [shippedCount, recentShips, achievements, crossProject] = await Promise.all([
467
470
  lc().countShippedTasksForBuilder(targetId),
468
471
  lc().recentShippedTasksByBuilder(targetId, 5),
469
472
  reward ? reward.getBuilderAchievements(targetId) : [],
473
+ hub ? hub.getPublicProfileExtras(b.github_id) : null,
470
474
  ]);
471
475
  // WHITELIST — only the leaderboard-equivalent public field set. Never spread
472
476
  // the raw row: it carries anthropic_email / anthropic_user_id /
@@ -484,8 +488,24 @@ module.exports = function buildBuildersRouter() {
484
488
  preferred_disciplines: b.preferred_disciplines,
485
489
  };
486
490
  // Identity + counters change rarely; a short public cache matches
487
- // /public/leaderboard's posture.
488
- res.set('Cache-Control', 'public, max-age=60');
491
+ // /public/leaderboard's posture. On the hub the response carries the
492
+ // cross_project section, whose hide flag must take effect IMMEDIATELY
493
+ // (ADR 0171 D5) — a 60s cached copy would keep serving hidden stats — so
494
+ // when the hub port resolves the response is no-store.
495
+ res.set('Cache-Control', hub ? 'no-store' : 'public, max-age=60');
496
+ // cross_project is attached only where the hub port exists (two literal
497
+ // branches so gen-api-docs proves the shape — the key merges as optional);
498
+ // null there means "no public cross-project record" (hidden / provisional /
499
+ // none — deliberately indistinguishable). Viewer-independent (ADR 0171 D4).
500
+ if (hub) {
501
+ return res.json({
502
+ builder,
503
+ shipped_count: shippedCount,
504
+ recent_ships: recentShips,
505
+ achievements,
506
+ cross_project: crossProject,
507
+ });
508
+ }
489
509
  res.json({
490
510
  builder,
491
511
  shipped_count: shippedCount,
package/src/module-api.js CHANGED
@@ -763,8 +763,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
763
763
  // landed since 1.19.166 with no explicit bump. run 31961815831. (task 1002620)
764
764
  // 1.19.168 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
765
765
  // landed since 1.19.167 with no explicit bump. run 31962256201. (task 1002620)
766
+ // 1.19.169 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
767
+ // landed since 1.19.168 with no explicit bump. run 31964702865. (task 1002620)
768
+ // 1.19.170 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
769
+ // landed since 1.19.169 with no explicit bump. run 31965483417. (task 1002620)
766
770
  // ---------------------------------------------------------------------------
767
- const CORE_VERSION = '1.19.168'; // CI auto-patch carrier (ADR 0161); see changelog above
771
+ const CORE_VERSION = '1.19.170'; // CI auto-patch carrier (ADR 0161); see changelog above
768
772
 
769
773
  // A namespaced logger so a module's log lines are attributable + consistent.
770
774
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,187 @@
1
+ // tests/profile_rollup_consent.mjs — task 1002278 (R02, ADR 0171; real DB).
2
+ //
3
+ // Proves the consent + hide semantics against real SQL — pg-mem cannot execute
4
+ // unnest(), so the rollup queries need a live Postgres (the task-1002911
5
+ // precedent). Replays the module's actual migrations, then asserts:
6
+ // 1. listScoutingRollup lists ONLY active + not-hidden builders (D2/D3) —
7
+ // provisional and hidden accounts drop out of /people + /scouting by
8
+ // construction.
9
+ // 2. getRollupForBuilder aggregates un-fanned and names ONLY projects whose
10
+ // catalog row is publicly listed (featured + active); everything else is
11
+ // counted in more_projects, never named. client_id never crosses.
12
+ // 3. getPublicProfileExtras is fail-closed (missing/provisional/hidden/
13
+ // non-numeric → null); getOwnProfileExtras serves the self view anyway.
14
+ // 4. upsertAccount's consent upgrade is one-way (activate flips provisional →
15
+ // active; a later plain upsert never demotes).
16
+ // 5. upsertActivity stores a validated rank (off-ladder → NULL) + capped
17
+ // disciplines against the real columns (D7).
18
+ //
19
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
20
+ // INTEGRATION set so the default DB-free lane never runs it.
21
+ //
22
+ // Run: DATABASE_URL=... node tests/profile_rollup_consent.mjs
23
+
24
+ import { createRequire } from 'node:module';
25
+ import { readFileSync } from 'node:fs';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { dirname, join } from 'node:path';
28
+ import { strict as assert } from 'node:assert';
29
+ const require = createRequire(import.meta.url);
30
+ const { Pool } = require('pg');
31
+ const pi = require('../modules/platform-identity/platform-identity.js');
32
+
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
35
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
36
+
37
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
38
+ try {
39
+ await pool.query('SELECT 1');
40
+ } catch (e) {
41
+ console.log(`profile_rollup_consent.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
42
+ await pool.end().catch(() => {});
43
+ process.exit(0);
44
+ }
45
+
46
+ // High-numbered fixture ids so a shared DB can never collide with real data.
47
+ const ACTIVE = 90002278, HIDDEN = 90002279, PROV = 90002280, LAZY = 90002281;
48
+ const ALL_IDS = [ACTIVE, HIDDEN, PROV, LAZY];
49
+ const PUB_CLIENT = 't1002278-pub', PRIV_CLIENT = 't1002278-priv', LOST_CLIENT = 't1002278-lost';
50
+ const ALL_CLIENTS = [PUB_CLIENT, PRIV_CLIENT, LOST_CLIENT];
51
+ const PUB_ORIGIN = 'https://t1002278-pub.example', PRIV_ORIGIN = 'https://t1002278-priv.example';
52
+ const q = (sql, params) => pool.query(sql, params);
53
+
54
+ async function cleanup() {
55
+ await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
56
+ await q(`DELETE FROM platform_identity_project_memberships WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
57
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
58
+ await q(`DELETE FROM platform_identity_projects WHERE origin = ANY($1::text[])`, [[PUB_ORIGIN, PRIV_ORIGIN]]).catch(() => {});
59
+ await q(`DELETE FROM platform_identity_sso_clients WHERE client_id = ANY($1::text[])`, [ALL_CLIENTS]).catch(() => {});
60
+ }
61
+
62
+ let pass = 0;
63
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
64
+
65
+ try {
66
+ // The module's actual migrations (idempotent).
67
+ await q(migrationSql('platform_identity_001_tables.sql'));
68
+ await q(migrationSql('platform_identity_004_projects.sql'));
69
+ await q(migrationSql('platform_identity_005_builder_activity.sql'));
70
+ await q(migrationSql('platform_identity_007_profile_consent.sql'));
71
+ await cleanup();
72
+
73
+ await q(
74
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, profile_state, hide_stats)
75
+ VALUES ($1, 'active-b', 'Active', 'active', false),
76
+ ($2, 'hidden-b', 'Hidden', 'active', true),
77
+ ($3, 'prov-b', 'Prov', 'provisional', false)`,
78
+ [ACTIVE, HIDDEN, PROV],
79
+ );
80
+ // Three reporting projects: one publicly catalogued (featured+active), one
81
+ // catalogued but NOT featured (→ never named), one with no catalog row at all.
82
+ await q(
83
+ `INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, origin)
84
+ VALUES ($1, 'x', 'Pub Proj', $4), ($2, 'x', 'Priv Proj', $5), ($3, 'x', 'Lost Proj', NULL)`,
85
+ [...ALL_CLIENTS, PUB_ORIGIN, PRIV_ORIGIN],
86
+ );
87
+ await q(
88
+ `INSERT INTO platform_identity_projects (origin, name, featured, status)
89
+ VALUES ($1, 'Pub Proj', true, 'active'), ($2, 'Priv Proj', false, 'active')`,
90
+ [PUB_ORIGIN, PRIV_ORIGIN],
91
+ );
92
+ await q(
93
+ `INSERT INTO platform_identity_builder_project_activity
94
+ (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active)
95
+ VALUES ($1, $4, 100, 5, 3, 'metic', '{engineer,ui}', '2026-01-01T00:00:00Z'),
96
+ ($2, $4, 50, 2, 1, 'archon', '{artist}', '2026-02-01T00:00:00Z'),
97
+ ($3, $4, 25, 1, 0, NULL, '{}', '2026-03-01T00:00:00Z'),
98
+ ($1, $5, 60, 4, 2, 'metic', '{engineer}', '2026-01-01T00:00:00Z'),
99
+ ($1, $6, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z')`,
100
+ [...ALL_CLIENTS, ACTIVE, HIDDEN, PROV],
101
+ );
102
+
103
+ // 1 — the scouting/people predicate (D2/D3).
104
+ const listed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
105
+ ok(listed.includes(ACTIVE), 'active + visible builder is listed');
106
+ ok(!listed.includes(HIDDEN), 'hide_stats builder drops out of the scouting rollup');
107
+ ok(!listed.includes(PROV), 'provisional (no hub-session consent) builder drops out');
108
+
109
+ // 2 — getRollupForBuilder semantics.
110
+ const r = await pi.getRollupForBuilder(ACTIVE, { pool });
111
+ assert.deepEqual(r.totals, { projects: 3, credits: 175, works_shipped: 8, karma: 4 });
112
+ pass++; console.log(' ok - totals aggregate un-fanned across the builder\'s projects');
113
+ assert.deepEqual([...r.disciplines].sort(), ['artist', 'engineer', 'ui']);
114
+ pass++; console.log(' ok - disciplines are the deduped union');
115
+ ok(new Date(r.last_active).toISOString() === '2026-03-01T00:00:00.000Z', 'last_active is the max');
116
+ ok(r.projects.length === 1 && r.projects[0].name === 'Pub Proj', 'only the publicly-catalogued project is named');
117
+ assert.deepEqual(Object.keys(r.projects[0]).sort(), ['name', 'origin', 'rank_held']);
118
+ pass++; console.log(' ok - named rows carry exactly name/origin/rank_held');
119
+ ok(r.projects[0].rank_held === 'metic', 'rank_held is that project\'s reported standing');
120
+ ok(r.more_projects === 2, 'unfeatured + uncatalogued projects are counted, never named');
121
+ ok(!JSON.stringify(r).includes('client_id'), 'client_id never crosses the shape');
122
+ ok(!JSON.stringify(r).includes('Priv Proj'), 'a non-public project name never crosses');
123
+
124
+ // 3 — the port reads.
125
+ ok(await pi.getPublicProfileExtras(HIDDEN, { pool }) === null, 'public read → null for a hidden builder');
126
+ ok(await pi.getPublicProfileExtras(PROV, { pool }) === null, 'public read → null for a provisional builder');
127
+ ok(await pi.getPublicProfileExtras(99999999999, { pool }) === null, 'public read → null with no account');
128
+ ok(await pi.getPublicProfileExtras('system:bfg', { pool }) === null, 'non-numeric principal → null, no bigint cast error');
129
+ const pub = await pi.getPublicProfileExtras(ACTIVE, { pool });
130
+ ok(pub && pub.totals.credits === 175, 'public read returns the rollup for an active, visible builder');
131
+ const own = await pi.getOwnProfileExtras(HIDDEN, { pool });
132
+ ok(own && own.totals.credits === 60, 'self view serves a hidden builder their own numbers');
133
+
134
+ // 4 — one-way consent upgrade on the real upsert.
135
+ await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b' }, { pool });
136
+ let acct = await pi.getAccount(LAZY, { pool });
137
+ ok(acct.profile_state === 'provisional', 'a plain upsert creates a provisional account');
138
+ await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b', activate: true }, { pool });
139
+ acct = await pi.getAccount(LAZY, { pool });
140
+ ok(acct.profile_state === 'active', 'an authorize-path upsert (activate) flips it active');
141
+ await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b' }, { pool });
142
+ acct = await pi.getAccount(LAZY, { pool });
143
+ ok(acct.profile_state === 'active', 'a later plain upsert never demotes an active account');
144
+
145
+ // 4b — the live hide toggle (R03, task 1002279): flipping hide_stats takes
146
+ // effect IMMEDIATELY on every public read — the profile port and the
147
+ // /people + /scouting rollup — while the self view keeps serving.
148
+ await pi.setAccountHideStats({ githubId: ACTIVE, githubLogin: 'active-b', hide: true }, { pool });
149
+ ok(await pi.getPublicProfileExtras(ACTIVE, { pool }) === null, 'hide ON → public read drops to null immediately');
150
+ const hiddenListed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
151
+ ok(!hiddenListed.includes(ACTIVE), 'hide ON → the builder drops out of /people + /scouting immediately');
152
+ const stillOwn = await pi.getOwnProfileExtras(ACTIVE, { pool });
153
+ ok(stillOwn && stillOwn.totals.credits === 175, 'hide ON → the self view still serves their own numbers');
154
+ let hiddenAcct = await pi.getAccount(ACTIVE, { pool });
155
+ ok(hiddenAcct.profile_state === 'active', 'the toggle never touches profile_state');
156
+ ok(hiddenAcct.github_login === 'active-b', 'the toggle never clobbers identity fields');
157
+ await pi.setAccountHideStats({ githubId: ACTIVE, githubLogin: 'active-b', hide: false }, { pool });
158
+ const backPub = await pi.getPublicProfileExtras(ACTIVE, { pool });
159
+ ok(backPub && backPub.totals.credits === 175, 'hide OFF → the public read returns immediately');
160
+
161
+ // 4c — hide-before-federating: a builder with NO account row can still hide;
162
+ // the insert creates a fail-closed provisional account carrying the flag.
163
+ const EARLY = 90002282;
164
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = $1`, [EARLY]).catch(() => {});
165
+ const early = await pi.setAccountHideStats({ githubId: EARLY, githubLogin: 'early-b', hide: true }, { pool });
166
+ ok(early.hide_stats === true && early.profile_state === 'provisional',
167
+ 'hide with no account row creates a provisional, hidden account');
168
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = $1`, [EARLY]).catch(() => {});
169
+
170
+ // 5 — rollup payload sanitization against the real columns (D7).
171
+ await pi.upsertActivity({
172
+ clientId: PUB_CLIENT, githubId: LAZY, credits: 1,
173
+ rank: 'Supreme Overlord',
174
+ disciplines: ['engineer', 42, 'x'.repeat(33), 'a', 'b', 'c', 'd', 'e', 'f', 'g'],
175
+ }, { pool });
176
+ const { rows: [act] } = await q(
177
+ `SELECT rank_held, disciplines FROM platform_identity_builder_project_activity
178
+ WHERE client_id = $1 AND github_id = $2`, [PUB_CLIENT, LAZY]);
179
+ ok(act.rank_held === null, 'an off-ladder rank is stored as NULL');
180
+ ok(act.disciplines.length <= 8 && act.disciplines.every((d) => d.length <= 32),
181
+ 'disciplines land validated + capped');
182
+
183
+ console.log(`\nprofile_rollup_consent.mjs: ${pass} passed`);
184
+ } finally {
185
+ await cleanup();
186
+ await pool.end().catch(() => {});
187
+ }
@@ -73,6 +73,36 @@ const PUBLIC_KEYS = [
73
73
  'total_credits', 'karma', 'created_at', 'preferred_disciplines',
74
74
  ];
75
75
 
76
+ // ---- the cross_project scope (ADR 0171, task 1002278) ---------------------
77
+ //
78
+ // On the hub the route resolves the 'platform-identity.hub' port and attaches
79
+ // its whitelist-shaped return as `cross_project`. The stub below mirrors the
80
+ // module's contract over an in-memory fixture that — like FULL_ROW above —
81
+ // deliberately carries the module-internal values (a client_id, a non-public
82
+ // project's name) so the raw-text scan proves the whitelist held end-to-end.
83
+ const SECRET_CLIENT_ID = 'cli_DO_NOT_LEAK_9f3a';
84
+ const SECRET_PROJECT_NAME = 'DO_NOT_LEAK_PRIVATE_PROJECT';
85
+ const CROSS_PROJECT_ROWS = [
86
+ { client_id: SECRET_CLIENT_ID, name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic', is_public: true },
87
+ { client_id: SECRET_CLIENT_ID, name: SECRET_PROJECT_NAME, origin: 'https://priv.example', rank_held: 'archon', is_public: false },
88
+ ];
89
+ // What the port returns for an active, visible account: the module's whitelist
90
+ // shape — named projects only where the catalog row is public; the rest counted.
91
+ function stubPublicProfileExtras() {
92
+ const named = CROSS_PROJECT_ROWS.filter((r) => r.is_public)
93
+ .map((r) => ({ name: r.name, origin: r.origin, rank_held: r.rank_held }));
94
+ return {
95
+ totals: { projects: 2, credits: 150, works_shipped: 7, karma: 4 },
96
+ disciplines: ['engineer', 'ui'],
97
+ last_active: '2026-08-01T00:00:00.000Z',
98
+ projects: named,
99
+ more_projects: CROSS_PROJECT_ROWS.length - named.length,
100
+ };
101
+ }
102
+ // The key contract for the cross_project object + its project rows.
103
+ const CP_PUBLIC_KEYS = ['totals', 'disciplines', 'last_active', 'projects', 'more_projects'];
104
+ const CP_PROJECT_ROW_KEYS = ['name', 'origin', 'rank_held'];
105
+
76
106
  // ---- stubs ----------------------------------------------------------------
77
107
 
78
108
  // auth: the profile route is public, but the router factory references
@@ -139,7 +169,7 @@ function get(port, path) {
139
169
  res.on('end', () => {
140
170
  const text = Buffer.concat(chunks).toString('utf8');
141
171
  let json = null; try { json = JSON.parse(text); } catch (_) {}
142
- resolve({ status: res.statusCode, json, text });
172
+ resolve({ status: res.statusCode, json, text, headers: res.headers });
143
173
  });
144
174
  });
145
175
  req.on('error', reject);
@@ -192,6 +222,61 @@ await t('non-numeric id → 400 bad_builder_id', async () => {
192
222
  assert.equal(r.json && r.json.error && r.json.error.code, 'bad_builder_id');
193
223
  });
194
224
 
225
+ // ---- cross_project (ADR 0171, task 1002278) -------------------------------
226
+ // The hub port resolves at REQUEST time, so the port-absent tests above ran
227
+ // against the same server; registering the stub here flips the route into its
228
+ // hub posture for the tests below.
229
+
230
+ await t('hub port ABSENT → no cross_project key + the cacheable posture stays', async () => {
231
+ const r = await get(port, '/api/gds/builders/95/profile');
232
+ assert.equal(r.status, 200);
233
+ assert.ok(!('cross_project' in r.json), 'cross_project must be absent on a non-hub instance');
234
+ assert.equal(r.headers['cache-control'], 'public, max-age=60');
235
+ });
236
+
237
+ let hubExtras = stubPublicProfileExtras();
238
+ seams.registerProvider('platform-identity.hub', {
239
+ getPublicProfileExtras: async () => hubExtras,
240
+ getOwnProfileExtras: async () => stubPublicProfileExtras(),
241
+ });
242
+
243
+ await t('hub port present → cross_project carries exactly the whitelist shape', async () => {
244
+ const r = await get(port, '/api/gds/builders/95/profile');
245
+ assert.equal(r.status, 200);
246
+ const cp = r.json.cross_project;
247
+ assert.ok(cp, 'cross_project attached when the port resolves');
248
+ assert.deepEqual(Object.keys(cp).sort(), [...CP_PUBLIC_KEYS].sort(), 'cross_project keys are exactly the contract');
249
+ assert.deepEqual(Object.keys(cp.totals).sort(), ['credits', 'karma', 'projects', 'works_shipped']);
250
+ for (const row of cp.projects) {
251
+ assert.deepEqual(Object.keys(row).sort(), [...CP_PROJECT_ROW_KEYS].sort(), 'project rows carry only name/origin/rank_held');
252
+ }
253
+ assert.equal(cp.projects.length, 1, 'only the public catalog row is named');
254
+ assert.equal(cp.more_projects, 1, 'the non-public project is counted, never named');
255
+ });
256
+
257
+ await t('hub port present → module-internal sentinels leak NOWHERE in the response', async () => {
258
+ const r = await get(port, '/api/gds/builders/95/profile');
259
+ assert.equal(r.status, 200);
260
+ for (const secret of [SECRET_CLIENT_ID, SECRET_PROJECT_NAME, SECRET_EMAIL, SECRET_USER, SECRET_REASON]) {
261
+ assert.ok(!r.text.includes(secret), `response body must not contain "${secret}"`);
262
+ }
263
+ assert.ok(!r.text.includes('client_id'), 'no client_id key may cross the port');
264
+ });
265
+
266
+ await t('hub port present → hide must beat the cache: Cache-Control is no-store', async () => {
267
+ const r = await get(port, '/api/gds/builders/95/profile');
268
+ assert.equal(r.headers['cache-control'], 'no-store');
269
+ });
270
+
271
+ await t('hub port returns null (hidden/provisional/none) → cross_project: null, still no-store', async () => {
272
+ hubExtras = null;
273
+ const r = await get(port, '/api/gds/builders/95/profile');
274
+ assert.equal(r.status, 200);
275
+ assert.ok('cross_project' in r.json, 'the key is present on the hub');
276
+ assert.equal(r.json.cross_project, null, 'null — no partial numbers, indistinguishable from no-account');
277
+ assert.equal(r.headers['cache-control'], 'no-store');
278
+ });
279
+
195
280
  server.close();
196
281
 
197
282
  console.log(`\n${passed} passed, ${failed} failed`);
@@ -53,11 +53,15 @@ try {
53
53
  // The module's actual migrations (idempotent CREATE TABLE IF NOT EXISTS).
54
54
  await q(migrationSql('platform_identity_001_tables.sql'));
55
55
  await q(migrationSql('platform_identity_005_builder_activity.sql'));
56
+ await q(migrationSql('platform_identity_007_profile_consent.sql'));
56
57
  await cleanup();
57
58
 
59
+ // profile_state='active' — since ADR 0171 the rollup only lists consented,
60
+ // visible builders; this test is about AGGREGATION, so fixtures opt in.
61
+ // (The predicate itself is proven by tests/profile_rollup_consent.mjs.)
58
62
  await q(
59
- `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url)
60
- VALUES ($1, 'multi', 'Multi', 'a'), ($2, 'solo', 'Solo', 'b'), ($3, 'ghost', 'Ghost', 'c')`,
63
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
64
+ VALUES ($1, 'multi', 'Multi', 'a', 'active'), ($2, 'solo', 'Solo', 'b', 'active'), ($3, 'ghost', 'Ghost', 'c', 'active')`,
61
65
  [MULTI, SOLO, GHOST],
62
66
  );
63
67
  // MULTI: two projects, 2 and 3 disciplines — the exact fan-out shape the old