@bongos/core 1.25.15 → 1.25.17

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 (66) hide show
  1. package/.bongos-core.json +128 -58
  2. package/.claude/skills/goal-create/SKILL.md +1 -0
  3. package/.claude/skills/planning-session/SKILL.md +5 -4
  4. package/bin/bongos.js +1 -0
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +22 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  11. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
  12. package/docs/api/openapi.json +415 -3
  13. package/docs/api-reference.md +13 -2
  14. package/docs/copy-inventory.md +120 -109
  15. package/docs/copy-registry.json +275 -165
  16. package/docs/module-api-changelog.md +4 -2
  17. package/docs/page-inventory.json +2 -0
  18. package/docs/page-readings.json +107 -107
  19. package/docs/project-context.template.md +1 -0
  20. package/modules/government/decision-rules.js +1 -0
  21. package/modules/hall-ui/public/dom-utils.js +5 -0
  22. package/modules/hall-ui/public/genesis-home.js +23 -8
  23. package/modules/hall-ui/public/setup-gate.js +1 -1
  24. package/modules/hall-ui/public/style.css +21 -1
  25. package/modules/lifecycle/first-read-db.js +152 -0
  26. package/modules/lifecycle/first-read.js +265 -0
  27. package/modules/lifecycle/kickoff-checklist.js +29 -6
  28. package/modules/lifecycle/kickoff-seed.js +27 -4
  29. package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
  30. package/modules/lifecycle/module.json +2 -1
  31. package/modules/lifecycle/routes/first-read.js +79 -0
  32. package/modules/onboarding/genesis-home.js +11 -2
  33. package/modules/onboarding/genesis-work.js +28 -5
  34. package/modules/provisioning/pollers/app-liveness.js +23 -1
  35. package/modules/provisioning/render-sandbox.js +113 -0
  36. package/modules/provisioning/render-standup.js +3 -1
  37. package/modules/provisioning/routes/provisioning.js +4 -1
  38. package/modules/provisioning/routes/render-standup.js +49 -1
  39. package/modules/public-landing/public/assets/guide-scene.css +245 -0
  40. package/modules/public-landing/public/assets/guide-scene.js +566 -0
  41. package/modules/public-landing/public/index.html +62 -2
  42. package/modules/public-landing/public/projects.html +101 -4
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +23 -1
  46. package/scripts/bongos/first-read.js +116 -0
  47. package/scripts/bongos/genesis.js +2 -0
  48. package/scripts/bongos/government-walk.js +4 -4
  49. package/scripts/bongos/provision-render-sandbox.js +171 -0
  50. package/scripts/bongos/provision-render.js +37 -2
  51. package/scripts/bongos/provision-teardown.js +1 -1
  52. package/scripts/bongos/render-api.js +33 -3
  53. package/scripts/bongos/render-payload.js +39 -1
  54. package/scripts/gds/first-read.js +7 -0
  55. package/scripts/gds/provision-render-sandbox.js +6 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/first_read.mjs +303 -0
  58. package/tests/genesis_home.mjs +70 -2
  59. package/tests/genesis_work.mjs +49 -0
  60. package/tests/government_act_alone_buildings.mjs +2 -0
  61. package/tests/landing_guide_scene.mjs +504 -0
  62. package/tests/provisioning_app_status.mjs +3 -2
  63. package/tests/provisioning_render_route.mjs +1 -1
  64. package/tests/provisioning_teardown_intent.mjs +50 -0
  65. package/tests/render_api.mjs +5 -2
  66. package/tests/render_sandbox.mjs +447 -0
@@ -85,6 +85,7 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
85
85
  const pool = deps.pool || api.pool;
86
86
  const insert = deps.createTask || ((row) => db.createTask(row));
87
87
  const find = deps.findTaskBySourceRef || ((q) => db.findTaskBySourceRef(q));
88
+ const addDep = deps.addTaskDependency || ((id, dep) => db.addTaskDependency(id, dep));
88
89
  const { rows: versions } = await pool.query(FIRST_VERSION_SQL);
89
90
  if (!versions.length) return { seeded: 0, skipped: 'no_version' };
90
91
  const versionId = versions[0].id;
@@ -109,18 +110,39 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
109
110
  seeded += 1;
110
111
  }
111
112
  }
