@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
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+ // provision-render-sandbox.js — the project's SANDBOX on Render: set it up beside the app,
3
+ // and remove it at teardown (task 1005289; ADR 0406, ADR 0327).
4
+ //
5
+ // WHY. Every project whose app runs on Render gets a second, free app from the same
6
+ // repository that follows `main` on every merge, so finished work reaches a test copy
7
+ // first and the live site moves only when a person promotes (ADR 0406 §2). This file is
8
+ // the runner half; the web tier queues it (modules/provisioning/render-sandbox.js) and the
9
+ // standup leg calls it after the app (provision-render.js), with the same borrowed key.
10
+ //
11
+ // THREE STAGES, kept apart so one failing never takes the others down:
12
+ // 1. BUILD — the app's own build settings: what the owner gave (new app), or read off
13
+ // their existing service (a linked one).
14
+ // 2. DATABASE — never Render's free database (it expires, one per workspace: ADR 0406
15
+ // §4). When the app has a PAID database we created, a second database is made INSIDE
16
+ // that server with plain SQL (Render's docs: "Adding multiple databases to a single
17
+ // instance" — there is no API call for it). No app database → no sandbox database.
18
+ // A failure here leaves the sandbox running with NO database and says why; it never
19
+ // falls back to buying one — that is the owner's call (ADR 0406 §6 choice 1).
20
+ // 3. SERVICE — a free web service named `<slug>-sandbox`, auto-deploying from `main`,
21
+ // with BONGOS_SANDBOX=1. Found by name first, so a re-run never makes a second one.
22
+ //
23
+ // The live service is not touched here: switching it to manual deploys belongs with the
24
+ // promote button that replaces them (task 1005290).
25
+ //
26
+ // TEARDOWN removes the sandbox service and the sandbox database only — never the live app
27
+ // or its data (ADR 0406 §7). It needs the owner's key; without one the sandbox is
28
+ // forgotten and the owner is told exactly what to delete.
29
+
30
+ const payload = require('./render-payload.js');
31
+ const { parseTargetRef } = require('./provision-repo-auth.js');
32
+
33
+ async function recordSandbox(db, instId, patch) {
34
+ await db.query(
35
+ `UPDATE provisioning_instances
36
+ SET app_host = COALESCE(app_host, '{}'::jsonb)
37
+ || jsonb_build_object('sandbox', COALESCE(app_host->'sandbox', '{}'::jsonb) || $2::jsonb)
38
+ WHERE id = $1`,
39
+ [instId, JSON.stringify({ ...patch, updatedAt: new Date().toISOString() })]);
40
+ }
41
+
42
+ // Run SQL against the app's database SERVER from the runner. Uses the server's external
43
+ // address (the runner is outside Render's network). Injected in tests: no real database.
44
+ async function defaultSqlRunner(connectionString, statements) {
45
+ const { Client } = require('pg');
46
+ const c = new Client({ connectionString, ssl: { rejectUnauthorized: true }, connectionTimeoutMillis: 15000 });
47
+ await c.connect();
48
+ try {
49
+ const out = [];
50
+ for (const s of statements) out.push(await c.query(s.text, s.values || []));
51
+ return out;
52
+ } finally { await c.end().catch(() => {}); }
53
+ }
54
+
55
+ // The same connection string pointed at another database on the same server.
56
+ function withDatabase(connectionString, dbName) {
57
+ const u = new URL(connectionString);
58
+ u.pathname = `/${dbName}`;
59
+ return u.toString();
60
+ }
61
+
62
+ // Stage 2. Returns { state: 'ready', name, url } | { state: 'none' | 'failed', note }.
63
+ async function sandboxDatabase(api, inst, host, deps) {
64
+ if (!host.postgresId) {
65
+ return { state: 'none', note: host.mode === 'adopt'
66
+ ? 'Bongos did not create your app’s database, so the sandbox has none. It runs your app without data.'
67
+ : 'Your app has no database, so its sandbox has none either.' };
68
+ }
69
+ try {
70
+ const pg = await api.getPostgres(host.postgresId);
71
+ if (pg.plan === 'free') return { state: 'none', note: 'Your app’s database is on Render’s free plan, which cannot hold a sandbox database, so the sandbox has none.' };
72
+ const conn = await api.postgresConnectionInfo(pg.id);
73
+ const ext = conn && conn.externalConnectionString;
74
+ const int = conn && conn.internalConnectionString;
75
+ if (!ext || !int) throw new Error('Render gave no connection address for your database');
76
+ const name = payload.sandboxDbName(inst);
77
+ const run = deps.sqlRunner || defaultSqlRunner;
78
+ const [exists] = await run(ext, [{ text: 'SELECT 1 FROM pg_database WHERE datname = $1', values: [name] }]);
79
+ if (!(exists && exists.rows && exists.rows.length)) await run(ext, [{ text: `CREATE DATABASE "${name}"` }]);
80
+ return { state: 'ready', name, url: withDatabase(int, name) };
81
+ } catch (e) {
82
+ return { state: 'failed', note: `The sandbox’s own database could not be made, so the sandbox runs with no data: ${String((e && e.message) || e).split('\n')[0].slice(0, 200)}` };
83
+ }
84
+ }
85
+
86
+ // Stages 1-3. Never throws: a sandbox failure is recorded on the sandbox and never fails
87
+ // the app it sits beside. Returns { ok, error? }.
88
+ async function standupSandbox(api, inst, host, deps) {
89
+ const { db, log } = deps;
90
+ try {
91
+ const repo = parseTargetRef(inst.target_ref);
92
+ if (!repo) throw new Error('the project has no GitHub repository for the sandbox to build from');
93
+ // 1. build settings
94
+ let build, region = host.region || payload.DEFAULTS.region;
95
+ if (host.mode === 'adopt') {
96
+ const live = await api.getService(host.serviceId);
97
+ build = payload.buildFromService(live);
98
+ if (!build) throw new Error('Render did not say how your linked service is built, so the sandbox could not copy it');
99
+ region = (live.serviceDetails && live.serviceDetails.region) || region;
100
+ } else {
101
+ build = payload.buildFor(host);
102
+ }
103
+ await recordSandbox(db, inst.id, { state: 'creating', error: null });
104
+ // 2. database — its own stage; its failure is the sandbox's note, not its end
105
+ const sb = host.sandbox || {};
106
+ const dbStage = await sandboxDatabase(api, inst, host, deps);
107
+ await recordSandbox(db, inst.id, { database: dbStage.state, dbName: dbStage.name || null, dbNote: dbStage.note || null });
108
+ log(` [render] sandbox database: ${dbStage.state}${dbStage.name ? ` (${dbStage.name})` : ''}`);
109
+ // 3. the free service, found before created
110
+ const name = payload.resourceName(inst, 'sandbox');
111
+ let svc = sb.serviceId ? await api.getService(sb.serviceId).catch(() => null) : null;
112
+ if (!svc) svc = await api.findServiceByName(name);
113
+ if (!svc) {
114
+ const created = await api.createService(payload.sandboxServicePayload(inst, {
115
+ ownerId: api.ownerId, repoUrl: `https://github.com/${repo.owner}/${repo.name}`, region, build,
116
+ dbUrl: dbStage.state === 'ready' ? dbStage.url : null,
117
+ }));
118
+ svc = created.service;
119
+ log(` [render] created sandbox service ${svc.id} (free, follows main)`);
120
+ }
121
+ const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
122
+ await recordSandbox(db, inst.id, { state: 'deploying', serviceId: svc.id, serviceUrl: url, error: null });
123
+ return { ok: true };
124
+ } catch (e) {
125
+ const error = `The sandbox could not be set up: ${String((e && e.message) || e).split('\n')[0].slice(0, 240)}`;
126
+ await recordSandbox(db, inst.id, { state: 'failed', error }).catch(() => {});
127
+ log(` ✖ [render] ${inst.slug} sandbox: ${error}`);
128
+ return { ok: false, error };
129
+ }
130
+ }
131
+
132
+ // Teardown's half for the sandbox. `api` is a Render client built from the key the owner
133
+ // lent with the teardown, or null when they lent none.
134
+ async function removeSandbox(inst, { db, log, apply, sqlRunner }, api) {
135
+ const host = inst.app_host || {};
136
+ const sb = host.sandbox;
137
+ if (host.platform !== 'render' || !sb || (!sb.serviceId && !sb.dbName)) return { removed: false, reason: 'none' };
138
+ if (!api) {
139
+ const what = [sb.serviceId && `the service ${payload.resourceName(inst, 'sandbox')}`, sb.dbName && `the database ${sb.dbName} inside your app’s database`].filter(Boolean).join(' and ');
140
+ log(` [render] sandbox left on Render (no key lent with the teardown): ${what}`);
141
+ if (apply) await recordSandbox(db, inst.id, { state: 'left', error: `No Render key came with the teardown, so the sandbox is still on your Render account: remove ${what} there.` });
142
+ return { removed: false, reason: 'no_key' };
143
+ }
144
+ const notes = [];
145
+ let dbGone = !sb.dbName;
146
+ if (sb.serviceId) {
147
+ if (apply) await api.deleteSandboxService(sb.serviceId);
148
+ notes.push(`service ${sb.serviceId}`);
149
+ }
150
+ if (sb.dbName && host.postgresId && payload.SANDBOX_DB_RE.test(sb.dbName)) {
151
+ try {
152
+ const conn = await api.postgresConnectionInfo(host.postgresId);
153
+ if (apply && conn && conn.externalConnectionString) {
154
+ await (sqlRunner || defaultSqlRunner)(conn.externalConnectionString, [{ text: `DROP DATABASE IF EXISTS "${sb.dbName}" WITH (FORCE)` }]);
155
+ }
156
+ notes.push(`database ${sb.dbName}`);
157
+ dbGone = true;
158
+ } catch (e) {
159
+ log(` (sandbox database ${sb.dbName} not dropped: ${e && e.message})`);
160
+ }
161
+ }
162
+ log(` [render] ${apply ? 'removed' : 'would remove'} the sandbox (${notes.join(' and ')}); the live app is untouched`);
163
+ if (apply) {
164
+ await recordSandbox(db, inst.id, dbGone
165
+ ? { state: 'removed', serviceId: null, serviceUrl: null, dbName: null, error: null }
166
+ : { state: 'removed', serviceId: null, serviceUrl: null, error: `The sandbox app was removed, but its database ${sb.dbName} is still inside your app’s database server.` });
167
+ }
168
+ return { removed: true };
169
+ }
170
+
171
+ module.exports = { standupSandbox, removeSandbox, sandboxDatabase, recordSandbox, withDatabase };
@@ -34,6 +34,7 @@
34
34
  const { RenderApi } = require('./render-api.js');
