@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.
Files changed (72) hide show
  1. package/.bongos-core.json +135 -65
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +17 -1
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +2 -1
  8. package/docs/api/openapi.json +288 -6
  9. package/docs/api-reference.md +14 -4
  10. package/docs/copy-inventory.md +132 -121
  11. package/docs/copy-registry.json +347 -195
  12. package/docs/file-map.md +2 -1
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +3 -0
  15. package/docs/page-readings.json +63 -63
  16. package/modules/app-host/CLAUDE.md +15 -0
  17. package/modules/app-host/module.json +20 -0
  18. package/modules/app-host/public/settings-widget.js +143 -0
  19. package/modules/app-host/routes/app-host.js +88 -0
  20. package/modules/government/charter-advice.js +103 -0
  21. package/modules/government/presets.js +42 -0
  22. package/modules/government/recommendations.js +102 -7
  23. package/modules/government/routes/government.js +1 -0
  24. package/modules/hall-ui/public/genesis-home.js +28 -3
  25. package/modules/lifecycle/first-read-catalog.js +62 -0
  26. package/modules/lifecycle/first-read-db.js +47 -9
  27. package/modules/lifecycle/first-read.js +88 -23
  28. package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
  29. package/modules/lifecycle/routes/first-read.js +27 -5
  30. package/modules/onboarding/genesis-home.js +68 -7
  31. package/modules/onboarding/genesis-work.js +169 -4
  32. package/modules/onboarding/routes/genesis-work.js +5 -2
  33. package/modules/platform-identity/module.json +1 -1
  34. package/modules/platform-identity/routes/sso-app-status.js +65 -0
  35. package/modules/provisioning/module.json +1 -0
  36. package/modules/provisioning/seams.js +32 -1
  37. package/modules/provisioning/starter-bundles.js +1 -0
  38. package/modules/public-landing/public/assets/guide-core.js +475 -0
  39. package/modules/public-landing/public/assets/guide-hall.css +121 -0
  40. package/modules/public-landing/public/assets/guide-hall.js +164 -0
  41. package/modules/public-landing/public/assets/guide-scene.css +9 -0
  42. package/modules/public-landing/public/assets/guide-scene.js +58 -381
  43. package/modules/public-landing/public/index.html +46 -1
  44. package/modules/public-landing/public/projects.html +11 -2
  45. package/modules/ui-design/kit/serve.js +1 -1
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/release-notes.json +24 -0
  49. package/scripts/bongos/first-read.js +45 -11
  50. package/scripts/bongos/genesis.js +29 -2
  51. package/scripts/bongos/publish-manifest.js +1 -0
  52. package/scripts/bongos/seam-wiring-guard.js +3 -2
  53. package/scripts/hall-preview/server.js +2 -0
  54. package/src/bongos/auth-admission.js +64 -0
  55. package/src/bongos/auth.js +2 -1
  56. package/src/bongos/module-scope-map.js +4 -0
  57. package/src/bongos/routes/core-update.js +3 -16
  58. package/src/bongos/routes/modules.js +11 -0
  59. package/src/bongos/serve-internal.js +20 -6
  60. package/src/module-api.js +6 -1
  61. package/src/module-loader/loader.js +2 -2
  62. package/tests/app_host.mjs +413 -0
  63. package/tests/first_read.mjs +184 -8
  64. package/tests/genesis_home.mjs +81 -1
  65. package/tests/genesis_work.mjs +103 -3
  66. package/tests/genesis_work_routes.mjs +9 -0
  67. package/tests/government_charter_advice.mjs +150 -0
  68. package/tests/landing_guide_hall.mjs +364 -0
  69. package/tests/landing_guide_scene.mjs +8 -5
  70. package/tests/module_api.mjs +1 -0
  71. package/tests/module_loader.mjs +1 -1
  72. 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, legalize and legislation adopt it when they are next touched. A stage with
44
+ // charter asks how decisions should go and the founder's principles (task 1005119);
45
+ // legalize and legislation adopt it when they are next touched. A stage with
45
46
  // no questions still takes a free page and a link.
46
47
  //
47
48
  // THE BOUNDARY. Pure apart from the store. The store is injected (tests) or the
@@ -57,6 +58,9 @@ const STATES = Object.freeze(['answered', 'not_sure', 'skipped']);
57
58
  const ORIGINS = Object.freeze(['founder', 'assisted', 'carried_over']);
