@bongos/core 1.19.411 → 1.19.413

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,299 @@
1
+ // tests/account_privacy_flags.mjs — account_visibility + recruiter_discoverable
2
+ // (task 1002966, goal 1000045; docs/specs/bongos-v1-privacy-and-invites.md D1/D3).
3
+ //
4
+ // These two flags are the keystone of the privacy model — task 1002967 (the
5
+ // shared effective-visibility predicate), task 1002968 (recruiter-sliver
6
+ // gating), task 1002806 (the private-profile 404 proof) and task 1002290
7
+ // (opt out of recruiting) all read them — so the MODEL is what this pins, not
8
+ // the plumbing:
9
+ //
10
+ // 1. the migration's stored shape: visibility NOT NULL DEFAULT 'public' with a
11
+ // two-value CHECK, discoverable NULLABLE (the tri-state is deliberate);
12
+ // 2. isRecruiterDiscoverable() — the ONE predicate, the JS twin of
13
+ // COALESCE(recruiter_discoverable, account_visibility = 'public'): a fresh
14
+ // account is public + discoverable, private flips the effective answer
15
+ // while the column stays NULL, and an explicit boolean beats both (the
16
+ // spec's D2 "private · discoverable" row is allowed, not refused);
17
+ // 3. the own-scoped write: only the flags sent are written, never identity,
18
+ // never consent, never hide_stats — and the account is always the SESSION's
19
+ // (a body can neither name nor reach another one);
20
+ // 4. the leak rule: neither column may appear in a public response body.
21
+ //
22
+ // Pure — a recording fake pool, no Postgres, no network. Runs in the DB-free
23
+ // unit gate.
24
+ //
25
+ // Run: node tests/account_privacy_flags.mjs
26
+
27
+ import { strict as assert } from 'node:assert';
28
+ import { test, after } from 'node:test';
29
+ import { createRequire } from 'node:module';
30
+ import http from 'node:http';
31
+ import { readFileSync } from 'node:fs';
32
+ import { fileURLToPath } from 'node:url';
33
+ import { dirname, join } from 'node:path';
34
+
35
+ const require = createRequire(import.meta.url);
36
+ const express = require('express');
37
+
38
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
39
+ const MIGRATION = readFileSync(
40
+ join(ROOT, 'modules/platform-identity/migrations/platform_identity_012_account_privacy.sql'), 'utf8');
41
+
42
+ // ---- the recording fake pool ----------------------------------------------
43
+ //
44
+ // One shared pool object so the route's `pool` (pinned into module-api below,
45
+ // resolved once at require time) and each test's queue are the same thing.
46
+ let queued = [];
47
+ let calls = [];
48
+ const pool = {
49
+ query: async (text, params) => {
50
+ calls.push({ text: String(text), params: params || [] });
51
+ return queued.shift() || { rows: [], rowCount: 0 };
52
+ },
53
+ };
54
+ function expect(...responses) { queued = responses; calls = []; }
55
+
56
+ // ---- the route over the real domain, with core pinned ----------------------
57
+
58
+ const SESSION = { github_id: 4242, github_login: 'octo', display_name: 'Octo', avatar_url: 'https://a.example/o.png' };
59
+ const OTHER_ACCOUNT = 9999;
60
+
61
+ const moduleApiPath = require.resolve('../src/module-api.js');
62
+ const helpers = require('../src/bongos/routes/_helpers.js');
63
+ require.cache[moduleApiPath] = {
64
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
65
+ exports: {
66
+ pool,
67
+ validateOrRespond: helpers.validateOrRespond,
68
+ // The own-scope stub: a live session, exactly as requireBuilder leaves it.
69
+ requireBuilder: (req, _res, next) => { req.builder = { ...SESSION }; next(); },
70
+ },
71
+ };
72
+ const pi = require('../modules/platform-identity/platform-identity.js');
73
+ const buildProfileRoutes = require('../modules/platform-identity/routes/profile.js');
74
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
75
+
76
+ const app = express();
77
+ app.use(express.json());
78
+ app.use(attachFail);
79
+ app.use('/api/bongos', buildProfileRoutes());
80
+ const server = app.listen(0, '127.0.0.1');
81
+ await new Promise((r) => server.once('listening', r));
82
+ const port = server.address().port;
83
+ after(() => server.close());
84
+
85
+ function call(method, path, body) {
86
+ return new Promise((resolve, reject) => {
87
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
88
+ const req = http.request({
89
+ host: '127.0.0.1', port, method, path,
90
+ headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
91
+ }, (res) => {
92
+ const chunks = [];
93
+ res.on('data', (c) => chunks.push(c));
94
+ res.on('end', () => {
95
+ const text = Buffer.concat(chunks).toString('utf8');
96
+ let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
97
+ resolve({ status: res.statusCode, json, text });
98
+ });
99
+ });
100
+ req.on('error', reject);
101
+ req.end(payload);
102
+ });
103
+ }
104
+
105
+ // A stored row as the module's RETURNING clause hands it back.
106
+ const row = (over = {}) => ({
107
+ github_id: String(SESSION.github_id), github_login: 'octo', profile_state: 'active',
108
+ hide_stats: false, account_visibility: 'public', recruiter_discoverable: null,
109
+ handle: 'octo', ...over,
110
+ });
111
+
112
+ // ---- 1. the stored shape ---------------------------------------------------
113
+
114
+ test('migration 012 stores visibility NOT NULL DEFAULT public and discoverable NULLABLE', () => {
115
+ assert.match(MIGRATION, /ADD COLUMN IF NOT EXISTS account_visibility text NOT NULL DEFAULT 'public'/,
116
+ "public is the default, and the DEFAULT clause IS the backfill — no existing profile changes state");
117
+ assert.match(MIGRATION, /ADD COLUMN IF NOT EXISTS recruiter_discoverable boolean;/,
118
+ 'recruiter_discoverable is a bare nullable boolean');
119
+ assert.doesNotMatch(MIGRATION, /recruiter_discoverable boolean NOT NULL/,
120
+ 'NOT NULL would destroy the tri-state: NULL is what "follow visibility" is made of');
121
+ assert.match(MIGRATION, /CHECK \(account_visibility IN \('public', 'private'\)\)/,
122
+ 'the engine refuses a third visibility value');
123
+ assert.match(MIGRATION, /ALTER TABLE platform_identity_accounts/, 'the flags live on the hub account (ADR 0171 D4 precedent)');
124
+ });
125
+
126
+ // ---- 2. the ONE predicate --------------------------------------------------
127
+
128
+ test('a fresh account is public AND discoverable — the opt-out half of D1', () => {
129
+ assert.equal(pi.isRecruiterDiscoverable(row()), true);
130
+ assert.equal(pi.isRecruiterDiscoverable(row({ recruiter_discoverable: false })), false,
131
+ 'a public account can leave recruiting and stay public');
132
+ });
133
+
134
+ test('private flips the effective answer to false while the column stays NULL', () => {
135
+ const acct = row({ account_visibility: 'private' });
136
+ assert.equal(acct.recruiter_discoverable, null, 'the stored flag is untouched — only the derived answer moves');
137
+ assert.equal(pi.isRecruiterDiscoverable(acct), false, 'recruiting is opt-IN for a private account');
138
+ });
139
+
140
+ test('an explicit true on a private account WINS (the spec D2 private-discoverable row)', () => {
141
+ assert.equal(pi.isRecruiterDiscoverable(row({ account_visibility: 'private', recruiter_discoverable: true })), true,
142
+ 'the spec allows this state — it is exactly what the D3 recruiter sliver serves');
143
+ });
144
+
145
+ test('isRecruiterDiscoverable is fail-closed on a missing account or a missing column', () => {
146
+ assert.equal(pi.isRecruiterDiscoverable(null), false);
147
+ assert.equal(pi.isRecruiterDiscoverable({}), false, 'no visibility column read → not discoverable');
148
+ assert.deepEqual(pi.ACCOUNT_VISIBILITY, ['public', 'private']);
149
+ });
150
+
151
+ // ---- 3. the own-scoped write -----------------------------------------------
152
+
153
+ test('setAccountPrivacy writes ONLY the flags sent — never identity, consent, or hide_stats', async () => {
154
+ expect({ rows: [row({ account_visibility: 'private' })] });
155
+ const out = await pi.setAccountPrivacy(
156
+ { githubId: SESSION.github_id, githubLogin: 'octo', fields: { account_visibility: 'private' } }, { pool });
157
+ assert.equal(out.account_visibility, 'private');
158
+ const sql = calls[0].text;
159
+ const updateBranch = sql.slice(sql.indexOf('DO UPDATE'), sql.indexOf('RETURNING'));
160
+ assert.match(updateBranch, /account_visibility\s+= CASE WHEN \$7::boolean THEN EXCLUDED\.account_visibility/);
161
+ assert.match(updateBranch, /recruiter_discoverable = CASE WHEN \$8::boolean THEN EXCLUDED\.recruiter_discoverable/);
162
+ assert.doesNotMatch(updateBranch, /github_login|display_name|avatar_url|profile_state|hide_stats/,
163
+ 'a privacy toggle is not an identity refresh, a consent grant, or a hide');
164
+ assert.equal(calls[0].params[6], true, 'visibility was sent');
165
+ assert.equal(calls[0].params[7], false, 'discoverable was not — its CASE arm keeps the stored value');
166
+ });
167
+
168
+ test('setAccountPrivacy accepts an explicit null discoverable — the reset to "follow visibility"', async () => {
169
+ expect({ rows: [row()] });
170
+ await pi.setAccountPrivacy(
171
+ { githubId: SESSION.github_id, fields: { recruiter_discoverable: null } }, { pool });
172
+ assert.equal(calls[0].params[5], null, 'null is written, not skipped');
173
+ assert.equal(calls[0].params[7], true, 'and it counts as sent');
174
+ });
175
+
176
+ test('setAccountPrivacy refuses a value outside the model, before any query', async () => {
177
+ expect();
178
+ assert.equal((await pi.setAccountPrivacy(
179
+ { githubId: SESSION.github_id, fields: { account_visibility: 'stealth' } }, { pool })).error,
180
+ 'invalid_account_visibility');
181
+ assert.equal((await pi.setAccountPrivacy(
182
+ { githubId: SESSION.github_id, fields: { recruiter_discoverable: 'yes' } }, { pool })).error,
183
+ 'invalid_recruiter_discoverable');
184
+ assert.equal((await pi.setAccountPrivacy({ githubId: SESSION.github_id, fields: {} }, { pool })).error, 'no_fields');
185
+ assert.equal((await pi.setAccountPrivacy(
186
+ { githubId: 'system:bfg', fields: { account_visibility: 'private' } }, { pool })).error, 'no_account');
187
+ assert.equal(calls.length, 0, 'a rejected write never reaches the database');
188
+ });
189
+
190
+ test('PATCH keys the write on the SESSION account — a body can neither name nor reach another one', async () => {
191
+ expect({ rows: [row({ account_visibility: 'private' })] });
192
+ const res = await call('PATCH', '/api/bongos/me/cross-project', { account_visibility: 'private' });
193
+ assert.equal(res.status, 200);
194
+ assert.equal(calls[0].params[0], SESSION.github_id, 'the write is keyed on the authenticated github_id');
195
+ assert.ok(!JSON.stringify(calls[0].params).includes(String(OTHER_ACCOUNT)));
196
+
197
+ expect({ rows: [row()] });
198
+ const named = await call('PATCH', '/api/bongos/me/cross-project',
199
+ { account_visibility: 'private', github_id: OTHER_ACCOUNT });
200
+ assert.equal(named.status, 400, 'a body that names an account is rejected outright (strict validation)');
201
+ assert.equal(named.json.error.code, 'validation_failed');
202
+ assert.equal(calls.length, 0, 'and nothing was written');
203
+ });
204
+
205
+ test('PATCH refuses a visibility outside the model and an empty body', async () => {
206
+ expect();
207
+ const bad = await call('PATCH', '/api/bongos/me/cross-project', { account_visibility: 'stealth' });
208
+ assert.equal(bad.status, 400);
209
+ assert.equal(bad.json.error.code, 'validation_failed');
210
+ const empty = await call('PATCH', '/api/bongos/me/cross-project', {});
211
+ assert.equal(empty.status, 400);
212
+ assert.equal(empty.json.error.code, 'no_fields');
213
+ assert.equal(calls.length, 0);
214
+ });
215
+
216
+ test('PATCH answers with the stored flags plus the ONE derived effective answer', async () => {
217
+ expect({ rows: [row({ account_visibility: 'private', recruiter_discoverable: true })] });
218
+ const res = await call('PATCH', '/api/bongos/me/cross-project', { recruiter_discoverable: true });
219
+ assert.deepEqual(res.json, {
220
+ hide_stats: false, account_visibility: 'private',
221
+ recruiter_discoverable: true, recruiter_discoverable_effective: true,
222
+ });
223
+ });
224
+
225
+ test('PATCH { hide_stats } alone still works and carries the privacy flags back', async () => {
226
+ expect({ rows: [row({ hide_stats: true })] });
227
+ const res = await call('PATCH', '/api/bongos/me/cross-project', { hide_stats: true });
228
+ assert.equal(res.status, 200);
229
+ assert.equal(res.json.hide_stats, true);
230
+ assert.equal(res.json.account_visibility, 'public');
231
+ assert.equal(res.json.recruiter_discoverable_effective, true);
232
+ assert.match(calls[0].text, /hide_stats/, 'the hide flag keeps its own writer (ADR 0171 D4)');
233
+ });
234
+
235
+ test('GET serves the own-scope view: both flags plus the effective answer', async () => {
236
+ expect(
237
+ { rows: [row({ account_visibility: 'private' })] },
238
+ { rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
239
+ { rows: [] },
240
+ );
241
+ const res = await call('GET', '/api/bongos/me/cross-project');
242
+ assert.equal(res.status, 200);
243
+ assert.equal(res.json.account_visibility, 'private');
244
+ assert.equal(res.json.recruiter_discoverable, null);
245
+ assert.equal(res.json.recruiter_discoverable_effective, false, 'derived server-side, so no client re-derives it');
246
+ });
247
+
248
+ // ---- 4. the leak rule ------------------------------------------------------
249
+
250
+ test('neither flag crosses a PUBLIC response — the /u/<handle> read stays a whitelist', async () => {
251
+ // The stored row carries both flags AND a sentinel private value; the public
252
+ // shape must be built from the whitelist, not spread from the row.
253
+ expect(
254
+ { rows: [{
255
+ github_id: '4242', github_login: 'octo', display_name: 'Octo', avatar_url: null,
256
+ handle: 'octo', bio: null, links: null, created_at: 'T', is_current: true,
257
+ account_visibility: 'private', recruiter_discoverable: true, profile_state: 'active', hide_stats: false,
258
+ }] },
259
+ { rows: [{ profile_state: 'active', hide_stats: false }] },
260
+ { rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
261
+ { rows: [] },
262
+ );
263
+ const out = await pi.getPublicProfileByHandle('octo', { pool });
264
+ const text = JSON.stringify(out);
265
+ for (const leak of ['account_visibility', 'recruiter_discoverable', 'profile_state', 'hide_stats', 'github_id']) {
266
+ assert.ok(!text.includes(leak), `${leak} must never serialize into a public body`);
267
+ }
268
+ assert.deepEqual(Object.keys(out.account).sort(),
269
+ ['avatar_url', 'bio', 'display_name', 'github_login', 'handle', 'links', 'member_since']);
270
+ });
271
+
272
+ // ---- 5. the settings copy states the grant ---------------------------------
273
+
274
+ test('the discoverable toggle names EXACTLY what it grants — the D3 sliver, name-only when hidden', () => {
275
+ const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
276
+ const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
277
+ assert.match(js, /your name, your disciplines, and whether you have been active in the last 30 days/,
278
+ 'the sliver is name + disciplines + a coarse activity signal — spelled out, not implied');
279
+ assert.match(js, /never your credits, works shipped, karma, or the projects you build on/,
280
+ 'and the copy says what it does NOT grant');
281
+ assert.match(js, /cross-project record is hidden, they will see your name and nothing else/,
282
+ 'hide_stats shrinks the grant to name-only, because the rest is rollup-derived');
283
+ assert.match(html, /<section[^>]*id="privacy-scroll"[^>]*hidden>/,
284
+ 'the section ships hidden — it reveals only where the hub module answers');
285
+ });
286
+
287
+ test('no toggle claims a protection this task has not built — the read paths are still open', () => {
288
+ const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
289
+ const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
290
+ // Storing a flag is not enforcing it (task 1002967 wires the read paths). A
291
+ // builder flipping this for personal safety must not be told they are hidden
292
+ // while GET /profiles/:handle still serves the page.
293
+ assert.match(html, /saved to your account now\. They start applying as[\s\S]*nothing is hidden yet/,
294
+ 'the section states plainly that the choice is recorded, not yet in force');
295
+ assert.doesNotMatch(js, /Private hides the page|is private — hidden from everyone/,
296
+ 'no present-tense promise that the page is already hidden');
297
+ assert.match(js, /Private will hide the page from everyone but you/,
298
+ 'the row says what the choice WILL do');
299
+ });
@@ -11,14 +11,38 @@
11
11
  // Stub auth + db + the lifecycle/reward ports (no network, no Postgres) exactly
12
12
  // as tests/onboarding_route_signals.mjs does for the sibling public route.
13
13
  //
14
+ // R06 (task 1002282) extends it with the consent PROOF — the five claims goal
15
+ // 1000045's C2 rests on, all DB-free so the `unit` gate actually runs them:
16
+ // 1. a two-project builder's COMBINED record is served to another account
17
+ // (the route is unauthenticated and viewer-independent, ADR 0171 D4);
18
+ // 2. flipping hide_stats removes it on the IMMEDIATE next load, and the
19
+ // response is `Cache-Control: no-store` — the old `public, max-age=60`
20
+ // would keep serving the pre-hide body for a minute (ADR 0171 D5);
21
+ // 3. `/scouting` (the endpoint the hall's /people page renders) drops the
22
+ // hidden builder too — enforcement is on every read path, not the profile;
23
+ // 4. the leak guard BITES: planting a private field turns the key-list and
24
+ // sentinel assertions red (a leak test that cannot fail is not a test);
25
+ // 5. no PARTIAL numbers survive a hide — a count beside a hidden total is a
26
+ // leak by subtraction.
27
+ //
28
+ // RESIDUAL, stated honestly (goal 1000045 C2): hide removes the hub's
29
+ // AGGREGATED view, not the per-instance public numbers it is derived from.
30
+ // Every instance's own public surfaces (its /public/leaderboard) stay public by
31
+ // design, so a determined reader can still reconstruct a sum the hard way.
32
+ //
14
33
  // Run: node tests/profile_route.mjs
15
34
 
16
35
  import { strict as assert } from 'node:assert';
17
36
  import { createRequire } from 'node:module';
18
37
  import http from 'node:http';
38
+ import { readFileSync } from 'node:fs';
39
+ import { fileURLToPath } from 'node:url';
40
+ import { dirname, join } from 'node:path';
19
41
 
20
42
  const require = createRequire(import.meta.url);
21
43
  const express = require('express');
44
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
45
+ const sourceOf = (rel) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
22
46
 
23
47
  let passed = 0;
24
48
  let failed = 0;
@@ -90,7 +114,7 @@ const CROSS_PROJECT_ROWS = [
90
114
  // shape — named projects only where the catalog row is public; the rest counted.
91
115
  function stubPublicProfileExtras() {
92
116
  const named = CROSS_PROJECT_ROWS.filter((r) => r.is_public)
93
- .map((r) => ({ name: r.name, origin: r.origin, rank_held: r.rank_held }));
117
+ .map((r) => ({ name: r.name, origin: r.origin, rank_held: r.rank_held, recent_ships: [] }));
94
118
  return {
95
119
  totals: { projects: 2, credits: 150, works_shipped: 7, karma: 4 },
96
120
  disciplines: ['engineer', 'ui'],
@@ -101,7 +125,28 @@ function stubPublicProfileExtras() {
101
125
  }
102
126
  // The key contract for the cross_project object + its project rows.
103
127
  const CP_PUBLIC_KEYS = ['totals', 'disciplines', 'last_active', 'projects', 'more_projects'];
104
- const CP_PROJECT_ROW_KEYS = ['name', 'origin', 'rank_held'];
128
+ // recent_ships rides the project row (task 1002803) and is ALWAYS present —
129
+ // getRollupForBuilder defaults it to [] — so it belongs in the exact key list.
130
+ const CP_PROJECT_ROW_KEYS = ['name', 'origin', 'rank_held', 'recent_ships'];
131
+ // Every module-internal value that must never reach a public body.
132
+ const CP_SENTINELS = [SECRET_CLIENT_ID, SECRET_PROJECT_NAME, SECRET_EMAIL, SECRET_USER, SECRET_REASON];
133
+
134
+ // The cross_project whitelist contract, in ONE place — the positive tests and
135
+ // the R06 negative control (task 1002282) run the SAME guard, which is what
136
+ // makes "planting a private field turns it red" a proof rather than a claim.
137
+ // Asserts the exact key list on the object and on every project row, and that
138
+ // no sentinel (nor the literal `client_id` key) survives in the raw body.
139
+ function assertCrossProjectContract(cp, text) {
140
+ assert.deepEqual(Object.keys(cp).sort(), [...CP_PUBLIC_KEYS].sort(), 'cross_project keys are exactly the contract');
141
+ assert.deepEqual(Object.keys(cp.totals).sort(), ['credits', 'karma', 'projects', 'works_shipped']);
142
+ for (const row of cp.projects) {
143
+ assert.deepEqual(Object.keys(row).sort(), [...CP_PROJECT_ROW_KEYS].sort(), 'project rows carry only the whitelisted keys');
144
+ }
145
+ for (const secret of CP_SENTINELS) {
146
+ assert.ok(!text.includes(secret), `response body must not contain "${secret}"`);
147
+ }
148
+ assert.ok(!text.includes('client_id'), 'no client_id key may cross the port');
149
+ }
105
150
 
106
151
  // ---- stubs ----------------------------------------------------------------
107
152
 
@@ -161,9 +206,9 @@ function listen(app) {
161
206
  });
162
207
  }
163
208
 
164
- function get(port, path) {
209
+ function get(port, path, headers) {
165
210
  return new Promise((resolve, reject) => {
166
- const req = http.request({ host: '127.0.0.1', port, method: 'GET', path }, (res) => {
211
+ const req = http.request({ host: '127.0.0.1', port, method: 'GET', path, headers: headers || {} }, (res) => {
167
212
  const chunks = [];
168
213
  res.on('data', (c) => chunks.push(c));
169
214
  res.on('end', () => {
@@ -235,8 +280,12 @@ await t('hub port ABSENT → no cross_project key + the cacheable posture stays'
235
280
  });
236
281
 
237
282
  let hubExtras = stubPublicProfileExtras();
283
+ // The port's public read sits behind a swappable reference: a port is
284
+ // single-provider by design (registerProvider throws on a duplicate), and the
285
+ // R06 proofs below repoint this at the REAL module read over a fake engine.
286
+ let hubPublicRead = async () => hubExtras;
238
287
  seams.registerProvider('platform-identity.hub', {
239
- getPublicProfileExtras: async () => hubExtras,
288
+ getPublicProfileExtras: async (githubId) => hubPublicRead(githubId),
240
289
  getOwnProfileExtras: async () => stubPublicProfileExtras(),
241
290
  });
242
291
 
@@ -245,11 +294,7 @@ await t('hub port present → cross_project carries exactly the whitelist shape'
245
294
  assert.equal(r.status, 200);
246
295
  const cp = r.json.cross_project;
247
296
  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
- }
297
+ assertCrossProjectContract(cp, r.text);
253
298
  assert.equal(cp.projects.length, 1, 'only the public catalog row is named');
254
299
  assert.equal(cp.more_projects, 1, 'the non-public project is counted, never named');
255
300
  });
@@ -277,6 +322,241 @@ await t('hub port returns null (hidden/provisional/none) → cross_project: null
277
322
  assert.equal(r.headers['cache-control'], 'no-store');
278
323
  });
279
324
 
325
+ // ---- R06 PROOF: hide is honored on every read path (task 1002282) ---------
326
+ //
327
+ // Deps R03 (task 1002279 — the hide_stats flag) and R04 (task 1002280 — the
328
+ // profile UI section) shipped; this pins their behaviour so it cannot rot. The
329
+ // proofs below run the REAL module reads (getPublicProfileExtras,
330
+ // setAccountHideStats, listScoutingRollup) and the REAL routes over a fake
331
+ // engine, so what is proven is the COMPOSITION — route → port → module read →
332
+ // query — not a stub of the answer.
333
+
334
+ // A deliberately literal-minded stand-in for Postgres. It holds the fixture
335
+ // rows and applies ONLY the consent predicates the query it is handed actually
336
+ // declares. That is what makes the scouting proof real rather than circular:
337
+ // delete `NOT pa.hide_stats` from listScoutingRollup and the hidden builder
338
+ // walks straight into the response, turning these tests red. The SQL semantics
339
+ // themselves (the un-fanned sums) are proven against a live Postgres by
340
+ // tests/profile_rollup_consent.mjs and tests/scouting_rollup_fanout.mjs.
341
+ const HUB_GITHUB_ID = FULL_ROW.github_id;
342
+ const HIDDEN_LOGIN = 'hidden-DO-NOT-LEAK-r06';
343
+ const PROV_LOGIN = 'provisional-DO-NOT-LEAK-r06';
344
+
345
+ // TWO projects — one publicly catalogued, one not. Distinctive digits so the
346
+ // "no partial numbers" scan below cannot pass by coincidence.
347
+ const PROOF_PROJECTS = [
348
+ { name: 'R06 Public Proj', origin: 'https://r06-pub.example', rank_held: 'metic',
349
+ credits: 60271, tasks_shipped: 4103, karma: 9021, is_public: true },
350
+ { name: SECRET_PROJECT_NAME, origin: 'https://r06-priv.example', rank_held: 'archon',
351
+ credits: 40129, tasks_shipped: 2011, karma: 3007, is_public: false },
352
+ ];
353
+ const sumOf = (k) => PROOF_PROJECTS.reduce((n, p) => n + p[k], 0);
354
+
355
+ // The one account row the reads consult. setAccountHideStats mutates it.
356
+ const hubAccount = { profile_state: 'active', hide_stats: false, handle: 'r06-handle' };
357
+
358
+ // The scouting "table": one visible builder plus the two that consent excludes.
359
+ const SCOUTING_TABLE = [
360
+ { profile_state: 'active', hide_stats: false,
361
+ row: { github_id: '11', github_login: 'r06-visible', display_name: 'Visible', avatar_url: null,
362
+ project_count: 2, total_credits: 5, total_tasks_shipped: 1, total_karma: 0,
363
+ last_active: '2026-08-01T00:00:00.000Z', disciplines: ['engineer'] } },
364
+ { profile_state: 'active', hide_stats: true,
365
+ row: { github_id: '12', github_login: HIDDEN_LOGIN, display_name: 'Hidden', avatar_url: null,
366
+ project_count: 7, total_credits: 88817, total_tasks_shipped: 6613, total_karma: 5519,
367
+ last_active: '2026-08-02T00:00:00.000Z', disciplines: ['ui'] } },
368
+ { profile_state: 'provisional', hide_stats: false,
369
+ row: { github_id: '13', github_login: PROV_LOGIN, display_name: 'Provisional', avatar_url: null,
370
+ project_count: 1, total_credits: 71713, total_tasks_shipped: 1, total_karma: 0,
371
+ last_active: '2026-08-03T00:00:00.000Z', disciplines: [] } },
372
+ ];
373
+
374
+ const engine = {
375
+ calls: [],
376
+ query: async (text, params = []) => {
377
+ const sql = String(text);
378
+ engine.calls.push({ sql, params });
379
+
380
+ // listScoutingRollup — the ONE read behind GET /scouting and the hall's
381
+ // /people page. Honors exactly the predicates the query declares.
382
+ if (/AS project_count/.test(sql)) {
383
+ let rows = SCOUTING_TABLE;
384
+ if (/pa\.profile_state = 'active'/.test(sql)) rows = rows.filter((r) => r.profile_state === 'active');
385
+ if (/NOT pa\.hide_stats/.test(sql)) rows = rows.filter((r) => !r.hide_stats);
386
+ return { rows: rows.map((r) => ({ ...r.row })) };
387
+ }
388
+ // setAccountHideStats — the own-scoped write behind PATCH /me/cross-project.
389
+ if (/INSERT INTO platform_identity_accounts/.test(sql)) {
390
+ hubAccount.hide_stats = !!params[4];
391
+ return { rows: [{ github_id: String(HUB_GITHUB_ID), github_login: 'jaxri', ...hubAccount }] };
392
+ }
393
+ // getPublicProfileExtras' consent read.
394
+ if (/SELECT profile_state, hide_stats/.test(sql)) {
395
+ return { rows: [{ profile_state: hubAccount.profile_state, hide_stats: hubAccount.hide_stats }] };
396
+ }
397
+ // getRollupForBuilder's per-project rows (the client_id → origin two-hop).
398
+ if (/platform_identity_sso_clients/.test(sql)) {
399
+ return { rows: PROOF_PROJECTS.map((p) => ({ name: p.name, origin: p.origin, rank_held: p.rank_held, is_public: p.is_public, recent_ships: [] })) };
400
+ }
401
+ // getRollupForBuilder's un-fanned aggregate — summed ACROSS both projects.
402
+ if (/AS works_shipped/.test(sql)) {
403
+ return { rows: [{ projects: PROOF_PROJECTS.length, credits: sumOf('credits'),
404
+ works_shipped: sumOf('tasks_shipped'), karma: sumOf('karma'),
405
+ last_active: '2026-08-02T00:00:00.000Z', disciplines: ['engineer', 'ui'] }] };
406
+ }
407
+ throw new Error(`fake engine: unexpected query ${sql.slice(0, 60)}`);
408
+ },
409
+ };
410
+
411
+ // Pin the module doorway (ADR 0083) BEFORE platform-identity loads, so the
412
+ // module's default pool — destructured at require time — is the fake engine.
413
+ const moduleApiPath = require.resolve('../src/module-api.js');
414
+ require.cache[moduleApiPath] = {
415
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
416
+ exports: {
417
+ pool: engine,
418
+ requireBuilder: (_req, _res, next) => next(),
419
+ requirePermission: () => (_req, _res, next) => next(),
420
+ parsePagination: () => ({ limit: 100, offset: 0 }),
421
+ corsPublicGet: () => (_req, _res, next) => next(),
422
+ },
423
+ };
424
+ const pi = require('../modules/platform-identity/platform-identity.js');
425
+ const buildScoutingRoutes = require('../modules/platform-identity/routes/scouting.js');
426
+
427
+ // From here the port serves the REAL public read over the fake engine.
428
+ hubPublicRead = (githubId) => pi.getPublicProfileExtras(githubId, { pool: engine });
429
+
430
+ await t('R06.1 a two-project builder\'s COMBINED record shows to another account', async () => {
431
+ const r = await get(port, '/api/gds/builders/95/profile');
432
+ assert.equal(r.status, 200);
433
+ const cp = r.json.cross_project;
434
+ assert.ok(cp, 'the record is served');
435
+ assertCrossProjectContract(cp, r.text);
436
+ assert.equal(cp.totals.projects, 2, 'both projects count');
437
+ assert.equal(cp.totals.credits, sumOf('credits'), 'credits are the sum across BOTH projects');
438
+ assert.equal(cp.totals.works_shipped, sumOf('tasks_shipped'), 'ships are summed across both');
439
+ assert.equal(cp.totals.karma, sumOf('karma'), 'karma is summed across both');
440
+ assert.deepEqual(cp.disciplines, ['engineer', 'ui'], 'the disciplines union spans both projects');
441
+ assert.equal(cp.projects.length, 1, 'only the publicly-catalogued project is NAMED');
442
+ assert.equal(cp.projects[0].name, PROOF_PROJECTS[0].name);
443
+ assert.equal(cp.more_projects, 1, 'the other is counted, never named');
444
+ });
445
+
446
+ await t('R06.1 the record is viewer-INDEPENDENT — another account sees the same body', async () => {
447
+ const anon = await get(port, '/api/gds/builders/95/profile');
448
+ const other = await get(port, '/api/gds/builders/95/profile', { Cookie: 'gds_session=some-other-builder' });
449
+ assert.equal(other.status, 200);
450
+ assert.equal(other.text, anon.text, 'one response shape for every reader (ADR 0171 D4)');
451
+ });
452
+
453
+ await t('R06.1 the profile read takes NO auth middleware — the portfolio is shareable', () => {
454
+ // A future PR walling this behind requireBuilder would break "another account
455
+ // can see it" while every assertion above still passed, so pin the shape.
456
+ const line = sourceOf('src/bongos/routes/builders.js').split('\n')
457
+ .find((l) => l.includes("router.get('/builders/:id/profile'"));
458
+ assert.ok(line, 'the profile route registration is findable');
459
+ assert.match(line, /router\.get\('\/builders\/:id\/profile', async \(req, res\) => \{/,
460
+ 'nothing may sit between the path and the handler — that is what makes the record public');
461
+ });
462
+
463
+ await t('R06.4 NEGATIVE CONTROL: the leak guard goes red when a private field is planted', async () => {
464
+ const realRead = hubPublicRead;
465
+ const rollup = async () => pi.getPublicProfileExtras(HUB_GITHUB_ID, { pool: engine });
466
+ try {
467
+ // (a) a module-internal key on the object itself.
468
+ hubPublicRead = async () => ({ ...(await rollup()), client_id: SECRET_CLIENT_ID });
469
+ let r = await get(port, '/api/gds/builders/95/profile');
470
+ assert.throws(() => assertCrossProjectContract(r.json.cross_project, r.text),
471
+ /cross_project keys are exactly the contract/, 'an extra top-level key must turn the guard red');
472
+
473
+ // (b) a module-internal key on a project ROW (the is_public discriminator).
474
+ hubPublicRead = async () => {
475
+ const cp = await rollup();
476
+ return { ...cp, projects: cp.projects.map((p) => ({ ...p, is_public: true })) };
477
+ };
478
+ r = await get(port, '/api/gds/builders/95/profile');
479
+ assert.throws(() => assertCrossProjectContract(r.json.cross_project, r.text),
480
+ /project rows carry only the whitelisted keys/, 'an extra project-row key must turn the guard red');
481
+
482
+ // (c) a sentinel smuggled inside an ALLOWED field — key lists stay legal, so
483
+ // this isolates the raw-body scan. This is the leak the shape check cannot see.
484
+ hubPublicRead = async () => {
485
+ const cp = await rollup();
486
+ return { ...cp, projects: [{ ...cp.projects[0], name: SECRET_PROJECT_NAME }] };
487
+ };
488
+ r = await get(port, '/api/gds/builders/95/profile');
489
+ assert.deepEqual(Object.keys(r.json.cross_project).sort(), [...CP_PUBLIC_KEYS].sort(),
490
+ 'the planted leak is shape-legal — only the sentinel scan can catch it');
491
+ assert.throws(() => assertCrossProjectContract(r.json.cross_project, r.text),
492
+ /must not contain/, 'a sentinel in an allowed field must turn the guard red');
493
+ } finally {
494
+ hubPublicRead = realRead;
495
+ }
496
+ });
497
+
498
+ await t('R06.2 hide_stats removes the record on the IMMEDIATE next load', async () => {
499
+ const before = await get(port, '/api/gds/builders/95/profile');
500
+ assert.ok(before.json.cross_project, 'public before the flip');
501
+ assert.equal(before.headers['cache-control'], 'no-store');
502
+
503
+ // Flip it through the REAL write the own-scoped PATCH /me/cross-project uses.
504
+ const row = await pi.setAccountHideStats(
505
+ { githubId: HUB_GITHUB_ID, githubLogin: 'jaxri', hide: true }, { pool: engine });
506
+ assert.equal(row.hide_stats, true, 'the write path set the flag');
507
+
508
+ // No sleep, no second server, no cache warm-up: the very next load.
509
+ const after = await get(port, '/api/gds/builders/95/profile');
510
+ assert.equal(after.status, 200);
511
+ assert.equal(after.json.cross_project, null, 'gone on the next load');
512
+ assert.equal(after.headers['cache-control'], 'no-store', 'hide must beat the cache (ADR 0171 D5)');
513
+ assert.ok(!/max-age/.test(after.headers['cache-control'] || ''),
514
+ 'any max-age would let a cache keep serving the pre-hide body — the old public, max-age=60 failed exactly here');
515
+ });
516
+
517
+ await t('R06.5 no PARTIAL numbers survive the hide (a count beside a hidden total leaks by subtraction)', async () => {
518
+ // Set the flag here too, so this proof stands on its own rather than on
519
+ // whatever R06.2 left behind.
520
+ await pi.setAccountHideStats({ githubId: HUB_GITHUB_ID, githubLogin: 'jaxri', hide: true }, { pool: engine });
521
+ const r = await get(port, '/api/gds/builders/95/profile');
522
+ assert.equal(r.json.cross_project, null);
523
+ for (const key of ['totals', 'works_shipped', 'more_projects', 'last_active', 'rank_held']) {
524
+ assert.ok(!r.text.includes(key), `nothing derived from the rollup may survive — found "${key}"`);
525
+ }
526
+ const digits = [sumOf('credits'), sumOf('tasks_shipped'), sumOf('karma'),
527
+ ...PROOF_PROJECTS.map((p) => p.credits)].map(String);
528
+ for (const v of [...digits, ...PROOF_PROJECTS.map((p) => p.name), ...PROOF_PROJECTS.map((p) => p.origin)]) {
529
+ assert.ok(!r.text.includes(v), `no rollup value may survive a hide — found "${v}"`);
530
+ }
531
+ });
532
+
533
+ // ---- R06.3: the OTHER read paths honor hide too ---------------------------
534
+
535
+ const scoutApp = express();
536
+ scoutApp.use(attachFail);
537
+ scoutApp.use('/api/gds', buildScoutingRoutes());
538
+ const scout = await listen(scoutApp);
539
+
540
+ await t('R06.3 GET /scouting drops the hidden AND the provisional builder', async () => {
541
+ engine.calls.length = 0;
542
+ const r = await get(scout.port, '/api/gds/scouting');
543
+ assert.equal(r.status, 200);
544
+ const logins = r.json.builders.map((b) => b.github_login);
545
+ assert.deepEqual(logins, ['r06-visible'], 'only the consented, non-hidden builder is listed');
546
+ assert.equal(engine.calls.length, 1, 'exactly ONE read — nothing can re-enter via a second, unfiltered query');
547
+ for (const s of [HIDDEN_LOGIN, PROV_LOGIN, '88817', '6613', '5519', '71713']) {
548
+ assert.ok(!r.text.includes(s), `the excluded builder leaks nothing — found "${s}"`);
549
+ }
550
+ });
551
+
552
+ await t('R06.3 the /people page reads /scouting and nothing else', async () => {
553
+ // /people is the hall's render of the same endpoint, so whatever /scouting
554
+ // drops, /people drops. Pin that it has no second data source.
555
+ const endpoints = [...sourceOf('modules/hall-ui/public/people.js').matchAll(/fetch\(\s*'([^']+)'/g)].map((m) => m[1]);
556
+ assert.deepEqual(endpoints, ['/api/gds/scouting?limit=500'], '/people has exactly one data source');
557
+ });
558
+
559
+ scout.server.close();
280
560
  server.close();
281
561
 
282
562
  console.log(`\n${passed} passed, ${failed} failed`);