@bongos/core 1.19.565 → 1.19.566

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.565",
6
- "core_contract": "1.19.565",
7
- "source_commit": "d4d7fdc583b75f4519d2f284349c53475e27f8dc",
5
+ "core_version": "1.19.566",
6
+ "core_contract": "1.19.566",
7
+ "source_commit": "b344a2bcd27acb87fd8d8bcac11241639954f7be",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-06T19:48:06.968Z",
9
+ "built_at": "2026-09-06T20:04:22.237Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 446,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2034,
14
+ "functional_verbatim": 2035,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2504,
20
- "tree_sha256": "32eef9b483861a2a84b7778a4fb37713f88901efef248266aaa73c278a519d2b",
19
+ "file_count": 2505,
20
+ "tree_sha256": "b8a38f68a219ad5d19cb432356353fa520857e09c5a5a8726caa463e577a99c1",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2672,7 +2672,7 @@
2672
2672
  {
2673
2673
  "path": "docs/module-api-changelog.md",
2674
2674
  "mode": "0000644",
2675
- "sha256": "17b7c3671b0652839a0983e0cbee2b00ca2438cb90e5d79771a1c7dd9415e64f"
2675
+ "sha256": "7073e3d898b2a0d80c9cad6f2ed3a142f2071b9cd4095dae8c580acb2f2095e3"
2676
2676
  },