58
59
  const LIMITS = Object.freeze({ answer: 4000, page: 20000, link: 500 });
59
60
 
61
+ // A closed question's options: [key, label] pairs, frozen.
62
+ const choices = (pairs) => Object.freeze(pairs.map(([key, label]) => Object.freeze({ key, label })));
63
+
60
64
  // Each adopted stage's questions, in the order the guided door asks them. `id` is
61
65
  // stable: a stored answer names it, so never rename or reuse one. `hint` is the shape
62
66
  // of a good answer, shown under the question in every door. `group` (optional) is a
@@ -83,6 +87,47 @@ const QUESTIONS = Object.freeze({
83
87
  Object.freeze({ id: 'others_wrong', group: 'The bet', ask: 'What is everyone else in this area getting wrong?',
84
88
  hint: 'The thing you believe that others in this space do not. This is the bet the project makes.' }),
85
89
  ]),
90
+ // CHARTER (task 1005119): how decisions should go, then the founder's principles.
91
+ // A question with `choices` is a CLOSED one: an answer is one of its keys, because
92
+ // the government module's recommender reads it (recommendations.js CHARTER_ANSWERS
93
+ // and presets.js PRINCIPLE_ANSWERS hold the same keys, by test; a module may not
94
+ // import a sibling, ADR 0083). The prose ones shape the drafted bylaws and code of
95
+ // conduct in the founder's own words. Every one is skippable and "not sure yet".
96
+ charter: Object.freeze([
97
+ Object.freeze({ id: 'final_say', group: 'How decisions go', ask: 'When people disagree, who should have the final say?',
98
+ hint: 'Who settles it in the end. This is who gets a vote.',
99
+ choices: choices([['founder', 'Me, the founder'], ['trusted', 'The people who have earned trust by shipping'],
100
+ ['everyone', 'Everyone on the project'], ['council', 'A small group I appoint']]) }),
101
+ Object.freeze({ id: 'agreement', group: 'How decisions go', ask: 'How much agreement should a decision need?',
102
+ hint: 'Fewer means faster; more means everyone is bought in.',
103
+ choices: choices([['first_ratifier', 'One yes is enough'], ['consent', 'It passes unless someone objects'],
104
+ ['majority', 'More than half must say yes'], ['unanimous', 'Everyone who votes must agree']]) }),
105
+ Object.freeze({ id: 'strangers', group: 'How decisions go', ask: 'Do you expect people you have not met to show up and contribute?',
106
+ hint: 'Whether newcomers should get a say straight away or earn it first.',
107
+ choices: choices([['yes', 'Yes'], ['no', 'No, it is people I know']]) }),
108
+ Object.freeze({ id: 'answers_to', group: 'How decisions go', ask: 'Is there anyone outside the project it answers to: funders, a mission, a legal entity?',
109
+ hint: 'Something the project must stay true to, whoever is building it.',
110
+ choices: choices([['yes', 'Yes'], ['no', 'No']]) }),
111
+ Object.freeze({ id: 'step_back', group: 'How decisions go', ask: 'Do you want to be able to step back later and hand this over?',
112
+ hint: 'Whether the project should keep deciding well without you.',
113
+ choices: choices([['yes', 'Yes'], ['no', 'No, I plan to stay at the helm']]) }),
114
+ Object.freeze({ id: 'speed', group: 'How decisions go', ask: 'Would you rather decide fast, or have everyone bought in?',
115
+ hint: 'When the two pull apart, which wins.',
116
+ choices: choices([['fast', 'Decide fast'], ['bought_in', 'Everyone bought in']]) }),
117
+ Object.freeze({ id: 'trust_level', group: 'Principles', ask: 'How much should people be trusted to work without checking in first?',
118
+ hint: 'This shapes how strict the rules for the project\'s AI agents are.',
119
+ choices: choices([['a_lot', 'A lot: let them get on with it'], ['some', 'Some: a few rules everyone follows'],
120
+ ['little', 'Little: tight rules, checked often']]) }),
121
+ Object.freeze({ id: 'catch_or_fix', group: 'Principles', ask: 'When something goes wrong, would you rather have caught it early, or move fast and fix it?',
122
+ hint: 'Neither is wrong. It sets how much is checked before work goes ahead.',
123
+ choices: choices([['catch_early', 'Catch it early'], ['move_fast', 'Move fast and fix it']]) }),
124
+ Object.freeze({ id: 'leave_behaviour', group: 'Principles', ask: 'What behaviour would make you ask someone to leave?',
125
+ hint: 'The line nobody crosses. It becomes the heart of the code of conduct.' }),
126
+ Object.freeze({ id: 'feel', group: 'Principles', ask: 'What do you want this project to feel like to work on?',
127
+ hint: 'A few words: calm, fast, careful, playful, serious.' }),
128
+ Object.freeze({ id: 'write_down', group: 'Principles', ask: 'Is there anything you want written down now, so it is not argued about later?',
129
+ hint: 'A rule, a promise or a boundary. It goes into the bylaws as you wrote it.' }),
130
+ ]),
86
131
  strategize: Object.freeze([
87
132
  Object.freeze({ id: 'big_pieces', ask: 'What are the big pieces, and in what order do they get built?',
88
133
  hint: 'Three to six pieces is plenty. Say why that order: what has to exist before what.' }),
@@ -116,6 +161,14 @@ function checkAnswer(stage, questionId, body = {}) {
116
161
  const words = text(b.text);
117
162
  if (words.length > LIMITS.answer) return { ok: false, reason: 'answer_too_long' };
118
163
  if (state === 'answered' && !words) return { ok: false, reason: 'empty_answer' };
164
+ // A closed question's answer is one of its keys (or its label, read back as the key),
165
+ // so the recommender reading it never has to guess at prose.
166
+ const q = questionFor(stage, questionId);
167
+ if (state === 'answered' && q.choices) {
168
+ const hit = q.choices.find((c) => c.key === words || c.label.toLowerCase() === words.toLowerCase());
169
+ if (!hit) return { ok: false, reason: 'not_a_choice', expected: q.choices.map((c) => c.key) };
170
+ return { ok: true, value: { state, answer_md: hit.key, origin } };
171
+ }
119
172
  return { ok: true, value: { state, answer_md: words || null, origin } };
120
173
  }
121
174
 
@@ -130,6 +183,7 @@ function checkLink(url) {
130
183
 
131
184
  // ── pure: the view ────────────────────────────────────────────────────────────
132
185
 
186
+ const labelOf = (q, key) => { const c = (q.choices || []).find((x) => x.key === key); return c ? c.label : null; };
133
187
  const iso = (v) => (v ? new Date(v).toISOString() : null);
134
188
 
135
189
  // shapeStage(stage, work, answers) → one stage's record as every door sees it.
@@ -141,8 +195,11 @@ function shapeStage(stage, work, answers = []) {
141
195
  const a = byId.get(q.id);
142
196
  return {
143
197
  id: q.id, ask: q.ask, hint: q.hint, group: q.group || null,
198
+ choices: q.choices || null,
144
199
  answer: a ? {
145
200
  state: a.state, text: a.answer_md || null, origin: a.origin,
201
+ // a closed answer's words, for a reader who should not see the key
202
+ choice_label: q.choices && a.state === 'answered' ? labelOf(q, a.answer_md) : null,
146
203
  // A carried-over answer is shown as such until the founder makes it theirs.
147
204
  carried_over: a.origin === 'carried_over',
148
205
  updated_at: iso(a.updated_at),
@@ -190,7 +247,7 @@ function contextMarkdown(stages) {
190
247
  out.push(`## ${s.stage[0].toUpperCase()}${s.stage.slice(1)}`);
191
248
  for (const q of said) {
192
249
  const a = q.answer;
193
- const body = a.state === 'answered' ? a.text : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
250
+ const body = a.state === 'answered' ? (a.choice_label || a.text) : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
194
251
  out.push(`- **${q.ask}**${ORIGIN_WORDS[a.origin] || ''}\n ${String(body).replace(/\n/g, '\n ')}`);
195
252
  }
196
253
  if (s.page) out.push('', '**In the founder\'s own words:**', '', s.page);
@@ -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) => lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`));
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
- return { ok: true, work: all.find((s) => s.stage === stage) };
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;
@@ -40,6 +40,7 @@
40
40
  "provisioning.projectOwnedBy",
41
41
  "provisioning.projectPublishIdentity",
42
42
  "provisioning.selfUpdateChannel",
43
+ "provisioning.projectAppStatus",
43
44
  "provisioning.moduleCatalog"
44
45
  ],
45
46
  "consumes": []
@@ -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',