@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
@@ -41,7 +41,8 @@
41
41
  //
42
42
  // WHICH STAGES. A stage adopts the pattern by declaring its questions in QUESTIONS.
43
43
  // Strategize does (task 1005116); scope's guided door is its project interview (task 1005114);
44
- // charter, legalize and legislation adopt it when they are next touched. A stage with
44
+ // charter asks how decisions should go and the founder's principles (task 1005119);
45
+ // legalize and legislation adopt it when they are next touched. A stage with
45
46
  // no questions still takes a free page and a link.
46
47
  //
47
48
  // THE BOUNDARY. Pure apart from the store. The store is injected (tests) or the
@@ -57,6 +58,9 @@ const STATES = Object.freeze(['answered', 'not_sure', 'skipped']);
57
58
  const ORIGINS = Object.freeze(['founder', 'assisted', 'carried_over']);
58
59
  const LIMITS = Object.freeze({ answer: 4000, page: 20000, link: 500 });
59
60
 
61
+ // A closed question's options: [key, label] pairs, frozen.
62
+ const choices = (pairs) => Object.freeze(pairs.map(([key, label]) => Object.freeze({ key, label })));
63
+
60
64
  // Each adopted stage's questions, in the order the guided door asks them. `id` is
61
65
  // stable: a stored answer names it, so never rename or reuse one. `hint` is the shape
62
66
  // of a good answer, shown under the question in every door. `group` (optional) is a
@@ -83,6 +87,47 @@ const QUESTIONS = Object.freeze({
83
87
  Object.freeze({ id: 'others_wrong', group: 'The bet', ask: 'What is everyone else in this area getting wrong?',
84
88
  hint: 'The thing you believe that others in this space do not. This is the bet the project makes.' }),
85
89
  ]),
