@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.
Files changed (61) hide show
  1. package/.bongos-core.json +108 -58
  2. package/.claude/skills/new-project/SKILL.md +22 -11
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +5 -1
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +103 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +282 -269
  13. package/docs/copy-registry.json +487 -368
  14. package/docs/design/mocks/hub/mine.html +377 -0
  15. package/docs/design/projects-hub-direction-v2.md +25 -1
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +4 -0
  18. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  19. package/modules/hall-ui/public/settings.html +3 -2
  20. package/modules/hall-ui/public/settings.js +10 -6
  21. package/modules/onboarding/routes/access-requests.js +16 -3
  22. package/modules/platform-identity/account-visibility.js +94 -3
  23. package/modules/platform-identity/platform-identity.js +50 -55
  24. package/modules/platform-identity/routes/connections.js +3 -3
  25. package/modules/platform-identity/tests/platform-identity.mjs +20 -0
  26. package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
  27. package/modules/provisioning/onboard-plan.js +13 -0
  28. package/modules/provisioning/provisioning.js +113 -5
  29. package/modules/provisioning/routes/provisioning.js +105 -0
  30. package/modules/provisioning/tests/provisioning.mjs +84 -0
  31. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -0
  32. package/modules/ui-design/kit/fixtures/provisioning-instances-empty.json +4 -0
  33. package/modules/ui-design/kit/fixtures/provisioning-instances.json +99 -0
  34. package/modules/ui-design/kit/lib.js +6 -2
  35. package/modules/ui-design/kit/probe.js +1 -1
  36. package/modules/ui-design/kit/render.js +1 -1
  37. package/modules/ui-design/kit/serve.js +63 -2
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/public-landing/assets/cosmos.css +224 -18
  41. package/public-landing/projects-empty.states.json +49 -0
  42. package/public-landing/projects-floor.states.json +63 -0
  43. package/public-landing/projects.html +273 -93
  44. package/public-landing/projects.probes.json +32 -7
  45. package/public-landing/projects.states.json +10 -6
  46. package/scripts/gds/fitness.js +9 -0
  47. package/scripts/gds/onboard.js +41 -1
  48. package/scripts/gds/provision-net.js +7 -3
  49. package/src/bongos/middleware/rate-limit.js +68 -2
  50. package/src/bongos/routes/auth.js +19 -12
  51. package/src/module-api.js +13 -1
  52. package/tests/account_existence_rate_limit.mjs +375 -0
  53. package/tests/account_privacy_flags.mjs +17 -10
  54. package/tests/effective_visibility_predicate.mjs +891 -0
  55. package/tests/module_api.mjs +1 -0
  56. package/tests/onboard.mjs +56 -0
  57. package/tests/profile_route.mjs +22 -11
  58. package/tests/projects_hub.mjs +102 -5
  59. package/tests/projects_hub_pre_uat.mjs +45 -11
  60. package/tests/provision.mjs +20 -0
  61. package/tests/ui_design_kit.mjs +32 -2