2677
2677
  {
2678
2678
  "path": "docs/modules-contract.md",
@@ -6772,7 +6772,7 @@
6772
6772
  {
6773
6773
  "path": "modules/public-landing/public/projects.html",
6774
6774
  "mode": "0000644",
6775
- "sha256": "edc38e4ae7859c1dfc5422a4a0351c10f31cf119cab878dae0e25461c6121578"
6775
+ "sha256": "7aa74c059670bd351bf8a4295e408aef4a908e3095711aee93f75408ccd837d5"
6776
6776
  },
6777
6777
  {
6778
6778
  "path": "modules/public-landing/public/projects.probes.json",
@@ -7517,12 +7517,12 @@
7517
7517
  {
7518
7518
  "path": "package-lock.json",
7519
7519
  "mode": "0000644",
7520
- "sha256": "405ae99d31e8bad9522feb9eeaa8adf7154d0c018d2ea8a88661391e0ff512f6"
7520
+ "sha256": "319b45c1535e293ea0c0223c730ec9a08b1059469eb86edc61d3c3d8b9976eee"
7521
7521
  },
7522
7522
  {
7523
7523
  "path": "package.json",
7524
7524
  "mode": "0000644",
7525
- "sha256": "8766e1ea5753cf0dcc31419c38775984d84fed2170f81cc6ce715d87471c9ec8"
7525
+ "sha256": "3884c6ddda900305c539824babe08c00fbf942a50c5f7c23bd805d674cae32dc"
7526
7526
  },
7527
7527
  {
7528
7528
  "path": "public-docs/index.html",
@@ -9222,7 +9222,7 @@
9222
9222
  {
9223
9223
  "path": "src/module-api.js",
9224
9224
  "mode": "0000644",
9225
- "sha256": "6571323e133d1a8ce8cb6ecf15a668b0482c40352d64b0543739ba7407ae1da8"
9225
+ "sha256": "feb3d851877e533d53226ddb6b71bf5d2aa5a47c3a9b38d2bd4801d3a1e8a64b"
9226
9226
  },
9227
9227
  {
9228
9228
  "path": "src/module-loader/catalog.js",
@@ -11114,6 +11114,11 @@
11114
11114
  "mode": "0000644",
11115
11115
  "sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
11116
11116
  },
11117
+ {
11118
+ "path": "tests/manage_settings_shared_read.mjs",
11119
+ "mode": "0000644",
11120
+ "sha256": "68a429b47a1ca36a41433c0d8c3298a0f54745a19b050675aedba658a2cea0ef"
11121
+ },
11117
11122
  {
11118
11123
  "path": "tests/me_ship_days_bound.mjs",
11119
11124
  "mode": "0000644",
@@ -11507,7 +11512,7 @@
11507
11512
  {
11508
11513
  "path": "tests/project_door_ui.mjs",
11509
11514
  "mode": "0000644",
11510
- "sha256": "29f59ab64b231a7ec5d17a3e784d451f75d80c19efd4cb6a523d263e8f88b581"
11515
+ "sha256": "97d570d16414bb5c71d54223702058018a13c58cdf563448c7d28e2fcf80488e"
11511
11516
  },
11512
11517
  {
11513
11518
  "path": "tests/project_invite_ownership.mjs",
@@ -11562,7 +11567,7 @@
11562
11567
  {
11563
11568
  "path": "tests/projects_hub.mjs",
11564
11569
  "mode": "0000644",
11565
- "sha256": "56b1ecd69244798b704b787e13fd9fc74ec07e575a847ef6603df7430b63a51e"
11570
+ "sha256": "f93044429430c9fff4fe6860bae87ff8f67bc49a485391e659422e63f209e08c"
11566
11571
  },
11567
11572
  {
11568
11573
  "path": "tests/projects_hub_module_picker.mjs",
@@ -1579,5 +1579,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1579
1579
  landed since 1.19.563 with no explicit bump. run 34052578659. (task 1002620)
1580
1580
  1.19.565 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1581
1581
  landed since 1.19.564 with no explicit bump. run 34055981223. (task 1002620)
1582
+ 1.19.566 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1583
+ landed since 1.19.565 with no explicit bump. run 34056809932. (task 1002620)
1582
1584
  ---------------------------------------------------------------------------
1583
1585
  ```
@@ -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.566",
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.566",
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.566",
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.566'; // 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
+ });
@@ -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',
@@ -1449,11 +1449,33 @@ test('the in-flight line reads open_intent only, and re-asks the manifest when t
1449
1449
  test('the settings reads fire on entry + tab return only — never on the in-flight tick', () => {
1450
1450
  assert.match(SCRIPT, /if \(!keepRepo\) loadVisibility\(inst\);/);
1451
1451
  const load = fnHub('loadVisibility');
1452
- assert.match(load, /\/settings', \{ credentials: 'same-origin' \}/, 'GET /provisioning/instances/:id/settings');
1453
- assert.match(load, /res\.status === 401 \? signInAgain\(\)/, 'a lapsed session is said as sign-in, not as an outage');
1452
+ assert.match(load, /ensureSettings\(\)/, 'the row comes from the shared reader, not a GET of its own');
1453
+ assert.match(load, /ans\.status === 401 \? signInAgain\(\)/, 'a lapsed session is said as sign-in, not as an outage');
1454
1454
  assert.match(load, /var asked = manageId;/, 'a stale answer must never describe a newer project');
1455
1455
  });
1456
1456
 
1457
+ /* One read of the settings row per manage entry (task 1003582). Three cards
1458
+ render three different keys out of the SAME row, and each used to fetch it
1459
+ for itself — three identical round trips per entry, one more with every card
1460
+ added. tests/manage_settings_shared_read.mjs proves the reader's behaviour;
1461
+ this pins that all three cards actually go through it. */
1462
+ test('the three settings cards share ONE read of the row per manage entry', () => {
1463
+ const ensure = fnHub('ensureSettings');
1464
+ assert.match(ensure, /\/settings', \{ credentials: 'same-origin' \}/, 'GET /provisioning/instances/:id/settings — the one reader');
1465
+ assert.match(ensure, /if \(settingsPromise && settingsFor === manageId\) return settingsPromise;/,
1466
+ 'memoised AND keyed on the project — a switch must never be served the last one’s row');
1467
+ for (const fn of ['loadVisibility', 'loadJoinability', 'loadDoor']) {
1468
+ assert.match(fnHub(fn), /ensureSettings\(\)/, `${fn} reads the row through the shared reader`);
1469
+ assert.doesNotMatch(fnHub(fn), /fetch\(/, `${fn} fires no GET of its own`);
1470
+ }
1471
+ assert.match(SCRIPT, /if \(!keepRepo\) forgetSettings\(\);/,
1472
+ 'the memo is dropped on entry + tab return, so neither is ever served a cached row');
1473
+ /* only the READS are shared: each card still writes its own key */
1474
+ for (const fn of ['doVisSave', 'doJoinSave', 'doDoorSave']) {
1475
+ assert.match(fnHub(fn), /method: 'PATCH'/, `${fn} still PATCHes its own key`);
1476
+ }
1477
+ });
1478
+
1457
1479
  /* ── how people join (task 1003044): the joinability toggle — the visibility
1458
1480
  section's shape, key for key: the choice from the platform's row, what the
1459
1481
  project runs from its own manifest (brand.project.joinability), the save's