90
+ // CHARTER (task 1005119): how decisions should go, then the founder's principles.
91
+ // A question with `choices` is a CLOSED one: an answer is one of its keys, because
92
+ // the government module's recommender reads it (recommendations.js CHARTER_ANSWERS
93
+ // and presets.js PRINCIPLE_ANSWERS hold the same keys, by test; a module may not
94
+ // import a sibling, ADR 0083). The prose ones shape the drafted bylaws and code of
95
+ // conduct in the founder's own words. Every one is skippable and "not sure yet".
96
+ charter: Object.freeze([
97
+ Object.freeze({ id: 'final_say', group: 'How decisions go', ask: 'When people disagree, who should have the final say?',
98
+ hint: 'Who settles it in the end. This is who gets a vote.',
99
+ choices: choices([['founder', 'Me, the founder'], ['trusted', 'The people who have earned trust by shipping'],
100
+ ['everyone', 'Everyone on the project'], ['council', 'A small group I appoint']]) }),
101
+ Object.freeze({ id: 'agreement', group: 'How decisions go', ask: 'How much agreement should a decision need?',
102
+ hint: 'Fewer means faster; more means everyone is bought in.',
103
+ choices: choices([['first_ratifier', 'One yes is enough'], ['consent', 'It passes unless someone objects'],
104
+ ['majority', 'More than half must say yes'], ['unanimous', 'Everyone who votes must agree']]) }),
105
+ Object.freeze({ id: 'strangers', group: 'How decisions go', ask: 'Do you expect people you have not met to show up and contribute?',
106
+ hint: 'Whether newcomers should get a say straight away or earn it first.',
107
+ choices: choices([['yes', 'Yes'], ['no', 'No, it is people I know']]) }),
108
+ Object.freeze({ id: 'answers_to', group: 'How decisions go', ask: 'Is there anyone outside the project it answers to: funders, a mission, a legal entity?',
109
+ hint: 'Something the project must stay true to, whoever is building it.',
110
+ choices: choices([['yes', 'Yes'], ['no', 'No']]) }),
111
+ Object.freeze({ id: 'step_back', group: 'How decisions go', ask: 'Do you want to be able to step back later and hand this over?',
112
+ hint: 'Whether the project should keep deciding well without you.',
113
+ choices: choices([['yes', 'Yes'], ['no', 'No, I plan to stay at the helm']]) }),
114
+ Object.freeze({ id: 'speed', group: 'How decisions go', ask: 'Would you rather decide fast, or have everyone bought in?',
115
+ hint: 'When the two pull apart, which wins.',
116
+ choices: choices([['fast', 'Decide fast'], ['bought_in', 'Everyone bought in']]) }),
117
+ Object.freeze({ id: 'trust_level', group: 'Principles', ask: 'How much should people be trusted to work without checking in first?',
118
+ hint: 'This shapes how strict the rules for the project\'s AI agents are.',
119
+ choices: choices([['a_lot', 'A lot: let them get on with it'], ['some', 'Some: a few rules everyone follows'],
120
+ ['little', 'Little: tight rules, checked often']]) }),
121
+ Object.freeze({ id: 'catch_or_fix', group: 'Principles', ask: 'When something goes wrong, would you rather have caught it early, or move fast and fix it?',
122
+ hint: 'Neither is wrong. It sets how much is checked before work goes ahead.',
123
+ choices: choices([['catch_early', 'Catch it early'], ['move_fast', 'Move fast and fix it']]) }),
124
+ Object.freeze({ id: 'leave_behaviour', group: 'Principles', ask: 'What behaviour would make you ask someone to leave?',
125
+ hint: 'The line nobody crosses. It becomes the heart of the code of conduct.' }),
126
+ Object.freeze({ id: 'feel', group: 'Principles', ask: 'What do you want this project to feel like to work on?',
127
+ hint: 'A few words: calm, fast, careful, playful, serious.' }),
128
+ Object.freeze({ id: 'write_down', group: 'Principles', ask: 'Is there anything you want written down now, so it is not argued about later?',
129
+ hint: 'A rule, a promise or a boundary. It goes into the bylaws as you wrote it.' }),
130
+ ]),
86
131
  strategize: Object.freeze([
87
132
  Object.freeze({ id: 'big_pieces', ask: 'What are the big pieces, and in what order do they get built?',
88
133
  hint: 'Three to six pieces is plenty. Say why that order: what has to exist before what.' }),
@@ -116,6 +161,14 @@ function checkAnswer(stage, questionId, body = {}) {
116
161
  const words = text(b.text);
117
162
  if (words.length > LIMITS.answer) return { ok: false, reason: 'answer_too_long' };
118
163
  if (state === 'answered' && !words) return { ok: false, reason: 'empty_answer' };
164
+ // A closed question's answer is one of its keys (or its label, read back as the key),
165
+ // so the recommender reading it never has to guess at prose.
166
+ const q = questionFor(stage, questionId);
167
+ if (state === 'answered' && q.choices) {
168
+ const hit = q.choices.find((c) => c.key === words || c.label.toLowerCase() === words.toLowerCase());
169
+ if (!hit) return { ok: false, reason: 'not_a_choice', expected: q.choices.map((c) => c.key) };
170
+ return { ok: true, value: { state, answer_md: hit.key, origin } };
171
+ }
119
172
  return { ok: true, value: { state, answer_md: words || null, origin } };
120
173
  }
121
174
 
@@ -130,6 +183,7 @@ function checkLink(url) {
130
183
 
131
184
  // ── pure: the view ────────────────────────────────────────────────────────────
132
185
 
186
+ const labelOf = (q, key) => { const c = (q.choices || []).find((x) => x.key === key); return c ? c.label : null; };
133
187
  const iso = (v) => (v ? new Date(v).toISOString() : null);
134
188
 
135
189
  // shapeStage(stage, work, answers) → one stage's record as every door sees it.
@@ -141,8 +195,11 @@ function shapeStage(stage, work, answers = []) {
141
195
  const a = byId.get(q.id);
142
196
  return {
143
197
  id: q.id, ask: q.ask, hint: q.hint, group: q.group || null,
198
+ choices: q.choices || null,
144
199
  answer: a ? {
145
200
  state: a.state, text: a.answer_md || null, origin: a.origin,
201
+ // a closed answer's words, for a reader who should not see the key
202
+ choice_label: q.choices && a.state === 'answered' ? labelOf(q, a.answer_md) : null,
146
203
  // A carried-over answer is shown as such until the founder makes it theirs.
147
204
  carried_over: a.origin === 'carried_over',
148
205
  updated_at: iso(a.updated_at),
@@ -190,7 +247,7 @@ function contextMarkdown(stages) {
190
247
  out.push(`## ${s.stage[0].toUpperCase()}${s.stage.slice(1)}`);
191
248
  for (const q of said) {
192
249
  const a = q.answer;
193
- const body = a.state === 'answered' ? a.text : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
250
+ const body = a.state === 'answered' ? (a.choice_label || a.text) : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
194
251
  out.push(`- **${q.ask}**${ORIGIN_WORDS[a.origin] || ''}\n ${String(body).replace(/\n/g, '\n ')}`);
195
252
  }
196
253
  if (s.page) out.push('', '**In the founder\'s own words:**', '', s.page);
@@ -223,7 +280,11 @@ function assistPrompt(stage) {
223
280
  ];
224
281
  if (qs.length) {
225
282
  lines.push('The questions (the id in brackets is what you record the answer under):');
226
- qs.forEach((q, i) => lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`));
283
+ qs.forEach((q, i) => {
284
+ lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`);
285
+ // A closed question records one of its keys, never prose.
286
+ if (q.choices) lines.push(` Record one of: ${q.choices.map((c) => `${c.key} (${c.label})`).join(' · ')}`);
287
+ });
227
288
  lines.push('');
228
289
  }
229
290
  lines.push('To see what I have already said: npx bongos genesis show ' + stage);
@@ -316,7 +377,43 @@ async function readAll(deps = {}) {
316
377
  async function readStage(stage, deps = {}) {
317
378
  if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
318
379
  const all = await readAll(deps);
319
- return { ok: true, work: all.find((s) => s.stage === stage) };
380
+ const work = all.find((s) => s.stage === stage);
381
+ if (stage === 'charter') work.advice = await charterAdvice(work, deps);
382
+ return { ok: true, work };
383
+ }
384
+
385
+ // charterAdvice(work, deps) — THE CHARTER STAGE'S RECOMMENDATION (task 1005119): the
386
+ // founder's answers, handed to the government module's one recommender through its
387
+ // port. Closed answers go as their keys, prose ones as the founder's words; "not sure
388
+ // yet" and skipped are not sent, so the recommender falls back to what it does
389
+ // without them. The head count is the founder's own expected number from the plan
390
+ // when they gave one. Null with no government module, and null (never a failed read)
391
+ // if the advice cannot be built: the questions still work without it.
392
+ async function charterAdvice(work, deps = {}) {
393
+ try {
394
+ const api = deps.api || require('../../src/module-api');
395
+ const gov = deps.government !== undefined ? deps.government : api.resolveOptional('government');
396
+ if (!gov || typeof gov.charterAdvice !== 'function') return null;
397
+ const answers = {};
398
+ const prose = {};
399
+ for (const q of work.questions) {
400
+ if (!q.answer || q.answer.state !== 'answered') continue;
401
+ (q.choices ? answers : prose)[q.id] = q.answer.text;
402
+ }
403
+ let headcount = null;
404
+ const get = deps.getSetting || (api.projectSettings && api.projectSettings.get);
405
+ if (get) {
406
+ const row = await get('founding.plan');
407
+ try {
408
+ const v = row && row.value;
409
+ const plan = typeof v === 'string' ? JSON.parse(v) : v;
410
+ headcount = (plan && plan.expected_builders) || null;
411
+ } catch (_) { headcount = null; }
412
+ }
413
+ return await gov.charterAdvice({ answers, prose, headcount });
414
+ } catch (_) {
415
+ return null;
416
+ }
320
417
  }
321
418
 
322
419
  async function setDoor(stage, door, by, deps = {}) {
@@ -15,7 +15,7 @@ const work = require('../genesis-work');
15
15
 
16
16
  const REFUSALS = {
17
17
  unknown_stage: 404, unknown_question: 404, bad_door: 400, bad_state: 400, bad_origin: 400,
18
- empty_answer: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
18
+ empty_answer: 400, not_a_choice: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
19
19
  };
20
20
 
21
21
  // `deps` is for tests only (an in-memory store); production passes none.
@@ -25,7 +25,7 @@ module.exports = function mountGenesisWorkRoutes(router, deps = {}) {
25
25
  try {
26
26
  const r = await fn(req);
27
27
  if (r && r.ok === false) {
28
- return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.reason } });
28
+ return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.reason, ...(r.expected ? { expected: r.expected } : {}) } });
29
29
  }
30
30
  res.set('Cache-Control', 'no-store');
31
31
  res.json(r);
@@ -12,7 +12,7 @@
12
12
  "maintainer": "Cloud Bongos maintainers",
13
13
  "maintenance": { "status": "core-maintained" },
14
14
  "contributes": {
15
- "routes": ["sso", "sso-invites", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
15
+ "routes": ["sso", "sso-invites", "sso-app-status", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
16
16
  "migrations": true,
17
17
  "pollers": ["code-sweep", "visibility-pull", "guild-totals"]
18
18
  },
@@ -0,0 +1,65 @@
1
+ // modules/platform-identity/routes/sso-app-status.js — a hosted project asks the hub where
2
+ // its own APP runs (task 1004571). Server-to-server, authenticated by client_id +
3
+ // client_secret like the membership check-in and the invite notices; the asker is core's
4
+ // readAppStatusFromHub (src/bongos/auth-admission.js), and its hall shows the answer in the
5
+ // options menu.
6
+ //
7
+ // POST /sso/app-status { client_id, client_secret } → { ok, known, app, sandbox }
8
+ //
9
+ // A PROJECT READS ITS OWN ROW, NEVER ANOTHER'S. The project is the authenticated client
10
+ // itself, named by both halves of the ADR 0246 projection (client_id = slug at the client's
11
+ // registered origin) — nothing in the body can name a different one. The row and its
12
+ // app-status reading belong to the provisioning module, so this asks through the
13
+ // `provisioning.projectAppStatus` port; a hub without provisioning answers `known: false`.
14
+ //
15
+ // WHAT IT CARRIES is app-status.js's shape as is: the project is already trusted with its
16
+ // own setup (it is the project's own hall asking). Its hall decides, per viewer, what an
17
+ // owner sees beyond the state. Nothing here calls Render.
18
+ //
19
+ // WIRE SKEW (ADR 0352): a NEW route, so an older hub answers 404 and the hall says it could
20
+ // not ask. The body is validated strictly, so a field added later must ride a header, or ship
21
+ // on the hub and be released before any instance sends it.
22
+ 'use strict';
23
+
24
+ const express = require('express');
25
+ const api = require('../../../src/module-api');
26
+ const pi = require('../platform-identity');
27
+
28
+ const log = api.logger('platform-identity');
29
+ const { pool, validateOrRespond } = api;
30
+
31
+ /** The host a client's registered origin names, lowercased; null for no usable origin. */
32
+ function originHost(origin) {
33
+ try { return new URL(String(origin || '')).host.toLowerCase() || null; } catch { return null; }
34
+ }
35
+
36
+ module.exports = function ssoAppStatusRoutes({ resolvePort = (name) => api.resolveOptional(name), db = pool } = {}) {
37
+ const router = express.Router();
38
+
39
+ // rank: public — authenticated by client_id + client_secret, not a builder rank.
40
+ router.post('/sso/app-status', async (req, res) => {
41
+ if (validateOrRespond(req, res, {
42
+ client_id: { required: true, type: 'string', maxLength: 200 },
43
+ client_secret: { required: true, type: 'string', maxLength: 512 },
44
+ }, { strict: true })) return undefined;
45
+ const { client_id, client_secret } = req.body;
46
+ try {
47
+ const client = await pi.getClient(client_id, { pool: db });
48
+ if (!client || client.status !== 'active' || !pi.clientSecretValid(client, client_secret)) {
49
+ return res.fail('invalid_client', 401);
50
+ }
51
+ const read = resolvePort('provisioning.projectAppStatus');
52
+ const host = originHost(client.origin);
53
+ const found = typeof read === 'function' && host ? await read({ slug: client.client_id, host }) : null;
54
+ if (!found) return res.json({ ok: true, known: false, app: null, sandbox: null });
55
+ return res.json({ ok: true, known: true, app: found.app || null, sandbox: found.sandbox || null });
56
+ } catch (err) {
57
+ log.error('[platform-identity] /sso/app-status', err && err.message);
58
+ return res.fail('app_status_failed', { status: 500, message: 'internal error' });
59
+ }
60
+ });
61
+
62
+ return router;
63
+ };
64
+
65
+ module.exports.originHost = originHost;
@@ -40,6 +40,7 @@
40
40
  "provisioning.projectOwnedBy",
41
41
  "provisioning.projectPublishIdentity",
42
42
  "provisioning.selfUpdateChannel",
43
+ "provisioning.projectAppStatus",
43
44
  "provisioning.moduleCatalog"
44
45
  ],
45
46
  "consumes": []
@@ -86,7 +86,29 @@ async function sweepOnce(deps = {}) {
86
86
  [r.id, h.state]);
87
87
  if (rowCount) { live++; log(`[app-liveness] ${r.slug}: its app answers at ${h.serviceUrl} — live`); }
88
88
  }
89
- return { checked: rows.length, live };
89
+ // The project's SANDBOX (ADR 0406, task 1005289) gets the same knock until its first
90
+ // answer, then never again: it sleeps on the free plan, and a knock would wake it.
91
+ const { rows: sbs } = await db.query(
92
+ `SELECT id, slug, app_host->'sandbox' AS sb FROM provisioning_instances
93
+ WHERE app_host->>'platform' = 'render' AND app_host->'sandbox'->>'state' = 'deploying'
94
+ AND status <> 'torn_down'
95
+ ORDER BY id LIMIT ${SWEEP_LIMIT}`);
96
+ // the same few-at-a-time knocking as the apps above, so one slow sandbox holds none up
97
+ const sbAnswers = [];
98
+ for (let i = 0; i < sbs.length; i += CONCURRENCY) {
99
+ const batch = sbs.slice(i, i + CONCURRENCY);
100
+ sbAnswers.push(...(await Promise.all(batch.map((r) => appAnswers((r.sb || {}).serviceUrl, { fetchImpl: deps.fetchImpl, timeoutMs: deps.timeoutMs })))));
101
+ }
102
+ for (const [i, r] of sbs.entries()) {
103
+ const sb = r.sb || {};
104
+ if (!sbAnswers[i]) continue;
105
+ const { rowCount } = await db.query(
106
+ `UPDATE provisioning_instances
107
+ SET app_host = jsonb_set(app_host, '{sandbox}', (app_host->'sandbox') || jsonb_build_object('state', 'live', 'liveAt', now()::text))
108
+ WHERE id = $1 AND app_host->'sandbox'->>'state' = 'deploying'`, [r.id]);
109
+ if (rowCount) { live++; log(`[app-liveness] ${r.slug}: its sandbox answers at ${sb.serviceUrl} — live`); }
110
+ }
111
+ return { checked: rows.length + sbs.length, live };
90
112
  }
91
113
 
92
114
  function start(deps = {}) {
@@ -0,0 +1,113 @@
1
+ // modules/provisioning/render-sandbox.js — the web tier's half of a project's SANDBOX on
2
+ // Render (task 1005289; ADR 0406, ADR 0327).
3
+ //
4
+ // WHY. Every project whose app runs on Render gets a second, free app from the same
5
+ // repository that follows `main`, so finished work reaches a test copy first (ADR 0406 §2).
6
+ // A new connection asks for one by itself (render-standup.js appHostFrom sets
7
+ // `sandboxWanted`); an existing project adds one here with one action; the owner reads its
8
+ // address and state beside the app's, in the same shape (app-status.js); and a teardown
9
+ // removes it when the owner lends their key with it.
10
+ //
11
+ // Like the rest of this module the web tier never calls Render (ADR 0111 §2): it queues a
12
+ // `render-standup` intent carrying the encrypted key, and the runner
13
+ // (scripts/bongos/provision-render-sandbox.js) does the work and clears the key.
14
+ 'use strict';
15
+
16
+ const { appStatus } = require('./app-status');
17
+
18
+ // The sandbox's own database starts EMPTY: the platform cannot know an app's tables, so
19
+ // the fake data is the app's own seed, run when it sees BONGOS_SANDBOX=1 (the starter's
20
+ // half, task 1005291). Said to the owner plainly rather than promised.
21
+ const READY_DB_NOTE = 'It has its own database, separate from your live data. It starts empty: your app fills it with test data when it runs as a sandbox.';
22
+
23
+ // Why a project cannot have a sandbox right now, in the owner's words, or null.
24
+ function noSandboxReason(inst) {
25
+ const h = (inst && inst.app_host) || {};
26
+ if (!inst || inst.status === 'torn_down' || inst.status === 'tearing_down') return 'This project has been removed, so it has no sandbox.';
27
+ if (!h.platform) return 'Your project has no app yet, so there is nothing to run in a sandbox. One is set up as soon as you connect your app to Render.';
28
+ if (h.platform !== 'render') return 'Sandboxes are set up on Render only, and your app runs somewhere else.';
29
+ if (!h.serviceId || !['deploying', 'linked', 'live'].includes(h.state)) return 'Your app is not set up on Render yet. Its sandbox is set up with it.';
30
+ return null;
31
+ }
32
+
33
+ // The owner's view of the sandbox: app-status.js's shape (stage, address, links, a
34
+ // failure as one sentence) plus what its database is and whether one can be added.
35
+ // Read from the row alone.
36
+ function sandboxStatus(inst, { now = Date.now() } = {}) {
37
+ const h = (inst && inst.app_host) || {};
38
+ const sb = h.sandbox;
39
+ const live = !sb || sb.state === 'removed';
40
+ if (live && !h.sandboxWanted) {
41
+ const why = noSandboxReason(inst);
42
+ return { state: 'none', message: why || 'This project has no sandbox yet.', can_add: !why };
43
+ }
44
+ if (!sb || sb.state === 'requested') {
45
+ const why = h.platform === 'render' && h.state === 'failed' ? 'Your app’s setup on Render stopped, so its sandbox is waiting for it.' : null;
46
+ return { state: 'requested', stage: 'creating', message: why || 'Your sandbox is set up right after your app.', can_add: false };
47
+ }
48
+ if (sb.state === 'left') return { state: 'left', error: sb.error || 'The sandbox is still on your Render account.', can_add: false };
49
+ const s = appStatus({ platform: 'render', state: sb.state, serviceId: sb.serviceId, serviceUrl: sb.serviceUrl, updatedAt: sb.updatedAt, error: sb.error }, { now });
50
+ return {
51
+ ...s,
52
+ retry: undefined,
53
+ plan: 'free',
54
+ follows: 'main',
55
+ database: { state: sb.database || 'none', note: sb.dbNote || (sb.database === 'ready' ? READY_DB_NOTE : null) },
56
+ can_add: sb.state === 'failed' && !noSandboxReason(inst),
57
+ };
58
+ }
59
+
60
+ // Queue an existing project's sandbox: a render-standup intent holding the key, and the
61
+ // row marked to want one, in ONE statement — the enqueueRenderStandup pattern, so an
62
+ // open-intent collision keeps nothing. Returns { intent } or { conflict, intent }.
63
+ async function enqueueSandboxAdd(db, instanceId, { keyEnc, requestedBy }) {
64
+ await require('./control-plane-guard').assertIntentAllowed(db, instanceId, 'render-standup');
65
+ try {
66
+ const { rows } = await db.query(
67
+ `WITH i AS (
68
+ INSERT INTO provisioning_intents (instance_id, action, requested_by, state, borrowed_key_enc)
69
+ VALUES ($1, 'render-standup', $2, 'pending', $3) RETURNING *
70
+ ), u AS (
71
+ UPDATE provisioning_instances
72
+ SET app_host = app_host || jsonb_build_object('sandboxWanted', true,
73
+ 'sandbox', COALESCE(app_host->'sandbox', '{}'::jsonb) || jsonb_build_object('state', 'requested', 'error', NULL))
74
+ WHERE id = $1 RETURNING id
75
+ )
76
+ SELECT i.id, i.instance_id, i.action, i.state, i.created_at FROM i`,
77
+ [instanceId, requestedBy ?? null, keyEnc]);
78
+ return { intent: rows[0] };
79
+ } catch (e) {
80
+ if (e && e.code === '23505') {
81
+ const { rows } = await db.query(
82
+ `SELECT id, action, state FROM provisioning_intents
83
+ WHERE instance_id = $1 AND state IN ('pending', 'running') ORDER BY created_at DESC LIMIT 1`, [instanceId]);
84
+ return { conflict: 'open_intent', intent: rows[0] || { action: 'unknown', state: 'open' } };
85
+ }
86
+ throw e;
87
+ }
88
+ }
89
+
90
+ // Put the owner's key on a teardown that is still pending, so the runner can remove the
91
+ // sandbox (and only the sandbox). The table's CHECK refuses a key on a resolved intent.
92
+ async function lendKeyToTeardown(db, intentId, keyEnc) {
93
+ const { rowCount } = await db.query(
94
+ `UPDATE provisioning_intents SET borrowed_key_enc = $2, updated_at = now()
95
+ WHERE id = $1 AND action = 'teardown' AND state = 'pending'`, [intentId, keyEnc]);
96
+ return rowCount > 0;
97
+ }
98
+
99
+ // The teardown route's half: the owner's Render key, sent as the `x-render-key` header
100
+ // (a header, not a body: the kernel's audit log keeps a copy of write bodies), lent to the
101
+ // teardown just queued so the runner can remove the sandbox. Returns whether it was lent.
102
+ // A missing or malformed key, a teardown that was not just created, or a platform that
103
+ // cannot seal a key safely each lend nothing, and the teardown goes ahead without it.
104
+ const KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
105
+ async function lendKeyFromRequest(db, req, teardown, secretBox) {
106
+ if (!teardown || !teardown.created || !teardown.intent) return false;
107
+ const key = String((req.headers && req.headers['x-render-key']) || '').trim();
108
+ if (!KEY_RE.test(key)) return false;
109
+ if (!secretBox || !secretBox.isConfigured()) return false;
110
+ return module.exports.lendKeyToTeardown(db, teardown.intent.id, secretBox.encrypt(key)); // through the export, so a test can stand in for the write
111
+ }
112
+
113
+ module.exports = { noSandboxReason, sandboxStatus, enqueueSandboxAdd, lendKeyToTeardown, lendKeyFromRequest };
@@ -84,7 +84,9 @@ function badRequest(body) {
84
84
 
85
85
  // What the row records about the request — ids and plans, never the key.
86
86
  function appHostFrom(body) {
87
- const h = { platform: 'render', mode: body.mode, ownerId: body.owner_id, state: 'requested', error: null };
87
+ // Every app on Render gets a sandbox beside it (ADR 0406, task 1005289): the runner sets
88
+ // it up after the app, with the same key (scripts/bongos/provision-render-sandbox.js).
89
+ const h = { platform: 'render', mode: body.mode, ownerId: body.owner_id, state: 'requested', error: null, sandboxWanted: true };
88
90
  if (body.mode === 'adopt') h.serviceId = body.service_id;
89
91
  if (body.mode === 'greenfield') {
90
92
  h.runtime = body.runtime;
@@ -50,6 +50,7 @@
50
50
  const crypto = require('node:crypto');
51
51
  const express = require('express');
52
52
  const api = require('../../../src/module-api');
53
+ const renderSandbox = require('../render-sandbox');
53
54
  const auth = api, { pool, validateOrRespond, parseId } = api;
54
55
  const provisioning = require('../provisioning');
55
56
  const { costLedger, ownerMonthlyUsd } = require('../cost-ledger');
@@ -1002,6 +1003,8 @@ module.exports = function provisioningRoutes() {
1002
1003
  message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
1003
1004
  details: { action: r.intent.action, state: r.intent.state } });
1004
1005
  }
1006
+ // An optional Render key (x-render-key) lets the runner remove the project's sandbox — never the live app (ADR 0406 §7, task 1005289).
1007
+ const lent = await renderSandbox.lendKeyFromRequest(pool, req, r, api.secretBox);
1005
1008
  if (r.created) {
1006
1009
  const who = isProjectOwner(inst, req.builder) ? 'owner' : `archon ${req.builder.github_login || req.builder.id}`;
1007
1010
  await provisioning.recordEvent(pool, {
@@ -1010,7 +1013,7 @@ module.exports = function provisioningRoutes() {
1010
1013
  actor,
1011
1014
  }).catch(() => {});
1012
1015
  }
1013
- res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
1016
+ res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded, sandbox_key_lent: lent,
1014
1017
  message: r.created ? 'Your instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
1015
1018
  } catch (err) {
1016
1019
  log.error('[provisioning] POST /provisioning/instances/:id/teardown', err);
@@ -25,6 +25,7 @@ const renderStandup = require('../render-standup');
25
25
  const { failFrom } = require('../public-refusal');
26
26
  const { lookupRender, RenderLookupError } = require('../render-lookup');
27
27
  const { appStatus } = require('../app-status');
28
+ const renderSandbox = require('../render-sandbox');
28
29
  const envManifest = require('../env-manifest');
29
30
  const { makeAskRateLimiter } = require('../rate-limit');
30
31
 
@@ -51,6 +52,8 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
51
52
  const router = express.Router();
52
53
  const lookupLimit = makeAskRateLimiter({ windowMs: 60_000, max: 20, key: (req) => `b:${req.builder && req.builder.id}` });
53
54
  const statusLimit = makeAskRateLimiter({ windowMs: 60_000, max: 60, key: (req) => `s:${req.builder && req.builder.id}` });
55
+ // Adding a sandbox queues work on the owner's account, so a handful a minute is plenty.
56
+ const sandboxLimit = makeAskRateLimiter({ windowMs: 60_000, max: 10, key: (req) => `x:${req.builder && req.builder.id}` });
54
57
 
55
58
  // POST /provisioning/render/lookup — rank: any authenticated builder. What the owner's
56
59
  // Render key can see: its workspaces, and the web services in the one they name. The
@@ -100,13 +103,58 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
100
103
  const envVars = app && app.state !== 'disconnected'
101
104
  ? (await envManifest.listEnvVars(pool, id)).map((v) => ({ name: v.name, required: v.required !== false, set_for_you: dbSet && v.name === 'DATABASE_URL' }))
102
105
  : [];
103
- res.json({ ok: true, app, env_vars: envVars, values_held: false });
106
+ // The sandbox beside it (ADR 0406, task 1005289), in the same shape.
107
+ const sandbox = renderSandbox.sandboxStatus(inst);
108
+ res.json({ ok: true, app, sandbox, env_vars: envVars, values_held: false });
104
109
  } catch (err) {
105
110
  log.error('[provisioning] GET /provisioning/instances/:id/render', err && err.message);
106
111
  failFrom(res, err, 'render_status_failed');
107
112
  }
108
113
  });
109
114
 
115
+ // POST /provisioning/instances/:id/render/sandbox — rank: any authenticated builder, and
116
+ // OWNER ONLY, like every Render route. An app already on Render adds its sandbox with
117
+ // one action (ADR 0406, task 1005289): the owner lends the key once, and the runner sets
118
+ // up a free app that follows main, beside the live one, which it does not touch. A
119
+ // project with no app on Render is told why it has none. The reply is a receipt.
120
+ router.post('/provisioning/instances/:id/render/sandbox', api.requireBuilder, sandboxLimit, async (req, res) => {
121
+ const id = parseId(req, res, { positiveInt: true });
122
+ if (!id) return;
123
+ if (validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: 200 } })) return;
124
+ try {
125
+ const inst = await provisioning.getInstanceById(pool, id);
126
+ if (!ownedBy(inst, req.builder)) return res.fail('instance_not_found', 404);
127
+ if (!renderStandup.KEY_RE.test(req.body.key)) {
128
+ return res.fail('render_request_invalid', { status: 400, message: 'That doesn’t look like a Render API key. It starts with “rnd_”.' });
129
+ }
130
+ const why = renderSandbox.noSandboxReason(inst);
131
+ if (why) return res.fail('sandbox_unavailable', { status: 409, message: why });
132
+ const sb = inst.app_host && inst.app_host.sandbox;
133
+ if (sb && ['creating', 'deploying', 'live'].includes(sb.state)) {
134
+ return res.fail('sandbox_exists', { status: 409, message: 'This project already has a sandbox.' });
135
+ }
136
+ if (!api.secretBox.isConfigured()) {
137
+ return res.fail('render_unavailable', { status: 503, message: 'The platform can’t take a key safely right now, so nothing was sent. Try again later.' });
138
+ }
139
+ const r = await renderSandbox.enqueueSandboxAdd(pool, id, { keyEnc: api.secretBox.encrypt(req.body.key), requestedBy: 'api:self' });
140
+ if (r.conflict) {
141
+ return res.fail('intent_conflict', { status: 409,
142
+ message: `A '${r.intent.action}' run is ${r.intent.state} for this project, so your key was not kept. Try again once it has finished.`,
143
+ details: { action: r.intent.action, state: r.intent.state } });
144
+ }
145
+ await provisioning.recordEvent(pool, {
146
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'render-standup',
147
+ detail: 'Render sandbox requested by owner', actor: 'api:self',
148
+ }).catch(() => {});
149
+ res.json({ ok: true, queued: true, action: 'render-standup', sandbox: true,
150
+ message: 'Queued. The platform will add a free sandbox app on your Render account that updates with every finished change. Your live app is not touched. Your key is used once and then thrown away.' });
151
+ } catch (err) {
152
+ // Never log the request: it carries the key.
153
+ log.error('[provisioning] POST /provisioning/instances/:id/render/sandbox', err && err.message);
154
+ failFrom(res, err, 'render_sandbox_failed');
155
+ }
156
+ });
157
+
110
158
  // POST /provisioning/instances/:id/render — rank: any authenticated builder, and OWNER
