@bongos/core 1.18.3 → 1.18.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 (471) hide show
  1. package/.bongos-core.json +1576 -286
  2. package/.claude/skills/builder-box/SKILL.md +6 -0
  3. package/.claude/skills/builder-sequence/SKILL.md +97 -0
  4. package/.claude/skills/builder-ship/SKILL.md +8 -2
  5. package/.claude/skills/builder-start/SKILL.md +11 -0
  6. package/.claude/skills/builder-sync/SKILL.md +41 -0
  7. package/.claude/skills/fix-task/SKILL.md +64 -0
  8. package/.claude/skills/goal-create/SKILL.md +158 -0
  9. package/.claude/skills/goal-review/SKILL.md +3 -1
  10. package/.claude/skills/idea-triage/SKILL.md +14 -8
  11. package/.claude/skills/merge-mode/SKILL.md +2 -2
  12. package/.claude/skills/new-project/SKILL.md +1 -0
  13. package/.claude/skills/planning-session/SKILL.md +45 -18
  14. package/.env.local.example +4 -3
  15. package/README.md +4 -2
  16. package/bin/bongos.js +1 -0
  17. package/clients/bongos-client/README.md +2 -2
  18. package/clients/bongos-client/bongos-client.global.js +82 -18
  19. package/clients/bongos-client/index.cjs +82 -18
  20. package/clients/bongos-client/index.d.ts +131 -29
  21. package/clients/bongos-client/index.mjs +82 -18
  22. package/config/branding.neutral.json +6 -1
  23. package/config/governance.neutral.json +21 -0
  24. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
  25. package/docs/adr/0090-metic-task-authoring.md +1 -1
  26. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
  27. package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
  28. package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
  29. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
  30. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
  31. package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
  32. package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
  33. package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
  34. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
  35. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
  36. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
  37. package/docs/adr/0152-metic-task-abandonment.md +67 -0
  38. package/docs/adr/0153-sequential-chain-runner.md +51 -0
  39. package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
  40. package/docs/adr/README.md +10 -0
  41. package/docs/api/openapi.json +843 -17
  42. package/docs/api-reference.md +23 -10
  43. package/docs/architecture.md +4 -2
  44. package/docs/branding-contract.md +3 -1
  45. package/docs/canonical-permissions.md +21 -8
  46. package/docs/file-map.md +8 -0
  47. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
  48. package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
  49. package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
  50. package/docs/onboarding/diagrams/assertions.json +4 -7
  51. package/docs/onboarding/drachmae-and-karma.md +3 -3
  52. package/docs/onboarding/primer.md +17 -16
  53. package/docs/onboarding/slash-commands.md +124 -0
  54. package/docs/recipes/builder-box-lifecycle.md +23 -0
  55. package/docs/recipes/devbox-app-release.md +14 -4
  56. package/docs/recipes/instance-first-run.md +16 -0
  57. package/docs/recipes/local-dev.md +81 -0
  58. package/docs/recipes/ops-gotchas.md +19 -1
  59. package/docs/recipes/overnight-builder.md +1 -1
  60. package/docs/recipes/parallel-goal-waves.md +174 -0
  61. package/docs/recipes/ship-and-regrade.md +2 -2
  62. package/docs/recipes/standalone-live-docs.md +48 -0
  63. package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
  64. package/migrations/003_pms.sql +6 -7
  65. package/migrations/006_pms_v2.sql +3 -31
  66. package/migrations/core_193_task_needs_rebase.sql +28 -0
  67. package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
  68. package/migrations/core_195_idea_triage_provenance.sql +57 -0
  69. package/migrations/core_196_session_reward_topup.sql +69 -0
  70. package/modules/autonomy/pollers/routine-timers.js +9 -8
  71. package/modules/builder-settings/wandering-prefs.js +61 -18
  72. package/modules/dev-box/app/README.md +73 -0
  73. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  74. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  75. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  76. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  77. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  78. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  79. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  80. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  81. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  82. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  83. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  84. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  85. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  86. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  87. package/modules/dev-box/app/build-resources/icon.png +0 -0
  88. package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
  89. package/modules/dev-box/app/electron-builder.yml +95 -0
  90. package/modules/dev-box/app/package-lock.json +5291 -0
  91. package/modules/dev-box/app/package.json +30 -0
  92. package/modules/dev-box/app/renderer/app.js +319 -0
  93. package/modules/dev-box/app/renderer/index.html +45 -0
  94. package/modules/dev-box/app/renderer/styles.css +246 -0
  95. package/modules/dev-box/app/renderer/theme.generated.css +16 -0
  96. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
  97. package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
  98. package/modules/dev-box/app/scripts/gen-brand.js +138 -0
  99. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
  100. package/modules/dev-box/app/scripts/gen-icons.js +286 -0
  101. package/modules/dev-box/app/scripts/vendor-client.js +23 -0
  102. package/modules/dev-box/app/src/box-controller.js +287 -0
  103. package/modules/dev-box/app/src/brand.generated.json +13 -0
  104. package/modules/dev-box/app/src/brand.js +16 -0
  105. package/modules/dev-box/app/src/config.js +19 -0
  106. package/modules/dev-box/app/src/gds-client.js +90 -0
  107. package/modules/dev-box/app/src/main.js +610 -0
  108. package/modules/dev-box/app/src/pairing.js +57 -0
  109. package/modules/dev-box/app/src/preload.js +29 -0
  110. package/modules/dev-box/app/src/session-store.js +58 -0
  111. package/modules/dev-box/app/src/ssh-setup.js +569 -0
  112. package/modules/dev-box/app/src/task-picker.js +70 -0
  113. package/modules/dev-box/app/src/tray.js +99 -0
  114. package/modules/dev-box/app/src/updater.js +55 -0
  115. package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
  116. package/modules/dev-box/app/test/unit.mjs +526 -0
  117. package/modules/dev-box/box-access.js +121 -4
  118. package/modules/dev-box/boxes.js +20 -0
  119. package/modules/dev-box/module.json +1 -1
  120. package/modules/dev-box/routes/box.js +87 -3
  121. package/modules/economy/credits.js +116 -25
  122. package/modules/economy/reward.js +8 -0
  123. package/modules/economy/streak.js +2 -1
  124. package/modules/governance/CLAUDE.md +15 -0
  125. package/modules/governance/catalog.js +419 -0
  126. package/modules/governance/config.js +174 -0
  127. package/modules/governance/db.js +125 -0
  128. package/modules/governance/migrations/governance_001_tables.sql +81 -0
  129. package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
  130. package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
  131. package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
  132. package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
  133. package/modules/governance/module.json +10 -0
  134. package/modules/governance/protected-surfaces.js +88 -0
  135. package/modules/governance/protected-surfaces.json +80 -0
  136. package/modules/governance/rank-roles.js +83 -0
  137. package/modules/governance/resolver.js +221 -0
  138. package/modules/governance/routes/governance.js +89 -0
  139. package/modules/governance/routes/role-sync.js +49 -0
  140. package/modules/governance/session-scopes.js +127 -0
  141. package/modules/governance/session-scopes.json +38 -0
  142. package/modules/hall-ui/public/atlas.css +117 -0
  143. package/modules/hall-ui/public/atlas.html +3 -114
  144. package/modules/hall-ui/public/brand-apply.js +34 -6
  145. package/modules/hall-ui/public/builders.js +187 -219
  146. package/modules/hall-ui/public/diagrams.html +2 -0
  147. package/modules/hall-ui/public/dom-utils.js +27 -0
  148. package/modules/hall-ui/public/drachmae.html +2 -0
  149. package/modules/hall-ui/public/drachmae.js +54 -1
  150. package/modules/hall-ui/public/gate.css +98 -0
  151. package/modules/hall-ui/public/gate.html +3 -95
  152. package/modules/hall-ui/public/goal-inbox.js +4 -1
  153. package/modules/hall-ui/public/goals-page.js +626 -114
  154. package/modules/hall-ui/public/goals.css +79 -1
  155. package/modules/hall-ui/public/goals.html +38 -11
  156. package/modules/hall-ui/public/hall-widgets.js +62 -52
  157. package/modules/hall-ui/public/harbor.css +123 -0
  158. package/modules/hall-ui/public/harbor.html +3 -120
  159. package/modules/hall-ui/public/index.html +11 -4
  160. package/modules/hall-ui/public/instances-panel.js +4 -1
  161. package/modules/hall-ui/public/modules.css +63 -0
  162. package/modules/hall-ui/public/modules.html +3 -60
  163. package/modules/hall-ui/public/new-project-wizard.js +7 -3
  164. package/modules/hall-ui/public/not-ready.html +2 -0
  165. package/modules/hall-ui/public/pair.css +73 -0
  166. package/modules/hall-ui/public/pair.html +3 -70
  167. package/modules/hall-ui/public/palette.js +302 -0
  168. package/modules/hall-ui/public/primer.css +13 -0
  169. package/modules/hall-ui/public/primer.html +2 -0
  170. package/modules/hall-ui/public/profile.html +2 -0
  171. package/modules/hall-ui/public/ranks.html +2 -0
  172. package/modules/hall-ui/public/roadmap.css +179 -0
  173. package/modules/hall-ui/public/roadmap.html +9 -172
  174. package/modules/hall-ui/public/roadmap.js +329 -316
  175. package/modules/hall-ui/public/sessions.css +16 -0
  176. package/modules/hall-ui/public/sessions.html +3 -13
  177. package/modules/hall-ui/public/settings.html +2 -0
  178. package/modules/hall-ui/public/settings.js +4 -4
  179. package/modules/hall-ui/public/shell.js +143 -18
  180. package/modules/hall-ui/public/style.css +231 -39
  181. package/modules/hall-ui/public/task-detail.js +347 -26
  182. package/modules/hall-ui/public/task.html +6 -1
  183. package/modules/hall-ui/public/watch.html +2 -0
  184. package/modules/hall-ui/public/work.html +8 -6
  185. package/modules/hall-ui/public/work.js +1 -1
  186. package/modules/ideas/inbox.js +61 -11
  187. package/modules/ideas/routes/inbox.js +23 -0
  188. package/modules/lifecycle/claim-eligibility.js +72 -0
  189. package/modules/lifecycle/db.js +519 -139
  190. package/modules/lifecycle/done-when.js +18 -3
  191. package/modules/lifecycle/github-push.js +19 -2
  192. package/modules/lifecycle/goal-advisory.js +114 -0
  193. package/modules/lifecycle/goal-authz.js +62 -0
  194. package/modules/lifecycle/lifecycle.js +9 -0
  195. package/modules/lifecycle/publish-reconciler.js +109 -6
  196. package/modules/lifecycle/routes/claims.js +68 -23
  197. package/modules/lifecycle/routes/done-when.js +110 -15
  198. package/modules/lifecycle/routes/goals.js +8 -38
  199. package/modules/lifecycle/routes/tasks.js +38 -6
  200. package/modules/lifecycle/ship-card.js +6 -0
  201. package/modules/onboarding/onboarding-state.js +1 -1
  202. package/modules/platform-identity/CLAUDE.md +15 -0
  203. package/modules/platform-identity/hub-device.js +147 -0
  204. package/modules/platform-identity/hub-keys.js +151 -0
  205. package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
  206. package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
  207. package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
  208. package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
  209. package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
  210. package/modules/platform-identity/module.json +18 -0
  211. package/modules/platform-identity/platform-identity.js +531 -0
  212. package/modules/platform-identity/pollers/code-sweep.js +43 -0
  213. package/modules/platform-identity/routes/my-projects.js +140 -0
  214. package/modules/platform-identity/routes/projects.js +107 -0
  215. package/modules/platform-identity/routes/scouting.js +35 -0
  216. package/modules/platform-identity/routes/sso.js +402 -0
  217. package/modules/platform-identity/tests/platform-identity.mjs +388 -0
  218. package/modules/platform-identity/ui/.gitkeep +0 -0
  219. package/modules/security/routes/reports.js +10 -0
  220. package/modules/sessions/db.js +166 -21
  221. package/modules/sessions/module.json +1 -1
  222. package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
  223. package/modules/sessions/routes/sessions.js +33 -0
  224. package/package-lock.json +2 -2
  225. package/package.json +1 -1
  226. package/public-landing/assets/anim/idle/frame_00.webp +0 -0
  227. package/public-landing/assets/anim/idle/frame_01.webp +0 -0
  228. package/public-landing/assets/anim/idle/frame_02.webp +0 -0
  229. package/public-landing/assets/anim/idle/frame_03.webp +0 -0
  230. package/public-landing/assets/anim/idle/frame_04.webp +0 -0
  231. package/public-landing/assets/anim/idle/frame_05.webp +0 -0
  232. package/public-landing/assets/anim/idle/frame_06.webp +0 -0
  233. package/public-landing/assets/anim/idle/frame_07.webp +0 -0
  234. package/public-landing/assets/anim/idle/frame_08.webp +0 -0
  235. package/public-landing/assets/anim/idle/frame_09.webp +0 -0
  236. package/public-landing/assets/anim/idle/frame_10.webp +0 -0
  237. package/public-landing/assets/anim/idle/frame_11.webp +0 -0
  238. package/public-landing/assets/anim/idle/frame_12.webp +0 -0
  239. package/public-landing/assets/anim/idle/frame_13.webp +0 -0
  240. package/public-landing/assets/anim/idle/frame_14.webp +0 -0
  241. package/public-landing/assets/anim/idle/frame_15.webp +0 -0
  242. package/public-landing/assets/anim/idle/frame_16.webp +0 -0
  243. package/public-landing/assets/anim/idle/frame_17.webp +0 -0
  244. package/public-landing/assets/anim/idle/frame_18.webp +0 -0
  245. package/public-landing/assets/anim/idle/frame_19.webp +0 -0
  246. package/public-landing/assets/anim/idle/frame_20.webp +0 -0
  247. package/public-landing/assets/anim/idle/frame_21.webp +0 -0
  248. package/public-landing/assets/anim/idle/frame_22.webp +0 -0
  249. package/public-landing/assets/anim/idle/frame_23.webp +0 -0
  250. package/public-landing/assets/anim/idle/frame_24.webp +0 -0
  251. package/public-landing/assets/anim/idle/frame_25.webp +0 -0
  252. package/public-landing/assets/anim/idle/frame_26.webp +0 -0
  253. package/public-landing/assets/anim/idle/frame_27.webp +0 -0
  254. package/public-landing/assets/anim/idle/frame_28.webp +0 -0
  255. package/public-landing/assets/anim/idle/frame_29.webp +0 -0
  256. package/public-landing/assets/anim/idle/frame_30.webp +0 -0
  257. package/public-landing/assets/anim/idle/frame_31.webp +0 -0
  258. package/public-landing/assets/anim/idle/frame_32.webp +0 -0
  259. package/public-landing/assets/anim/idle/frame_33.webp +0 -0
  260. package/public-landing/assets/anim/idle/frame_34.webp +0 -0
  261. package/public-landing/assets/anim/idle/frame_35.webp +0 -0
  262. package/public-landing/assets/anim/idle/frame_36.webp +0 -0
  263. package/public-landing/assets/anim/idle/frame_37.webp +0 -0
  264. package/public-landing/assets/anim/idle/frame_38.webp +0 -0
  265. package/public-landing/assets/anim/idle/frame_39.webp +0 -0
  266. package/public-landing/assets/anim/idle/frame_40.webp +0 -0
  267. package/public-landing/assets/anim/idle/frame_41.webp +0 -0
  268. package/public-landing/assets/anim/idle/frame_42.webp +0 -0
  269. package/public-landing/assets/anim/idle/frame_43.webp +0 -0
  270. package/public-landing/create.html +51 -58
  271. package/public-landing/gate/README.md +137 -0
  272. package/public-landing/gate/dispmaps/bar.png +0 -0
  273. package/public-landing/gate/dispmaps/flat.png +0 -0
  274. package/public-landing/gate/dispmaps/key.png +0 -0
  275. package/public-landing/gate/index.html +587 -0
  276. package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
  277. package/public-landing/gate/previews/off-the-boats.jpg +0 -0
  278. package/public-landing/gate/space.css +799 -0
  279. package/public-landing/gate/space.js +1826 -0
  280. package/public-landing/gate/sprites/enter.png +0 -0
  281. package/public-landing/gate/sprites/search.png +0 -0
  282. package/public-landing/gate/sprites/terminal-blank.png +0 -0
  283. package/public-landing/gate/sprites/tile.png +0 -0
  284. package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
  285. package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
  286. package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
  287. package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
  288. package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
  289. package/public-landing/world/assets/plates/scene-establishing.png +0 -0
  290. package/scripts/gds/abandon.js +6 -4
  291. package/scripts/gds/api.js +10 -5
  292. package/scripts/gds/api.ps1 +26 -0
  293. package/scripts/gds/api.sh +6 -0
  294. package/scripts/gds/audit-ships.js +7 -1
  295. package/scripts/gds/backfill-sessions.js +78 -18
  296. package/scripts/gds/backup.js +23 -1
  297. package/scripts/gds/box-connect-lib.js +124 -31
  298. package/scripts/gds/box-sync.js +66 -0
  299. package/scripts/gds/box.js +225 -53
  300. package/scripts/gds/cf-tunnel.js +145 -6
  301. package/scripts/gds/claim.js +34 -3
  302. package/scripts/gds/cli-lib.js +161 -27
  303. package/scripts/gds/code.js +6 -2
  304. package/scripts/gds/deploy-divergence.js +209 -0
  305. package/scripts/gds/dev-lib.js +309 -0
  306. package/scripts/gds/dev.js +322 -0
  307. package/scripts/gds/doctor.js +90 -61
  308. package/scripts/gds/fetch-art-key.js +1 -1
  309. package/scripts/gds/fetch-sound-prefs.js +2 -2
  310. package/scripts/gds/fitness.js +228 -12
  311. package/scripts/gds/gds-db-backup.service +16 -1
  312. package/scripts/gds/gds-db-offsite.service +9 -1
  313. package/scripts/gds/gen-api-client.js +1 -1
  314. package/scripts/gds/gen-api-docs.js +36 -14
  315. package/scripts/gds/gen-diagrams.js +45 -28
  316. package/scripts/gds/gen-file-map.js +2 -1
  317. package/scripts/gds/gen-freshness.js +32 -0
  318. package/scripts/gds/gen-repo-map.js +3 -2
  319. package/scripts/gds/gen-session-index.js +14 -5
  320. package/scripts/gds/hooks/pre-push.js +2 -2
  321. package/scripts/gds/init.js +60 -3
  322. package/scripts/gds/install-git-hooks.js +1 -1
  323. package/scripts/gds/key-card.js +15 -6
  324. package/scripts/gds/key-status.js +1 -1
  325. package/scripts/gds/linkify-refs.js +35 -9
  326. package/scripts/gds/login.js +16 -1
  327. package/scripts/gds/paste-token.js +7 -1
  328. package/scripts/gds/preflight.js +126 -0
  329. package/scripts/gds/provision.js +123 -12
  330. package/scripts/gds/publish-manifest.js +7 -1
  331. package/scripts/gds/reauth.js +7 -3
  332. package/scripts/gds/regen-instance-docs.js +47 -0
  333. package/scripts/gds/run-unit-tests.js +4 -2
  334. package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
  335. package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
  336. package/scripts/gds/sequence.js +425 -0
  337. package/scripts/gds/session-cost.js +10 -1
  338. package/scripts/gds/setup.js +31 -5
  339. package/scripts/gds/shell.js +5 -3
  340. package/scripts/gds/ship.js +319 -29
  341. package/scripts/gds/smoke-gds.js +139 -8
  342. package/scripts/gds/start.js +171 -22
  343. package/scripts/gds/status.js +1 -1
  344. package/scripts/gds/triage.js +10 -9
  345. package/scripts/gds/upgrade.js +124 -2
  346. package/scripts/migrate.sh +99 -14
  347. package/scripts/status-mirror-sync.js +114 -84
  348. package/src/bongos/api-errors.js +1 -0
  349. package/src/bongos/auth.js +447 -102
  350. package/src/bongos/db-kernel.js +27 -0
  351. package/src/bongos/db.js +39 -0
  352. package/src/bongos/module-scope-map.js +27 -1
  353. package/src/bongos/permission-path-check.js +136 -102
  354. package/src/bongos/repo-info.js +47 -11
  355. package/src/bongos/route-rank-check.js +429 -46
  356. package/src/bongos/routes/_helpers.js +12 -1
  357. package/src/bongos/routes/auth.js +219 -2
  358. package/src/bongos/routes/backup.js +58 -2
  359. package/src/bongos/routes/me.js +35 -1
  360. package/src/bongos/serve-internal.js +84 -15
  361. package/src/branding.js +32 -0
  362. package/src/instance-config.js +30 -0
  363. package/src/module-api.js +124 -1
  364. package/src/platform-server.js +86 -9
  365. package/tests/abandon_task.mjs +59 -3
  366. package/tests/api_cli_flags.mjs +53 -0
  367. package/tests/auth_page_gate.mjs +13 -0
  368. package/tests/backfill_sessions.mjs +127 -1
  369. package/tests/backup_cli.mjs +54 -0
  370. package/tests/bongos_box_connect.mjs +77 -0
  371. package/tests/box_access.mjs +130 -1
  372. package/tests/box_credential.mjs +125 -0
  373. package/tests/box_host_keys.mjs +24 -0
  374. package/tests/box_infra_resolve.mjs +149 -0
  375. package/tests/box_scope_session.mjs +6 -2
  376. package/tests/box_sync.mjs +55 -0
  377. package/tests/box_task_scope.mjs +99 -0
  378. package/tests/branding.mjs +12 -0
  379. package/tests/cf_ruleset_preflight.mjs +142 -0
  380. package/tests/cf_tunnel.mjs +37 -0
  381. package/tests/claim_gate_rebase.mjs +98 -0
  382. package/tests/clawback_confirmed_backlog.mjs +103 -0
  383. package/tests/cli_lib_formatters.mjs +20 -0
  384. package/tests/confirm_attribution.mjs +30 -46
  385. package/tests/credit_grant.mjs +198 -10
  386. package/tests/criterion_authoring_gate.mjs +272 -0
  387. package/tests/criterion_progress.mjs +33 -1
  388. package/tests/dev_lib.mjs +253 -0
  389. package/tests/devbox_artifact_gate.mjs +75 -0
  390. package/tests/devbox_dmg_backdrop.mjs +137 -0
  391. package/tests/devbox_icons.mjs +304 -0
  392. package/tests/devbox_installer_brand.mjs +232 -0
  393. package/tests/devbox_key_register.mjs +234 -0
  394. package/tests/docs_openapi_route.mjs +110 -0
  395. package/tests/doctor_hooks.mjs +141 -0
  396. package/tests/extract_json.mjs +140 -0
  397. package/tests/fitness.mjs +89 -0
  398. package/tests/gen_freshness.mjs +96 -0
  399. package/tests/goal_advisory.mjs +251 -0
  400. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  401. package/tests/goal_routes.mjs +59 -4
  402. package/tests/goal_tier.mjs +12 -3
  403. package/tests/governance_catalog.mjs +129 -0
  404. package/tests/governance_claim_eligibility.mjs +262 -0
  405. package/tests/governance_config.mjs +187 -0
  406. package/tests/governance_ownership_scope.mjs +410 -0
  407. package/tests/governance_page_gates.mjs +324 -0
  408. package/tests/governance_parity.mjs +336 -0
  409. package/tests/governance_principals.mjs +232 -0
  410. package/tests/governance_protected_surfaces.mjs +208 -0
  411. package/tests/governance_require_permission.mjs +223 -0
  412. package/tests/governance_resolver.mjs +84 -0
  413. package/tests/governance_role_sync.mjs +231 -0
  414. package/tests/governance_seed.mjs +127 -0
  415. package/tests/governance_session_scope.mjs +434 -0
  416. package/tests/governance_vanilla_boot.mjs +203 -0
  417. package/tests/grade_attribution.mjs +7 -7
  418. package/tests/hall_audit.mjs +223 -0
  419. package/tests/hall_nav.mjs +191 -0
  420. package/tests/hall_palette.mjs +206 -0
  421. package/tests/hall_signout.mjs +124 -0
  422. package/tests/hook_user_agents.mjs +10 -0
  423. package/tests/idea_triage.mjs +162 -0
  424. package/tests/idp_device_exchange.mjs +93 -0
  425. package/tests/init.mjs +51 -0
  426. package/tests/instance_config.mjs +65 -0
  427. package/tests/instance_mismatch_guard.mjs +23 -6
  428. package/tests/instance_release_config.mjs +104 -0
  429. package/tests/linkify_refs.mjs +15 -2
  430. package/tests/migrate_root_resolution.mjs +124 -1
  431. package/tests/module_contributions.mjs +55 -0
  432. package/tests/module_loader.mjs +10 -5
  433. package/tests/module_scope_active_claims.mjs +94 -0
  434. package/tests/needs_rebase.mjs +92 -0
  435. package/tests/one_active_claim_per_session.mjs +76 -0
  436. package/tests/override_attribution.mjs +10 -8
  437. package/tests/override_request_ownership.mjs +8 -4
  438. package/tests/parse_cookie_decode.mjs +42 -0
  439. package/tests/pay_on_land.mjs +129 -0
  440. package/tests/platform_boot.mjs +100 -1
  441. package/tests/post_login_landing.mjs +42 -0
  442. package/tests/preflight_checks.mjs +72 -0
  443. package/tests/provision.mjs +132 -7
  444. package/tests/publish_branch_route.mjs +53 -0
  445. package/tests/publish_reconciler.mjs +187 -2
  446. package/tests/repo_info_binding.mjs +248 -0
  447. package/tests/reward_gate.mjs +11 -2
  448. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  449. package/tests/reward_mode_surfacing.mjs +168 -0
  450. package/tests/route_permission_check.mjs +301 -0
  451. package/tests/route_rank_check.mjs +254 -12
  452. package/tests/route_validation.mjs +30 -0
  453. package/tests/sequence.mjs +267 -0
  454. package/tests/session_digest.mjs +44 -0
  455. package/tests/session_index.mjs +18 -6
  456. package/tests/session_records.mjs +39 -1
  457. package/tests/session_reward_on_land.mjs +381 -0
  458. package/tests/session_reward_reconcile.mjs +229 -0
  459. package/tests/ship_api_client_pathspec.mjs +100 -0
  460. package/tests/ship_base_flag.mjs +93 -0
  461. package/tests/ship_ci_deploy.mjs +80 -3
  462. package/tests/ship_upstream_deletion.mjs +57 -0
  463. package/tests/smoke_target.mjs +233 -0
  464. package/tests/start_env_guard.mjs +87 -0
  465. package/tests/start_rebase_warning.mjs +103 -0
  466. package/tests/status_mirror_sync.mjs +122 -0
  467. package/tests/stop_refcheck.mjs +19 -1
  468. package/tests/task_goal_link.mjs +15 -6
  469. package/tests/upgrade.mjs +139 -0
  470. package/tests/web_admission_status.mjs +162 -0
  471. package/tests/write_validation_gaps.mjs +110 -0
