@bongos/core 1.19.565 → 1.19.567

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.
@@ -5586,7 +5586,10 @@ summary{min-height:24px;padding:3px 0;}
5586
5586
  return), never from renderManage, which the fetch-free tick calls */
5587
5587
  if (!keepRepo) loadRepoVisibility(inst);
5588
5588
  /* the settings row + the project's own manifest — the same cadence as
5589
- the GitHub read: entry + tab return, never the in-flight tick */
5589
+ the GitHub read: entry + tab return, never the in-flight tick. The
5590
+ three cards below share ONE read of the row (task 1003582), so
5591
+ dropping the memo here is what keeps each entry a fresh read */
5592
+ if (!keepRepo) forgetSettings();
5590
5593
  if (!keepRepo) loadVisibility(inst);
5591
5594
  if (!keepRepo) loadJoinability(inst);
5592
5595
  if (!keepRepo) loadDoor();
@@ -5936,30 +5939,67 @@ summary{min-height:24px;padding:3px 0;}
5936
5939
  .then(function () { if (timer) clearTimeout(timer); });
5937
5940
  }
5938
5941
 
5942
+ /* ── the settings row, read ONCE per manage entry (task 1003582) ─────────
5943
+ Three cards render off the SAME row — how the project is reached
5944
+ (platform_visibility), how people join (joinability), and its door on the
5945
+ map (visibility) — and each used to GET it for itself: three identical
5946
+ round trips on every entry, growing by one with each card added.
5947
+
5948
+ Memoised like ensureModsCatalog()/ensureBundles(), with the one difference
5949
+ that matters: those hold a catalog nothing rewrites, this holds a
5950
+ per-project row every card's Save rewrites. So the memo is keyed on the
5951
+ project AND dropped by forgetSettings() at the top of each entry pass —
5952
+ nothing else may reuse it, or a card would paint a row the owner has since
5953
+ changed. The saves stay separate: each card still PATCHes its own key.
5954
+
5955
+ It RESOLVES with the whole answer, status included, rather than throwing on
5956
+ a refusal — each card keeps writing its own line for a lapsed session vs.
5957
+ an outage. A transport failure still rejects, and every consumer catches it
5958
+ privately, so one card's failure never silences its siblings. */
5959
+ var settingsPromise = null;
5960
+ var settingsFor = null; /* which project the memo holds — a switch must not reuse it */
5961
+
5962
+ function ensureSettings() {
5963
+ if (settingsPromise && settingsFor === manageId) return settingsPromise;
5964
+ settingsFor = manageId;
5965
+ settingsPromise = fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
5966
+ .then(function (res) {
5967
+ return jsonOrEmpty(res).then(function (data) {
5968
+ return { ok: res.ok, status: res.status, data: data || {} };
5969
+ });
5970
+ });
5971
+ return settingsPromise;
5972
+ }
5973
+
5974
+ /* Drop the memo so the next ensureSettings() is a real read. This is also
5975
+ what makes the alert's "Try again" work: a rejected promise left memoised
5976
+ would keep answering with the failure forever. */
5977
+ function forgetSettings() { settingsPromise = null; settingsFor = null; }
5978
+
5939
5979
  function loadVisibility(inst) {
5940
5980
  mVisSettings = null; mVisRunsHtml = ''; mVisPushSeen = false;
5941
5981
  visMsg('<p class="finePrint">Reading the project’s settings…</p>');
5942
5982
  var asked = manageId; /* a stale answer must never describe a newer project */
5943
- fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
5944
- .then(function (res) {
5945
- if (asked !== manageId) throw new Error('stale');
5946
- return jsonOrEmpty(res).then(function (data) {
5947
- if (res.ok) {
5948
- mVisSettings = {
5949
- value: (data.settings || {}).platform_visibility,
5950
- options: (data.options || {}).platform_visibility || Object.keys(VIS_OPTIONS)
5951
- };
5952
- renderVis();
5953
- /* while a push is open the line is the intent's — the manifest is
5954
- asked the moment it settles (renderVisState), not now */
5955
- if (!visPushOpen(inst)) loadVisRuns(inst);
5956
- return;
5957
- }
5958
- visMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
5959
- });
5983
+ ensureSettings()
5984
+ .then(function (ans) {
5985
+ if (asked !== manageId) return;
5986
+ if (ans.ok) {
5987
+ mVisSettings = {
5988
+ value: (ans.data.settings || {}).platform_visibility,
5989
+ options: (ans.data.options || {}).platform_visibility || Object.keys(VIS_OPTIONS)
5990
+ };
5991
+ renderVis();
5992
+ /* while a push is open the line is the intent's — the manifest is
5993
+ asked the moment it settles (renderVisState), not now */
5994
+ if (!visPushOpen(inst)) loadVisRuns(inst);
5995
+ return;
5996
+ }
5997
+ visMsg('<p class="finePrint">' + (ans.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
5960
5998
  })
5961
- .catch(function (err) {
5962
- if (String(err && err.message) === 'stale') return;
5999
+ .catch(function () {
6000
+ /* the read is shared now, so its rejection reaches all three cards —
6001
+ each one still only speaks for the project it was asked about */
6002
+ if (asked !== manageId) return;
5963
6003
  visMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
5964
6004
  });
5965
6005
  }
@@ -6313,24 +6353,22 @@ summary{min-height:24px;padding:3px 0;}
6313
6353
  mJoinSettings = null; mJoinRunsHtml = ''; mJoinPushSeen = false;
6314
6354
  joinMsg('<p class="finePrint">Reading the project’s settings…</p>');
6315
6355
  var asked = manageId; /* a stale answer must never describe a newer project */
6316
- fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
6317
- .then(function (res) {
6318
- if (asked !== manageId) throw new Error('stale');
6319
- return jsonOrEmpty(res).then(function (data) {
6320
- if (res.ok) {
6321
- mJoinSettings = {
6322
- value: (data.settings || {}).joinability,
6323
- options: (data.options || {}).joinability || Object.keys(JOIN_OPTIONS)
6324
- };
6325
- renderJoin();
6326
- if (!visPushOpen(inst)) loadJoinRuns(inst);
6327
- return;
6328
- }
6329
- joinMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
6330
- });
6356
+ ensureSettings()
6357
+ .then(function (ans) {
6358
+ if (asked !== manageId) return;
6359
+ if (ans.ok) {
6360
+ mJoinSettings = {
6361
+ value: (ans.data.settings || {}).joinability,
6362
+ options: (ans.data.options || {}).joinability || Object.keys(JOIN_OPTIONS)
6363
+ };
6364
+ renderJoin();
6365
+ if (!visPushOpen(inst)) loadJoinRuns(inst);
6366
+ return;
6367
+ }
6368
+ joinMsg('<p class="finePrint">' + (ans.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
6331
6369
  })
6332
- .catch(function (err) {
6333
- if (String(err && err.message) === 'stale') return;
6370
+ .catch(function () {
6371
+ if (asked !== manageId) return;
6334
6372
  joinMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
6335
6373
  });
6336
6374
  }
@@ -6539,23 +6577,21 @@ summary{min-height:24px;padding:3px 0;}
6539
6577
  mDoorSettings = null;
6540
6578
  doorMsg('<p class="finePrint">Reading the project’s settings…</p>');
6541
6579
  var asked = manageId; /* a stale answer must never describe a newer project */
6542
- fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
6543
- .then(function (res) {
6544
- if (asked !== manageId) throw new Error('stale');
6545
- return jsonOrEmpty(res).then(function (data) {
6546
- if (res.ok) {
6547
- mDoorSettings = {
6548
- value: (data.settings || {}).visibility,
6549
- options: (data.options || {}).visibility || Object.keys(DOOR_OPTIONS)
6550
- };
6551
- renderDoor();
6552
- return;
6553
- }
6554
- doorMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
6555
- });
6580
+ ensureSettings()
6581
+ .then(function (ans) {
6582
+ if (asked !== manageId) return;
6583
+ if (ans.ok) {
6584
+ mDoorSettings = {
6585
+ value: (ans.data.settings || {}).visibility,
6586
+ options: (ans.data.options || {}).visibility || Object.keys(DOOR_OPTIONS)
6587
+ };
6588
+ renderDoor();
6589
+ return;
6590
+ }
6591
+ doorMsg('<p class="finePrint">' + (ans.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
6556
6592
  })
6557
- .catch(function (err) {
6558
- if (String(err && err.message) === 'stale') return;
6593
+ .catch(function () {
6594
+ if (asked !== manageId) return;
6559
6595
  doorMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
6560
6596
  });
6561
6597
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.565",
3
+ "version": "1.19.567",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.565",
9
+ "version": "1.19.567",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.565",
3
+ "version": "1.19.567",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.565'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.567'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,146 @@
1
+ // tests/manage_settings_shared_read.mjs — one read of the project's settings
2
+ // row per manage entry, EXECUTED (task 1003582).
3
+ //
4
+ // The defect this pins shut: entering /projects?view=manage fired
5
+ // GET /provisioning/instances/:id/settings three times in a row, because the
6
+ // three cards that render off that row — how the project is reached, how people
7
+ // join, and its door on the map — each fetched it for themselves. Three round
8
+ // trips and three DB reads for one row, growing by one with every settings-
9
+ // backed card added to the page.
10
+ //
11
+ // tests/projects_hub.mjs pins that all three cards go through the shared
12
+ // reader; this file lifts the reader itself out of modules/public-landing/
13
+ // public/projects.html, runs it in a vm under a scripted fetch, and counts the
14
+ // round trips. The vm-harness precedent is tests/project_door_ui.mjs.
15
+ //
16
+ // What it proves, and why each matters:
17
+ // • three callers in one entry cost ONE round trip — the defect itself
18
+ // • forgetSettings() makes the next read real, so entry and tab return each
19
+ // see the row as it is now, never the one from the last visit
20
+ // • that is also what lets the alert's "Try again" retry after a failure: a
21
+ // rejected promise left memoised would answer with the failure forever
22
+ // • switching projects is never served the previous project's row, even with
23
+ // the memo still warm — the cache is keyed on the project, not just present
24
+ // • a refusal RESOLVES carrying its status, so each card can still tell a
25
+ // lapsed session apart from an outage in its own words
26
+ //
27
+ // Run: node --test --test-reporter=tap tests/manage_settings_shared_read.mjs
28
+
29
+ import assert from 'node:assert/strict';
30
+ import { test } from 'node:test';
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import vm from 'node:vm';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
37
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8');
38
+ const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
39
+
40
+ // The reader, sliced from the page verbatim. Both boundaries are asserted so a
41
+ // rename cannot leave this file executing an empty string and passing.
42
+ const START = SCRIPT.indexOf(' var settingsPromise = null;');
43
+ const END = SCRIPT.indexOf(' function loadVisibility(');
44
+ assert.ok(START > 0, 'the shared reader must start at its memo declaration');
45
+ assert.ok(END > START, 'the shared reader must sit above its first consumer');
46
+ const REGION = SCRIPT.slice(START, END);
47
+ for (const fn of ['function ensureSettings(', 'function forgetSettings(']) {
48
+ assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
49
+ }
50
+
51
+ // ── the harness ──────────────────────────────────────────────────────────────
52
+ // Narrow on purpose: the region touches fetch, jsonOrEmpty, API and manageId,
53
+ // and nothing else — so the stub stays honest about what it can prove.
54
+
55
+ function boot({ ok = true, status = 200, body = { settings: { visibility: 'public' } }, reject = false } = {}) {
56
+ const requests = [];
57
+ const sandbox = {
58
+ Promise, JSON, String, Object, Error, encodeURIComponent,
59
+ API: '/api/bongos',
60
+ manageId: '18',
61
+ jsonOrEmpty: (res) => Promise.resolve(res._json),
62
+ fetch: (url, init = {}) => {
63
+ requests.push({ url, credentials: init.credentials });
64
+ return reject ? Promise.reject(new Error('network')) : Promise.resolve({ ok, status, _json: body });
65
+ },
66
+ };
67
+ sandbox.globalThis = sandbox;
68
+ vm.createContext(sandbox);
69
+ vm.runInContext(REGION, sandbox, { filename: 'projects.html#shared-settings-read' });
70
+
71
+ const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
72
+ return { requests, sandbox, settle };
73
+ }
74
+
75
+ // ── the tests ────────────────────────────────────────────────────────────────
76
+
77
+ test('the three cards of one manage entry cost ONE round trip, not three', async () => {
78
+ // Arrange — the page's own entry pass: three readers, back to back
79
+ const ui = boot();
80
+
81
+ // Act
82
+ const answers = await Promise.all([
83
+ ui.sandbox.ensureSettings(),
84
+ ui.sandbox.ensureSettings(),
85
+ ui.sandbox.ensureSettings(),
86
+ ]);
87
+
88
+ // Assert
89
+ assert.equal(ui.requests.length, 1, 'one GET served all three cards');
90
+ assert.equal(ui.requests[0].url, '/api/bongos/provisioning/instances/18/settings');
91
+ assert.equal(ui.requests[0].credentials, 'same-origin', 'the cookie session is the auth — this page carries no token');
92
+ for (const a of answers) assert.deepEqual(a.data.settings, { visibility: 'public' }, 'every caller got the same row');
93
+ });
94
+
95
+ test('forgetSettings makes the next read real — entry and tab return never reuse the last visit’s row', async () => {
96
+ // Arrange
97
+ const ui = boot();
98
+ await ui.sandbox.ensureSettings();
99
+
100
+ // Act — what loadManage does at the top of every entry + tab return pass
101
+ ui.sandbox.forgetSettings();
102
+ await ui.sandbox.ensureSettings();
103
+
104
+ // Assert
105
+ assert.equal(ui.requests.length, 2, 'the second entry asked the server again');
106
+ });
107
+
108
+ test('a failed read can be retried — the rejection is not memoised past forgetSettings', async () => {
109
+ // Arrange — the outage the manage alert's "Try again" exists for
110
+ const ui = boot({ reject: true });
111
+ await assert.rejects(ui.sandbox.ensureSettings(), /network/);
112
+
113
+ // Act — the retry runs loadManage again, which forgets first
114
+ ui.sandbox.forgetSettings();
115
+ await assert.rejects(ui.sandbox.ensureSettings(), /network/);
116
+
117
+ // Assert
118
+ assert.equal(ui.requests.length, 2, 'the retry was a real second attempt, not the stored failure');
119
+ });
120
+
121
+ test('switching projects is never served the previous project’s row', async () => {
122
+ // Arrange — the memo is warm for project 18
123
+ const ui = boot();
124
+ await ui.sandbox.ensureSettings();
125
+
126
+ // Act — the owner opens a different project; loadManage has not forgotten yet
127
+ ui.sandbox.manageId = '19';
128
+ await ui.sandbox.ensureSettings();
129
+
130
+ // Assert
131
+ assert.equal(ui.requests.length, 2, 'the memo is keyed on the project, not merely present');
132
+ assert.equal(ui.requests[1].url, '/api/bongos/provisioning/instances/19/settings');
133
+ });
134
+
135
+ test('a refusal RESOLVES carrying its status, so each card can still say what happened', async () => {
136
+ // Arrange — a lapsed session: the card must offer sign-in, not report an outage
137
+ const ui = boot({ ok: false, status: 401, body: {} });
138
+
139
+ // Act
140
+ const ans = await ui.sandbox.ensureSettings();
141
+
142
+ // Assert
143
+ assert.equal(ans.ok, false);
144
+ assert.equal(ans.status, 401, 'the status reaches the card — 401 reads differently from 500');
145
+ assert.deepEqual(ans.data, {}, 'an empty body is an object, so a card can read through it without guarding');
146
+ });
@@ -0,0 +1,114 @@
1
+ // tests/origin_containment.mjs — the downward-only containment rule (task 1003670,
2
+ // ADR 0254): may a URL a REMOTE instance reported be published under that instance's
3
+ // name?
4
+ //
5
+ // Two surfaces relay a third party's URL into an anchor on the hub — a builder's ship
6
+ // links and a project's `hall_origin` — and until this task they answered the question
7
+ // differently: the ship link demanded containment, the hall address demanded only that
8
+ // the host DIFFER from the project's site. This file is the rule's own proof, so the
9
+ // two callers cannot drift again.
10
+ //
11
+ // EVERY CASE HERE IS AN ATTACK OR THE SHAPE THAT MAKES ONE POSSIBLE. What is asserted
12
+ // is what is REFUSED; the accept list is deliberately short, because a containment rule
13
+ // that is generous is not one.
14
+ //
15
+ // Run: node --test tests/origin_containment.mjs
16
+
17
+ import test from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ // A REAL ESM import, not createRequire: knip does not trace createRequire(), and this
20
+ // is the export's only test (learning: knip-createrequire-test-imports).
21
+ import { urlWithinOrigin } from '../modules/platform-identity/origin-containment.js';
22
+
23
+ const ORIGIN = 'https://project.example';
24
+
25
+ test('a link at the reporting origin itself is kept, verbatim', () => {
26
+ assert.equal(urlWithinOrigin(`${ORIGIN}/builders#/task/1`, ORIGIN), `${ORIGIN}/builders#/task/1`);
27
+ assert.equal(urlWithinOrigin(ORIGIN, ORIGIN), ORIGIN);
28
+ });
29
+
30
+ test('a link BELOW the reporting origin is kept — the hall on a subdomain is the whole point', () => {
31
+ const hall = 'https://builders.project.example/';
32
+ assert.equal(urlWithinOrigin(hall, ORIGIN), hall);
33
+ assert.equal(urlWithinOrigin('https://a.b.c.project.example/x', ORIGIN), 'https://a.b.c.project.example/x',
34
+ 'depth is not bounded — every label below the origin is still the origin\'s own');
35
+ });
36
+
37
+ test('the host comparison ignores case, and a port is not a trust boundary', () => {
38
+ assert.equal(urlWithinOrigin('https://BUILDERS.Project.Example/x', ORIGIN), 'https://BUILDERS.Project.Example/x');
39
+ assert.equal(urlWithinOrigin('https://project.example:8443/x', ORIGIN), 'https://project.example:8443/x');
40
+ assert.equal(urlWithinOrigin('http://project.example/x', ORIGIN), 'http://project.example/x',
41
+ 'http is allowed — the scheme gate is about javascript:, not about TLS');
42
+ });
43
+
44
+ /* ── the refusals: each row is a way a hostile instance tries to publish a link that
45
+ is not its own, under a name that is ─────────────────────────────────────────── */
46
+
47
+ for (const [attack, raw, origin] of [
48
+ // The plain one this task exists for: a remote instance advertising a hall it does
49
+ // not own, which the hub then renders as an <a href> under the victim's project.
50
+ ['an unrelated host', 'https://evil.example/phish', ORIGIN],
51
+ ['an unrelated host with a convincing path', 'https://evil.example/project.example/builders', ORIGIN],
52
+
53
+ // A shared multi-tenant apex, the shape origin-containment.js's header names: the
54
+ // neighbour is NOT the neighbour's to link, and neither is the apex above both.
55
+ ['a SIBLING under a shared apex', 'https://bar.pages.example/x', 'https://foo.pages.example'],
56
+ ['an ANCESTOR of its own host (the github.io shape)', 'https://pages.example/x', 'https://foo.pages.example'],
57
+ ['the bare apex of a deep origin', 'https://example/x', 'https://a.b.example'],
58
+
59
+ // Suffix matching without the dot anchor is a lookalike-domain generator: every
60
+ // registrable name that ENDS in the victim's name would contain it.
61
+ ['a lookalike domain that merely ends in the origin', 'https://notproject.example/x', ORIGIN],
62
+ ['a lookalike with the origin as a bare substring', 'https://project.example.evil.test/x', ORIGIN],
63
+
64
+ // The host check alone passes this: the host IS evil.example. What a human reads is
65
+ // the victim's domain, which is the entire trick.
66
+ ['credentials that read as the victim\'s domain', 'https://project.example@evil.example/x', ORIGIN],
67
+ ['user:password credentials on the origin itself', 'https://user:pw@project.example/x', ORIGIN],
68
+
69
+ // A relayed href is a script sink if the scheme is not constrained here.
70
+ ['a javascript: scheme', 'javascript:alert(1)', ORIGIN],
71
+ ['a data: URL', 'data:text/html,<script>alert(1)</script>', ORIGIN],
72
+ ['a file: URL', 'file:///etc/passwd', ORIGIN],
73
+ ['a protocol-relative URL', '//evil.example/x', ORIGIN],
74
+
75
+ // Not a URL, or not a string at all — a third-party JSON field is any shape.
76
+ ['a bare path', '/builders', ORIGIN],
77
+ ['an empty string', '', ORIGIN],
78
+ ['a number', 12345, ORIGIN],
79
+ ['an object', { href: `${ORIGIN}/x` }, ORIGIN],
80
+ ['null', null, ORIGIN],
81
+ ['undefined', undefined, ORIGIN],
82
+
83
+ // The ORIGIN side is stored data, not validated data. A hostless one leaves the
84
+ // suffix as a bare ".", which a legal trailing-dot FQDN would match.
85
+ ['a trailing-dot FQDN against a hostless origin', 'https://evil.example./x', 'data:text/plain,x'],
86
+ ['any link against a hostless origin', 'https://evil.example/x', 'file:///tmp/x'],
87
+ ['any link against an unparseable origin', 'https://evil.example/x', 'not-a-url'],
88
+ ['any link against an empty origin', 'https://evil.example/x', ''],
89
+ ['any link against a null origin', 'https://evil.example/x', null],
90
+ ]) {
91
+ test(`REFUSED: ${attack}`, () => {
92
+ assert.equal(urlWithinOrigin(raw, origin), null);
93
+ });
94
+ }
95
+
96
+ test('REFUSED: a URL over the cap — refused whole, never truncated', () => {
97
+ // Truncation is the tempting alternative and it is worse: a sliced URL still parses
98
+ // and points somewhere else, so the hub would publish a link nobody wrote.
99
+ const long = `${ORIGIN}/${'x'.repeat(600)}`;
100
+ assert.equal(urlWithinOrigin(long, ORIGIN), null, 'contained, but too long — still refused');
101
+ assert.equal(urlWithinOrigin(long, ORIGIN, 1000), long, 'the cap is the only thing refusing it');
102
+ assert.equal(urlWithinOrigin(`${ORIGIN}/x`, ORIGIN, 5), null, 'a caller may bound it tighter');
103
+ });
104
+
105
+ test('a refusal is always null — never a throw, and never a repaired URL', () => {
106
+ for (const raw of ['https://evil.example/x', 'javascript:alert(1)', 'http://', 7, [], true]) {
107
+ const out = urlWithinOrigin(raw, ORIGIN);
108
+ assert.equal(out, null, `${String(raw)} → null`);
109
+ }
110
+ // The kept value is the INPUT, byte for byte: normalising it here would mean the hub
111
+ // publishes a URL the instance never reported.
112
+ const odd = `${ORIGIN}/a//b?q=1#f`;
113
+ assert.equal(urlWithinOrigin(odd, ORIGIN), odd);
114
+ });
@@ -13,6 +13,10 @@
13
13
  // the page, so the region is sliced out rather than read from a .js file, and the
14
14
  // slice boundaries are asserted so a rename can never quietly test nothing.
15
15
  //
16
+ // Since task 1003582 the settings row is read once per manage entry by a shared
17
+ // ensureSettings(), declared ABOVE this region — so the sandbox injects it (see
18
+ // tests/manage_settings_shared_read.mjs for that reader's own behaviour).
19
+ //
16
20
  // What it proves, and why each matters:
17
21
  // • the three choices render with the goal's PLAIN interview labels
18
22
  // • the project's CURRENT door is the preselected one — an owner opening the
@@ -105,13 +109,23 @@ function boot({ settings, options = ['public', 'private', 'stealth'], patchAnswe
105
109
  const fetch = (url, init = {}) => {
106
110
  requests.push({ url, method: init.method || 'GET', body: init.body, credentials: init.credentials, headers: init.headers });
107
111
  if ((init.method || 'GET') === 'GET') {
108
- return answer({ ok: true, settings, options: { visibility: options } });
112
+ // the row is read through ensureSettings() below — a GET arriving here
113
+ // means this section grew a second reader, which is the whole defect
114
+ throw new Error('this section reads the settings row through ensureSettings(), never its own GET');
109
115
  }
110
116
  return patchAnswer || answer({ ok: true, saved: { ...settings, visibility: JSON.parse(init.body).visibility }, push: { queued: true, message: 'Saved. Your project will restart to pick this up — until then it keeps its current setting.' } });
111
117
  };
118
+ // The shared read the three settings cards live off (task 1003582). Answers
119
+ // in ensureSettings()'s own shape — resolved, status carried, never thrown —
120
+ // and still records the round trip, so a card that stopped reading the row
121
+ // would show up here as a missing request rather than as silence.
122
+ const ensureSettings = () => {
123
+ requests.push({ url: '/api/bongos/provisioning/instances/18/settings', method: 'GET', credentials: 'same-origin' });
124
+ return Promise.resolve({ ok: true, status: 200, data: { settings, options: { visibility: options } } });
125
+ };
112
126
 
113
127
  const sandbox = {
114
- $, document, fetch, console, Promise, JSON, String, Object, Array, Error, RegExp,
128
+ $, document, fetch, ensureSettings, console, Promise, JSON, String, Object, Array, Error, RegExp,
115
129
  encodeURIComponent, setTimeout, clearTimeout,
116
130
  API: '/api/bongos',
117
131
  manageId: '18',
@@ -21,7 +21,17 @@ const require = createRequire(import.meta.url);
21
21
  const stats = require('../modules/platform-identity/project-stats.js');
22
22
 
23
23
  const realFetch = globalThis.fetch;
24
- afterEach(() => { globalThis.fetch = realFetch; });
24
+ // The SSRF guard's resolver. Most fixtures below dodge it with literal IPs, but the
25
+ // hall-containment group needs real HOSTNAMES — containment is a question about
26
+ // domain labels, and an IP origin cannot express "a subdomain of my own site". The
27
+ // module captured `require('node:dns').promises` (the object) at load, so replacing
28
+ // `.lookup` on it is what the guard actually calls.
29
+ const dnsPromises = require('node:dns').promises;
30
+ const realLookup = dnsPromises.lookup;
31
+ function stubDns() {
32
+ dnsPromises.lookup = () => Promise.resolve([{ address: '93.184.216.34', family: 4 }]);
33
+ }
34
+ afterEach(() => { globalThis.fetch = realFetch; dnsPromises.lookup = realLookup; });
25
35
  beforeEach(() => { stats._reset(); });
26
36
 
27
37
  function json(body) {
@@ -359,28 +369,86 @@ test('the shipped feed is asked for the window explicitly', async () => {
359
369
  assert.ok(urls.includes('https://8.8.8.8/api/bongos'), 'the discovery index answers the hall address');
360
370
  });
361
371
 
362
- test('hall_origin is the discovery index\'s hall only where it is a different host from the site', async () => {
363
- stub({
364
- 'https://1.1.1.1': { leaderboard: [], enabled: {}, index: { hall: 'https://builders.example.com' } },
365
- 'https://8.8.8.8': { leaderboard: [], enabled: {}, index: { hall: 'https://8.8.8.8' } }, // single host
366
- 'https://9.9.9.9': { leaderboard: [], enabled: {}, index: { hall: 'javascript:alert(1)' } },
367
- 'https://4.4.4.4': { leaderboard: [], enabled: {} }, // no index (older core)
368
- });
369
- const out = await stats.getProjectStats(['https://1.1.1.1', 'https://8.8.8.8', 'https://9.9.9.9', 'https://4.4.4.4']);
370
- assert.equal(out['https://1.1.1.1'].hall_origin, 'https://builders.example.com');
371
- assert.equal(out['https://8.8.8.8'].hall_origin, null, 'a hall at <site>/builders is the site\'s own door');
372
- assert.equal(out['https://9.9.9.9'].hall_origin, null);
372
+ /* ── hall_origin: CONTAINMENT first, the door test second ───────────────────────
373
+ This is the one value in this file that becomes an <a href> on the hub rather
374
+ than an <img src>, and it arrives from a REMOTE instance's own discovery index.
375
+ It used to be checked only for DIFFERING from the project's site host — which is
376
+ containment inverted: it admitted every host on the internet except the one that
377
+ was certainly safe. Task 1003670 / ADR 0254 put it on the same downward-only rule
378
+ a pushed ship link obeys (origin-containment.js, whose own attack matrix is
379
+ tests/origin_containment.mjs). What is asserted here is the WIRING: that a sweep
380
+ through getProjectStats really refuses these.
381
+
382
+ Hostname fixtures, not the file's usual literal IPs — see stubDns() above. ── */
383
+
384
+ // each row: [why, origin, the hall the remote advertises, what the hub may publish]
385
+ const HALL_CASES = [
386
+ ['a hall on a subdomain of the project\'s own origin is the legitimate shape',
387
+ 'https://good.example', 'https://builders.good.example', 'https://builders.good.example'],
388
+ ['another PORT on the same host is a real second door — a port is not a trust boundary',
389
+ 'https://port.example', 'https://port.example:8443', 'https://port.example:8443'],
390
+ ['a hall AT the site host is the site\'s own door, not a second one',
391
+ 'https://solo.example', 'https://solo.example', null],
392
+ // THE ATTACK. Under ADR 0252's auto-appear anyone who points a domain at the hub
393
+ // can report this, and the old rule published it BECAUSE it differed.
394
+ ['an unrelated host is a phishing hop, and differing is exactly what used to admit it',
395
+ 'https://victim.example', 'https://evil.example/builders', null],
396
+ ['a SIBLING under a shared apex belongs to the neighbour',
397
+ 'https://foo.pages.example', 'https://bar.pages.example', null],
398
+ ['an ANCESTOR of the project\'s own host is the github.io shape — never upward',
399
+ 'https://foo.pages.example', 'https://pages.example', null],
400
+ ['a lookalike domain that merely ENDS in the origin (the dot anchor)',
401
+ 'https://look.example', 'https://notlook.example', null],
402
+ ['embedded credentials that read as the project\'s own domain',
403
+ 'https://creds.example', 'https://creds.example@evil.example/builders', null],
404
+ ['a non-http scheme never becomes an href',
405
+ 'https://scheme.example', 'javascript:alert(1)', null],
406
+ ['a hall over the URL cap is refused whole, never truncated into a different address',
407
+ 'https://long.example', `https://a.long.example/${'x'.repeat(400)}`, null],
408
+ ['a hall the project does not name',
409
+ 'https://quiet.example', undefined, null],
410
+ ];
411
+
412
+ test('hall_origin publishes a hall only at or BELOW the project\'s own origin, and only where it is a different door', async () => {
413
+ stubDns();
414
+ const fixtures = {};
415
+ for (const [, origin, hall] of HALL_CASES) {
416
+ fixtures[origin] = { leaderboard: [], enabled: {}, index: hall === undefined ? {} : { hall } };
417
+ }
418
+ stub(fixtures);
419
+ const out = await stats.getProjectStats(HALL_CASES.map(([, origin]) => origin));
420
+ for (const [why, origin, , expected] of HALL_CASES) {
421
+ assert.equal(out[origin].hall_origin, expected, why);
422
+ }
423
+ });
424
+
425
+ test('a project with no discovery index at all (an older core) reports no hall', async () => {
426
+ stub({ 'https://4.4.4.4': { leaderboard: [], enabled: {} } });
427
+ const out = await stats.getProjectStats(['https://4.4.4.4']);
373
428
  assert.equal(out['https://4.4.4.4'].hall_origin, null);
374
429
  });
375
430
 
431
+ test('containment is read from the ONE shared rule, not re-implemented here', async () => {
432
+ // The defect this replaced was a second, weaker answer to a question the corpus had
433
+ // already answered one field over. A local re-implementation would drift again.
434
+ const src = await import('node:fs').then((fs) =>
435
+ fs.readFileSync(new URL('../modules/platform-identity/project-stats.js', import.meta.url), 'utf8'));
436
+ assert.match(src, /require\('\.\/origin-containment'\)/, 'the rule is required, not copied');
437
+ const fn = (src.match(/function hallOrigin\([\s\S]*?\n\}/) || [''])[0];
438
+ assert.match(fn, /urlWithinOrigin\(index\?\.hall, origin, URL_MAX\)/, 'the hall is contained before anything else looks at it');
439
+ assert.doesNotMatch(fn, /httpUrl\(/, 'httpUrl only scheme-checks and TRUNCATES — it is not a containment rule');
440
+ });
441
+
376
442
  test('a dead shipped feed leaves the other quarters standing', async () => {
377
- stub({ 'https://8.8.8.8': { leaderboard: [{ github_login: 'a' }], enabled: { game: true }, index: { hall: 'https://h.example.com' } } });
443
+ // the hall is on this project's OWN host (another port) — anything else is refused
444
+ // by containment now, which would make this test prove the wrong thing
445
+ stub({ 'https://8.8.8.8': { leaderboard: [{ github_login: 'a' }], enabled: { game: true }, index: { hall: 'https://8.8.8.8:8443' } } });
378
446
  const out = await stats.getProjectStats(['https://8.8.8.8']);
379
447
  assert.equal(out['https://8.8.8.8'].shipped_30d, null, 'the dead quarter is unknown');
380
448
  assert.equal(out['https://8.8.8.8'].shipped_30d_capped, null);
381
449
  assert.equal(out['https://8.8.8.8'].builders, 1);
382
450
  assert.deepEqual(out['https://8.8.8.8'].tags, ['game']);
383
- assert.equal(out['https://8.8.8.8'].hall_origin, 'https://h.example.com');
451
+ assert.equal(out['https://8.8.8.8'].hall_origin, 'https://8.8.8.8:8443');
384
452
  });
385
453
 
386
454
  test('the origin\'s host is resolved once per sweep, not once per feed', async () => {