@bongos/core 1.25.16 → 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.
- package/.bongos-core.json +95 -40
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +22 -2
- package/clients/bongos-client/index.mjs +14 -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/api/openapi.json +415 -3
- package/docs/api-reference.md +13 -2
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +1 -1
- package/modules/government/decision-rules.js +1 -0
- package/modules/lifecycle/first-read-db.js +152 -0
- package/modules/lifecycle/first-read.js +265 -0
- package/modules/lifecycle/kickoff-checklist.js +12 -3
- package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/first-read.js +79 -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/public-landing/public/projects.html +101 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/bongos/first-read.js +116 -0
- 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/render-api.js +33 -3
- package/scripts/bongos/render-payload.js +39 -1
- package/scripts/gds/first-read.js +7 -0
- package/scripts/gds/provision-render-sandbox.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/first_read.mjs +303 -0
- package/tests/government_act_alone_buildings.mjs +2 -0
- 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,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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -2250,7 +2250,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2250
2250
|
wires. null when there is nothing to show (no platform chosen). */
|
|
2251
2251
|
function appStatusView(data) {
|
|
2252
2252
|
var app = data && data.app;
|
|
2253
|
-
if (!app)
|
|
2253
|
+
if (!app) {
|
|
2254
|
+
/* no app on Render yet: the sandbox says plainly why it has none (task 1005289) */
|
|
2255
|
+
if (!data || !data.sandbox || data.sandbox.state !== 'none') return null;
|
|
2256
|
+
var nv = { root: appEl('div', 'appStat') };
|
|
2257
|
+
sandboxView(data.sandbox, nv);
|
|
2258
|
+
return nv;
|
|
2259
|
+
}
|
|
2254
2260
|
var v = { root: appEl('div', 'appStat') };
|
|
2255
2261
|
if (app.state === 'disconnected') {
|
|
2256
2262
|
v.root.appendChild(appPara(['This project is no longer connected to Render. Its services there are still yours; remove them in Render if you want to stop paying.']));
|
|
@@ -2304,6 +2310,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2304
2310
|
}
|
|
2305
2311
|
}
|
|
2306
2312
|
if (app.links) v.root.appendChild(appPara([appLink(app.links.service, 'Open it on Render')]));
|
|
2313
|
+
sandboxView(data.sandbox, v);
|
|
2307
2314
|
var vars = (data.env_vars || []).filter(function (x) { return !x.set_for_you; });
|
|
2308
2315
|
if (vars.length) {
|
|
2309
2316
|
v.root.appendChild(appEl('h3', null, 'Still to set on Render'));
|
|
@@ -2320,7 +2327,59 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2320
2327
|
}
|
|
2321
2328
|
return v;
|
|
2322
2329
|
}
|
|
2323
|
-
|
|
2330
|
+
/* ── the project's sandbox (task 1005289, ADR 0406) ──
|
|
2331
|
+
A free copy of the app on the owner's Render account that takes every finished change
|
|
2332
|
+
first; the live app moves only when a person promotes. Same server-shaped answer as
|
|
2333
|
+
the app's (`data.sandbox`), drawn beside it, node by node. "Add a sandbox" asks for the
|
|
2334
|
+
key once, like every Render action, and never keeps it. */
|
|
2335
|
+
var mSandboxOn = {}; /* project id → has a sandbox to remove, so the teardown asks for the key only then */
|
|
2336
|
+
/* the stage word is the app's own (APP_STAGES), so one page never names one stage two ways */
|
|
2337
|
+
function stageWord(stage) {
|
|
2338
|
+
var s = stage === 'quiet' ? 'building' : stage;
|
|
2339
|
+
for (var i = 0; i < APP_STAGES.length; i++) if (APP_STAGES[i][0] === s) return APP_STAGES[i][1];
|
|
2340
|
+
return APP_STAGES[0][1];
|
|
2341
|
+
}
|
|
2342
|
+
function sandboxView(sb, v) {
|
|
2343
|
+
if (!sb) return;
|
|
2344
|
+
var box = appEl('div', 'appSandbox');
|
|
2345
|
+
box.appendChild(appEl('h3', null, 'Sandbox'));
|
|
2346
|
+
box.appendChild(appPara(['A free test copy of your app. Every finished change reaches it first; your live app only changes when you promote it.'], 'finePrint'));
|
|
2347
|
+
if (sb.state === 'none' || sb.state === 'requested' || sb.state === 'left') {
|
|
2348
|
+
box.appendChild(appPara([String(sb.error || sb.message || '')], sb.state === 'left' ? 'err' : null));
|
|
2349
|
+
} else if (sb.error) {
|
|
2350
|
+
box.appendChild(appPara([String(sb.error)], 'err'));
|
|
2351
|
+
} else {
|
|
2352
|
+
var line = [stageWord(sb.stage)];
|
|
2353
|
+
if (sb.url) line.push(' at ', appLink(sb.url, String(sb.url).replace(/^https:\/\//, '')));
|
|
2354
|
+
line.push('. It sleeps when nobody visits and takes about a minute to wake.');
|
|
2355
|
+
box.appendChild(appPara(line));
|
|
2356
|
+
if (sb.links) box.appendChild(appPara([appLink(sb.links.service, 'Open the sandbox on Render')]));
|
|
2357
|
+
}
|
|
2358
|
+
if (sb.database && sb.database.note) box.appendChild(appPara([String(sb.database.note)], 'finePrint'));
|
|
2359
|
+
if (sb.can_add) {
|
|
2360
|
+
v.sbBtn = appEl('button', 'btn ghost small appSbBtn', sb.state === 'none' ? 'Add a sandbox' : 'Try the sandbox again');
|
|
2361
|
+
v.sbBtn.type = 'button';
|
|
2362
|
+
box.appendChild(v.sbBtn);
|
|
2363
|
+
v.sbForm = appEl('div', 'appRetry appSbForm');
|
|
2364
|
+
v.sbForm.hidden = true;
|
|
2365
|
+
var lab = appEl('label', 'wizField');
|
|
2366
|
+
lab.appendChild(appEl('span', null, 'Render API key'));
|
|
2367
|
+
v.sbKey = appEl('input', 'appRetryKey');
|
|
2368
|
+
v.sbKey.type = 'password'; v.sbKey.autocomplete = 'off'; v.sbKey.spellcheck = false; v.sbKey.maxLength = 200; v.sbKey.placeholder = 'rnd_…';
|
|
2369
|
+
lab.appendChild(v.sbKey);
|
|
2370
|
+
v.sbForm.appendChild(lab);
|
|
2371
|
+
v.sbSend = appEl('button', 'btn small', 'Add the sandbox');
|
|
2372
|
+
v.sbSend.type = 'button';
|
|
2373
|
+
v.sbForm.appendChild(v.sbSend);
|
|
2374
|
+
v.sbHint = appEl('div', 'hint appRetryHint', 'Free on Render. Your key is used once and thrown away.');
|
|
2375
|
+
v.sbHint.setAttribute('role', 'status');
|
|
2376
|
+
v.sbForm.appendChild(v.sbHint);
|
|
2377
|
+
box.appendChild(v.sbForm);
|
|
2378
|
+
}
|
|
2379
|
+
v.root.appendChild(box);
|
|
2380
|
+
}
|
|
2381
|
+
function sandboxSettled(sb) { return !sb || ['none', 'left', 'removed'].indexOf(sb.state) >= 0 || !!sb.error || sb.stage === 'live' || sb.stage === 'quiet'; }
|
|
2382
|
+
function appTerminal(data) { var a = data && data.app; return (!a || a.state === 'disconnected' || !!a.error || a.stage === 'live') && sandboxSettled(data && data.sandbox); }
|
|
2324
2383
|
function fetchAppStatus(id) {
|
|
2325
2384
|
return fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + '/render', { credentials: 'same-origin' })
|
|
2326
2385
|
.then(function (res) { return res.ok ? res.json() : null; })
|
|
@@ -2337,6 +2396,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2337
2396
|
function paint(data) {
|
|
2338
2397
|
if (stopped || !alive()) return;
|
|
2339
2398
|
last = data;
|
|
2399
|
+
var sbNow = data && data.sandbox;
|
|
2400
|
+
mSandboxOn[String(id)] = !!(sbNow && (sbNow.links || sbNow.url));
|
|
2340
2401
|
/* never repaint over a Retry the owner has opened — it holds a key they are typing */
|
|
2341
2402
|
/* …whether this watch opened it or an earlier one on the same box did (the project
|
|
2342
2403
|
page starts a fresh watch on every reload of the project) */
|
|
@@ -2352,6 +2413,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2352
2413
|
}
|
|
2353
2414
|
function tick() { fetchAppStatus(id).then(paint); }
|
|
2354
2415
|
function wire(v) {
|
|
2416
|
+
if (v.sbBtn) wireSandbox(v);
|
|
2355
2417
|
if (!v.retryBtn) return;
|
|
2356
2418
|
v.retryBtn.addEventListener('click', function () {
|
|
2357
2419
|
v.retryForm.hidden = false; v.retryBtn.hidden = true; v.keyEl.focus();
|
|
@@ -2381,6 +2443,30 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2381
2443
|
});
|
|
2382
2444
|
});
|
|
2383
2445
|
}
|
|
2446
|
+
function wireSandbox(v) {
|
|
2447
|
+
v.sbBtn.addEventListener('click', function () { v.sbForm.hidden = false; v.sbBtn.hidden = true; v.sbKey.focus(); });
|
|
2448
|
+
v.sbSend.addEventListener('click', function () {
|
|
2449
|
+
var key = String(v.sbKey.value || '').trim();
|
|
2450
|
+
if (!/^rnd_[A-Za-z0-9]{16,128}$/.test(key)) {
|
|
2451
|
+
v.sbHint.textContent = 'That doesn’t look like a Render API key. It starts with “rnd_”.'; v.sbHint.className = 'hint appRetryHint err'; return;
|
|
2452
|
+
}
|
|
2453
|
+
var body = JSON.stringify({ key: key });
|
|
2454
|
+
v.sbKey.value = ''; key = '';
|
|
2455
|
+
v.sbSend.disabled = true; v.sbHint.textContent = 'Handing it to Render…'; v.sbHint.className = 'hint appRetryHint';
|
|
2456
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + '/render/sandbox', {
|
|
2457
|
+
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: body
|
|
2458
|
+
}).then(function (res) {
|
|
2459
|
+
body = '';
|
|
2460
|
+
return res.json().catch(function () { return {}; }).then(function (d) {
|
|
2461
|
+
if (!res.ok) throw new Error((d.error && d.error.message) || 'The sandbox could not be queued.');
|
|
2462
|
+
v.sbForm.hidden = true; view = null; clearTimeout(t); ticks = 0; tick();
|
|
2463
|
+
});
|
|
2464
|
+
}).catch(function (err) {
|
|
2465
|
+
body = '';
|
|
2466
|
+
v.sbSend.disabled = false; v.sbHint.textContent = err.message; v.sbHint.className = 'hint appRetryHint err';
|
|
2467
|
+
});
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2384
2470
|
tick();
|
|
2385
2471
|
return function stop() { stopped = true; clearTimeout(t); };
|
|
2386
2472
|
}
|
|
@@ -11095,7 +11181,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
11095
11181
|
'<input type="text" id="mDelName" autocomplete="off" spellcheck="false" maxlength="40" ' +
|
|
11096
11182
|
'aria-label="Type the project name to confirm" placeholder="' + esc(inst.slug) + '">' +
|
|
11097
11183
|
'<button type="button" class="btn small danger" data-del-yes disabled>Yes, tear it down</button>' +
|
|
11098
|
-
'<button type="button" class="linklike" data-del-no>Keep it</button></div>'
|
|
11184
|
+
'<button type="button" class="linklike" data-del-no>Keep it</button></div>' +
|
|
11185
|
+
(mSandboxOn[String(inst.id)]
|
|
11186
|
+
? '<label class="wizField"><span>Render API key, to remove its sandbox too (optional)</span>' +
|
|
11187
|
+
'<input type="password" id="mDelRenderKey" autocomplete="off" spellcheck="false" maxlength="200" placeholder="rnd_…"></label>' +
|
|
11188
|
+
'<p class="finePrint">Only the free sandbox is removed. Your live app on Render is left as it is. The key is used once and thrown away; without it the sandbox stays on your Render account for you to remove.</p>'
|
|
11189
|
+
: ''));
|
|
11099
11190
|
var field = $('mDelName');
|
|
11100
11191
|
if (field) field.focus();
|
|
11101
11192
|
}
|
|
@@ -11126,11 +11217,17 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
11126
11217
|
/* the typed name is this page's own gate, never the server's — so a click
|
|
11127
11218
|
on a button that only LOOKS enabled cannot act */
|
|
11128
11219
|
if (!inst || !delTypedMatches()) return;
|
|
11220
|
+
var rkField = $('mDelRenderKey');
|
|
11221
|
+
var rk = rkField ? String(rkField.value || '').trim() : '';
|
|
11222
|
+
var headers = { 'Content-Type': 'application/json' };
|
|
11223
|
+
if (/^rnd_[A-Za-z0-9]{16,128}$/.test(rk)) headers['x-render-key'] = rk;
|
|
11224
|
+
rk = '';
|
|
11129
11225
|
mDelAsking = null;
|
|
11130
11226
|
delMsg('<p class="finePrint">Asking…</p>');
|
|
11131
11227
|
fetch(API + '/provisioning/instances/' + encodeURIComponent(inst.id) + '/teardown', {
|
|
11132
|
-
method: 'POST', credentials: 'same-origin', headers:
|
|
11228
|
+
method: 'POST', credentials: 'same-origin', headers: headers, body: '{}'
|
|
11133
11229
|
}).then(function (res) {
|
|
11230
|
+
delete headers['x-render-key'];
|
|
11134
11231
|
return jsonOrEmpty(res).then(function (data) {
|
|
11135
11232
|
if (res.ok) {
|
|
11136
11233
|
var note = esc(data.message || 'Teardown requested.');
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.17",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.25.
|
|
9
|
+
"version": "1.25.17",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.17",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -11824,5 +11824,15 @@
|
|
|
11824
11824
|
"id": "1005116",
|
|
11825
11825
|
"text": "Founders now write their own strategy. Each setup stage can be done three ways: answer guided questions, write it freely, or get help from your own AI — and you can switch at any time without losing anything. Your answers are"
|
|
11826
11826
|
}
|
|
11827
|
+
],
|
|
11828
|
+
"1.25.17": [
|
|
11829
|
+
{
|
|
11830
|
+
"id": "1005289",
|
|
11831
|
+
"text": "Every project on Render now gets a free test copy of its app that takes each finished change first, shown beside the live site, added in one click for existing projects and removed when the project is taken down."
|
|
11832
|
+
},
|
|
11833
|
+
{
|
|
11834
|
+
"id": "1005280",
|
|
11835
|
+
"text": "Owners bringing in an existing project get a prompt for their own AI to read the code and map goals, tasks and what's broken; they then choose what Bongos files as drafts."
|
|
11836
|
+
}
|
|
11827
11837
|
]
|
|
11828
11838
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/bongos/first-read.js — `bongos first-read`: the command the owner's own AI
|
|
3
|
+
// session runs during the first read-through of a project that already exists (task
|
|
4
|
+
// 1005280, goal 1000106).
|
|
5
|
+
//
|
|
6
|
+
// The session reads the repository on the owner's computer and subscription, writes a
|
|
7
|
+
// map, and uses this to hand it to Bongos. Nothing here sends repository content: only
|
|
8
|
+
// the map file the session wrote, which `check` validates locally first with the same
|
|
9
|
+
// rules the server applies (modules/lifecycle/first-read.js).
|
|
10
|
+
//
|
|
11
|
+
// bongos first-read prompt print the prompt and the map's shape
|
|
12
|
+
// bongos first-read check <map.json> validate locally (no network)
|
|
13
|
+
// bongos first-read submit <map.json> send it for review — files nothing
|
|
14
|
+
// bongos first-read show [<map id>] the newest map (or one), with what is filed
|
|
15
|
+
// bongos first-read accept <map id> --all | --items <ref>,<ref> ONLY with the owner's yes
|
|
16
|
+
// bongos first-read reject <map id> the owner's no — files nothing
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const fr = require('../../modules/lifecycle/first-read');
|
|
22
|
+
|
|
23
|
+
function readMap(file) {
|
|
24
|
+
if (!file) { console.error(`usage: bongos first-read check|submit <${fr.MAP_FILE}>`); process.exit(2); }
|
|
25
|
+
let text;
|
|
26
|
+
try { text = fs.readFileSync(file, 'utf8').replace(/^/, ''); } catch (e) { console.error(`Cannot read ${file}: ${e.message}`); process.exit(2); }
|
|
27
|
+
try { return JSON.parse(text); } catch (e) { console.error(`${file} is not valid JSON: ${e.message}`); process.exit(1); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printErrors(errors) {
|
|
31
|
+
console.error('The map needs fixing:');
|
|
32
|
+
for (const e of errors) console.error(` - ${e}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function printItems(items) {
|
|
36
|
+
for (const i of items) {
|
|
37
|
+
const mark = i.filed ? ' [already in Bongos]' : '';
|
|
38
|
+
const indent = i.type === 'goal' ? '' : ' ';
|
|
39
|
+
console.log(`${indent}${i.type.padEnd(4)} ${i.title}${mark}\n${indent} ref: ${i.ref}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A refusal prints and returns null; the caller then ends with cliExit — never a hard
|
|
44
|
+
// process.exit after a request (the Windows abort in scripts/bongos/CLAUDE.md).
|
|
45
|
+
async function call(method, path, body) {
|
|
46
|
+
const { apiCall } = require('./cli-lib');
|
|
47
|
+
const res = await apiCall(method, path, body);
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const d = res.data || {};
|
|
50
|
+
console.error(`Bongos refused (${res.status}): ${d.error || d.code || 'error'}`);
|
|
51
|
+
if (Array.isArray(d.errors)) printErrors(d.errors);
|
|
52
|
+
if (d.unknown) console.error(` unknown refs: ${d.unknown.join(', ')}`);
|
|
53
|
+
if (d.hint || d.message) console.error(` ${d.hint || d.message}`);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return res.data;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fail = () => require('./cli-lib').cliExit(1);
|
|
60
|
+
const flag = (name) => { const i = process.argv.indexOf(name); return i === -1 ? null : (process.argv[i + 1] || ''); };
|
|
61
|
+
|
|
62
|
+
(async () => {
|
|
63
|
+
const [verb, arg] = process.argv.slice(2);
|
|
64
|
+
switch (verb) {
|
|
65
|
+
case 'prompt':
|
|
66
|
+
console.log(fr.firstReadPrompt());
|
|
67
|
+
return;
|
|
68
|
+
case 'check': {
|
|
69
|
+
const v = fr.validateRepoMap(readMap(arg));
|
|
70
|
+
if (!v.ok) { printErrors(v.errors); process.exit(1); }
|
|
71
|
+
console.log(`The map is in the right shape: ${v.items.length} items.`);
|
|
72
|
+
printItems(v.items);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
case 'submit': {
|
|
76
|
+
const raw = readMap(arg);
|
|
77
|
+
const v = fr.validateRepoMap(raw);
|
|
78
|
+
if (!v.ok) { printErrors(v.errors); process.exit(1); }
|
|
79
|
+
const out = await call('POST', '/api/bongos/first-read/maps', { map: raw });
|
|
80
|
+
if (!out) return fail();
|
|
81
|
+
console.log(`Sent as map ${out.map_id}. Nothing is filed yet.`);
|
|
82
|
+
printItems(out.items);
|
|
83
|
+
console.log(`\nWalk the owner through it. Only with their yes:\n bongos first-read accept ${out.map_id} --all\n bongos first-read accept ${out.map_id} --items <ref>,<ref>\nor, if they want none of it:\n bongos first-read reject ${out.map_id}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
case 'show': {
|
|
87
|
+
const out = await call('GET', `/api/bongos/first-read/maps/${arg || 'latest'}`);
|
|
88
|
+
if (!out) return fail();
|
|
89
|
+
console.log(`Map ${out.map_id} (${out.status})\n${out.summary}\n`);
|
|
90
|
+
printItems(out.items);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
case 'accept': {
|
|
94
|
+
if (!arg) { console.error('usage: bongos first-read accept <map id> --all | --items <ref>,<ref>'); process.exit(2); }
|
|
95
|
+
const list = flag('--items');
|
|
96
|
+
const items = process.argv.includes('--all') ? 'all' : (list ? list.split(',').map((s) => s.trim()).filter(Boolean) : null);
|
|
97
|
+
if (!items || (Array.isArray(items) && !items.length)) { console.error('Say what the owner accepted: --all, or --items <ref>,<ref>.'); process.exit(2); }
|
|
98
|
+
const out = await call('POST', `/api/bongos/first-read/maps/${arg}/accept`, items === 'all' ? { all: true } : { items });
|
|
99
|
+
if (!out) return fail();
|
|
100
|
+
console.log(`Filed as drafts: ${out.filed.goals.length} goals, ${out.filed.tasks.length} tasks (tasks wait in backlog until promoted).`);
|
|
101
|
+
for (const g of out.filed.goals) console.log(` goal ${g.id} ${g.title}`);
|
|
102
|
+
for (const t of out.filed.tasks) console.log(` task ${t.id} ${t.title}`);
|
|
103
|
+
for (const s of out.skipped) console.log(` skipped ${s.ref} (${s.reason.replace('_', ' ')})`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
case 'reject': {
|
|
107
|
+
if (!arg) { console.error('usage: bongos first-read reject <map id>'); process.exit(2); }
|
|
108
|
+
if (!(await call('POST', `/api/bongos/first-read/maps/${arg}/reject`, {}))) return fail();
|
|
109
|
+
console.log(`Map ${arg} rejected. Nothing was filed.`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
console.log('usage: bongos first-read prompt | check <file> | submit <file> | show [id] | accept <id> --all|--items a,b | reject <id>');
|
|
114
|
+
process.exit(verb ? 2 : 0);
|
|
115
|
+
}
|
|
116
|
+
})().catch((e) => { console.error(e.message || e); process.exitCode = 1; });
|
|
@@ -73,10 +73,8 @@ const ACTIVE_IDS = BUILDERS.filter((b) => b.status === 'active').map((b) => b.id
|
|
|
73
73
|
const FOUNDER = '42';
|
|
74
74
|
const COUNCIL = ['43', '44'];
|
|
75
75
|
|
|
76
|
-
// Each bound route as a request that
|
|
77
|
-
//
|
|
78
|
-
// tests/government_act_alone_buildings.mjs, with People's two carrying a real
|
|
79
|
-
// custom rank and builder of this project.
|
|
76
|
+
// Each bound route as a request that stops in its handler's first check (bad id or body),
|
|
77
|
+
// before any DB write — as tests/government_act_alone_buildings.mjs; People's two carry a real rank + builder.
|
|
80
78
|
const BAD = 'x';
|
|
81
79
|
const REQUESTS = Object.freeze({
|
|
82
80
|
'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
|
|
@@ -98,6 +96,7 @@ const REQUESTS = Object.freeze({
|
|
|
98
96
|
'POST /goals/reorder': ['POST', '/goals/reorder', {}],
|
|
99
97
|
'POST /work-categories': ['POST', '/work-categories', {}],
|
|
100
98
|
'PUT /versions/focus': ['PUT', '/versions/focus', { version_id: ' ' }],
|
|
99
|
+
'POST /first-read/maps/:id/accept': ['POST', `/first-read/maps/${BAD}/accept`, { all: true }],
|
|
101
100
|
'POST /autonomy/fence': ['POST', '/autonomy/fence', { enabled: 'yes' }],
|
|
102
101
|
'POST /autonomy/fence/goals': ['POST', '/autonomy/fence/goals', { goal_id: 0 }],
|
|
103
102
|
'DELETE /autonomy/fence/goals/:goalId': ['DELETE', `/autonomy/fence/goals/${BAD}`],
|
|
@@ -141,6 +140,7 @@ const ROUTERS = Object.freeze([
|
|
|
141
140
|
'modules/lifecycle/routes/goals.js',
|
|
142
141
|
'modules/lifecycle/routes/versions.js',
|
|
143
142
|
'modules/lifecycle/routes/project-settings.js',
|
|
143
|
+
'modules/lifecycle/routes/first-read.js',
|
|
144
144
|
'modules/render-deploy/routes/act.js',
|
|
145
145
|
'modules/npm-release/routes/release.js',
|
|
146
146
|
'modules/provisioning/routes/core-upgrade.js',
|