@@ -80,6 +80,78 @@ function renderAuthErrorPage(heading, detail, opts = {}) {
80
80
  </body></html>`;
81
81
  }
82
82
 
83
+ // renderAccessPendingPage — the calm "hang tight" page shown instead of the red
84
+ // dead-end when admission is plausibly imminent (task 1002097): the configured
85
+ // firstAdmin during the bootstrap window, or a login with a pending request.
86
+ //
87
+ // It polls GET /auth/web/admission-status and, the moment the answer flips to
88
+ // admitted, sends the visitor back through /auth/web/start for a FRESH sign-in.
89
+ // A fresh start is required, not optional: the OAuth code that brought them
90
+ // here is already spent, so re-running the callback would fail on state
91
+ // mismatch. The owner chose auto-walk-in, hence no button to click.
92
+ //
93
+ // Branding resolves defensively, exactly as renderAuthErrorPage does — a
94
+ // branding hiccup must degrade to neutral copy, never crash the page.
95
+ function renderAccessPendingPage(login) {
96
+ const worldName = (() => { try { return branding().identity?.worldName || 'The city'; } catch { return 'The city'; } })();
97
+ const epigraph = (() => { try { return branding().copy?.authError?.epigraph || '— access —'; } catch { return '— access —'; } })();
98
+ // The probe takes the login as a query param; everything interpolated into
99
+ // the page is escaped, and the JS reads it from a data- attribute rather than
100
+ // being built by string concatenation into a script body.
101
+ const safeLogin = escapeHtml(String(login || ''));
102
+ return `<!doctype html>
103
+ <html lang="en"><head>
104
+ <meta charset="utf-8">
105
+ <meta name="viewport" content="width=device-width, initial-scale=1">
106
+ <title>${escapeHtml(worldName)} — confirming access</title>
107
+ <link rel="preconnect" href="https://fonts.googleapis.com">
108
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
109
+ <link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=EB+Garamond:ital,wght@0,400;0,500&display=swap" rel="stylesheet">
110
+ <link rel="stylesheet" href="/builders/style.css">
111
+ </head><body>
112
+ <header class="hall">
113
+ <p class="hall__epigraph">${escapeHtml(epigraph)}</p>
114
+ <h1>Confirming your access</h1>
115
+ <p class="hall__sub">Hang tight — this usually takes a few seconds.</p>
116
+ </header>
117
+ <main>
118
+ <section class="scroll">
119
+ <div class="landing" id="pending" data-login="${safeLogin}">
120
+ <p>We're checking your access now. You'll be signed in automatically as soon as it clears — no need to refresh.</p>
121
+ <p class="landing__note" id="pending-note">Checking…</p>
122
+ </div>
123
+ </section>
124
+ </main>
125
+ <script>
126
+ (function () {
127
+ var el = document.getElementById('pending');
128
+ var note = document.getElementById('pending-note');
129
+ var login = el ? el.getAttribute('data-login') : '';
130
+ var EVERY_MS = 3000;
131
+ var GIVE_UP_MS = 5 * 60 * 1000;
132
+ var started = Date.now();
133
+
134
+ function land() { window.location.href = '/api/gds/auth/web/start'; }
135
+
136
+ function tick() {
137
+ if (Date.now() - started > GIVE_UP_MS) {
138
+ note.textContent = 'Still waiting on approval. You can leave this page open, or try signing in again later.';
139
+ return;
140
+ }
141
+ fetch('/api/gds/auth/web/admission-status?login=' + encodeURIComponent(login), { credentials: 'omit' })
142
+ .then(function (r) { return r.ok ? r.json() : null; })
143
+ .then(function (j) {
144
+ if (j && j.admitted) { land(); return; }
145
+ setTimeout(tick, EVERY_MS);
146
+ })
147
+ .catch(function () { setTimeout(tick, EVERY_MS); });
148
+ }
149
+ setTimeout(tick, EVERY_MS);
150
+ })();
151
+ </script>
152
+ </body></html>`;
153
+ }
154
+
83
155
  // Cookie names. The web flow writes the new `gds_session` (+ `gds_oauth_state`)
84
156
  // names; both flows accept either the new or the legacy `pms_session` /
85
157
  // `pms_oauth_state` cookies on read so a builder mid-flow doesn't have to
@@ -189,6 +261,27 @@ function configuredOAuthOrigin() {
189
261
  }
190
262
  }
191
263
 
264
+ // The post-sign-in landing used when no explicit ?return= / same-apex Referer
265
+ // was stashed for this sign-in. Configurable per instance via
266
+ // branding.landing.postLogin (env override <PREFIX>_POST_LOGIN_LANDING) so a
267
+ // platform-home instance can land visitors on its OWN front door —
268
+ // cloudbongos.com sets it to /gate/ (the orbs) so a visitor who signs in from
269
+ // the orbs returns to the orbs, never auto-redirected into the builders' hall
270
+ // (task 1002240). Every other instance keeps the portable default '/builders':
271
+ // the hall always exists, a /gate/ orbs site does not. The configured value is
272
+ // run through the SAME open-redirect allowlist as ?return= (safeReturnTo), so a
273
+ // misconfigured landing can never become an open redirect; anything unsafe or
274
+ // absent falls back to '/builders'. Takes an optional resolved pack so the pure
275
+ // path is unit-testable without disk I/O (mirrors clientBranding()).
276
+ function postLoginLanding(brand) {
277
+ try {
278
+ const b = brand || branding();
279
+ return safeReturnTo(b.landing?.postLogin) || '/builders';
280
+ } catch {
281
+ return '/builders';
282
+ }
283
+ }
284
+
192
285
  // The OAuth redirect_uri must be a FIXED, registered origin — never derived from
193
286
  // the request's x-forwarded-host (spoofable; F2 Hacker finding). Configured via
194
287
  // env, falling back to the configured canonical public origin, so an injected host
@@ -348,6 +441,49 @@ module.exports = function buildAuthRouter() {
348
441
  : auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos }));
349
442
  });
350
443
 
444
+ // GET /auth/web/admission-status?login=X — the probe renderAccessPendingPage
445
+ // polls (task 1002097). Answers ONE question: would this login be admitted
446
+ // right now? It mirrors the real gate via auth.webAdmissionStatus so the page
447
+ // can never invite someone in the gate would refuse.
448
+ //
449
+ // rank: public — it must be reachable by a visitor with no session (that is
450
+ // the entire situation it exists for). It grants nothing: admission still
451
+ // runs through the normal sign-in, and this only reads state.
452
+ //
453
+ // WHAT IT LEAKS, stated plainly (task 1002430 — an earlier version of this
454
+ // comment claimed the opposite and was wrong). Returning only a boolean does
455
+ // NOT make it non-enumerable: with open enrollment off, and for any login
456
+ // that is not the configured firstAdmin, `admitted:true` means that login is
457
+ // a member or has been invited. That reveals account existence to an
458
+ // unauthenticated caller. It is also NOT throttled — rateLimitMiddleware
459
+ // returns early for non-write methods, so GETs bypass it entirely; the page's
460
+ // 3s poll bounds the honest client, not an attacker.
461
+ //
462
+ // Why it is nonetheless not a new exposure: the onboarding module's
463
+ // GET /access-requests/status?github_login=X is ALREADY public and returns
464
+ // the strictly richer member|invited|pending|dismissed|none plus its own
465
+ // `admitted`. Anything derivable here is already derivable there, so this
466
+ // adds no net-new surface — and hardening only this route would accomplish
467
+ // nothing while that one stays open. Whether unauthenticated login-existence
468
+ // probing is acceptable at all is a real question, but it belongs to both
469
+ // routes together, not to a comment here.
470
+ //
471
+ // That endpoint is also why this one exists: it models neither the firstAdmin
472
+ // bypass nor open enrollment, so it reports the OWNER as 'none' forever —
473
+ // the exact case this page has to resolve.
474
+ router.get('/auth/web/admission-status', async (req, res) => {
475
+ const login = typeof req.query.login === 'string' ? req.query.login.slice(0, 100) : '';
476
+ if (!login) return res.json({ admitted: false });
477
+ try {
478
+ const { admitted } = await auth.webAdmissionStatus(login);
479
+ return res.json({ admitted: !!admitted });
480
+ } catch (err) {
481
+ // Fail closed and quietly — the page just keeps polling.
482
+ console.error('[gds] auth/web/admission-status', err);
483
+ return res.json({ admitted: false });
484
+ }
485
+ });
486
+
351
487
  // rank: public — OAuth callback; the state cookie + code are the bearer.
352
488
  router.get('/auth/web/callback', async (req, res) => {
353
489
  if (!auth.authConfigured() && !auth.idpConfigured()) {
@@ -415,15 +551,37 @@ module.exports = function buildAuthRouter() {
415
551
  res.clearCookie(REPOS_COOKIE, domainOpts);
416
552
  }
417
553
  // Land back where sign-in began if a validated return target was stashed
418
- // (e.g. the builders subdomain bounced through here); else the hall.
554
+ // (e.g. the builders subdomain bounced through here); else the instance's
555
+ // configured post-login landing (default '/builders'; cloudbongos.com →
556
+ // /gate/, the orbs — task 1002240).
419
557
  const returnTo = safeReturnTo(parseCookie(req, RETURN_COOKIE));
420
558
  res.clearCookie(RETURN_COOKIE, domainOpts);
421
- res.redirect(returnTo || '/builders');
559
+ res.redirect(returnTo || postLoginLanding());
422
560
  } catch (err) {
423
561
  // Approval gate (2026-05-30): a first-time GitHub account that hasn't been
424
562
  // approved by an Archon can't enter. Send them to the hall to request
425
563
  // access rather than dead-ending on a generic error.
426
564
  if (err && err.code === 'NOT_APPROVED') {
565
+ // task 1002097: a blocked sign-in is not always a rejection. On a fresh
566
+ // or just-restarted instance the configured firstAdmin lands here for a
567
+ // few seconds — before FIRST_ADMIN is loaded / the bootstrap finishes —
568
+ // and so does anyone whose access_request an Archon is about to mark
569
+ // invited. The red dead-end looked broken, and could not be retried:
570
+ // the OAuth code is single-use, so a reload throws "State mismatch"
571
+ // instead. For those two, show a calm waiting page that polls and walks
572
+ // them in by itself. Genuine strangers still get the request-access
573
+ // page below.
574
+ let expectedSoon = false;
575
+ try {
576
+ ({ expectedSoon } = await auth.webAdmissionStatus(err.login));
577
+ } catch (probeErr) {
578
+ // Never let the classifier turn a clean 403 into a 500 — fall through
579
+ // to the existing page, which is the safe answer for a stranger.
580
+ console.error('[gds] web/callback admission classify', probeErr);
581
+ }
582
+ if (expectedSoon) {
583
+ return res.status(202).type('text/html').send(renderAccessPendingPage(err.login));
584
+ }
427
585
  return res.status(403).type('text/html').send(renderAuthErrorPage(
428
586
  'This account is not approved yet',
429
587
  'Signing in with GitHub is only for builders an Archon has already approved. If you are new, request access at the builders’ hall — an Archon will approve you, then this sign-in will let you in. If you were just approved, the Archon may still need to mark you invited; give it a moment and try again.',
@@ -617,9 +775,67 @@ module.exports = function buildAuthRouter() {
617
775
  res.clearCookie(SESSION_COOKIE, domainOpts);
618
776
  res.clearCookie(SESSION_COOKIE_LEGACY, domainOpts);
619
777
  }
778
+ // Federated single-logout (ADR 0144, task 1002238): announce the logout so the
779
+ // hub (platform-identity, enabled only on cloudbongos.com) can fan a signed
780
+ // back-channel logout out to every federated project this builder entered. A
781
+ // fire-and-forget seam EMIT — zero listeners on a non-hub instance, so it's a
782
+ // no-op there; the fan-out is best-effort server-side and never blocks or fails
783
+ // this logout (the hub session is already revoked above).
784
+ seams.emit('builder.logout', {
785
+ builderId: req.builder.id,
786
+ githubId: req.builder.github_id,
787
+ githubLogin: req.builder.github_login,
788
+ });
620
789
  res.json({ ok: true });
621
790
  });
622
791
 
792
+ // POST /auth/backchannel-logout — the INSTANCE side of federated single-logout
793
+ // (ADR 0144, task 1002238). The hub POSTs a hub-signed logout token here when a
794
+ // builder signs out of the hub; we verify it and revoke ALL of that builder's
795
+ // local sessions, so one hub logout ends the session on every federated project.
796
+ //
797
+ // rank: public — the hub-signed EdDSA logout token IS the credential (like the
798
+ // OAuth callback), NOT a builder session. Inert (404) on an instance that does
799
+ // not federate through a hub (idp unset), so a self-hosted instance exposes no
800
+ // new surface. Idempotent: an unknown/replayed subject just revokes nothing.
801
+ router.post('/auth/backchannel-logout', async (req, res) => {
802
+ const idp = auth.loadIdpConfig();
803
+ if (!idp) {
804
+ // Not a federated instance — indistinguishable from "no such route".
805
+ return res.status(404).type('text/plain').send('not found');
806
+ }
807
+ if (validateOrRespond(req, res, {
808
+ logout_token: { required: true, type: 'string', maxLength: 4096 },
809
+ })) return;
810
+ const publicKey = auth.loadHubPublicKey();
811
+ if (!publicKey) return res.fail('idp_unconfigured', { status: 503, message: 'hub public key not configured' });
812
+ let payload;
813
+ try {
814
+ payload = auth.verifyHubLogoutToken(req.body.logout_token, { publicKey, clientId: idp.clientId });
815
+ } catch (err) {
816
+ // A bad / expired / forged / wrong-audience token is an auth failure (401),
817
+ // never a server error — and never revokes anything.
818
+ return res.fail('invalid_logout_token', { status: 401, message: String(err.message || 'invalid logout token').slice(0, 160) });
819
+ }
820
+ // Anti-replay (defense-in-depth, symmetric with the CLI assertion path): honor
821
+ // a logout token's jti ONCE. A replay within the ~120s TTL is an idempotent
822
+ // no-op, so a captured token can't keep killing sessions the builder creates
823
+ // AFTER it was minted.
824
+ if (auth.logoutTokenJtiSeenAndRecord(payload.jti, payload.exp)) {
825
+ return res.json({ ok: true });
826
+ }
827
+ try {
828
+ const builder = await db.getBuilderByGithubId(payload.sub);
829
+ // Revoke ALL of this builder's local sessions (web + CLI) — the hub said sign
830
+ // them out. No-op + 200 if we don't know this builder (never leak existence).
831
+ if (builder) await db.revokeAllSessionsForBuilder(builder.id);
832
+ res.json({ ok: true });
833
+ } catch (err) {
834
+ console.error('[gds] /auth/backchannel-logout revoke', err);
835
+ res.fail('server_error', { status: 500, message: 'logout failed' });
836
+ }
837
+ });
838
+
623
839
  // POST /auth/cli-token/issue — V3.R91 / #401. Issue a fresh CLI bearer for
624
840
  // the requesting builder, gated on a WEB COOKIE session (not a bearer). The
625
841
  // browser already proves identity via the gds_session cookie; this endpoint
@@ -835,6 +1051,7 @@ module.exports = function buildAuthRouter() {
835
1051
  // open-redirect guard on the post-sign-in return target and the cross-subdomain
836
1052
  // cookie-domain decision — worth locking down independently of a running server.
837
1053
  module.exports.safeReturnTo = safeReturnTo;
1054
+ module.exports.postLoginLanding = postLoginLanding;
838
1055
  module.exports.sessionCookieDomainOpts = sessionCookieDomainOpts;
839
1056
  module.exports.reqHost = reqHost;
840
1057
  // The auth-error page renderer (task 1439): pure over the branding pack, so a
@@ -37,7 +37,39 @@ const BACKUP_SCRIPT = process.env.GDS_BACKUP_SCRIPT
37
37
  // In-memory guard: true while a trigger-initiated dump is running.
38
38
  let triggerRunning = false;
39
39
 
40
- function latestDump() {
40
+ // A dump older than this counts as STALE. The nightly timer fires daily, so 36h
41
+ // allows one missed run plus slack before we call it broken.
42
+ const STALE_AFTER_HOURS = Number(process.env.GDS_BACKUP_STALE_HOURS) || 36;
43
+
44
+ // parseDumpTimestamp — the dump filename stamp `YYYY-MM-DDTHHMMSSZ` is ALMOST
45
+ // ISO-8601 but omits the time separators, so Date.parse rejects it. Re-insert
46
+ // the colons and parse. Returns epoch ms, or null if it doesn't match.
47
+ // Pure + exported so tests can pin it without touching the filesystem.
48
+ function parseDumpTimestamp(stamp) {
49
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(String(stamp || ''));
50
+ if (!m) return null;
51
+ const ms = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}Z`);
52
+ return Number.isFinite(ms) ? ms : null;
53
+ }
54
+
55
+ // dumpAgeHours — hours between a dump stamp and now. null when unparseable, so
56
+ // callers can tell "cannot tell" apart from "fresh". Pure (now is injectable).
57
+ function dumpAgeHours(stamp, nowMs = Date.now()) {
58
+ const ms = parseDumpTimestamp(stamp);
59
+ if (ms === null) return null;
60
+ return Math.max(0, (nowMs - ms) / 3_600_000);
61
+ }
62
+
63
+ // isStaleDump — fail LOUD on an unreadable/absent stamp. A dump we cannot date
64
+ // must not read as healthy: that silence is exactly how the cloudbongos nightly
65
+ // sat failed for 13 days (2026-07-15 → 07-28) with nothing noticing (1002479).
66
+ function isStaleDump(stamp, nowMs = Date.now(), staleAfterHours = STALE_AFTER_HOURS) {
67
+ const age = dumpAgeHours(stamp, nowMs);
68
+ if (age === null) return true;
69
+ return age > staleAfterHours;
70
+ }
71
+
72
+ function latestDump(nowMs = Date.now()) {
41
73
  let files;
42
74
  try {
43
75
  files = fs.readdirSync(BACKUP_DIR);
@@ -54,7 +86,18 @@ function latestDump() {
54
86
  try { size_bytes = fs.statSync(full).size; } catch { /* ok */ }
55
87
  // Filename: <db>-<YYYY-MM-DDTHHMMSSZ>.sql.gz — extract timestamp from it.
56
88
  const ts = name.replace(`${DB_NAME}-`, '').replace('.sql.gz', '');
57
- return { filename: name, timestamp: ts, size_bytes, count: dumps.length };
89
+ const age = dumpAgeHours(ts, nowMs);
90
+ return {
91
+ filename: name,
92
+ timestamp: ts,
93
+ size_bytes,
94
+ count: dumps.length,
95
+ // Staleness is reported, not just implied by the timestamp, so a consumer
96
+ // (CLI, dashboard, alert) can act on one boolean instead of re-deriving it.
97
+ age_hours: age === null ? null : Math.round(age * 10) / 10,
98
+ stale: isStaleDump(ts, nowMs),
99
+ stale_after_hours: STALE_AFTER_HOURS,
100
+ };
58
101
  }
59
102
 
60
103
  module.exports = function buildBackupRouter() {
@@ -68,6 +111,11 @@ module.exports = function buildBackupRouter() {
68
111
  backup_dir: BACKUP_DIR,
69
112
  db_name: DB_NAME,
70
113
  latest_dump: dump,
114
+ // Top-level too, so a consumer gets a health signal WITHOUT having to
115
+ // special-case latest_dump===null. No dump at all is the worst state,
116
+ // not a neutral one, so it reports stale.
117
+ stale: dump ? dump.stale : true,
118
+ age_hours: dump ? dump.age_hours : null,
71
119
  trigger_running: triggerRunning,
72
120
  });
73
121
  }));
