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