@bongos/core 1.25.16 → 1.25.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.bongos-core.json +200 -75
  2. package/bin/bongos.js +1 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +24 -0
  5. package/clients/bongos-client/index.cjs +24 -0
  6. package/clients/bongos-client/index.d.ts +36 -2
  7. package/clients/bongos-client/index.mjs +24 -0
  8. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  9. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
  10. package/docs/api/openapi.json +761 -112
  11. package/docs/api-reference.md +23 -3
  12. package/docs/copy-inventory.md +118 -108
  13. package/docs/copy-registry.json +315 -172
  14. package/docs/file-map.md +2 -1
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +3 -0
  17. package/docs/page-readings.json +62 -62
  18. package/modules/app-host/CLAUDE.md +15 -0
  19. package/modules/app-host/module.json +20 -0
  20. package/modules/app-host/public/settings-widget.js +143 -0
  21. package/modules/app-host/routes/app-host.js +88 -0
  22. package/modules/government/charter-advice.js +103 -0
  23. package/modules/government/decision-rules.js +1 -0
  24. package/modules/government/presets.js +42 -0
  25. package/modules/government/recommendations.js +102 -7
  26. package/modules/government/routes/government.js +1 -0
  27. package/modules/lifecycle/first-read-catalog.js +62 -0
  28. package/modules/lifecycle/first-read-db.js +190 -0
  29. package/modules/lifecycle/first-read.js +330 -0
  30. package/modules/lifecycle/kickoff-checklist.js +12 -3
  31. package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
  32. package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
  33. package/modules/lifecycle/module.json +2 -1
  34. package/modules/lifecycle/routes/first-read.js +101 -0
  35. package/modules/onboarding/genesis-work.js +101 -4
  36. package/modules/onboarding/routes/genesis-work.js +2 -2
  37. package/modules/platform-identity/module.json +1 -1
  38. package/modules/platform-identity/routes/sso-app-status.js +65 -0
  39. package/modules/provisioning/module.json +1 -0
  40. package/modules/provisioning/pollers/app-liveness.js +23 -1
  41. package/modules/provisioning/render-sandbox.js +113 -0
  42. package/modules/provisioning/render-standup.js +3 -1
  43. package/modules/provisioning/routes/provisioning.js +4 -1
  44. package/modules/provisioning/routes/render-standup.js +49 -1
  45. package/modules/provisioning/seams.js +32 -1
  46. package/modules/provisioning/starter-bundles.js +1 -0
  47. package/modules/public-landing/public/assets/guide-core.js +475 -0
  48. package/modules/public-landing/public/assets/guide-hall.css +121 -0
  49. package/modules/public-landing/public/assets/guide-hall.js +164 -0
  50. package/modules/public-landing/public/assets/guide-scene.css +9 -0
  51. package/modules/public-landing/public/assets/guide-scene.js +58 -381
  52. package/modules/public-landing/public/index.html +46 -1
  53. package/modules/public-landing/public/projects.html +112 -6
  54. package/modules/ui-design/kit/serve.js +1 -1
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/release-notes.json +28 -0
  58. package/scripts/bongos/first-read.js +150 -0
  59. package/scripts/bongos/genesis.js +21 -1
  60. package/scripts/bongos/government-walk.js +4 -4
  61. package/scripts/bongos/provision-render-sandbox.js +171 -0
  62. package/scripts/bongos/provision-render.js +37 -2
  63. package/scripts/bongos/provision-teardown.js +1 -1
  64. package/scripts/bongos/publish-manifest.js +1 -0
  65. package/scripts/bongos/render-api.js +33 -3
  66. package/scripts/bongos/render-payload.js +39 -1
  67. package/scripts/bongos/seam-wiring-guard.js +3 -2
  68. package/scripts/gds/first-read.js +7 -0
  69. package/scripts/gds/provision-render-sandbox.js +6 -0
  70. package/scripts/hall-preview/server.js +2 -0
  71. package/src/bongos/auth-admission.js +64 -0
  72. package/src/bongos/auth.js +2 -1
  73. package/src/bongos/module-scope-map.js +4 -0
  74. package/src/bongos/routes/core-update.js +3 -16
  75. package/src/bongos/routes/modules.js +11 -0
  76. package/src/bongos/serve-internal.js +20 -6
  77. package/src/module-api.js +6 -1
  78. package/src/module-loader/loader.js +2 -2
  79. package/tests/app_host.mjs +413 -0
  80. package/tests/first_read.mjs +479 -0
  81. package/tests/genesis_work.mjs +74 -3
  82. package/tests/government_act_alone_buildings.mjs +2 -0
  83. package/tests/government_charter_advice.mjs +150 -0
  84. package/tests/landing_guide_hall.mjs +364 -0
  85. package/tests/landing_guide_scene.mjs +8 -5
  86. package/tests/module_api.mjs +1 -0
  87. package/tests/module_loader.mjs +1 -1
  88. package/tests/npm_release_where.mjs +13 -3
  89. package/tests/provisioning_app_status.mjs +3 -2
  90. package/tests/provisioning_render_route.mjs +1 -1
  91. package/tests/provisioning_teardown_intent.mjs +50 -0
  92. package/tests/render_api.mjs +5 -2
  93. package/tests/render_sandbox.mjs +447 -0