@@ -102,3 +150,11 @@ module.exports = function buildBackupRouter() {
102
150
 
103
151
  return router;
104
152
  };
153
+
154
+ // Pure helpers exported for tests (tests/backup_cli.mjs) — importing the real
155
+ // implementations beats re-implementing them in the test, which is how a test
156
+ // can pass while the shipped code is wrong.
157
+ module.exports.parseDumpTimestamp = parseDumpTimestamp;
158
+ module.exports.dumpAgeHours = dumpAgeHours;
159
+ module.exports.isStaleDump = isStaleDump;
160
+ module.exports.STALE_AFTER_HOURS = STALE_AFTER_HOURS;
@@ -83,7 +83,7 @@ module.exports = function buildMeRouter() {
83
83
  const [
84
84
  builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
85
85
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
86
- behaviorPrefs, geminiKeyRow,
86
+ behaviorPrefs, geminiKeyRow, needsRebaseTasks,
87
87
  ] = await Promise.all([
88
88
  db.getBuilderById(req.builder.id),
89
89
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -136,6 +136,14 @@ module.exports = function buildMeRouter() {
136
136
  // Own Gemini-key status via the art-pipeline port (ADR 0102) — the storage
137
137
  // is art-pipeline domain now; degrades to a no-key shape on a vanilla instance.
138
138
  seams.resolveOptional('art.getBuilderGeminiKeyRow', async () => ({ enc: null, last4: null, set_at: null }))(req.builder.id),
139
+ // ADR 0120 part 5: the confirmed tasks this builder owes a rebase on —
140
+ // un-landed confirmed work the publish-reconciler flagged needs_rebase
141
+ // (part 2). It GATES new claims (part 3: claimTask refuses REBASE_REQUIRED)
142
+ // and its per-task reward now lands only on ship (part 1), so the CLI
143
+ // (/builder-start) + the hall surface an owed-rebase warning from this
144
+ // without a follow-up call. Through the `lifecycle` port; [] on a
145
+ // lifecycle-less instance (guarded — the port is resolveOptional).
146
+ lc() ? lc().needsRebaseTasksForBuilder(req.builder.id) : [],
139
147
  ]);
140
148
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
141
149
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
@@ -148,6 +156,18 @@ module.exports = function buildMeRouter() {
148
156
  } catch (err) {
149
157
  console.error('[gds] GET /me cost status', err);
150
158
  }
159
+ // Best-effort read of the resolved reward policy (see the payload note).
160
+ // Never break the profile over a display concern — null degrades the CLI to
161
+ // its own paying default rather than a wrong "not paid" claim.
162
+ const rewardPolicySafe = () => {
163
+ try {
164
+ const p = typeof reward.rewardConfig === 'function' ? reward.rewardConfig() : null;
165
+ return p && typeof p.mode === 'string' ? { mode: p.mode, perTaskCredit: p.perTaskCredit !== false } : null;
166
+ } catch (err) {
167
+ console.error('[gds] GET /me reward policy', err);
168
+ return null;
169
+ }
170
+ };
151
171
  // For each active claim, compute the streak preview (cheap; pool query).
152
172
  // Lets the CLI render "shipping this would extend streak to N days" hints.
153
173
  const activeClaimsWithPreview = await Promise.all(
@@ -161,6 +181,20 @@ module.exports = function buildMeRouter() {
161
181
  active_claims: activeClaimsWithPreview,
162
182
  // Legacy field — first claim only. Prefer active_claims (plural).
163
183
  active_claim: activeClaimsWithPreview[0] ?? null,
184
+ // ADR 0120 part 5: un-landed confirmed tasks this builder owes a rebase on.
185
+ // Each row: { id, title, needs_rebase_reason, needs_rebase_at }, oldest
186
+ // first. Empty when none owed (or on a lifecycle-less instance). These GATE
187
+ // new claims (part 3) and their reward is pending until they land on ship
188
+ // (part 1); the CLI + hall render a warning off this array.
189
+ needs_rebase_tasks: needsRebaseTasks,
190
+ // ADR 0146 / task 1002469: the instance's RESOLVED reward policy, so the
191
+ // CLI can label a task's advertised `credits_reward` honestly. On a
192
+ // `cost-plus-only` instance that estimate is never paid, and a builder who
193
+ // isn't told reads a correct `+0` at ship as a broken payout. It must come
194
+ // from the SERVER — the instance's branding.json is not in the builder's
195
+ // checkout, so a laptop reading local config always sees the default.
196
+ // Resolved by economy (never re-derived); null if economy is disabled.
197
+ reward: rewardPolicySafe(),
164
198
  est_vs_actual: estVsActual,
165
199
  unlocked,
166
200
  locked_with_progress: lockedWithProgress,
@@ -47,7 +47,7 @@ const { buildBongosDownloads } = require('../bongos-downloads');
47
47
  const { clientBranding, branding } = require('../branding');
48
48
  const { clientModules, isModuleEnabled } = require('../modules');
49
49
  const { hallWidgetScripts, moduleWebSurfaces } = require('../module-loader/loader');
50
- const { resolveCoreRoot } = require('../instance-config');
50
+ const { resolveCoreRoot, resolveDocsRoot, resolveInstanceRoot } = require('../instance-config');
51
51
  const { API_PREFIX, LEGACY_API_PREFIXES, API_VERSION, VERSIONED_API_PREFIX, ALL_API_PREFIXES } = require('./api-prefix');
52
52
 
53
53
  // ADR 0108 §1: every use of ROOT below (infra/ installers, modules/, docs/) is
@@ -56,15 +56,28 @@ const { API_PREFIX, LEGACY_API_PREFIXES, API_VERSION, VERSIONED_API_PREFIX, ALL_
56
56
  const ROOT = resolveCoreRoot();
57
57
 
58
58
  // Instance-only RELEASE config (config/devbox-app.json, config/bongos-cli.json) is
59
- // boundary-excluded from the portable core (publish-manifest INSTANCE_EXCLUDE), so a
60
- // vanilla self-host boot ships without it. Requiring it hard used to CRASH the boot
61
- // (task 1824). Guard the require so an absent config makes its download surface simply
62
- // not mount (fail-to-vanilla, like branding/modules) rather than take the server down.
63
- // A malformed config (JSON parse error, etc.) still throws — only a MISSING file is
64
- // tolerated. A branded instance that ships the config gets the routes exactly as before.
65
- function tryRequireConfig(relPath) {
66
- try { return require(relPath); }
67
- catch (e) { if (e && e.code === 'MODULE_NOT_FOUND') return null; throw e; }
59
+ // boundary-excluded from the portable core (publish-manifest INSTANCE_EXCLUDE), so it is
60
+ // HOST content: it lives at the INSTANCE root, which on a vendored instance is NOT the
61
+ // package dir this file sits under. A core-relative require therefore looked in
62
+ // node_modules/@bongos/core/config/ and never found it, so /downloads/* silently failed
63
+ // to mount (task 1002371 — same class as ADR 0144's vendored infra/ path bug; ADR 0145's
64
+ // copy-the-config-into-node_modules workaround is lost on the next `npm ci`). So resolve
65
+ // the instance root FIRST — the branding/modules resolution (src/branding.js INSTANCE_PATH)
66
+ // — then the core package dir, which keeps a single checkout (and that copied-in
67
+ // workaround) byte-identical.
68
+ //
69
+ // Fail-soft is preserved verbatim: an ABSENT config makes its download surface simply not
70
+ // mount (fail-to-vanilla, like branding/modules) rather than crash the boot as the original
71
+ // hard require did (task 1824); a MALFORMED config still throws — only a MISSING file is
72
+ // tolerated. `roots` is injected so the ladder is unit-testable with zero ambient state.
73
+ function tryLoadInstanceConfig(name, roots = [resolveInstanceRoot(), ROOT]) {
74
+ for (const root of new Set(roots)) {
75
+ let raw;
76
+ try { raw = fs.readFileSync(path.join(root, 'config', name), 'utf8'); }
77
+ catch (e) { if (e.code === 'ENOENT') continue; throw e; }
78
+ return JSON.parse(raw);
79
+ }
80
+ return null;
68
81
  }
69
82
 
70
83
  // Map the pack's camelCase theme color keys → the kebab-case CSS custom
@@ -193,6 +206,60 @@ function docsIndexHtml() {
193
206
  return _docsHtml;
194
207
  }
195
208
 
209
+ // The OpenAPI 3.1 spec served at /docs/openapi.json — SELF-HEALING (task 1002200).
210
+ // The Redoc page (public-docs/) ships as static assets, but the SPEC is a generated
211
+ // artifact whose on-disk copy is NOT guaranteed at every deploy topology, so a bare
212
+ // res.sendFile(one fixed path) 404s exactly when the file is missing:
213
+ // • Standalone instance (ADR 0108/0140): the route resolves from the CORE package
214
+ // (resolveCoreRoot → node_modules/@bongos/core) while the generator writes the
215
+ // INSTANCE's own spec to resolveDocsRoot (the instance repo) — a path mismatch.
216
+ // • A pinned @bongos/core that predates the spec's packaging ships NO copy at all
217
+ // (the observed cloudbongos.com 404: public-docs/ shipped, docs/api/ didn't).
218
+ // • A fresh instance, before its first generate, has neither.
219
+ // So resolve the spec in tiers: (1) the committed spec at resolveDocsRoot() (the
220
+ // instance's own — carries its module routes), then (2) at resolveCoreRoot() (the
221
+ // core-package copy), then (3) GENERATE it in-process from the live route source.
222
+ // gen-api-docs.build() reads routes from resolveCoreRoot() (always present — it IS
223
+ // the running code), is DB-free + express-free (a pure line+regex extractor that
224
+ // never executes a route or opens a connection), and yields the identical spec.
225
+ // resolveOpenapiJson is the pure, injectable core so the fallback ladder is unit-
226
+ // testable (tests/docs_openapi_route.mjs).
227
+ function resolveOpenapiJson({ candidates = [], generate } = {}) {
228
+ for (const p of candidates) {
229
+ let raw;
230
+ try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; } // absent → next tier
231
+ try { JSON.parse(raw); } catch { continue; } // corrupt → next tier
232
+ return raw;
233
+ }
234
+ if (typeof generate === 'function') {
235
+ try {
236
+ const out = generate();
237
+ if (out) return out;
238
+ } catch (e) {
239
+ console.error('[docs] in-process OpenAPI generation failed:', e && e.message);
240
+ }
241
+ }
242
+ return '';
243
+ }
244
+
245
+ // Memoized like docsIndexHtml: one resolve per process; a redeploy restarts and
246
+ // re-resolves. '' (→ the route falls through to 404) only if every tier fails.
247
+ let _openapiJson;
248
+ function openapiJson() {
249
+ if (_openapiJson !== undefined) return _openapiJson;
250
+ const roots = [...new Set([resolveDocsRoot(), ROOT])]; // instance repo first, then core pkg
251
+ _openapiJson = resolveOpenapiJson({
252
+ candidates: roots.map((r) => path.join(r, 'docs/api/openapi.json')),
253
+ generate() {
254
+ // Lazy require: boot never pays for it, and a generator hiccup is contained
255
+ // to this one endpoint rather than taking down server start.
256
+ const gen = require('../../scripts/gds/gen-api-docs.js');
257
+ return JSON.stringify(gen.build().spec, null, 2) + '\n';
258
+ },
259
+ });
260
+ return _openapiJson;
261
+ }
262
+
196
263
  // The browser-global build of the generated typed client (task 1996 / R09),
197
264
  // served at /lib/bongos-client.js so classic <script> web pages can consume the
198
265
  // same generated client the CLI uses (window.BongosClient). Read once at boot
@@ -408,7 +475,7 @@ function mountInternalSurfaces(app) {
408
475
  // builders.<apex> (the builders rewrite shim below only prefixes non-/api
409
476
  // paths, so without this placement /downloads/* would be rewritten to
410
477
  // /builders/downloads/* and 404).
411
- const devboxCfg = tryRequireConfig('../../config/devbox-app.json');
478
+ const devboxCfg = tryLoadInstanceConfig('devbox-app.json');
412
479
  if (devboxCfg) {
413
480
  const devboxDownloads = buildDevboxDownloads(devboxCfg);
414
481
  app.get('/downloads/devbox/latest.json', (_req, res) => {
@@ -439,7 +506,7 @@ function mountInternalSurfaces(app) {
439
506
  // every user-facing URL on the apex. The installer scripts are served with the
440
507
  // server's PINNED origin substituted for __GDS_API_BASE__ — never a request
441
508
  // header, so a spoofed Host can't redirect a `curl | sh` (the box-connect.sh rule).
442
- const bongosCfg = tryRequireConfig('../../config/bongos-cli.json');
509
+ const bongosCfg = tryLoadInstanceConfig('bongos-cli.json');
443
510
  if (bongosCfg) {
444
511
  const bongosDownloads = buildBongosDownloads(bongosCfg);
445
512
  // Pinned public origin for the served installer scripts (never a request header —
@@ -847,7 +914,7 @@ function mountInternalSurfaces(app) {
847
914
  // external fetch (matches the /vendor/* self-hosting convention; CSP-safe).
848
915
  // GET /docs → the Redoc host page (branding-injected)
849
916
  // GET /docs/redoc.standalone.js → the vendored renderer (long-cached)
850
- // GET /docs/openapi.json → the generated spec (CORS-open so Postman/Swagger/AI can fetch it)
917
+ // GET /docs/openapi.json → the spec, SELF-HEALING (committed → generated), CORS-open so Postman/Swagger/AI can fetch it; see openapiJson()
851
918
  app.get('/docs', (req, res, next) => {
852
919
  if (isModuleSurfaceHost(req)) return next();
853
920
  const html = docsIndexHtml();
@@ -864,10 +931,12 @@ function mountInternalSurfaces(app) {
864
931
  });
865
932
  app.get('/docs/openapi.json', (req, res, next) => {
866
933
  if (isModuleSurfaceHost(req)) return next();
934
+ const json = openapiJson();
935
+ if (!json) return next(); // no committed spec + generation failed → fall through to 404
867
936
  res.set('Access-Control-Allow-Origin', '*'); // read-only spec — tools fetch it cross-origin
868
937
  res.set('Cache-Control', 'public, max-age=60');
869
938
  res.type('application/json');
870
- res.sendFile(path.join(ROOT, 'docs/api/openapi.json'));
939
+ res.send(json);
871
940
  });
872
941
 
873
942
  app.use((req, res, next) => {
@@ -895,4 +964,4 @@ function mountInternalSurfaces(app) {
895
964
  return app;
896
965
  }
897
966
 
898
- module.exports = { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGlobal, injectBrandingInto, injectModuleHallWidgets, themeMarkup, applyBrandTokens };
967
+ module.exports = { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGlobal, injectBrandingInto, injectModuleHallWidgets, themeMarkup, applyBrandTokens, resolveOpenapiJson, openapiJson, tryLoadInstanceConfig };
package/src/branding.js CHANGED
@@ -49,6 +49,11 @@ const ENV_OVERRIDES = [
49
49
  // idp:false), so a co-tenant instance sharing the neutral pack still federates
50
50
  // individually. 'bool'-coerced below — auth.js idpEnabled() reads it as strict === true.
51
51
  ['AUTH_IDP', ['auth', 'idp'], 'bool'],
52
+ // Post-sign-in landing when no ?return=/Referer was carried (auth.js
53
+ // postLoginLanding). Env-overridable so a platform-home instance can flip its
54
+ // front door with one env var and no instance branding.json — cloudbongos.com
55
+ // sets <PREFIX>_POST_LOGIN_LANDING=/gate/ (the orbs). Task 1002240.
56
+ ['POST_LOGIN_LANDING', ['landing', 'postLogin']],
52
57
  ];
53
58
 
54
59
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
@@ -192,7 +197,34 @@ function clientBranding(b = branding()) {
192
197
  notFound: b.copy?.notFound || 'Page not found.',
193
198
  },
194
199
  rankLabels: b.rankLabels || {},
200
+ reward: clientReward(),
195
201
  };
196
202
  }
197
203
 
204
+ // The RESOLVED reward policy, projected client-safe (ADR 0146 follow-up, task
205
+ // 1002469). Display surfaces need to know which streams this instance actually
206
+ // pays — on a `cost-plus-only` instance the per-task "Est." is a guide, never a
207
+ // payout, and a builder who is not told that reads a correct `+0` as a bug.
208
+ //
209
+ // Resolved through the economy module's `reward` port, NOT re-derived here: the
210
+ // mode→perTaskCredit mapping is economy's to own (ADR 0146 put the knob inside
211
+ // the module deliberately). `module-seams` is a dependency-free leaf, so this
212
+ // adds no boot-order coupling to the config layer.
213
+ //
214
+ // Fail-safe: an instance with economy disabled, or a resolve that throws, falls
215
+ // back to the ADR 0146 DEFAULT (pay both streams) — the same direction the pure
216
+ // resolver takes an unknown mode, so a hiccup can never silently tell builders
217
+ // their per-task reward is withheld.
218
+ function clientReward() {
219
+ const fallback = { mode: 'cost-plus-and-estimate', perTaskCredit: true };
220
+ try {
221
+ const reward = require('./module-seams').resolveOptional('reward');
222
+ const p = reward && typeof reward.rewardConfig === 'function' ? reward.rewardConfig() : null;
223
+ if (!p || typeof p.mode !== 'string') return fallback;
224
+ return { mode: p.mode, perTaskCredit: p.perTaskCredit !== false };
225
+ } catch (_) {
226
+ return fallback;
227
+ }
228
+ }
229
+
198
230
  module.exports = { resolveBranding, loadBranding, deepMerge, branding, clientBranding, userAgent, NEUTRAL_PATH, INSTANCE_PATH };