@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
@@ -132,7 +132,7 @@ const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' }
132
132
  // only its length matters here; `optional` and `bundles` are byte-for-byte.
133
133
  const STARTER_BUNDLES_FIXTURE = {
134
134
  core: [
135
- 'autonomy', 'builder-settings', 'copy-desk', 'economy', 'government', 'grading',
135
+ 'app-host', 'autonomy', 'builder-settings', 'copy-desk', 'economy', 'government', 'grading',
136
136
  'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'page-notes', 'public-landing',
137
137
  'security', 'sessions', 'specialities', 'status-ui', 'ui-design',
138
138
  ],
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.17",
3
+ "version": "1.25.19",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.17",
9
+ "version": "1.25.19",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.17",
3
+ "version": "1.25.19",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11834,5 +11834,29 @@
11834
11834
  "id": "1005280",
11835
11835
  "text": "Owners bringing in an existing project get a prompt for their own AI to read the code and map goals, tasks and what's broken; they then choose what Bongos files as drafts."
11836
11836
  }
11837
+ ],
11838
+ "1.25.18": [
11839
+ {
11840
+ "id": "1005281",
11841
+ "text": "When an owner's own AI reads their existing project, it can now suggest modules from the project's own list that would do a weak job better; the owner accepts each one as a to-do or dismisses it."
11842
+ },
11843
+ {
11844
+ "id": "1005276",
11845
+ "text": "People who start a project from the front page can now talk with the guide in a plain, simple version instead of the game, and switch between the two mid-chat."
11846
+ },
11847
+ {
11848
+ "id": "1005119",
11849
+ "text": "When founding a project, the charter step now asks how decisions should go and what the founder believes, then recommends a government, a level of strictness, and a first draft of the rules, each with the reason in the founder"
11850
+ },
11851
+ {
11852
+ "id": "1004571",
11853
+ "text": "Inside a project's hall, the options menu now shows where the project's app runs (no platform yet, or Render and its progress and address), and gives the owner a one-click way to connect Render."
11854
+ }
11855
+ ],
11856
+ "1.25.19": [
11857
+ {
11858
+ "id": "1005115",
11859
+ "text": "A solo founder's Genesis now moves to the next stage by itself when a stage's work is finished, and every stage leaves a named document (the scope, the strategy...) they can come back and edit any time."
11860
+ }
11837
11861
  ]
11838
11862
  }
@@ -8,12 +8,15 @@
8
8
  // the map file the session wrote, which `check` validates locally first with the same
9
9
  // rules the server applies (modules/lifecycle/first-read.js).
10
10
  //
11
- // bongos first-read prompt print the prompt and the map's shape
12
- // bongos first-read check <map.json> validate locally (no network)
11
+ // bongos first-read prompt print the prompt, with this project's module list
12
+ // bongos first-read catalog the modules a map may recommend (task 1005281)
13
+ // bongos first-read check <map.json> validate (asks for the module list only when
14
+ // the map recommends modules)
13
15
  // bongos first-read submit <map.json> send it for review — files nothing
14
16
  // bongos first-read show [<map id>] the newest map (or one), with what is filed
15
17
  // bongos first-read accept <map id> --all | --items <ref>,<ref> ONLY with the owner's yes
16
- // bongos first-read reject <map id> the owner's no — files nothing
18
+ // bongos first-read dismiss <map id> --items <ref>,<ref> the owner's no to some items
19
+ // bongos first-read reject <map id> the owner's no to all of it — files nothing
17
20
 
18
21
  'use strict';
19
22
 
