@bongos/core 1.19.1073 → 1.19.1075

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 (377) hide show
  1. package/.bongos-core.json +404 -549
  2. package/.claude/skills/feedback/SKILL.md +2 -2
  3. package/bin/bongos.js +1 -3
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +4 -60
  6. package/clients/bongos-client/index.cjs +4 -60
  7. package/clients/bongos-client/index.d.ts +5 -90
  8. package/clients/bongos-client/index.mjs +4 -60
  9. package/config/branding.neutral.json +1 -5
  10. package/config/modules.neutral.json +2 -3
  11. package/config/scheduled-routines.json +0 -2
  12. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
  13. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
  14. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  15. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
  16. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +2 -0
  17. package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
  18. package/docs/adr/README.md +1 -0
  19. package/docs/api/openapi.json +175 -1665
  20. package/docs/api-reference.md +9 -41
  21. package/docs/architecture.md +16 -1
  22. package/docs/branding-contract.md +0 -3
  23. package/docs/copy-inventory.md +454 -479
  24. package/docs/copy-registry.json +746 -978
  25. package/docs/file-map.md +5 -34
  26. package/docs/module-api-changelog.md +4 -0
  27. package/docs/modules-contract.md +13 -1
  28. package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
  29. package/docs/onboarding/diagrams/README.md +3 -3
  30. package/docs/onboarding/diagrams/assertions.json +2 -22
  31. package/docs/onboarding/primer.md +9 -28
  32. package/docs/page-inventory.json +4 -1
  33. package/docs/page-readings.json +1032 -1077
  34. package/docs/recipes/render-pilot.md +29 -10
  35. package/migrations/core_257_module_store_registry.sql +87 -0
  36. package/migrations/core_258_drop_dev_box_tables.sql +55 -0
  37. package/modules/agents/module.json +1 -0
  38. package/modules/autonomy/module.json +1 -0
  39. package/modules/builder-settings/module.json +1 -0
  40. package/modules/builder-settings/render-prefs.js +4 -5
  41. package/modules/copy-desk/docx.js +78 -5
  42. package/modules/copy-desk/module.json +1 -0
  43. package/modules/copy-desk/routes/copy-desk.js +10 -10
  44. package/modules/copy-desk/tests/copy_docx.mjs +107 -1
  45. package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
  46. package/modules/discord/module.json +1 -0
  47. package/modules/discord/ship-broadcast.js +3 -3
  48. package/modules/economy/credits.js +2 -2
  49. package/modules/economy/module.json +1 -0
  50. package/modules/economy/routes/credits.js +1 -2
  51. package/modules/government/catalog.js +13 -17
  52. package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
  53. package/modules/government/module.json +1 -0
  54. package/modules/government/protected-surfaces.json +3 -11
  55. package/modules/government/resolver.js +3 -46
  56. package/modules/government/routes/government.js +0 -2
  57. package/modules/government/session-scopes.js +43 -83
  58. package/modules/government/session-scopes.json +8 -18
  59. package/modules/grading/module.json +1 -0
  60. package/modules/hall-ui/module.json +1 -0
  61. package/modules/hall-ui/public/board-lib.js +2 -2
  62. package/modules/hall-ui/public/builders.js +29 -40
  63. package/modules/hall-ui/public/collab.html +1 -1
  64. package/modules/hall-ui/public/collab.js +1 -1
  65. package/modules/hall-ui/public/diagrams.html +3 -3
  66. package/modules/hall-ui/public/dom-utils.js +1 -1
  67. package/modules/hall-ui/public/gate.js +2 -2
  68. package/modules/hall-ui/public/gate.states.json +1 -1
  69. package/modules/hall-ui/public/hall-render.js +43 -79
  70. package/modules/hall-ui/public/index.html +5 -1
  71. package/modules/hall-ui/public/modules.js +1 -0
  72. package/modules/hall-ui/public/oversight.css +5 -5
  73. package/modules/hall-ui/public/palette.js +0 -11
  74. package/modules/hall-ui/public/primer.js +1 -1
  75. package/modules/hall-ui/public/sessions.html +1 -1
  76. package/modules/hall-ui/public/settings-sessions.js +1 -1
  77. package/modules/hall-ui/public/settings.css +0 -1
  78. package/modules/hall-ui/public/settings.html +4 -33
  79. package/modules/hall-ui/public/settings.js +3 -33
  80. package/modules/hall-ui/public/settings.states.json +2 -2
  81. package/modules/hall-ui/public/studio.html +1 -1
  82. package/modules/hall-ui/public/style.css +10 -13
  83. package/modules/hall-ui/public/task.html +4 -1
  84. package/modules/hall-ui/public/task.js +36 -1
  85. package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
  86. package/modules/hall-ui/public/tweak-editor.css +16 -0
  87. package/modules/hall-ui/public/tweak-editor.html +26 -3
  88. package/modules/hall-ui/public/tweak-editor.js +32 -1
  89. package/modules/hall-ui/public/work.js +1 -1
  90. package/modules/hall-ui/records/tweak-editor.md +1 -0
  91. package/modules/ideas/module.json +1 -0
  92. package/modules/ideas/projection.js +1 -1
  93. package/modules/lifecycle/db-claim-reads.js +1 -58
  94. package/modules/lifecycle/db-ship.js +4 -9
  95. package/modules/lifecycle/db.js +0 -4
  96. package/modules/lifecycle/github-push.js +12 -14
  97. package/modules/lifecycle/kickoff-checklist.js +0 -4
  98. package/modules/lifecycle/lifecycle.js +0 -8
  99. package/modules/lifecycle/module.json +1 -0
  100. package/modules/lifecycle/publish-reconciler.js +10 -10
  101. package/modules/lifecycle/routes/tasks.js +13 -15
  102. package/modules/lifecycle/ship-card.js +1 -1
  103. package/modules/lifecycle/task-visuals.js +2 -2
  104. package/modules/memory/module.json +1 -0
  105. package/modules/memory/routes/memory.js +2 -2
  106. package/modules/npm-release/module.json +1 -0
  107. package/modules/onboarding/module.json +1 -0
  108. package/modules/onboarding/onboarding-state.js +17 -36
  109. package/modules/onboarding/routes/access-requests.js +3 -3
  110. package/modules/platform-identity/module.json +1 -0
  111. package/modules/platform-identity/platform-identity.js +13 -5
  112. package/modules/platform-identity/routes/sso.js +5 -4
  113. package/modules/platform-identity/tests/platform-identity.mjs +17 -3
  114. package/modules/provisioning/capacity.js +1 -1
  115. package/modules/provisioning/module.json +1 -0
  116. package/modules/provisioning/provisioning.js +6 -5
  117. package/modules/provisioning/routes/provisioning.js +4 -4
  118. package/modules/provisioning/starter-bundles.js +5 -25
  119. package/modules/public-landing/module.json +1 -0
  120. package/modules/public-landing/public/assets/cosmos.css +1 -1
  121. package/modules/public-landing/public/contact.html +1 -1
  122. package/modules/public-landing/public/privacy.html +1 -1
  123. package/modules/public-landing/public/projects.html +101 -32
  124. package/modules/public-landing/public/projects.probes.json +2 -2
  125. package/modules/public-landing/public/projects.states.json +4 -3
  126. package/modules/public-landing/public/terms.html +1 -1
  127. package/modules/security/module.json +1 -0
  128. package/modules/sessions/db.js +1 -1
  129. package/modules/sessions/module.json +1 -0
  130. package/modules/specialities/module.json +1 -0
  131. package/modules/status-ui/module.json +1 -0
  132. package/modules/ui-design/module.json +1 -0
  133. package/package-lock.json +2 -2
  134. package/package.json +1 -1
  135. package/release-notes.json +84 -0
  136. package/scripts/gds/artifact-format.js +203 -0
  137. package/scripts/gds/artifact-staleness.js +2 -2
  138. package/scripts/gds/audit-authorship.sh +1 -13
  139. package/scripts/gds/backfill-task-rewards.js +5 -5
  140. package/scripts/gds/claim.js +0 -15
  141. package/scripts/gds/claude-materialize.js +2 -2
  142. package/scripts/gds/cli-lib.js +15 -15
  143. package/scripts/gds/codemod-rename-src-gds.js +1 -1
  144. package/scripts/gds/context-pack.js +5 -7
  145. package/scripts/gds/diagram-facts.js +12 -23
  146. package/scripts/gds/do-api.js +13 -80
  147. package/scripts/gds/doc-cli-guard.js +2 -0
  148. package/scripts/gds/doctor.js +4 -4
  149. package/scripts/gds/feedback-latest.js +139 -9
  150. package/scripts/gds/fitness-checks-identity.js +2 -7
  151. package/scripts/gds/fitness-checks-packaging.js +4 -4
  152. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  153. package/scripts/gds/fitness-ratchets.js +5 -5
  154. package/scripts/gds/fitness.js +10 -10
  155. package/scripts/gds/gen-api-client.js +4 -4
  156. package/scripts/gds/gen-api-docs.js +1 -1
  157. package/scripts/gds/gen-diagrams.js +49 -71
  158. package/scripts/gds/gen-repo-map.js +13 -14
  159. package/scripts/gds/gen-session-index.js +8 -8
  160. package/scripts/gds/http-api-client.js +12 -19
  161. package/scripts/gds/init.js +1 -1
  162. package/scripts/gds/leak-scan-allowlist.js +1 -1
  163. package/scripts/gds/lint-infra-exec.js +1 -1
  164. package/scripts/gds/local-preview-lib.js +8 -39
  165. package/scripts/gds/local-preview.js +2 -15
  166. package/scripts/gds/migration-namespace.js +3 -3
  167. package/scripts/gds/module-artifact.js +212 -0
  168. package/scripts/gds/module.js +95 -6
  169. package/scripts/gds/package-core.js +5 -116
  170. package/scripts/gds/plain-cards.js +1 -1
  171. package/scripts/gds/provision-config.js +2 -2
  172. package/scripts/gds/provision-net.js +2 -3
  173. package/scripts/gds/provision.js +9 -9
  174. package/scripts/gds/publish-manifest.js +1 -2
  175. package/scripts/gds/push-path-brief.js +10 -10
  176. package/scripts/gds/regen-instance-docs.js +1 -1
  177. package/scripts/gds/rename-history-check.js +1 -1
  178. package/scripts/gds/run-unit-tests.js +2 -16
  179. package/scripts/gds/sandbox-stage.js +39 -341
  180. package/scripts/gds/session-digest-build.js +2 -2
  181. package/scripts/gds/ship-deploy-target.js +13 -13
  182. package/scripts/gds/ship-flow.js +7 -7
  183. package/scripts/gds/ship-land.js +9 -10
  184. package/scripts/gds/ship-merge.js +4 -4
  185. package/scripts/gds/ship-preflight-steps.js +4 -4
  186. package/scripts/gds/ship-regen.js +15 -49
  187. package/scripts/gds/ship.js +13 -33
  188. package/scripts/gds/skill-preflight.js +9 -8
  189. package/scripts/gds/start.js +0 -15
  190. package/scripts/hall-preview/README.md +5 -5
  191. package/scripts/hall-preview/server.js +6 -6
  192. package/scripts/render-diagrams.sh +1 -1
  193. package/src/bongos/api-errors.js +1 -1
  194. package/src/bongos/api-prefix.js +4 -4
  195. package/src/bongos/auth-github.js +2 -3
  196. package/src/bongos/auth.js +51 -126
  197. package/src/bongos/db-kernel.js +2 -3
  198. package/src/bongos/db.js +5 -28
  199. package/src/bongos/module-scope-map.js +14 -29
  200. package/src/bongos/module-store.js +141 -0
  201. package/src/bongos/platform-visibility-gate.js +1 -1
  202. package/src/bongos/route-rank-check.js +3 -16
  203. package/src/bongos/routes/auth.js +0 -116
  204. package/src/bongos/routes/builders.js +5 -15
  205. package/src/bongos/routes/instance.js +8 -14
  206. package/src/bongos/routes/me.js +7 -23
  207. package/src/bongos/routes/modules.js +83 -0
  208. package/src/bongos/routes/security.js +3 -3
  209. package/src/bongos/routes.js +7 -2
  210. package/src/bongos/serve-internal.js +5 -6
  211. package/src/bongos-downloads.js +1 -6
  212. package/src/branding.js +2 -2
  213. package/src/build-info.js +4 -6
  214. package/src/instance-config.js +2 -2
  215. package/src/module-api.js +13 -6
  216. package/src/module-loader/catalog.js +3 -0
  217. package/src/module-loader/loader.js +4 -4
  218. package/src/module-loader/manifest-schema.js +16 -8
  219. package/src/module-seams.js +2 -3
  220. package/src/modules.js +42 -14
  221. package/tests/agents_spend_guard.mjs +1 -1
  222. package/tests/api_alias_caller_ratchet.mjs +1 -1
  223. package/tests/api_alias_exceptions.mjs +1 -1
  224. package/tests/api_client.mjs +1 -16
  225. package/tests/api_path_404.mjs +1 -1
  226. package/tests/auth_page_gate.mjs +10 -10
  227. package/tests/backfill_task_rewards.mjs +1 -1
  228. package/tests/canonical_profile_url.mjs +1 -3
  229. package/tests/claim_action.mjs +4 -1
  230. package/tests/claim_from_task_and_home.mjs +127 -0
  231. package/tests/{box_ship_permissions.mjs → cli_allowlist_permissions.mjs} +5 -5
  232. package/tests/cli_sessions.mjs +1 -1
  233. package/tests/consumer_layout_boot.mjs +2 -0
  234. package/tests/context_pack.mjs +13 -13
  235. package/tests/copy_desk_page_docx.mjs +33 -2
  236. package/tests/copy_inventory.mjs +7 -7
  237. package/tests/core_upgrade_runner.mjs +6 -6
  238. package/tests/credit_grant.mjs +2 -2
  239. package/tests/criterion_suggest.mjs +1 -1
  240. package/tests/deploy_divergence_line.mjs +11 -11
  241. package/tests/design_tokens_sync.mjs +3 -2
  242. package/tests/diagram_facts_offset.mjs +1 -1
  243. package/tests/do_api.mjs +106 -0
  244. package/tests/feedback_latest.mjs +122 -0
  245. package/tests/fitness.mjs +44 -42
  246. package/tests/fitness_ratchets.mjs +2 -2
  247. package/tests/gate_approvals.mjs +0 -1
  248. package/tests/github_push_land_proof.mjs +1 -1
  249. package/tests/go_live.mjs +3 -3
  250. package/tests/goal_advisory.mjs +3 -3
  251. package/tests/goal_suggest.mjs +3 -3
  252. package/tests/government_abuse_matrix.mjs +21 -19
  253. package/tests/government_ownership_scope.mjs +33 -35
  254. package/tests/government_parity.mjs +2 -2
  255. package/tests/government_protected_surfaces.mjs +56 -37
  256. package/tests/government_require_permission.mjs +3 -3
  257. package/tests/government_seed.mjs +37 -4
  258. package/tests/government_session_scope.mjs +92 -366
  259. package/tests/hall_audit.mjs +1 -1
  260. package/tests/hall_error_envelope.mjs +4 -0
  261. package/tests/hall_landing_boot.mjs +4 -1
  262. package/tests/hall_palette.mjs +1 -16
  263. package/tests/hall_record_world.mjs +6 -0
  264. package/tests/hall_tweak_editor.mjs +52 -1
  265. package/tests/helpers.mjs +1 -1
  266. package/tests/host_topology_skips.mjs +3 -4
  267. package/tests/html_comment_nesting.mjs +137 -0
  268. package/tests/infra_exec_paths.mjs +9 -10
  269. package/tests/init.mjs +4 -5
  270. package/tests/instance_manifest.mjs +18 -25
  271. package/tests/kickoff_checklist.mjs +1 -1
  272. package/tests/lib_sh_resolution.mjs +0 -221
  273. package/tests/local_preview.mjs +0 -12
  274. package/tests/migration_namespace.mjs +6 -6
  275. package/tests/module-scope-map.mjs +9 -9
  276. package/tests/module_api.mjs +2 -2
  277. package/tests/module_catalog.mjs +1 -1
  278. package/tests/module_cli.mjs +20 -20
  279. package/tests/module_contributions.mjs +6 -6
  280. package/tests/module_loader.mjs +6 -6
  281. package/tests/module_manifest.mjs +26 -17
  282. package/tests/module_route_rank.mjs +2 -2
  283. package/tests/module_store_publish.mjs +307 -0
  284. package/tests/module_store_publish_route.mjs +121 -0
  285. package/tests/module_store_registry_migration.mjs +70 -0
  286. package/tests/modules.mjs +56 -26
  287. package/tests/modules_route_wiring.mjs +1 -1
  288. package/tests/onboarding_route_signals.mjs +21 -31
  289. package/tests/onboarding_state.mjs +35 -49
  290. package/tests/paste_token_session_store.mjs +1 -1
  291. package/tests/permission_path.mjs +0 -3
  292. package/tests/platform_boot.mjs +1 -2
  293. package/tests/platform_visibility_gate.mjs +1 -1
  294. package/tests/profile_route.mjs +1 -6
  295. package/tests/project_modules_ui.mjs +17 -17
  296. package/tests/projects_hub.mjs +5 -5
  297. package/tests/projects_hub_app_step.mjs +138 -0
  298. package/tests/projects_hub_module_picker.mjs +77 -76
  299. package/tests/projects_hub_pre_uat.mjs +4 -5
  300. package/tests/provision.mjs +7 -7
  301. package/tests/provisioning_capacity.mjs +1 -1
  302. package/tests/provisioning_recommendations.mjs +2 -1
  303. package/tests/provisioning_starter_bundles.mjs +0 -22
  304. package/tests/publish_branch_route.mjs +11 -19
  305. package/tests/publish_manifest.mjs +2 -3
  306. package/tests/publish_reconciler.mjs +8 -8
  307. package/tests/push_path_brief.mjs +10 -9
  308. package/tests/rank_tier_single_source.mjs +1 -13
  309. package/tests/regrade_eligibility.mjs +1 -1
  310. package/tests/rename_history_restraint.mjs +1 -1
  311. package/tests/repo_map.mjs +12 -13
  312. package/tests/runner_drift.mjs +3 -21
  313. package/tests/sandbox_stage.mjs +57 -390
  314. package/tests/seam_wiring_guard.mjs +11 -11
  315. package/tests/search_isolation.mjs +3 -3
  316. package/tests/session_records.mjs +1 -1
  317. package/tests/session_rename_fallback.mjs +1 -22
  318. package/tests/session_start_freshness.mjs +3 -3
  319. package/tests/session_token_hash_db.mjs +1 -1
  320. package/tests/ship_card.mjs +1 -1
  321. package/tests/ship_ci_deploy.mjs +17 -17
  322. package/tests/ship_error_shape.mjs +1 -1
  323. package/tests/ship_premerge.mjs +1 -1
  324. package/tests/ship_resume.mjs +1 -1
  325. package/tests/skill_preflight.mjs +2 -2
  326. package/tests/skip_is_not_pass.mjs +1 -1
  327. package/tests/task_visual_slots.mjs +31 -0
  328. package/tests/terms_acceptance.mjs +2 -2
  329. package/tests/upgrade.mjs +4 -4
  330. package/tests/watch_sealed_floor.mjs +0 -1
  331. package/tests/wizard_draft_resume.mjs +2 -2
  332. package/tests/wizard_intent_resume.mjs +21 -10
  333. package/tests/wizard_preselect_why.mjs +18 -18
  334. package/docs/onboarding/browser-terminal-guide.md +0 -73
  335. package/docs/recipes/managed-settings-remote-control.md +0 -416
  336. package/modules/dev-box/CLAUDE.md +0 -15
  337. package/modules/dev-box/box-access.js +0 -623
  338. package/modules/dev-box/box-credential.js +0 -94
  339. package/modules/dev-box/box-onboard.js +0 -486
  340. package/modules/dev-box/boxes.js +0 -1038
  341. package/modules/dev-box/db.js +0 -39
  342. package/modules/dev-box/module.json +0 -19
  343. package/modules/dev-box/routes/box.js +0 -1223
  344. package/scripts/gds/box-auth-check.js +0 -160
  345. package/scripts/gds/box-infra.js +0 -321
  346. package/scripts/gds/box-sync.js +0 -216
  347. package/scripts/gds/box.js +0 -1332
  348. package/scripts/gds/cf-tunnel.js +0 -558
  349. package/scripts/gds/smoke-box.sh +0 -150
  350. package/scripts/gds/tree-preflight.js +0 -241
  351. package/src/bongos/app-pair.js +0 -172
  352. package/tests/app_pair.mjs +0 -168
  353. package/tests/box_access.mjs +0 -578
  354. package/tests/box_auth_check.mjs +0 -114
  355. package/tests/box_code_staleness.mjs +0 -129
  356. package/tests/box_connect_e2e.mjs +0 -196
  357. package/tests/box_cost.mjs +0 -70
  358. package/tests/box_credential.mjs +0 -125
  359. package/tests/box_dns_repoint.mjs +0 -153
  360. package/tests/box_env.mjs +0 -64
  361. package/tests/box_host_keys.mjs +0 -126
  362. package/tests/box_infra_resolve.mjs +0 -246
  363. package/tests/box_onboard.mjs +0 -317
  364. package/tests/box_scope_session.mjs +0 -119
  365. package/tests/box_sweep_docs.mjs +0 -119
  366. package/tests/box_sync.mjs +0 -55
  367. package/tests/box_sync_scope_report.mjs +0 -126
  368. package/tests/box_task_scope.mjs +0 -169
  369. package/tests/box_task_scope_pause.mjs +0 -66
  370. package/tests/box_terminal_ssrf.mjs +0 -96
  371. package/tests/boxes.mjs +0 -1585
  372. package/tests/cf_ruleset_preflight.mjs +0 -142
  373. package/tests/cf_tunnel.mjs +0 -496
  374. package/tests/docs_scrubber_damage.mjs +0 -111
  375. package/tests/module_scope_active_claims.mjs +0 -94
  376. package/tests/ship_api_client_pathspec.mjs +0 -100
  377. package/tests/tree_preflight.mjs +0 -243
