@bongos/core 1.19.412 → 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.
- package/.bongos-core.json +30 -20
- package/clients/bongos-client/index.d.ts +4 -4
- package/docs/api/openapi.json +25 -10
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +96 -92
- package/docs/copy-registry.json +147 -111
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/settings.html +18 -0
- package/modules/hall-ui/public/settings.js +93 -10
- package/modules/platform-identity/migrations/platform_identity_012_account_privacy.sql +59 -0
- package/modules/platform-identity/platform-identity.js +91 -1
- package/modules/platform-identity/routes/profile.js +68 -17
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +299 -0
|
@@ -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
|
+
});
|