@@ -0,0 +1,375 @@
1
+ // tests/account_existence_rate_limit.mjs — the account-existence oracle has a
2
+ // ceiling, and it is ONE ceiling (task 1003339, goal 1000045; ADR 0209).
3
+ //
4
+ // The hole this pins shut: two PUBLIC routes answer "does this GitHub login have
5
+ // an account here?" — the onboarding module's GET /access-requests/status (the
6
+ // rich member|invited|pending|dismissed|none) and core's
7
+ // GET /auth/web/admission-status (its boolean twin) — and neither was throttled.
8
+ // The global limiter returns early for non-write methods, so every GET bypassed
9
+ // it. Scripted at line rate, either route turns a list of GitHub usernames into a
10
+ // membership roster: who builds here, and confirmation that a named person is
11
+ // present. That is targeting data, and it is worth having for a phishing run.
12
+ //
13
+ // What is executed here, not asserted about:
14
+ //
15
+ // 1. COUNTING — the first ACCOUNT_EXISTENCE_LIMIT probes from one address pass
16
+ // and the next is refused, on BOTH routes, over real HTTP through the real
17
+ // routers.
18
+ // 2. SHARING — the two routes spend from ONE budget. Exhaust it on the rich
19
+ // route and the boolean twin is closed too, and vice versa. This is the
20
+ // property that matters most and the easiest one to fake: two limiters
21
+ // configured 120/60s each look identical in a diff and hand an enumerator
22
+ // 240/min. So it is proved by behaviour AND by object identity (§4).
23
+ // 3. SLIDING — still refused 30s in, open again at 61s. Proved on the real
24
+ // mounted route by moving Date.now, not by testing a private helper.
25
+ // 4. ISOLATION — the ceiling is per-IP, so one abuser cannot close the door on
26
+ // everybody else.
27
+ // 5. CHEAPNESS — a refused probe issues no database query. The limiter runs
28
+ // ahead of the lookups, so the oracle is not also a free amplifier.
29
+ //
30
+ // MUTATION-CHECKED (the tests/own_session_revoke.mjs standard): remove
31
+ // `accountExistenceReadRateLimit` from either route's mount and this file goes
32
+ // red — §1's counting fails for that route and §2's sharing fails in both
33
+ // directions. Replace the shared instance with a per-route copy and §2 fails
34
+ // while §1 still passes, which is exactly the regression worth catching.
35
+ //
36
+ // DB-FREE. The fake pool below is a small in-memory builders/access_requests
37
+ // pair that answers from the statement's OWN shape, so the 200s are real answers
38
+ // and the query counter in §5 counts real intent. Everything above it is real:
39
+ // the real doorway (src/module-api.js — the module gets the limiter through it,
40
+ // which is the point), the real auth.webAdmissionStatus, the real routers, real
41
+ // HTTP with the production 'trust proxy' setting.
42
+ //
43
+ // Run: node tests/account_existence_rate_limit.mjs
44
+
45
+ import { strict as assert } from 'node:assert';
46
+ import http from 'node:http';
47
+ import { createRequire } from 'node:module';
48
+ import { readFileSync } from 'node:fs';
49
+ import { fileURLToPath } from 'node:url';
50
+ import { dirname, join } from 'node:path';
51
+ // ESM `import`, not createRequire, ON PURPOSE: the knip dead-code ratchet does
52
+ // not credit a CJS export reached through createRequire, so these three would
53
+ // read as unused and regress the baseline. Safe to hoist above the pool pin
54
+ // below — rate-limit.js has no requires of its own — and an ESM import of a CJS
55
+ // module resolves through the same cache, so SHARED_LIMITER is the very object
56
+ // auth.js and the doorway hold (which §4 asserts).
57
+ import {
58
+ accountExistenceReadRateLimit as SHARED_LIMITER,
59
+ ACCOUNT_EXISTENCE_LIMIT as LIMIT,
60
+ ACCOUNT_EXISTENCE_WINDOW_MS as WINDOW_MS,
61
+ } from '../src/bongos/middleware/rate-limit.js';
62
+
63
+ const require = createRequire(import.meta.url);
64
+ const ROOT = join(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 in-memory tables + their statement interpreter
75
+ // ===========================================================================
76
+
77
+ // 'ada' is a member; 'bo' has an invite; 'cy' is still pending; everyone else is
78
+ // a stranger. The 200-level answers below are therefore real route output, which
79
+ // is what makes "the limiter refused before the lookup" (§5) meaningful.
80
+ const builders = new Set(['ada']);
81
+ const accessRequests = [
82
+ { github_login: 'bo', status: 'invited' },
83
+ { github_login: 'cy', status: 'pending' },
84
+ { github_login: 'dez', status: 'dismissed' },
85
+ ];
86
+
87
+ let queries = [];
88
+ const pool = {
89
+ query: async (text, params = []) => {
90
+ const sql = String(text).replace(/\s+/g, ' ').trim();
91
+ queries.push({ sql, params });
92
+ const login = String(params[0] ?? '').toLowerCase();
93
+ const req = accessRequests.filter((r) => r.github_login.toLowerCase() === login);
94
+
95
+ // db-kernel accessStateByLogin — the three-EXISTS probe behind
96
+ // auth.webAdmissionStatus. Recognised by its own projection.
97
+ if (/AS member/.test(sql)) {
98
+ return { rows: [{
99
+ member: builders.has(login),
100
+ invited: req.some((r) => r.status === 'invited'),
101
+ pending: req.some((r) => r.status === 'pending'),
102
+ }], rowCount: 1 };
103
+ }
104
+ if (/^SELECT 1 FROM builders/.test(sql)) {
105
+ return builders.has(login) ? { rows: [{ '?column?': 1 }], rowCount: 1 } : { rows: [], rowCount: 0 };
106
+ }
107
+ if (/FROM access_requests/.test(sql)) {
108
+ // The route prefers an 'invited' row, else the most recent.
109
+ const ordered = [...req].sort((a, b) => Number(b.status === 'invited') - Number(a.status === 'invited'));
110
+ return ordered.length ? { rows: [{ status: ordered[0].status }], rowCount: 1 } : { rows: [], rowCount: 0 };
111
+ }
112
+ throw new Error(`fake pool: unhandled statement — ${sql.slice(0, 90)}`);
113
+ },
114
+ on: () => {},
115
+ };
116
+
117
+ // Pin the fake pool BEFORE anything that reaches for a connection is loaded, so
118
+ // the doorway, db-kernel and the routers all resolve to it and no real pg Pool
119
+ // is ever built.
120
+ const poolPath = require.resolve('../src/bongos/pool.js');
121
+ require.cache[poolPath] = {
122
+ id: poolPath, filename: poolPath, loaded: true,
123
+ exports: { pool, instanceDbName: () => 'test' },
124
+ };
125
+
126
+ const express = require('express');
127
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
128
+ const api = require('../src/module-api.js');
129
+ const buildAuthRouter = require('../src/bongos/routes/auth.js');
130
+ const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
131
+
132
+ const RICH = (login) => `/api/gds/access-requests/status?github_login=${encodeURIComponent(login)}`;
133
+ const BOOL = (login) => `/api/gds/auth/web/admission-status?login=${encodeURIComponent(login)}`;
134
+
135
+ const authRouter = buildAuthRouter();
136
+ const accessRouter = buildAccessRequestsRouter();
137
+
138
+ const app = express();
139
+ // Exactly what src/platform-server.js sets, so req.ip resolves from
140
+ // X-Forwarded-For the same way it does behind the real proxy.
141
+ app.set('trust proxy', 1);
142
+ app.use(express.json());
143
+ app.use(attachFail);
144
+ app.use('/api/gds', accessRouter);
145
+ app.use('/api/gds', authRouter);
146
+
147
+ const server = app.listen(0, '127.0.0.1');
148
+ await new Promise((r) => server.once('listening', r));
149
+ const PORT = server.address().port;
150
+
151
+ // node:http rather than fetch — §3 moves Date.now, and undici's connection
152
+ // bookkeeping should not be dragged along for the ride.
153
+ function call(path, ip) {
154
+ return new Promise((resolve, reject) => {
155
+ const req = http.request(
156
+ { host: '127.0.0.1', port: PORT, path, method: 'GET', headers: { 'x-forwarded-for': ip } },
157
+ (res) => {
158
+ let data = '';
159
+ res.on('data', (c) => { data += c; });
160
+ res.on('end', () => resolve({
161
+ status: res.statusCode,
162
+ headers: res.headers,
163
+ body: data ? JSON.parse(data) : null,
164
+ }));
165
+ }
166
+ );
167
+ req.on('error', reject);
168
+ req.end();
169
+ });
170
+ }
171
+
172
+ // Spend the whole budget for `ip`, asserting every probe was actually served.
173
+ async function exhaust(ip, pathFor = RICH, login = 'ada') {
174
+ for (let i = 0; i < LIMIT; i++) {
175
+ const r = await call(pathFor(login), ip);
176
+ assert.equal(r.status, 200, `probe ${i + 1} of ${LIMIT} should still be inside the budget (got ${r.status})`);
177
+ }
178
+ }
179
+
180
+ // ===========================================================================
181
+ // 1 — the ceiling exists, on both routes
182
+ // ===========================================================================
183
+
184
+ console.log('the ceiling exists — both probes stop answering:');
185
+
186
+ await t(`the rich probe answers ${LIMIT} times from one address, then refuses`, async () => {
187
+ const ip = '10.0.0.1';
188
+ await exhaust(ip);
189
+ const over = await call(RICH('ada'), ip);
190
+ assert.equal(over.status, 429, `probe ${LIMIT + 1} must be refused`);
191
+ });
192
+
193
+ await t('the rich probe\'s refusal is the ADR 0116 envelope, and says which budget ran out', async () => {
194
+ const ip = '10.0.0.2';
195
+ await exhaust(ip);
196
+ const over = await call(RICH('ada'), ip);
197
+ assert.equal(over.body.error.code, 'rate_limited');
198
+ assert.equal(over.body.error.details.scope, 'account-existence',
199
+ 'the two routes must name ONE budget — a per-route scope name would mean per-route buckets');
200
+ assert.equal(over.headers['x-ratelimit-scope'], 'account-existence');
201
+ assert.equal(over.headers['x-ratelimit-limit'], String(LIMIT));
202
+ const retry = Number(over.headers['retry-after']);
203
+ assert.ok(Number.isInteger(retry) && retry >= 1 && retry <= WINDOW_MS / 1000,
204
+ `Retry-After must be a whole number of seconds inside the window (got ${over.headers['retry-after']})`);
205
+ assert.equal(over.body.error.details.retry_after_seconds, retry, 'the body agrees with the header');
206
+ });
207
+
208
+ await t(`the boolean probe answers ${LIMIT} times from one address, then refuses`, async () => {
209
+ const ip = '10.0.0.3';
210
+ await exhaust(ip, BOOL);
211
+ const over = await call(BOOL('ada'), ip);
212
+ assert.equal(over.status, 429, `probe ${LIMIT + 1} must be refused`);
213
+ assert.equal(over.body.error.code, 'rate_limited');
214
+ assert.equal(over.body.error.details.scope, 'account-existence');
215
+ });
216
+
217
+ await t('under the ceiling both probes still answer truthfully — this is a cap, not a mute', async () => {
218
+ const ip = '10.0.0.4';
219
+ const member = await call(RICH('ada'), ip);
220
+ assert.deepEqual(member.body, { github_login: 'ada', status: 'member', admitted: true });
221
+ const invited = await call(RICH('bo'), ip);
222
+ assert.equal(invited.body.status, 'invited');
223
+ const stranger = await call(RICH('zzz'), ip);
224
+ assert.equal(stranger.body.status, 'none');
225
+ const bool = await call(BOOL('ada'), ip);
226
+ assert.deepEqual(bool.body, { admitted: true });
227
+ assert.equal((await call(BOOL('zzz'), ip)).body.admitted, false);
228
+ });
229
+
230
+ // ===========================================================================
231
+ // 2 — ONE budget, not two. The property a copy-pasted limiter would lose.
232
+ // ===========================================================================
233
+
234
+ console.log('\none oracle, one budget — the routes cannot be alternated for double the rate:');
235
+
236
+ await t('spending the budget on the rich probe closes the boolean twin as well', async () => {
237
+ const ip = '10.0.0.5';
238
+ await exhaust(ip, RICH);
239
+ const twin = await call(BOOL('ada'), ip);
240
+ assert.equal(twin.status, 429,
241
+ 'the twin answers the same question — two separate 120/60s budgets would hand an enumerator 240/min');
242
+ assert.equal(twin.body.error.details.scope, 'account-existence');
243
+ });
244
+
245
+ await t('and in the other direction — spending it on the boolean twin closes the rich probe', async () => {
246
+ const ip = '10.0.0.6';
247
+ await exhaust(ip, BOOL);
248
+ const rich = await call(RICH('ada'), ip);
249
+ assert.equal(rich.status, 429);
250
+ });
251
+
252
+ await t('a budget spent half on each route still totals one budget', async () => {
253
+ const ip = '10.0.0.7';
254
+ for (let i = 0; i < LIMIT; i++) {
255
+ const r = await call(i % 2 === 0 ? RICH('ada') : BOOL('ada'), ip);
256
+ assert.equal(r.status, 200, `alternating probe ${i + 1} should still be inside the shared budget`);
257
+ }
258
+ assert.equal((await call(RICH('ada'), ip)).status, 429, 'alternating must not buy a single extra probe');
259
+ assert.equal((await call(BOOL('ada'), ip)).status, 429);
260
+ });
261
+
262
+ // ===========================================================================
263
+ // 3 — the window slides, so the cap is a cap and not a permanent ban
264
+ // ===========================================================================
265
+
266
+ console.log('\nthe window slides — an exhausted address recovers, and not a moment early:');
267
+
268
+ await t(`still refused part-way through the window, open again after ${WINDOW_MS / 1000}s`, async () => {
269
+ const ip = '10.0.0.8';
270
+ const realNow = Date.now;
271
+ try {
272
+ await exhaust(ip);
273
+ assert.equal((await call(RICH('ada'), ip)).status, 429, 'exhausted to begin with');
274
+
275
+ // Move the clock, not the limiter: this is the route that ships, with its
276
+ // real 60s window, being asked again later.
277
+ const t0 = realNow();
278
+ Date.now = () => t0 + WINDOW_MS / 2;
279
+ assert.equal((await call(RICH('ada'), ip)).status, 429,
280
+ 'half a window later the timestamps are still inside it — a fixed-bucket reset would wrongly open here');
281
+
282
+ Date.now = () => t0 + WINDOW_MS + 1000;
283
+ const after = await call(RICH('ada'), ip);
284
+ assert.equal(after.status, 200, 'once the whole window has passed the budget is back');
285
+ assert.equal(after.body.status, 'member', 'and it is serving real answers again, not a cached 200');
286
+ } finally {
287
+ Date.now = realNow;
288
+ }
289
+ });
290
+
291
+ // ===========================================================================
292
+ // 4 — per-IP, and structurally shared
293
+ // ===========================================================================
294
+
295
+ console.log('\nthe ceiling is per-address, and the sharing is structural:');
296
+
297
+ await t('one exhausted address does not close the door on anybody else', async () => {
298
+ const noisy = '10.0.0.9';
299
+ const bystander = '10.0.0.10';
300
+ await exhaust(noisy);
301
+ assert.equal((await call(RICH('ada'), noisy)).status, 429);
302
+ const other = await call(RICH('ada'), bystander);
303
+ assert.equal(other.status, 200, 'a per-IP cap must not degenerate into a global kill switch');
304
+ assert.equal((await call(BOOL('ada'), bystander)).status, 200);
305
+ });
306
+
307
+ await t('both routes mount the very same middleware INSTANCE — sharing that cannot drift', () => {
308
+ const shared = SHARED_LIMITER;
309
+ assert.equal(typeof shared, 'function');
310
+ assert.equal(require('../src/bongos/middleware/rate-limit.js').accountExistenceReadRateLimit, shared,
311
+ 'the ESM and CJS views of the middleware must be the same object, or this whole test proves nothing');
312
+ assert.equal(api.accountExistenceReadRateLimit, shared,
313
+ 'the doorway must hand modules the same instance core uses, not a fresh one');
314
+
315
+ const handlers = (router, path) => {
316
+ const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods.get);
317
+ assert.ok(layer, `${path} must be a declared GET route`);
318
+ return layer.route.stack.map((s) => s.handle);
319
+ };
320
+ assert.ok(handlers(accessRouter, '/access-requests/status').includes(shared),
321
+ 'the onboarding probe must mount the shared limiter');
322
+ assert.ok(handlers(authRouter, '/auth/web/admission-status').includes(shared),
323
+ 'the core probe must mount the shared limiter');
324
+ });
325
+
326
+ await t('neither route vendors its own sliding window — the copy-paste regression stays out', () => {
327
+ for (const rel of ['modules/onboarding/routes/access-requests.js', 'src/bongos/routes/auth.js']) {
328
+ const src = readFileSync(join(ROOT, rel), 'utf8');
329
+ assert.ok(!/RATE_(WINDOW|LIMIT)|readRateLimit\s*=|new Map\(\); \/\/ ip/.test(src),
330
+ `${rel} must reach the shared limiter, never vendor a second one`);
331
+ }
332
+ const onboarding = readFileSync(join(ROOT, 'modules/onboarding/routes/access-requests.js'), 'utf8');
333
+ assert.match(onboarding, /accountExistenceReadRateLimit\s*\}?\s*=\s*api/,
334
+ 'the module reaches it through the published doorway (ADR 0083), not a deep core require');
335
+ });
336
+
337
+ // ===========================================================================
338
+ // 5 — a refused probe is cheap, so the oracle is not also an amplifier
339
+ // ===========================================================================
340
+
341
+ console.log('\na refused probe costs nothing:');
342
+
343
+ await t('the limiter runs ahead of the lookups — a 429 issues no query', async () => {
344
+ const ip = '10.0.0.11';
345
+ await exhaust(ip);
346
+ queries = [];
347
+ assert.equal((await call(RICH('ada'), ip)).status, 429);
348
+ assert.equal((await call(BOOL('ada'), ip)).status, 429);
349
+ assert.deepEqual(queries, [], 'a refused probe must not reach the database at all');
350
+ });
351
+
352
+ await t('an allowed probe DOES query — so the previous test is not vacuous', async () => {
353
+ queries = [];
354
+ await call(RICH('ada'), '10.0.0.12');
355
+ assert.ok(queries.length > 0, 'the 200 path really does hit the pool');
356
+ });
357
+
358
+ // ===========================================================================
359
+ // 6 — the budget is sized for the honest callers it has to clear
360
+ // ===========================================================================
361
+
362
+ await t('the ceiling clears both honest pollers with room to spare', () => {
363
+ // renderAccessPendingPage polls the boolean probe every 3s; `bongos login`
364
+ // polls the rich one every 5s. A future tightening that cannot fit both,
365
+ // several times over, would break sign-in for a shared/NAT address.
366
+ const honestPerMinute = 60 / 3 + 60 / 5;
367
+ assert.ok(LIMIT >= honestPerMinute * 3,
368
+ `${LIMIT}/min must leave real margin over the ${honestPerMinute}/min the pollers need`);
369
+ });
370
+
371
+ // ===========================================================================
372
+
373
+ server.close();
374
+ console.log(`\n${passed} passed, ${failed} failed`);
375
+ process.exit(failed === 0 ? 0 : 1);
@@ -284,16 +284,23 @@ test('the discoverable toggle names EXACTLY what it grants — the D3 sliver, na
284
284
  'the section ships hidden — it reveals only where the hub module answers');