113
+ // task 1005110: the order genesisChecklist declares becomes a real edge, so the claim
114
+ // gate refuses the later step (DEPS_NOT_SHIPPED) until the earlier one ships. Written
115
+ // for steps already filed too (ON CONFLICT DO NOTHING), so a re-run repairs a missing edge.
116
+ for (const steps of Object.values(genesisChecklist({ existing }))) {
117
+ for (const t of steps) {
118
+ if (!t.after) continue;
119
+ const [me, before] = await Promise.all([
120
+ find({ source: GENESIS_SOURCE, sourceRef: t.ref }),
121
+ find({ source: GENESIS_SOURCE, sourceRef: t.after }),
122
+ ]);
123
+ if (me && before) await addDep(me.id, before.id);
124
+ }
125
+ }
112
126
  return { seeded, versionId };
113
127
  }
114
128
 
115
- // genesisStageTasks(deps) → { stageKey: [{ id, title, status, description }] } for every
116
- // genesis task on the board, grouped by the stage its source_ref names. The genesis home
117
- // lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
129
+ // genesisStageTasks(deps) → { stageKey: [{ id, title, status, description, waits_on }] } for
130
+ // every genesis task on the board, grouped by the stage its source_ref names. The genesis
131
+ // home lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
132
+ // `waits_on` is [{ id, title }] of the tasks it depends on that have not shipped: the claim
133
+ // gate's own rule (unsatisfiedDeps, abandoned still blocks), so a row drawn locked is one
134
+ // POST /claims would refuse (task 1005110).
118
135
  // The description rides along so each row's Copy prompt carries the task's own words
119
136
  // (task 1005109); the read stays bounded, it is only the few seeded genesis tasks.
