@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.
- package/.bongos-core.json +200 -75
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +36 -2
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
- package/docs/api/openapi.json +761 -112
- package/docs/api-reference.md +23 -3
- package/docs/copy-inventory.md +118 -108
- package/docs/copy-registry.json +315 -172
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +62 -62
- package/modules/app-host/CLAUDE.md +15 -0
- package/modules/app-host/module.json +20 -0
- package/modules/app-host/public/settings-widget.js +143 -0
- package/modules/app-host/routes/app-host.js +88 -0
- package/modules/government/charter-advice.js +103 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/government/presets.js +42 -0
- package/modules/government/recommendations.js +102 -7
- package/modules/government/routes/government.js +1 -0
- package/modules/lifecycle/first-read-catalog.js +62 -0
- package/modules/lifecycle/first-read-db.js +190 -0
- package/modules/lifecycle/first-read.js +330 -0
- package/modules/lifecycle/kickoff-checklist.js +12 -3
- package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
- package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/first-read.js +101 -0
- package/modules/onboarding/genesis-work.js +101 -4
- package/modules/onboarding/routes/genesis-work.js +2 -2
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso-app-status.js +65 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/pollers/app-liveness.js +23 -1
- package/modules/provisioning/render-sandbox.js +113 -0
- package/modules/provisioning/render-standup.js +3 -1
- package/modules/provisioning/routes/provisioning.js +4 -1
- package/modules/provisioning/routes/render-standup.js +49 -1
- package/modules/provisioning/seams.js +32 -1
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/public-landing/public/assets/guide-core.js +475 -0
- package/modules/public-landing/public/assets/guide-hall.css +121 -0
- package/modules/public-landing/public/assets/guide-hall.js +164 -0
- package/modules/public-landing/public/assets/guide-scene.css +9 -0
- package/modules/public-landing/public/assets/guide-scene.js +58 -381
- package/modules/public-landing/public/index.html +46 -1
- package/modules/public-landing/public/projects.html +112 -6
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/bongos/first-read.js +150 -0
- package/scripts/bongos/genesis.js +21 -1
- package/scripts/bongos/government-walk.js +4 -4
- package/scripts/bongos/provision-render-sandbox.js +171 -0
- package/scripts/bongos/provision-render.js +37 -2
- package/scripts/bongos/provision-teardown.js +1 -1
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/render-api.js +33 -3
- package/scripts/bongos/render-payload.js +39 -1
- package/scripts/bongos/seam-wiring-guard.js +3 -2
- package/scripts/gds/first-read.js +7 -0
- package/scripts/gds/provision-render-sandbox.js +6 -0
- package/scripts/hall-preview/server.js +2 -0
- package/src/bongos/auth-admission.js +64 -0
- package/src/bongos/auth.js +2 -1
- package/src/bongos/module-scope-map.js +4 -0
- package/src/bongos/routes/core-update.js +3 -16
- package/src/bongos/routes/modules.js +11 -0
- package/src/bongos/serve-internal.js +20 -6
- package/src/module-api.js +6 -1
- package/src/module-loader/loader.js +2 -2
- package/tests/app_host.mjs +413 -0
- package/tests/first_read.mjs +479 -0
- package/tests/genesis_work.mjs +74 -3
- package/tests/government_act_alone_buildings.mjs +2 -0
- package/tests/government_charter_advice.mjs +150 -0
- package/tests/landing_guide_hall.mjs +364 -0
- package/tests/landing_guide_scene.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_where.mjs +13 -3
- package/tests/provisioning_app_status.mjs +3 -2
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +50 -0
- package/tests/render_api.mjs +5 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
28
|
-
// account it does not own (ADR 0327 §3)
|
|
29
|
-
//
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/bongos/auth.js
CHANGED
|
@@ -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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
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
|
|
426
|
-
//
|
|
427
|
-
//
|
|
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.
|
|
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
|