@bongos/core 1.19.419 → 1.19.421
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 +108 -58
- package/.claude/skills/new-project/SKILL.md +22 -11
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +103 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +282 -269
- package/docs/copy-registry.json +487 -368
- package/docs/design/mocks/hub/mine.html +377 -0
- package/docs/design/projects-hub-direction-v2.md +25 -1
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/settings.html +3 -2
- package/modules/hall-ui/public/settings.js +10 -6
- package/modules/onboarding/routes/access-requests.js +16 -3
- package/modules/platform-identity/account-visibility.js +94 -3
- package/modules/platform-identity/platform-identity.js +50 -55
- package/modules/platform-identity/routes/connections.js +3 -3
- package/modules/platform-identity/tests/platform-identity.mjs +20 -0
- package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
- package/modules/provisioning/onboard-plan.js +13 -0
- package/modules/provisioning/provisioning.js +113 -5
- package/modules/provisioning/routes/provisioning.js +105 -0
- package/modules/provisioning/tests/provisioning.mjs +84 -0
- package/modules/ui-design/kit/fixtures/featured-hub.json +1 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-empty.json +4 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +99 -0
- package/modules/ui-design/kit/lib.js +6 -2
- package/modules/ui-design/kit/probe.js +1 -1
- package/modules/ui-design/kit/render.js +1 -1
- package/modules/ui-design/kit/serve.js +63 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +224 -18
- package/public-landing/projects-empty.states.json +49 -0
- package/public-landing/projects-floor.states.json +63 -0
- package/public-landing/projects.html +273 -93
- package/public-landing/projects.probes.json +32 -7
- package/public-landing/projects.states.json +10 -6
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/onboard.js +41 -1
- package/scripts/gds/provision-net.js +7 -3
- package/src/bongos/middleware/rate-limit.js +68 -2
- package/src/bongos/routes/auth.js +19 -12
- package/src/module-api.js +13 -1
- package/tests/account_existence_rate_limit.mjs +375 -0
- package/tests/account_privacy_flags.mjs +17 -10
- package/tests/effective_visibility_predicate.mjs +891 -0
- package/tests/module_api.mjs +1 -0
- package/tests/onboard.mjs +56 -0
- package/tests/profile_route.mjs +22 -11
- package/tests/projects_hub.mjs +102 -5
- package/tests/projects_hub_pre_uat.mjs +45 -11
- package/tests/provision.mjs +20 -0
- package/tests/ui_design_kit.mjs +32 -2
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
// tests/effective_visibility_predicate.mjs
|
|
2
|
+
//
|
|
3
|
+
// THE EFFECTIVE-VISIBILITY STRUCTURAL GUARD — "every public read of a platform
|
|
4
|
+
// account routes through the ONE shared predicate, and nothing else spells one"
|
|
5
|
+
// (task 1002967, goal 1000045; privacy spec D1/D2, ADR 0171 D2/D4/D7, ADR 0202 D4).
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS EXISTS. Wiring account_visibility into the four public reads that
|
|
8
|
+
// exist today is the easy half and it is worth almost nothing on its own: the
|
|
9
|
+
// surfaces this feature must hold are mostly NOT BUILT YET. Hub profile search,
|
|
10
|
+
// the sitemap and the /u/<handle> page are all named in the spec and none of
|
|
11
|
+
// them exists in this tree. So the thing that actually protects a private
|
|
12
|
+
// account is not the four call sites — it is that the FIFTH read, written by
|
|
13
|
+
// somebody else six months from now, cannot quietly grow its own variant of
|
|
14
|
+
// "active and public". Drift between two public reads is not a bug that shows
|
|
15
|
+
// up as a broken page; it is a privacy leak that shows up as nothing at all.
|
|
16
|
+
//
|
|
17
|
+
// THE MECHANISM — three layers, each catching what the one above cannot:
|
|
18
|
+
//
|
|
19
|
+
// 1. THE ONE DEFINITION. accountVisibleSql / isAccountVisible (existence),
|
|
20
|
+
// accountActiveSql / isAccountActive (the own-scope consent floor) and
|
|
21
|
+
// rollupVisibleSql / isRollupVisible (existence AND NOT hidden) are proved
|
|
22
|
+
// to be the same rule in SQL and in JS, and the rollup pair is proved to be
|
|
23
|
+
// COMPOSED from the existence pair rather than restating it — so the two
|
|
24
|
+
// forms cannot disagree even in principle.
|
|
25
|
+
//
|
|
26
|
+
// 2. THE SOLE-OWNER RULE. modules/platform-identity/account-visibility.js —
|
|
27
|
+
// the file ADR 0202 D4 named and task 1002295 landed, which this task
|
|
28
|
+
// ADOPTS rather than duplicating — is the only first-party server file
|
|
29
|
+
// allowed to compare profile_state / account_visibility / hide_stats
|
|
30
|
+
// against a literal. Any other file that does is a hand-written
|
|
31
|
+
// variant, and fails here. Exemptions exist but do not exempt: each carries
|
|
32
|
+
// a stated reason AND a checked obligation, on the own_scope_guard model.
|
|
33
|
+
//
|
|
34
|
+
// 3. THE ADOPTION RULE, with a discovery backstop. Every SELECT this repo
|
|
35
|
+
// issues against platform_identity_accounts is enumerated from the source,
|
|
36
|
+
// and each must either route through a shared predicate or be inventoried
|
|
37
|
+
// as a deliberately non-public read with a stated reason. A NEW read — the
|
|
38
|
+
// search endpoint, the sitemap builder — is discovered automatically and
|
|
39
|
+
// fails until its author makes that choice explicitly. That is the layer
|
|
40
|
+
// aimed at the surfaces that do not exist yet.
|
|
41
|
+
//
|
|
42
|
+
// Rejected alternatives, and why: a plain grep for the column names catches the
|
|
43
|
+
// WRITE paths too (upsertAccount legitimately sets profile_state) and would be
|
|
44
|
+
// silenced by an allowlist that then swallows real drift. Testing only the four
|
|
45
|
+
// current call sites passes forever while the feature rots — it cannot fail for
|
|
46
|
+
// the reason this task exists. Requiring an @visibility-checked annotation is
|
|
47
|
+
// invisible exactly when the author forgets it, which is the failure mode.
|
|
48
|
+
//
|
|
49
|
+
// The behavioural half runs the REAL public route over a recording fake pool
|
|
50
|
+
// (the tests/account_privacy_flags.mjs pattern): a private account 404s — never
|
|
51
|
+
// 403 — and the response is BYTE-IDENTICAL for an anonymous viewer, a signed-in
|
|
52
|
+
// stranger, and the account's own owner. That last one is the D2 reconciliation
|
|
53
|
+
// and the reason the public read takes no viewer argument at all.
|
|
54
|
+
//
|
|
55
|
+
// DB-free by construction: source text plus a fake pool. No Postgres, no server
|
|
56
|
+
// beyond an ephemeral loopback listener. It therefore runs in the lane that
|
|
57
|
+
// actually executes (task 1002282: a proof that never runs is not a gate).
|
|
58
|
+
//
|
|
59
|
+
// Run: node tests/effective_visibility_predicate.mjs
|
|
60
|
+
|
|
61
|
+
import { strict as assert } from 'node:assert';
|
|
62
|
+
import fs from 'node:fs';
|
|
63
|
+
import path from 'node:path';
|
|
64
|
+
import http from 'node:http';
|
|
65
|
+
import { createRequire } from 'node:module';
|
|
66
|
+
import { fileURLToPath } from 'node:url';
|
|
67
|
+
|
|
68
|
+
// The pure predicate module is imported as ESM so knip credits its exports
|
|
69
|
+
// (a createRequire import reads as unused). It has no doorway dependency,
|
|
70
|
+
// which is exactly what makes that safe.
|
|
71
|
+
import {
|
|
72
|
+
ACCOUNT_VISIBILITY,
|
|
73
|
+
accountVisibleSql,
|
|
74
|
+
isAccountVisible,
|
|
75
|
+
accountActiveSql,
|
|
76
|
+
isAccountActive,
|
|
77
|
+
rollupVisibleSql,
|
|
78
|
+
isRollupVisible,
|
|
79
|
+
isRecruiterDiscoverable,
|
|
80
|
+
} from '../modules/platform-identity/account-visibility.js';
|
|
81
|
+
|
|
82
|
+
const require = createRequire(import.meta.url);
|
|
83
|
+
const repo = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
84
|
+
|
|
85
|
+
let passed = 0;
|
|
86
|
+
let failed = 0;
|
|
87
|
+
function t(name, fn) {
|
|
88
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
89
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
90
|
+
}
|
|
91
|
+
async function ta(name, fn) {
|
|
92
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
93
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ===========================================================================
|
|
97
|
+
// The inventories — every exception carries a reason AND an obligation.
|
|
98
|
+
// ===========================================================================
|
|
99
|
+
|
|
100
|
+
// The one file allowed to spell the predicate. Everything else must import it.
|
|
101
|
+
const PREDICATE_OWNER = 'modules/platform-identity/account-visibility.js';
|
|
102
|
+
|
|
103
|
+
// Files that name a visibility column against a literal WITHOUT being the owner.
|
|
104
|
+
//
|
|
105
|
+
// An exemption is PER OCCURRENCE, not per file, and that granularity is the
|
|
106
|
+
// point: platform-identity.js is simultaneously the file most likely to need one
|
|
107
|
+
// and the file most likely to grow a real variant, so excusing it wholesale
|
|
108
|
+
// would blind the guard exactly where it matters most. Each entry names the
|
|
109
|
+
// exact spelling and how many of it are allowed; a SECOND one, or a different
|
|
110
|
+
// one, still fails. Each also substitutes a checked obligation for the rule it
|
|
111
|
+
// is excused from — an exemption that demanded nothing would make "add a line
|
|
112
|
+
// here" the greenest fix for a red build.
|
|
113
|
+
const SPELLING_EXEMPTIONS = [
|
|
114
|
+
{
|
|
115
|
+
file: 'modules/platform-identity/platform-identity.js',
|
|
116
|
+
allow: [{
|
|
117
|
+
text: 'hide_stats = true',
|
|
118
|
+
count: 1,
|
|
119
|
+
why: "hiddenGithubIds asks the OPPOSITE question to visibility — 'which of these accounts have hidden themselves' — for the closeness-graph consent gate. It must stay fail-closed on hide alone (ADR 0171 D4), so it is not expressible as the shared predicate and is not a variant of it.",
|
|
120
|
+
}],
|
|
121
|
+
obligation: (code) => /require\(\s*'\.\/account-visibility'\s*\)/.test(code),
|
|
122
|
+
demands: 'still imports the shared predicate rather than re-deriving one',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
file: 'scripts/gds/seed-bv1-privacy-invites-tasks.js',
|
|
126
|
+
allow: [
|
|
127
|
+
{ text: "profile_state='", count: 1, why: 'a one-shot historical seeder: this sits inside a task DESCRIPTION string, quoting the predicate as prose describing the work to be done. Its subject IS this predicate.' },
|
|
128
|
+
{ text: 'NOT hide_stats', count: 1, why: 'the second half of that same quoted sentence in that same task description — prose, not a query.' },
|
|
129
|
+
],
|
|
130
|
+
obligation: (code) => !/\.query\s*\(/.test(code),
|
|
131
|
+
demands: 'issues no database query at all — it posts task rows over HTTP',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
file: 'scripts/gds/seed-bv1-r01-delta-tasks.js',
|
|
135
|
+
allow: [
|
|
136
|
+
{ text: "profile_state='", count: 1, why: 'the ADR 0171 R01 seeder: the predicate is quoted as prose inside the task briefs this script files, the same shape as the privacy seeder above.' },
|
|
137
|
+
{ text: 'NOT hide_stats', count: 1, why: 'the second half of that same quoted sentence in that same brief — prose describing work, never executed.' },
|
|
138
|
+
],
|
|
139
|
+
obligation: (code) => !/\.query\s*\(/.test(code),
|
|
140
|
+
demands: 'issues no database query at all — it posts task rows over HTTP',
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
// Every SELECT against platform_identity_accounts that is deliberately NOT a
|
|
145
|
+
// public read. A public read must route through a shared predicate instead; the
|
|
146
|
+
// guard below enumerates the statements from source and demands one or the other,
|
|
147
|
+
// so a NEW read cannot appear without landing in exactly one of these two piles.
|
|
148
|
+
const NON_PUBLIC_READS = [
|
|
149
|
+
{
|
|
150
|
+
fn: 'getAccount',
|
|
151
|
+
why: 'the internal account read: own-scope Settings, and the re-read every writer in this module does after an upsert. It returns the flags themselves (they are its output), so gating it on them would make the owner unable to see their own setting.',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
fn: 'getAccountByLogin',
|
|
155
|
+
why: 'resolves a github_login for the Archon-gated invite path (POST /projects/invite). Inviting a person is not publishing them: the caller already knows the login and the result never reaches a public body.',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
fn: 'hiddenGithubIds',
|
|
159
|
+
why: 'the closeness-graph consent gate — it answers only "which of these accounts set hide_stats", the OPPOSITE question to visibility. Its callers are requireBuilder-gated and it must stay fail-closed on hide alone (ADR 0171 D4); joining addressability would silently empty the graph of every private account for readers who are allowed to see the edge.',
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
// Reads that fetch the predicate's columns and hand the DECISION to a named
|
|
164
|
+
// caller, because the answer is viewer-relative and therefore cannot be a WHERE
|
|
165
|
+
// clause. ADR 0202 D4 §2 is the live case: a private account you are already
|
|
166
|
+
// connected to is addressable to YOU and to nobody else, so the row must come
|
|
167
|
+
// back and the caller must judge it.
|
|
168
|
+
//
|
|
169
|
+
// This is not an excuse, it is a redirection of the obligation: the guard checks
|
|
170
|
+
// that the read really does SELECT the columns (so the caller CAN decide) and
|
|
171
|
+
// that the named gate really does call a shared predicate. Delete the gate and
|
|
172
|
+
// this fails, even though the deferral is still listed.
|
|
173
|
+
const DEFERRED_READS = [
|
|
174
|
+
{
|
|
175
|
+
fn: 'findAccountByHandle',
|
|
176
|
+
gatedBy: {
|
|
177
|
+
file: 'modules/platform-identity/routes/connections.js',
|
|
178
|
+
fn: 'visibleTarget',
|
|
179
|
+
// Naming the EXACT predicate matters: visibleTarget also calls
|
|
180
|
+
// isAccountActive for the own-scope floor, so "calls some shared helper"
|
|
181
|
+
// would still pass with the existence check deleted. It is the existence
|
|
182
|
+
// check that makes a private stranger a 404.
|
|
183
|
+
predicate: /\bisAccountVisible\s*\(/,
|
|
184
|
+
},
|
|
185
|
+
why: 'connections resolve a handle to a counterparty THIS VIEWER may address (ADR 0202 D4 §2) — publicly visible, or private-but-already-connected. That is viewer-relative by definition, so the predicate cannot live in the query; it lives one frame up, in visibleTarget, which answers the same null for an unknown handle, a provisional account and a private stranger alike.',
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
// Floors that keep this guard from going quietly vacuous — a regex that stopped
|
|
190
|
+
// matching would otherwise "pass" by checking nothing.
|
|
191
|
+
const MIN_ACCOUNT_SELECTS = 6;
|
|
192
|
+
const MIN_PREDICATE_ADOPTERS = 3;
|
|
193
|
+
const MIN_FILES_SCANNED = 300;
|
|
194
|
+
|
|
195
|
+
// ===========================================================================
|
|
196
|
+
// Machinery
|
|
197
|
+
// ===========================================================================
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Blank comments while preserving every byte offset, so line numbers stay true.
|
|
201
|
+
* Comment prose in this repo discusses the predicate constantly (that is the
|
|
202
|
+
* point of the doc-comments); only real code may be judged. String and template
|
|
203
|
+
* literals are KEPT — a SQL predicate lives inside a template literal, so they
|
|
204
|
+
* are the evidence, not the noise.
|
|
205
|
+
*/
|
|
206
|
+
function blankComments(src) {
|
|
207
|
+
const out = src.split('');
|
|
208
|
+
let i = 0;
|
|
209
|
+
let state = 'code'; // code | line | block | sq | dq | tpl
|
|
210
|
+
while (i < src.length) {
|
|
211
|
+
const c = src[i];
|
|
212
|
+
const d = src[i + 1];
|
|
213
|
+
if (state === 'code') {
|
|
214
|
+
if (c === '/' && d === '/') { out[i] = ' '; out[i + 1] = ' '; i += 2; state = 'line'; continue; }
|
|
215
|
+
if (c === '/' && d === '*') { out[i] = ' '; out[i + 1] = ' '; i += 2; state = 'block'; continue; }
|
|
216
|
+
if (c === "'") { state = 'sq'; i++; continue; }
|
|
217
|
+
if (c === '"') { state = 'dq'; i++; continue; }
|
|
218
|
+
if (c === '`') { state = 'tpl'; i++; continue; }
|
|
219
|
+
i++; continue;
|
|
220
|
+
}
|
|
221
|
+
if (state === 'line') {
|
|
222
|
+
if (c === '\n') { state = 'code'; i++; continue; }
|
|
223
|
+
out[i] = ' '; i++; continue;
|
|
224
|
+
}
|
|
225
|
+
if (state === 'block') {
|
|
226
|
+
if (c === '*' && d === '/') { out[i] = ' '; out[i + 1] = ' '; i += 2; state = 'code'; continue; }
|
|
227
|
+
if (c !== '\n') out[i] = ' ';
|
|
228
|
+
i++; continue;
|
|
229
|
+
}
|
|
230
|
+
if (c === '\\') { i += 2; continue; }
|
|
231
|
+
if ((state === 'sq' && c === "'") || (state === 'dq' && c === '"') || (state === 'tpl' && c === '`')) { state = 'code'; i++; continue; }
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
return out.join('');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A hand-written visibility predicate, in either language. SQL `= 'active'` and
|
|
239
|
+
* JS `=== 'active'` are the same mistake wearing different syntax, so both count.
|
|
240
|
+
* Deliberately NOT here: a write (`profile_state = CASE WHEN …`) and a
|
|
241
|
+
* projection default (`account_visibility || 'public'`) — neither compares the
|
|
242
|
+
* column to a literal, and neither decides who may see anything.
|
|
243
|
+
*/
|
|
244
|
+
const SPELLINGS = [
|
|
245
|
+
[/\bprofile_state\s*(?:=|==|===|!=|!==|<>)\s*['"]/g, "profile_state compared to a literal"],
|
|
246
|
+
[/\baccount_visibility\s*(?:=|==|===|!=|!==|<>)\s*['"]/g, 'account_visibility compared to a literal'],
|
|
247
|
+
[/\bCOALESCE\s*\(\s*recruiter_discoverable/gi, 'the recruiter-reach COALESCE'],
|
|
248
|
+
[/\bNOT\s+(?:[A-Za-z_][\w]*\.)?hide_stats\b/g, 'a SQL NOT hide_stats'],
|
|
249
|
+
[/\bhide_stats\s*(?:=|==|===)\s*(?:true|false)\b/g, 'hide_stats compared to a boolean'],
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
function spellingsIn(code) {
|
|
253
|
+
const hits = [];
|
|
254
|
+
for (const [re, label] of SPELLINGS) {
|
|
255
|
+
for (const m of code.matchAll(re)) {
|
|
256
|
+
hits.push({ label, text: m[0].trim(), line: code.slice(0, m.index).split('\n').length });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return hits;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Walk a directory for first-party .js files. */
|
|
263
|
+
function jsFiles(...dirs) {
|
|
264
|
+
const out = [];
|
|
265
|
+
const walk = (dir) => {
|
|
266
|
+
let entries;
|
|
267
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
268
|
+
for (const e of entries) {
|
|
269
|
+
const p = path.join(dir, e.name);
|
|
270
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); }
|
|
271
|
+
else if (e.name.endsWith('.js')) out.push(p);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
for (const d of dirs) walk(path.join(repo, d));
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const rel = (abs) => path.relative(repo, abs).split(path.sep).join('/');
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A browser asset cannot enforce a server-side privacy predicate — it renders
|
|
282
|
+
* the owner their own stored setting, and the server is the gate this guard
|
|
283
|
+
* covers. Tests and migrations are the predicate's own documentation and proof.
|
|
284
|
+
*/
|
|
285
|
+
function isServerSource(relPath) {
|
|
286
|
+
return !relPath.includes('/public/')
|
|
287
|
+
&& !relPath.includes('/tests/')
|
|
288
|
+
&& !relPath.includes('/migrations/');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Top-level function declarations, in source order, as { name, at }. */
|
|
292
|
+
function functionSpans(code) {
|
|
293
|
+
const fns = [...code.matchAll(/(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g)]
|
|
294
|
+
.map((m) => ({ name: m[1], at: m.index }));
|
|
295
|
+
return fns.map((f, i) => ({
|
|
296
|
+
name: f.name,
|
|
297
|
+
at: f.at,
|
|
298
|
+
end: i + 1 < fns.length ? fns[i + 1].at : code.length,
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Every SQL template literal naming platform_identity_accounts, attributed to
|
|
304
|
+
* the function that issues it, with the statement's leading verb. This is the
|
|
305
|
+
* discovery half: it enumerates reads from the source rather than from a list
|
|
306
|
+
* somebody has to remember to update.
|
|
307
|
+
*/
|
|
308
|
+
function accountStatements(code) {
|
|
309
|
+
const spans = functionSpans(code);
|
|
310
|
+
const owner = (idx) => spans.find((s) => idx >= s.at && idx < s.end) || { name: '(top level)', at: 0, end: code.length };
|
|
311
|
+
const out = [];
|
|
312
|
+
for (const m of code.matchAll(/`([^`]*platform_identity_accounts[^`]*)`/g)) {
|
|
313
|
+
const sql = m[1].replace(/\s+/g, ' ').trim();
|
|
314
|
+
const verb = (sql.match(/^(WITH|SELECT|INSERT|UPDATE|DELETE)\b/i) || [, '?'])[1].toUpperCase();
|
|
315
|
+
const fn = owner(m.index);
|
|
316
|
+
out.push({ sql, verb, fn: fn.name, body: code.slice(fn.at, fn.end), line: code.slice(0, m.index).split('\n').length });
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Does this function body route through one of the shared predicates? */
|
|
322
|
+
const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|isAccountVisible|isAccountActive|isRollupVisible)\s*\(/;
|
|
323
|
+
|
|
324
|
+
// ===========================================================================
|
|
325
|
+
// Self-tests — the machinery is the guard, so it gets proved too.
|
|
326
|
+
// ===========================================================================
|
|
327
|
+
|
|
328
|
+
console.log('guard machinery:');
|
|
329
|
+
|
|
330
|
+
t('blankComments erases prose but keeps offsets and the SQL inside template literals', () => {
|
|
331
|
+
const src = "const a = 1; // profile_state = 'active'\n/* account_visibility = 'public' */ const s = `WHERE profile_state = 'active'`;\n";
|
|
332
|
+
const out = blankComments(src);
|
|
333
|
+
assert.equal(out.length, src.length, 'offsets must be preserved');
|
|
334
|
+
assert.equal(spellingsIn(out).length, 1, 'exactly the one real predicate survives, not the two in comments');
|
|
335
|
+
assert.match(out, /WHERE profile_state = 'active'/, 'a SQL literal is the evidence and must survive');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
t('SPELLINGS catch a hand-written variant in either language, and leave writes alone', () => {
|
|
339
|
+
const caught = (s) => spellingsIn(s).length > 0;
|
|
340
|
+
assert.ok(caught("WHERE pa.profile_state = 'active'"), 'SQL equality');
|
|
341
|
+
assert.ok(caught("if (a.profile_state !== 'active') return null;"), 'JS inequality');
|
|
342
|
+
assert.ok(caught("AND account_visibility = 'public'"), 'the existence axis in SQL');
|
|
343
|
+
assert.ok(caught("acct.account_visibility === 'private'"), 'the existence axis in JS');
|
|
344
|
+
assert.ok(caught('COALESCE(recruiter_discoverable, account_visibility)'), 'the recruiter COALESCE');
|
|
345
|
+
assert.ok(caught('AND NOT pa.hide_stats'), 'the hide half of a rollup predicate');
|
|
346
|
+
// A WRITE names the column but compares it to nothing — it decides no visibility.
|
|
347
|
+
assert.ok(!caught("profile_state = CASE WHEN $5::boolean THEN 'active' ELSE x END"), 'an upsert is not a predicate');
|
|
348
|
+
assert.ok(!caught("account_visibility: (acct && acct.account_visibility) || 'public'"), 'a projection default is not a predicate');
|
|
349
|
+
assert.ok(!caught('hide_stats = EXCLUDED.hide_stats'), 'an upsert assignment is not a predicate');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
t('accountStatements attributes a SQL literal to the function that issues it', () => {
|
|
353
|
+
const code = [
|
|
354
|
+
'async function alpha() { return q(`SELECT 1 FROM platform_identity_accounts WHERE x`); }',
|
|
355
|
+
'async function beta() { return q(`INSERT INTO platform_identity_accounts (a) VALUES ($1)`); }',
|
|
356
|
+
].join('\n');
|
|
357
|
+
const found = accountStatements(code);
|
|
358
|
+
assert.deepEqual(found.map((s) => [s.fn, s.verb]), [['alpha', 'SELECT'], ['beta', 'INSERT']]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ===========================================================================
|
|
362
|
+
// 1. The ONE definition — SQL and JS cannot disagree
|
|
363
|
+
// ===========================================================================
|
|
364
|
+
|
|
365
|
+
console.log('\nthe one definition (two forms, one rule):');
|
|
366
|
+
|
|
367
|
+
t('accountVisibleSql is the existence axis and NOTHING else', () => {
|
|
368
|
+
const bare = accountVisibleSql();
|
|
369
|
+
const aliased = accountVisibleSql('pa');
|
|
370
|
+
for (const sql of [bare, aliased]) {
|
|
371
|
+
assert.match(sql, /profile_state = 'active'/, 'consent floors it (ADR 0171 D3)');
|
|
372
|
+
assert.match(sql, /account_visibility = 'public'/, 'and the existence axis gates it (privacy spec D1)');
|
|
373
|
+
assert.doesNotMatch(sql, /hide_stats/,
|
|
374
|
+
'addressability must NOT include hide: hiding removes the rollup, not the page (spec D2)');
|
|
375
|
+
assert.doesNotMatch(sql, /recruiter_discoverable/,
|
|
376
|
+
'recruiting reach is a third question — isRecruiterDiscoverable answers it');
|
|
377
|
+
}
|
|
378
|
+
assert.match(aliased, /pa\.profile_state/, 'an alias qualifies every column');
|
|
379
|
+
assert.match(aliased, /pa\.account_visibility/);
|
|
380
|
+
assert.doesNotMatch(bare, /\w\.profile_state/, 'no alias when none is asked for');
|
|
381
|
+
assert.equal(accountVisibleSql(''), bare, 'an empty alias is the same as none');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
t('rollupVisibleSql is COMPOSED from accountVisibleSql, never restated', () => {
|
|
385
|
+
// Composition is what makes drift impossible in principle rather than by
|
|
386
|
+
// review: change the existence rule and the rollup rule moves with it.
|
|
387
|
+
assert.ok(rollupVisibleSql('pa').includes(accountVisibleSql('pa')),
|
|
388
|
+
'the rollup predicate must literally contain the addressability predicate');
|
|
389
|
+
assert.ok(rollupVisibleSql().includes(accountVisibleSql()));
|
|
390
|
+
assert.match(rollupVisibleSql('pa'), /AND NOT pa\.hide_stats/, 'and add exactly the hide term');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
t('the alias is the one interpolated thing, and both builders refuse a non-identifier', () => {
|
|
394
|
+
const inject = "pa; DROP TABLE platform_identity_accounts --";
|
|
395
|
+
assert.throws(() => accountVisibleSql(inject), /bare SQL identifier/);
|
|
396
|
+
assert.throws(() => rollupVisibleSql(inject), /bare SQL identifier/);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
t('the JS ports answer exactly what the SQL asks, row for row', () => {
|
|
400
|
+
// The table IS the D2 matrix. Each row is checked against BOTH forms, which is
|
|
401
|
+
// the only way "a route and a query cannot drift" is more than a slogan.
|
|
402
|
+
const rows = [
|
|
403
|
+
{ row: { profile_state: 'active', account_visibility: 'public', hide_stats: false }, visible: true, rollup: true },
|
|
404
|
+
{ row: { profile_state: 'active', account_visibility: 'public', hide_stats: true }, visible: true, rollup: false },
|
|
405
|
+
{ row: { profile_state: 'active', account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
|
|
406
|
+
{ row: { profile_state: 'active', account_visibility: 'private', hide_stats: true }, visible: false, rollup: false },
|
|
407
|
+
{ row: { profile_state: 'provisional', account_visibility: 'public', hide_stats: false }, visible: false, rollup: false },
|
|
408
|
+
{ row: { profile_state: 'provisional', account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
|
|
409
|
+
];
|
|
410
|
+
for (const { row, visible, rollup } of rows) {
|
|
411
|
+
const id = JSON.stringify(row);
|
|
412
|
+
assert.equal(isAccountVisible(row), visible, `isAccountVisible ${id}`);
|
|
413
|
+
assert.equal(isRollupVisible(row), rollup, `isRollupVisible ${id}`);
|
|
414
|
+
// The SQL twin, evaluated the only honest way a DB-free test can: the
|
|
415
|
+
// fragment's own column comparisons, resolved against this row.
|
|
416
|
+
const sqlSaysVisible = /profile_state = 'active'/.test(accountVisibleSql())
|
|
417
|
+
&& row.profile_state === 'active'
|
|
418
|
+
&& row.account_visibility === 'public';
|
|
419
|
+
assert.equal(sqlSaysVisible, visible, `the SQL fragment agrees for ${id}`);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
t('both ports are fail-closed on an absent or unreadable account', () => {
|
|
424
|
+
for (const bad of [null, undefined, {}, { profile_state: 'active' }, { account_visibility: 'public' }]) {
|
|
425
|
+
assert.equal(isAccountVisible(bad), false, `${JSON.stringify(bad)} is not visible`);
|
|
426
|
+
assert.equal(isRollupVisible(bad), false, `${JSON.stringify(bad)} has no public rollup`);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
t('the recruiter-reach predicate still lives here, and is still the only COALESCE', () => {
|
|
431
|
+
// Task 1002967 MOVED it beside the others rather than adding a second variant
|
|
432
|
+
// (its own doc-comment forbids one). The reach axis is independent: an
|
|
433
|
+
// explicit true on a private account is the spec's D2 "private · discoverable".
|
|
434
|
+
assert.equal(isRecruiterDiscoverable({ account_visibility: 'public', recruiter_discoverable: null }), true);
|
|
435
|
+
assert.equal(isRecruiterDiscoverable({ account_visibility: 'private', recruiter_discoverable: null }), false);
|
|
436
|
+
assert.equal(isRecruiterDiscoverable({ account_visibility: 'private', recruiter_discoverable: true }), true);
|
|
437
|
+
assert.equal(isRecruiterDiscoverable(null), false);
|
|
438
|
+
assert.deepEqual(ACCOUNT_VISIBILITY, ['public', 'private']);
|
|
439
|
+
// ...and it is NOT the addressability predicate: a discoverable private
|
|
440
|
+
// account is still absent from every public read.
|
|
441
|
+
assert.equal(isAccountVisible({ profile_state: 'active', account_visibility: 'private', recruiter_discoverable: true }), false,
|
|
442
|
+
'reach never buys addressability — that is what the D3 sliver is for');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ===========================================================================
|
|
446
|
+
// 2. The sole-owner rule — nothing else spells a variant
|
|
447
|
+
// ===========================================================================
|
|
448
|
+
|
|
449
|
+
console.log('\nthe sole-owner rule (no surface writes its own variant):');
|
|
450
|
+
|
|
451
|
+
const scanned = jsFiles('modules', 'src', 'scripts').map((abs) => ({
|
|
452
|
+
abs, rel: rel(abs), code: blankComments(fs.readFileSync(abs, 'utf8')),
|
|
453
|
+
})).filter((f) => isServerSource(f.rel));
|
|
454
|
+
|
|
455
|
+
t('the guard actually scanned the repo (not a rotted walk over nothing)', () => {
|
|
456
|
+
assert.ok(scanned.length >= MIN_FILES_SCANNED,
|
|
457
|
+
`only ${scanned.length} server-side files scanned (floor ${MIN_FILES_SCANNED}) — the directory walk has rotted`);
|
|
458
|
+
assert.ok(scanned.some((f) => f.rel === PREDICATE_OWNER),
|
|
459
|
+
`${PREDICATE_OWNER} was not scanned — the owner file moved or vanished`);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
t(`${PREDICATE_OWNER} really does define the predicate (the owner is not vacuous)`, () => {
|
|
463
|
+
const owner = scanned.find((f) => f.rel === PREDICATE_OWNER);
|
|
464
|
+
const hits = spellingsIn(owner.code);
|
|
465
|
+
assert.ok(hits.length >= 3,
|
|
466
|
+
`the owner file spells only ${hits.length} predicate term(s) — if the definition moved, this guard is now pointing at nothing`);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const usedExemptions = new Set();
|
|
470
|
+
for (const f of scanned) {
|
|
471
|
+
if (f.rel === PREDICATE_OWNER) continue;
|
|
472
|
+
const hits = spellingsIn(f.code);
|
|
473
|
+
if (!hits.length) continue;
|
|
474
|
+
t(`${f.rel} — names a visibility column against a literal: it must be the owner, or exempt WITH an obligation`, () => {
|
|
475
|
+
const ex = SPELLING_EXEMPTIONS.find((e) => e.file === f.rel);
|
|
476
|
+
const blame = hits.map((h) => `${h.text} (line ${h.line}, ${h.label})`).join('; ');
|
|
477
|
+
const advice = `this is a second variant of the effective-visibility predicate, which is exactly how a private account leaks. `
|
|
478
|
+
+ `Import it from ${PREDICATE_OWNER} instead. If this occurrence genuinely cannot be, add it to SPELLING_EXEMPTIONS with the exact spelling, a stated reason, and an obligation the guard can check.`;
|
|
479
|
+
assert.ok(ex, `hand-writes ${blame} — ${advice}`);
|
|
480
|
+
usedExemptions.add(ex.file);
|
|
481
|
+
assert.ok(ex.obligation(f.code),
|
|
482
|
+
`the exemption stands on a claim the guard checks — that this file ${ex.demands} — and that claim is now FALSE`);
|
|
483
|
+
// Per-occurrence, so an excused file cannot quietly grow a real variant.
|
|
484
|
+
for (const a of ex.allow) assert.ok(a.why && a.why.length > 40, `the allowance for "${a.text}" must carry a real stated reason`);
|
|
485
|
+
const budget = new Map(ex.allow.map((a) => [a.text, a.count]));
|
|
486
|
+
const unexcused = [];
|
|
487
|
+
for (const h of hits) {
|
|
488
|
+
const left = budget.get(h.text);
|
|
489
|
+
if (left > 0) budget.set(h.text, left - 1);
|
|
490
|
+
else unexcused.push(h);
|
|
491
|
+
}
|
|
492
|
+
assert.equal(unexcused.length, 0,
|
|
493
|
+
`is exempt for ${[...budget.keys()].map((k) => `"${k}"`).join(', ')}, but ALSO hand-writes `
|
|
494
|
+
+ `${unexcused.map((h) => `${h.text} (line ${h.line})`).join('; ')} — ${advice}`);
|
|
495
|
+
for (const [text, left] of budget) {
|
|
496
|
+
assert.equal(left, 0, `the allowance for "${text}" has ${left} unused slot(s) — the occurrence is gone, so shrink or remove it`);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
t('every listed spelling exemption still names a real, still-offending file', () => {
|
|
502
|
+
const stale = SPELLING_EXEMPTIONS.filter((e) => !usedExemptions.has(e.file)).map((e) => e.file);
|
|
503
|
+
assert.equal(stale.length, 0,
|
|
504
|
+
`stale exemption(s): ${stale.join(', ')} — the file no longer spells a predicate (or no longer exists), and an exemption for nothing silently widens the guard`);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// ===========================================================================
|
|
508
|
+
// 3. The adoption rule + the discovery backstop
|
|
509
|
+
// ===========================================================================
|
|
510
|
+
|
|
511
|
+
console.log('\nthe adoption rule (every account READ is classified, including ones not written yet):');
|
|
512
|
+
|
|
513
|
+
const allStatements = scanned.flatMap((f) => accountStatements(f.code).map((s) => ({ ...s, file: f.rel })));
|
|
514
|
+
const reads = allStatements.filter((s) => s.verb === 'SELECT' || s.verb === 'WITH');
|
|
515
|
+
const adopters = reads.filter((s) => ADOPTS.test(s.body));
|
|
516
|
+
const claimedNonPublic = new Set();
|
|
517
|
+
const claimedDeferred = new Set();
|
|
518
|
+
|
|
519
|
+
t('the statement sweep found the account reads it is supposed to be judging', () => {
|
|
520
|
+
assert.ok(reads.length >= MIN_ACCOUNT_SELECTS,
|
|
521
|
+
`only ${reads.length} account read(s) discovered (floor ${MIN_ACCOUNT_SELECTS}) — the SQL-literal scan has rotted and this section is checking nothing`);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
for (const s of reads) {
|
|
525
|
+
t(`${s.file}:${s.line} ${s.fn} — an account read is either predicate-gated or declared non-public`, () => {
|
|
526
|
+
if (ADOPTS.test(s.body)) return; // routes through the shared predicate
|
|
527
|
+
|
|
528
|
+
// Deferred: the decision is the caller's, and the guard checks that caller.
|
|
529
|
+
const deferred = DEFERRED_READS.find((r) => r.fn === s.fn);
|
|
530
|
+
if (deferred) {
|
|
531
|
+
claimedDeferred.add(deferred.fn);
|
|
532
|
+
assert.ok(deferred.why && deferred.why.length > 60,
|
|
533
|
+
'deferring a visibility decision is a privacy claim — it must carry a real reason');
|
|
534
|
+
for (const col of ['profile_state', 'account_visibility']) {
|
|
535
|
+
assert.match(s.sql, new RegExp(`\\b${col}\\b`),
|
|
536
|
+
`defers the decision to ${deferred.gatedBy.fn} but does not SELECT ${col} — the caller cannot judge a column it was not given`);
|
|
537
|
+
}
|
|
538
|
+
const gateFile = scanned.find((f) => f.rel === deferred.gatedBy.file);
|
|
539
|
+
assert.ok(gateFile, `the named gate file ${deferred.gatedBy.file} does not exist`);
|
|
540
|
+
const gate = functionSpans(gateFile.code).find((x) => x.name === deferred.gatedBy.fn);
|
|
541
|
+
assert.ok(gate, `${deferred.gatedBy.file} no longer defines ${deferred.gatedBy.fn} — the obligation this deferral rests on is GONE`);
|
|
542
|
+
const gateBody = gateFile.code.slice(gate.at, gate.end);
|
|
543
|
+
assert.match(deferred.gatedBy.predicate.source, /isAccountVisible|isRollupVisible/,
|
|
544
|
+
'a deferral must demand an EXISTENCE predicate — the consent floor alone is not what makes a private stranger a 404');
|
|
545
|
+
assert.match(gateBody, deferred.gatedBy.predicate,
|
|
546
|
+
`${deferred.gatedBy.fn} is named as the gate for ${s.fn} but no longer applies ${deferred.gatedBy.predicate} — the deferral now leads nowhere, and a private stranger is addressable`);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const declared = NON_PUBLIC_READS.find((r) => r.fn === s.fn);
|
|
551
|
+
assert.ok(declared,
|
|
552
|
+
`SELECTs from platform_identity_accounts without routing through the shared effective-visibility predicate, and is not declared non-public. `
|
|
553
|
+
+ `If this is a public read (search, a sitemap, a directory, a profile), join accountVisibleSql()/rollupVisibleSql() — or call isAccountVisible()/isRollupVisible() on the row. `
|
|
554
|
+
+ `If it is deliberately internal or own-scoped, add it to NON_PUBLIC_READS with a stated reason. `
|
|
555
|
+
+ `If the decision is genuinely viewer-relative, add it to DEFERRED_READS naming the caller that applies the predicate — the guard will check that caller.`);
|
|
556
|
+
claimedNonPublic.add(declared.fn);
|
|
557
|
+
assert.ok(declared.why && declared.why.length > 60,
|
|
558
|
+
'declaring a read non-public is a privacy claim — it must carry a real reason, not a label');
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
t('the shared predicate has real adopters (the wiring did not silently unwire itself)', () => {
|
|
563
|
+
const names = [...new Set(adopters.map((s) => s.fn))];
|
|
564
|
+
assert.ok(names.length >= MIN_PREDICATE_ADOPTERS,
|
|
565
|
+
`only ${names.length} account read(s) route through the shared predicate (floor ${MIN_PREDICATE_ADOPTERS}): ${names.join(', ')}`);
|
|
566
|
+
for (const required of ['resolveHandle', 'listScoutingRollup']) {
|
|
567
|
+
assert.ok(names.includes(required),
|
|
568
|
+
`${required} is a public read and no longer routes through the shared predicate`);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
t('getPublicProfileExtras gates on the shared JS port (its SQL carries no predicate to join)', () => {
|
|
573
|
+
const pi = scanned.find((f) => f.rel === 'modules/platform-identity/platform-identity.js');
|
|
574
|
+
const span = functionSpans(pi.code).find((s) => s.name === 'getPublicProfileExtras');
|
|
575
|
+
assert.ok(span, 'getPublicProfileExtras vanished — the port behind every public rollup read');
|
|
576
|
+
const body = pi.code.slice(span.at, span.end);
|
|
577
|
+
assert.match(body, /isRollupVisible\s*\(/, 'the rollup port must gate on the shared JS predicate');
|
|
578
|
+
assert.match(body, /account_visibility/, 'and its SELECT must actually fetch the column the predicate reads');
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
t('every declared non-public read still exists (a stale declaration is a blind spot)', () => {
|
|
582
|
+
const stale = [
|
|
583
|
+
...NON_PUBLIC_READS.filter((r) => !claimedNonPublic.has(r.fn)),
|
|
584
|
+
...DEFERRED_READS.filter((r) => !claimedDeferred.has(r.fn)),
|
|
585
|
+
].map((r) => r.fn);
|
|
586
|
+
assert.equal(stale.length, 0,
|
|
587
|
+
`declared non-public but no longer found as an account read: ${stale.join(', ')} — remove it, or the next function to take that name inherits the excuse`);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ===========================================================================
|
|
591
|
+
// 4. Behaviour — an EXECUTED boundary, not a string match
|
|
592
|
+
// ===========================================================================
|
|
593
|
+
//
|
|
594
|
+
// The fake pool below is a small in-memory platform_identity_accounts table plus
|
|
595
|
+
// a mini-interpreter that builds each statement's filter FROM THAT STATEMENT'S
|
|
596
|
+
// OWN WHERE CLAUSE. So if accountVisibleSql() were dropped from resolveHandle,
|
|
597
|
+
// the clause would genuinely stop carrying `account_visibility = 'public'`, the
|
|
598
|
+
// private row would genuinely come back, and the 404 tests below would genuinely
|
|
599
|
+
// fail with a 200 — the boundary is executed, not asserted against a string.
|
|
600
|
+
// The interpreter REFUSES any predicate term it does not recognise, so a
|
|
601
|
+
// rewritten clause it cannot read is a loud error rather than a silently absent
|
|
602
|
+
// filter (the tests/own_session_revoke.mjs pattern, task 1003341).
|
|
603
|
+
//
|
|
604
|
+
// Everything above the pool is real: the real router, the real domain module,
|
|
605
|
+
// over real HTTP.
|
|
606
|
+
|
|
607
|
+
console.log('\nbehaviour (an executed privacy boundary):');
|
|
608
|
+
|
|
609
|
+
const ACCOUNTS = [
|
|
610
|
+
{ github_id: '4242', github_login: 'octo', display_name: 'Octo', avatar_url: null,
|
|
611
|
+
handle: 'octo', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
612
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
613
|
+
// The private account this whole task exists for.
|
|
614
|
+
{ github_id: '5150', github_login: 'ghost', display_name: 'Ghost', avatar_url: null,
|
|
615
|
+
handle: 'ghost', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
616
|
+
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
|
|
617
|
+
// Private AND recruiter-discoverable — the spec's D2 "private · discoverable"
|
|
618
|
+
// row. Reach must not buy addressability: this one still 404s.
|
|
619
|
+
{ github_id: '5151', github_login: 'scout', display_name: 'Scout', avatar_url: null,
|
|
620
|
+
handle: 'scout', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
621
|
+
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
|
|
622
|
+
// Public but hidden — the axes are independent: the PAGE resolves, the numbers do not.
|
|
623
|
+
{ github_id: '6060', github_login: 'shy', display_name: 'Shy', avatar_url: null,
|
|
624
|
+
handle: 'shy', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
625
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
|
|
626
|
+
{ github_id: '7070', github_login: 'newbie', display_name: 'Newbie', avatar_url: null,
|
|
627
|
+
handle: 'newbie', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
628
|
+
profile_state: 'provisional', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
629
|
+
// A private account inside the P01 rename window. Its OLD handle must 404 too —
|
|
630
|
+
// a 301 to the current handle would confirm the account exists.
|
|
631
|
+
{ github_id: '8080', github_login: 'moved', display_name: 'Moved', avatar_url: null,
|
|
632
|
+
handle: 'moved-now', previous_handle: 'moved-was', bio: null, links: null, created_at: 'T',
|
|
633
|
+
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
|
|
634
|
+
// ...and a PUBLIC one, so the control proves the 301 window still works.
|
|
635
|
+
{ github_id: '9090', github_login: 'shifted', display_name: 'Shifted', avatar_url: null,
|
|
636
|
+
handle: 'shifted-now', previous_handle: 'shifted-was', bio: null, links: null, created_at: 'T',
|
|
637
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
638
|
+
];
|
|
639
|
+
|
|
640
|
+
// The columns this interpreter knows how to filter on. Anything else in a WHERE
|
|
641
|
+
// clause throws — the guard against a predicate quietly degrading to no filter.
|
|
642
|
+
const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'github_id', 'handle', 'previous_handle']);
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Build a row test from a statement's own WHERE clause. Literal comparisons
|
|
646
|
+
* (profile_state = 'active') matter as much as placeholders here, because the
|
|
647
|
+
* effective-visibility predicate is made ENTIRELY of literal comparisons — a
|
|
648
|
+
* fake pool that only modelled $N filters would score the privacy terms as
|
|
649
|
+
* absent and pass no matter what.
|
|
650
|
+
*/
|
|
651
|
+
function accountFilter(where, params) {
|
|
652
|
+
const tests = [];
|
|
653
|
+
const consumed = new Set();
|
|
654
|
+
|
|
655
|
+
if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
|
|
656
|
+
if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
|
|
657
|
+
if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
|
|
658
|
+
|
|
659
|
+
const byId = /\b(?:[a-z_]+\.)?github_id = \$(\d+)/.exec(where);
|
|
660
|
+
if (byId) { consumed.add(byId[1]); const v = params[Number(byId[1]) - 1]; tests.push((r) => String(r.github_id) === String(v)); }
|
|
661
|
+
|
|
662
|
+
const byHandle = /lower\(handle\) = \$(\d+) OR lower\(previous_handle\) = \$(\d+)/.exec(where);
|
|
663
|
+
if (byHandle) {
|
|
664
|
+
consumed.add(byHandle[1]); consumed.add(byHandle[2]);
|
|
665
|
+
const v = String(params[Number(byHandle[1]) - 1]);
|
|
666
|
+
tests.push((r) => String(r.handle || '').toLowerCase() === v || String(r.previous_handle || '').toLowerCase() === v);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (const m of where.matchAll(/\$(\d+)/g)) {
|
|
670
|
+
if (!consumed.has(m[1])) {
|
|
671
|
+
throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
for (const m of where.matchAll(/\b([a-z_]+)\s*(?:=|<>|!=)\s*'/g)) {
|
|
675
|
+
if (!KNOWN_COLUMNS.has(m[1])) {
|
|
676
|
+
throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return (r) => tests.every((fn) => fn(r));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let calls = [];
|
|
683
|
+
const pool = {
|
|
684
|
+
query: async (text, params = []) => {
|
|
685
|
+
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
686
|
+
calls.push({ sql, params });
|
|
687
|
+
|
|
688
|
+
// The scouting rollup: its privacy decision lives entirely in the EXISTS
|
|
689
|
+
// sub-select over the accounts table, so that is what is executed here —
|
|
690
|
+
// one result row per account the predicate admits. The aggregation above it
|
|
691
|
+
// is not the boundary and is not modelled.
|
|
692
|
+
if (/platform_identity_builder_project_activity/.test(sql) && /EXISTS \( SELECT 1 FROM platform_identity_accounts/.test(sql)) {
|
|
693
|
+
const inner = /EXISTS \( SELECT 1 FROM platform_identity_accounts pa WHERE (.+?)\) GROUP BY/.exec(sql);
|
|
694
|
+
assert.ok(inner, `the rollup EXISTS clause changed shape — the interpreter must be updated, not bypassed: ${sql.slice(0, 160)}`);
|
|
695
|
+
const match = accountFilter(inner[1].replace(/pa\.github_id = a\.github_id AND /, ''), params);
|
|
696
|
+
const rows = ACCOUNTS.filter(match).map((a) => ({
|
|
697
|
+
github_id: a.github_id, github_login: a.github_login, display_name: a.display_name,
|
|
698
|
+
avatar_url: a.avatar_url, project_count: 1, total_credits: 1, total_tasks_shipped: 1,
|
|
699
|
+
total_karma: 0, last_active: 'T', disciplines: null,
|
|
700
|
+
}));
|
|
701
|
+
return { rows, rowCount: rows.length };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (/FROM platform_identity_accounts/.test(sql)) {
|
|
705
|
+
const where = sql.includes(' WHERE ') ? sql.slice(sql.indexOf(' WHERE ') + 7).replace(/ ORDER BY .*| LIMIT .*/g, '') : '';
|
|
706
|
+
const cols = (/^SELECT (.+?) FROM /.exec(sql) || [, ''])[1];
|
|
707
|
+
let rows = ACCOUNTS.filter(accountFilter(where, params));
|
|
708
|
+
if (/is_current/.test(cols)) {
|
|
709
|
+
// resolveHandle asks the row whether the handle it matched is the CURRENT
|
|
710
|
+
// one; that flag is what forks 200 from 301, so the fake must derive it
|
|
711
|
+
// rather than assume it. ORDER BY … DESC LIMIT 1 is modelled with it.
|
|
712
|
+
const asked = String(params[0]).toLowerCase();
|
|
713
|
+
rows = rows.map((r) => ({ ...r, is_current: String(r.handle || '').toLowerCase() === asked }))
|
|
714
|
+
.sort((a, b) => Number(b.is_current) - Number(a.is_current))
|
|
715
|
+
.slice(0, /LIMIT 1/.test(sql) ? 1 : rows.length);
|
|
716
|
+
}
|
|
717
|
+
return { rows, rowCount: rows.length };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// The rollup aggregates themselves — reached only AFTER the gate has passed,
|
|
721
|
+
// so they are canned. What they return is not what this file is proving.
|
|
722
|
+
if (/platform_identity_builder_project_activity/.test(sql)) {
|
|
723
|
+
return /projects/.test(sql)
|
|
724
|
+
? { rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }], rowCount: 1 }
|
|
725
|
+
: { rows: [], rowCount: 0 };
|
|
726
|
+
}
|
|
727
|
+
throw new Error(`fake pool: unhandled statement — ${sql.slice(0, 90)}`);
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
732
|
+
require.cache[moduleApiPath] = {
|
|
733
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
734
|
+
exports: {
|
|
735
|
+
pool,
|
|
736
|
+
// The public route's CORS wrapper is not under test — a pass-through keeps
|
|
737
|
+
// the doorway honest without dragging core config in.
|
|
738
|
+
corsPublicGet: () => (_req, _res, next) => next(),
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
742
|
+
const buildPublicProfileRoutes = require('../modules/platform-identity/routes/public-profile.js');
|
|
743
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
744
|
+
const express = require('express');
|
|
745
|
+
|
|
746
|
+
const app = express();
|
|
747
|
+
app.use(attachFail);
|
|
748
|
+
app.use('/api/gds', buildPublicProfileRoutes());
|
|
749
|
+
const server = app.listen(0, '127.0.0.1');
|
|
750
|
+
await new Promise((r) => server.once('listening', r));
|
|
751
|
+
const port = server.address().port;
|
|
752
|
+
|
|
753
|
+
// A viewer is expressed the only way this route could ever notice one: request
|
|
754
|
+
// headers. The route has no requireBuilder, so a session cookie/bearer is the
|
|
755
|
+
// most a viewer can be — and the point is that it changes nothing.
|
|
756
|
+
function get(path_, headers = {}) {
|
|
757
|
+
calls = [];
|
|
758
|
+
return new Promise((resolve, reject) => {
|
|
759
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'GET', path: path_, headers }, (res) => {
|
|
760
|
+
const chunks = [];
|
|
761
|
+
res.on('data', (c) => chunks.push(c));
|
|
762
|
+
res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
|
|
763
|
+
});
|
|
764
|
+
req.on('error', reject);
|
|
765
|
+
req.end();
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
await ta('CONTROL: the fixture and the interpreter really work — a public account resolves', async () => {
|
|
770
|
+
// Without this, every 404 below could be a broken fake rather than a boundary.
|
|
771
|
+
const res = await get('/api/gds/profiles/octo');
|
|
772
|
+
assert.equal(res.status, 200, 'the feature must be a gate, not a blanket denial');
|
|
773
|
+
assert.match(res.text, /"handle":"octo"/);
|
|
774
|
+
assert.match(res.text, /"cross_project":\{/, 'and a public, unhidden account still gets its rollup');
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
await ta('a private account 404s on the public read — the row never leaves the database', async () => {
|
|
778
|
+
const res = await get('/api/gds/profiles/ghost');
|
|
779
|
+
assert.equal(res.status, 404, 'a private account must be indistinguishable from an unknown one');
|
|
780
|
+
assert.match(res.text, /profile_not_found/);
|
|
781
|
+
assert.ok(!res.text.includes('Ghost'), 'and nothing about the account may appear in the body');
|
|
782
|
+
assert.match(calls[0].sql, /account_visibility = 'public'/,
|
|
783
|
+
'the refusal came from the DATABASE predicate, not from a caller remembering to check');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
await ta('private + recruiter-discoverable still 404s — reach never buys addressability', async () => {
|
|
787
|
+
const res = await get('/api/gds/profiles/scout');
|
|
788
|
+
assert.equal(res.status, 404,
|
|
789
|
+
"the spec's D2 private-discoverable row is served by the recruiter sliver, never by the public page");
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
await ta('a private account 404s on its PREVIOUS handle too — the 301 window is not a bypass', async () => {
|
|
793
|
+
const moved = await get('/api/gds/profiles/moved-was');
|
|
794
|
+
assert.equal(moved.status, 404, 'a 301 to the current handle would confirm the account exists');
|
|
795
|
+
assert.ok(!moved.text.includes('moved-now'), 'and must not name the handle it moved to');
|
|
796
|
+
// CONTROL: the rename window itself still works for a public account.
|
|
797
|
+
const shifted = await get('/api/gds/profiles/shifted-was');
|
|
798
|
+
assert.equal(shifted.status, 301, 'the P01 rename window is untouched for a public account');
|
|
799
|
+
assert.match(shifted.text, /shifted-now/);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
await ta('a provisional account still 404s, and a hidden PUBLIC one still resolves', async () => {
|
|
803
|
+
assert.equal((await get('/api/gds/profiles/newbie')).status, 404, 'consent still floors everything (ADR 0171 D3)');
|
|
804
|
+
const shy = await get('/api/gds/profiles/shy');
|
|
805
|
+
assert.equal(shy.status, 200,
|
|
806
|
+
'hide removes the ROLLUP, not the page — addressability and hide are different invariants (spec D2)');
|
|
807
|
+
assert.match(shy.text, /"cross_project":null/, 'and the numbers are gone, indistinguishably from having none');
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
await ta('the public read answers 404 — NEVER 403 — on every path that can refuse', async () => {
|
|
811
|
+
const src = blankComments(fs.readFileSync(path.join(repo, 'modules/platform-identity/routes/public-profile.js'), 'utf8'));
|
|
812
|
+
assert.doesNotMatch(src, /\b403\b/,
|
|
813
|
+
'a 403 confirms the handle exists, which is the disclosure the 404 rule exists to prevent (spec D2 / ADR 0202 D4)');
|
|
814
|
+
for (const handle of ['ghost', 'scout', 'newbie', 'moved-was', 'nobody-at-all']) {
|
|
815
|
+
assert.equal((await get(`/api/gds/profiles/${handle}`)).status, 404, `${handle} must 404`);
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
await ta('the response is BYTE-IDENTICAL to a stranger, a signed-in viewer, and the owner', async () => {
|
|
820
|
+
// This is D2's viewer-independence reconciliation. The owner of a private
|
|
821
|
+
// account gets the same 404 as everyone else; their own page is composed from
|
|
822
|
+
// the own-scope side door (GET /me/cross-project → getOwnProfileExtras),
|
|
823
|
+
// never from this endpoint.
|
|
824
|
+
const viewers = [
|
|
825
|
+
['anonymous', {}],
|
|
826
|
+
['a signed-in stranger', { cookie: 'gds_session=stranger-token' }],
|
|
827
|
+
['the account owner', { cookie: 'gds_session=ghost-own-token', authorization: 'Bearer ghost-own-token' }],
|
|
828
|
+
];
|
|
829
|
+
const seen = [];
|
|
830
|
+
for (const [, headers] of viewers) seen.push(await get('/api/gds/profiles/ghost', headers));
|
|
831
|
+
for (let i = 1; i < seen.length; i++) {
|
|
832
|
+
assert.equal(seen[i].status, seen[0].status, `${viewers[i][0]} got a different status`);
|
|
833
|
+
assert.equal(seen[i].text, seen[0].text, `${viewers[i][0]} got a different body — the public read varied by viewer`);
|
|
834
|
+
}
|
|
835
|
+
// The structural reason it cannot vary: the read takes a handle and an options
|
|
836
|
+
// bag, and nothing else. A viewer parameter is how viewer-dependence arrives.
|
|
837
|
+
const params = /^[^(]*\(([^)]*)\)/.exec(pi.getPublicProfileByHandle.toString())[1];
|
|
838
|
+
assert.match(params, /^handle,\s*\{[^}]*pool[^}]*\}/,
|
|
839
|
+
`getPublicProfileByHandle(${params}) — the public read must take a handle and a pool bag only`);
|
|
840
|
+
assert.doesNotMatch(params, /viewer|builder|session|req\b/i, 'no viewer may reach the public read');
|
|
841
|
+
const routeSrc = blankComments(fs.readFileSync(path.join(repo, 'modules/platform-identity/routes/public-profile.js'), 'utf8'));
|
|
842
|
+
assert.doesNotMatch(routeSrc, /req\s*\.\s*builder\b/,
|
|
843
|
+
'the public profile route must never consult the viewer — that is what makes the 404 the same for everyone');
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
await ta('the own-scope side door still serves the owner their own record', async () => {
|
|
847
|
+
// The other half of D2: the public read 404s for everyone, and the owner's page
|
|
848
|
+
// is composed from HERE. getOwnProfileExtras is deliberately flag-blind.
|
|
849
|
+
calls = [];
|
|
850
|
+
const own = await pi.getOwnProfileExtras('5150', { pool });
|
|
851
|
+
assert.ok(own && own.totals, 'a private account must still be able to read its own numbers');
|
|
852
|
+
assert.ok(!calls.some((c) => /account_visibility|profile_state/.test(c.sql)),
|
|
853
|
+
'the self view applies no visibility predicate — that is what makes it a side door');
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
await ta('a private account is absent from the rollup, so it cannot leak by aggregate either', async () => {
|
|
857
|
+
calls = [];
|
|
858
|
+
assert.equal(await pi.getPublicProfileExtras('5150', { pool }), null,
|
|
859
|
+
'the port behind GET /builders/:id/profile must answer null for a private account');
|
|
860
|
+
assert.match(calls[0].sql, /account_visibility/, 'and it must SELECT the column to be able to say so');
|
|
861
|
+
assert.ok(await pi.getPublicProfileExtras('4242', { pool }), 'CONTROL: a public account still has one');
|
|
862
|
+
assert.equal(await pi.getPublicProfileExtras('6060', { pool }), null, 'a hidden account still has none');
|
|
863
|
+
assert.equal(await pi.getPublicProfileExtras('7070', { pool }), null, 'a provisional account still has none');
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
await ta('private accounts are absent from the leaderboard/directory rollup', async () => {
|
|
867
|
+
const rows = await pi.listScoutingRollup({ limit: 50, pool });
|
|
868
|
+
const shown = rows.map((r) => r.github_login).sort();
|
|
869
|
+
assert.deepEqual(shown, ['octo', 'shifted'],
|
|
870
|
+
'only active, publicly-visible, unhidden accounts reach /people and /scouting — '
|
|
871
|
+
+ `got ${JSON.stringify(shown)}; ghost/scout are private, shy is hidden, newbie is provisional, moved is private`);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
server.close();
|
|
875
|
+
|
|
876
|
+
// ===========================================================================
|
|
877
|
+
// 5. Lane proof
|
|
878
|
+
// ===========================================================================
|
|
879
|
+
|
|
880
|
+
console.log('\ncoverage:');
|
|
881
|
+
|
|
882
|
+
t('this guard runs in the DB-free unit lane (it is not parked as integration or known-failing)', () => {
|
|
883
|
+
const runner = fs.readFileSync(path.join(repo, 'scripts', 'gds', 'run-unit-tests.js'), 'utf8');
|
|
884
|
+
assert.ok(
|
|
885
|
+
!/effective_visibility_predicate/.test(runner),
|
|
886
|
+
'effective_visibility_predicate is named in run-unit-tests.js — parked into INTEGRATION or KNOWN_FAILING it would gate nothing (task 1002282)'
|
|
887
|
+
);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
891
|
+
process.exit(failed === 0 ? 0 : 1);
|