285
285
  });
286
286
 
287
- test('no toggle claims a protection this task has not built — the read paths are still open', () => {
287
+ test('each toggle claims exactly what is enforced — visibility in force, recruiting not yet', () => {
288
288
  const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
289
289
  const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
290
- // Storing a flag is not enforcing it (task 1002967 wires the read paths). A
291
- // builder flipping this for personal safety must not be told they are hidden
292
- // while GET /profiles/:handle still serves the page.
293
- assert.match(html, /saved to your account now\. They start applying as[\s\S]*nothing is hidden yet/,
294
- 'the section states plainly that the choice is recorded, not yet in force');
295
- assert.doesNotMatch(js, /Private hides the page|is private — hidden from everyone/,
296
- 'no present-tense promise that the page is already hidden');
297
- assert.match(js, /Private will hide the page from everyone but you/,
298
- 'the row says what the choice WILL do');
290
+ // A toggle may promise only what a read path actually enforces, and the two
291
+ // rows are at different stages. Task 1002967 wired VISIBILITY into every
292
+ // public read, so its row moved to the present tense here — and it must also
293
+ // say the page 404s to the OWNER, because that is the surprising half of D2
294
+ // (the owner reads their record through the own-scope side door instead).
295
+ // DISCOVERABILITY is still stored-only: no recruiting surface reads it, so a
296
+ // present-tense promise there would be the "protection you do not have" this
297
+ // assertion pair exists to prevent.
298
+ assert.match(js, /Private hides the page from everyone — including you/,
299
+ 'visibility is enforced now, so the row says so — and names the 404-to-self');
300
+ assert.doesNotMatch(js, /Private will hide the page/,
301
+ 'the future tense outlived its truth the moment resolveHandle joined the predicate');
302
+ assert.match(html, /profile page setting applies now[\s\S]*Recruiter discoverability is saved to your[\s\S]*starts applying as the recruiting surfaces land/,
303
+ 'the section note splits the two: one in force, one recorded and waiting');
304
+ assert.doesNotMatch(js, /Recruiters looking for builders (?:can|are able to) find you\b/,
305
+ 'no present-tense promise about recruiting reach — nothing reads that flag yet');
299
306
  });