@bongos/core 1.19.167 → 1.19.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.167",
6
- "core_contract": "1.19.167",
7
- "source_commit": "35e223942ac5cbbccc7300e2db6175343b2f300a",
5
+ "core_version": "1.19.169",
6
+ "core_contract": "1.19.169",
7
+ "source_commit": "21b29b58735b3e1407e5a32314e6e163928aa766",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T17:31:19.570Z",
9
+ "built_at": "2026-08-16T18:29:19.872Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 305,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1265,
14
+ "functional_verbatim": 1267,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1591,
20
- "tree_sha256": "b033aa8ae702d08b637604677eefba0fb49124560898516f90f6fbbe751ce09a",
19
+ "file_count": 1593,
20
+ "tree_sha256": "8457440205714a5e435a88d3ec027ba850403a9e1a079d9b42280393f7d287d5",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "4245c4fb82c1c4f5c87788030dc4640987fe22e0d588072d4549e9982cea7ecc"
370
+ "sha256": "afbaacddff0e422658021543825155933c49527b78f6e2693a23a62cfe97fe83"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -1352,7 +1352,7 @@
1352
1352
  {
1353
1353
  "path": "docs/api/openapi.json",
1354
1354
  "mode": "0000644",
1355
- "sha256": "9382373e7be837c0ce4e26f23c3cd32bf20ab09858650bdee318dd90a06dedbe"
1355
+ "sha256": "2da0e769d0f7f1af2ea503104a66f254cbc925dc1266908f4aeb6b933406cf18"
1356
1356
  },
1357
1357
  {
1358
1358
  "path": "docs/architecture.md",
@@ -3784,6 +3784,11 @@
3784
3784
  "mode": "0000644",
3785
3785
  "sha256": "4885d452673c2d6170835c8d7f03c101847ddb313a69dfb66c8e640f875ecdc1"
3786
3786
  },
