@bongos/core 1.19.424 → 1.19.426
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 +59 -34
- package/docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md +100 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +184 -184
- package/docs/copy-registry.json +235 -235
- package/docs/design/mocks/hub/wizard.html +421 -0
- package/docs/design/projects-hub-direction-v2.md +26 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/people.js +5 -5
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/settings.js +12 -12
- package/modules/platform-identity/account-visibility.js +47 -1
- package/modules/platform-identity/platform-identity.js +14 -11
- package/modules/platform-identity/routes/scouting.js +61 -21
- package/modules/platform-identity/routes/sso.js +6 -0
- package/modules/platform-identity/scouting-authz.js +139 -0
- package/modules/ui-design/kit/serve.js +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +63 -0
- package/public-landing/projects.html +207 -199
- package/public-landing/projects.probes.json +7 -1
- package/public-landing/projects.states.json +7 -1
- package/src/bongos/auth.js +59 -3
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +23 -13
- package/tests/effective_visibility_predicate.mjs +49 -4
- package/tests/projects_hub.mjs +56 -0
- package/tests/scouting_owner_access_and_reach.mjs +544 -0
- package/tests/scouting_page_gate.mjs +185 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// tests/scouting_owner_access_and_reach.mjs
|
|
2
|
+
//
|
|
3
|
+
// R14 (task 1002290, goal 1000045 C5; ADR 0210) — the two halves of graduating
|
|
4
|
+
// the cross-project recruiting directory, proved as an EXECUTED boundary:
|
|
5
|
+
//
|
|
6
|
+
// WHO MAY READ IT. GET /scouting used to admit exactly one audience: a holder
|
|
7
|
+
// of `project.curate`. It now also admits a PROJECT OWNER — a caller whose
|
|
8
|
+
// own project reported them as its owner at check-in AND whose sign-in the
|
|
9
|
+
// hub itself witnessed. A builder who is neither still gets the unchanged
|
|
10
|
+
// 403, and an owner claim the hub never witnessed is not an owner claim.
|
|
11
|
+
//
|
|
12
|
+
// WHO APPEARS IN IT. The recruiting opt-out (`recruiter_discoverable`, privacy
|
|
13
|
+
// spec D1) gated nothing until this task: a public account that had opted out
|
|
14
|
+
// still appeared. listScoutingRollup now joins scoutingListableSql, so an
|
|
15
|
+
// opted-out builder is absent from /scouting AND from the hall's /people —
|
|
16
|
+
// which is the SAME endpoint, proved from source below rather than asserted
|
|
17
|
+
// twice — and flipping the toggle back restores them.
|
|
18
|
+
//
|
|
19
|
+
// AND NEITHER AXIS TOUCHES THE OTHER (ADR 0016). Being scoutable is not
|
|
20
|
+
// authority and opting out is not a punishment: an opted-out builder who owns
|
|
21
|
+
// a project still READS the directory (200) while being absent from it,
|
|
22
|
+
// including from their own copy of it.
|
|
23
|
+
//
|
|
24
|
+
// WHY THE FAKE POOL IS SHAPED THE WAY IT IS. Every filter below is built FROM THE
|
|
25
|
+
// STATEMENT'S OWN WHERE CLAUSE — the tests/own_session_revoke.mjs and
|
|
26
|
+
// tests/effective_visibility_predicate.mjs pattern. So deleting the reach term
|
|
27
|
+
// from listScoutingRollup genuinely stops the clause from carrying the COALESCE,
|
|
28
|
+
// the opted-out row genuinely comes back, and the assertions below genuinely go
|
|
29
|
+
// red. A statement carrying a placeholder or a literal comparison the
|
|
30
|
+
// interpreter cannot read is a LOUD ERROR, never a silently absent filter — a
|
|
31
|
+
// guard that degrades to "no WHERE at all" passes forever and protects nothing.
|
|
32
|
+
//
|
|
33
|
+
// Everything above the pool is real: the real route file, the real
|
|
34
|
+
// requirePermission gate over the real government catalog, real HTTP. Only the
|
|
35
|
+
// session layer is a double (there is no browser here to hold one), and it is
|
|
36
|
+
// the one layer this task does not touch.
|
|
37
|
+
//
|
|
38
|
+
// DB-free by construction, so it runs in the lane that actually executes — CI has
|
|
39
|
+
// no Postgres and a real-DB proof there would self-skip (task 1002282).
|
|
40
|
+
//
|
|
41
|
+
// Run: node tests/scouting_owner_access_and_reach.mjs
|
|
42
|
+
|
|
43
|
+
import { strict as assert } from 'node:assert';
|
|
44
|
+
import fs from 'node:fs';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import http from 'node:http';
|
|
47
|
+
import { createRequire } from 'node:module';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
|
|
50
|
+
// ESM-imported so knip credits the modules' exports (a createRequire import reads
|
|
51
|
+
// as unused). Both are pure files with no doorway dependency, which is what makes
|
|
52
|
+
// importing them here safe.
|
|
53
|
+
import {
|
|
54
|
+
scoutingListableSql,
|
|
55
|
+
rollupVisibleSql,
|
|
56
|
+
isRecruiterDiscoverable,
|
|
57
|
+
} from '../modules/platform-identity/account-visibility.js';
|
|
58
|
+
import {
|
|
59
|
+
isProjectOwnerMembership,
|
|
60
|
+
OWNER_MEMBERSHIP_KIND,
|
|
61
|
+
} from '../modules/platform-identity/scouting-authz.js';
|
|
62
|
+
|
|
63
|
+
const require = createRequire(import.meta.url);
|
|
64
|
+
const repo = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
65
|
+
|
|
66
|
+
let passed = 0;
|
|
67
|
+
let failed = 0;
|
|
68
|
+
async function t(name, fn) {
|
|
69
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
70
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ===========================================================================
|
|
74
|
+
// The fixtures — the privacy matrix crossed with the ownership matrix
|
|
75
|
+
// ===========================================================================
|
|
76
|
+
|
|
77
|
+
// Accounts. Only the columns the predicate reads matter; the rest is display.
|
|
78
|
+
const ACCOUNTS = [
|
|
79
|
+
// The control: public, unhidden, reach unset (NULL = follow visibility = in).
|
|
80
|
+
{ github_id: '4242', github_login: 'octo', display_name: 'Octo',
|
|
81
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
82
|
+
// THE ROW THIS TASK EXISTS FOR: public, unhidden, and explicitly opted OUT.
|
|
83
|
+
// Before R14 this account appeared in /scouting anyway.
|
|
84
|
+
{ github_id: '5000', github_login: 'optout', display_name: 'Opt Out',
|
|
85
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: false },
|
|
86
|
+
// Explicitly opted IN while public — the same effective answer as NULL, from
|
|
87
|
+
// the other side of the tri-state. Proves the column is read, not the default.
|
|
88
|
+
{ github_id: '8000', github_login: 'optin', display_name: 'Opt In',
|
|
89
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: true },
|
|
90
|
+
// Private AND discoverable — the spec's D2 "private / discoverable" row. Reach
|
|
91
|
+
// never buys addressability: the full rollup is never its disclosure.
|
|
92
|
+
{ github_id: '5151', github_login: 'scout', display_name: 'Scout',
|
|
93
|
+
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
|
|
94
|
+
// Hidden, and provisional — the two floors that already held before R14.
|
|
95
|
+
{ github_id: '6060', github_login: 'shy', display_name: 'Shy',
|
|
96
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
|
|
97
|
+
{ github_id: '7070', github_login: 'newbie', display_name: 'Newbie',
|
|
98
|
+
profile_state: 'provisional', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// Project memberships, as the hub holds them. `membership_kind` is what the
|
|
102
|
+
// PROJECT reported at check-in; `signin_verified_at` is the hub's OWN witness
|
|
103
|
+
// that the person really signed in (ADR 0205 D1/D2). Owner needs both.
|
|
104
|
+
const MEMBERSHIPS = [
|
|
105
|
+
{ client_id: 'proj-a', github_id: '9001', membership_kind: 'owner', signin_verified_at: 'T' },
|
|
106
|
+
{ client_id: 'proj-b', github_id: '9002', membership_kind: 'member', signin_verified_at: 'T' },
|
|
107
|
+
// An owner claim the hub NEVER witnessed. touchMembership cannot write this
|
|
108
|
+
// shape today; it is here because the gate must refuse it if one ever appears.
|
|
109
|
+
{ client_id: 'proj-c', github_id: '9003', membership_kind: 'owner', signin_verified_at: null },
|
|
110
|
+
{ client_id: 'proj-d', github_id: '9004', membership_kind: 'pending', signin_verified_at: 'T' },
|
|
111
|
+
// The opted-out builder ALSO owns a project — the ADR 0016 cross-check below.
|
|
112
|
+
{ client_id: 'proj-e', github_id: '5000', membership_kind: 'owner', signin_verified_at: 'T' },
|
|
113
|
+
// A perfectly good owner row on a SUSPENDED project. A membership never
|
|
114
|
+
// expires and nothing ages it out, so suspending the client is the operator's
|
|
115
|
+
// only revocation lever — the gate must answer to it.
|
|
116
|
+
{ client_id: 'proj-x', github_id: '9008', membership_kind: 'owner', signin_verified_at: 'T' },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// The projects those memberships belong to. `status` is the platform's own
|
|
120
|
+
// switch (migration platform_identity_001_tables.sql): 'active' | 'suspended'.
|
|
121
|
+
const CLIENTS = [
|
|
122
|
+
{ client_id: 'proj-a', status: 'active' },
|
|
123
|
+
{ client_id: 'proj-b', status: 'active' },
|
|
124
|
+
{ client_id: 'proj-c', status: 'active' },
|
|
125
|
+
{ client_id: 'proj-d', status: 'active' },
|
|
126
|
+
{ client_id: 'proj-e', status: 'active' },
|
|
127
|
+
{ client_id: 'proj-x', status: 'suspended' },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
// ===========================================================================
|
|
131
|
+
// The fake pool — every filter read out of the statement's own WHERE clause
|
|
132
|
+
// ===========================================================================
|
|
133
|
+
|
|
134
|
+
const KNOWN_ACCOUNT_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'recruiter_discoverable']);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A row test for platform_identity_accounts, built from `where`. Literal
|
|
138
|
+
* comparisons count as much as placeholders: the effective-visibility predicate
|
|
139
|
+
* is made ENTIRELY of literals, so an interpreter that modelled only $N filters
|
|
140
|
+
* would score every privacy term as absent and pass no matter what.
|
|
141
|
+
*/
|
|
142
|
+
function accountFilter(where, params) {
|
|
143
|
+
const tests = [];
|
|
144
|
+
const consumed = new Set();
|
|
145
|
+
|
|
146
|
+
if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
|
|
147
|
+
if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
|
|
148
|
+
if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
|
|
149
|
+
// The reach term, modelled through the SHARED JS twin rather than
|
|
150
|
+
// re-implemented — an interpreter that read the COALESCE its own way could
|
|
151
|
+
// agree with a WRONG query and still call it a pass.
|
|
152
|
+
if (/COALESCE\((?:[a-z_]+\.)?recruiter_discoverable,/.test(where)) tests.push(isRecruiterDiscoverable);
|
|
153
|
+
|
|
154
|
+
for (const m of where.matchAll(/\$(\d+)/g)) {
|
|
155
|
+
if (!consumed.has(m[1])) {
|
|
156
|
+
throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const m of where.matchAll(/\b([a-z_]+)\s*(?:=|<>|!=)\s*'/g)) {
|
|
160
|
+
if (!KNOWN_ACCOUNT_COLUMNS.has(m[1])) {
|
|
161
|
+
throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
assert.ok(tests.length > 0, `fake pool: the accounts predicate in "${where}" carried NO readable term — the boundary would be vacuous`);
|
|
165
|
+
return (r) => tests.every((fn) => fn(r));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The membership read behind the ownership gate, joined to its project. Its
|
|
170
|
+
* terms are all placeholders, so an unconsumed $N is the loud error: it means
|
|
171
|
+
* the gate grew a condition this interpreter is not applying — which is exactly
|
|
172
|
+
* how the client-status join announced itself when it was added.
|
|
173
|
+
*/
|
|
174
|
+
function membershipFilter(where, params) {
|
|
175
|
+
const tests = [];
|
|
176
|
+
const consumed = new Set();
|
|
177
|
+
const clientStatus = (clientId) => (CLIENTS.find((c) => c.client_id === clientId) || {}).status;
|
|
178
|
+
|
|
179
|
+
const byId = /\bgithub_id = \$(\d+)/.exec(where);
|
|
180
|
+
if (byId) { consumed.add(byId[1]); const v = params[Number(byId[1]) - 1]; tests.push((r) => String(r.github_id) === String(v)); }
|
|
181
|
+
|
|
182
|
+
const byKind = /\bmembership_kind = \$(\d+)/.exec(where);
|
|
183
|
+
if (byKind) { consumed.add(byKind[1]); const v = params[Number(byKind[1]) - 1]; tests.push((r) => r.membership_kind === v); }
|
|
184
|
+
|
|
185
|
+
if (/\bsignin_verified_at IS NOT NULL\b/.test(where)) tests.push((r) => !!r.signin_verified_at);
|
|
186
|
+
|
|
187
|
+
// The JOIN's own condition. Modelled as a real lookup against CLIENTS, so a
|
|
188
|
+
// membership whose project is not in the table drops out the way an INNER JOIN
|
|
189
|
+
// drops it — not as a term that quietly passes.
|
|
190
|
+
const byStatus = /\bc\.status = \$(\d+)/.exec(where);
|
|
191
|
+
if (byStatus) {
|
|
192
|
+
consumed.add(byStatus[1]);
|
|
193
|
+
const v = params[Number(byStatus[1]) - 1];
|
|
194
|
+
tests.push((r) => clientStatus(r.client_id) === v);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const m of where.matchAll(/\$(\d+)/g)) {
|
|
198
|
+
if (!consumed.has(m[1])) {
|
|
199
|
+
throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}"`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
assert.ok(tests.length > 0, `fake pool: the membership predicate in "${where}" carried NO readable term`);
|
|
203
|
+
return (r) => tests.every((fn) => fn(r));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let calls = [];
|
|
207
|
+
let membershipQueryThrows = false;
|
|
208
|
+
|
|
209
|
+
const pool = {
|
|
210
|
+
query: async (text, params = []) => {
|
|
211
|
+
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
212
|
+
calls.push({ sql, params });
|
|
213
|
+
|
|
214
|
+
if (/FROM platform_identity_project_memberships/.test(sql)) {
|
|
215
|
+
if (membershipQueryThrows) throw new Error('simulated membership read failure');
|
|
216
|
+
assert.match(sql, /JOIN platform_identity_sso_clients/,
|
|
217
|
+
'the ownership read must join the project — a membership with no live project behind it is not an ownership claim');
|
|
218
|
+
const where = sql.slice(sql.indexOf(' WHERE ') + 7).replace(/ ORDER BY .*| LIMIT .*/g, '');
|
|
219
|
+
const rows = MEMBERSHIPS.filter(membershipFilter(where, params));
|
|
220
|
+
return { rows: rows.slice(0, 1), rowCount: Math.min(rows.length, 1) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The scouting rollup. Its consent decision lives ENTIRELY in the EXISTS
|
|
224
|
+
// sub-select over the accounts table, so that is what is executed here: one
|
|
225
|
+
// result row per account the predicate admits. The aggregation above it is
|
|
226
|
+
// not the boundary and is not modelled.
|
|
227
|
+
if (/platform_identity_builder_project_activity/.test(sql) && /EXISTS \( SELECT 1 FROM platform_identity_accounts/.test(sql)) {
|
|
228
|
+
const inner = /EXISTS \( SELECT 1 FROM platform_identity_accounts pa WHERE (.+?)\) GROUP BY/.exec(sql);
|
|
229
|
+
assert.ok(inner, `the rollup EXISTS clause changed shape — the interpreter must be updated, not bypassed: ${sql.slice(0, 160)}`);
|
|
230
|
+
const match = accountFilter(inner[1].replace(/pa\.github_id = a\.github_id AND /, ''), params);
|
|
231
|
+
const rows = ACCOUNTS.filter(match).map((a) => ({
|
|
232
|
+
github_id: a.github_id, github_login: a.github_login, display_name: a.display_name,
|
|
233
|
+
avatar_url: null, project_count: 1, total_credits: 1, total_tasks_shipped: 1,
|
|
234
|
+
total_karma: 0, last_active: 'T', disciplines: null,
|
|
235
|
+
}));
|
|
236
|
+
return { rows, rowCount: rows.length };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
throw new Error(`fake pool: unhandled statement — ${sql.slice(0, 120)}`);
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ===========================================================================
|
|
244
|
+
// The real route, the real permission gate, over real HTTP
|
|
245
|
+
// ===========================================================================
|
|
246
|
+
|
|
247
|
+
const auth = require('../src/bongos/auth.js');
|
|
248
|
+
const seams = require('../src/module-seams.js');
|
|
249
|
+
const catalog = require('../modules/government/catalog.js');
|
|
250
|
+
|
|
251
|
+
// The caller a request speaks for. requireBuilder is the SESSION layer — the one
|
|
252
|
+
// layer this task does not touch, and the one a DB-free test cannot have — so it
|
|
253
|
+
// is the double; every authorization decision below is made by real code.
|
|
254
|
+
let currentCaller = null;
|
|
255
|
+
|
|
256
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
257
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
258
|
+
require.cache[moduleApiPath] = {
|
|
259
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
260
|
+
exports: {
|
|
261
|
+
pool,
|
|
262
|
+
parsePagination: helpers.parsePagination,
|
|
263
|
+
// THE REAL GATE. Not a stub: the 403 a non-owner gets below is produced by
|
|
264
|
+
// production code resolving production permission atoms.
|
|
265
|
+
requirePermission: auth.requirePermission,
|
|
266
|
+
requireBuilder: (req, res, next) => {
|
|
267
|
+
if (!currentCaller) return res.fail('unauthenticated', 401);
|
|
268
|
+
req.builder = { ...currentCaller };
|
|
269
|
+
next();
|
|
270
|
+
},
|
|
271
|
+
logger: () => ({ error: () => {}, info: () => {}, warn: () => {} }),
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const express = require('express');
|
|
276
|
+
const buildScoutingRoutes = require('../modules/platform-identity/routes/scouting.js');
|
|
277
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
278
|
+
|
|
279
|
+
const app = express();
|
|
280
|
+
app.use(attachFail);
|
|
281
|
+
app.use('/api/gds', buildScoutingRoutes());
|
|
282
|
+
const server = app.listen(0, '127.0.0.1');
|
|
283
|
+
await new Promise((r) => server.once('listening', r));
|
|
284
|
+
const port = server.address().port;
|
|
285
|
+
|
|
286
|
+
// The real government port, answering the seeded permission set for the caller's
|
|
287
|
+
// rank — the same rows migration governance_002 gives that rank.
|
|
288
|
+
seams._reset();
|
|
289
|
+
seams.registerProvider('government', {
|
|
290
|
+
resolveBuilderPermissions: async () => catalog.seedForRank(currentCaller ? currentCaller.rank : 'xenos'),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// One request AS a caller. `caller` null = no session.
|
|
294
|
+
function getScouting(caller, query = '') {
|
|
295
|
+
currentCaller = caller;
|
|
296
|
+
calls = [];
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'GET', path: `/api/gds/scouting${query}` }, (res) => {
|
|
299
|
+
const chunks = [];
|
|
300
|
+
res.on('data', (c) => chunks.push(c));
|
|
301
|
+
res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
|
|
302
|
+
});
|
|
303
|
+
req.on('error', reject);
|
|
304
|
+
req.end();
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const logins = (res) => JSON.parse(res.text).builders.map((b) => b.github_login).sort();
|
|
308
|
+
|
|
309
|
+
// The cast. `rank` is the caller's rank on the HUB; `github_id` is the hub
|
|
310
|
+
// account whose project memberships decide the ownership door.
|
|
311
|
+
const OWNER = { id: 1, github_id: '9001', github_login: 'owner', rank: 'xenos' };
|
|
312
|
+
const PLAIN_MEMBER = { id: 2, github_id: '9002', github_login: 'plain', rank: 'xenos' };
|
|
313
|
+
const UNWITNESSED = { id: 3, github_id: '9003', github_login: 'unwitnessed', rank: 'xenos' };
|
|
314
|
+
const PENDING = { id: 4, github_id: '9004', github_login: 'pending', rank: 'thetes' };
|
|
315
|
+
const OPERATOR = { id: 5, github_id: '9005', github_login: 'operator', rank: 'metic' };
|
|
316
|
+
const ARCHON = { id: 6, github_id: '9006', github_login: 'archon', rank: 'archon' };
|
|
317
|
+
const OPTED_OUT_OWNER = { id: 7, github_id: '5000', github_login: 'optout', rank: 'xenos' };
|
|
318
|
+
const PRINCIPAL = { id: 8, github_id: 'system:bfg', github_login: 'bfg', rank: 'xenos' };
|
|
319
|
+
const SUSPENDED_OWNER = { id: 9, github_id: '9008', github_login: 'suspended', rank: 'xenos' };
|
|
320
|
+
|
|
321
|
+
// ===========================================================================
|
|
322
|
+
// 1. WHO MAY READ IT — the authz widening
|
|
323
|
+
// ===========================================================================
|
|
324
|
+
|
|
325
|
+
console.log('authz — who may read the recruiting directory:');
|
|
326
|
+
|
|
327
|
+
await t('CONTROL: the operator door is untouched — a `project.curate` holder still reads it', async () => {
|
|
328
|
+
// Without this, every refusal below could be a broken harness rather than a gate.
|
|
329
|
+
const res = await getScouting(OPERATOR);
|
|
330
|
+
assert.equal(res.status, 200, 'ADR 0157 put project.curate at the metic floor; R14 did not move it');
|
|
331
|
+
assert.ok(JSON.parse(res.text).builders.length > 0, 'and the read really returns the directory');
|
|
332
|
+
assert.equal((await getScouting(ARCHON)).status, 200, 'an Archon holds the atom too');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await t('a PROJECT OWNER reads it — with no rank at all (goal 1000045 C5)', async () => {
|
|
336
|
+
const res = await getScouting(OWNER);
|
|
337
|
+
assert.equal(res.status, 200,
|
|
338
|
+
'the registrant of a project holds no hub rank; the ownership door is the whole point of R14');
|
|
339
|
+
assert.ok(calls.some((c) => /platform_identity_project_memberships/.test(c.sql)),
|
|
340
|
+
'and the admission came from the membership READ, not from a rank the caller happens to hold');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await t('a builder who is neither owner nor operator CANNOT — and gets the unchanged 403', async () => {
|
|
344
|
+
const res = await getScouting(PLAIN_MEMBER);
|
|
345
|
+
assert.equal(res.status, 403, 'building on a project is not owning one');
|
|
346
|
+
const body = JSON.parse(res.text);
|
|
347
|
+
assert.equal(body.error.code, 'permission_forbidden', 'the refusal is the permission gate, unmodified');
|
|
348
|
+
assert.deepEqual(body.error.details.required, ['project.curate'],
|
|
349
|
+
'naming the atom it lacked — the widening must not swallow or reshape a refusal');
|
|
350
|
+
assert.equal((await getScouting(PENDING)).status, 403, 'a thetes without the atom is refused too');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await t('an owner claim the hub never WITNESSED is not an owner claim (ADR 0205 D4)', async () => {
|
|
354
|
+
const res = await getScouting(UNWITNESSED);
|
|
355
|
+
assert.equal(res.status, 403,
|
|
356
|
+
'membership_kind=owner without signin_verified_at is a client’s unproven self-report');
|
|
357
|
+
assert.ok(isProjectOwnerMembership({ membership_kind: OWNER_MEMBERSHIP_KIND, signin_verified_at: 'T' }));
|
|
358
|
+
assert.ok(!isProjectOwnerMembership({ membership_kind: OWNER_MEMBERSHIP_KIND, signin_verified_at: null }),
|
|
359
|
+
'the pure predicate agrees with the query — the two forms cannot drift');
|
|
360
|
+
assert.ok(!isProjectOwnerMembership(null), 'and an absent row is fail-closed');
|
|
361
|
+
// BOTH layers must carry the witness, and only one of them is visible to the
|
|
362
|
+
// assertions above: the JS predicate rejects an unwitnessed row after the fact,
|
|
363
|
+
// so deleting the SQL term alone leaves every test green. Pin it from source.
|
|
364
|
+
// (Order matters for a reason worth stating: the query filters first, so
|
|
365
|
+
// `LIMIT 1` can only ever pick a witnessed row. Were the SQL term dropped, the
|
|
366
|
+
// JS check would still refuse — the worst case is a false DENIAL, never a
|
|
367
|
+
// false admission.)
|
|
368
|
+
const authzSrc = fs.readFileSync(path.join(repo, 'modules/platform-identity/scouting-authz.js'), 'utf8');
|
|
369
|
+
assert.match(authzSrc, /signin_verified_at IS NOT NULL/,
|
|
370
|
+
'the ownership query must filter on the hub witness in SQL too — the JS predicate is the second layer, not the only one');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await t('SUSPENDING the project shuts the door — the derived read has a revocation lever', async () => {
|
|
374
|
+
// A membership row never expires and nothing ages it out, so without this the
|
|
375
|
+
// owner of a rogue project would keep reading every builder on the platform
|
|
376
|
+
// forever. Suspending the client already stops that project signing anyone in;
|
|
377
|
+
// the read derived from it must answer to the same switch.
|
|
378
|
+
const proj = CLIENTS.find((c) => c.client_id === 'proj-x');
|
|
379
|
+
assert.equal(proj.status, 'suspended', 'fixture check');
|
|
380
|
+
assert.equal((await getScouting(SUSPENDED_OWNER)).status, 403,
|
|
381
|
+
'an owner membership on a suspended project is not a live ownership claim');
|
|
382
|
+
try {
|
|
383
|
+
proj.status = 'active';
|
|
384
|
+
assert.equal((await getScouting(SUSPENDED_OWNER)).status, 200,
|
|
385
|
+
'CONTROL: the very same row admits once the project is active again — the refusal was the STATUS, not a broken fixture');
|
|
386
|
+
} finally {
|
|
387
|
+
proj.status = 'suspended';
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await t('a non-numeric principal answers false rather than throwing a bigint cast mid-request', async () => {
|
|
392
|
+
const res = await getScouting(PRINCIPAL);
|
|
393
|
+
assert.equal(res.status, 403, "core's 'system:bfg' shape must be refused, not 500");
|
|
394
|
+
assert.ok(!calls.some((c) => /platform_identity_project_memberships/.test(c.sql)),
|
|
395
|
+
'and the guard stops it BEFORE the bigint column ever sees it');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await t('a broken ownership read falls CLOSED onto the permission gate — never 500s, never admits', async () => {
|
|
399
|
+
membershipQueryThrows = true;
|
|
400
|
+
try {
|
|
401
|
+
assert.equal((await getScouting(OWNER)).status, 403,
|
|
402
|
+
'a database blip on the new door must deny, not admit — the failure mode is the OLD door');
|
|
403
|
+
assert.equal((await getScouting(OPERATOR)).status, 200,
|
|
404
|
+
'and the operator door keeps working through it: the outage is scoped to the question it broke');
|
|
405
|
+
} finally {
|
|
406
|
+
membershipQueryThrows = false;
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await t('an unauthenticated caller never reaches either door', async () => {
|
|
411
|
+
assert.equal((await getScouting(null)).status, 401, 'requireBuilder still fronts the whole route');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ===========================================================================
|
|
415
|
+
// 2. WHO APPEARS IN IT — the recruiting opt-out
|
|
416
|
+
// ===========================================================================
|
|
417
|
+
|
|
418
|
+
console.log('\nreach — the recruiting opt-out finally gates something:');
|
|
419
|
+
|
|
420
|
+
await t('an opted-out builder is ABSENT, while public, unhidden and active', async () => {
|
|
421
|
+
const res = await getScouting(OPERATOR);
|
|
422
|
+
const shown = logins(res);
|
|
423
|
+
assert.ok(!shown.includes('optout'),
|
|
424
|
+
`a public account that opted out of recruiting must not appear — got ${JSON.stringify(shown)}`);
|
|
425
|
+
assert.ok(!res.text.includes('Opt Out'), 'and nothing about them may appear in the body');
|
|
426
|
+
// The ownership door is asked first, so the rollup is not calls[0]. Find it.
|
|
427
|
+
const rollup = calls.find((c) => /platform_identity_builder_project_activity/.test(c.sql));
|
|
428
|
+
assert.ok(rollup, 'the directory read must have happened at all');
|
|
429
|
+
assert.match(rollup.sql, /COALESCE\(pa\.recruiter_discoverable/,
|
|
430
|
+
'the exclusion came from the DATABASE predicate, not from a caller remembering to check');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await t('and the whole matrix lands exactly where the specs put it', async () => {
|
|
434
|
+
const shown = logins(await getScouting(OPERATOR));
|
|
435
|
+
assert.deepEqual(shown, ['octo', 'optin'],
|
|
436
|
+
'in: reach-unset-and-public (octo) + explicitly-opted-in (optin). '
|
|
437
|
+
+ 'out: optout (opted out), scout (private, and reach never buys addressability), '
|
|
438
|
+
+ `shy (hidden), newbie (provisional) — got ${JSON.stringify(shown)}`);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await t('opting back IN restores them — the toggle is a toggle, not a tombstone', async () => {
|
|
442
|
+
const row = ACCOUNTS.find((a) => a.github_login === 'optout');
|
|
443
|
+
const before = row.recruiter_discoverable;
|
|
444
|
+
try {
|
|
445
|
+
row.recruiter_discoverable = null; // back to "follow visibility" = discoverable
|
|
446
|
+
assert.ok(logins(await getScouting(OPERATOR)).includes('optout'),
|
|
447
|
+
'resetting to NULL follows the public visibility setting and puts them back');
|
|
448
|
+
row.recruiter_discoverable = true; // and the explicit opt-in
|
|
449
|
+
assert.ok(logins(await getScouting(OPERATOR)).includes('optout'), 'as does an explicit true');
|
|
450
|
+
} finally {
|
|
451
|
+
row.recruiter_discoverable = before;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await t('MUTATION CHECK: drop the reach half and the opted-out builder comes straight back', async () => {
|
|
456
|
+
// The assertions above are only worth something if they can FAIL. This runs the
|
|
457
|
+
// rollup's own predicate with the reach term removed — exactly what deleting
|
|
458
|
+
// recruiterReachSql from scoutingListableSql would produce — and shows the
|
|
459
|
+
// interpreter genuinely readmits the row. A guard that passes with the guard
|
|
460
|
+
// deleted is worthless, so this is the assertion that makes the rest real.
|
|
461
|
+
const withReach = scoutingListableSql('pa');
|
|
462
|
+
const withoutReach = rollupVisibleSql('pa');
|
|
463
|
+
assert.notEqual(withReach, withoutReach, 'the two predicates must differ, or nothing was wired');
|
|
464
|
+
|
|
465
|
+
const admits = (clause) => ACCOUNTS.filter(accountFilter(clause, [])).map((a) => a.github_login).sort();
|
|
466
|
+
assert.deepEqual(admits(withReach), ['octo', 'optin'], 'with the reach term: the opt-out holds');
|
|
467
|
+
assert.deepEqual(admits(withoutReach), ['octo', 'optin', 'optout'],
|
|
468
|
+
'WITHOUT it: optout is readmitted — so the passing assertions above are load-bearing, not decorative');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await t('/people is the SAME endpoint, so "absent from /people" is true by construction', async () => {
|
|
472
|
+
const page = fs.readFileSync(path.join(repo, 'modules/hall-ui/public/people.js'), 'utf8');
|
|
473
|
+
assert.match(page, /fetch\('\/api\/gds\/scouting/,
|
|
474
|
+
'the hall directory reads GET /scouting; if it ever grows its own query, this proof stops covering it');
|
|
475
|
+
const source = fs.readFileSync(path.join(repo, 'modules/platform-identity/platform-identity.js'), 'utf8');
|
|
476
|
+
assert.match(source, /scoutingListableSql\('pa'\)/,
|
|
477
|
+
'and listScoutingRollup — the one reader behind both surfaces — joins the composed predicate');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ===========================================================================
|
|
481
|
+
// 3. THE TWO AXES NEVER TOUCH (ADR 0016)
|
|
482
|
+
// ===========================================================================
|
|
483
|
+
|
|
484
|
+
console.log('\nboundary — reach is not authority, in either direction:');
|
|
485
|
+
|
|
486
|
+
await t('opting out of recruiting grants and denies NO permission', async () => {
|
|
487
|
+
// The opted-out builder also owns a project. Being unfindable must not make
|
|
488
|
+
// them unable to find: they read the directory exactly as any owner does.
|
|
489
|
+
const res = await getScouting(OPTED_OUT_OWNER);
|
|
490
|
+
assert.equal(res.status, 200,
|
|
491
|
+
'opting out of being FOUND is not opting out of FINDING — the axes are independent');
|
|
492
|
+
const shown = logins(res);
|
|
493
|
+
assert.ok(!shown.includes('optout'),
|
|
494
|
+
'and their own copy of the directory omits them too: the read is viewer-independent, '
|
|
495
|
+
+ 'so a reader can never infer a privacy setting from whose list they got');
|
|
496
|
+
assert.deepEqual(shown, logins(await getScouting(OPERATOR)),
|
|
497
|
+
'byte-for-byte the same population an operator sees');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
await t('being scoutable buys nothing either — the response is the same descriptive whitelist', async () => {
|
|
501
|
+
const res = await getScouting(OWNER);
|
|
502
|
+
for (const leak of ['recruiter_discoverable', 'account_visibility', 'profile_state', 'hide_stats', 'client_id']) {
|
|
503
|
+
assert.ok(!res.text.includes(leak), `${leak} must never serialize into the directory body`);
|
|
504
|
+
}
|
|
505
|
+
const row = JSON.parse(res.text).builders[0];
|
|
506
|
+
assert.deepEqual(Object.keys(row).sort(),
|
|
507
|
+
['avatar_url', 'disciplines', 'display_name', 'github_id', 'github_login', 'last_active',
|
|
508
|
+
'project_count', 'total_credits', 'total_karma', 'total_tasks_shipped'],
|
|
509
|
+
'the projection stays an explicit whitelist — widening WHO reads it did not widen WHAT is read');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
await t('the route names its atom in the MIDDLEWARE LIST, so the auditor cannot be lied to', () => {
|
|
513
|
+
// The ownership door is invisible to a static scanner. If the permission gate
|
|
514
|
+
// were hidden inside the helper too, route-rank-check.js and the generated API
|
|
515
|
+
// docs would record this privileged route as any-builder — wrong in the
|
|
516
|
+
// dangerous direction. The atom is therefore spelled at the mount point.
|
|
517
|
+
const src = fs.readFileSync(path.join(repo, 'modules/platform-identity/routes/scouting.js'), 'utf8');
|
|
518
|
+
assert.match(src, /api\.requirePermission\('project\.curate'\)/,
|
|
519
|
+
'the atom must be spelled where the route-rank auditor reads it');
|
|
520
|
+
const rrc = require('../src/bongos/route-rank-check.js');
|
|
521
|
+
const lines = src.split('\n');
|
|
522
|
+
const at = lines.findIndex((l) => /router\.get\(/.test(l));
|
|
523
|
+
const { rank, perms } = rrc.classifyGate(lines, at);
|
|
524
|
+
assert.equal(rank, 'metic+archon', `the auditor must still record the privileged floor, got ${rank}`);
|
|
525
|
+
assert.deepEqual(perms, ['project.curate']);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
server.close();
|
|
529
|
+
seams._reset();
|
|
530
|
+
|
|
531
|
+
// ===========================================================================
|
|
532
|
+
// 4. Lane proof
|
|
533
|
+
// ===========================================================================
|
|
534
|
+
|
|
535
|
+
console.log('\ncoverage:');
|
|
536
|
+
|
|
537
|
+
await t('this proof runs in the DB-free unit lane (not parked as integration or known-failing)', () => {
|
|
538
|
+
const runner = fs.readFileSync(path.join(repo, 'scripts', 'gds', 'run-unit-tests.js'), 'utf8');
|
|
539
|
+
assert.ok(!/scouting_owner_access_and_reach/.test(runner),
|
|
540
|
+
'named in run-unit-tests.js it would be parked into a lane CI does not run — and a proof that never runs is not a gate (task 1002282)');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
544
|
+
process.exit(failed === 0 ? 0 : 1);
|