120
137
  async function genesisStageTasks(deps = {}) {
121
138
  const pool = deps.pool || api.pool;
122
139
  const { rows } = await pool.query(
123
- `SELECT id, title, status, description, source_ref FROM tasks WHERE source = $1 ORDER BY id ASC`,
140
+ `SELECT t.id, t.title, t.status, t.description, t.source_ref,
141
+ COALESCE((SELECT json_agg(json_build_object('id', x.id, 'title', x.title) ORDER BY x.id)
142
+ FROM dependencies d JOIN tasks x ON x.id = d.to_id
143
+ WHERE d.from_kind = 'task' AND d.from_id = t.id
144
+ AND d.to_kind = 'task' AND x.status <> 'shipped'), '[]'::json) AS waits_on
145
+ FROM tasks t WHERE t.source = $1 ORDER BY t.id ASC`,
124
146
  [GENESIS_SOURCE],
125
147
  );
126
148
  const out = {};
@@ -129,6 +151,7 @@ async function genesisStageTasks(deps = {}) {
129
151
  if (!stage) continue;
130
152
  (out[stage] = out[stage] || []).push({
131
153
  id: String(r.id), title: r.title, status: r.status, description: r.description || '',
154
+ waits_on: (Array.isArray(r.waits_on) ? r.waits_on : []).map((w) => ({ id: String(w.id), title: w.title })),
132
155
  });
133
156
  }
134
157
  return out;
@@ -0,0 +1,44 @@
1
+ -- lifecycle_022_repo_maps.sql — the first read-through's repository maps (task 1005280,
2
+ -- goal 1000106).
3
+ --
4
+ -- WHAT IT IS. A project that arrives with code gets a prompt for the owner's OWN AI
5
+ -- session; that session reads the repository on the owner's computer and subscription
6
+ -- and sends back a map (modules/lifecycle/first-read.js). A map waits here as `pending`
7
+ -- until the owner accepts it — whole or item by item — or rejects it. Nothing is filed
8
+ -- by the submit itself.
9
+ --
10
+ -- WHY A SECOND TABLE. Re-running the read-through must not file the same goal or task
11
+ -- twice, and goals carry no source_ref to dedup on. So every accepted item's stable
12
+ -- `ref` (derived from its title, never its position) is a row in
13
+ -- lifecycle_repo_map_filed, PRIMARY KEY on the ref: the next accept skips it, whichever
14
+ -- map names it again.
15
+ --
16
+ -- MODULE-OWNED and `lifecycle_`-prefixed (ADR 0083 §Decision #5).
17
+
18
+ CREATE TABLE IF NOT EXISTS lifecycle_repo_maps (
19
+ id bigserial PRIMARY KEY,
20
+ schema text NOT NULL,
21
+ map jsonb NOT NULL,
22
+ status text NOT NULL DEFAULT 'pending',
23
+ submitted_by bigint REFERENCES builders(id),
24
+ submitted_at timestamptz NOT NULL DEFAULT now(),
25
+ reviewed_by bigint REFERENCES builders(id),
26
+ reviewed_at timestamptz,
27
+
28
+ CONSTRAINT lifecycle_repo_maps_status_chk
29
+ CHECK (status IN ('pending', 'accepted', 'rejected'))
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS lifecycle_repo_maps_newest_idx
33
+ ON lifecycle_repo_maps (submitted_at DESC);
34
+
35
+ CREATE TABLE IF NOT EXISTS lifecycle_repo_map_filed (
36
+ ref text PRIMARY KEY,
37
+ kind text NOT NULL,
38
+ entity_id bigint NOT NULL,
39
+ map_id bigint NOT NULL REFERENCES lifecycle_repo_maps(id),
40
+ filed_by bigint REFERENCES builders(id),
41
+ filed_at timestamptz NOT NULL DEFAULT now(),
42
+
43
+ CONSTRAINT lifecycle_repo_map_filed_kind_chk CHECK (kind IN ('goal', 'task'))
44
+ );
@@ -38,7 +38,8 @@
38
38
  "home-city",
39
39
  "home-gallery",
40
40
  "task-votes",
41
- "notes"
41
+ "notes",
42
+ "first-read"
42
43
  ],
43
44
  "pollers": [
44
45
  "publish-reconciler"
@@ -0,0 +1,79 @@
1
+ // modules/lifecycle/routes/first-read.js — the HTTP surface of the first read-through
2
+ // (task 1005280, goal 1000106): hand out the prompt, take a repository map from the
3
+ // owner's own AI session, show it for review, and accept or reject it.
4
+ //
5
+ // No route here calls an AI, reads a repository, or files anything on submit. Filing is
6
+ // the accept route alone, gated like creating a goal (`goal.create` + the planning
7
+ // act-alone rule), because accepting a map IS creating goals and tasks. The rules are
8
+ // first-read.js; the store is first-read-db.js. Reaches core only through the doorway.
9
+
10
+ 'use strict';
11
+
12
+ const express = require('express');
13
+ const api = require('../../../src/module-api');
14
+ const fr = require('../first-read');
15
+ const store = require('../first-read-db');
16
+
17
+ const { asyncHandler, parseId, validateOrRespond } = api;
18
+
19
+ module.exports = function buildFirstReadRouter() {
20
+ const router = express.Router();
21
+
22
+ // rank: any-builder — the prompt and the shape are instructions, not project data.
23
+ router.get('/first-read/prompt', api.requireBuilder, (req, res) => {
24
+ res.json({ schema: fr.SCHEMA, file: fr.MAP_FILE, prompt: fr.firstReadPrompt(), example: fr.exampleMap(), limits: fr.LIMITS });
25
+ });
26
+
27
+ // rank: goal.create — a map is a planning proposal for this project. Stores it
28
+ // pending; files nothing. A malformed map is refused with every error named.
29
+ router.post('/first-read/maps', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps', async (req, res) => {
30
+ // The envelope is checked here; the map inside it by first-read.js, which names every error.
31
+ if (validateOrRespond(req, res, { map: { required: true, type: 'object' } })) return undefined;
32
+ const out = await store.submitMap({ raw: req.body.map, builderId: req.builder.id });
33
+ if (!out.ok) return res.fail('invalid_map', 400, { errors: out.errors });
34
+ return res.status(201).json({
35
+ map_id: out.map_id, status: out.status,
36
+ items: out.items.map((i) => ({ ref: i.ref, type: i.type, title: i.title })),
37
+ next: 'Show the owner the map. File it only with their yes: POST /first-read/maps/' + out.map_id + '/accept { all: true } or { items: [ref] }.',
38
+ });
39
+ }, { errorCode: 'first_read_submit_failed' }));
40
+
41
+ // rank: goal.create — the review is for whoever may accept or reject it.
42
+ router.get('/first-read/maps/:id', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('GET /first-read/maps/:id', async (req, res) => {
43
+ const id = req.params.id === 'latest' ? 'latest' : parseId(req, res, { positiveInt: true });
44
+ if (id == null) return undefined;
45
+ const review = await store.reviewMap(id);
46
+ if (!review) return res.fail('not_found', 404);
47
+ return res.json(review);
48
+ }, { errorCode: 'first_read_review_failed' }));
49
+
50
+ // rank: goal.create + act-alone planning — the owner's yes. Files the chosen items as
51
+ // draft goals (open) and tasks (backlog); an item already filed is skipped.
52
+ router.post('/first-read/maps/:id/accept', api.requireBuilder, api.requirePermission('goal.create'), api.requireActAlone('planning'),
53
+ asyncHandler('POST /first-read/maps/:id/accept', async (req, res) => {
54
+ const mapId = parseId(req, res, { positiveInt: true });
55
+ if (mapId == null) return undefined;
56
+ if (validateOrRespond(req, res, {
57
+ all: { type: 'boolean' },
58
+ items: { type: 'array', itemsType: 'string', maxItems: 500 },
59
+ })) return undefined;
60
+ const { all, items } = req.body || {};
61
+ if ((all === true) === Array.isArray(items)) {
62
+ return res.fail('invalid_items', 400, { hint: 'send { all: true } or { items: [ref, …] } — the refs from the review' });
63
+ }
64
+ const out = await store.acceptMap({ mapId, select: all === true ? 'all' : items, builderId: req.builder.id });
65
+ if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
66
+ return res.json(out);
67
+ }, { errorCode: 'first_read_accept_failed' }));
68
+
69
+ // rank: goal.create — the owner's no. Files nothing.
70
+ router.post('/first-read/maps/:id/reject', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps/:id/reject', async (req, res) => {
71
+ const mapId = parseId(req, res, { positiveInt: true });
72
+ if (mapId == null) return undefined;
73
+ const out = await store.rejectMap({ mapId, builderId: req.builder.id });
74
+ if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
75
+ return res.json(out);
76
+ }, { errorCode: 'first_read_reject_failed' }));
77
+
78
+ return router;
79
+ };
@@ -143,10 +143,19 @@ function readPlanValue(raw) {
143
143
 
144
144
  const FINISHED = Object.freeze(['shipped', 'abandoned']); // the Board Room's own gate, mirrored for display
145
145
 
146
+ // taskState(t) — one stage task as the checklist draws it (task 1005110).
147
+ function taskState(t) {
148
+ const finished = FINISHED.includes(t.status);
149
+ const waits = finished ? [] : (Array.isArray(t.waits_on) ? t.waits_on : []);
150
+ return { ...t, finished, waits_on: waits, state: finished ? 'done' : waits.length ? 'locked' : 'open' };
151
+ }
152
+
146
153
  // shapeGenesis({ mode, plan, tasks, board, stageAt }) — PURE. The genesis home's view.
147
154
  // mode foundingModeFor's shape (non-null)
148
155
  // plan the saved plan or null (deciding)
149
- // tasks { stage: [{ id, title, status, description }] }
156
+ // tasks { stage: [{ id, title, status, description, waits_on }] }
157
+ // Each task comes back with `state`: 'done' (finished), 'locked' (waits on an unshipped
158
+ // task, named in `waits_on`) or 'open' — decided here, never re-derived in a browser.
150
159
  // board { stage: genesisStageStatus row } or {}
151
160
  // stageAt { stage: ISO } — when each stage began, for its due date
152
161
  function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
@@ -154,7 +163,7 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
154
163
  const stages = PROPER.map((key) => {
155
164
  const info = STAGE_INFO[key];
156
165
  const idx = fm.STAGES.indexOf(key);
157
- const list = (tasks[key] || []).map((t) => ({ ...t, finished: FINISHED.includes(t.status) }));
166
+ const list = (tasks[key] || []).map((t) => taskState(t));
158
167
  const b = board[key] || null;
159
168
  const allFinished = list.length > 0 && list.every((t) => t.finished);
160
169
  const onBoard = !!(b && (b.state === 'sitting' || b.state === 'passed'));
@@ -40,7 +40,7 @@
40
40
  // un-answer appends a history row, so the founder can see what an answer used to say.
41
41
  //
42
42
  // WHICH STAGES. A stage adopts the pattern by declaring its questions in QUESTIONS.
43
- // Strategize does here; scope's guided door is its project interview (task 1005114);
43
+ // Strategize does (task 1005116); scope's guided door is its project interview (task 1005114);
44
44
  // charter, legalize and legislation adopt it when they are next touched. A stage with
45
45
  // no questions still takes a free page and a link.
46
46
  //
@@ -59,8 +59,30 @@ const LIMITS = Object.freeze({ answer: 4000, page: 20000, link: 500 });
59
59
 
60
60
  // Each adopted stage's questions, in the order the guided door asks them. `id` is
61
61
  // stable: a stored answer names it, so never rename or reuse one. `hint` is the shape
62
- // of a good answer, shown under the question in every door.
62
+ // of a good answer, shown under the question in every door. `group` (optional) is a
63
+ // heading the guided door shows above the questions that share it.
64
+ //
65
+ // SCOPE IS THE PROJECT INTERVIEW (task 1005114): the questions every later goal and task
66
+ // should be planned inside — what this is for, who it is for, and the bet — rather than
67
+ // V1's edges, which is what came back thin before. The owner's starter set, as written;
68
+ // the list is "not final" and the owner invited more. These are prose questions; the
69
+ // closed-vocabulary ones (team shape, geography, entity, rewards) are the hub's project
70
+ // detail and are not repeated here.
63
71
  const QUESTIONS = Object.freeze({
72
+ scope: Object.freeze([
73
+ Object.freeze({ id: 'what_creating', group: 'What and why', ask: 'In a few sentences, what are you creating with this project?',
74
+ hint: 'Say it the way you would to a friend. What exists when this is done that does not exist now?' }),
75
+ Object.freeze({ id: 'goal_impact', group: 'What and why', ask: 'What is your goal with it? What impact do you want it to have?',
76
+ hint: 'Why this is worth doing at all, and for whom it changes something.' }),
77
+ Object.freeze({ id: 'success_two_years', group: 'What and why', ask: 'In two years, what does success look like?',
78
+ hint: 'Something you could point at: who is using it, what they do with it, what it has become.' }),
79
+ Object.freeze({ id: 'reach_who', group: 'Who', ask: 'Who do you want to reach with this project?',
80
+ hint: 'The people it is ultimately for, not only the first ones. Be as specific as you can.' }),
81
+ Object.freeze({ id: 'work_with_who', group: 'Who', ask: 'Who do you want working on it with you?',
82
+ hint: 'The kind of people you hope join: their skills, how they work, what they care about.' }),
83
+ Object.freeze({ id: 'others_wrong', group: 'The bet', ask: 'What is everyone else in this area getting wrong?',
84
+ hint: 'The thing you believe that others in this space do not. This is the bet the project makes.' }),
85
+ ]),
64
86
  strategize: Object.freeze([
65
87
  Object.freeze({ id: 'big_pieces', ask: 'What are the big pieces, and in what order do they get built?',
66
88
  hint: 'Three to six pieces is plenty. Say why that order: what has to exist before what.' }),
@@ -118,7 +140,7 @@ function shapeStage(stage, work, answers = []) {
118
140
  const questions = questionsFor(stage).map((q) => {
119
141
  const a = byId.get(q.id);
120
142
  return {
121
- id: q.id, ask: q.ask, hint: q.hint,
143
+ id: q.id, ask: q.ask, hint: q.hint, group: q.group || null,
122
144
  answer: a ? {
123
145
  state: a.state, text: a.answer_md || null, origin: a.origin,
124
146
  // A carried-over answer is shown as such until the founder makes it theirs.
@@ -196,11 +218,12 @@ function assistPrompt(stage) {
196
218
  '- Offer two or three options when I am stuck, never one confident answer.',
197
219
  '- "Not sure yet" is a fine answer. Record it as that rather than filling the gap for me.',
198
220
  '- Record an answer only after I say it is mine, in my words (you may tidy the wording; read it back first).',
221
+ '- Once I have answered, you may offer a draft page built only from my answers. I edit it; save it only when I say it is mine.',
199
222
  '',
200
223
  ];
201
224
  if (qs.length) {
202
- lines.push('The questions:');
203
- qs.forEach((q, i) => lines.push(`${i + 1}. ${q.ask} (${q.hint})`));
225
+ 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})`));
204
227
  lines.push('');
205
228
  }
206
229
  lines.push('To see what I have already said: npx bongos genesis show ' + stage);
@@ -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;