35
35
  const payload = require('./render-payload.js');
36
36
  const { parseTargetRef } = require('./provision-repo-auth.js');
37
+ const sandbox = require('./provision-render-sandbox.js');
37
38
 
38
39
  // A key waiting longer than this was lent for a run that never came. It is refused and
39
40
  // cleared rather than spent late.
@@ -147,7 +148,18 @@ async function renderStandupInstance(inst, deps, intent) {
147
148
  const owners = await api.listOwners();
148
149
  if (!owners.some((o) => o.id === host.ownerId)) return refuse(`this Render key cannot reach workspace ${host.ownerId}`);
149
150
 
150
- return host.mode === 'adopt' ? await adopt(api, inst, host, deps) : await greenfield(api, inst, host, deps);
151
+ // An app already up that only asked for its sandbox (render-sandbox.js
152
+ // enqueueSandboxAdd) is not touched again: the run goes straight to the sandbox.
153
+ const sandboxOnly = !!(host.sandboxWanted && host.sandbox && host.sandbox.state === 'requested'
154
+ && host.serviceId && ['deploying', 'linked', 'live'].includes(host.state));
155
+ const app = sandboxOnly ? { ok: true } : (host.mode === 'adopt' ? await adopt(api, inst, host, deps) : await greenfield(api, inst, host, deps));
156
+ // The sandbox (ADR 0406, task 1005289): after the app, with the same key. Its failure
157
+ // is recorded on the sandbox and never fails the app.
158
+ if (app.ok && host.sandboxWanted) {
159
+ const fresh = deps.provisioning && deps.provisioning.getInstanceById ? ((await deps.provisioning.getInstanceById(db, inst.id)) || inst) : inst;
160
+ await sandbox.standupSandbox(api, fresh, { ...host, ...(fresh.app_host || {}) }, deps);
161
+ }
162
+ return app;
151
163
  } catch (e) {
152
164
  return refuse(plainReason(e));
153
165
  } finally {
@@ -190,9 +202,32 @@ async function afterProvision(inst, deps, intent, result) {
190
202
  // Teardown's half (ADR 0327 §3): forget the Render app, never delete it. The ids stay on
191
203
  // the row so an operator can still say which services were the project's; the owner
192
204
  // deletes them in their own Render dashboard if they want to stop paying.
193
- async function forgetRenderApp(inst, { db, log, apply }) {
205
+ //
206
+ // The SANDBOX is the one exception (ADR 0406 §7, task 1005289): the platform made it, so
207
+ // the platform removes it — when the owner lent their key with the teardown (the running
208
+ // teardown intent's borrowed_key_enc). The key is cleared here whatever happens.
209
+ async function forgetRenderApp(inst, deps) {
210
+ const { db, log, apply } = deps;
194
211
  const host = inst.app_host;
195
212
  if (!host || host.platform !== 'render') return;
213
+ if (host.sandbox) {
214
+ let keyEnc = null, intentId = null;
215
+ try {
216
+ const { rows } = await db.query(
217
+ `SELECT id, borrowed_key_enc FROM provisioning_intents
218
+ WHERE instance_id = $1 AND action = 'teardown' AND state IN ('pending', 'running') AND borrowed_key_enc IS NOT NULL
219
+ ORDER BY created_at DESC LIMIT 1`, [inst.id]);
220
+ if (rows && rows[0]) { keyEnc = rows[0].borrowed_key_enc; intentId = rows[0].id; }
221
+ const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
222
+ const api = keyEnc && host.ownerId ? renderApiFor(deps, secretBox.decrypt(keyEnc), host.ownerId) : null;
223
+ await sandbox.removeSandbox(inst, deps, api);
224
+ } catch (e) {
225
+ log(` (sandbox not removed: ${e && e.message})`);
226
+ if (apply) await sandbox.recordSandbox(db, inst.id, { state: 'left', error: `The sandbox could not be removed, so it is still on your Render account: ${String((e && e.message) || e).split('\n')[0].slice(0, 200)}` }).catch(() => {});
227
+ } finally {
228
+ if (intentId != null) await clearBorrowedKey(db, intentId);
229
+ }
230
+ }
196
231
  const what = [host.serviceId && `service ${host.serviceId}`, host.postgresId && `database ${host.postgresId}`].filter(Boolean).join(' and ') || 'nothing yet';
197
232
  log(` [render] ${apply ? 'disconnected' : 'would disconnect'} from Render (${what}); nothing on Render is deleted — the owner removes it in their own dashboard`);
198
233
  if (apply) await recordAppHost(db, inst.id, { state: 'disconnected' });
@@ -40,7 +40,7 @@ async function teardownInstance(inst, deps) {
40
40
  if (inst.db_name) log(` (left for the operator: dropdb ${dbName(inst)}, prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz)`);
41
41
  parkAddress(inst, { controlExec, log, apply });
42
42
  // The app's Render services are the OWNER's: forget them, never delete them (ADR 0327 §3, task 1004183).
43
- await require('./provision-render.js').forgetRenderApp(inst, { db, log, apply });
43
+ await require('./provision-render.js').forgetRenderApp(inst, { ...deps, db, log, apply }); // …except the sandbox, removed when a key came with the teardown (ADR 0406 §7)
44
44
  // Revoke the instance's deploy key (ADR 0176) and remove its local halves. The
45
45
  // checkout itself is deliberately left in place (data is precious), so the key
46
46
  // files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
@@ -78,6 +78,7 @@ const PUBLISH_ALLOWLIST = [
78
78
  'modules/render-deploy/', // the deploy page of a project whose app deploys through Render (task 1004270). default:false. MUST be here for the npm-release reason: modules/hall-ui/ ships as one subtree, so a released core without it carries deploy.js's loader for #rd-panel with no routes behind it.
79
79
  'modules/market-watch/', // the weekly AI market watch (task 1004935, goal 1000137). default:false and empty for now, but MUST be here: shipped is not enabled, and a released core without it answers "unknown module" to an instance that turns it on; tests/market_watch_module.mjs (publishable) also reads its manifest.
80
80
  'modules/ai-studio/', // AI generation for artists (task 1004916, ADR 0392): keys, prepaid balance, generate. default:false.
81
+ 'modules/app-host/', // where the app runs (task 1004571): the options menu's panel, read from the hub. default:true.
81
82
  'modules/front-door-guide/', // the start-a-project guide (task 1005272): a capped AI chat for a signed-out visitor on the platform's key. default:false, hub first; shipped is not enabled.
82
83
  'modules/raise/', // the /raise investor page (task 1004847, ADR 0384) — ships in core, enabled ONLY on the hub, like platform-identity. Owner decision 2026-10-25: the whole page, its copy and starting numbers included, lives in the repo so builders can contribute to it, accepting that it publishes with the core. Secrets never do: the investor password is RAISE_PASSWORD_HASH in the instance env. default:false.
83
84
  'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
@@ -24,9 +24,13 @@
24
24
  // resource through the guard, so nothing is ever sent to or read from a resource whose
25
25
  // workspace was not checked.
26
26
  //
27
- // NO DELETE. The platform must not hold the ability to destroy infrastructure in an
28
- // account it does not own (ADR 0327 §3). There is no delete method, and _req refuses the
29
- // verb outright, so adding one later is a visible change to this file, not a one-liner.
27
+ // NO DELETE — with ONE narrow exception. The platform must not hold the ability to destroy
28
+ // infrastructure in an account it does not own (ADR 0327 §3), and _req refuses the verb
29
+ // outright. The exception is the project's SANDBOX (ADR 0406 §7, task 1005289): a free
30
+ // service the platform itself created, removed when the owner tears the project down with
31
+ // their key lent. deleteSandboxService reads the service through the guard first and
32
+ // refuses anything not named `…-sandbox` on the free plan, so it can never reach the
33
+ // owner's live app; it is the only path to a DELETE in this file.
30
34
 
31
35
  'use strict';
32
36
 
@@ -133,6 +137,32 @@ class RenderApi extends JsonHttpApi {
133
137
  return this._req('PUT', `/services/${encodeURIComponent(serviceId)}/env-vars/${encodeURIComponent(key)}`, { value }, { dryRunResult: { key } });
134
138
  }
135
139
 
140
+ // The service of this exact name in this workspace, or null. Lets a re-run find a
141
+ // sandbox an earlier run created before its id reached the row, instead of making a
142
+ // second one. Every element of the list is checked through the guard.
143
+ async findServiceByName(name) {
144
+ const q = `/services?name=${encodeURIComponent(name)}&ownerId=${encodeURIComponent(this.ownerId)}&limit=20`;
145
+ const json = await this._req('GET', q, null, { dryRunResult: { items: [] } });
146
+ const items = Array.isArray(json) ? json : (json && json.items) || [];
147
+ const found = items.map((e) => (e && e.service) || e).filter((s) => s && typeof s.id === 'string');
148
+ found.forEach((s) => this.assertOwned(s, `service ${s.id}`));
149
+ return found.find((s) => s.name === name) || null;
150
+ }
151
+
152
+ // Remove a project's SANDBOX service — and nothing else (see the header). The service is
153
+ // read through the workspace guard, and refused unless it is named `…-sandbox` AND on
154
+ // the free plan: the owner's live app is never either.
155
+ async deleteSandboxService(serviceId) {
156
+ const svc = await this.getService(serviceId);
157
+ const plan = svc.serviceDetails && svc.serviceDetails.plan;
158
+ if (!/-sandbox$/.test(String(svc.name || '')) || plan !== 'free') {
159
+ throw new RenderApiError(`refusing to delete service ${serviceId}: it is not a free sandbox the platform created (ADR 0406 §7)`);
160
+ }
161
+ const path = `/services/${encodeURIComponent(serviceId)}`;
162
+ if (this.dryRun) { this.log(`[dry-run] Render DELETE ${path}`); return { dryRun: true }; }
163
+ return super._req('DELETE', path, null);
164
+ }
165
+
136
166
  async triggerDeploy(serviceId) {
137
167
  await this.getService(serviceId);
138
168
  return this._req('POST', `/services/${encodeURIComponent(serviceId)}/deploys`, {}, { dryRunResult: { id: null } });
@@ -82,4 +82,42 @@ function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region,
82
82
  };
83
83
  }
84
84
 
85
- module.exports = { DEFAULTS, resourceName, plansFor, buildFor, renderPostgresPayload, renderServicePayload };
85
+ // POST /services, for the project's SANDBOX (ADR 0406, task 1005289): the same repository
86
+ // and build as the app, on Render's FREE plan, following `main` on every merge. It runs
87
+ // with BONGOS_SANDBOX=1 so the app (and the starter's outbound gate, task 1005291) knows
88
+ // it is not the live site. `dbUrl` is the sandbox's OWN database, never the live one.
89
+ const SANDBOX_SUFFIX = 'sandbox';
90
+ function sandboxServicePayload(inst, { ownerId, repoUrl, region, build, dbUrl }) {
91
+ const p = renderServicePayload(inst, { ownerId, repoUrl, branch: 'main', webPlan: 'free', region, build, dbUrl });
92
+ p.name = resourceName(inst, SANDBOX_SUFFIX);
93
+ p.autoDeploy = 'yes';
94
+ p.envVars = [{ key: 'BONGOS_SANDBOX', value: '1' }, ...p.envVars];
95
+ return p;
96
+ }
97
+
98
+ // The sandbox's database name inside the app's own database server: the project's slug
99
+ // with underscores, ending `_sandbox`. Checked against a strict shape because it is put
100
+ // into a CREATE / DROP DATABASE statement as an identifier.
101
+ const SANDBOX_DB_RE = /^[a-z][a-z0-9_]{0,40}_sandbox$/;
102
+ function sandboxDbName(inst) {
103
+ let base = resourceName(inst).replace(/-/g, '_').replace(/^[^a-z]+/, '').slice(0, 40);
104
+ if (!base) base = `project_${Number(inst.id) || 0}`;
105
+ const name = `${base}_sandbox`;
106
+ if (!SANDBOX_DB_RE.test(name)) throw new Error(`could not make a safe sandbox database name from '${inst.slug}'`);
107
+ return name;
108
+ }
109
+
110
+ // How an adopted service is built, read off Render's own answer, so the sandbox copies
111
+ // it (ADR 0406 §7: "the sandbox is a new free service copying its build settings").
112
+ // Returns null when the answer does not say enough to copy.
113
+ function buildFromService(svc) {
114
+ const d = (svc && svc.serviceDetails) || {};
115
+ const runtime = d.runtime || d.env;
116
+ if (!RUNTIMES.includes(runtime)) return null;
117
+ if (runtime === 'docker') return { runtime: 'docker' };
118
+ const e = d.envSpecificDetails || {};
119
+ if (!e.startCommand) return null;
120
+ return { runtime, buildCommand: e.buildCommand || BUILD_DEFAULTS[runtime], startCommand: e.startCommand };
121
+ }
122
+
123
+ module.exports = { DEFAULTS, resourceName, plansFor, buildFor, renderPostgresPayload, renderServicePayload, sandboxServicePayload, sandboxDbName, buildFromService, SANDBOX_DB_RE };
@@ -97,11 +97,12 @@ const SEAM_CONSUMERS = {
97
97
  uiSections: {
98
98
  // Per-MODULE, not per-entry: hallWidgetScripts looks for one widget file per page
99
99
  // however many sections the module names — hall-widget.js for the index, and since
100
- // task 1004301 task-widget.js for a task's page. Either one wires the declaration.
100
+ // task 1004301 task-widget.js for a task's page, and since task 1004571 settings-widget.js
101
+ // for the options menu. Any one wires the declaration.
101
102
  consumer: 'src/module-loader/loader.js hallWidgetScripts',
102
103
  kind: 'file',
103
104
  artifact: (dir) => path.join(dir, 'public', 'hall-widget.js'),
104
- alternates: (dir) => [path.join(dir, 'public', 'task-widget.js')],
105
+ alternates: (dir) => [path.join(dir, 'public', 'task-widget.js'), path.join(dir, 'public', 'settings-widget.js')],
105
106
  },
106
107
  skills: {
107
108
  // moduleSkillSources resolves a declared name against the module's OWN tree or
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/first-read.js — @bongos-forwarder (task 1004848). Generated by
3
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
4
+ // The real script is scripts/bongos/first-read.js. This forwarder keeps callers that still name
5
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
6
+ 'use strict';
7
+ require('../bongos/forwarder.js').forward(module, 'first-read.js');
@@ -0,0 +1,6 @@
1
+ // scripts/gds/provision-render-sandbox.js — @bongos-forwarder (task 1004848). Generated by
2
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
3
+ // The real script is scripts/bongos/provision-render-sandbox.js. This forwarder keeps callers that still name
4
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
5
+ 'use strict';
6
+ require('../bongos/forwarder.js').forward(module, 'provision-render-sandbox.js');
@@ -457,6 +457,8 @@ for (const key of MODULES_ON) {
457
457
  const isOn = (k) => !!(mods.enabled && mods.enabled[k]);
458
458
  if (stamped['index.html']) stamped['index.html'] = injectModuleHallWidgets(stamped['index.html'], hallWidgetScripts({ isEnabled: isOn }));
459
459
  if (stamped['task.html']) stamped['task.html'] = injectModuleHallWidgets(stamped['task.html'], hallWidgetScripts({ isEnabled: isOn, file: 'task-widget.js' }), 'task');
460
+ // the options menu's module panels (task 1004571)
461
+ if (stamped['settings.html']) stamped['settings.html'] = injectModuleHallWidgets(stamped['settings.html'], hallWidgetScripts({ isEnabled: isOn, file: 'settings-widget.js' }), 'settings');
460
462
  // ...and their assets, for the same set. The real asset route (serve-internal.js) gates on
461
463
  // the instance config's enabled set, which ignores --modules-on, so a widget injected for
462
464
  // a module this checkout's config leaves off 404'd and the slot stayed empty (task 1004940).
@@ -626,8 +626,72 @@ async function notifyHubOfInvite(githubLogin, { invited = true } = {}) {
626
626
  }
627
627
  }
628
628
 
629
+ // WHERE THIS PROJECT'S OWNER TAKES IT OFFLINE OR DISCONNECTS IT (task 1004570). Both
630
+ // controls live on the hub's Projects page for this project — the typed-name confirm and
631
+ // the intent queue are there (ADR 0290) — so the hall only links to them and gains no
632
+ // second delete path. Moved here from routes/core-update.js (task 1004571) so the kernel's
633
+ // readAppStatusFromHub can name the same page; the route re-exports it. The hub origin and
634
+ // this project's client id, which is its slug; null on a self-hosted instance or the hub itself, so the
635
+ // hall shows no section rather than a dead link. The page resolves ?project=<slug>
636
+ // against the visitor's OWN projects, so the link opens nothing for anyone else.
637
+ function hubManageLinks(config) {
638
+ if (!config || typeof config.origin !== 'string' || !config.clientId) return null;
639
+ if (!/^https?:\/\/[^\s"'<>]+$/i.test(config.origin)) return null;
640
+ const origin = config.origin.replace(/\/+$/, '');
641
+ const page = `${origin}/projects?view=manage&project=${encodeURIComponent(String(config.clientId))}`;
642
+ let host = origin;
643
+ try { host = new URL(origin).host; } catch { /* keep the origin as written */ }
644
+ return { hub_host: host, manage_url: page, offline_url: `${page}#mDelete`, disconnect_url: `${page}#mDisconnect` };
645
+ }
646
+
647
+ // readAppStatusFromHub() — where THIS project's own app runs, asked of the hub (task
648
+ // 1004571). We host every project's hall; its app runs on the owner's Render account, and
649
+ // that setup's state lives in the hub's provisioning row, not here. So the hall asks, as
650
+ // the project itself (client_id + client_secret, POST /sso/app-status), and the hub answers
651
+ // with modules/provisioning/app-status.js's own shape for this project only.
652
+ //
653
+ // In the kernel beside the other hub calls for readApplicantProfileFromHub's reason: it
654
+ // holds the project's hub client secret, and a module reaches it only through this narrow
655
+ // port — no argument, one project, one direction in.
656
+ //
657
+ // Answers, never throws:
658
+ // { hub: false } — not federated (self-hosted, or the hub)
659
+ // { hub: true, reachable: false, manage } — the hub did not answer (down, older hub)
660
+ // { hub: true, reachable: true, known, app, sandbox, manage }
661
+ // `manage` is the hub's page for this project ({ manage_url, hub_host }, hubManageLinks
662
+ // above), where the owner connects Render; it names an address and grants nothing.
663
+
664
+ const HUB_APP_STATUS_TIMEOUT_MS = 5000;
665
+
666
+ async function readAppStatusFromHub() {
667
+ let idp = null;
668
+ try { idp = loadIdpConfig(); } catch { idp = null; }
669
+ if (!idp) return { hub: false };
670
+ const links = hubManageLinks(idp);
671
+ const manage = links ? { manage_url: links.manage_url, hub_host: links.hub_host } : null;
672
+ try {
673
+ const r = await fetch(`${idp.origin}/api/bongos/sso/app-status`, {
674
+ method: 'POST',
675
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
676
+ signal: AbortSignal.timeout(HUB_APP_STATUS_TIMEOUT_MS),
677
+ body: JSON.stringify({ client_id: idp.clientId, client_secret: idp.clientSecret }),
678
+ });
679
+ const body = r.ok ? await r.json().catch(() => null) : null;
680
+ if (!body || body.ok !== true) return { hub: true, reachable: false, manage };
681
+ const app = body.app && typeof body.app === 'object' ? body.app : null;
682
+ // The sandbox beside the app (task 1005289); an older hub sends none.
683
+ const sandbox = body.sandbox && typeof body.sandbox === 'object' ? body.sandbox : null;
684
+ return { hub: true, reachable: true, known: body.known === true, app, sandbox, manage };
685
+ } catch (err) {
686
+ log.info('[auth] hub app-status read failed (non-blocking):', err.message);
687
+ return { hub: true, reachable: false, manage };
688
+ }
689
+ }
690
+
629
691
  module.exports = {
630
692
  firstAdminLogin,
693
+ readAppStatusFromHub,
694
+ hubManageLinks,
631
695
  firstTimeAdmissionBlocked,
632
696
  instanceNeedsFirstAdmin,
633
697
  readApplicantProfileFromHub,
@@ -57,7 +57,7 @@ const ic = require('../instance-config');
57
57
  const { branding, userAgent } = require('../branding');
58
58
 
59
59
  const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
60
- const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readApplicantProfileFromHub, readApplicantViewFromHub, webAdmissionStatus } = require('./auth-admission.js');
60
+ const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readAppStatusFromHub, readApplicantProfileFromHub, readApplicantViewFromHub, webAdmissionStatus } = require('./auth-admission.js');
61
61
  const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
62
62
  const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
63
63
  const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
@@ -843,6 +843,7 @@ module.exports = {
843
843
  joinabilityMode,
844
844
  readApplicantProfileFromHub,
845
845
  readApplicantViewFromHub,
846
+ readAppStatusFromHub,
846
847
  notifyHubOfInvite,
847
848
  readApplicationVouch,
848
849
  firstTimeAdmissionBlocked,
@@ -193,6 +193,10 @@ const MODULE_GLOBS = {
193
193
  'tests/front_door_guide_plan.mjs', 'tests/front_door_guide_db.mjs',
194
194
  'tests/front_door_guide_existing.mjs',
195
195
  ],
196
+ // Where the app runs (task 1004571): the options menu's panel on a project's hall.
197
+ 'app-host': [
198
+ 'modules/app-host/', 'tests/app_host.mjs',
199
+ ],
196
200
  // The /raise investor page (task 1004847, ADR 0384): hub-only.
197
201
  raise: [
198
202
  'modules/raise/',
@@ -53,22 +53,9 @@ function hubDeployUrl(config) {
53
53
  return `${config.origin.replace(/\/+$/, '')}/deploy?project=${encodeURIComponent(String(config.clientId))}`;
54
54
  }
55
55
 
56
- // WHERE THIS PROJECT'S OWNER TAKES IT OFFLINE OR DISCONNECTS IT (task 1004570). Both
57
- // controls live on the hub's Projects page for this project — the typed-name confirm and
58
- // the intent queue are there (ADR 0290) — so the hall only links to them and gains no
59
- // second delete path. Same two inputs as hubDeployUrl (the hub origin and this project's
60
- // client id, which is its slug); null on a self-hosted instance or the hub itself, so the
61
- // hall shows no section rather than a dead link. The page resolves ?project=<slug>
62
- // against the visitor's OWN projects, so the link opens nothing for anyone else.
63
- function hubManageLinks(config) {
64
- if (!config || typeof config.origin !== 'string' || !config.clientId) return null;
65
- if (!/^https?:\/\/[^\s"'<>]+$/i.test(config.origin)) return null;
66
- const origin = config.origin.replace(/\/+$/, '');
67
- const page = `${origin}/projects?view=manage&project=${encodeURIComponent(String(config.clientId))}`;
68
- let host = origin;
69
- try { host = new URL(origin).host; } catch { /* keep the origin as written */ }
70
- return { hub_host: host, manage_url: page, offline_url: `${page}#mDelete`, disconnect_url: `${page}#mDisconnect` };
71
- }
56
+ // hubManageLinks — the hub's page for THIS project (task 1004570). Lives in the kernel's
57
+ // auth-admission.js since task 1004571, which needs it too and may not import a route file.
58
+ const { hubManageLinks } = require('../auth-admission');
72
59
 
73
60
  // WHERE THIS PROJECT'S UPDATE BUTTON GOES (task 1004296). The act itself never happens in
74
61
  // Settings: it is the deploy door's two-step confirm and auto-rollback (ADR 0293), and that
@@ -232,9 +232,20 @@ function contributeModuleDesk() {
232
232
  seams.contribute('governor.docket', { desk: 'rules.modules', gate: { permission: 'module.enable' }, read: async () => moduleDeskRows(listModules()) });
233
233
  }
234
234
 
235
+ // The port `modules.catalog` (task 1005281): every module on disk with its words and
236
+ // whether it is on, so a module (the lifecycle's first read-through) can name only real
237
+ // modules without requiring core. Read-only, built per call from listModules().
238
+ function registerModuleCatalogPort() {
239
+ if (seams.hasProvider('modules.catalog')) return;
240
+ seams.registerProvider('modules.catalog', () => listModules().map((m) => ({
241
+ key: m.key, title: m.title || m.key, description: m.description || '', enabled: m.enabled === true,
242
+ })));
243
+ }
244
+
235
245
  module.exports = function buildModulesRouter() {
236
246
  contributeModuleRechecksToDocket();
237
247
  contributeModuleDesk();
248
+ registerModuleCatalogPort();
238
249
  // Task 1004399: carry tarballs written inside the installed core package (the old
239
250
  // store path) into the instance store before the next upgrade erases them. Async and
240
251
  // never fatal; a no-op in a single checkout, where the two paths are one directory.
@@ -418,15 +418,18 @@ function clientBundleScriptTag() {
418
418
  // [{ key, src }]; default reads the live enabled set. Pure + exported for the test.
419
419
  function injectModuleHallWidgets(html, scripts = hallWidgetScripts({ isEnabled: isModuleEnabled }), consumer = 'builders') {
420
420
  if (!Array.isArray(scripts) || scripts.length === 0) return html;
421
- const tags = scripts
422
- .map((s) => `<script src="${s.src}" defer></script>`)
423
- .join('');
424
421
  // Anchor on the consuming page script's <script> (builders.js on the index, task.js on
425
- // a task's page — task 1004301) regardless of its ?v= stamp, so the module widgets
426
- // always land immediately before the consumer: both are `defer`, so they run in
427
- // document order, and a widget's listener exists before the page first announces.
422
+ // a task's page — task 1004301; settings.js on the options menu — task 1004571)
423
+ // regardless of its ?v= stamp, so the module widgets always land immediately before the
424
+ // consumer. A widget loads the way its consumer does: builders.js and task.js are
425
+ // `defer`, so their widgets are too and run in document order; settings.js is a plain
426
+ // script, so its widgets are plain too — a deferred one would run after it had mounted.
428
427
  const name = /^[a-z-]+$/.test(consumer) ? consumer : 'builders';
429
428
  const anchor = html.match(new RegExp(`<script src="\\/builders\\/${name}\\.js[^"]*"[^>]*><\\/script>`));
429
+ const defer = !anchor || /\sdefer[\s>=]/.test(anchor[0]);
430
+ const tags = scripts
431
+ .map((s) => `<script src="${s.src}"${defer ? ' defer' : ''}></script>`)
432
+ .join('');
430
433
  if (anchor) return html.replace(anchor[0], tags + anchor[0]);
431
434
  return html.includes('</body>') ? html.replace('</body>', `${tags}</body>`) : html + tags;
432
435
  }
@@ -1171,6 +1174,17 @@ function mountInternalSurfaces(app) {
1171
1174
  );
1172
1175
  }
1173
1176
 
1177
+ // task 1004571: the options menu. A module that ships public/settings-widget.js
1178
+ // registers a panel through window.OTB.contributeSettingsPanel; it is loaded just before
1179
+ // settings.js, which mounts every registered panel. With none enabled, nothing changes.
1180
+ if (buildersHtml['settings.html']) {
1181
+ buildersHtml['settings.html'] = injectModuleHallWidgets(
1182
+ buildersHtml['settings.html'],
1183
+ hallWidgetScripts({ isEnabled: isModuleEnabled, file: 'settings-widget.js' }),
1184
+ 'settings',
1185
+ );
1186
+ }
1187
+
1174
1188
  // Serve enabled modules' browser assets at /modules/<key>/public/* — the URL the
1175
1189
  // loader's hallWidgetScripts projection points at. Mounted BEFORE the /builders
1176
1190
  // static handler. Gated per-request on the live enabled set so a disabled module's
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.16'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.18'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -409,6 +409,11 @@ module.exports = {
409
409
  // 1002304). The guild rides only beside a profile, so a null profile is a
410
410
  // null view; an older hub that sends no guild answers guild: null.
411
411
  get readApplicantViewFromHub() { return require('./bongos/auth').readApplicantViewFromHub; },
412
+ // readAppStatusFromHub() — where THIS project's app runs, read from the hub with the
413
+ // project's own client credentials (task 1004571). The same narrow-port posture: no
414
+ // argument, so a module can neither widen nor re-address it, and the secret stays off
415
+ // the doorway. Never throws: { hub: false } | { hub: true, reachable, known?, app?, manage }.
416
+ get readAppStatusFromHub() { return require('./bongos/auth').readAppStatusFromHub; },
412
417
  // notifyHubOfInvite(githubLogin, { invited }) — tell the hub about one of THIS
413
418
  // project's invites (true) or its rescind (false), so the invitee sees it in
414
419
  // My Projects (task 1002818, ADR 0353). The same narrow-port reason as the