@bongos/core 1.19.428 → 1.19.429

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,628 @@
1
+ // tests/canonical_profile_url.mjs — the proof for P08 (task 1002804, ADR 0214):
2
+ // the canonical profile URL is resolved in ONE place, it is emitted only when
3
+ // the builder actually has a publicly visible platform account, the numeric-id
4
+ // redirect is not an existence oracle, and widening the profile response did not
5
+ // widen what it leaks.
6
+ //
7
+ // THE MECHANISM (the tests/connections_ownership.mjs → profile_privacy_owner_view
8
+ // .mjs house pattern). The fake pool builds its row filter FROM EACH STATEMENT'S
9
+ // OWN WHERE CLAUSE and throws on a placeholder or a literal comparison it did
10
+ // not consume, so a predicate this file cannot read can never degrade into "no
11
+ // constraint" and quietly pass. The REAL core router runs above it over a REAL
12
+ // loopback socket, and the REAL browser script runs in a vm above a stub DOM.
13
+ // Nothing here asserts against a canned response.
14
+ //
15
+ // THE FLAG IS FALSE TODAY (ADR 0214 D1), so the three render behaviours are all
16
+ // no-ops in production. Testing only that would be testing nothing, so the
17
+ // client tests run the SAME source with the one constant flipped — and assert
18
+ // the substitution changed exactly one thing, because a mutation that misses is
19
+ // the failure mode this file exists to avoid.
20
+ //
21
+ // LANE: DB-free. It must run on every PR, where there is no Postgres. The last
22
+ // test asserts that membership from the runner's own source (learning 1000218 —
23
+ // three tests declared INTEGRATION in a header, were in neither lane, and
24
+ // asserted nothing for months).
25
+
26
+ import { test } from 'node:test';
27
+ import assert from 'node:assert/strict';
28
+ import http from 'node:http';
29
+ import vm from 'node:vm';
30
+ import express from 'express';
31
+ import { createRequire } from 'node:module';
32
+ import { readFileSync } from 'node:fs';
33
+ import { join, dirname } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ // ESM `import`, not createRequire: knip credits a CJS module's exports as used
37
+ // only through real import bindings (the knip-createrequire learning).
38
+ import {
39
+ CANONICAL_PROFILE_PREFIX,
40
+ CANONICAL_PROFILE_PAGE_LIVE,
41
+ canonicalProfilePath,
42
+ canonicalProfileUrl,
43
+ normalizeHubOrigin,
44
+ getPublicHandle,
45
+ } from '../modules/platform-identity/canonical-profile.js';
46
+
47
+ const require = createRequire(import.meta.url);
48
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
49
+ const read = (...p) => readFileSync(join(ROOT, ...p), 'utf8');
50
+
51
+ const CLIENT_SRC = read('modules', 'hall-ui', 'public', 'profile-canonical.js');
52
+ const MODULE_SRC = read('modules', 'platform-identity', 'canonical-profile.js');
53
+ const PROFILE_JS = read('modules', 'hall-ui', 'public', 'profile.js');
54
+ const PROFILE_HTML = read('modules', 'hall-ui', 'public', 'profile.html');
55
+ const SERVE_INTERNAL = read('src', 'bongos', 'serve-internal.js');
56
+
57
+ // ===========================================================================
58
+ // The fake pool — its filter comes from the statement, and it refuses what it
59
+ // cannot read
60
+ // ===========================================================================
61
+
62
+ // github_id -> row. Only the columns the read actually SELECTs.
63
+ let ACCOUNTS = new Map();
64
+
65
+ function predicateFor(rawWhere, params) {
66
+ const consumed = new Set();
67
+ const tests = [];
68
+ const p = (n) => { consumed.add(String(n)); return params[Number(n) - 1]; };
69
+
70
+ let where = rawWhere;
71
+ let m;
72
+ if ((m = /\bgithub_id = \$(\d+)::bigint/.exec(where))) {
73
+ const v = p(m[1]);
74
+ tests.push((r) => String(r.github_id) === String(v));
75
+ where = where.replace(m[0], ' TRUE ');
76
+ }
77
+
78
+ for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
79
+ if (!consumed.has(ph[1])) {
80
+ throw new Error(`fake pool: unread predicate on $${ph[1]} in "${rawWhere}" — the interpreter must never silently ignore a filter`);
81
+ }
82
+ }
83
+ // A literal comparison this interpreter does not model would be a visibility
84
+ // predicate applied in SQL that the fake pool silently ignores — the exact
85
+ // shape that makes a privacy test pass while the code leaks.
86
+ const leftover = where.replace(/\bTRUE\b/g, '').trim();
87
+ if (leftover) {
88
+ throw new Error(`fake pool: unmodelled WHERE remainder "${leftover}" — refusing rather than ignoring a filter`);
89
+ }
90
+ return (r) => tests.every((fn) => fn(r));
91
+ }
92
+
93
+ const pool = {
94
+ async query(text, params = []) {
95
+ const sql = String(text).replace(/\s+/g, ' ').trim();
96
+ const m = /^SELECT (.+?) FROM platform_identity_accounts WHERE (.+)$/i.exec(sql);
97
+ if (!m) throw new Error(`fake pool: unsupported statement — ${sql}`);
98
+ const cols = m[1].split(',').map((c) => c.trim());
99
+ const keep = predicateFor(m[2], params);
100
+ const rows = [...ACCOUNTS.values()].filter(keep).map((r) => {
101
+ const out = {};
102
+ for (const c of cols) out[c] = r[c];
103
+ return out;
104
+ });
105
+ return { rows, rowCount: rows.length };
106
+ },
107
+ };
108
+
109
+ // Calibrate the instrument before trusting it (the house rule).
110
+ test('the fake pool refuses a filter it cannot read rather than ignoring it', () => {
111
+ assert.throws(
112
+ () => predicateFor('github_id = $1::bigint AND secret = $2', ['1', 'x']),
113
+ /unread predicate on \$2/,
114
+ );
115
+ assert.throws(
116
+ () => predicateFor("github_id = $1::bigint AND profile_state = 'active'", ['1']),
117
+ /unmodelled WHERE remainder/,
118
+ );
119
+ });
120
+
121
+ test('the fake pool refuses a statement shape it does not model', async () => {
122
+ await assert.rejects(() => pool.query('DELETE FROM platform_identity_accounts'), /unsupported statement/);
123
+ });
124
+
125
+ const account = (over = {}) => ({
126
+ github_id: '4242',
127
+ handle: 'ada',
128
+ profile_state: 'active',
129
+ account_visibility: 'public',
130
+ // The T&C witness task 1002913 folded into the CONSENT floor that
131
+ // isAccountVisible composes from. A visible account has one.
132
+ terms_accepted_at: '2026-08-01T00:00:00.000Z',
133
+ ...over,
134
+ });
135
+
136
+ const seed = (...rows) => { ACCOUNTS = new Map(rows.map((r) => [String(r.github_id), r])); };
137
+
138
+ // ===========================================================================
139
+ // The resolver — composition rules, provable while the flag is false
140
+ // ===========================================================================
141
+
142
+ test('normalizeHubOrigin accepts a bare https origin and refuses everything else', () => {
143
+ assert.equal(normalizeHubOrigin('https://cloudbongos.com'), 'https://cloudbongos.com');
144
+ assert.equal(normalizeHubOrigin('https://cloudbongos.com/'), 'https://cloudbongos.com');
145
+ assert.equal(normalizeHubOrigin(' https://cloudbongos.com// '), 'https://cloudbongos.com');
146
+ // A downgraded scheme must never be named by a canonical.
147
+ assert.equal(normalizeHubOrigin('http://cloudbongos.com'), null);
148
+ // Credentials, a path, a query and a fragment are not origins.
149
+ assert.equal(normalizeHubOrigin('https://u:p@cloudbongos.com'), null);
150
+ assert.equal(normalizeHubOrigin('https://cloudbongos.com/u'), null);
151
+ assert.equal(normalizeHubOrigin('https://cloudbongos.com?x=1'), null);
152
+ assert.equal(normalizeHubOrigin('https://cloudbongos.com#f'), null);
153
+ assert.equal(normalizeHubOrigin('javascript:alert(1)'), null);
154
+ assert.equal(normalizeHubOrigin(''), null);
155
+ assert.equal(normalizeHubOrigin(null), null);
156
+ assert.equal(normalizeHubOrigin(undefined), null);
157
+ });
158
+
159
+ test('canonicalProfilePath vouches only for a handle in the handle charset', () => {
160
+ assert.equal(canonicalProfilePath('ada'), '/u/ada');
161
+ assert.equal(canonicalProfilePath('ADA'), '/u/ada');
162
+ assert.equal(canonicalProfilePath(' ada-l '), '/u/ada-l');
163
+ // Anything that could escape the path segment is refused, not encoded — this
164
+ // value goes into an href and a <link rel=canonical>.
165
+ assert.equal(canonicalProfilePath('ada/../../etc'), null);
166
+ assert.equal(canonicalProfilePath('ada?x=1'), null);
167
+ assert.equal(canonicalProfilePath('a b'), null);
168
+ assert.equal(canonicalProfilePath('a'.repeat(40)), null);
169
+ assert.equal(canonicalProfilePath(''), null);
170
+ assert.equal(canonicalProfilePath(null), null);
171
+ });
172
+
173
+ test('the canonical URL is DARK: the page at /u/ does not resolve yet (ADR 0214 D1)', () => {
174
+ assert.equal(CANONICAL_PROFILE_PAGE_LIVE, false);
175
+ assert.equal(canonicalProfileUrl('https://cloudbongos.com', 'ada'), null,
176
+ 'while the page 404s, the resolver must point nowhere — a canonical naming a 404 spends the page that works');
177
+ });
178
+
179
+ test('no /u/ page route exists while the flag says the page is not live', () => {
180
+ // The anti-drift device R14 inherits: land the page without flipping the flag
181
+ // and this fails. Scans every server-side route file for a registration in the
182
+ // canonical namespace.
183
+ const dirs = ['src', 'modules'];
184
+ const found = [];
185
+ const walk = (rel) => {
186
+ for (const e of require('node:fs').readdirSync(join(ROOT, rel), { withFileTypes: true })) {
187
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
188
+ const next = `${rel}/${e.name}`;
189
+ if (e.isDirectory()) { walk(next); continue; }
190
+ if (!e.name.endsWith('.js')) continue;
191
+ const code = readFileSync(join(ROOT, next), 'utf8');
192
+ if (new RegExp(`(?:router|app)\\.(?:get|use|all)\\(\\s*['"\`]\\${CANONICAL_PROFILE_PREFIX}`).test(code)) found.push(next);
193
+ }
194
+ };
195
+ for (const d of dirs) walk(d);
196
+ if (!CANONICAL_PROFILE_PAGE_LIVE) {
197
+ assert.deepEqual(found, [],
198
+ `a route in the ${CANONICAL_PROFILE_PREFIX} namespace has landed while CANONICAL_PROFILE_PAGE_LIVE is still false — `
199
+ + 'flip it (modules/platform-identity/canonical-profile.js) and its client mirror, or the page will exist and nothing will point at it');
200
+ } else {
201
+ assert.ok(found.length, 'the flag says the canonical page is live but no route serves it');
202
+ }
203
+ });
204
+
205
+ // ===========================================================================
206
+ // getPublicHandle — the EXISTENCE predicate, and the indistinguishable null
207
+ // ===========================================================================
208
+
209
+ test('getPublicHandle answers the handle for an active, public account', async () => {
210
+ seed(account());
211
+ assert.equal(await getPublicHandle('4242', { pool }), 'ada');
212
+ assert.equal(await getPublicHandle(4242, { pool }), 'ada', 'a numeric id resolves the same as its string');
213
+ });
214
+
215
+ test('getPublicHandle returns an INDISTINGUISHABLE null for absent, provisional and private', async () => {
216
+ const answers = [];
217
+ seed();
218
+ answers.push(await getPublicHandle('4242', { pool })); // absent
219
+ seed(account({ profile_state: 'provisional' }));
220
+ answers.push(await getPublicHandle('4242', { pool })); // provisional
221
+ seed(account({ account_visibility: 'private' }));
222
+ answers.push(await getPublicHandle('4242', { pool })); // private
223
+ assert.deepEqual(answers, [null, null, null],
224
+ 'the three "no" cases ADR 0171 D2 requires to stay merged must produce the identical answer');
225
+ });
226
+
227
+ test('getPublicHandle is NOT gated on hide_stats — hiding suppresses numbers, never addressability', async () => {
228
+ seed(account({ hide_stats: true }));
229
+ assert.equal(await getPublicHandle('4242', { pool }), 'ada',
230
+ 'a builder who hides their stats still has a public profile at a canonical URL; it just carries no numbers');
231
+ // The assertion above only BITES because the read hands the predicate all
232
+ // three columns. Drop hide_stats from the SELECT and isAccountVisible and
233
+ // isRollupVisible return the same answer here — the predicate choice becomes
234
+ // decorative and a later swap to the wrong one fails open, silently. Pinning
235
+ // the projection is what keeps the test above a real test.
236
+ assert.match(MODULE_SRC, /SELECT handle, profile_state, account_visibility, terms_accepted_at, hide_stats/,
237
+ 'getPublicHandle must SELECT the shared predicates’ WHOLE column set: hide_stats so the predicate choice above still decides something, terms_accepted_at because isAccountVisible composes the consent floor that reads it (task 1002913) and a missing column fail-closes this to null for everyone');
238
+ });
239
+
240
+ // Locked in after a live near-miss: merging origin/main mid-session brought task
241
+ // 1002913, which folded the T&C witness into isAccountActive — the consent floor
242
+ // isAccountVisible composes from. This read did not SELECT the new column, so
243
+ // every account read as un-consented and the whole feature fail-closed to null.
244
+ // Safe direction, still broken. The assertion below is what caught it, and it
245
+ // stays so the next predicate change cannot pass silently.
246
+ test('an account that never accepted the terms gets no handle — the consent floor still applies', async () => {
247
+ seed(account({ terms_accepted_at: null }));
248
+ assert.equal(await getPublicHandle('4242', { pool }), null,
249
+ 'isAccountVisible composes the consent floor (task 1002913); an un-accepted account is not publicly addressable');
250
+ seed(account());
251
+ assert.equal(await getPublicHandle('4242', { pool }), 'ada',
252
+ 'and the same row WITH the witness resolves — otherwise the test above passes because the read is simply broken');
253
+ });
254
+
255
+ test('getPublicHandle refuses a non-numeric principal without touching the database', async () => {
256
+ seed(account());
257
+ let queried = false;
258
+ const spy = { query: async (...a) => { queried = true; return pool.query(...a); } };
259
+ assert.equal(await getPublicHandle('system:reconciler', { pool: spy }), null);
260
+ assert.equal(await getPublicHandle(null, { pool: spy }), null);
261
+ assert.equal(queried, false, 'a system:* id must answer null, not reach a bigint cast');
262
+ });
263
+
264
+ test('getPublicHandle refuses a stored handle outside the handle charset', async () => {
265
+ seed(account({ handle: 'ada/../etc' }));
266
+ assert.equal(await getPublicHandle('4242', { pool }), null,
267
+ 'the resolver validates on the way OUT too — a malformed stored handle must not reach an href');
268
+ });
269
+
270
+ // ===========================================================================
271
+ // The widened profile response — real router, real socket
272
+ // ===========================================================================
273
+
274
+ const BUILDER = {
275
+ id: 7,
276
+ github_id: '4242',
277
+ github_login: 'ada',
278
+ display_name: 'Ada',
279
+ avatar_url: null,
280
+ rank: 'metic',
281
+ total_credits: 12,
282
+ karma: 3,
283
+ created_at: '2026-01-01T00:00:00.000Z',
284
+ preferred_disciplines: ['engineer'],
285
+ // The private columns the whitelist exists to keep out. Sentinel values so a
286
+ // raw-text scan of the serialized body bites even if a key were renamed.
287
+ anthropic_email: 'SENTINEL-anthropic-email',
288
+ anthropic_user_id: 'SENTINEL-anthropic-user',
289
+ monthly_budget_usd: 999,
290
+ system_role: 'SENTINEL-system-role',
291
+ box_blocked: true,
292
+ box_blocked_reason: 'SENTINEL-box-blocked',
293
+ deactivated_reason: 'SENTINEL-deactivated',
294
+ };
295
+
296
+ const dbPath = require.resolve('../src/bongos/db.js');
297
+ require.cache[dbPath] = {
298
+ id: dbPath,
299
+ filename: dbPath,
300
+ loaded: true,
301
+ exports: {
302
+ RANKS: ['xenos', 'thetes', 'metic', 'archon'],
303
+ getBuilderById: async (id) => (String(id) === '7' ? { ...BUILDER } : null),
304
+ hasCliSession: async () => false,
305
+ },
306
+ };
307
+
308
+ const seams = require('../src/module-seams.js');
309
+ seams._reset();
310
+ seams.registerProvider('lifecycle', {
311
+ countShippedTasksForBuilder: async () => 3,
312
+ recentShippedTasksByBuilder: async () => [],
313
+ });
314
+ seams.registerProvider('reward', { getBuilderAchievements: async () => [] });
315
+
316
+ // The hub port, resolved per request, so a test can flip what it answers.
317
+ let hubHandle = async (githubId) => getPublicHandle(githubId, { pool });
318
+ let hubExtras = async () => null;
319
+ seams.registerProvider('platform-identity.hub', {
320
+ getPublicProfileExtras: (githubId) => hubExtras(githubId),
321
+ getOwnProfileExtras: async () => null,
322
+ getPublicHandle: (githubId) => hubHandle(githubId),
323
+ });
324
+
325
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
326
+ const buildBuildersRouter = require('../src/bongos/routes/builders.js');
327
+
328
+ const app = express();
329
+ app.use(express.json());
330
+ app.use(attachFail);
331
+ app.use('/api/gds', buildBuildersRouter());
332
+ const server = app.listen(0, '127.0.0.1');
333
+ await new Promise((r) => server.once('listening', r));
334
+ const port = server.address().port;
335
+
336
+ function get(path) {
337
+ return new Promise((resolve, reject) => {
338
+ const req = http.request({ host: '127.0.0.1', port, method: 'GET', path }, (res) => {
339
+ const chunks = [];
340
+ res.on('data', (c) => chunks.push(c));
341
+ res.on('end', () => {
342
+ const text = Buffer.concat(chunks).toString('utf8');
343
+ let data = null; try { data = JSON.parse(text); } catch { /* non-JSON */ }
344
+ resolve({ status: res.statusCode, text, data });
345
+ });
346
+ });
347
+ req.on('error', reject);
348
+ req.end();
349
+ });
350
+ }
351
+
352
+ test('the profile response carries the handle when the account is publicly visible', async () => {
353
+ seed(account());
354
+ const r = await get('/api/gds/builders/7/profile');
355
+ assert.equal(r.status, 200);
356
+ assert.equal(r.data.handle, 'ada');
357
+ });
358
+
359
+ test('the profile response omits the handle for absent / provisional / private, identically', async () => {
360
+ const bodies = [];
361
+ seed();
362
+ bodies.push((await get('/api/gds/builders/7/profile')).data);
363
+ seed(account({ profile_state: 'provisional' }));
364
+ bodies.push((await get('/api/gds/builders/7/profile')).data);
365
+ seed(account({ account_visibility: 'private' }));
366
+ bodies.push((await get('/api/gds/builders/7/profile')).data);
367
+ for (const b of bodies) assert.equal(b.handle, null);
368
+ assert.equal(JSON.stringify(bodies[0]), JSON.stringify(bodies[1]),
369
+ 'a provisional account must be byte-identical to no account');
370
+ assert.equal(JSON.stringify(bodies[1]), JSON.stringify(bodies[2]),
371
+ 'a private account must be byte-identical to a provisional one — a redirect that fires on the difference would be an oracle');
372
+ });
373
+
374
+ test('widening the response did not widen the leak: no private column appears anywhere in the body', async () => {
375
+ seed(account());
376
+ hubExtras = async () => ({ totals: { projects: 1, credits: 2, works_shipped: 3, karma: 4 }, disciplines: [], last_active: null, projects: [], more_projects: 0 });
377
+ const r = await get('/api/gds/builders/7/profile');
378
+ hubExtras = async () => null;
379
+ assert.equal(r.status, 200);
380
+ // The raw serialized text, not the parsed keys — a private value smuggled
381
+ // under a different key still fails.
382
+ assert.doesNotMatch(r.text, /SENTINEL-/, 'a private builder column reached the public profile body');
383
+ assert.doesNotMatch(r.text, /monthly_budget_usd|anthropic_|system_role|box_blocked|deactivated_reason/);
384
+ assert.deepEqual(Object.keys(r.data.builder).sort(), [
385
+ 'avatar_url', 'created_at', 'display_name', 'github_login', 'id', 'karma',
386
+ 'preferred_disciplines', 'rank', 'total_credits',
387
+ ], 'the builder whitelist itself must not have grown — handle rides beside it, not inside it');
388
+ });
389
+
390
+ test('the handle rides a DIFFERENT predicate from cross_project, which is what closes the twice-renamed hole', async () => {
391
+ seed(account({ hide_stats: true }));
392
+ hubExtras = async () => null; // rollup-invisible: hidden
393
+ const r = await get('/api/gds/builders/7/profile');
394
+ assert.equal(r.data.cross_project, null);
395
+ assert.equal(r.data.handle, 'ada',
396
+ 'a hidden builder still needs an address: this is the handle profile-connections.js could not get from a twice-renamed github_login');
397
+ });
398
+
399
+ test('publishing the handle map put the route under the SHARED public read ceiling', async () => {
400
+ // The global limiter meters writes only, and the public read limiter is
401
+ // mounted under /public/*, which this route is not — so before this task an
402
+ // id walk read the whole roster at line rate, and it now also carries the
403
+ // builder → canonical-handle map that /profiles/:handle is capped for.
404
+ // Asserted by OBJECT IDENTITY against the shared instance, not by "some
405
+ // limiter is mounted": a second 120/60s limiter would look identical in a
406
+ // diff and hand an enumerator two budgets (ADR 0209's finding).
407
+ const { publicReadRateLimitMiddleware, PUBLIC_READ_LIMIT } = require('../src/bongos/middleware/rate-limit.js');
408
+ const layer = buildBuildersRouter().stack.find(
409
+ (l) => l.route && l.route.path === '/builders/:id/profile' && l.route.methods.get,
410
+ );
411
+ assert.ok(layer, 'the profile route is no longer registered under the path this guard watches');
412
+ const handlers = layer.route.stack.map((s) => s.handle);
413
+ assert.ok(handlers.includes(publicReadRateLimitMiddleware),
414
+ 'GET /builders/:id/profile must mount the SHARED public read limiter — a private copy would be a second budget');
415
+ assert.equal(PUBLIC_READ_LIMIT, 120, 'the /public/* posture this route now shares');
416
+ });
417
+
418
+ test('an older hub provider without getPublicHandle degrades to null instead of throwing', async () => {
419
+ seams._reset();
420
+ seams.registerProvider('lifecycle', {
421
+ countShippedTasksForBuilder: async () => 3,
422
+ recentShippedTasksByBuilder: async () => [],
423
+ });
424
+ seams.registerProvider('reward', { getBuilderAchievements: async () => [] });
425
+ seams.registerProvider('platform-identity.hub', { getPublicProfileExtras: async () => null, getOwnProfileExtras: async () => null });
426
+ const r = await get('/api/gds/builders/7/profile');
427
+ assert.equal(r.status, 200, 'a cross-version hub must not 500 the profile route');
428
+ assert.equal(r.data.handle, null);
429
+ });
430
+
431
+ test.after(() => server.close());
432
+
433
+ // ===========================================================================
434
+ // The browser script — the three behaviours, run for real in a vm
435
+ // ===========================================================================
436
+
437
+ const LIVE_SRC = CLIENT_SRC.replace('const CANONICAL_PROFILE_PAGE_LIVE = false;', 'const CANONICAL_PROFILE_PAGE_LIVE = true;');
438
+
439
+ test('the live-source substitution changes exactly one thing (the instrument, checked first)', () => {
440
+ assert.notEqual(LIVE_SRC, CLIENT_SRC, 'the constant this file flips is not spelled the way the test expects — every client assertion below would be testing the dark path');
441
+ const diffs = CLIENT_SRC.split('\n').filter((l, i) => l !== LIVE_SRC.split('\n')[i]);
442
+ assert.equal(diffs.length, 1, 'the substitution must flip one line, not rewrite the module');
443
+ });
444
+
445
+ function boot(src, { hubOriginMeta, modules, search } = {}) {
446
+ const head = { children: [], appendChild(el) { this.children.push(el); } };
447
+ const els = new Map();
448
+ const el = (id) => {
449
+ if (!els.has(id)) els.set(id, { id, hidden: true, _html: '', attrs: {}, setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; }, set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } });
450
+ return els.get(id);
451
+ };
452
+ const meta = hubOriginMeta === undefined ? null : { getAttribute: () => hubOriginMeta };
453
+ const replaced = [];
454
+ const assigned = [];
455
+ const sandbox = {
456
+ console: { error() {}, warn() {}, log() {} },
457
+ URL,
458
+ URLSearchParams,
459
+ document: {
460
+ head,
461
+ querySelector: (sel) => (sel === 'meta[name="hub-origin"]' ? meta : (head.children.find((c) => c.attrs.rel === 'canonical') || null)),
462
+ getElementById: (id) => (id === 'xp-scroll' || id === 'xp-body' ? el(id) : null),
463
+ createElement: () => ({ attrs: {}, setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; } }),
464
+ },
465
+ // `assign` is stubbed too, and deliberately records into a SEPARATE list:
466
+ // swapping replace→assign must fail on the assertion (the numeric URL would
467
+ // stay in history as a place Back returns to), not crash on an undefined
468
+ // method, which would "pass" the mutation check for the wrong reason.
469
+ location: { search: search || '', replace: (u) => replaced.push(u), assign: (u) => assigned.push(u), href: '' },
470
+ window: { OTB: { escapeHtml: (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }, __MODULES__: modules || [] },
471
+ };
472
+ sandbox.globalThis = sandbox;
473
+ vm.createContext(sandbox);
474
+ vm.runInContext(src, sandbox, { filename: 'profile-canonical.js' });
475
+ return {
476
+ api: sandbox.window.OTBCanonical,
477
+ canonicalHref: () => { const l = head.children.find((c) => c.attrs.rel === 'canonical'); return l ? l.attrs.href : null; },
478
+ xp: () => el('xp-scroll'),
479
+ xpHtml: () => el('xp-body').innerHTML,
480
+ replaced,
481
+ assigned,
482
+ };
483
+ }
484
+
485
+ const FEDERATED = { hubOriginMeta: 'https://cloudbongos.com', modules: ['lifecycle', 'hall-ui'] };
486
+ const HUB = { hubOriginMeta: 'https://cloudbongos.com', modules: ['platform-identity', 'hall-ui'] };
487
+
488
+ test('DARK today: with the shipped source nothing is emitted, anywhere', () => {
489
+ const fed = boot(CLIENT_SRC, FEDERATED);
490
+ fed.api.render({ handle: 'ada' });
491
+ assert.equal(fed.canonicalHref(), null, 'a canonical must not name a URL that 404s');
492
+ assert.equal(fed.xp().hidden, true);
493
+ const hub = boot(CLIENT_SRC, { ...HUB, search: '?id=7' });
494
+ hub.api.render({ handle: 'ada' });
495
+ assert.deepEqual(hub.replaced, [], 'redirecting a working page to a 404 would be the worst of the three');
496
+ });
497
+
498
+ test('LIVE, federated: rel=canonical points at the hub and the link-out is visible', () => {
499
+ const page = boot(LIVE_SRC, FEDERATED);
500
+ page.api.render({ handle: 'ada' });
501
+ assert.equal(page.canonicalHref(), 'https://cloudbongos.com/u/ada');
502
+ assert.equal(page.xp().hidden, false);
503
+ assert.match(page.xpHtml(), /href="https:\/\/cloudbongos\.com\/u\/ada"/);
504
+ assert.match(page.xpHtml(), /See the cross-project record/,
505
+ 'the link-out is what ADR 0171 D1 asks a federated instance to render instead of inlining numbers');
506
+ assert.deepEqual(page.replaced, [], 'a federated instance shows its OWN record — it must never redirect away from it');
507
+ });
508
+
509
+ test('LIVE, hub: the numeric ?id= form redirects to the canonical handle URL', () => {
510
+ const page = boot(LIVE_SRC, { ...HUB, search: '?id=7' });
511
+ page.api.render({ handle: 'ada' });
512
+ assert.deepEqual(page.replaced, ['https://cloudbongos.com/u/ada']);
513
+ assert.equal(page.canonicalHref(), null, 'a page that redirects must not also declare a canonical');
514
+ });
515
+
516
+ test('LIVE, hub without ?id=: the self view is not redirected', () => {
517
+ const page = boot(LIVE_SRC, { ...HUB, search: '' });
518
+ page.api.render({ handle: 'ada' });
519
+ assert.deepEqual(page.replaced, [], 'the handle-less /profile is the viewer\'s own page, not a numeric address');
520
+ });
521
+
522
+ test('LIVE: no handle means no canonical, no link-out and NO REDIRECT — the fallback is a working page', () => {
523
+ for (const data of [{}, { handle: null }, { handle: '' }]) {
524
+ const fed = boot(LIVE_SRC, FEDERATED);
525
+ fed.api.render(data);
526
+ assert.equal(fed.canonicalHref(), null);
527
+ assert.equal(fed.xp().hidden, true);
528
+
529
+ const hub = boot(LIVE_SRC, { ...HUB, search: '?id=7' });
530
+ hub.api.render(data);
531
+ assert.deepEqual(hub.replaced, [],
532
+ 'absent, provisional and private all arrive here as a missing handle and all get the ordinary local page — there is no error shape to read a signal off');
533
+ }
534
+ });
535
+
536
+ test('LIVE: a vanilla instance with no hub origin emits nothing', () => {
537
+ for (const meta of [undefined, '', ' ', 'http://cloudbongos.com', 'https://cloudbongos.com/u']) {
538
+ const page = boot(LIVE_SRC, { hubOriginMeta: meta, modules: [] });
539
+ page.api.render({ handle: 'ada' });
540
+ assert.equal(page.canonicalHref(), null, `an unusable hub-origin meta (${JSON.stringify(meta)}) must produce no canonical, never a relative one`);
541
+ assert.equal(page.xp().hidden, true);
542
+ }
543
+ });
544
+
545
+ test('LIVE: a hostile handle from the wire never reaches an href', () => {
546
+ for (const handle of ['ada/../../admin', 'ada"onload=x', '../evil', 'a b']) {
547
+ const page = boot(LIVE_SRC, FEDERATED);
548
+ page.api.render({ handle });
549
+ assert.equal(page.canonicalHref(), null, `handle ${JSON.stringify(handle)} must be refused, not encoded`);
550
+ }
551
+ });
552
+
553
+ // ===========================================================================
554
+ // The wiring: the mirror, the page, and the stamp
555
+ // ===========================================================================
556
+
557
+ test('the client mirror and the module agree on both constants', () => {
558
+ const liveInModule = /const CANONICAL_PROFILE_PAGE_LIVE = (true|false);/.exec(MODULE_SRC);
559
+ const liveInClient = /const CANONICAL_PROFILE_PAGE_LIVE = (true|false);/.exec(CLIENT_SRC);
560
+ assert.ok(liveInModule && liveInClient, 'both files must spell the flag as a plain const so this guard can read it');
561
+ assert.equal(liveInClient[1], liveInModule[1],
562
+ 'the browser mirror has drifted from modules/platform-identity/canonical-profile.js — flip both or neither');
563
+ assert.equal(String(CANONICAL_PROFILE_PAGE_LIVE), liveInModule[1], 'the module export disagrees with its own source');
564
+
565
+ const prefixInClient = /const CANONICAL_PROFILE_PREFIX = '([^']+)';/.exec(CLIENT_SRC);
566
+ assert.ok(prefixInClient, 'the client must spell the prefix as a plain const');
567
+ assert.equal(prefixInClient[1], CANONICAL_PROFILE_PREFIX, 'the two canonical namespaces have drifted apart');
568
+ });
569
+
570
+ test('the profile page loads the script BEFORE profile.js, and profile.js calls it', () => {
571
+ // Match the <script> TAGS, not the filenames: both names also appear in the
572
+ // page's prose, and an ordering assertion that reads a comment proves nothing.
573
+ const tags = [...PROFILE_HTML.matchAll(/<script\s+src="\/builders\/([a-z-]+\.js)/g)].map((m) => m[1]);
574
+ assert.ok(tags.includes('profile-canonical.js'),
575
+ 'the page never loads the script, so nothing above runs in a browser');
576
+ assert.ok(
577
+ tags.indexOf('profile-canonical.js') < tags.indexOf('profile.js'),
578
+ `profile.js must load AFTER profile-canonical.js so window.OTBCanonical exists when renderProfile runs — got ${tags.join(', ')}`,
579
+ );
580
+ assert.match(PROFILE_JS, /window\.OTBCanonical\.render\(data\)/,
581
+ 'profile.js must hand the profile response to the canonical wiring');
582
+ });
583
+
584
+ test('the hub origin is stamped as a meta, and never straight into an href', () => {
585
+ assert.match(PROFILE_HTML, /<meta name="hub-origin" content="\{\{hubOrigin\}\}">/,
586
+ 'the page must carry the server-stamped hub origin (ADR 0171 D1 named this gap)');
587
+ assert.match(SERVE_INTERNAL, /\.split\('\{\{hubOrigin\}\}'\)\.join\(/,
588
+ 'applyBrandTokens must resolve the token, or the page ships the literal {{hubOrigin}}');
589
+ assert.match(SERVE_INTERNAL, /function safeHubOrigin\(\)/);
590
+ // An empty token inside an href silently becomes a same-host relative URL —
591
+ // a canonical claiming the instance IS the hub. dropEmptyHrefLinks only saves
592
+ // a BARE token, so the rule is that the token never appears in an href at all.
593
+ assert.doesNotMatch(PROFILE_HTML, /href="[^"]*\{\{hubOrigin\}\}/,
594
+ 'an empty hubOrigin in an href degrades to a same-host URL claiming to be the hub');
595
+ });
596
+
597
+ test('the resolver is the ONLY place that spells the canonical namespace', () => {
598
+ // Two files may carry it: the module and its browser mirror (checked equal
599
+ // above). A third would be a second opinion about where a profile lives.
600
+ const owners = ['modules/platform-identity/canonical-profile.js', 'modules/hall-ui/public/profile-canonical.js'];
601
+ const offenders = [];
602
+ const walk = (rel) => {
603
+ for (const e of require('node:fs').readdirSync(join(ROOT, rel), { withFileTypes: true })) {
604
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
605
+ const next = `${rel}/${e.name}`;
606
+ if (e.isDirectory()) { walk(next); continue; }
607
+ if (!e.name.endsWith('.js')) continue;
608
+ if (owners.includes(next)) continue;
609
+ const code = readFileSync(join(ROOT, next), 'utf8').replace(/\/\/[^\n]*/g, '');
610
+ if (/['"`]\/u\/\$\{|['"`]\/u\/['"`+]/.test(code)) offenders.push(next);
611
+ }
612
+ };
613
+ for (const d of ['src', 'modules']) walk(d);
614
+ assert.deepEqual(offenders, [],
615
+ 'a canonical profile URL is built somewhere other than the resolver — three surfaces building their own URL is three ways to disagree');
616
+ });
617
+
618
+ // ===========================================================================
619
+ // Lane membership — this proof must RUN, on every PR
620
+ // ===========================================================================
621
+
622
+ test('this proof runs in the DB-free lane that executes on every PR', () => {
623
+ const runner = read('scripts', 'gds', 'run-unit-tests.js');
624
+ assert.ok(
625
+ !/canonical_profile_url/.test(runner),
626
+ 'named in run-unit-tests.js means parked into INTEGRATION or KNOWN_FAILING — where CI has no Postgres, this file would self-skip forever and gate nothing (learning 1000218)',
627
+ );
628
+ });