@@ -221,12 +221,12 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
221
221
  //
222
222
  // ONE SEAM, ONE FILE PER PAGE (task 1004301). `file` names which page's widget is being
223
223
  // asked for: hall-widget.js for the hall index (the default, and the only caller until
224
- // then), task-widget.js for a task's own page. Same declaration (uiSections), same
224
+ // then), task-widget.js for a task's own page, settings-widget.js for the options menu (1004571). Same declaration (uiSections), same
225
225
  // enabled-only asset mount, same "a disabled module simply is not there" — so a module
226
226
  // adds a line to the task page without the page's own code ever naming it. The name is
227
227
  // matched against PAGE_WIDGET_FILES rather than trusted, so no caller can turn this into
228
228
  // a way to serve an arbitrary file.
229
- const PAGE_WIDGET_FILES = Object.freeze(['hall-widget.js', 'task-widget.js']);
229
+ const PAGE_WIDGET_FILES = Object.freeze(['hall-widget.js', 'task-widget.js', 'settings-widget.js']);
230
230
  function hallWidgetScripts({ isEnabled, file = 'hall-widget.js' } = {}, opts) {
231
231
  if (!PAGE_WIDGET_FILES.includes(file)) return [];
232
232
  const out = [];
@@ -0,0 +1,413 @@
1
+ // tests/app_host.mjs — where the project's app runs, in the hall's options menu (task 1004571).
2
+ //
3
+ // The whole chain, DB-free and with no Render call anywhere:
4
+ // 1. the provisioning port (projectAppStatus) — the row, by both halves of the projection,
5
+ // read through app-status.js's own shape;
6
+ // 2. the hub route (POST /sso/app-status) — only an authenticated project, only its own row;
7
+ // 3. the core asker (readAppStatusFromHub) — the project's credentials, never throws;
8
+ // 4. the hall route (GET /app-host) — each state, owner vs everyone else, new fields kept;
9
+ // 5. the module switch — the panel's script is injected only while the module is on, and
10
+ // loads before settings.js the way settings.js loads;
11
+ // 6. the panel itself — what a builder reads in each state.
12
+ //
13
+ // Run: node --test tests/app_host.mjs
14
+
15
+ import test from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import http from 'node:http';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import vm from 'node:vm';
21
+ import { createRequire } from 'node:module';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const express = require('express');
27
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
28
+
29
+ // The doorway with a quiet logger and a stand-in sign-in gate, BEFORE any route file loads.
30
+ const apiPath = require.resolve('../src/module-api.js');
31
+ const realApi = require(apiPath);
32
+ let viewer = { id: 7 };
33
+ const fakeApi = {
34
+ ...realApi,
35
+ logger: () => ({ error() {}, warn() {}, info() {} }),
36
+ requireBuilder: (req, _res, next) => { req.builder = viewer; next(); },
37
+ };
38
+ require.cache[apiPath].exports = fakeApi;
39
+
40
+ async function serve(router) {
41
+ const app = express();
42
+ app.use(express.json());
43
+ app.use(attachFail);
44
+ app.use('/api/bongos', router);
45
+ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
46
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
47
+ const call = (method, route, body) => new Promise((resolve, reject) => {
48
+ const payload = body ? JSON.stringify(body) : '';
49
+ const req = http.request(`${base}${route}`, {
50
+ method, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
51
+ }, (res) => {
52
+ let raw = '';
53
+ res.on('data', (c) => { raw += c; });
54
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: raw ? JSON.parse(raw) : null }));
55
+ });
56
+ req.on('error', reject);
57
+ req.end(payload);
58
+ });
59
+ return { call, close: () => server.close() };
60
+ }
61
+
62
+ // ── 1. the provisioning port ─────────────────────────────────────────────────
63
+
64
+ const { projectAppStatus } = require('../modules/provisioning/seams.js');
65
+
66
+ function rowDb(row) {
67
+ const seen = [];
68
+ return { seen, async query(sql, params) { seen.push({ sql: String(sql), params }); return { rows: row ? [row] : [] }; } };
69
+ }
70
+
71
+ test('port: the project is named by slug AND host, the host lowercased', async () => {
72
+ const db = rowDb(null);
73
+ assert.equal(await projectAppStatus(db, { slug: 'mercury', host: 'Mercury.Test' }), null);
74
+ assert.deepEqual(db.seen[0].params, ['mercury', 'mercury.test']);
75
+ assert.match(db.seen[0].sql, /i\.slug = \$1 AND lower\(i\.domain\) = \$2/);
76
+ });
77
+
78
+ test('port: a half-named project is no answer, and no query runs', async () => {
79
+ const db = rowDb({ app_host: null, waiting: false });
80
+ assert.equal(await projectAppStatus(db, { slug: 'mercury' }), null);
81
+ assert.equal(await projectAppStatus(db, { host: 'mercury.test' }), null);
82
+ assert.equal(db.seen.length, 0);
83
+ });
84
+
85
+ test('port: no platform yet is { app: null }; Render reads through app-status.js', async () => {
86
+ assert.deepEqual(await projectAppStatus(rowDb({ app_host: null, waiting: false }), { slug: 'm', host: 'm.test' }), { app: null, sandbox: null });
87
+ const live = await projectAppStatus(rowDb({
88
+ app_host: { platform: 'render', state: 'live', serviceUrl: 'https://mercury-app.onrender.com', serviceId: 'srv-abcdefgh12' },
89
+ waiting: false,
90
+ }), { slug: 'm', host: 'm.test' });
91
+ assert.equal(live.app.stage, 'live');
92
+ assert.equal(live.app.url, 'https://mercury-app.onrender.com');
93
+ assert.equal(live.app.links.service, 'https://dashboard.render.com/web/srv-abcdefgh12');
94
+ const failed = await projectAppStatus(rowDb({ app_host: { platform: 'render', state: 'failed', error: 'the build command failed' }, waiting: false }), { slug: 'm', host: 'm.test' });
95
+ assert.equal(failed.app.state, 'failed');
96
+ assert.equal(failed.app.error, 'The build command failed.');
97
+ const waiting = await projectAppStatus(rowDb({ app_host: { platform: 'render', state: 'requested' }, waiting: true }), { slug: 'm', host: 'm.test' });
98
+ assert.equal(waiting.app.waiting, true);
99
+ assert.equal(waiting.app.stage, 'creating');
100
+ });
101
+
102
+ // ── 2. the hub route ─────────────────────────────────────────────────────────
103
+
104
+ const pi = require('../modules/platform-identity/platform-identity.js');
105
+ const SECRET = 'project-secret';
106
+ const CLIENTS = {
107
+ mercury: { client_id: 'mercury', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: 'https://Mercury.test' },
108
+ frozen: { client_id: 'frozen', secret_hash: pi.sha256hex(SECRET), status: 'suspended', origin: 'https://frozen.test' },
109
+ shell: { client_id: 'shell', secret_hash: '', status: 'active', origin: 'https://shell.test' },
110
+ };
111
+ const clientDb = { async query(_sql, params) { return { rows: CLIENTS[params[0]] ? [CLIENTS[params[0]]] : [] }; } };
112
+ const buildHubRoute = require('../modules/platform-identity/routes/sso-app-status.js');
113
+
114
+ test('hub: a wrong secret, a suspended client and a catalog-only row are all refused, and the port is never asked', async () => {
115
+ const asked = [];
116
+ const hub = await serve(buildHubRoute({ db: clientDb, resolvePort: () => async (t) => { asked.push(t); return { app: null }; } }));
117
+ try {
118
+ for (const body of [
119
+ { client_id: 'mercury', client_secret: 'nope' },
120
+ { client_id: 'frozen', client_secret: SECRET },
121
+ { client_id: 'shell', client_secret: '' },
122
+ { client_id: 'nobody', client_secret: SECRET },
123
+ ]) {
124
+ const r = await hub.call('POST', '/sso/app-status', body);
125
+ assert.equal(r.status, 401, JSON.stringify(body));
126
+ }
127
+ assert.equal(asked.length, 0);
128
+ } finally { hub.close(); }
129
+ });
130
+
131
+ test('hub: the project reads ITS OWN row — slug from the client, host from its registered origin', async () => {
132
+ const asked = [];
133
+ const app = { platform: 'render', state: 'live', stage: 'live', url: 'https://m.onrender.com', sandbox: { url: 'https://m-sandbox.onrender.com' } };
134
+ const hub = await serve(buildHubRoute({ db: clientDb, resolvePort: (n) => (n === 'provisioning.projectAppStatus' ? async (t) => { asked.push(t); return { app }; } : null) }));
135
+ try {
136
+ const r = await hub.call('POST', '/sso/app-status', { client_id: 'mercury', client_secret: SECRET });
137
+ assert.equal(r.status, 200);
138
+ assert.deepEqual(asked, [{ slug: 'mercury', host: 'mercury.test' }]);
139
+ assert.deepEqual(r.body, { ok: true, known: true, app, sandbox: null }, 'the shape passes through, new fields and all');
140
+ const extra = await hub.call('POST', '/sso/app-status', { client_id: 'mercury', client_secret: SECRET, slug: 'venus' });
141
+ assert.equal(extra.status, 400, 'nothing in the body can name another project');
142
+ } finally { hub.close(); }
143
+ });
144
+
145
+ test('hub: no provisioning, or no row, is known:false', async () => {
146
+ for (const resolvePort of [() => null, () => async () => null]) {
147
+ const hub = await serve(buildHubRoute({ db: clientDb, resolvePort }));
148
+ try {
149
+ const r = await hub.call('POST', '/sso/app-status', { client_id: 'mercury', client_secret: SECRET });
150
+ assert.deepEqual(r.body, { ok: true, known: false, app: null, sandbox: null });
151
+ } finally { hub.close(); }
152
+ }
153
+ });
154
+
155
+ // ── 3. the core asker ────────────────────────────────────────────────────────
156
+
157
+ const pin = (rel, exports) => {
158
+ const p = require.resolve(rel);
159
+ require.cache[p] = { id: p, filename: p, loaded: true, exports };
160
+ };
161
+ const IDP = { origin: 'https://hub.test', clientId: 'mercury', clientSecret: SECRET };
162
+ function loadAsker(idp) {
163
+ pin('../src/bongos/auth-config.js', { ...require('../src/bongos/auth-config.js'), loadIdpConfig: () => idp });
164
+ delete require.cache[require.resolve('../src/bongos/auth-admission.js')];
165
+ return require('../src/bongos/auth-admission.js').readAppStatusFromHub;
166
+ }
167
+ async function withFetch(impl, fn) {
168
+ const real = globalThis.fetch;
169
+ const sent = [];
170
+ globalThis.fetch = async (url, init) => { sent.push({ url, init }); return impl(url, init); };
171
+ try { await fn(sent); } finally { globalThis.fetch = real; }
172
+ }
173
+ const MANAGE = { manage_url: 'https://hub.test/projects?view=manage&project=mercury', hub_host: 'hub.test' };
174
+
175
+ test('asker: no hub answers hub:false and sends nothing', async () => {
176
+ const ask = loadAsker(null);
177
+ await withFetch(async () => ({ ok: true }), async (sent) => {
178
+ assert.deepEqual(await ask(), { hub: false });
179
+ assert.equal(sent.length, 0);
180
+ });
181
+ });
182
+
183
+ test('asker: it sends only this project\'s credentials, bounded, and returns the hub\'s answer with the manage page', async () => {
184
+ const ask = loadAsker(IDP);
185
+ const app = { platform: 'render', state: 'creating', stage: 'creating' };
186
+ await withFetch(async () => ({ ok: true, json: async () => ({ ok: true, known: true, app }) }), async (sent) => {
187
+ assert.deepEqual(await ask(), { hub: true, reachable: true, known: true, app, sandbox: null, manage: MANAGE });
188
+ assert.equal(sent[0].url, 'https://hub.test/api/bongos/sso/app-status');
189
+ assert.deepEqual(JSON.parse(sent[0].init.body), { client_id: 'mercury', client_secret: SECRET });
190
+ assert.ok(sent[0].init.signal);
191
+ });
192
+ });
193
+
194
+ test('asker: a hub that is down or older never throws — reachable:false', async () => {
195
+ const ask = loadAsker(IDP);
196
+ await withFetch(async () => { throw new Error('ECONNREFUSED'); }, async () => {
197
+ assert.deepEqual(await ask(), { hub: true, reachable: false, manage: MANAGE });
198
+ });
199
+ await withFetch(async () => ({ ok: false, status: 404, json: async () => ({}) }), async () => {
200
+ assert.deepEqual(await ask(), { hub: true, reachable: false, manage: MANAGE });
201
+ });
202
+ });
203
+
204
+ // ── 4. the hall route ────────────────────────────────────────────────────────
205
+
206
+ const buildHall = require('../modules/app-host/routes/app-host.js');
207
+ const { viewFor } = buildHall;
208
+ const RENDER_LIVE = {
209
+ platform: 'render', state: 'live', stage: 'live', url: 'https://m.onrender.com',
210
+ links: { service: 'https://dashboard.render.com/web/srv-abcdefgh12' }, workspace: 'tea-x', retry: null,
211
+ sandbox: { url: 'https://m-sandbox.onrender.com', stage: 'live' },
212
+ };
213
+
214
+ test('hall: each state, as the owner and as everyone else', () => {
215
+ const read = (app, extra = {}) => ({ hub: true, reachable: true, known: true, app, manage: MANAGE, ...extra });
216
+ // no platform yet
217
+ let v = viewFor(read(null), { owner: true });
218
+ assert.equal(v.app, null);
219
+ assert.equal(v.connect_url, `${MANAGE.manage_url}#mAppCard`);
220
+ v = viewFor(read(null), { owner: false });
221
+ assert.equal(v.connect_url, null, 'no action a non-owner cannot complete');
222
+ assert.equal(v.owner, false);
223
+ // live, owner: links kept; non-owner: owner-only fields gone, the rest (and new fields) kept
224
+ v = viewFor(read(RENDER_LIVE), { owner: true });
225
+ assert.ok(v.app.links);
226
+ assert.equal('retry' in v.app, false);
227
+ v = viewFor(read(RENDER_LIVE), { owner: false });
228
+ for (const k of buildHall.OWNER_ONLY) assert.equal(k in v.app, false, k);
229
+ assert.equal(v.app.url, RENDER_LIVE.url);
230
+ assert.deepEqual(v.app.sandbox, RENDER_LIVE.sandbox, 'a field the hub adds later arrives untouched');
231
+ // failed: the sentence reaches everyone; only the owner gets where to retry
232
+ const failed = { platform: 'render', state: 'failed', stage: null, error: 'The build command failed.', retry: { owner_id: 'tea-x' } };
233
+ assert.equal(viewFor(read(failed), { owner: false }).app.error, 'The build command failed.');
234
+ assert.equal(viewFor(read(failed), { owner: false }).connect_url, null);
235
+ assert.ok(viewFor(read(failed), { owner: true }).connect_url);
236
+ // not reachable, not known, no hub
237
+ assert.equal(viewFor({ hub: true, reachable: false, manage: MANAGE }, { owner: true }).reachable, false);
238
+ assert.equal(viewFor(read(null, { known: false }), { owner: true }).known, false);
239
+ assert.deepEqual(viewFor({ hub: false }, { owner: true }), { hub: false, reachable: false, known: false, app: null, sandbox: null, owner: false, connect_url: null, hub_host: null });
240
+ });
241
+
242
+ test('hall: GET /app-host decides owner on the server, never from the request', async () => {
243
+ const read = async () => ({ hub: true, reachable: true, known: true, app: RENDER_LIVE, manage: MANAGE });
244
+ let ownerIds = new Set([1]);
245
+ const hall = await serve(buildHall({ readHub: read, isOwner: async (req) => ownerIds.has(req.builder.id) }));
246
+ try {
247
+ viewer = { id: 1 };
248
+ let r = await hall.call('GET', '/app-host?owner=1');
249
+ assert.equal(r.status, 200);
250
+ assert.equal(r.body.owner, true);
251
+ assert.ok(r.body.app.links);
252
+ assert.equal(r.headers['cache-control'], 'no-store');
253
+ viewer = { id: 2 };
254
+ r = await hall.call('GET', '/app-host?owner=1');
255
+ assert.equal(r.body.owner, false);
256
+ assert.equal(r.body.connect_url, null);
257
+ assert.equal(r.body.app.links, undefined);
258
+ } finally { hall.close(); viewer = { id: 7 }; }
259
+ });
260
+
261
+ test('hall: the owner atom is core.pin.move, and an unreadable authority is no', async () => {
262
+ // The default isOwner, through the government port.
263
+ const realResolve = fakeApi.resolveOptional;
264
+ const read = async () => ({ hub: true, reachable: true, known: true, app: null, manage: MANAGE });
265
+ try {
266
+ for (const [gov, expected] of [
267
+ [{ resolveBuilderPermissions: async () => ['core.pin.move'] }, true],
268
+ [{ resolveBuilderPermissions: async () => ['task.claim'] }, false],
269
+ [{ resolveBuilderPermissions: async () => { throw new Error('db down'); } }, false],
270
+ [null, false],
271
+ ]) {
272
+ fakeApi.resolveOptional = (n) => (n === 'government' ? gov : null);
273
+ const hall = await serve(buildHall({ readHub: read }));
274
+ try { assert.equal((await hall.call('GET', '/app-host')).body.owner, expected); } finally { hall.close(); }
275
+ }
276
+ } finally { fakeApi.resolveOptional = realResolve; }
277
+ });
278
+
279
+ // ── 5. the module switch ─────────────────────────────────────────────────────
280
+
281
+ const { hallWidgetScripts } = require('../src/module-loader/loader.js');
282
+ const { injectModuleHallWidgets } = require('../src/bongos/serve-internal.js');
283
+
284
+ test('module: on, its panel script is listed for the options menu; off, it is not', () => {
285
+ const on = hallWidgetScripts({ isEnabled: () => true, file: 'settings-widget.js' });
286
+ assert.ok(on.some((s) => s.key === 'app-host' && s.src === '/modules/app-host/public/settings-widget.js'));
287
+ const off = hallWidgetScripts({ isEnabled: (k) => k !== 'app-host', file: 'settings-widget.js' });
288
+ assert.equal(off.some((s) => s.key === 'app-host'), false);
289
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/app-host/module.json'), 'utf8'));
290
+ assert.equal(manifest.default, true);
291
+ assert.deepEqual(manifest.contributes.routes, ['app-host']);
292
+ });
293
+
294
+ test('module: the panel script lands before settings.js and loads the way it does (not deferred)', () => {
295
+ const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
296
+ const out = injectModuleHallWidgets(html, [{ key: 'app-host', src: '/modules/app-host/public/settings-widget.js' }], 'settings');
297
+ const at = out.indexOf('<script src="/modules/app-host/public/settings-widget.js"></script>');
298
+ assert.ok(at > 0, 'injected as a plain script');
299
+ assert.ok(at < out.indexOf('<script src="/builders/settings.js'));
300
+ assert.ok(at > out.indexOf('<script src="/builders/dom-utils.js'), 'after the seam exists');
301
+ assert.equal(injectModuleHallWidgets(html, [], 'settings'), html, 'off: the page is unchanged');
302
+ // The hall index keeps its deferred widgets.
303
+ const idx = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/index.html'), 'utf8');
304
+ assert.match(injectModuleHallWidgets(idx, [{ key: 'x', src: '/modules/x/public/hall-widget.js' }]), /hall-widget\.js" defer><\/script>/);
305
+ });
306
+
307
+ // ── 6. the panel ─────────────────────────────────────────────────────────────
308
+
309
+ function loadPanel() {
310
+ const panels = [];
311
+ // The hall's shared escaper, as window.OTB carries it (dom-utils.js).
312
+ const { escapeHtml } = require('../src/html-escape.js');
313
+ const window = { OTB: { escapeHtml, contributeSettingsPanel: (p) => panels.push(p) } };
314
+ vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'modules/app-host/public/settings-widget.js'), 'utf8'), { window });
315
+ return { panels, render: window.OTBAppHost.render };
316
+ }
317
+
318
+ test('panel: registers one options-menu panel', () => {
319
+ const { panels } = loadPanel();
320
+ assert.equal(panels.length, 1);
321
+ assert.equal(panels[0].id, 'app-host');
322
+ assert.equal(panels[0].title, 'Where the app runs');
323
+ });
324
+
325
+ test('panel: what a builder reads in each state', () => {
326
+ const { render } = loadPanel();
327
+ const base = { hub: true, reachable: true, known: true, hub_host: 'hub.test' };
328
+ const owner = { ...base, owner: true, connect_url: `${MANAGE.manage_url}#mAppCard` };
329
+ const member = { ...base, owner: false, connect_url: null };
330
+
331
+ let h = render({ ...owner, app: null });
332
+ assert.match(h, /No platform yet/);
333
+ assert.match(h, /href="https:\/\/hub\.test\/projects\?view=manage&amp;project=mercury#mAppCard">Connect Render</);
334
+ h = render({ ...member, app: null });
335
+ assert.match(h, /No platform yet/);
336
+ assert.doesNotMatch(h, /<a /, 'no action a non-owner cannot complete');
337
+ assert.match(h, /owner can connect Render/);
338
+
339
+ assert.match(render({ ...member, app: { platform: 'render', state: 'creating', stage: 'creating' } }), /creating.*Step 1 of 3|Step 1 of 3[\s\S]*creating/);
340
+ assert.match(render({ ...member, app: { platform: 'render', state: 'deploying', stage: 'building' } }), /Step 2 of 3/);
341
+ assert.match(render({ ...member, app: { platform: 'render', state: 'deploying', stage: 'quiet', quiet: true } }), /hasn’t answered/);
342
+ h = render({ ...member, app: { platform: 'render', state: 'live', stage: 'live', url: 'https://m.onrender.com' } });
343
+ assert.match(h, /status-pill--ok">live/);
344
+ assert.match(h, /href="https:\/\/m\.onrender\.com"/);
345
+ assert.doesNotMatch(render({ ...member, app: { platform: 'render', state: 'live', stage: 'live', url: 'https://evil.example' } }), /evil/, 'only an onrender.com address is linked');
346
+
347
+ h = render({ ...owner, app: { platform: 'render', state: 'failed', stage: null, error: 'The build command <b>failed</b>.' } });
348
+ assert.match(h, /status-pill--danger">failed/);
349
+ assert.match(h, /The build command &lt;b&gt;failed&lt;\/b&gt;\./, 'the setup\'s own words, escaped');
350
+ assert.match(h, />Retry on the project page</);
351
+ h = render({ ...member, app: { platform: 'render', state: 'failed', stage: null, error: 'The build command failed.' } });
352
+ assert.match(h, /owner can retry it from this project's page on hub\.test/);
353
+ assert.doesNotMatch(h, /<a /);
354
+
355
+ assert.match(render({ ...owner, reachable: false }), /Check again/);
356
+ assert.match(render({ ...owner, known: false }), /isn’t set up through hub\.test/);
357
+ assert.match(render({ hub: false }), /runs on its own server/);
358
+ });
359
+
360
+ // ── 7. the sandbox beside the app (task 1005289's shape, carried as is) ──────────
361
+
362
+ test('sandbox: the port reads it from the same row, only for a Render app', async () => {
363
+ const row = {
364
+ status: 'active', waiting: false,
365
+ app_host: {
366
+ platform: 'render', state: 'live', serviceUrl: 'https://m.onrender.com', serviceId: 'srv-abcdefgh12',
367
+ sandbox: { state: 'live', serviceId: 'srv-sandbox1234', serviceUrl: 'https://m-sandbox.onrender.com' },
368
+ },
369
+ };
370
+ const got = await projectAppStatus(rowDb(row), { slug: 'm', host: 'm.test' });
371
+ assert.equal(got.sandbox.stage, 'live');
372
+ assert.equal(got.sandbox.url, 'https://m-sandbox.onrender.com');
373
+ assert.equal((await projectAppStatus(rowDb({ app_host: null, status: 'active', waiting: false }), { slug: 'm', host: 'm.test' })).sandbox, null);
374
+ });
375
+
376
+ test('sandbox: the hub and the asker pass it through; an older hub sends none', async () => {
377
+ const sandbox = { state: 'live', stage: 'live', url: 'https://m-sandbox.onrender.com' };
378
+ const hub = await serve(buildHubRoute({ db: clientDb, resolvePort: () => async () => ({ app: { platform: 'render', stage: 'live' }, sandbox }) }));
379
+ try {
380
+ const r = await hub.call('POST', '/sso/app-status', { client_id: 'mercury', client_secret: SECRET });
381
+ assert.deepEqual(r.body.sandbox, sandbox);
382
+ } finally { hub.close(); }
383
+ const ask = loadAsker(IDP);
384
+ await withFetch(async () => ({ ok: true, json: async () => ({ ok: true, known: true, app: null }) }), async () => {
385
+ assert.equal((await ask()).sandbox, null);
386
+ });
387
+ });
388
+
389
+ test('sandbox: everyone sees it, only the owner gets its links, and "none" is not drawn', () => {
390
+ const app = { platform: 'render', state: 'live', stage: 'live' };
391
+ const sandbox = { state: 'live', stage: 'live', url: 'https://m-sandbox.onrender.com', links: { service: 'https://dashboard.render.com/web/srv-sandbox1234' }, can_add: false, plan: 'free' };
392
+ const read = (sb) => ({ hub: true, reachable: true, known: true, app, sandbox: sb, manage: MANAGE });
393
+ assert.ok(viewFor(read(sandbox), { owner: true }).sandbox.links);
394
+ const other = viewFor(read(sandbox), { owner: false }).sandbox;
395
+ assert.equal(other.links, undefined);
396
+ assert.equal(other.can_add, undefined);
397
+ assert.equal(other.url, sandbox.url);
398
+ assert.equal(other.plan, 'free', 'other fields pass through');
399
+ assert.equal(viewFor(read({ state: 'none', message: 'This project has no sandbox yet.', can_add: true }), { owner: true }).sandbox, null);
400
+ });
401
+
402
+ test('sandbox: the panel draws a second row with its own stage and address', () => {
403
+ const { render } = loadPanel();
404
+ const d = { hub: true, reachable: true, known: true, hub_host: 'hub.test', owner: false, connect_url: null,
405
+ app: { platform: 'render', state: 'live', stage: 'live', url: 'https://m.onrender.com' } };
406
+ let h = render({ ...d, sandbox: { state: 'live', stage: 'live', url: 'https://m-sandbox.onrender.com' } });
407
+ assert.match(h, /Sandbox[\s\S]*href="https:\/\/m-sandbox\.onrender\.com"/);
408
+ h = render({ ...d, sandbox: { state: 'requested', stage: 'creating', message: 'Your sandbox is set up right after your app.' } });
409
+ assert.match(h, /Sandbox[\s\S]*set up right after your app[\s\S]*creating/);
410
+ h = render({ ...d, sandbox: { state: 'failed', stage: null, error: 'The free plan is full.' } });
411
+ assert.match(h, /Setting up the sandbox stopped\. The free plan is full\./);
412
+ assert.doesNotMatch(render({ ...d, sandbox: null }), /Sandbox/);
413
+ });