@@ -12,11 +12,11 @@ const { satisfies, isValidRange, parseVersion } = require(path.join(ROOT, 'src',
12
12
  const { validateManifest, assertValidManifest } = require(path.join(ROOT, 'src', 'module-loader', 'manifest-schema.js'));
13
13
 
14
14
  const baseManifest = () => ({
15
- key: 'dev-box',
16
- title: 'Dev boxes',
17
- description: 'Per-builder cloud dev environments.',
18
- coreVersion: '^1.0.0',
19
- contributes: { routes: ['box'], pollers: ['box-lifecycle'], uiSections: ['boxes'] },
15
+ key: 'discord',
16
+ title: 'Discord',
17
+ description: 'A bridge to the team chat.',
18
+ version: '1.0.0', coreVersion: '^1.0.0',
19
+ contributes: { routes: ['discord'], pollers: ['discord-bot'], uiSections: ['chat'] },
20
20
  });
21
21
 
22
22
  // --- semver ---
@@ -62,7 +62,7 @@ test('isValidRange', () => {
62
62
  // --- manifest validation ---
63
63
 
64
64
  test('a minimal valid manifest passes', () => {
65
- const r = validateManifest({ key: 'x', title: 'X', description: 'd', coreVersion: '^1.0.0', contributes: {} });
65
+ const r = validateManifest({ key: 'x', title: 'X', description: 'd', version: '1.0.0', coreVersion: '^1.0.0', contributes: {} });
66
66
  assert.equal(r.valid, true, r.errors.join('; '));
67
67
  });
68
68
 
@@ -81,7 +81,7 @@ test('a full valid manifest passes (provides/consumes/prerequisites/migrations)'
81
81
  });
82
82
 
83
83
  test('missing required fields each produce an error', () => {
84
- for (const field of ['key', 'title', 'description', 'coreVersion']) {
84
+ for (const field of ['key', 'title', 'description', 'version', 'coreVersion']) {
85
85
  const m = baseManifest();
86
86
  delete m[field];
87
87
  const r = validateManifest(m);
@@ -91,25 +91,34 @@ test('missing required fields each produce an error', () => {
91
91
  });
92
92
 
93
93
  test('bad key format is rejected', () => {
94
- const r = validateManifest({ ...baseManifest(), key: 'Dev_Box' });
94
+ const r = validateManifest({ ...baseManifest(), key: 'Discord_Bot' });
95
95
  assert.equal(r.valid, false);
96
96
  assert.ok(r.errors.some((e) => e.startsWith('key')));
97
97
  });
98
98
 
99
99
  test('invalid coreVersion range is rejected', () => {
100
- const r = validateManifest({ ...baseManifest(), coreVersion: 'whenever' });
100
+ const r = validateManifest({ ...baseManifest(), version: '1.0.0', coreVersion: 'whenever' });
101
101
  assert.equal(r.valid, false);
102
102
  assert.ok(r.errors.some((e) => e.startsWith('coreVersion')));
103
103
  });
104
104
 
105
+ test('version must be an exact X.Y.Z — a range or junk is rejected (task 1003781)', () => {
106
+ for (const bad of ['^1.0.0', '1.0', 'v1.0.0', '1.0.0-beta', '']) {
107
+ const r = validateManifest({ ...baseManifest(), version: bad });
108
+ assert.equal(r.valid, false, `"${bad}" should be rejected`);
109
+ assert.ok(r.errors.some((e) => e.startsWith('version')));
110
+ }
111
+ assert.equal(validateManifest({ ...baseManifest(), version: '2.10.3' }).valid, true);
112
+ });
113
+
105
114
  test('unknown contributes key is rejected (typo guard)', () => {
106
- const r = validateManifest({ ...baseManifest(), contributes: { routs: ['box'] } });
115
+ const r = validateManifest({ ...baseManifest(), contributes: { routs: ['discord'] } });
107
116
  assert.equal(r.valid, false);
108
117
  assert.ok(r.errors.some((e) => e.includes('contributes.routs')));
109
118
  });
110
119
 
111
120
  test('non-array contributes.routes is rejected', () => {
112
- const r = validateManifest({ ...baseManifest(), contributes: { routes: 'box' } });
121
+ const r = validateManifest({ ...baseManifest(), contributes: { routes: 'discord' } });
113
122
  assert.equal(r.valid, false);
114
123
  assert.ok(r.errors.some((e) => e.includes('contributes.routes')));
115
124
  });
@@ -287,7 +296,7 @@ test('every bundled module declares a maintenance posture (the catalog has no un
287
296
  test('declarativeSeams: a valid exemption naming a declared seam + entry passes', () => {
288
297
  const r = validateManifest({
289
298
  ...baseManifest(),
290
- declarativeSeams: { pollers: ['box-lifecycle'], uiSections: ['boxes'] },
299
+ declarativeSeams: { pollers: ['discord-bot'], uiSections: ['chat'] },
291
300
  });
292
301
  assert.equal(r.valid, true, r.errors.join('; '));
293
302
  });
@@ -295,8 +304,8 @@ test('declarativeSeams: a valid exemption naming a declared seam + entry passes'
295
304
  test('declarativeSeams: an exemption for a seam the module does not declare is an ERROR', () => {
296
305
  const r = validateManifest({
297
306
  ...baseManifest(),
298
- contributes: { routes: ['box'] }, // no pollers declared
299
- declarativeSeams: { pollers: ['box-lifecycle'] },
307
+ contributes: { routes: ['discord'] }, // no pollers declared
308
+ declarativeSeams: { pollers: ['discord-bot'] },
300
309
  });
301
310
  assert.equal(r.valid, false);
302
311
  assert.ok(r.errors.some((e) => /does not declare contributes\.pollers/.test(e)), r.errors.join('; '));
@@ -305,10 +314,10 @@ test('declarativeSeams: an exemption for a seam the module does not declare is a
305
314
  test('declarativeSeams: an exemption naming an ENTRY the module does not list is an ERROR (the typo case)', () => {
306
315
  const r = validateManifest({
307
316
  ...baseManifest(),
308
- declarativeSeams: { pollers: ['box-lifecyle'] }, // transposed letters
317
+ declarativeSeams: { pollers: ['discord-bto'] }, // transposed letters
309
318
  });
310
319
  assert.equal(r.valid, false);
311
- assert.ok(r.errors.some((e) => /"box-lifecyle" is not in contributes\.pollers/.test(e)), r.errors.join('; '));
320
+ assert.ok(r.errors.some((e) => /"discord-bto" is not in contributes\.pollers/.test(e)), r.errors.join('; '));
312
321
  });
313
322
 
314
323
  test('declarativeSeams: the per-entry check spans EVERY seam, not just bare string arrays', () => {
@@ -335,7 +344,7 @@ test('declarativeSeams: an unknown seam key is an ERROR, not a silent no-op', ()
335
344
 
336
345
  test('declarativeSeams: shape errors — non-object block, non-string-array entries', () => {
337
346
  assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: ['pollers'] }).valid, false);
338
- assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: 'box-lifecycle' } }).valid, false);
347
+ assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: 'discord-bot' } }).valid, false);
339
348
  assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: [''] } }).valid, false);
340
349
  });