3787
+ {
3788
+ "path": "modules/platform-identity/migrations/platform_identity_007_profile_consent.sql",
3789
+ "mode": "0000644",
3790
+ "sha256": "cce2f971f030aa628f9d7855f2e421595ea71cb314e90965206dbe970b7beded"
3791
+ },
3787
3792
  {
3788
3793
  "path": "modules/platform-identity/module.json",
3789
3794
  "mode": "0000644",
@@ -3792,7 +3797,7 @@
3792
3797
  {
3793
3798
  "path": "modules/platform-identity/platform-identity.js",
3794
3799
  "mode": "0000644",
3795
- "sha256": "3d20563833a9daf20d14745f2ce1c5fe530fc918eac03427185998ff9b67beed"
3800
+ "sha256": "cf354fb34732141cb9a0774d11f1b344a3bc15e90814a80496a2024c941fcb77"
3796
3801
  },
3797
3802
  {
3798
3803
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -3817,17 +3822,17 @@
3817
3822
  {
3818
3823
  "path": "modules/platform-identity/routes/scouting.js",
3819
3824
  "mode": "0000644",
3820
- "sha256": "c6db7fa003caaf55d2ecb68f1ff73d7d89d8b71b6d999ed3ebf71286e8726359"
3825
+ "sha256": "886c9d98c94e84d2e51e21d5e716caafe757edbb49073d7dc096221b339ffd27"
3821
3826
  },
3822
3827
  {
3823
3828
  "path": "modules/platform-identity/routes/sso.js",
3824
3829
  "mode": "0000644",
3825
- "sha256": "1a3b19e793387c8410062cb2d53f981eb108ff72c31e09741bcc7c8622ca2b80"
3830
+ "sha256": "45c8a071f8bf61bd97bce83518fe3bd1b32cff99f31281c2cba84ad500359d60"
3826
3831
  },
3827
3832
  {
3828
3833
  "path": "modules/platform-identity/tests/platform-identity.mjs",
3829
3834
  "mode": "0000644",
3830
- "sha256": "727fc8c7533eec1e4af477582bdd6428e9842b930b3cc898bb8a13ddead83ec3"
3835
+ "sha256": "bff11d1f5ed3cadeccdb22c2754649ba281f43462fe4732d40f3ef011fe51af2"
3831
3836
  },
3832
3837
  {
3833
3838
  "path": "modules/platform-identity/ui/.gitkeep",
@@ -4202,12 +4207,12 @@
4202
4207
  {
4203
4208
  "path": "package-lock.json",
4204
4209
  "mode": "0000644",
4205
- "sha256": "91c78ba67f8cece0ac27f0dd7ae8cd927e3d4e041eeb41d7de1ddb9897331554"
4210
+ "sha256": "5a6c2bc1f2f6522a312cf2d980ff8fad527711139b2c8c0e5d4a32df9f91e697"
4206
4211
  },
4207
4212
  {
4208
4213
  "path": "package.json",
4209
4214
  "mode": "0000644",
4210
- "sha256": "c1fd91411d3a575ba33f6632a8820320389a490c4678e9a238c288008f8d03b5"
4215
+ "sha256": "e29a81671e4d241ceca9cdcc9ebb0a94d5c0012b216c4a2b66fd54c934e1ed8c"
4211
4216
  },
4212
4217
  {
4213
4218
  "path": "public-docs/index.html",
@@ -5192,7 +5197,7 @@
5192
5197
  {
5193
5198
  "path": "scripts/gds/run-unit-tests.js",
5194
5199
  "mode": "0000644",
5195
- "sha256": "5e7030e617e4db066136556a43cf030078b0d042ee480c9b7245eea05ab83776"
5200
+ "sha256": "21bcaeaa9d8b2048b9c3f9ff9b251a9203a9259c991a536636c0ec3c8745bbd5"
5196
5201
  },
5197
5202
  {
5198
5203
  "path": "scripts/gds/sandbox-stage.js",
@@ -5572,7 +5577,7 @@
5572
5577
  {
5573
5578
  "path": "src/bongos/auth.js",
5574
5579
  "mode": "0000644",
5575
- "sha256": "a8215fa2659638d5850acb787eb3b193b18b545bc7e86f801764cab328b0c837"
5580
+ "sha256": "04cddf3c307c2f3bf22b8b29140973b80b549a04f1ba90be38ed7f1a794352ad"
5576
5581
  },
5577
5582
  {
5578
5583
  "path": "src/bongos/cascade-dispatch.js",
@@ -5712,7 +5717,7 @@
5712
5717
  {
5713
5718
  "path": "src/bongos/routes/builders.js",
5714
5719
  "mode": "0000644",
5715
- "sha256": "43a409918ba54af69d0c7183af510ff9d73b26566a1a520ad97d84360abcc79b"
5720
+ "sha256": "9423af249991f4d40e03ffa8d73a3584279f9d1095115c420047a735103bb417"
5716
5721
  },
5717
5722
  {
5718
5723
  "path": "src/bongos/routes/github.js",
@@ -5812,7 +5817,7 @@
5812
5817
  {
5813
5818
  "path": "src/module-api.js",
5814
5819
  "mode": "0000644",
5815
- "sha256": "4bd1d6d5c41a01b6338a148203a8a289864cee0dd7e3d2a4f5de15dd90c0443e"
5820
+ "sha256": "723ed0f74974f36f8db1ae45eabf0a51679b6f6892f0b26e6275d99d9ace0f62"
5816
5821
  },
5817
5822
  {
5818
5823
  "path": "src/module-loader/catalog.js",
@@ -7369,10 +7374,15 @@
7369
7374
  "mode": "0000644",
7370
7375
  "sha256": "3c85d28ecc55e375988f3e55aee993c8c84220889678262d8207146c2772f3f5"
7371
7376
  },
7377
+ {
7378
+ "path": "tests/profile_rollup_consent.mjs",
7379
+ "mode": "0000644",
7380
+ "sha256": "4db6fc34b52ed55c1e21bc3c8b7d9a63ef08f13571283a06393662d6e1468d40"
7381
+ },
7372
7382
  {
7373
7383
  "path": "tests/profile_route.mjs",
7374
7384
  "mode": "0000644",
7375
- "sha256": "dc354f082204073409aea90df9b91f149d474c6930b761ed5c9928bb76e1358f"
7385
+ "sha256": "3b3045a6143d67c963516ed5703b5dabb9b8945a392118721e6a0a56258f1732"
7376
7386
  },
7377
7387
  {
7378
7388
  "path": "tests/project_stats.mjs",
@@ -7567,7 +7577,7 @@
7567
7577
  {
7568
7578
  "path": "tests/scouting_rollup_fanout.mjs",
7569
7579
  "mode": "0000644",
7570
- "sha256": "1b58003a1fac89b05acfc3287e529a9e17e2b7a6ec48259b5700f6cc837cc554"
7580
+ "sha256": "3451cb25abd4eb819a7b951d1e06310d575faf6145808711fcdd2e2afb655ec3"
7571
7581
  },
7572
7582
  {
7573
7583
  "path": "tests/scrubber_corpus.mjs",
@@ -30,7 +30,7 @@ export interface GetBoxesResponse { boxes: unknown }
30
30
  export interface GetBuildersDirectoryResponse { builders: unknown }
31
31
  export interface GetBuildersIdActiveClaimsResponse { builder_id: unknown; claims: unknown; stale_hours: unknown }
32
32
  export interface GetBuildersIdOnboardingResponse { builder_id: unknown; onboarding: unknown }
33
- export interface GetBuildersIdProfileResponse { builder: unknown; shipped_count: unknown; recent_ships: unknown; achievements: unknown }
33
+ export interface GetBuildersIdProfileResponse { builder: unknown; shipped_count: unknown; recent_ships: unknown; achievements: unknown; cross_project?: unknown }
34
34
  export interface GetBuildersMePendingRankChangeResponse { change: unknown; greeting?: unknown }
35
35
  export interface GetBuildersRosterResponse { builders: unknown; stale_hours: unknown }
36
36
  export interface GetDiscordChannelsPlanResponse { ok: boolean; guild_id: unknown; op_count: unknown; ops: unknown; warnings: unknown }
@@ -12617,7 +12617,8 @@
12617
12617
  "builder": {},
12618
12618
  "shipped_count": {},
12619
12619
  "recent_ships": {},
12620
- "achievements": {}
12620
+ "achievements": {},
12621
+ "cross_project": {}
12621
12622
  },
12622
12623
  "required": [
12623
12624
  "builder",
@@ -0,0 +1,32 @@
1
+ -- platform_identity_007_profile_consent.sql — profile consent state + the C2
2
+ -- hide flag (ADR 0171 D3/D4, task 1002278 / goal 1000045).
3
+ --
4
+ -- profile_state is FAIL-CLOSED consent: an account is 'active' iff a human was
5
+ -- demonstrably at the hub — the one path that proves it is a hub-session
6
+ -- /sso/authorize (where T&C acceptance can later be captured, task 1002913).
7
+ -- Device-poll and membership check-in create 'provisional' accounts; the
8
+ -- activity-rollup endpoint creates no account at all. Public surfaces (the
9
+ -- profile port, /people, /scouting) read only 'active' accounts.
10
+ --
11
+ -- Deliberately NO backfill joining core `builders`: that join is type-unsafe
12
+ -- ('system:bfg'-style text ids vs bigint) and would put a core-table read inside
13
+ -- a module migration (ADR 0083 boundary). All existing accounts start
14
+ -- 'provisional' and lazily upgrade on their next hub-session authorize.
15
+ --
16
+ -- hide_stats is the account-global C2 opt-out — enforced in the module's read
17
+ -- functions (ADR 0171 D2/D4), written by an own-scoped PATCH (task 1002279, R03).
18
+ --
19
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
20
+
21
+ BEGIN;
22
+
23
+ ALTER TABLE platform_identity_accounts
24
+ ADD COLUMN IF NOT EXISTS profile_state text NOT NULL DEFAULT 'provisional'
25
+ CHECK (profile_state IN ('provisional', 'active')),
26
+ ADD COLUMN IF NOT EXISTS hide_stats boolean NOT NULL DEFAULT false;
27
+
28
+ -- The public-surface predicate (profile_state = 'active' AND NOT hide_stats)
29
+ -- filters per-builder; accounts are looked up by github_id (already indexed via
30
+ -- the UNIQUE constraint), so no new index is needed.
31
+
32
+ COMMIT;
@@ -101,17 +101,24 @@ function redirectUriAllowed(client, redirectUri) {
101
101
 
102
102
  // Upsert the platform account for a GitHub identity (keyed on the stable
103
103
  // github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
104
- async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl }, { pool = defaultPool } = {}) {
104
+ //
105
+ // `activate` is the ONE consent upgrade path (ADR 0171 D3): an account becomes
106
+ // profile_state='active' iff touched by a hub-session /sso/authorize — the path
107
+ // where a human is demonstrably at the hub. Every other caller (device-poll,
108
+ // membership check-in) leaves the fail-closed 'provisional' default; activation
109
+ // is one-way (a later non-activating upsert never demotes an active account).
110
+ async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
105
111
  const { rows } = await pool.query(
106
- `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url)
107
- VALUES ($1, $2, $3, $4)
112
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
113
+ VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END)
108
114
  ON CONFLICT (github_id) DO UPDATE SET
109
115
  github_login = EXCLUDED.github_login,
110
116
  display_name = EXCLUDED.display_name,
111
117
  avatar_url = EXCLUDED.avatar_url,
118
+ profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
112
119
  updated_at = now()
113
- RETURNING id, github_id, github_login, display_name, avatar_url`,
114
- [githubId, githubLogin, displayName ?? null, avatarUrl ?? null],
120
+ RETURNING id, github_id, github_login, display_name, avatar_url, profile_state`,
121
+ [githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!activate],
115
122
  );
116
123
  return rows[0];
117
124
  }
@@ -131,7 +138,7 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
131
138
 
132
139
  async function getAccount(githubId, { pool = defaultPool } = {}) {
133
140
  const { rows } = await pool.query(
134
- `SELECT id, github_id, github_login, display_name, avatar_url, created_at, updated_at
141
+ `SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats, created_at, updated_at
135
142
  FROM platform_identity_accounts WHERE github_id = $1`,
136
143
  [githubId],
137
144
  );
@@ -306,6 +313,19 @@ async function upsertMembership({ clientId, githubId, role = 'builder' }, { pool
306
313
  return rows[0];
307
314
  }
308
315
 
316
+ // One membership row for (project, builder), any role — or null. Backs the
317
+ // activity-rollup membership requirement (ADR 0171 D7): a client may only report
318
+ // activity for a builder who actually has a membership on that project.
319
+ async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
320
+ const { rows } = await pool.query(
321
+ `SELECT id, client_id, github_id, role, joined_at, last_active
322
+ FROM platform_identity_project_memberships
323
+ WHERE client_id = $1 AND github_id = $2`,
324
+ [clientId, githubId],
325
+ );
326
+ return rows[0] || null;
327
+ }
328
+
309
329
  // All memberships for one account, newest-active first, joined to the project's
310
330
  // display fields (name/origin from sso_clients). Powers "My Projects" (task #1002112).
311
331
  async function listMembershipsForAccount(githubId, { pool = defaultPool } = {}) {
@@ -402,10 +422,23 @@ async function setClientStatus(clientId, status, { pool = defaultPool } = {}) {
402
422
  // Cross-project builder activity (the scouting data plane — ADR 0141 §4)
403
423
  // -------------------------------------------------------------------------
404
424
 
425
+ // Rollup payload caps (ADR 0171 D7): the reported rank/disciplines are third-party
426
+ // self-report headed for a public portfolio, so they are validated HERE, next to
427
+ // the data, where no caller can forget. Rank must be one of the four core rank
428
+ // keys (else stored null — a label is never invented); disciplines are string
429
+ // elements only, bounded in count and length.
430
+ const MAX_ROLLUP_DISCIPLINES = 8;
431
+ const MAX_DISCIPLINE_LENGTH = 32;
432
+
405
433
  // Upsert one project's rollup for one builder (keyed by client_id + github_id).
406
434
  // Descriptive analytics — never authority. Refreshes last_active on each report.
407
435
  async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [] }, { pool = defaultPool } = {}) {
408
- const disc = Array.isArray(disciplines) ? disciplines : [];
436
+ // api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
437
+ // lazy getter over db-kernel's canonical four-rank ladder).
438
+ const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
439
+ const disc = (Array.isArray(disciplines) ? disciplines : [])
440
+ .filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
441
+ .slice(0, MAX_ROLLUP_DISCIPLINES);
409
442
  const { rows } = await pool.query(
410
443
  `INSERT INTO platform_identity_builder_project_activity
411
444
  (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active, updated_at)
@@ -415,7 +448,7 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
415
448
  rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
416
449
  last_active = now(), updated_at = now()
417
450
  RETURNING id, client_id, github_id`,
418
- [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, rank, disc],
451
+ [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc],
419
452
  );
420
453
  return rows[0];
421
454
  }
@@ -430,6 +463,12 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
430
463
  // (task 1002911). So scalars aggregate in `agg` over the un-fanned table, and the
431
464
  // disciplines union is computed separately in `disc`. Proven against real SQL by
432
465
  // tests/scouting_rollup_fanout.mjs.
466
+ //
467
+ // Consent by construction (ADR 0171 D2/D3): only builders whose account is
468
+ // profile_state='active' AND NOT hide_stats appear — /people and /scouting are
469
+ // live cross-project surfaces, so the predicate lives IN the query, not in any
470
+ // caller's memory. Provisional (no hub-session consent) and hidden builders drop
471
+ // out entirely; the projection stays an explicit whitelist (no raw-row spread).
433
472
  async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
434
473
  const { rows } = await pool.query(
435
474
  `WITH agg AS (
@@ -440,6 +479,11 @@ async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
440
479
  sum(a.karma)::int AS total_karma,
441
480
  max(a.last_active) AS last_active
442
481
  FROM platform_identity_builder_project_activity a
482
+ WHERE EXISTS (
483
+ SELECT 1 FROM platform_identity_accounts pa
484
+ WHERE pa.github_id = a.github_id
485
+ AND pa.profile_state = 'active'
486
+ AND NOT pa.hide_stats)
443
487
  GROUP BY a.github_id
444
488
  ), disc AS (
445
489
  SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines
@@ -463,6 +507,100 @@ async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
463
507
  return rows;
464
508
  }
465
509
 
510
+ // Is this a value we can safely bind against the bigint github_id column?
511
+ // Core builders include 'system:bfg'-style TEXT principals; binding one would
512
+ // throw a bigint cast error mid-request, so every profile read guards here and
513
+ // returns null instead (ADR 0171 D2).
514
+ function isNumericGithubId(githubId) {
515
+ return /^\d+$/.test(String(githubId ?? ''));
516
+ }
517
+
518
+ // One builder's cross-project record (ADR 0171 D2/D6/D7, task 1002278) — the
519
+ // whitelist-shaped aggregate the profile surfaces render. Aggregation mirrors
520
+ // listScoutingRollup's corrected shape: scalars over the UN-FANNED activity
521
+ // table, the disciplines union computed separately (never a pre-GROUP-BY unnest
522
+ // fan-out — bug task 1002911). Per-project rows join the module-internal
523
+ // client_id → sso_clients.origin → projects.origin two-hop; a project is NAMED
524
+ // only when its catalog row is publicly listed today (featured + active — the
525
+ // exact set listFeaturedProjects publishes; goal 1000046's visibility states
526
+ // will widen this predicate when they land). Everything else is counted in
527
+ // more_projects, never named ("the aggregate is not the leak, the name is").
528
+ // client_id never appears in the returned shape.
529
+ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
530
+ if (!isNumericGithubId(githubId)) return null;
531
+ const { rows: aggRows } = await pool.query(
532
+ `WITH agg AS (
533
+ SELECT count(*)::int AS projects,
534
+ COALESCE(sum(a.credits), 0)::int AS credits,
535
+ COALESCE(sum(a.tasks_shipped), 0)::int AS works_shipped,
536
+ COALESCE(sum(a.karma), 0)::int AS karma,
537
+ max(a.last_active) AS last_active
538
+ FROM platform_identity_builder_project_activity a
539
+ WHERE a.github_id = $1
540
+ ), disc AS (
541
+ SELECT array_agg(DISTINCT x.d) AS disciplines
542
+ FROM (SELECT unnest(a.disciplines) AS d
543
+ FROM platform_identity_builder_project_activity a
544
+ WHERE a.github_id = $1) x
545
+ WHERE x.d IS NOT NULL
546
+ )
547
+ SELECT agg.projects, agg.credits, agg.works_shipped, agg.karma,
548
+ agg.last_active, disc.disciplines
549
+ FROM agg LEFT JOIN disc ON true`,
550
+ [githubId],
551
+ );
552
+ const agg = aggRows[0];
553
+ const { rows: projRows } = await pool.query(
554
+ `SELECT p.name, p.origin, a.rank_held,
555
+ (p.id IS NOT NULL AND p.featured AND p.status = 'active') AS is_public
556
+ FROM platform_identity_builder_project_activity a
557
+ LEFT JOIN platform_identity_sso_clients c ON c.client_id = a.client_id
558
+ LEFT JOIN platform_identity_projects p ON p.origin = c.origin
559
+ WHERE a.github_id = $1
560
+ ORDER BY a.last_active DESC`,
561
+ [githubId],
562
+ );
563
+ const named = projRows
564
+ .filter((r) => r.is_public)
565
+ .map((r) => ({ name: r.name, origin: r.origin, rank_held: r.rank_held }));
566
+ return {
567
+ totals: {
568
+ projects: agg.projects,
569
+ credits: agg.credits,
570
+ works_shipped: agg.works_shipped,
571
+ karma: agg.karma,
572
+ },
573
+ disciplines: agg.disciplines || [],
574
+ last_active: agg.last_active,
575
+ projects: named,
576
+ more_projects: projRows.length - named.length,
577
+ };
578
+ }
579
+
580
+ // The PUBLIC read behind the 'platform-identity.hub' port (ADR 0171 D1/D2):
581
+ // null unless the account exists, consented (profile_state='active') and is not
582
+ // hidden (hide_stats) — the fail-closed rule every public surface inherits by
583
+ // construction. A null is deliberately indistinguishable between "no account",
584
+ // "provisional" and "hidden" (no partial numbers; a count plus a hidden total is
585
+ // a leak by subtraction).
586
+ async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
587
+ if (!isNumericGithubId(githubId)) return null;
588
+ const { rows } = await pool.query(
589
+ `SELECT profile_state, hide_stats FROM platform_identity_accounts WHERE github_id = $1`,
590
+ [githubId],
591
+ );
592
+ const acct = rows[0];
593
+ if (!acct || acct.profile_state !== 'active' || acct.hide_stats) return null;
594
+ return getRollupForBuilder(githubId, { pool });
595
+ }
596
+
597
+ // The SELF read (ADR 0171 D2's first narrow side door): a builder inspects their
598
+ // own numbers in Settings regardless of profile_state/hide_stats. Callers MUST
599
+ // key this by the authenticated session's github_id, never a caller-named one.
600
+ async function getOwnProfileExtras(githubId, { pool = defaultPool } = {}) {
601
+ return getRollupForBuilder(githubId, { pool });
602
+ }
603
+
466
604
  // -------------------------------------------------------------------------
467
605
  // Authorization codes (single-use, short-lived, bound)
468
606
  // -------------------------------------------------------------------------
@@ -548,15 +686,20 @@ module.exports = {
548
686
  // memberships
549
687
  MEMBERSHIP_ROLES,
550
688
  upsertMembership,
689
+ getMembership,
551
690
  listMembershipsForAccount,
552
691
  removeMembership,
553
692
  // invitations
554
693
  inviteToProject,
555
694
  listPendingInvitesForAccount,
556
695
  resolveInvite,
557
- // cross-project activity + scouting
696
+ // cross-project activity + scouting + profile rollup
558
697
  upsertActivity,
559
698
  listScoutingRollup,
699
+ isNumericGithubId,
700
+ getRollupForBuilder,
701
+ getPublicProfileExtras,
702
+ getOwnProfileExtras,
560
703
  // codes
561
704
  mintAuthCode,
562
705
  redeemAuthCode,
@@ -1,9 +1,10 @@
1
- // modules/platform-identity/routes/scouting.js — the INTERNAL, Archon-only
2
- // cross-project scouting view (ADR 0141 §4, task #1002114). Ranks builders across
3
- // ALL hosted projects from platform_identity_builder_project_activity. It is a
4
- // talent tool for the platform operator — NOT shown to builders, and NOT authority
5
- // (the rollup is descriptive; authority stays local, ADR 0141 / ADR 0016). Consent
6
- // for cross-project aggregation is handled by the platform T&C (out of scope here).
1
+ // modules/platform-identity/routes/scouting.js — the INTERNAL cross-project
2
+ // scouting view (ADR 0141 §4, task #1002114; Metic floor per ADR 0157). Ranks
3
+ // builders across ALL hosted projects from platform_identity_builder_project_activity.
4
+ // A talent tool for the platform operator — NOT authority (the rollup is
5
+ // descriptive; authority stays local, ADR 0141 / ADR 0016). Consent is enforced
6
+ // IN listScoutingRollup itself (ADR 0171 D2/D3): only profile_state='active',
7
+ // non-hidden builders appear, so this surface honors hide by construction.
7
8
  //
8
9
  // Reaches core only through the doorway (ADR 0083).
9
10
  'use strict';
@@ -94,7 +94,26 @@ async function fanOutBackchannelLogout(githubId) {
94
94
  }
95
95
  }
96
96
 
97
+ // Register the `platform-identity.hub` kernel PORT (ADR 0171 D1/D2) — the
98
+ // declared-but-previously-unwired port from module.json `provides`. The core
99
+ // profile route (src/bongos/routes/builders.js) resolves it beside 'reward' to
100
+ // attach the cross_project section; on any instance without this module the
101
+ // resolve returns null and the section is absent (degrade, don't 500). Shapes
102
+ // are whitelisted INSIDE the module (client_id never crosses the port).
103
+ // Registered from the route factory (the one boot hook that runs both live AND
104
+ // when a test rebuilds the GDS router), hasProvider-guarded so re-instantiation
105
+ // never trips the single-provider guard — the builder-settings pattern.
106
+ function registerSeams() {
107
+ if (!api.hasProvider('platform-identity.hub')) {
108
+ api.registerProvider('platform-identity.hub', {
109
+ getPublicProfileExtras: (githubId) => pi.getPublicProfileExtras(githubId, { pool }),
110
+ getOwnProfileExtras: (githubId) => pi.getOwnProfileExtras(githubId, { pool }),
111
+ });
112
+ }
113
+ }
114
+
97
115
  module.exports = function ssoRoutes() {
116
+ registerSeams();
98
117
  const router = express.Router();
99
118
 
100
119
  // Wire the federated single-logout fan-out to core's `builder.logout` event,
@@ -170,12 +189,16 @@ module.exports = function ssoRoutes() {
170
189
 
171
190
  // A live hub session — record/refresh the platform account and mint a
172
191
  // single-use code bound to (client_id, github_id, redirect_uri).
192
+ // `activate: true` — this is THE consent upgrade path (ADR 0171 D3): a
193
+ // hub-session authorize is the one place a human is demonstrably at the hub,
194
+ // so the account's profile_state lazily flips provisional → active here.
173
195
  try {
174
196
  await pi.upsertAccount({
175
197
  githubId: session.github_id,
176
198
  githubLogin: session.github_login,
177
199
  displayName: session.display_name,
178
200
  avatarUrl: session.avatar_url,
201
+ activate: true,
179
202
  }, { pool });
180
203
  const code = await pi.mintAuthCode({
181
204
  clientId,
@@ -382,6 +405,21 @@ module.exports = function ssoRoutes() {
382
405
  if (!client || client.status !== 'active' || !pi.clientSecretValid(client, b.client_secret)) {
383
406
  return res.fail('invalid_client', 401);
384
407
  }
408
+ // Impersonation hardening (ADR 0171 D7) — this data now feeds a PUBLIC
409
+ // portfolio, so a client may only report activity for a builder who
410
+ // actually holds a membership on that project. Non-numeric github_id is
411
+ // rejected before it can hit the bigint column; a 'pending' row is an
412
+ // invite pre-seed, not a membership (listMembershipsForAccount excludes
413
+ // it), so it does not satisfy the requirement. The instance sequences its
414
+ // check-in BEFORE this rollup (src/bongos/auth.js), so the legit first
415
+ // sign-in always has its membership row by the time this arrives.
416
+ if (!pi.isNumericGithubId(b.github_id)) {
417
+ return res.fail('invalid_github_id', 400);
418
+ }
419
+ const membership = await pi.getMembership(b.client_id, b.github_id, { pool });
420
+ if (!membership || membership.role === 'pending') {
421
+ return res.fail('membership_required', 409);
422
+ }
385
423
  await pi.upsertActivity({
386
424
  clientId: b.client_id,
387
425
  githubId: b.github_id,
@@ -128,8 +128,8 @@ t('a service principal (system_role set) may NOT federate', () => {
128
128
 
129
129
  console.log('\naccounts:');
130
130
 
131
- await ta('upsertAccount upserts on github_id and returns the row', async () => {
132
- const row = { id: '1', github_id: '42', github_login: 'octocat', display_name: 'Octo', avatar_url: 'a' };
131
+ await ta('upsertAccount upserts on github_id and returns the row (default: NOT activating)', async () => {
132
+ const row = { id: '1', github_id: '42', github_login: 'octocat', display_name: 'Octo', avatar_url: 'a', profile_state: 'provisional' };
133
133
  const pool = mockPool([{ rows: [row] }]);
134
134
  const out = await P.upsertAccount(
135
135
  { githubId: 42, githubLogin: 'octocat', displayName: 'Octo', avatarUrl: 'a' }, { pool });
@@ -137,7 +137,20 @@ await ta('upsertAccount upserts on github_id and returns the row', async () => {
137
137
  const c = pool.calls[0];
138
138
  assert.match(c.text, /INSERT INTO platform_identity_accounts/);
139
139
  assert.match(c.text, /ON CONFLICT \(github_id\) DO UPDATE/);
140
- assert.deepEqual(c.params, [42, 'octocat', 'Octo', 'a']);
140
+ assert.deepEqual(c.params, [42, 'octocat', 'Octo', 'a', false]);
141
+ });
142
+
143
+ await ta('upsertAccount consent upgrade is one-way (ADR 0171 D3): activate flips to active, default preserves the stored state', async () => {
144
+ const pool = mockPool([{ rows: [{ id: '1' }] }, { rows: [{ id: '1' }] }]);
145
+ await P.upsertAccount({ githubId: 42, githubLogin: 'octocat', activate: true }, { pool });
146
+ await P.upsertAccount({ githubId: 42, githubLogin: 'octocat' }, { pool });
147
+ const sql = pool.calls[0].text;
148
+ // INSERT: activate decides active vs the fail-closed provisional default.
149
+ assert.match(sql, /CASE WHEN \$5::boolean THEN 'active' ELSE 'provisional' END/);
150
+ // UPDATE: a non-activating upsert must KEEP the stored profile_state (never demote).
151
+ assert.match(sql, /CASE WHEN \$5::boolean THEN 'active' ELSE platform_identity_accounts\.profile_state END/);
152
+ assert.equal(pool.calls[0].params[4], true, 'activate:true passes true');
153
+ assert.equal(pool.calls[1].params[4], false, 'default passes false');
141
154
  });
142
155
 
143
156
  console.log('\nSSO client registry:');
@@ -334,6 +347,44 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
334
347
  assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer']]);
335
348
  });
336
349
 
350
+ await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {
351
+ const pool = mockPool([{ rows: [{ id: '1' }] }]);
352
+ await P.upsertActivity({
353
+ clientId: 'c1', githubId: 42,
354
+ rank: 'Supreme Overlord', // not one of the four core rank keys → stored null
355
+ disciplines: [
356
+ 'engineer', 42, null, { evil: true }, // non-strings dropped
357
+ 'x'.repeat(33), // over-length dropped
358
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', // valid tail — capped to 8 total
359
+ ],
360
+ }, { pool });
361
+ const c = pool.calls[0];
362
+ assert.equal(c.params[5], null, 'an invented rank label is never stored');
363
+ const disc = c.params[6];
364
+ assert.ok(disc.length <= 8, `disciplines capped at 8 (got ${disc.length})`);
365
+ assert.ok(disc.every((d) => typeof d === 'string' && d.length > 0 && d.length <= 32),
366
+ 'only bounded string elements survive');
367
+ assert.equal(disc[0], 'engineer', 'valid elements pass through in order');
368
+ });
369
+
370
+ await ta('upsertActivity keeps a legitimate core rank key', async () => {
371
+ const pool = mockPool([{ rows: [{ id: '1' }] }]);
372
+ await P.upsertActivity({ clientId: 'c1', githubId: 42, rank: 'archon', disciplines: [] }, { pool });
373
+ assert.equal(pool.calls[0].params[5], 'archon');
374
+ });
375
+
376
+ await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
377
+ const row = { id: '9', client_id: 'c1', github_id: '42', role: 'builder' };
378
+ const pool = mockPool([{ rows: [row] }]);
379
+ const out = await P.getMembership('c1', 42, { pool });
380
+ assert.deepEqual(out, row);
381
+ const c = pool.calls[0];
382
+ assert.match(c.text, /FROM platform_identity_project_memberships/);
383
+ assert.deepEqual(c.params, ['c1', 42]);
384
+ const miss = await P.getMembership('c1', 43, { pool });
385
+ assert.equal(miss, null);
386
+ });
387
+
337
388
  await ta('listScoutingRollup aggregates per builder across projects, ranked, joined to accounts', async () => {
338
389
  const pool = mockPool([{ rows: [{ github_id: '42', github_login: 'octo', total_credits: 250, project_count: 2 }] }]);
339
390
  const out = await P.listScoutingRollup({ pool, limit: 50 });
@@ -363,6 +414,89 @@ await ta('listScoutingRollup scalar aggregation is fan-out-free (task 1002911)',
363
414
  assert.match(discCte, /array_agg\(DISTINCT x\.d\)/, 'disciplines are deduped across projects');
364
415
  });
365
416
 
417
+ await ta('listScoutingRollup enforces consent IN the query (ADR 0171 D2/D3)', async () => {
418
+ // /people and /scouting must honor hide BY CONSTRUCTION: the predicate lives
419
+ // in the scalar-aggregation CTE, not in any caller. Semantics proven against
420
+ // real SQL by tests/profile_rollup_consent.mjs.
421
+ const pool = mockPool([{ rows: [] }]);
422
+ await P.listScoutingRollup({ pool, limit: 10 });
423
+ const aggCte = pool.calls[0].text.slice(0, pool.calls[0].text.indexOf('disc AS'));
424
+ assert.match(aggCte, /profile_state = 'active'/, 'provisional accounts drop out');
425
+ assert.match(aggCte, /NOT pa\.hide_stats/, 'hidden builders drop out');
426
+ });
427
+
428
+ console.log('\nprofile rollup port (ADR 0171, task 1002278):');
429
+
430
+ await ta('getRollupForBuilder guards non-numeric github_id BEFORE any query (no bigint cast error)', async () => {
431
+ const pool = mockPool([]);
432
+ for (const bad of ['system:bfg', 'system:grader', '', null, undefined, '42abc']) {
433
+ assert.equal(await P.getRollupForBuilder(bad, { pool }), null, `"${bad}" → null`);
434
+ }
435
+ assert.equal(pool.calls.length, 0, 'no query is ever issued for a non-numeric id');
436
+ });
437
+
438
+ await ta('getRollupForBuilder aggregates un-fanned + names only publicly-catalogued projects', async () => {
439
+ const pool = mockPool([
440
+ { rows: [{ projects: 2, credits: 150, works_shipped: 7, karma: 4, last_active: 'T', disciplines: ['engineer', 'ui'] }] },
441
+ { rows: [
442
+ { name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic', is_public: true },
443
+ { name: 'Secret Proj', origin: 'https://sec.example', rank_held: 'archon', is_public: false },
444
+ ] },
445
+ ]);
446
+ const out = await P.getRollupForBuilder(42, { pool });
447
+ assert.deepEqual(out.totals, { projects: 2, credits: 150, works_shipped: 7, karma: 4 });
448
+ assert.deepEqual(out.disciplines, ['engineer', 'ui']);
449
+ assert.equal(out.projects.length, 1, 'only the public catalog row is named');
450
+ assert.deepEqual(out.projects[0], { name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic' });
451
+ assert.equal(out.more_projects, 1, 'the non-public project is counted, never named');
452
+ // The whitelist holds structurally: client_id never appears in the shape.
453
+ assert.ok(!JSON.stringify(out).includes('client_id'), 'client_id must not cross the port');
454
+ // The scalar aggregation must be un-fanned (the 1002911 rule), disciplines separate.
455
+ const aggSql = pool.calls[0].text;
456
+ const aggCte = aggSql.slice(aggSql.indexOf('WITH agg AS'), aggSql.indexOf('disc AS'));
457
+ assert.doesNotMatch(aggCte, /unnest/i, 'scalar CTE must not fan rows out');
458
+ assert.match(aggSql.slice(aggSql.indexOf('disc AS')), /array_agg\(DISTINCT x\.d\)/);
459
+ });
460
+
461
+ await ta('getPublicProfileExtras is fail-closed: null for missing / provisional / hidden accounts', async () => {
462
+ // No account row at all.
463
+ assert.equal(await P.getPublicProfileExtras(42, { pool: mockPool([{ rows: [] }]) }), null);
464
+ // Provisional (no hub-session consent yet).
465
+ assert.equal(await P.getPublicProfileExtras(42, {
466
+ pool: mockPool([{ rows: [{ profile_state: 'provisional', hide_stats: false }] }]) }), null);
467
+ // Active but hidden.
468
+ assert.equal(await P.getPublicProfileExtras(42, {
469
+ pool: mockPool([{ rows: [{ profile_state: 'active', hide_stats: true }] }]) }), null);
470
+ // Non-numeric principal — null before any query.
471
+ const silent = mockPool([]);
472
+ assert.equal(await P.getPublicProfileExtras('system:bfg', { pool: silent }), null);
473
+ assert.equal(silent.calls.length, 0);
474
+ });
475
+
476
+ await ta('getPublicProfileExtras returns the rollup for an active, visible account', async () => {
477
+ const pool = mockPool([
478
+ { rows: [{ profile_state: 'active', hide_stats: false }] },
479
+ { rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
480
+ { rows: [] },
481
+ ]);
482
+ const out = await P.getPublicProfileExtras(42, { pool });
483
+ assert.deepEqual(out.totals, { projects: 1, credits: 10, works_shipped: 1, karma: 0 });
484
+ assert.deepEqual(out.disciplines, [], 'null disciplines surface as an empty array');
485
+ assert.deepEqual(out.projects, []);
486
+ assert.equal(out.more_projects, 0);
487
+ });
488
+
489
+ await ta('getOwnProfileExtras serves the self view regardless of flags (no flags lookup)', async () => {
490
+ const pool = mockPool([
491
+ { rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
492
+ { rows: [] },
493
+ ]);
494
+ const out = await P.getOwnProfileExtras(42, { pool });
495
+ assert.ok(out && out.totals, 'self view returns the rollup');
496
+ assert.ok(!pool.calls.some((c) => /profile_state/.test(c.text)),
497
+ 'the self read never consults profile_state/hide_stats');
498
+ });
499
+
366
500
  console.log('\nauthorization codes:');
367
501
 
368
502
  await ta('mintAuthCode stores the HASH of the code (never the plaintext) + binds it', async () => {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.167",
3
+ "version": "1.19.169",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.167",
9
+ "version": "1.19.169",
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.167",
3
+ "version": "1.19.169",
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
@@ -761,8 +761,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
761
761
  // landed since 1.19.165 with no explicit bump. run 31961272140. (task 1002620)
762
762
  // 1.19.167 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
763
763
  // landed since 1.19.166 with no explicit bump. run 31961815831. (task 1002620)
764
+ // 1.19.168 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
764
768
  // ---------------------------------------------------------------------------
765
- const CORE_VERSION = '1.19.167'; // CI auto-patch carrier (ADR 0161); see changelog above
769
+ const CORE_VERSION = '1.19.169'; // CI auto-patch carrier (ADR 0161); see changelog above
766
770
 
767
771
  // A namespaced logger so a module's log lines are attributable + consistent.
768
772
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,162 @@
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
+ // 5 — rollup payload sanitization against the real columns (D7).
146
+ await pi.upsertActivity({
147
+ clientId: PUB_CLIENT, githubId: LAZY, credits: 1,
148
+ rank: 'Supreme Overlord',
149
+ disciplines: ['engineer', 42, 'x'.repeat(33), 'a', 'b', 'c', 'd', 'e', 'f', 'g'],
150
+ }, { pool });
151
+ const { rows: [act] } = await q(
152
+ `SELECT rank_held, disciplines FROM platform_identity_builder_project_activity
153
+ WHERE client_id = $1 AND github_id = $2`, [PUB_CLIENT, LAZY]);
154
+ ok(act.rank_held === null, 'an off-ladder rank is stored as NULL');
155
+ ok(act.disciplines.length <= 8 && act.disciplines.every((d) => d.length <= 32),
156
+ 'disciplines land validated + capped');
157
+
158
+ console.log(`\nprofile_rollup_consent.mjs: ${pass} passed`);
159
+ } finally {
160
+ await cleanup();
161
+ await pool.end().catch(() => {});
162
+ }
@@ -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