@@ -34,9 +37,10 @@ function printErrors(errors) {
34
37
 
35
38
  function printItems(items) {
36
39
  for (const i of items) {
37
- const mark = i.filed ? ' [already in Bongos]' : '';
38
- const indent = i.type === 'goal' ? '' : ' ';
39
- console.log(`${indent}${i.type.padEnd(4)} ${i.title}${mark}\n${indent} ref: ${i.ref}`);
40
+ const mark = !i.filed ? '' : i.filed.kind === 'dismissed' ? ' [dismissed]' : ' [already in Bongos]';
41
+ const indent = i.type === 'goal' || i.type === 'module' ? '' : ' ';
42
+ console.log(`${indent}${i.type.padEnd(6)} ${i.title}${mark}\n${indent} ref: ${i.ref}`);
43
+ if (i.type === 'module' && i.found) console.log(`${indent} found: ${i.found}`);
40
44
  }
41
45
  }
42
46
 
@@ -57,16 +61,37 @@ async function call(method, path, body) {
57
61
  }
58
62
 
59
63
  const fail = () => require('./cli-lib').cliExit(1);
64
+
65
+ // The live module list, or null when it cannot be read (no session, or the server says so).
66
+ async function liveCatalog() {
67
+ const out = await call('GET', '/api/bongos/first-read/catalog');
68
+ return out && out.available ? out.modules : null;
69
+ }
60
70
  const flag = (name) => { const i = process.argv.indexOf(name); return i === -1 ? null : (process.argv[i + 1] || ''); };
61
71
 
62
72
  (async () => {
63
73
  const [verb, arg] = process.argv.slice(2);
64
74
  switch (verb) {
65
- case 'prompt':
66
- console.log(fr.firstReadPrompt());
75
+ case 'prompt': {
76
+ // The server's prompt carries the live module list; offline, the fixed text says
77
+ // how to fetch it later.
78
+ const out = await call('GET', '/api/bongos/first-read/prompt');
79
+ console.log(out ? out.prompt : fr.firstReadPrompt());
80
+ return;
81
+ }
82
+ case 'catalog': {
83
+ const out = await call('GET', '/api/bongos/first-read/catalog');
84
+ if (!out) return fail();
85
+ if (!out.available) { console.log('The module list cannot be read on this project. Leave "recommended_modules" out of the map.'); return; }
86
+ if (!out.modules.length) { console.log('This project has no modules left to switch on. Leave "recommended_modules" out of the map.'); return; }
87
+ console.log('Modules this project can switch on (recommend only these, by key):');
88
+ for (const m of out.modules) console.log(` ${m.key}: ${m.title}. ${m.summary}`.trimEnd());
67
89
  return;
90
+ }
68
91
  case 'check': {
69
- const v = fr.validateRepoMap(readMap(arg));
92
+ const raw = readMap(arg);
93
+ const recommends = Array.isArray(raw.recommended_modules) && raw.recommended_modules.length > 0;
94
+ const v = fr.validateRepoMap(raw, recommends ? { catalog: await liveCatalog() } : {});
70
95
  if (!v.ok) { printErrors(v.errors); process.exit(1); }
71
96
  console.log(`The map is in the right shape: ${v.items.length} items.`);
72
97
  printItems(v.items);
@@ -80,7 +105,7 @@ const flag = (name) => { const i = process.argv.indexOf(name); return i === -1 ?
80
105
  if (!out) return fail();
81
106
  console.log(`Sent as map ${out.map_id}. Nothing is filed yet.`);
82
107
  printItems(out.items);
83
- console.log(`\nWalk the owner through it. Only with their yes:\n bongos first-read accept ${out.map_id} --all\n bongos first-read accept ${out.map_id} --items <ref>,<ref>\nor, if they want none of it:\n bongos first-read reject ${out.map_id}`);
108
+ console.log(`\nWalk the owner through it. Only with their yes:\n bongos first-read accept ${out.map_id} --all\n bongos first-read accept ${out.map_id} --items <ref>,<ref>\nwhat they turn down:\n bongos first-read dismiss ${out.map_id} --items <ref>,<ref>\nor, if they want none of it:\n bongos first-read reject ${out.map_id}`);
84
109
  return;
85
110
  }
86
111
  case 'show': {
@@ -103,6 +128,15 @@ const flag = (name) => { const i = process.argv.indexOf(name); return i === -1 ?
103
128
  for (const s of out.skipped) console.log(` skipped ${s.ref} (${s.reason.replace('_', ' ')})`);
104
129
  return;
105
130
  }
131
+ case 'dismiss': {
132
+ const list = (flag('--items') || '').split(',').map((x) => x.trim()).filter(Boolean);
133
+ if (!arg || !list.length) { console.error('usage: bongos first-read dismiss <map id> --items <ref>,<ref>'); process.exit(2); }
134
+ const out = await call('POST', `/api/bongos/first-read/maps/${arg}/dismiss`, { items: list });
135
+ if (!out) return fail();
136
+ console.log(`Dismissed: ${out.dismissed.length ? out.dismissed.join(', ') : 'nothing'}. A later read-through will not raise them again.`);
137
+ for (const x of out.skipped) console.log(` skipped ${x.ref} (${x.reason.replace('_', ' ')})`);
138
+ return;
139
+ }
106
140
  case 'reject': {
107
141
  if (!arg) { console.error('usage: bongos first-read reject <map id>'); process.exit(2); }
108
142
  if (!(await call('POST', `/api/bongos/first-read/maps/${arg}/reject`, {}))) return fail();
@@ -110,7 +144,7 @@ const flag = (name) => { const i = process.argv.indexOf(name); return i === -1 ?
110
144
  return;
111
145
  }
112
146
  default:
113
- console.log('usage: bongos first-read prompt | check <file> | submit <file> | show [id] | accept <id> --all|--items a,b | reject <id>');
147
+ console.log('usage: bongos first-read prompt | catalog | check <file> | submit <file> | show [id] | accept <id> --all|--items a,b | dismiss <id> --items a,b | reject <id>');
114
148
  process.exit(verb ? 2 : 0);
115
149
  }
116
150
  })().catch((e) => { console.error(e.message || e); process.exitCode = 1; });
@@ -11,6 +11,7 @@
11
11
  //
12
12
  // Usage:
13
13
  // npx bongos genesis show [stage] every stage, or one, with its answers
14
+ // npx bongos genesis document <stage> the stage's document (task 1005115): what it left behind, editable any time
14
15
  // npx bongos genesis context what the founder said, as markdown (for planning sessions)
15
16
  // npx bongos genesis assist <stage> the brief for the founder's AI session (the assisted door)
16
17
  // npx bongos genesis answer <stage> <question> "<words>" [--origin founder|assisted]
@@ -28,7 +29,7 @@
28
29
  const fs = require('node:fs');
29
30
  const { apiCall, requireSession, errCode, cliExit } = require('./cli-lib');
30
31
 
31
- const USAGE = 'usage: npx bongos genesis show [stage] | context | assist <stage> | answer <stage> <question> "<words>" [--origin founder|assisted] | answer <stage> <question> --not-sure|--skip ["why"] | clear <stage> <question> | history <stage> <question> | door <stage> guided|open|assisted | page <stage> "<words>"|--file <path>|--remove | link <stage> <url>|--remove';
32
+ const USAGE = 'usage: npx bongos genesis show [stage] | context | document <stage> | assist <stage> | answer <stage> <question> "<words>" [--origin founder|assisted] | answer <stage> <question> --not-sure|--skip ["why"] | clear <stage> <question> | history <stage> <question> | door <stage> guided|open|assisted | page <stage> "<words>"|--file <path>|--remove | link <stage> <url>|--remove';
32
33
 
33
34
  const STATE_LABEL = { answered: '', not_sure: '[not sure yet] ', skipped: '[skipped] ' };
34
35
  const ORIGIN_LABEL = { assisted: ' (with AI help)', carried_over: ' (carried over — confirm or edit)' };
@@ -41,16 +42,36 @@ function formatStage(s) {
41
42
  for (const q of s.questions) {
42
43
  if (q.group && q.group !== group) { group = q.group; out.push(` — ${group} —`); }
43
44
  out.push(` ${q.id}: ${q.ask}`);
45
+ if (q.choices) out.push(` choose: ${q.choices.map((c) => `${c.key} (${c.label})`).join(' · ')}`);
44
46
  if (!q.answer) { out.push(' (empty)'); continue; }
45
47
  const a = q.answer;
46
- const words = a.text ? a.text.replace(/\n/g, '\n ') : '';
48
+ const said = a.choice_label || a.text;
49
+ const words = said ? said.replace(/\n/g, '\n ') : '';
47
50
  out.push(` ${STATE_LABEL[a.state] || ''}${words}${ORIGIN_LABEL[a.origin] || ''}`.trimEnd());
48
51
  }
49
52
  if (s.page) out.push(' Page:', ` ${s.page.replace(/\n/g, '\n ')}`);
50
53
  if (s.link) out.push(` Link: ${s.link}`);
54
+ if (s.advice) out.push('', ...formatAdvice(s.advice));
51
55
  return out.join('\n');
52
56
  }
53
57
 
58
+ // Pure: the charter stage's recommendation from the founder's answers (task 1005119).
59
+ // A recommendation only: choosing is still the founder's.
60
+ function formatAdvice(a) {
61
+ const out = [` Recommended government: ${a.charter.name}`, ` Why: ${a.charter.why}`];
62
+ if (a.charter.council_rank) out.push(` It is governed by a council rank you create: ${a.charter.council_rank}.`);
63
+ out.push(' It is a recommendation. Any form, or your own settings, is still yours to choose.');
64
+ if (a.preset) {
65
+ out.push(` Recommended strictness: ${a.preset.name}. ${a.preset.line}`, ` Why: ${a.preset.why}`, ` ${a.preset.is}`);
66
+ for (const l of a.preset.laws) out.push(` - ${l.text}: ${l.effect_on_ai}`);
67
+ } else {
68
+ out.push(' Strictness: no preset recommended. Written principles with no laws is a fine way to start.');
69
+ }
70
+ out.push(' Draft bylaws and code of conduct, from your answers (edit it, then save it with',
71
+ ' npx bongos genesis page charter --file <path>):', '', ...String(a.draft_md || '').split('\n').map((l) => ` ${l}`));
72
+ return out;
73
+ }
74
+
54
75
  // Pure: parse `answer` arguments into the PUT body, or { error }.
55
76
  function parseAnswer(rest) {
56
77
  const args = [...rest];
@@ -110,6 +131,12 @@ async function main(argv) {
110
131
  return 0;
111
132
  }
112
133
  if (!stage) { console.error(USAGE); return 2; }
134
+ if (cmd === 'document') {
135
+ const d = await call('GET', `/founding/work/${enc(stage)}/document`, null, 'read the document');
136
+ if (!d) return 1;
137
+ console.log(d.document.markdown || `${d.document.title}: nothing written yet.`);
138
+ return 0;
139
+ }
113
140
  if (cmd === 'assist') {
114
141
  const d = await call('GET', `/founding/work/${enc(stage)}/assist`, null, 'build the brief');
115
142
  if (!d) return 1;
@@ -78,6 +78,7 @@ const PUBLISH_ALLOWLIST = [
78
78
  'modules/render-deploy/', // the deploy page of a project whose app deploys through Render (task 1004270). default:false. MUST be here for the npm-release reason: modules/hall-ui/ ships as one subtree, so a released core without it carries deploy.js's loader for #rd-panel with no routes behind it.
79
79
  'modules/market-watch/', // the weekly AI market watch (task 1004935, goal 1000137). default:false and empty for now, but MUST be here: shipped is not enabled, and a released core without it answers "unknown module" to an instance that turns it on; tests/market_watch_module.mjs (publishable) also reads its manifest.
80
80
  'modules/ai-studio/', // AI generation for artists (task 1004916, ADR 0392): keys, prepaid balance, generate. default:false.
81
+ 'modules/app-host/', // where the app runs (task 1004571): the options menu's panel, read from the hub. default:true.
81
82
  'modules/front-door-guide/', // the start-a-project guide (task 1005272): a capped AI chat for a signed-out visitor on the platform's key. default:false, hub first; shipped is not enabled.
82
83
  'modules/raise/', // the /raise investor page (task 1004847, ADR 0384) — ships in core, enabled ONLY on the hub, like platform-identity. Owner decision 2026-10-25: the whole page, its copy and starting numbers included, lives in the repo so builders can contribute to it, accepting that it publishes with the core. Secrets never do: the investor password is RAISE_PASSWORD_HASH in the instance env. default:false.
83
84
  'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
@@ -97,11 +97,12 @@ const SEAM_CONSUMERS = {
97
97
  uiSections: {
98
98
  // Per-MODULE, not per-entry: hallWidgetScripts looks for one widget file per page
99
99
  // however many sections the module names — hall-widget.js for the index, and since
100
- // task 1004301 task-widget.js for a task's page. Either one wires the declaration.
100
+ // task 1004301 task-widget.js for a task's page, and since task 1004571 settings-widget.js
101
+ // for the options menu. Any one wires the declaration.
101
102
  consumer: 'src/module-loader/loader.js hallWidgetScripts',
102
103
  kind: 'file',
103
104
  artifact: (dir) => path.join(dir, 'public', 'hall-widget.js'),
104
- alternates: (dir) => [path.join(dir, 'public', 'task-widget.js')],
105
+ alternates: (dir) => [path.join(dir, 'public', 'task-widget.js'), path.join(dir, 'public', 'settings-widget.js')],
105
106
  },
106
107
  skills: {
107
108
  // moduleSkillSources resolves a declared name against the module's OWN tree or
@@ -457,6 +457,8 @@ for (const key of MODULES_ON) {
457
457
  const isOn = (k) => !!(mods.enabled && mods.enabled[k]);
458
458
  if (stamped['index.html']) stamped['index.html'] = injectModuleHallWidgets(stamped['index.html'], hallWidgetScripts({ isEnabled: isOn }));
459
459
  if (stamped['task.html']) stamped['task.html'] = injectModuleHallWidgets(stamped['task.html'], hallWidgetScripts({ isEnabled: isOn, file: 'task-widget.js' }), 'task');
460
+ // the options menu's module panels (task 1004571)
461
+ if (stamped['settings.html']) stamped['settings.html'] = injectModuleHallWidgets(stamped['settings.html'], hallWidgetScripts({ isEnabled: isOn, file: 'settings-widget.js' }), 'settings');
460
462
  // ...and their assets, for the same set. The real asset route (serve-internal.js) gates on
461
463
  // the instance config's enabled set, which ignores --modules-on, so a widget injected for
462
464
  // a module this checkout's config leaves off 404'd and the slot stayed empty (task 1004940).
@@ -626,8 +626,72 @@ async function notifyHubOfInvite(githubLogin, { invited = true } = {}) {
626
626
  }
627
627
  }
628
628
 
629
+ // WHERE THIS PROJECT'S OWNER TAKES IT OFFLINE OR DISCONNECTS IT (task 1004570). Both
630
+ // controls live on the hub's Projects page for this project — the typed-name confirm and
631
+ // the intent queue are there (ADR 0290) — so the hall only links to them and gains no
632
+ // second delete path. Moved here from routes/core-update.js (task 1004571) so the kernel's
633
+ // readAppStatusFromHub can name the same page; the route re-exports it. The hub origin and
634
+ // this project's client id, which is its slug; null on a self-hosted instance or the hub itself, so the
635
+ // hall shows no section rather than a dead link. The page resolves ?project=<slug>
636
+ // against the visitor's OWN projects, so the link opens nothing for anyone else.
637
+ function hubManageLinks(config) {
638
+ if (!config || typeof config.origin !== 'string' || !config.clientId) return null;
639
+ if (!/^https?:\/\/[^\s"'<>]+$/i.test(config.origin)) return null;
640
+ const origin = config.origin.replace(/\/+$/, '');
641
+ const page = `${origin}/projects?view=manage&project=${encodeURIComponent(String(config.clientId))}`;
642
+ let host = origin;
643
+ try { host = new URL(origin).host; } catch { /* keep the origin as written */ }
644
+ return { hub_host: host, manage_url: page, offline_url: `${page}#mDelete`, disconnect_url: `${page}#mDisconnect` };
645
+ }
646
+
647
+ // readAppStatusFromHub() — where THIS project's own app runs, asked of the hub (task
648
+ // 1004571). We host every project's hall; its app runs on the owner's Render account, and
649
+ // that setup's state lives in the hub's provisioning row, not here. So the hall asks, as
650
+ // the project itself (client_id + client_secret, POST /sso/app-status), and the hub answers
651
+ // with modules/provisioning/app-status.js's own shape for this project only.
652
+ //
653
+ // In the kernel beside the other hub calls for readApplicantProfileFromHub's reason: it
654
+ // holds the project's hub client secret, and a module reaches it only through this narrow
655
+ // port — no argument, one project, one direction in.
656
+ //
657
+ // Answers, never throws:
658
+ // { hub: false } — not federated (self-hosted, or the hub)
659
+ // { hub: true, reachable: false, manage } — the hub did not answer (down, older hub)
660
+ // { hub: true, reachable: true, known, app, sandbox, manage }
661
+ // `manage` is the hub's page for this project ({ manage_url, hub_host }, hubManageLinks
662
+ // above), where the owner connects Render; it names an address and grants nothing.
663
+
664
+ const HUB_APP_STATUS_TIMEOUT_MS = 5000;
665
+
666
+ async function readAppStatusFromHub() {
667
+ let idp = null;
668
+ try { idp = loadIdpConfig(); } catch { idp = null; }
669
+ if (!idp) return { hub: false };
670
+ const links = hubManageLinks(idp);
671
+ const manage = links ? { manage_url: links.manage_url, hub_host: links.hub_host } : null;
672
+ try {
673
+ const r = await fetch(`${idp.origin}/api/bongos/sso/app-status`, {
674
+ method: 'POST',
675
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
676
+ signal: AbortSignal.timeout(HUB_APP_STATUS_TIMEOUT_MS),
677
+ body: JSON.stringify({ client_id: idp.clientId, client_secret: idp.clientSecret }),
678
+ });
679
+ const body = r.ok ? await r.json().catch(() => null) : null;
680
+ if (!body || body.ok !== true) return { hub: true, reachable: false, manage };
681
+ const app = body.app && typeof body.app === 'object' ? body.app : null;
682
+ // The sandbox beside the app (task 1005289); an older hub sends none.
683
+ const sandbox = body.sandbox && typeof body.sandbox === 'object' ? body.sandbox : null;
684
+ return { hub: true, reachable: true, known: body.known === true, app, sandbox, manage };
685
+ } catch (err) {
686
+ log.info('[auth] hub app-status read failed (non-blocking):', err.message);
687
+ return { hub: true, reachable: false, manage };
688
+ }
689
+ }
690
+
629
691
  module.exports = {
630
692
  firstAdminLogin,
693
+ readAppStatusFromHub,
694
+ hubManageLinks,
631
695
  firstTimeAdmissionBlocked,
632
696
  instanceNeedsFirstAdmin,
633
697
  readApplicantProfileFromHub,
@@ -57,7 +57,7 @@ const ic = require('../instance-config');
57
57
  const { branding, userAgent } = require('../branding');
58
58
 
59
59
  const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
60
- const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readApplicantProfileFromHub, readApplicantViewFromHub, webAdmissionStatus } = require('./auth-admission.js');
60
+ const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readAppStatusFromHub, readApplicantProfileFromHub, readApplicantViewFromHub, webAdmissionStatus } = require('./auth-admission.js');
61
61
  const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
62
62
  const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
63
63
  const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
@@ -843,6 +843,7 @@ module.exports = {
843
843
  joinabilityMode,
844
844
  readApplicantProfileFromHub,
845
845
  readApplicantViewFromHub,
846
+ readAppStatusFromHub,
846
847
  notifyHubOfInvite,
847
848
  readApplicationVouch,
848
849
  firstTimeAdmissionBlocked,
@@ -193,6 +193,10 @@ const MODULE_GLOBS = {
193
193
  'tests/front_door_guide_plan.mjs', 'tests/front_door_guide_db.mjs',
194
194
  'tests/front_door_guide_existing.mjs',
195
195
  ],
196
+ // Where the app runs (task 1004571): the options menu's panel on a project's hall.
197
+ 'app-host': [
198
+ 'modules/app-host/', 'tests/app_host.mjs',
199
+ ],
196
200
  // The /raise investor page (task 1004847, ADR 0384): hub-only.
197
201
  raise: [
198
202
  'modules/raise/',
@@ -53,22 +53,9 @@ function hubDeployUrl(config) {
53
53
  return `${config.origin.replace(/\/+$/, '')}/deploy?project=${encodeURIComponent(String(config.clientId))}`;
54
54
  }
55
55
 
56
- // WHERE THIS PROJECT'S OWNER TAKES IT OFFLINE OR DISCONNECTS IT (task 1004570). Both
57
- // controls live on the hub's Projects page for this project — the typed-name confirm and
58
- // the intent queue are there (ADR 0290) — so the hall only links to them and gains no
59
- // second delete path. Same two inputs as hubDeployUrl (the hub origin and this project's
60
- // client id, which is its slug); null on a self-hosted instance or the hub itself, so the
61
- // hall shows no section rather than a dead link. The page resolves ?project=<slug>
62
- // against the visitor's OWN projects, so the link opens nothing for anyone else.
63
- function hubManageLinks(config) {
64
- if (!config || typeof config.origin !== 'string' || !config.clientId) return null;
65
- if (!/^https?:\/\/[^\s"'<>]+$/i.test(config.origin)) return null;
66
- const origin = config.origin.replace(/\/+$/, '');
67
- const page = `${origin}/projects?view=manage&project=${encodeURIComponent(String(config.clientId))}`;
68
- let host = origin;
69
- try { host = new URL(origin).host; } catch { /* keep the origin as written */ }
70
- return { hub_host: host, manage_url: page, offline_url: `${page}#mDelete`, disconnect_url: `${page}#mDisconnect` };
71
- }
56
+ // hubManageLinks — the hub's page for THIS project (task 1004570). Lives in the kernel's
57
+ // auth-admission.js since task 1004571, which needs it too and may not import a route file.
58
+ const { hubManageLinks } = require('../auth-admission');
72
59
 
73
60
  // WHERE THIS PROJECT'S UPDATE BUTTON GOES (task 1004296). The act itself never happens in
74
61
  // Settings: it is the deploy door's two-step confirm and auto-rollback (ADR 0293), and that
@@ -232,9 +232,20 @@ function contributeModuleDesk() {
232
232
  seams.contribute('governor.docket', { desk: 'rules.modules', gate: { permission: 'module.enable' }, read: async () => moduleDeskRows(listModules()) });
233
233
  }
234
234
 
235
+ // The port `modules.catalog` (task 1005281): every module on disk with its words and
236
+ // whether it is on, so a module (the lifecycle's first read-through) can name only real
237
+ // modules without requiring core. Read-only, built per call from listModules().
238
+ function registerModuleCatalogPort() {
239
+ if (seams.hasProvider('modules.catalog')) return;
240
+ seams.registerProvider('modules.catalog', () => listModules().map((m) => ({
241
+ key: m.key, title: m.title || m.key, description: m.description || '', enabled: m.enabled === true,
242
+ })));
243
+ }
244
+
235
245
  module.exports = function buildModulesRouter() {
236
246
  contributeModuleRechecksToDocket();
237
247
  contributeModuleDesk();
248
+ registerModuleCatalogPort();
238
249
  // Task 1004399: carry tarballs written inside the installed core package (the old
239
250
  // store path) into the instance store before the next upgrade erases them. Async and
240
251
  // never fatal; a no-op in a single checkout, where the two paths are one directory.
@@ -418,15 +418,18 @@ function clientBundleScriptTag() {
418
418
  // [{ key, src }]; default reads the live enabled set. Pure + exported for the test.
419
419
  function injectModuleHallWidgets(html, scripts = hallWidgetScripts({ isEnabled: isModuleEnabled }), consumer = 'builders') {
420
420
  if (!Array.isArray(scripts) || scripts.length === 0) return html;
421
- const tags = scripts
422
- .map((s) => `<script src="${s.src}" defer></script>`)
423
- .join('');
424
421
  // Anchor on the consuming page script's <script> (builders.js on the index, task.js on
425
- // a task's page — task 1004301) regardless of its ?v= stamp, so the module widgets
426
- // always land immediately before the consumer: both are `defer`, so they run in
427
- // document order, and a widget's listener exists before the page first announces.
422
+ // a task's page — task 1004301; settings.js on the options menu — task 1004571)
423
+ // regardless of its ?v= stamp, so the module widgets always land immediately before the
424
+ // consumer. A widget loads the way its consumer does: builders.js and task.js are
425
+ // `defer`, so their widgets are too and run in document order; settings.js is a plain
426
+ // script, so its widgets are plain too — a deferred one would run after it had mounted.
428
427
  const name = /^[a-z-]+$/.test(consumer) ? consumer : 'builders';
429
428
  const anchor = html.match(new RegExp(`<script src="\\/builders\\/${name}\\.js[^"]*"[^>]*><\\/script>`));
429
+ const defer = !anchor || /\sdefer[\s>=]/.test(anchor[0]);
430
+ const tags = scripts
431
+ .map((s) => `<script src="${s.src}"${defer ? ' defer' : ''}></script>`)
432
+ .join('');
430
433
  if (anchor) return html.replace(anchor[0], tags + anchor[0]);
431
434
  return html.includes('</body>') ? html.replace('</body>', `${tags}</body>`) : html + tags;
432
435
  }
@@ -1171,6 +1174,17 @@ function mountInternalSurfaces(app) {
1171
1174
  );
1172
1175
  }
1173
1176
 
1177
+ // task 1004571: the options menu. A module that ships public/settings-widget.js
1178
+ // registers a panel through window.OTB.contributeSettingsPanel; it is loaded just before
1179
+ // settings.js, which mounts every registered panel. With none enabled, nothing changes.
1180
+ if (buildersHtml['settings.html']) {
1181
+ buildersHtml['settings.html'] = injectModuleHallWidgets(
1182
+ buildersHtml['settings.html'],
1183
+ hallWidgetScripts({ isEnabled: isModuleEnabled, file: 'settings-widget.js' }),
1184
+ 'settings',
1185
+ );
1186
+ }
1187
+
1174
1188
  // Serve enabled modules' browser assets at /modules/<key>/public/* — the URL the
1175
1189
  // loader's hallWidgetScripts projection points at. Mounted BEFORE the /builders
1176
1190
  // static handler. Gated per-request on the live enabled set so a disabled module's
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.17'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.19'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -409,6 +409,11 @@ module.exports = {
409
409
  // 1002304). The guild rides only beside a profile, so a null profile is a
410
410
  // null view; an older hub that sends no guild answers guild: null.
411
411
  get readApplicantViewFromHub() { return require('./bongos/auth').readApplicantViewFromHub; },
412
+ // readAppStatusFromHub() — where THIS project's app runs, read from the hub with the
413
+ // project's own client credentials (task 1004571). The same narrow-port posture: no
414
+ // argument, so a module can neither widen nor re-address it, and the secret stays off
415
+ // the doorway. Never throws: { hub: false } | { hub: true, reachable, known?, app?, manage }.
416
+ get readAppStatusFromHub() { return require('./bongos/auth').readAppStatusFromHub; },
412
417
  // notifyHubOfInvite(githubLogin, { invited }) — tell the hub about one of THIS
413
418
  // project's invites (true) or its rescind (false), so the invitee sees it in
414
419
  // My Projects (task 1002818, ADR 0353). The same narrow-port reason as the
@@ -221,12 +221,12 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
221
221
  //
222
222
  // ONE SEAM, ONE FILE PER PAGE (task 1004301). `file` names which page's widget is being
223
223
  // asked for: hall-widget.js for the hall index (the default, and the only caller until
224
- // then), task-widget.js for a task's own page. Same declaration (uiSections), same
224
+ // then), task-widget.js for a task's own page, settings-widget.js for the options menu (1004571). Same declaration (uiSections), same
225
225
  // enabled-only asset mount, same "a disabled module simply is not there" — so a module
226
226
  // adds a line to the task page without the page's own code ever naming it. The name is
227
227
  // matched against PAGE_WIDGET_FILES rather than trusted, so no caller can turn this into
228
228
  // a way to serve an arbitrary file.
229
- const PAGE_WIDGET_FILES = Object.freeze(['hall-widget.js', 'task-widget.js']);
229
+ const PAGE_WIDGET_FILES = Object.freeze(['hall-widget.js', 'task-widget.js', 'settings-widget.js']);
230
230
  function hallWidgetScripts({ isEnabled, file = 'hall-widget.js' } = {}, opts) {
231
231
  if (!PAGE_WIDGET_FILES.includes(file)) return [];
232
232
  const out = [];