111
159
  // ONLY: the key is the owner's and so is the account it acts in, so an archon on
112
160
  // someone else's project gets the same 404 as a stranger. The reply is a receipt;
@@ -7,6 +7,8 @@ const api = require('../../src/module-api');
7
7
  const { pool } = api;
8
8
  const provisioning = require('./provisioning');
9
9
  const { selfUpdateChannel } = require('./core-upgrade');
10
+ const { appStatus } = require('./app-status');
11
+ const { sandboxStatus } = require('./render-sandbox');
10
12
  const { ALWAYS_ON_CORE, optionalModules } = require('./starter-bundles');
11
13
 
12
14
  // The `provisioning.projectPublishIdentity` port (task 1003578, ADR 0247 D4): the name and
@@ -29,6 +31,32 @@ async function projectPublishIdentity(db, { slug, host } = {}) {
29
31
  return rows[0] ? { name: rows[0].slug, description: rows[0].description } : null;
30
32
  }
31
33
 
34
+ // The `provisioning.projectAppStatus` port (task 1004571): where a hosted project's APP runs,
35
+ // for that project's own hall. The hall asks the hub over the client-authenticated SSO route
36
+ // (platform-identity routes/sso-app-status.js), which names the project by BOTH halves of the
37
+ // ADR 0246 projection, as projectPublishIdentity does. The answer is app-status.js's own
38
+ // shape — never a second one — read from the row alone (nothing here calls Render), with the
39
+ // same "a lent key not yet spent means wait" reading the owner's route makes.
40
+ // The sandbox beside a Render app (ADR 0406, task 1005289) rides along in render-sandbox.js's
41
+ // own shape, the one the owner's route returns; null when the app is not on Render.
42
+ // null = no such project on this plane; { app: null } = a project whose app has no platform.
43
+ async function projectAppStatus(db, { slug, host } = {}) {
44
+ if (typeof slug !== 'string' || !slug || typeof host !== 'string' || !host) return null;
45
+ const { rows } = await db.query(
46
+ `SELECT i.app_host, i.status,
47
+ EXISTS (SELECT 1 FROM provisioning_intents p
48
+ WHERE p.instance_id = i.id AND p.state IN ('pending', 'running')
49
+ AND p.borrowed_key_enc IS NOT NULL) AS waiting
50
+ FROM provisioning_instances i
51
+ WHERE i.slug = $1 AND lower(i.domain) = $2
52
+ LIMIT 1`,
53
+ [slug, host.toLowerCase()],
54
+ );
55
+ if (!rows[0]) return null;
56
+ const app = appStatus(rows[0].app_host, { keyWaiting: rows[0].waiting === true });
57
+ return { app, sandbox: app && app.platform === 'render' ? sandboxStatus(rows[0]) : null };
58
+ }
59
+
32
60
  // Idempotent — the loader calls the routes factory once at boot, but tests may
