@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.
@@ -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;
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Cloud Bongos maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["sso", "projects", "my-projects", "scouting"],
13
+ "routes": ["sso", "projects", "my-projects", "scouting", "profile"],
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep"]
16
16
  },
@@ -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,120 @@ 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
+
604
+ // The C2 hide-stats WRITE (ADR 0171 D4, task 1002279): own-scoped — callers key
605
+ // this by the AUTHENTICATED session's github_id. Upserts so a hub builder who
606
+ // never federated (no account row yet) can still hide ahead of time: the insert
607
+ // creates a fail-closed provisional account already carrying the flag; the
608
+ // UPDATE branch touches ONLY hide_stats (never identity fields, never
609
+ // profile_state — activation stays /sso/authorize's alone, D3).
610
+ async function setAccountHideStats({ githubId, githubLogin, displayName, avatarUrl, hide }, { pool = defaultPool } = {}) {
611
+ if (!isNumericGithubId(githubId)) return null;
612
+ const { rows } = await pool.query(
613
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, hide_stats)
614
+ VALUES ($1, $2, $3, $4, $5)
615
+ ON CONFLICT (github_id) DO UPDATE SET
616
+ hide_stats = EXCLUDED.hide_stats,
617
+ updated_at = now()
618
+ RETURNING github_id, github_login, profile_state, hide_stats`,
619
+ [githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!hide],
620
+ );
621
+ return rows[0];
622
+ }
623
+
466
624
  // -------------------------------------------------------------------------
467
625
  // Authorization codes (single-use, short-lived, bound)
468
626
  // -------------------------------------------------------------------------
@@ -548,15 +706,21 @@ module.exports = {
548
706
  // memberships
549
707
  MEMBERSHIP_ROLES,
550
708
  upsertMembership,
709
+ getMembership,
551
710
  listMembershipsForAccount,
552
711
  removeMembership,
553
712
  // invitations
554
713
  inviteToProject,
555
714
  listPendingInvitesForAccount,
556
715
  resolveInvite,
557
- // cross-project activity + scouting
716
+ // cross-project activity + scouting + profile rollup
558
717
  upsertActivity,
559
718
  listScoutingRollup,
719
+ isNumericGithubId,
720
+ getRollupForBuilder,
721
+ getPublicProfileExtras,
722
+ getOwnProfileExtras,
723
+ setAccountHideStats,
560
724
  // codes
561
725
  mintAuthCode,
562
726
  redeemAuthCode,
@@ -0,0 +1,77 @@
1
+ // modules/platform-identity/routes/profile.js — the builder's OWN cross-project
2
+ // profile controls (ADR 0171 D4, task 1002279 / goal 1000045). Two own-scoped
3
+ // endpoints behind a live session; both key on req.builder.github_id — never a
4
+ // caller-named id — so a builder can only ever read or flip their own flag.
5
+ //
6
+ // GET /me/cross-project — the Settings self view: the hide flag + consent
7
+ // state + the builder's own rollup, served regardless
8
+ // of flags (getOwnProfileExtras — a hidden builder
9
+ // inspects their numbers HERE, not on the public
10
+ // route, which stays viewer-independent).
11
+ // PATCH /me/cross-project — flip hide_stats. Takes effect immediately on every
12
+ // public surface (profile cross_project, /people,
13
+ // /scouting): enforcement lives in the module's read
14
+ // functions (D2) and the hub profile response is
15
+ // no-store (D5), so there is no cache to outwait.
16
+ //
17
+ // Reaches core only through the doorway (ADR 0083).
18
+ 'use strict';
19
+
20
+ const express = require('express');
21
+ const api = require('../../../src/module-api');
22
+ const pi = require('../platform-identity');
23
+
24
+ const { pool, requireBuilder, validateOrRespond } = api;
25
+
26
+ module.exports = function profileRoutes() {
27
+ const router = express.Router();
28
+
29
+ // rank: any-builder — the caller's OWN cross-project record + hide flag (own data).
30
+ router.get('/me/cross-project', requireBuilder, async (req, res) => {
31
+ try {
32
+ // Core builders include system:* principals whose github_id is not numeric;
33
+ // they have no platform identity — answer the empty shape, not a 500.
34
+ if (!pi.isNumericGithubId(req.builder.github_id)) {
35
+ return res.json({ hide_stats: false, profile_state: null, cross_project: null });
36
+ }
37
+ const [acct, extras] = await Promise.all([
38
+ pi.getAccount(req.builder.github_id, { pool }),
39
+ pi.getOwnProfileExtras(req.builder.github_id, { pool }),
40
+ ]);
41
+ res.json({
42
+ hide_stats: !!(acct && acct.hide_stats),
43
+ profile_state: acct ? acct.profile_state : null,
44
+ cross_project: extras,
45
+ });
46
+ } catch (err) {
47
+ console.error('[platform-identity] GET /me/cross-project', err);
48
+ res.fail('cross_project_read_failed', { status: 500, message: 'internal error' });
49
+ }
50
+ });
51
+
52
+ // rank: any-builder — flip the caller's OWN hide flag (C2's self-serve opt-out;
53
+ // own data, keyed on the authenticated github_id).
54
+ router.patch('/me/cross-project', requireBuilder, async (req, res) => {
55
+ if (validateOrRespond(req, res, {
56
+ hide_stats: { required: true, type: 'boolean' },
57
+ })) return;
58
+ try {
59
+ if (!pi.isNumericGithubId(req.builder.github_id)) {
60
+ return res.fail('no_platform_identity', 400);
61
+ }
62
+ const row = await pi.setAccountHideStats({
63
+ githubId: req.builder.github_id,
64
+ githubLogin: req.builder.github_login,
65
+ displayName: req.builder.display_name,
66
+ avatarUrl: req.builder.avatar_url,
67
+ hide: req.body.hide_stats,
68
+ }, { pool });
69
+ res.json({ hide_stats: !!(row && row.hide_stats) });
70
+ } catch (err) {
71
+ console.error('[platform-identity] PATCH /me/cross-project', err);
72
+ res.fail('cross_project_write_failed', { status: 500, message: 'internal error' });
73
+ }
74
+ });
75
+
76
+ return router;
77
+ };
@@ -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,111 @@ 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('setAccountHideStats flips ONLY the hide flag (never identity, never profile_state)', async () => {
490
+ const pool = mockPool([{ rows: [{ github_id: '42', hide_stats: true, profile_state: 'provisional' }] }]);
491
+ const out = await P.setAccountHideStats(
492
+ { githubId: 42, githubLogin: 'octo', displayName: 'Octo', avatarUrl: 'a', hide: true }, { pool });
493
+ assert.equal(out.hide_stats, true);
494
+ const c = pool.calls[0];
495
+ assert.match(c.text, /ON CONFLICT \(github_id\) DO UPDATE/);
496
+ // The UPDATE branch may set hide_stats + updated_at and NOTHING else — a
497
+ // settings toggle is not an identity refresh and must never touch consent.
498
+ const updateBranch = c.text.slice(c.text.indexOf('DO UPDATE'), c.text.indexOf('RETURNING'));
499
+ assert.match(updateBranch, /hide_stats = EXCLUDED\.hide_stats/);
500
+ assert.doesNotMatch(updateBranch, /github_login|display_name|avatar_url|profile_state/,
501
+ 'the update branch must not clobber identity or consent');
502
+ assert.equal(c.params[4], true, 'hide passed as a boolean');
503
+ });
504
+
505
+ await ta('setAccountHideStats guards non-numeric github_id (system:* principals) → null, no query', async () => {
506
+ const pool = mockPool([]);
507
+ assert.equal(await P.setAccountHideStats({ githubId: 'system:bfg', hide: true }, { pool }), null);
508
+ assert.equal(pool.calls.length, 0);
509
+ });
510
+
511
+ await ta('getOwnProfileExtras serves the self view regardless of flags (no flags lookup)', async () => {
512
+ const pool = mockPool([
513
+ { rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
514
+ { rows: [] },
515
+ ]);
516
+ const out = await P.getOwnProfileExtras(42, { pool });
517
+ assert.ok(out && out.totals, 'self view returns the rollup');
518
+ assert.ok(!pool.calls.some((c) => /profile_state/.test(c.text)),
519
+ 'the self read never consults profile_state/hide_stats');
520
+ });
521
+
366
522
  console.log('\nauthorization codes:');
367
523
 
368
524
  await ta('mintAuthCode stores the HASH of the code (never the plaintext) + binds it', async () => {