@bongos/core 1.25.17 → 1.25.19
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 +135 -65
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +17 -1
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +2 -1
- package/docs/api/openapi.json +288 -6
- package/docs/api-reference.md +14 -4
- package/docs/copy-inventory.md +132 -121
- package/docs/copy-registry.json +347 -195
- 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 +63 -63
- 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/presets.js +42 -0
- package/modules/government/recommendations.js +102 -7
- package/modules/government/routes/government.js +1 -0
- package/modules/hall-ui/public/genesis-home.js +28 -3
- package/modules/lifecycle/first-read-catalog.js +62 -0
- package/modules/lifecycle/first-read-db.js +47 -9
- package/modules/lifecycle/first-read.js +88 -23
- package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
- package/modules/lifecycle/routes/first-read.js +27 -5
- package/modules/onboarding/genesis-home.js +68 -7
- package/modules/onboarding/genesis-work.js +169 -4
- package/modules/onboarding/routes/genesis-work.js +5 -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/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 +11 -2
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/bongos/first-read.js +45 -11
- package/scripts/bongos/genesis.js +29 -2
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/seam-wiring-guard.js +3 -2
- 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 +184 -8
- package/tests/genesis_home.mjs +81 -1
- package/tests/genesis_work.mjs +103 -3
- package/tests/genesis_work_routes.mjs +9 -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
|
@@ -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);
|
|
@@ -203,6 +260,73 @@ function contextMarkdown(stages) {
|
|
|
203
260
|
...out].join('\n').trimEnd() + '\n';
|
|
204
261
|
}
|
|
205
262
|
|
|
263
|
+
// ── each stage's DOCUMENT (task 1005115) ──────────────────────────────────────
|
|
264
|
+
// A finished stage leaves something behind the founder can come back to: the scope, the
|
|
265
|
+
// strategy, the charter … It is NOT a second store. The document IS the stage's record
|
|
266
|
+
// above (its answers, its free page, its link) read as one named page, so editing it is
|
|
267
|
+
// editing those, through the same writes, by the same person. It stays editable after
|
|
268
|
+
// the stage moves on, and editing it never reopens the stage or undoes anything: the
|
|
269
|
+
// stage's progress is founding-mode's, and nothing here reads or writes it.
|
|
270
|
+
const DOCUMENT_TITLES = Object.freeze({
|
|
271
|
+
scope: 'The scope', strategize: 'The strategy', charter: 'The charter',
|
|
272
|
+
legalize: 'The rewards', legislation: 'The voting rules',
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// documentOf(shape) → the stage's document: its title, whether anything is in it yet,
|
|
276
|
+
// and the founder's words as markdown. Each part names the write that edits it, so a
|
|
277
|
+
// presentation (the game's district, the hall) never has to know the store.
|
|
278
|
+
// The markdown is the founder's raw words, unescaped: a reader shows it as text or through
|
|
279
|
+
// an escaping markdown renderer, never as HTML.
|
|
280
|
+
function documentOf(s) {
|
|
281
|
+
const said = s.questions.filter((q) => q.answer);
|
|
282
|
+
const lines = [`# ${DOCUMENT_TITLES[s.stage]}`, ''];
|
|
283
|
+
let group = null;
|
|
284
|
+
for (const q of said) {
|
|
285
|
+
if (q.group && q.group !== group) { group = q.group; lines.push(`## ${group}`, ''); }
|
|
286
|
+
const a = q.answer;
|
|
287
|
+
const body = a.state === 'answered' ? (a.choice_label || a.text) : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
|
|
288
|
+
lines.push(`**${q.ask}**${ORIGIN_WORDS[a.origin] || ''}`, '', String(body), '');
|
|
289
|
+
}
|
|
290
|
+
if (s.page) lines.push(said.length ? "## In the founder's own words" : '', '', s.page, '');
|
|
291
|
+
if (s.link) lines.push(`The founder's existing document: ${s.link}`, '');
|
|
292
|
+
const filled = said.length > 0 || !!s.page || !!s.link;
|
|
293
|
+
return {
|
|
294
|
+
stage: s.stage,
|
|
295
|
+
title: DOCUMENT_TITLES[s.stage],
|
|
296
|
+
filled,
|
|
297
|
+
markdown: filled ? lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n' : '',
|
|
298
|
+
counts: s.counts,
|
|
299
|
+
updated_at: latest([s.updated_at, ...said.map((q) => q.answer.updated_at)]),
|
|
300
|
+
// Editing never reopens the stage. Task 1005107 narrows this for a stage the Board
|
|
301
|
+
// Room passed; until then every document is open to its author.
|
|
302
|
+
editable: true,
|
|
303
|
+
edit: {
|
|
304
|
+
answer: `PUT /api/bongos/founding/work/${s.stage}/answers/:question`,
|
|
305
|
+
page: `PUT /api/bongos/founding/work/${s.stage}/page`,
|
|
306
|
+
link: `PUT /api/bongos/founding/work/${s.stage}/link`,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const latest = (isos) => isos.filter(Boolean).sort().at(-1) || null;
|
|
312
|
+
|
|
313
|
+
async function readDocument(stage, deps = {}) {
|
|
314
|
+
const r = await readStage(stage, deps);
|
|
315
|
+
if (!r.ok) return r;
|
|
316
|
+
return { ok: true, document: documentOf(r.work) };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// readDocuments(deps) → { stage: { title, filled, updated_at } } — the light summary the
|
|
320
|
+
// genesis home carries per stage, so the page shows each stage's document in place.
|
|
321
|
+
async function readDocuments(deps = {}) {
|
|
322
|
+
const out = {};
|
|
323
|
+
for (const s of await readAll(deps)) {
|
|
324
|
+
const d = documentOf(s);
|
|
325
|
+
out[s.stage] = { title: d.title, filled: d.filled, updated_at: d.updated_at, editable: d.editable };
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
206
330
|
// assistPrompt(stage) → the brief the founder hands their own AI session for the
|
|
207
331
|
// assisted door. It works the stage's own questions (never a shape of its own),
|
|
208
332
|
// argues rather than authors, and records only what the founder decides.
|
|
@@ -223,7 +347,11 @@ function assistPrompt(stage) {
|
|
|
223
347
|
];
|
|
224
348
|
if (qs.length) {
|
|
225
349
|
lines.push('The questions (the id in brackets is what you record the answer under):');
|
|
226
|
-
qs.forEach((q, i) =>
|
|
350
|
+
qs.forEach((q, i) => {
|
|
351
|
+
lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`);
|
|
352
|
+
// A closed question records one of its keys, never prose.
|
|
353
|
+
if (q.choices) lines.push(` Record one of: ${q.choices.map((c) => `${c.key} (${c.label})`).join(' · ')}`);
|
|
354
|
+
});
|
|
227
355
|
lines.push('');
|
|
228
356
|
}
|
|
229
357
|
lines.push('To see what I have already said: npx bongos genesis show ' + stage);
|
|
@@ -316,7 +444,43 @@ async function readAll(deps = {}) {
|
|
|
316
444
|
async function readStage(stage, deps = {}) {
|
|
317
445
|
if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
|
|
318
446
|
const all = await readAll(deps);
|
|
319
|
-
|
|
447
|
+
const work = all.find((s) => s.stage === stage);
|
|
448
|
+
if (stage === 'charter') work.advice = await charterAdvice(work, deps);
|
|
449
|
+
return { ok: true, work };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// charterAdvice(work, deps) — THE CHARTER STAGE'S RECOMMENDATION (task 1005119): the
|
|
453
|
+
// founder's answers, handed to the government module's one recommender through its
|
|
454
|
+
// port. Closed answers go as their keys, prose ones as the founder's words; "not sure
|
|
455
|
+
// yet" and skipped are not sent, so the recommender falls back to what it does
|
|
456
|
+
// without them. The head count is the founder's own expected number from the plan
|
|
457
|
+
// when they gave one. Null with no government module, and null (never a failed read)
|
|
458
|
+
// if the advice cannot be built: the questions still work without it.
|
|
459
|
+
async function charterAdvice(work, deps = {}) {
|
|
460
|
+
try {
|
|
461
|
+
const api = deps.api || require('../../src/module-api');
|
|
462
|
+
const gov = deps.government !== undefined ? deps.government : api.resolveOptional('government');
|
|
463
|
+
if (!gov || typeof gov.charterAdvice !== 'function') return null;
|
|
464
|
+
const answers = {};
|
|
465
|
+
const prose = {};
|
|
466
|
+
for (const q of work.questions) {
|
|
467
|
+
if (!q.answer || q.answer.state !== 'answered') continue;
|
|
468
|
+
(q.choices ? answers : prose)[q.id] = q.answer.text;
|
|
469
|
+
}
|
|
470
|
+
let headcount = null;
|
|
471
|
+
const get = deps.getSetting || (api.projectSettings && api.projectSettings.get);
|
|
472
|
+
if (get) {
|
|
473
|
+
const row = await get('founding.plan');
|
|
474
|
+
try {
|
|
475
|
+
const v = row && row.value;
|
|
476
|
+
const plan = typeof v === 'string' ? JSON.parse(v) : v;
|
|
477
|
+
headcount = (plan && plan.expected_builders) || null;
|
|
478
|
+
} catch (_) { headcount = null; }
|
|
479
|
+
}
|
|
480
|
+
return await gov.charterAdvice({ answers, prose, headcount });
|
|
481
|
+
} catch (_) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
320
484
|
}
|
|
321
485
|
|
|
322
486
|
async function setDoor(stage, door, by, deps = {}) {
|
|
@@ -383,5 +547,6 @@ async function readContext(deps = {}) {
|
|
|
383
547
|
// and are exercised through the operations (tests/genesis_work.mjs).
|
|
384
548
|
module.exports = {
|
|
385
549
|
STAGES, LIMITS, QUESTIONS, assistPrompt,
|
|
550
|
+
readDocument, readDocuments,
|
|
386
551
|
pgStore, readAll, readStage, setDoor, setPage, setLink, setAnswer, clearAnswer, answerHistory, readContext,
|
|
387
552
|
};
|
|
@@ -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);
|
|
@@ -41,6 +41,9 @@ module.exports = function mountGenesisWorkRoutes(router, deps = {}) {
|
|
|
41
41
|
router.get('/founding/work', api.requireBuilder, handle(async () => ({ stages: await work.readAll(deps) })));
|
|
42
42
|
router.get('/founding/context', api.requireBuilder, handle(() => work.readContext(deps)));
|
|
43
43
|
router.get('/founding/work/:stage', api.requireBuilder, handle((req) => work.readStage(stage(req), deps)));
|
|
44
|
+
// The stage's document (task 1005115): its record read as one named page, editable
|
|
45
|
+
// through the writes below at any time, without reopening the stage.
|
|
46
|
+
router.get('/founding/work/:stage/document', api.requireBuilder, handle((req) => work.readDocument(stage(req), deps)));
|
|
44
47
|
router.get('/founding/work/:stage/assist', api.requireBuilder, handle(async (req) => {
|
|
45
48
|
const prompt = work.assistPrompt(stage(req));
|
|
46
49
|
return prompt ? { prompt } : { ok: false, reason: 'unknown_stage' };
|
|
@@ -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;
|
|
@@ -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',
|