33
61
  // instantiate the router repeatedly; gating on hasProvider keeps each port
34
62
  // single-registered.
@@ -45,6 +73,8 @@ async function projectPublishIdentity(db, { slug, host } = {}) {
45
73
  // question names both halves of the ADR 0246 projection.
46
74
  // • PROVIDES port `provisioning.projectPublishIdentity` ({ slug, host }) → { name,
47
75
  // description } | null — see projectPublishIdentity above.
76
+ // • PROVIDES port `provisioning.projectAppStatus` ({ slug, host }) → { app, sandbox } | null — see
77
+ // projectAppStatus above.
48
78
  // • PROVIDES port `provisioning.moduleCatalog` () → { core: [key], optional: [{ key,
49
79
  // label, summary }] } — the modules a NEW project gets and may opt into, with the
50
80
  // owner-facing words the creation picker shows (task 1005273). The start-a-project
@@ -57,10 +87,11 @@ function registerProvisioningSeams() {
57
87
  api.registerProvider('provisioning.projectOwnedBy', (target) => provisioning.projectOwnedBy(pool, target));
58
88
  api.registerProvider('provisioning.projectPublishIdentity', (target) => projectPublishIdentity(pool, target));
59
89
  api.registerProvider('provisioning.selfUpdateChannel', () => selfUpdateChannel(pool));
90
+ api.registerProvider('provisioning.projectAppStatus', (target) => projectAppStatus(pool, target));
60
91
  api.registerProvider('provisioning.moduleCatalog', () => ({
61
92
  core: [...ALWAYS_ON_CORE],
62
93
  optional: optionalModules().map(({ key, label, summary }) => ({ key, label, summary })),
63
94
  }));
64
95
  }
65
96
 
66
- module.exports = { registerProvisioningSeams, projectPublishIdentity };
97
+ module.exports = { registerProvisioningSeams, projectPublishIdentity, projectAppStatus };
@@ -45,6 +45,7 @@
45
45
  // layers on, not part of any bundle, and they are listed here so a creation form
46
46
  // can show an owner what they already have without a second round trip.
47
47
  const ALWAYS_ON_CORE = Object.freeze([
48
+ 'app-host',
48
49
  'autonomy',
49
50
  'builder-settings',
50
51
  'copy-desk',