341
350
 
@@ -91,9 +91,9 @@ test('checkRouteRanks: an absent modules/ dir leaves the core result clean (byte
91
91
 
92
92
  // ---- the grade-time trigger fires on a module route change ----
93
93
  test('ROUTE_FILE_RE matches a module route path (a module route change triggers the rank pre-pass)', () => {
94
- assert.match('modules/dev-box/routes/box.js', rrc.ROUTE_FILE_RE);
94
+ assert.match('modules/discord/routes/discord.js', rrc.ROUTE_FILE_RE);
95
95
  assert.match('src/bongos/routes/tasks.js', rrc.ROUTE_FILE_RE); // core still matches
96
- assert.ok(!rrc.ROUTE_FILE_RE.test('modules/dev-box/module.json'), 'a non-route module file does not trigger the pre-pass');
96
+ assert.ok(!rrc.ROUTE_FILE_RE.test('modules/discord/module.json'), 'a non-route module file does not trigger the pre-pass');
97
97
  });
98
98
 
99
99
  // ---- a rank DRIFT on a pinned MODULE route reds the build (BV1.R61) ----
@@ -0,0 +1,307 @@
1
+ // tests/module_store_publish.mjs — `bongos module publish` end to end without a
2
+ // network or a database (task 1004271, ADR 0338 D1): the pack/verify pair in
3
+ // scripts/gds/module-artifact.js, the store's stage/commit + registry transaction in
4
+ // src/bongos/module-store.js (against a fake pg client), and cmdPublish's refusals.
5
+ //
6
+ // Run: node tests/module_store_publish.mjs
7
+
8
+ import { test } from 'node:test';
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const artifact = require('../scripts/gds/module-artifact.js');
17
+ const format = require('../scripts/gds/artifact-format.js');
18
+ const store = require('../src/bongos/module-store.js');
19
+ const moduleCli = require('../scripts/gds/module.js');
20
+
21
+ const NOW = () => new Date('2026-09-28T00:00:00Z');
22
+
23
+ function moduleJson(over = {}) {
24
+ return {
25
+ key: 'weather', title: 'Weather', description: 'Shows the weather.',
26
+ version: '1.2.0', coreVersion: '^1.0.0', contributes: {}, ...over,
27
+ };
28
+ }
29
+
30
+ // A temp modules/ root holding one module. files: { relPath: content }.
31
+ function fixture(files = {}, mj = moduleJson()) {
32
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-publish-'));
33
+ const dir = path.join(root, mj.key);
34
+ const all = { 'module.json': JSON.stringify(mj, null, 2), 'routes/weather.js': 'module.exports = () => null;\n', ...files };
35
+ for (const [rel, body] of Object.entries(all)) {
36
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
37
+ fs.writeFileSync(path.join(dir, rel), body);
38
+ }
39
+ return root;
40
+ }
41
+
42
+ const pack = (root, key = 'weather') => artifact.packModule(key, { modulesDir: root, now: NOW, modeOf: () => '644' });
43
+
44
+ // Rebuild a tarball from { name: Buffer } with the real writer, for tamper cases.
45
+ function retar(tgz, edit) {
46
+ const entries = format.readTar(tgz);
47
+ const map = new Map(entries.map((e) => [e.name, e]));
48
+ edit(map);
49
+ return format.buildTar([...map.values()]);
50
+ }
51
+
52
+ // ---- pack ------------------------------------------------------------------
53
+
54
+ test('pack: the tarball is the core release format — package/ root, inner manifest, a tree pin', async () => {
55
+ const { tgz, manifest, sidecar } = pack(fixture());
56
+ const names = format.readTar(tgz).map((e) => e.name).sort();
57
+ assert.deepEqual(names, ['package/.bongos-module.json', 'package/module.json', 'package/routes/weather.js']);
58
+ assert.equal(manifest.artifact, 'bongos-module');
59
+ assert.equal(manifest.module_key, 'weather');
60
+ assert.equal(manifest.module_version, '1.2.0');
61
+ assert.equal(manifest.core_version, '^1.0.0');
62
+ assert.equal(manifest.file_count, 2);
63
+ assert.equal(manifest.tree_sha256, format.canonicalTreeHash(manifest.files));
64
+ assert.equal(sidecar.tarball.sha256, format.sha256hex(tgz));
65
+ assert.equal(sidecar.tarball.name, 'bongos-module-weather-1.2.0.tgz');
66
+ });
67
+
68
+ test('pack: deterministic — the same module packs to the same bytes', async () => {
69
+ const root = fixture();
70
+ assert.equal(format.sha256hex(pack(root).tgz), format.sha256hex(pack(root).tgz));
71
+ });
72
+
73
+ test('pack: refuses a module.json without a version, or naming another key', async () => {
74
+ const noVersion = moduleJson(); delete noVersion.version;
75
+ assert.throws(() => pack(fixture({}, noVersion)), /version/);
76
+ const root = fixture();
77
+ fs.writeFileSync(path.join(root, 'weather', 'module.json'), JSON.stringify(moduleJson({ key: 'other' })));
78
+ assert.throws(() => pack(root), /names key "other"/);
79
+ });
80
+
81
+ test('pack: a credential-named file anywhere in the module refuses the whole publish', async () => {
82
+ for (const bad of ['.env', 'config/.env.local', 'certs/server.pem', 'deploy/id_rsa']) {
83
+ assert.throws(() => pack(fixture({ [bad]: 'SECRET=1' })), /denylist refuses/, bad);
84
+ }
85
+ });
86
+
87
+ test('pack: the repo denylist applies at the module\'s canonical path', async () => {
88
+ // modules/game/ is on PUBLISH_DENYLIST — a module keyed "game" can never publish.
89
+ const mj = moduleJson({ key: 'game' });
90
+ assert.throws(() => pack(fixture({}, mj), 'game'), /denylist refuses: modules\/game\//);
91
+ });
92
+
93
+ test('pack: the local sign-off record is never packed', async () => {
94
+ const { tgz } = pack(fixture({ '.upstream-signoff.json': '{}' }));
95
+ assert.ok(!format.readTar(tgz).some((e) => e.name.endsWith('.upstream-signoff.json')));
96
+ });
97
+
98
+ // ---- verify (the store's side) ----------------------------------------------
99
+
100
+ test('verify: a freshly packed module passes and reports its own pins', async () => {
101
+ const { tgz, manifest } = pack(fixture());
102
+ const v = (await artifact.verifyModuleArtifact(tgz, { key: 'weather' }));
103
+ assert.equal(v.ok, true, v.message);
104
+ assert.equal(v.version, '1.2.0');
105
+ assert.equal(v.treeSha256, manifest.tree_sha256);
106
+ assert.equal(v.tarballSha256, format.sha256hex(tgz));
107
+ assert.equal(v.bytes, tgz.length);
108
+ });
109
+
110
+ test('verify: an upload for a different key is refused', async () => {
111
+ const { tgz } = pack(fixture());
112
+ assert.equal((await artifact.verifyModuleArtifact(tgz, { key: 'forecast' })).code, 'key_mismatch');
113
+ });
114
+
115
+ test('verify: a file changed after packing is caught by its hash', async () => {
116
+ const { tgz } = pack(fixture());
117
+ const bad = retar(tgz, (m) => { m.get('package/routes/weather.js').buf = Buffer.from('evil()'); });
118
+ assert.equal((await artifact.verifyModuleArtifact(bad, { key: 'weather' })).code, 'hash_mismatch');
119
+ });
120
+
121
+ test('verify: a file smuggled in beside the manifest is caught', async () => {
122
+ const { tgz } = pack(fixture());
123
+ const bad = retar(tgz, (m) => m.set('package/extra.js', { name: 'package/extra.js', mode: 0o644, buf: Buffer.from('x') }));
124
+ assert.equal((await artifact.verifyModuleArtifact(bad, { key: 'weather' })).code, 'hash_mismatch');
125
+ });
126
+
127
+ test('verify: a path that climbs out of the module is refused before anything else', async () => {
128
+ const { tgz } = pack(fixture());
129
+ const bad = retar(tgz, (m) => m.set('package/../../etc/x', { name: 'package/../../etc/x', mode: 0o644, buf: Buffer.from('x') }));
130
+ assert.equal((await artifact.verifyModuleArtifact(bad, { key: 'weather' })).code, 'bad_tarball');
131
+ });
132
+
133
+ test('verify: a secret file re-hashed into a consistent manifest is still refused', async () => {
134
+ // A hostile client can make the hashes agree; the denylist must run server-side anyway.
135
+ const root = fixture();
136
+ const good = pack(root);
137
+ const entries = format.readTar(good.tgz).filter((e) => e.name !== 'package/.bongos-module.json');
138
+ entries.push({ name: 'package/.env', mode: 0o644, buf: Buffer.from('K=1') });
139
+ const files = entries.map((e) => ({ path: e.name.slice(8), mode: '0000644', sha256: format.sha256hex(e.buf) }));
140
+ const manifest = { ...good.manifest, files, file_count: files.length, tree_sha256: format.canonicalTreeHash(files) };
141
+ entries.push({ name: 'package/.bongos-module.json', mode: 0o644, buf: Buffer.from(JSON.stringify(manifest)) });
142
+ assert.equal((await artifact.verifyModuleArtifact(format.buildTar(entries), { key: 'weather' })).code, 'denied_content');
143
+ });
144
+
145
+ test('verify: garbage, an empty body and an oversize body are refused plainly', async () => {
146
+ assert.equal((await artifact.verifyModuleArtifact(Buffer.from('not a tarball'), { key: 'weather' })).code, 'bad_tarball');
147
+ assert.equal((await artifact.verifyModuleArtifact(Buffer.alloc(0), { key: 'weather' })).code, 'empty_artifact');
148
+ const { tgz } = pack(fixture());
149
+ assert.equal((await artifact.verifyModuleArtifact(tgz, { key: 'weather', maxBytes: 10 })).code, 'artifact_too_large');
150
+ });
151
+
152
+ test('verify: a small upload that inflates past the unpacked cap (a gzip bomb) is refused', async () => {
153
+ const bomb = format.buildTar([{ name: 'package/zeros.bin', mode: 0o644, buf: Buffer.alloc(17 * 1024 * 1024) }]);
154
+ assert.ok(bomb.length < artifact.MAX_TARBALL_BYTES, 'it fits the wire cap, so only the unpacked cap can catch it');
155
+ const v = await artifact.verifyModuleArtifact(bomb, { key: 'weather' });
156
+ assert.equal(v.code, 'bad_tarball');
157
+ assert.match(v.message, /within 16777216 bytes/);
158
+ });
159
+
160
+ test('readTar: refuses entry types the writer never makes (a symlink)', async () => {
161
+ const tar = Buffer.concat([format.ustarHeader({ name: 'package/link', mode: 0o644, size: 0, typeflag: '2' }), Buffer.alloc(1024)]);
162
+ const zlib = require('node:zlib');
163
+ assert.throws(() => format.readTar(zlib.gzipSync(tar)), /only regular files/);
164
+ });
165
+
166
+ // ---- the store: disk + registry transaction ---------------------------------
167
+
168
+ test('store: a staged tarball becomes the final file only on commit', async () => {
169
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-store-'));
170
+ const staged = await store.stageArtifact(Buffer.from('tgz'), { key: 'weather', version: '1.2.0', dir });
171
+ assert.ok(fs.existsSync(staged.tmp));
172
+ assert.ok(!fs.existsSync(staged.final));
173
+ await store.commitArtifact(staged);
174
+ assert.equal(fs.readFileSync(staged.final, 'utf8'), 'tgz');
175
+ assert.equal(store.relativeArtifactPath(staged.final, { dir }), 'weather/weather-1.2.0.tgz');
176
+ await store.removeArtifact(staged.tmp); // already moved — a no-op, never a throw
177
+ });
178
+
179
+ test('store: a key or version that could escape the store dir is refused before any write', async () => {
180
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-store-'));
181
+ await assert.rejects(store.stageArtifact(Buffer.from('x'), { key: '../x', version: '1.0.0', dir }));
182
+ await assert.rejects(store.stageArtifact(Buffer.from('x'), { key: 'weather', version: '../../1', dir }));
183
+ assert.deepEqual(fs.readdirSync(dir), []);
184
+ });
185
+
186
+ // A fake pg pool: `state` is the registry, answers are computed from it.
187
+ function fakeDb(state = { modules: {}, versions: [] }) {
188
+ const log = [];
189
+ const client = {
190
+ async query(sql, params = []) {
191
+ log.push(sql.trim().split(/\s+/).slice(0, 2).join(' '));
192
+ if (/^SELECT module_key, author_id, status FROM store_modules/.test(sql)) {
193
+ const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
194
+ }
195
+ if (/^INSERT INTO store_modules/.test(sql)) {
196
+ state.modules[params[0]] = { module_key: params[0], author_id: params[3], status: 'listed' }; return { rows: [] };
197
+ }
198
+ if (/^SELECT version FROM store_module_versions/.test(sql)) {
199
+ return { rows: state.versions.filter((v) => v.module_key === params[0]) };
200
+ }
201
+ if (/^INSERT INTO store_module_versions/.test(sql)) {
202
+ const row = { id: state.versions.length + 1, module_key: params[0], version: params[1] };
203
+ state.versions.push(row); return { rows: [row] };
204
+ }
205
+ return { rows: [] };
206
+ },
207
+ release() {},
208
+ };
209
+ return { pool: { connect: async () => client }, state, log };
210
+ }
211
+
212
+ const input = (over = {}) => ({
213
+ key: 'weather', title: 'Weather', description: 'd', version: '1.2.0', coreVersion: '^1.0.0',
214
+ manifest: {}, treeSha256: 'a'.repeat(64), tarballSha256: 'b'.repeat(64), bytes: 10,
215
+ artifactPath: 'weather/weather-1.2.0.tgz', builderId: 7, ...over,
216
+ });
217
+
218
+ test('registry: the first publish creates the module with the publisher as author', async () => {
219
+ const db = fakeDb();
220
+ const r = await store.publishVersion(input(), { db: db.pool });
221
+ assert.equal(r.ok, true);
222
+ assert.equal(r.created, true);
223
+ assert.equal(db.state.modules.weather.author_id, 7);
224
+ assert.deepEqual(db.log.filter((l) => /BEGIN|COMMIT|ROLLBACK/.test(l)), ['BEGIN', 'COMMIT']);
225
+ });
226
+
227
+ test('registry: only the author can publish a later version', async () => {
228
+ const db = fakeDb({ modules: { weather: { module_key: 'weather', author_id: 7, status: 'listed' } }, versions: [] });
229
+ const r = await store.publishVersion(input({ builderId: 8 }), { db: db.pool });
230
+ assert.equal(r.ok, false);
231
+ assert.equal(r.status, 403);
232
+ assert.equal(r.code, 'not_module_author');
233
+ assert.equal(db.state.versions.length, 0);
234
+ });
235
+
236
+ test('registry: a delisted module takes no new version (ADR 0338 D2)', async () => {
237
+ const db = fakeDb({ modules: { weather: { module_key: 'weather', author_id: 7, status: 'delisted' } }, versions: [] });
238
+ assert.equal((await store.publishVersion(input(), { db: db.pool })).code, 'module_delisted');
239
+ });
240
+
241
+ test('registry: a version is published once, and never older than the latest', async () => {
242
+ const state = { modules: { weather: { module_key: 'weather', author_id: 7, status: 'listed' } },
243
+ versions: [{ module_key: 'weather', version: '1.2.0' }] };
244
+ assert.equal((await store.publishVersion(input(), { db: fakeDb(state).pool })).code, 'version_exists');
245
+ assert.equal((await store.publishVersion(input({ version: '1.1.9' }), { db: fakeDb(state).pool })).code, 'version_not_newer');
246
+ const ok = await store.publishVersion(input({ version: '1.10.0' }), { db: fakeDb(state).pool });
247
+ assert.equal(ok.ok, true, 'numeric, not string, ordering: 1.10.0 > 1.2.0');
248
+ });
249
+
250
+ test('registry: if putting the file in place fails, the row is rolled back', async () => {
251
+ const db = fakeDb();
252
+ await assert.rejects(
253
+ store.publishVersion(input(), { db: db.pool, beforeCommit: () => { throw new Error('disk full'); } }),
254
+ /disk full/);
255
+ assert.ok(db.log.includes('ROLLBACK'));
256
+ assert.ok(!db.log.includes('COMMIT'));
257
+ });
258
+
259
+ // ---- the CLI ------------------------------------------------------------------
260
+
261
+ const sink = () => { const lines = []; return { lines, log: (s) => lines.push(String(s)) }; };
262
+
263
+ test('cli: --dry-run packs and reports, and never uploads', async () => {
264
+ const out = sink();
265
+ let uploaded = false;
266
+ const code = await moduleCli.cmdPublish(['weather', '--dry-run'], {
267
+ log: out.log, errlog: out.log, modulesDir: fixture(), upload: async () => { uploaded = true; },
268
+ });
269
+ assert.equal(code, 0);
270
+ assert.equal(uploaded, false);
271
+ assert.ok(out.lines.some((l) => /"weather" 1\.2\.0/.test(l)));
272
+ });
273
+
274
+ test('cli: a module the gate refuses never reaches the network', async () => {
275
+ const out = sink();
276
+ let uploaded = false;
277
+ const code = await moduleCli.cmdPublish(['weather'], {
278
+ log: out.log, errlog: out.log, modulesDir: fixture({ '.env': 'K=1' }), upload: async () => { uploaded = true; },
279
+ });
280
+ assert.equal(code, 1);
281
+ assert.equal(uploaded, false);
282
+ assert.ok(out.lines.some((l) => /REFUSING/.test(l)));
283
+ });
284
+
285
+ test('cli: uploads the packed bytes, and a store refusal is a failure exit with its reason', async () => {
286
+ const root = fixture();
287
+ let sent = null;
288
+ const ok = await moduleCli.cmdPublish(['weather'], {
289
+ log: () => {}, errlog: () => {}, modulesDir: root,
290
+ upload: async (k, body) => { sent = { k, body }; return { ok: true, status: 201, data: { created_module: true, version: { version: '1.2.0' } } }; },
291
+ });
292
+ assert.equal(ok, 0);
293
+ assert.equal(sent.k, 'weather');
294
+ assert.equal((await artifact.verifyModuleArtifact(sent.body, { key: 'weather' })).ok, true, 'what is sent is what the store accepts');
295
+
296
+ const out = sink();
297
+ const refused = await moduleCli.cmdPublish(['weather'], {
298
+ log: () => {}, errlog: out.log, modulesDir: root,
299
+ upload: async () => ({ ok: false, status: 409, data: { error: { code: 'version_exists', message: 'weather 1.2.0 is already published' } } }),
300
+ });
301
+ assert.equal(refused, 1);
302
+ assert.ok(out.lines.some((l) => /HTTP 409/.test(l) && /already published/.test(l)));
303
+ });
304
+
305
+ test('cli: no key is a usage error', async () => {
306
+ assert.equal(await moduleCli.cmdPublish([], { log: () => {}, errlog: () => {} }), 2);
307
+ });
@@ -0,0 +1,121 @@
1
+ // tests/module_store_publish_route.mjs — the seam test for POST
2
+ // /store/modules/:key/versions (task 1004271). module_store_publish.mjs covers each
3
+ // piece; this drives the real handler off the real router, so a lost require or a
4
+ // mis-wired call between verify → stage → registry → commit is a failure here, not in
5
+ // production. Same harness shape as modules_route_wiring.mjs: no port, no real DB —
6
+ // src/bongos/pool.js is replaced in the require cache before the route loads, and the
7
+ // store dir is a temp dir.
8
+ //
9
+ // Run: node tests/module_store_publish_route.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const STORE = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-store-route-'));
20
+ process.env.MODULE_STORE_DIR = STORE;
21
+
22
+ // The registry, as the fake pool sees it.
23
+ const state = { modules: {}, versions: [] };
24
+ const client = {
25
+ async query(sql, params = []) {
26
+ if (/^SELECT module_key, author_id, status FROM store_modules/.test(sql)) {
27
+ const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
28
+ }
29
+ if (/^INSERT INTO store_modules/.test(sql)) {
30
+ state.modules[params[0]] = { module_key: params[0], author_id: params[3], status: 'listed' }; return { rows: [] };
31
+ }
32
+ if (/^SELECT version FROM store_module_versions/.test(sql)) {
33
+ return { rows: state.versions.filter((v) => v.module_key === params[0]) };
34
+ }
35
+ if (/^\s*INSERT INTO store_module_versions/.test(sql)) {
36
+ const row = { id: state.versions.length + 1, module_key: params[0], version: params[1], artifact_path: params[7], published_by: params[8] };
37
+ state.versions.push(row); return { rows: [row] };
38
+ }
39
+ return { rows: [] };
40
+ },
41
+ release() {},
42
+ };
43
+ const poolPath = require.resolve('../src/bongos/pool.js');
44
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => client, query: client.query } } };
45
+
46
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
47
+ const artifact = require('../scripts/gds/module-artifact.js');
48
+
49
+ function handlerFor(router, method, p) {
50
+ const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
51
+ assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
52
+ const stack = layer.route.stack;
53
+ return { handle: stack[stack.length - 1].handle, names: stack.map((s) => s.name) };
54
+ }
55
+
56
+ async function invoke(handle, req) {
57
+ const res = {
58
+ statusCode: 200, body: undefined,
59
+ json(b) { this.body = b; return this; },
60
+ status(c) { this.statusCode = c; return this; },
61
+ fail(code, opts) { this.statusCode = opts.status; this.body = { error: code, ...opts }; return this; },
62
+ };
63
+ let nextErr = null;
64
+ await handle(req, res, (err) => { nextErr = err; });
65
+ if (nextErr) throw nextErr;
66
+ return res;
67
+ }
68
+
69
+ function packed(version = '1.0.0') {
70
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-src-'));
71
+ const dir = path.join(root, 'weather');
72
+ fs.mkdirSync(dir);
73
+ fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
74
+ key: 'weather', title: 'Weather', description: 'd', version, coreVersion: '^1.0.0', contributes: {},
75
+ }));
76
+ return artifact.packModule('weather', { modulesDir: root, modeOf: () => '644' }).tgz;
77
+ }
78
+
79
+ const router = buildModulesRouter();
80
+ const route = handlerFor(router, 'post', '/store/modules/:key/versions');
81
+
82
+ test('the route is gated before it reads a byte: builder, then permission, then the capped parser', async () => {
83
+ assert.deepEqual(route.names.slice(0, 3), ['requireBuilder', route.names[1], 'tarballBody']);
84
+ assert.notEqual(route.names[1], 'tarballBody');
85
+ });
86
+
87
+ test('a good upload is verified, recorded and kept on disk; the audit sees a marker, not the bytes', async () => {
88
+ const req = { params: { key: 'weather' }, body: packed('1.0.0'), builder: { id: 42 } };
89
+ const res = await invoke(route.handle, req);
90
+ assert.equal(res.statusCode, 201, JSON.stringify(res.body));
91
+ assert.equal(res.body.created_module, true);
92
+ assert.equal(state.modules.weather.author_id, 42);
93
+ assert.equal(state.versions[0].artifact_path, 'weather/weather-1.0.0.tgz');
94
+ const onDisk = fs.readFileSync(path.join(STORE, 'weather', 'weather-1.0.0.tgz'));
95
+ assert.equal((await artifact.verifyModuleArtifact(onDisk, { key: 'weather' })).ok, true);
96
+ assert.deepEqual(fs.readdirSync(path.join(STORE, 'weather')), ['weather-1.0.0.tgz'], 'no temp file left behind');
97
+ assert.deepEqual(req.body, { tarball_bytes: onDisk.length });
98
+ });
99
+
100
+ test('a refused publish leaves nothing on disk', async () => {
101
+ const res = await invoke(route.handle, { params: { key: 'weather' }, body: packed('1.0.0'), builder: { id: 42 } });
102
+ assert.equal(res.statusCode, 409);
103
+ assert.equal(res.body.error, 'version_exists');
104
+ assert.deepEqual(fs.readdirSync(path.join(STORE, 'weather')), ['weather-1.0.0.tgz']);
105
+ });
106
+
107
+ test('someone else cannot publish over the author', async () => {
108
+ const res = await invoke(route.handle, { params: { key: 'weather' }, body: packed('1.1.0'), builder: { id: 7 } });
109
+ assert.equal(res.statusCode, 403);
110
+ assert.equal(res.body.error, 'not_module_author');
111
+ assert.ok(!fs.existsSync(path.join(STORE, 'weather', 'weather-1.1.0.tgz')));
112
+ });
113
+
114
+ test('a tarball for another key, garbage, or no body is refused before the registry is touched', async () => {
115
+ const before = state.versions.length;
116
+ assert.equal((await invoke(route.handle, { params: { key: 'forecast' }, body: packed('2.0.0'), builder: { id: 42 } })).body.error, 'key_mismatch');
117
+ assert.equal((await invoke(route.handle, { params: { key: 'weather' }, body: Buffer.from('nope'), builder: { id: 42 } })).statusCode, 422);
118
+ assert.equal((await invoke(route.handle, { params: { key: 'weather' }, body: {}, builder: { id: 42 } })).body.error, 'empty_artifact');
119
+ assert.equal((await invoke(route.handle, { params: { key: 'Bad_Key' }, body: packed(), builder: { id: 42 } })).body.error, 'bad_module_key');
120
+ assert.equal(state.versions.length, before);
121
+ });
@@ -0,0 +1,70 @@
1
+ // tests/module_store_registry_migration.mjs — the module store registry tables
2
+ // (task 1003812, ADR 0338 D1). DB-free: the unit gate has no Postgres, so this
3
+ // pins the rules the owner decided directly in the migration text, where a
4
+ // later edit that loosened one would otherwise land silently. The live apply
5
+ // runs in the PG lane (scripts/verify-gds-only-schema.sh) and at deploy.
6
+ //
7
+ // Run: node tests/module_store_registry_migration.mjs
8
+
9
+ import { test } from 'node:test';
10
+ import { strict as assert } from 'node:assert';
11
+ import { readFileSync } from 'node:fs';
12
+ import { createRequire } from 'node:module';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const FILE = 'core_257_module_store_registry.sql';
19
+ const SQL = readFileSync(path.join(ROOT, 'migrations', FILE), 'utf8');
20
+ // Comments stripped so an assertion can only be satisfied by real DDL.
21
+ const DDL = SQL.replace(/--.*$/gm, '');
22
+
23
+ // The body of one CREATE TABLE, up to its closing ");".
24
+ function table(name) {
25
+ const m = new RegExp(`CREATE TABLE IF NOT EXISTS ${name} \\(([\\s\\S]*?)\\n\\);`).exec(DDL);
26
+ assert.ok(m, `${name} is created`);
27
+ return m[1];
28
+ }
29
+
30
+ test('the three registry tables exist, idempotently', () => {
31
+ for (const t of ['store_modules', 'store_module_versions', 'store_module_prices']) table(t);
32
+ assert.doesNotMatch(DDL, /CREATE TABLE (?!IF NOT EXISTS)/, 'every CREATE TABLE is IF NOT EXISTS');
33
+ assert.doesNotMatch(DDL, /CREATE INDEX (?!IF NOT EXISTS)/, 'every CREATE INDEX is IF NOT EXISTS');
34
+ assert.match(DDL, /INSERT INTO schema_migrations \(version\) VALUES \('core_257_module_store_registry'\)\s+ON CONFLICT \(version\) DO NOTHING;/);
35
+ });
36
+
37
+ test('a price of zero is allowed in both currencies — free modules are first-class', () => {
38
+ const t = table('store_module_prices');
39
+ assert.match(t, /price_cents\s+integer CHECK \(price_cents >= 0\)/);
40
+ assert.match(t, /price_credits\s+integer CHECK \(price_credits >= 0\)/);
41
+ assert.doesNotMatch(t, /price_(cents|credits)[^,\n]*> 0\)/, 'no price column may forbid zero');
42
+ // money OR credits (or both), never neither
43
+ assert.match(t, /CHECK \(price_cents IS NOT NULL OR price_credits IS NOT NULL\)/);
44
+ });
45
+
46
+ test('a version is exact semver and can be published only once per module', () => {
47
+ const t = table('store_module_versions');
48
+ assert.match(t, /version\s+text NOT NULL\s+CHECK \(version ~ '\^\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$'\)/);
49
+ assert.match(t, /UNIQUE \(module_key, version\)/);
50
+ // the package-core.js pins install verifies against
51
+ assert.match(t, /tree_sha256\s+text NOT NULL CHECK \(tree_sha256 ~ '\^\[0-9a-f\]\{64\}\$'\)/);
52
+ assert.match(t, /tarball_sha256\s+text NOT NULL/);
53
+ assert.match(t, /artifact_path\s+text NOT NULL/);
54
+ });
55
+
56
+ test('delisting is a status, never a delete — and the timestamp moves with it (ADR 0338 D2)', () => {
57
+ const t = table('store_modules');
58
+ assert.match(t, /CHECK \(status IN \('listed', 'delisted'\)\)/);
59
+ assert.match(t, /CHECK \(\(status = 'delisted'\) = \(delisted_at IS NOT NULL\)\)/);
60
+ assert.doesNotMatch(DDL, /ON DELETE CASCADE/, 'nothing cascades away a version or price');
61
+ });
62
+
63
+ test('the store surface models no payment, balance or payout (area 8 owns those)', () => {
64
+ assert.doesNotMatch(DDL, /balance|payout|payment|fee/i);
65
+ });
66
+
67
+ test('the migration is forward-safe under the lint gate', () => {
68
+ const lint = require('../scripts/gds/lint-migrations.js');
69
+ assert.deepEqual(lint.scanMigrationSql(SQL).unsafe || [], []);
70
+ });