hecks 3.9.1 → 3.11.0

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 (504) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/pizzas.bluebook +29 -0
  3. data/examples/pizzas/bluebook/pizzas.hecksagon +7 -0
  4. data/exe/hecks +4 -4
  5. data/lib/hecks/adapters/driven/d1.rb +2 -10
  6. data/lib/hecks/adapters/driven/heki.adapter +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +2 -12
  8. data/lib/hecks/adapters/driven/lambda.adapter +1 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +3 -18
  10. data/lib/hecks/adapters/driven/postgres.rb +4 -11
  11. data/lib/hecks/adapters/driven/sqlite.adapter +1 -1
  12. data/lib/hecks/adapters/driven/sqlite.rb +2 -10
  13. data/lib/hecks/adapters/driving/admission.rb +46 -0
  14. data/lib/hecks/adapters/driving/cli/nesting.rb +36 -0
  15. data/lib/hecks/adapters/driving/cli/shorthand.rb +88 -0
  16. data/lib/hecks/adapters/driving/cli.rb +127 -0
  17. data/lib/hecks/adapters/driving/cli_runner/answers.rb +160 -0
  18. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +155 -0
  19. data/lib/hecks/adapters/driving/cli_runner/settling.rb +120 -0
  20. data/lib/hecks/adapters/driving/cli_runner/streaming.rb +93 -0
  21. data/lib/hecks/adapters/driving/cli_runner/suggestions.rb +47 -0
  22. data/lib/hecks/adapters/driving/cli_runner.rb +69 -0
  23. data/lib/hecks/adapters/driving/command_request.rb +121 -0
  24. data/lib/hecks/adapters/driving/handle.rb +214 -0
  25. data/lib/hecks/adapters/driving/json.rb +136 -0
  26. data/lib/hecks/adapters/driving/launcher_options.rb +267 -0
  27. data/lib/hecks/adapters/driving/mcp/properties.rb +49 -0
  28. data/lib/hecks/adapters/driving/mcp/tool_calls.rb +116 -0
  29. data/lib/hecks/adapters/driving/mcp/tool_definitions/auditing.rb +53 -0
  30. data/lib/hecks/adapters/driving/mcp/tool_definitions/calling.rb +71 -0
  31. data/lib/hecks/adapters/driving/mcp/tool_definitions/reading.rb +99 -0
  32. data/lib/hecks/adapters/driving/mcp/tool_definitions.rb +18 -0
  33. data/lib/hecks/adapters/driving/mcp.rb +159 -0
  34. data/lib/hecks/adapters/driving/mcp_guide_cache.rb +78 -0
  35. data/lib/hecks/adapters/driving/mcp_scope/argument_policy.rb +97 -0
  36. data/lib/hecks/adapters/driving/mcp_scope/env_problems.rb +69 -0
  37. data/lib/hecks/adapters/driving/mcp_scope/presenter.rb +83 -0
  38. data/lib/hecks/adapters/driving/mcp_scope.rb +213 -0
  39. data/lib/hecks/adapters/driving/ruby/aggregate_module.rb +171 -0
  40. data/lib/hecks/adapters/driving/ruby/chapter.rb +91 -0
  41. data/lib/hecks/adapters/driving/ruby.rb +83 -0
  42. data/lib/hecks/adapters/driving/usage_cache.rb +144 -0
  43. data/lib/hecks/{doors.rb → adapters/driving.rb} +10 -6
  44. data/lib/hecks/behaviors/expectations/boot.rb +1 -1
  45. data/lib/hecks/bench/ruby_runner.rb +1 -1
  46. data/lib/hecks/bluebook/ask_resolution.rb +130 -0
  47. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +8 -2
  48. data/lib/hecks/bluebook/assembly/contracts/rules.rb +4 -2
  49. data/lib/hecks/bluebook/assembly/contracts/structure.rb +3 -1
  50. data/lib/hecks/bluebook/assembly/marks.rb +1 -0
  51. data/lib/hecks/bluebook/attribute.rb +1 -1
  52. data/lib/hecks/bluebook/behaviour/command.rb +13 -8
  53. data/lib/hecks/bluebook/behaviour/policy.rb +30 -0
  54. data/lib/hecks/bluebook/capabilities/attribute_values.rb +72 -0
  55. data/lib/hecks/bluebook/capabilities.rb +91 -9
  56. data/lib/hecks/bluebook/domain_port.rb +16 -4
  57. data/lib/hecks/bluebook/dsl/adapter_builder.rb +18 -6
  58. data/lib/hecks/bluebook/dsl/binding_proxy.rb +13 -0
  59. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/computed_sources.rb +98 -0
  60. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/fan_out_rows.rb +42 -0
  61. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/projected_fields.rb +14 -2
  62. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/provisions.rb +69 -3
  63. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/with_projections.rb +17 -10
  64. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +5 -0
  65. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  66. data/lib/hecks/bluebook/dsl/command_builder/mutations.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/command_builder/operands.rb +121 -0
  68. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -0
  69. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +10 -1
  70. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +45 -2
  71. data/lib/hecks/bluebook/dsl/policy_builder.rb +47 -10
  72. data/lib/hecks/bluebook/dsl/rule_reference.rb +7 -4
  73. data/lib/hecks/bluebook/dsl/world_builder.rb +1 -1
  74. data/lib/hecks/bluebook/expression/ast_json/emitters.rb +6 -0
  75. data/lib/hecks/bluebook/expression/ast_json.rb +9 -6
  76. data/lib/hecks/bluebook/expression/ast_reader.rb +14 -9
  77. data/lib/hecks/bluebook/expression/projection.json +36 -0
  78. data/lib/hecks/bluebook/expression/resolver/operations.rb +31 -0
  79. data/lib/hecks/bluebook/expression/resolver/parsing.rb +38 -5
  80. data/lib/hecks/bluebook/expression/resolver/scanning.rb +22 -5
  81. data/lib/hecks/bluebook/expression/resolver/values.rb +10 -0
  82. data/lib/hecks/bluebook/expression/resolver.rb +20 -1
  83. data/lib/hecks/bluebook/hecksagon.rb +23 -3
  84. data/lib/hecks/bluebook/lifecycle.rb +22 -3
  85. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +13 -1
  86. data/lib/hecks/bluebook/meta_validator/modes.rb +4 -1
  87. data/lib/hecks/bluebook/meta_validator/readings.rb +18 -1
  88. data/lib/hecks/bluebook/meta_validator/reconstruction/heads.rb +2 -1
  89. data/lib/hecks/bluebook/meta_validator/shapes.rb +5 -0
  90. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +1 -1
  91. data/lib/hecks/bluebook/meta_validator.rb +12 -12
  92. data/lib/hecks/bluebook/model_check/ask_checks.rb +71 -0
  93. data/lib/hecks/bluebook/model_check/policy_checks.rb +2 -1
  94. data/lib/hecks/bluebook/model_check/reference_checks.rb +70 -0
  95. data/lib/hecks/bluebook/model_check.rb +8 -2
  96. data/lib/hecks/bluebook/policy.rb +4 -2
  97. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  98. data/lib/hecks/bluebook/synthesizer.rb +2 -0
  99. data/lib/hecks/bluebook.rb +1 -0
  100. data/lib/hecks/cli/command_table.rb +1 -1
  101. data/lib/hecks/cli/console.rb +2 -1
  102. data/lib/hecks/cli/document.rb +1 -1
  103. data/lib/hecks/cli/launcher_words.rb +4 -4
  104. data/lib/hecks/cli/mcp.rb +4 -4
  105. data/lib/hecks/cli/project_cli/launcher_source.rb +2 -2
  106. data/lib/hecks/cli/project_cli/templates.rb +5 -5
  107. data/lib/hecks/cli/project_cli.rb +9 -6
  108. data/lib/hecks/cli/run.rb +2 -2
  109. data/lib/hecks/cli/smoke_http/checks.rb +4 -1
  110. data/lib/hecks/cli/smoke_http/transport.rb +3 -3
  111. data/lib/hecks/cli/smoke_http.rb +3 -0
  112. data/lib/hecks/codemod.rb +5 -2
  113. data/lib/hecks/corpus/routes.rb +7 -0
  114. data/lib/hecks/corpus/rust.rb +3 -2
  115. data/lib/hecks/deploy/bluebook/deploy.bluebook +124 -2
  116. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  117. data/lib/hecks/deploy/oidc.json +16 -3
  118. data/lib/hecks/doc/reference/rendering.rb +6 -3
  119. data/lib/hecks/forms/app/commands.rb +8 -7
  120. data/lib/hecks/forms/app/queries.rb +2 -1
  121. data/lib/hecks/forms/app.rb +1 -1
  122. data/lib/hecks/forms/record_renderer.rb +1 -1
  123. data/lib/hecks/framework/bluebook/privacy.bluebook +11 -0
  124. data/lib/hecks/fuzzing/concurrent_dispatch.rb +4 -1
  125. data/lib/hecks/fuzzing/failure_triage.rb +2 -1
  126. data/lib/hecks/fuzzing/mutation/run.rb +11 -4
  127. data/lib/hecks/fuzzing/properties/catalog.rb +6 -4
  128. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +18 -11
  129. data/lib/hecks/fuzzing/properties/guards.rb +1 -1
  130. data/lib/hecks/fuzzing/self_consistency/saga_redelivery.rb +1 -1
  131. data/lib/hecks/fuzzing/self_consistency.rb +6 -2
  132. data/lib/hecks/fuzzing/shrinker.rb +3 -1
  133. data/lib/hecks/fuzzing/value_generator.rb +19 -5
  134. data/lib/hecks/gate/stages.yml +4 -10
  135. data/lib/hecks/grammar/evolve.rb +1 -1
  136. data/lib/hecks/grammar/expression_operators.json +372 -0
  137. data/lib/hecks/grammar/operators.rb +3 -1
  138. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +10 -10
  139. data/lib/hecks/hecks/adapters/codebase/inspection/rubycritic.rb +35 -0
  140. data/lib/hecks/hecks/adapters/codebase/inspection.rb +104 -0
  141. data/lib/hecks/hecks/adapters/codebase/publishing.rb +2 -1
  142. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +12 -2
  143. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +2 -1
  144. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +1 -1
  145. data/lib/hecks/hecks/adapters/cost_explorer.rb +8 -5
  146. data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
  147. data/lib/hecks/hecks/adapters/deploy_toolchain/statuses.rb +2 -1
  148. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
  149. data/lib/hecks/hecks/adapters/finding_github.rb +13 -6
  150. data/lib/hecks/hecks/adapters/first_admin.rb +2 -2
  151. data/lib/hecks/hecks/adapters/github_rulesets.rb +6 -3
  152. data/lib/hecks/hecks/adapters/in_process_boot.rb +1 -1
  153. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +1 -1
  154. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +10 -20
  155. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +28 -5
  156. data/lib/hecks/hecks/adapters/local_files.rb +2 -2
  157. data/lib/hecks/hecks/adapters/rehearsal/audit_report.rb +107 -0
  158. data/lib/hecks/hecks/adapters/rehearsal/census.rb +71 -0
  159. data/lib/hecks/hecks/adapters/rehearsal/declaration.rb +133 -0
  160. data/lib/hecks/hecks/adapters/rehearsal/environment.rb +83 -0
  161. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +142 -0
  162. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +96 -0
  163. data/lib/hecks/hecks/adapters/rehearsal/postgres_census.rb +100 -0
  164. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +69 -0
  165. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +129 -0
  166. data/lib/hecks/hecks/adapters/rehearsal/rows.rb +41 -0
  167. data/lib/hecks/hecks/adapters/rehearsal/rust_audit.rb +83 -0
  168. data/lib/hecks/hecks/adapters/rehearsal/subject.rb +104 -0
  169. data/lib/hecks/hecks/adapters/rehearsal.rb +54 -0
  170. data/lib/hecks/hecks/adapters/rehearsal_store.adapter +7 -0
  171. data/lib/hecks/hecks/adapters/rehearsal_store.rb +81 -0
  172. data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
  173. data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
  174. data/lib/hecks/hecks/adapters/terminal.rb +13 -11
  175. data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
  176. data/lib/hecks/hecks/codebase.behaviors +13 -1
  177. data/lib/hecks/hecks/codebase.bluebook +239 -5
  178. data/lib/hecks/hecks/custodian.behaviors +24 -6
  179. data/lib/hecks/hecks/custodian.bluebook +6 -1227
  180. data/lib/hecks/hecks/deploy.behaviors +1 -1
  181. data/lib/hecks/hecks/deploy.world +1 -3
  182. data/lib/hecks/hecks/hecks.behaviors +1 -1
  183. data/lib/hecks/hecks/hecks.hecksagon +61 -18
  184. data/lib/hecks/hecks/hecks.world +11 -10
  185. data/lib/hecks/hecks/rehearsal.bluebook +477 -0
  186. data/lib/hecks/hecks/site.behaviors +1 -1
  187. data/lib/hecks/hecks/site.world +0 -1
  188. data/lib/hecks/hecks/tooling.bluebook +1238 -0
  189. data/lib/hecks/indifferent_key.rb +20 -0
  190. data/lib/hecks/language/adapter.bluebook +13 -3
  191. data/lib/hecks/language/bluebook/aggregate.bluebook +23 -0
  192. data/lib/hecks/language/bluebook/bluebook.bluebook +15 -0
  193. data/lib/hecks/language/bluebook/entity.bluebook +18 -0
  194. data/lib/hecks/language/bluebook/policy.bluebook +9 -1
  195. data/lib/hecks/language/bluebook/query.bluebook +1 -1
  196. data/lib/hecks/language/bluebook/vocabulary.bluebook +11 -4
  197. data/lib/hecks/language/hecksagon/domain_port.bluebook +1 -1
  198. data/lib/hecks/language/hecksagon/hecksagon.bluebook +22 -0
  199. data/lib/hecks/language/oidc.json +10 -0
  200. data/lib/hecks/language/port.bluebook +4 -4
  201. data/lib/hecks/literal.rb +12 -3
  202. data/lib/hecks/ports/key_vault.rb +7 -2
  203. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +21 -10
  204. data/lib/hecks/ports/persistence/plugins/era/postgres_era/boot.rb +4 -2
  205. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +2 -1
  206. data/lib/hecks/ports/persistence/repository_factory.rb +6 -3
  207. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +29 -0
  208. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +1 -1
  209. data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
  210. data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
  211. data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
  212. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  213. data/lib/hecks/projections/deploy/shared.rb +5 -2
  214. data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
  215. data/lib/hecks/projections/model/deviations.rb +2 -2
  216. data/lib/hecks/projections/model.rb +2 -2
  217. data/lib/hecks/projections/oidc.rb +13 -1
  218. data/lib/hecks/projections/shape.rb +5 -2
  219. data/lib/hecks/projections/site/cms_editor/attributes.rb +95 -0
  220. data/lib/hecks/projections/site/cms_editor/blocks.rb +157 -0
  221. data/lib/hecks/projections/site/cms_editor/checks.rb +77 -0
  222. data/lib/hecks/projections/site/cms_editor/clearing.rb +70 -0
  223. data/lib/hecks/projections/site/cms_editor/color.rb +124 -0
  224. data/lib/hecks/projections/site/cms_editor/composition.rb +42 -0
  225. data/lib/hecks/projections/site/cms_editor/destructive.rb +61 -0
  226. data/lib/hecks/projections/site/cms_editor/drafts.rb +102 -0
  227. data/lib/hecks/projections/site/cms_editor/hints.rb +74 -0
  228. data/lib/hecks/projections/site/cms_editor/icons.rb +86 -0
  229. data/lib/hecks/projections/site/cms_editor/key_kinds.rb +31 -0
  230. data/lib/hecks/projections/site/cms_editor/limits.rb +72 -0
  231. data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
  232. data/lib/hecks/projections/site/cms_editor/moment.rb +34 -0
  233. data/lib/hecks/projections/site/cms_editor/palette.rb +115 -0
  234. data/lib/hecks/projections/site/cms_editor/pickers.rb +85 -0
  235. data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
  236. data/lib/hecks/projections/site/cms_editor/preview.rb +91 -0
  237. data/lib/hecks/projections/site/cms_editor/relations.rb +92 -0
  238. data/lib/hecks/projections/site/cms_editor/scale_checks.rb +66 -0
  239. data/lib/hecks/projections/site/cms_editor/scheduling.rb +150 -0
  240. data/lib/hecks/projections/site/cms_editor/schema.rb +161 -0
  241. data/lib/hecks/projections/site/cms_editor/setting.rb +130 -0
  242. data/lib/hecks/projections/site/cms_editor/targets.rb +86 -0
  243. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +36 -0
  244. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +370 -0
  245. data/lib/hecks/projections/site/cms_editor/templates/src/assets.ts.tmpl +98 -0
  246. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +87 -0
  247. data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
  248. data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
  249. data/lib/hecks/projections/site/cms_editor/templates/src/browser/app.css.tmpl +412 -0
  250. data/lib/hecks/projections/site/cms_editor/templates/src/browser/autosave.js.tmpl +144 -0
  251. data/lib/hecks/projections/site/cms_editor/templates/src/browser/blocks.js.tmpl +500 -0
  252. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_doc.js.tmpl +175 -0
  253. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_editor.js.tmpl +418 -0
  254. data/lib/hecks/projections/site/cms_editor/templates/src/browser/epoch.js.tmpl +61 -0
  255. data/lib/hecks/projections/site/cms_editor/templates/src/browser/forms.js.tmpl +210 -0
  256. data/lib/hecks/projections/site/cms_editor/templates/src/browser/lists.js.tmpl +40 -0
  257. data/lib/hecks/projections/site/cms_editor/templates/src/browser/main.js.tmpl +26 -0
  258. data/lib/hecks/projections/site/cms_editor/templates/src/browser/media_picker.js.tmpl +232 -0
  259. data/lib/hecks/projections/site/cms_editor/templates/src/browser/moments.js.tmpl +73 -0
  260. data/lib/hecks/projections/site/cms_editor/templates/src/browser/pickers.js.tmpl +152 -0
  261. data/lib/hecks/projections/site/cms_editor/templates/src/browser/preview.js.tmpl +98 -0
  262. data/lib/hecks/projections/site/cms_editor/templates/src/browser/shell.js.tmpl +140 -0
  263. data/lib/hecks/projections/site/cms_editor/templates/src/browser/toast.js.tmpl +66 -0
  264. data/lib/hecks/projections/site/cms_editor/templates/src/choices.ts.tmpl +99 -0
  265. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +133 -0
  266. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +29 -0
  267. data/lib/hecks/projections/site/cms_editor/templates/src/flash.ts.tmpl +52 -0
  268. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +43 -0
  269. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +177 -0
  270. data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
  271. data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
  272. data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
  273. data/lib/hecks/projections/site/cms_editor/templates/src/names.ts.tmpl +36 -0
  274. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +214 -0
  275. data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
  276. data/lib/hecks/projections/site/cms_editor/templates/src/ui/availability.ts.tmpl +15 -0
  277. data/lib/hecks/projections/site/cms_editor/templates/src/ui/block_list.js.tmpl +213 -0
  278. data/lib/hecks/projections/site/cms_editor/templates/src/ui/blocks.ts.tmpl +275 -0
  279. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +123 -0
  280. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
  281. data/lib/hecks/projections/site/cms_editor/templates/src/ui/detail.ts.tmpl +88 -0
  282. data/lib/hecks/projections/site/cms_editor/templates/src/ui/dialogs.ts.tmpl +43 -0
  283. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +275 -0
  284. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +61 -0
  285. data/lib/hecks/projections/site/cms_editor/templates/src/ui/icons.ts.tmpl +15 -0
  286. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +92 -0
  287. data/lib/hecks/projections/site/cms_editor/templates/src/ui/links.ts.tmpl +100 -0
  288. data/lib/hecks/projections/site/cms_editor/templates/src/ui/moments.ts.tmpl +44 -0
  289. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +63 -0
  290. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +213 -0
  291. data/lib/hecks/projections/site/cms_editor/templates/src/ui/paging.ts.tmpl +82 -0
  292. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pickers.ts.tmpl +110 -0
  293. data/lib/hecks/projections/site/cms_editor/templates/src/ui/preview.ts.tmpl +87 -0
  294. data/lib/hecks/projections/site/cms_editor/templates/src/ui/related.ts.tmpl +97 -0
  295. data/lib/hecks/projections/site/cms_editor/templates/src/ui/schedule.ts.tmpl +160 -0
  296. data/lib/hecks/projections/site/cms_editor/templates/src/ui/shell.ts.tmpl +211 -0
  297. data/lib/hecks/projections/site/cms_editor/templates/src/ui/table.ts.tmpl +151 -0
  298. data/lib/hecks/projections/site/cms_editor/templates/src/ui/theme.js.tmpl +16 -0
  299. data/lib/hecks/projections/site/cms_editor/templates/src/ui/values.ts.tmpl +69 -0
  300. data/lib/hecks/projections/site/cms_editor/templates/src/ui/words.ts.tmpl +97 -0
  301. data/lib/hecks/projections/site/cms_editor/templates/src/ui/world.ts.tmpl +35 -0
  302. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +17 -0
  303. data/lib/hecks/projections/site/cms_editor/theme.rb +29 -0
  304. data/lib/hecks/projections/site/cms_editor/theme_checks.rb +57 -0
  305. data/lib/hecks/projections/site/cms_editor.rb +133 -0
  306. data/lib/hecks/projections/site/role_probe.rb +74 -0
  307. data/lib/hecks/projector/cli_audience.rb +2 -1
  308. data/lib/hecks/projector/cli_projector/listing.rb +1 -1
  309. data/lib/hecks/projector/cli_projector/option_specs.rb +1 -1
  310. data/lib/hecks/projector/docs_projector/closing.rb +1 -1
  311. data/lib/hecks/projector/docs_projector.rb +1 -1
  312. data/lib/hecks/projector/exporter/bindings.rb +40 -10
  313. data/lib/hecks/projector/exporter/capability_values.rb +55 -0
  314. data/lib/hecks/projector/narrate_projector/reactions.rb +1 -1
  315. data/lib/hecks/projector/target.rb +11 -0
  316. data/lib/hecks/projector.rb +32 -5
  317. data/lib/hecks/quality_control/adapters/github_checks.rb +12 -8
  318. data/lib/hecks/quality_control/adapters/github_issues.rb +8 -4
  319. data/lib/hecks/quality_control/cli/qa_open_pr.rb +2 -2
  320. data/lib/hecks/quality_control/cli/qa_sweep/checks/shrinking.rb +1 -1
  321. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +1 -1
  322. data/lib/hecks/quality_control/cli/qa_sweep.rb +1 -1
  323. data/lib/hecks/quality_control/quality_control.bluebook +6 -2
  324. data/lib/hecks/release/approval.rb +64 -0
  325. data/lib/hecks/router/{door.rb → aggregate_reader.rb} +5 -4
  326. data/lib/hecks/router/namespace_installer.rb +16 -16
  327. data/lib/hecks/router.rb +1 -1
  328. data/lib/hecks/runtime/best_effort.rb +22 -0
  329. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -2
  330. data/lib/hecks/runtime/command_interpreter.rb +3 -2
  331. data/lib/hecks/runtime/command_rules/emission.rb +1 -1
  332. data/lib/hecks/runtime/dispatcher.rb +4 -4
  333. data/lib/hecks/runtime/entity_element/location.rb +1 -1
  334. data/lib/hecks/runtime/event.rb +5 -0
  335. data/lib/hecks/runtime/identity.rb +7 -6
  336. data/lib/hecks/runtime/interpreting.rb +2 -1
  337. data/lib/hecks/runtime/invocation_builder.rb +1 -1
  338. data/lib/hecks/runtime/loader/boot_steps.rb +8 -5
  339. data/lib/hecks/runtime/loader.rb +10 -10
  340. data/lib/hecks/runtime/policy_interpreter/arguments.rb +7 -4
  341. data/lib/hecks/runtime/policy_interpreter/fan_out.rb +3 -3
  342. data/lib/hecks/runtime/policy_interpreter.rb +6 -6
  343. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -1
  344. data/lib/hecks/runtime/reaction_invocation.rb +1 -1
  345. data/lib/hecks/runtime/rebuild_sweep.rb +11 -1
  346. data/lib/hecks/runtime/registry/ask_verification.rb +35 -0
  347. data/lib/hecks/runtime/registry/hecksagon_rules.rb +10 -0
  348. data/lib/hecks/runtime/registry/merging.rb +1 -1
  349. data/lib/hecks/runtime/registry/providers.rb +16 -0
  350. data/lib/hecks/runtime/registry/read_repository.rb +6 -4
  351. data/lib/hecks/runtime/registry/verification.rb +4 -1
  352. data/lib/hecks/runtime/registry/world_defaults.rb +29 -0
  353. data/lib/hecks/runtime/registry.rb +1 -1
  354. data/lib/hecks/runtime/saga_interpreter/compensation.rb +1 -1
  355. data/lib/hecks/runtime/saga_interpreter/delivery.rb +4 -4
  356. data/lib/hecks/runtime/saga_interpreter/transitions.rb +1 -1
  357. data/lib/hecks/runtime/saga_interpreter.rb +3 -3
  358. data/lib/hecks/runtime/tenant_check.rb +5 -4
  359. data/lib/hecks/runtime/value/coercion.rb +2 -2
  360. data/lib/hecks/runtime/value/validation.rb +16 -4
  361. data/lib/hecks/runtime.rb +9 -9
  362. data/lib/hecks/rust_build/project_rust/target_ir.rb +1 -1
  363. data/lib/hecks/rust_build/wasm.rb +1 -1
  364. data/lib/hecks/rust_build/wasm_browser.rb +1 -1
  365. data/lib/hecks/rust_build.rb +11 -0
  366. data/lib/hecks/served_options.rb +53 -0
  367. data/lib/hecks/site/bluebook/site.bluebook +29 -1
  368. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  369. data/lib/hecks/storehouse/audit.rb +1 -1
  370. data/lib/hecks/storehouse/dispatching.rb +8 -6
  371. data/lib/hecks/storehouse/introspection.rb +2 -2
  372. data/lib/hecks/storehouse/querying.rb +6 -4
  373. data/lib/hecks/storehouse.rb +4 -4
  374. data/lib/hecks/tools/comment_style/source_file.rb +4 -1
  375. data/lib/hecks/tools/deploy_recipe_lint/fixtures.rb +4 -1
  376. data/lib/hecks/tools/lanes/rows.rb +6 -0
  377. data/lib/hecks/tools/oidc_manifests.rb +4 -5
  378. data/lib/hecks/tools/regeneration_run.rb +11 -4
  379. data/lib/hecks/tools/site_routes/cli.rb +1 -1
  380. data/lib/hecks/tools/site_routes/editor_files.rb +45 -0
  381. data/lib/hecks/tools/site_routes.rb +14 -7
  382. data/lib/hecks/tools/tenant_provisioning.rb +2 -2
  383. data/lib/hecks/tools/tools_doc.rb +4 -4
  384. data/lib/hecks/version.rb +1 -1
  385. data/lib/hecks/vocabulary.rb +4 -4
  386. data/lib/hecks.rb +15 -15
  387. data/lib/rubocop/cop/hecks/env_dump.rb +69 -0
  388. data/lib/rubocop/cop/hecks/monotonic_duration.rb +53 -0
  389. data/lib/rubocop/cop/hecks/no_wall_clock_in_runtime.rb +34 -0
  390. data/lib/rubocop/cop/hecks/raw_shell_interpolation.rb +79 -0
  391. data/lib/rubocop/cop/hecks/sleep_in_spec.rb +34 -0
  392. data/lib/rubocop/cop/hecks/swallowed_rescue.rb +37 -0
  393. data/lib/rubocop/cop/hecks/symbol_string_key_mix.rb +50 -0
  394. data/lib/rubocop/cop/hecks/untyped_raise.rb +37 -0
  395. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  396. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
  397. data/prebuilt/verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json +1 -0
  398. data/rust/.gitignore +6 -0
  399. data/rust/Cargo.toml +9 -1
  400. data/rust/build/src/binding_pass.rs +103 -0
  401. data/rust/build/src/cargo_sync.rs +6 -0
  402. data/rust/build/src/lineage_pass.rs +3 -3
  403. data/rust/build/src/main.rs +17 -5
  404. data/rust/build/src/pipeline.rs +48 -5
  405. data/rust/build/src/seams_pass.rs +123 -0
  406. data/rust/build/src/translation_pass.rs +167 -0
  407. data/rust/codegen/src/asks.rs +103 -0
  408. data/rust/codegen/src/bridging.rs +1 -1
  409. data/rust/codegen/src/commands.rs +13 -8
  410. data/rust/codegen/src/constraints.rs +25 -0
  411. data/rust/codegen/src/domain_generator.rs +11 -9
  412. data/rust/codegen/src/expr_emitter.rs +13 -0
  413. data/rust/codegen/src/json_codec.rs +54 -15
  414. data/rust/codegen/src/main.rs +2 -1
  415. data/rust/codegen/src/mutations.rs +174 -4
  416. data/rust/codegen/src/naming.rs +30 -0
  417. data/rust/codegen/src/queries.rs +11 -2
  418. data/rust/codegen/src/reactions.rs +1 -1
  419. data/rust/codegen/src/types.rs +166 -8
  420. data/rust/host/HECKS_RELEASE +1 -1
  421. data/rust/host/src/api.rs +4 -4
  422. data/rust/host/src/bin/validate_harness.rs +43 -0
  423. data/rust/host/src/boundary_fuzz/expr_json.rs +2 -2
  424. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  425. data/rust/host/src/boundary_fuzz/server.rs +1 -1
  426. data/rust/host/src/checkout.rs +35 -19
  427. data/rust/host/src/commerce_ir.rs +408 -2
  428. data/rust/host/src/expr_json.rs +118 -0
  429. data/rust/host/src/mint.rs +1 -1
  430. data/rust/host/src/payments/tests.rs +27 -1
  431. data/rust/host/src/payments.rs +12 -4
  432. data/rust/host/src/query_step.rs +22 -4
  433. data/rust/host/src/reference_validate.rs +653 -108
  434. data/rust/host/src/resend.rs +2 -0
  435. data/rust/host/src/server.rs +19 -6
  436. data/rust/host/src/ui_schema.rs +132 -5
  437. data/rust/host/src/web/newsletter.rs +7 -8
  438. data/rust/host/src/web/newsletter_send.rs +38 -13
  439. data/rust/host/src/web/registrations.rs +229 -26
  440. data/rust/host/src/web.rs +3 -3
  441. data/rust/parser/src/emit.rs +38 -6
  442. data/rust/parser/src/expr/ast_json.rs +7 -1
  443. data/rust/parser/src/expr/resolver.rs +171 -6
  444. data/rust/parser/src/ir.rs +7 -0
  445. data/rust/parser/src/keywords.rs +24 -0
  446. data/rust/parser/src/main.rs +3 -0
  447. data/rust/parser/src/parse/aggregate.rs +2 -1
  448. data/rust/parser/src/parse/chapter.rs +2 -2
  449. data/rust/parser/src/parse/command.rs +2 -0
  450. data/rust/parser/src/parse/hecksagon.rs +5 -2
  451. data/rust/parser/src/parse/lifecycle.rs +81 -0
  452. data/rust/parser/src/parse/mod.rs +15 -1
  453. data/rust/parser/src/parse/policy.rs +13 -2
  454. data/rust/parser/src/parse/value_object.rs +13 -8
  455. data/rust/parser/src/ruby_value.rs +1 -1
  456. data/rust/src/exemplar/commands.rs +4 -4
  457. data/rust/src/exemplar/constraints.rs +1 -1
  458. data/rust/src/exemplar/json.rs +43 -1
  459. data/rust/src/kernel/cli.rs +7 -5
  460. data/rust/src/kernel/dispatch.rs +2 -2
  461. data/rust/src/kernel/dispatch_guard_tests.rs +1 -1
  462. data/rust/src/kernel/expr.rs +73 -4
  463. data/rust/src/kernel/expression_operators/arithmetic.rs +130 -5
  464. data/rust/src/kernel/expression_operators/presence.rs +41 -3
  465. data/rust/src/kernel/expression_operators/text.rs +26 -2
  466. data/rust/src/kernel/json.rs +15 -0
  467. data/rust/src/kernel/mod.rs +1 -1
  468. data/rust/src/kernel/orchestrate.rs +54 -5
  469. data/rust/src/kernel/orchestrate_guard_tests.rs +67 -0
  470. data/rust/src/kernel/pattern.rs +60 -0
  471. data/rust/src/kernel/reference_lookup.rs +90 -7
  472. metadata +174 -36
  473. data/lib/hecks/doors/cli_door/nesting.rb +0 -34
  474. data/lib/hecks/doors/cli_door/shorthand.rb +0 -85
  475. data/lib/hecks/doors/cli_door.rb +0 -124
  476. data/lib/hecks/doors/cli_runner/answers.rb +0 -138
  477. data/lib/hecks/doors/cli_runner/resolution.rb +0 -146
  478. data/lib/hecks/doors/cli_runner/settling.rb +0 -115
  479. data/lib/hecks/doors/cli_runner/streaming.rb +0 -90
  480. data/lib/hecks/doors/cli_runner/suggestions.rb +0 -43
  481. data/lib/hecks/doors/cli_runner.rb +0 -66
  482. data/lib/hecks/doors/command_request.rb +0 -119
  483. data/lib/hecks/doors/handle.rb +0 -208
  484. data/lib/hecks/doors/json_door.rb +0 -132
  485. data/lib/hecks/doors/launcher_options.rb +0 -247
  486. data/lib/hecks/doors/mcp_door/properties.rb +0 -47
  487. data/lib/hecks/doors/mcp_door/tool_calls.rb +0 -113
  488. data/lib/hecks/doors/mcp_door/tool_definitions/auditing.rb +0 -51
  489. data/lib/hecks/doors/mcp_door/tool_definitions/calling.rb +0 -69
  490. data/lib/hecks/doors/mcp_door/tool_definitions/reading.rb +0 -97
  491. data/lib/hecks/doors/mcp_door/tool_definitions.rb +0 -15
  492. data/lib/hecks/doors/mcp_door.rb +0 -156
  493. data/lib/hecks/doors/mcp_door_scope/argument_policy.rb +0 -92
  494. data/lib/hecks/doors/mcp_door_scope/env_problems.rb +0 -55
  495. data/lib/hecks/doors/mcp_door_scope/presenter.rb +0 -78
  496. data/lib/hecks/doors/mcp_door_scope.rb +0 -201
  497. data/lib/hecks/doors/mcp_guide_cache.rb +0 -75
  498. data/lib/hecks/doors/ruby_door/aggregate_door.rb +0 -162
  499. data/lib/hecks/doors/ruby_door/chapter.rb +0 -89
  500. data/lib/hecks/doors/ruby_door.rb +0 -80
  501. data/lib/hecks/doors/usage_cache.rb +0 -139
  502. data/prebuilt/0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal +0 -0
  503. data/prebuilt/6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal +0 -0
  504. data/prebuilt/verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json +0 -1
@@ -0,0 +1,133 @@
1
+ __BANNER__
2
+
3
+ // Running one command and judging what came of it. The host answers HTTP 200 whether or not the
4
+ // domain refused, and its `refusals` list can carry entries replayed from history, so the outcome is
5
+ // judged by the state that comes back, never by what was sent: a command may set fields from
6
+ // arguments that are not fields (a clearing argument), so the arguments are no evidence. The command
7
+ // took effect when the instance's state changed, and for a lifecycle move when it reached the state
8
+ // the move names. When the state did not change at all (a repeated command, a move to the state it
9
+ // is already in) the answer's last refusal decides, and only if it names this verb.
10
+ import type { Answer, HostClient } from "@hecks/client";
11
+ import { DomainUnavailable } from "@hecks/client";
12
+
13
+ import { qualified } from "./names.ts";
14
+ import type { Aggregate, Command } from "./schema.ts";
15
+ import { problemsOf } from "./ui/block_list.js";
16
+ import type { Problem } from "./ui/blocks.ts";
17
+
18
+ export type State = Record<string, unknown>;
19
+ export type Outcome = { ok: true; id: string } | { ok: false; message: string; field?: string; problems?: Problem[] };
20
+
21
+ /** The text an identity value holds: the part of a value object, or the plain value. */
22
+ export function leaf(value: unknown): string | null {
23
+ if (value && typeof value === "object") return leaf(Object.values(value as object).find((part) => part !== null && part !== undefined));
24
+ return value === null || value === undefined ? null : String(value);
25
+ }
26
+
27
+ /** The id an instance is addressed by. */
28
+ export const idOf = (agg: Aggregate, state: State | undefined): string | null => leaf(state?.[agg.identity]);
29
+
30
+ /** The state of one instance in an answer, or undefined when the answer holds none by that id. */
31
+ export function instanceIn(client: HostClient, answer: Answer, agg: Aggregate, id: string): State | undefined {
32
+ return client.instancesOf(answer, qualified(agg)).find(([key]) => key === id)?.[1];
33
+ }
34
+
35
+ /**
36
+ * A client that asks the host to read once and remembers the answer, for a request that only reads:
37
+ * the page and the navigation's counts then come from one read of the host.
38
+ */
39
+ export function reading(client: HostClient): HostClient {
40
+ let held: Promise<Answer> | undefined;
41
+ return new Proxy(client, {
42
+ get(target, key) {
43
+ if (key === "read") return () => (held ??= target.read());
44
+ const member = Reflect.get(target, key, target);
45
+ return typeof member === "function" ? member.bind(target) : member;
46
+ },
47
+ });
48
+ }
49
+
50
+ /**
51
+ * A value as it is compared: the keys of an object in order and each null left out, since one answer
52
+ * of a host may echo a slot that was never set as null where another leaves it out, in an order of its own.
53
+ */
54
+ function canonical(value: unknown): unknown {
55
+ if (Array.isArray(value)) return value.map(canonical);
56
+ if (value === null || typeof value !== "object") return value;
57
+ const held = Object.entries(value as object).filter(([, part]) => part !== null);
58
+ return Object.fromEntries(held.sort(([a], [b]) => (a < b ? -1 : 1)).map(([key, part]) => [key, canonical(part)]));
59
+ }
60
+
61
+ const sameState = (a: State | undefined, b: State | undefined): boolean => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
62
+
63
+ /** Whether the host's refusal names this aggregate's verb: by its chapter too when the editor spans several. */
64
+ function refusedLast(answer: Answer, agg: Aggregate, command: Command): boolean {
65
+ const last = (answer.refusals ?? []).at(-1);
66
+ if (last === undefined) return false;
67
+ const verb = String(last.verb ?? "");
68
+ return verb.replace(/^.*::/, "") === `${agg.name}.${command.name}` && (!agg.chapter || !verb.includes("::") || verb.startsWith(`${agg.chapter}::`));
69
+ }
70
+
71
+ /** Whether `after` shows the command took effect, given the instance as it was `before`. */
72
+ export function applied(agg: Aggregate, command: Command, before: State | undefined, after: State | undefined, answer: Answer): boolean {
73
+ if (!after) return false;
74
+ if (sameState(before, after)) return !refusedLast(answer, agg, command);
75
+ const move = agg.lifecycle?.transitions.find((transition) => transition.verb === command.name);
76
+ return move && agg.lifecycle ? after[agg.lifecycle.field] === move.to : true;
77
+ }
78
+
79
+ const label = (name: string): string => name.replace(/_/g, " ");
80
+
81
+ /** The command's attribute a value object's name stands for, to name the offending field. */
82
+ function fieldOf(command: Command, type: string): string {
83
+ const held = command.attributes.find((attr) => attr.type === type);
84
+ return held ? label(held.name) : type;
85
+ }
86
+
87
+ /**
88
+ * The words for a refusal. The host words a given as `<Command> refused — <condition>`, which reads
89
+ * as though the condition were the reason, so it is shown as the condition that was not met; an
90
+ * invariant is shown as the field and the rule, without the value that offended (raw JSON).
91
+ */
92
+ export function refusalText(refusal: { kind?: string; error?: string } | undefined, command: Command): string {
93
+ const error = refusal?.error ?? "";
94
+ const refused = /^.+? refused — (.+)$/s.exec(error);
95
+ if (refusal?.kind === "GivenNotMet" && refused) return `Not allowed unless ${refused[1]}.`;
96
+ if (refusal?.kind === "EnsuresNotMet" && refused) return `Not allowed: the result would break this rule: ${refused[1]}.`;
97
+ const invariant = /^(.+?) invariant violated — (.+?)(?: \(given .*\))?$/s.exec(error);
98
+ if (invariant) return `${fieldOf(command, invariant[1])}: ${invariant[2]}.`;
99
+ if (refusal?.kind === "InvariantViolation" && refused) return `${refused[1]}.`;
100
+ return error || `The domain did not apply ${command.name}.`;
101
+ }
102
+
103
+ /** The name of the command's attribute a refused invariant is about, so its input can be marked; undefined when none. */
104
+ export function refusedField(refusal: { kind?: string; error?: string } | undefined, command: Command): string | undefined {
105
+ const invariant = /^(.+?) invariant violated — /s.exec(refusal?.error ?? "");
106
+ return invariant ? command.attributes.find((attr) => attr.type === invariant[1])?.name : undefined;
107
+ }
108
+
109
+ /** The words for a command that changed nothing: the host's last refusal, or a plain statement. */
110
+ export function refusalMessage(answer: Answer, command: Command): string {
111
+ return refusalText((answer.refusals ?? []).at(-1), command);
112
+ }
113
+
114
+ /**
115
+ * Dispatches `command` as the role it declares and judges the outcome. `to` is the instance a
116
+ * non-creating command acts on; a creating command's instance is addressed by what it was given.
117
+ */
118
+ export async function runCommand(client: HostClient, agg: Aggregate, command: Command, args: State, to?: string): Promise<Outcome> {
119
+ try {
120
+ const id = to ?? idOf(agg, args);
121
+ const before = id ? instanceIn(client, await client.read(), agg, id) : undefined;
122
+ const answer = await client.dispatch(`${qualified(agg)}.${command.name}`, args, to, command.role ?? undefined);
123
+ const after = id ? instanceIn(client, answer, agg, id) : undefined;
124
+ if (id && applied(agg, command, before, after, answer)) return { ok: true, id };
125
+ const last = (answer.refusals ?? []).at(-1);
126
+ const problems: Problem[] = problemsOf(last?.error, command.attributes, args, agg.valueObjects);
127
+ const field = refusedField(last, command);
128
+ return { ok: false, message: refusalMessage(answer, command), field, problems };
129
+ } catch (err) {
130
+ if (err instanceof DomainUnavailable) return { ok: false, message: `The domain could not be reached: ${err.message}` };
131
+ throw err;
132
+ }
133
+ }
@@ -0,0 +1,29 @@
1
+ __BANNER__
2
+
3
+ // What the editor is built from: the project's Editor row, defaults filled. Change the row and run
4
+ // `hecks site site_projection.project_site`; with `--check` it fails when this file is out of date.
5
+
6
+ export const EDITOR = {
7
+ title: __TITLE__,
8
+ brand: __BRAND__,
9
+ logo: __LOGO__,
10
+ basePath: __BASE_PATH__,
11
+ ssoPath: __SSO_PATH__,
12
+ login: __LOGIN__,
13
+ sessionCookie: __SESSION_COOKIE__,
14
+ sessionMaxAge: 28800,
15
+ flashMaxAge: 60,
16
+ hostCookie: __HOST_COOKIE__,
17
+ hostEnv: __HOST_ENV__,
18
+ hostDefault: __HOST_DEFAULT__,
19
+ roles: __ROLES__,
20
+ mediaDir: __MEDIA_DIR__,
21
+ mediaMaxBytes: __MEDIA_MAX_BYTES__,
22
+ pageSize: __PAGE_SIZE__,
23
+ /** Where a record is shown on the site, with `{key}`, `{kind}`, `{slug}` and `{id}`; null for no preview. */
24
+ preview: __PREVIEW__ as string | null,
25
+ accountPath: "/accounts/me",
26
+ membersPath: "/members",
27
+ rememberMs: 60000,
28
+ timeoutMs: 5000,
29
+ } as const;
@@ -0,0 +1,52 @@
1
+ __BANNER__
2
+
3
+ // A notice that survives one redirect: "Published." after a command succeeds and the person is sent
4
+ // to the page that shows the result. It is carried in a short-lived cookie that is signed (so a
5
+ // visitor cannot make the editor say something), holds only a kind and a sentence, and is read
6
+ // once: the page that shows it clears it in the same response. There is no server-side state.
7
+ // The sentence is escaped wherever it is shown; it is encoded as base64url so no character of it can
8
+ // break out of the cookie.
9
+ import { createHmac, timingSafeEqual } from "node:crypto";
10
+
11
+ import { EDITOR } from "./config.ts";
12
+
13
+ export type FlashKind = "success" | "error" | "info";
14
+
15
+ export interface Flash {
16
+ kind: FlashKind;
17
+ message: string;
18
+ }
19
+
20
+ const SUFFIX = ":editor-flash";
21
+ const NAME = `${EDITOR.sessionCookie}_flash`;
22
+ const ATTRIBUTES = `Path=${EDITOR.basePath}; HttpOnly; SameSite=Lax`;
23
+ const KINDS: readonly string[] = ["success", "error", "info"];
24
+
25
+ const sign = (secret: string, payload: string): string => createHmac("sha256", secret + SUFFIX).update(payload).digest("base64url");
26
+
27
+ /** The `Set-Cookie` value that carries `flash` to the next page. */
28
+ export function flashCookie(flash: Flash, secret: string, now: () => number = Date.now): string {
29
+ const payload = Buffer.from(JSON.stringify({ k: flash.kind, m: flash.message.slice(0, 300), t: Math.floor(now() / 1000) })).toString("base64url");
30
+ const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
31
+ return `${NAME}=${payload}.${sign(secret, payload)}; ${ATTRIBUTES}; Max-Age=${EDITOR.flashMaxAge}${secure}`;
32
+ }
33
+
34
+ /** The `Set-Cookie` value that spends the notice. */
35
+ export const spentFlash = (): string => `${NAME}=; ${ATTRIBUTES}; Max-Age=0`;
36
+
37
+ /** The notice a `Cookie` header carries, or null when it has none, it was forged or it has expired. */
38
+ export function readFlash(header: string | null, secret: string, now: () => number = Date.now): Flash | null {
39
+ const raw = header?.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${NAME}=`))?.slice(NAME.length + 1);
40
+ const [payload, signature] = raw?.split(".") ?? [];
41
+ if (!payload || !signature || !secret) return null;
42
+ const expected = Buffer.from(sign(secret, payload));
43
+ const given = Buffer.from(signature);
44
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null;
45
+ try {
46
+ const found = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
47
+ const fresh = typeof found.t === "number" && now() / 1000 - found.t <= EDITOR.flashMaxAge && now() / 1000 >= found.t - 5;
48
+ return fresh && KINDS.includes(found.k) && typeof found.m === "string" ? { kind: found.k, message: found.m } : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
@@ -0,0 +1,43 @@
1
+ __BANNER__
2
+
3
+ // The domain host, reached the way every server-side caller reaches it: @hecks/client's HostClient
4
+ // posting to /dispatch from this machine. The host accepts that protocol from a loopback peer only,
5
+ // which is why this server, not the browser, talks to it; it authenticates each person itself
6
+ // (auth/) before any of these calls is made on their behalf.
7
+ import { DomainRefusal, HostClient, rowsOf } from "@hecks/client";
8
+ import type { Answer } from "@hecks/client";
9
+
10
+ import { EDITOR } from "./config.ts";
11
+ import { SCHEMA } from "./schema.ts";
12
+
13
+ export interface HostOptions {
14
+ /** The host's address, when not read from the environment variable the Editor row names. */
15
+ url?: string;
16
+ fetch?: typeof fetch;
17
+ /** The domain to address, when it is not the chapter the editor edits (pictures kept in another chapter). */
18
+ domain?: string;
19
+ }
20
+
21
+ /** The host's base URL, without a trailing slash. */
22
+ export function hostUrl(options: HostOptions = {}): string {
23
+ return (options.url ?? (process.env[EDITOR.hostEnv] || EDITOR.hostDefault)).replace(/\/+$/, "");
24
+ }
25
+
26
+ /** A client for the domain this editor edits. */
27
+ export function hostClient(options: HostOptions = {}): HostClient {
28
+ return new HostClient({ domain: options.domain ?? SCHEMA.domain, url: hostUrl(options), fetch: options.fetch, timeoutMs: EDITOR.timeoutMs });
29
+ }
30
+
31
+ /**
32
+ * The rows a query answered. A host that has no such query for its running domain (a declared
33
+ * query the Rust host does not generate) answers with no entry at all, which `rowsOf` reads as
34
+ * null; that is a refusal here, not an empty list, or a list page, a picker or the picture
35
+ * listing would show nothing while the records are there.
36
+ *
37
+ * @throws {DomainRefusal} when the host refused the question or did not answer it
38
+ */
39
+ export function rowsAnswered(answer: Answer, name: string): Record<string, unknown>[] {
40
+ const rows = rowsOf(answer, name);
41
+ if (rows === null) throw new DomainRefusal("QueryNotAnswered", `The host did not answer ${name}.`);
42
+ return rows as Record<string, unknown>[];
43
+ }
@@ -0,0 +1,177 @@
1
+ __BANNER__
2
+
3
+ // The pictures: `POST <base>/media` takes an upload, `GET <base>/media` lists the registered
4
+ // pictures, and `GET <base>/media/<key>` serves one. Every request reaching here is already
5
+ // authenticated by app.ts; nothing is read or written before that.
6
+ //
7
+ // An upload is checked before anything is kept: a bounded read of the body, an allow-list of raster
8
+ // types decided by the picture's own first bytes, a size cap from the Editor row, and alt text. The
9
+ // bytes then go through the storage port under a key made from their own digest, and the domain's
10
+ // register-picture command records the picture (key, alt text, type, size). The domain never holds
11
+ // the bytes, and a client-supplied filename is never used.
12
+ import { createHash } from "node:crypto";
13
+
14
+ import { DomainRefusal } from "@hecks/client";
15
+ import type { HostClient } from "@hecks/client";
16
+
17
+ import { instanceIn, leaf, runCommand } from "../commands.ts";
18
+ import type { State } from "../commands.ts";
19
+ import { EDITOR } from "../config.ts";
20
+ import { hostClient, rowsAnswered } from "../host.ts";
21
+ import type { HostOptions } from "../host.ts";
22
+ import { mayEdit, qualified } from "../names.ts";
23
+ import { SCHEMA } from "../schema.ts";
24
+ import type { Aggregate, Media, MediaField } from "../schema.ts";
25
+ import { boundaryOf, parts } from "./multipart.ts";
26
+ import { EXTENSION_OF, KEY_PATTERN, TYPE_OF, sizeOf, sniff } from "./sniff.ts";
27
+ import { diskStorage } from "./storage.ts";
28
+ import type { MediaStorage } from "./storage.ts";
29
+
30
+ /** The room the form's framing takes beyond the picture's own size cap. */
31
+ const OVERHEAD_BYTES = 65_536;
32
+
33
+ const json = (value: unknown, status = 200): Response =>
34
+ new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" } });
35
+
36
+ const refuse = (status: number, error: string): Response => json({ error }, status);
37
+
38
+ /** The body, or null when it is larger than `limit`: no more than `limit` bytes are ever held. */
39
+ async function readBounded(request: Request, limit: number): Promise<Uint8Array | null> {
40
+ const declared = Number(request.headers.get("content-length") ?? 0);
41
+ if (declared > limit) return null;
42
+ const reader = request.body?.getReader();
43
+ const chunks: Uint8Array[] = [];
44
+ let size = 0;
45
+ while (reader) {
46
+ const { done, value } = await reader.read();
47
+ if (done) break;
48
+ size += value.length;
49
+ if (size > limit) {
50
+ await reader.cancel();
51
+ return null;
52
+ }
53
+ chunks.push(value);
54
+ }
55
+ return Buffer.concat(chunks);
56
+ }
57
+
58
+ /** A value as the command takes it: in the value object's part when the attribute is one. */
59
+ function sent(field: MediaField, value: string | number): unknown {
60
+ return field.wrap ? { [field.wrap]: value } : value;
61
+ }
62
+
63
+ function recordOf(media: Media, key: string, alt: string, mime: string, bytes: Uint8Array): State {
64
+ const record: State = {
65
+ [media.fields.key.name]: sent(media.fields.key, key),
66
+ [media.fields.alt.name]: sent(media.fields.alt, alt),
67
+ [media.fields.mime.name]: sent(media.fields.mime, mime),
68
+ };
69
+ const size = sizeOf(bytes, mime);
70
+ const { width, height } = media.fields;
71
+ if (size && width) record[width.name] = sent(width, size.width);
72
+ if (size && height) record[height.name] = sent(height, size.height);
73
+ return record;
74
+ }
75
+
76
+ function picture(storage: MediaStorage, media: Media, state: State) {
77
+ const key = leaf(state[media.fields.key.name]);
78
+ return key === null ? null : { key, alt: leaf(state[media.fields.alt.name]) ?? "", mime: leaf(state[media.fields.mime.name]) ?? "", url: storage.url(key) };
79
+ }
80
+
81
+ function aggregateOf(media: Media): Aggregate {
82
+ const own = (candidate: Aggregate): boolean => candidate.name === media.aggregate && (candidate.chapter ?? SCHEMA.domain) === (media.domain ?? SCHEMA.domain);
83
+ return media.definition ?? (SCHEMA.aggregates.find(own) as Aggregate);
84
+ }
85
+
86
+ /** Whether a person in `role` may use the pictures: not when the chapter that holds them limits its roles to others. */
87
+ export function mediaOpen(role: string | null): boolean {
88
+ const media = SCHEMA.media as Media;
89
+ return mayEdit(aggregateOf(media), role);
90
+ }
91
+
92
+ /** What was uploaded, or the refusal to answer with. */
93
+ async function received(request: Request): Promise<{ file: Uint8Array; alt: string; error?: undefined } | { error: Response }> {
94
+ const limit = EDITOR.mediaMaxBytes;
95
+ const body = await readBounded(request, limit + OVERHEAD_BYTES);
96
+ if (!body) return { error: refuse(413, `A picture may be at most ${limit} bytes.`) };
97
+ const boundary = boundaryOf(request.headers.get("content-type"));
98
+ const sections = boundary ? parts(body, boundary) : [];
99
+ const file = sections.find((part) => part.name === "file" && part.filename !== null);
100
+ if (!file || file.data.length === 0) return { error: refuse(400, "Choose a picture to upload.") };
101
+ if (file.data.length > limit) return { error: refuse(413, `A picture may be at most ${limit} bytes.`) };
102
+ const alt = Buffer.from(sections.find((part) => part.name === "alt")?.data ?? []).toString("utf8").trim();
103
+ return { file: file.data, alt };
104
+ }
105
+
106
+ async function upload(client: HostClient, storage: MediaStorage, request: Request, media: Media): Promise<Response> {
107
+ const got = await received(request);
108
+ if (got.error) return got.error;
109
+ const mime = sniff(got.file);
110
+ if (!mime) return refuse(415, "Only JPEG, PNG, WebP, GIF and AVIF pictures are accepted.");
111
+ if (got.alt === "") return refuse(422, "Describe the picture in alt text before adding it.");
112
+ const key = `${createHash("sha256").update(got.file).digest("hex")}.${EXTENSION_OF[mime]}`;
113
+ const agg = aggregateOf(media);
114
+ const answer = await client.read();
115
+ const known = instanceIn(client, answer, agg, key);
116
+ if (known) return json({ ...picture(storage, media, known), registered: false });
117
+ await storage.put(key, got.file, mime);
118
+ const command = agg.commands.find((candidate) => candidate.name === media.command) as NonNullable<typeof agg.commands[number]>;
119
+ const outcome = await runCommand(client, agg, command, recordOf(media, key, got.alt, mime, got.file));
120
+ if (!outcome.ok) return refuse(422, outcome.message);
121
+ return json({ key, alt: got.alt, mime, url: storage.url(key), registered: true }, 201);
122
+ }
123
+
124
+ async function stateOf(client: HostClient, media: Media): Promise<State[]> {
125
+ const agg = aggregateOf(media);
126
+ const everything = async () => client.instancesOf(await client.read(), qualified(agg)).map(([, state]) => state);
127
+ if (!media.listing) return everything();
128
+ const name = `${qualified(agg)}.${media.listing}`;
129
+ try {
130
+ return rowsAnswered(await client.query(name, {}), name) as State[];
131
+ } catch (err) {
132
+ // The listing only narrows what is offered; a host that does not answer it still holds the pictures.
133
+ if (err instanceof DomainRefusal) return everything();
134
+ throw err;
135
+ }
136
+ }
137
+
138
+ async function listing(client: HostClient, storage: MediaStorage, media: Media): Promise<Response> {
139
+ try {
140
+ const pictures = (await stateOf(client, media)).flatMap((state) => picture(storage, media, state) ?? []);
141
+ return json({ pictures });
142
+ } catch (err) {
143
+ if (err instanceof DomainRefusal) return refuse(422, err.message);
144
+ throw err;
145
+ }
146
+ }
147
+
148
+ async function serve(storage: MediaStorage, key: string): Promise<Response> {
149
+ const bytes = KEY_PATTERN.test(key) ? await storage.read(key) : null;
150
+ if (!bytes) return refuse(404, "No such picture.");
151
+ return new Response(bytes as BodyInit, {
152
+ headers: {
153
+ "Content-Type": TYPE_OF[key.slice(key.lastIndexOf(".") + 1)],
154
+ "Content-Length": String(bytes.length),
155
+ "X-Content-Type-Options": "nosniff",
156
+ "Content-Security-Policy": "default-src 'none'; sandbox",
157
+ "Cache-Control": "private, max-age=300",
158
+ },
159
+ });
160
+ }
161
+
162
+ /**
163
+ * The handler of everything under `<base>/media`, given the path segments after `media`. When the
164
+ * pictures are kept in another chapter (`media.domain`), they are read and registered through a
165
+ * client for that chapter, not the editor's own.
166
+ */
167
+ export function mediaHandler(storage: MediaStorage = diskStorage(), options: HostOptions = {}) {
168
+ const media = SCHEMA.media as Media;
169
+ const elsewhere = media.domain ? hostClient({ ...options, domain: media.domain }) : null;
170
+ return (editors: HostClient, request: Request, rest: string[]): Promise<Response> | Response => {
171
+ const client = elsewhere ?? editors;
172
+ if (rest.length === 0 && request.method === "POST") return upload(client, storage, request, media);
173
+ if (rest.length === 0 && request.method === "GET") return listing(client, storage, media);
174
+ if (rest.length === 1 && request.method === "GET") return serve(storage, rest[0]);
175
+ return refuse(404, "No such page.");
176
+ };
177
+ }
@@ -0,0 +1,53 @@
1
+ __BANNER__
2
+
3
+ // A `multipart/form-data` body read into its parts. The body has already been read within the size
4
+ // cap by the time it gets here, so this only splits it: no part is written anywhere, and a part's
5
+ // filename is returned for information and never used as a path.
6
+
7
+ export interface Part {
8
+ name: string;
9
+ filename: string | null;
10
+ data: Uint8Array;
11
+ }
12
+
13
+ /** The most parts a body may have; an upload form has a handful. */
14
+ const MAX_PARTS = 8;
15
+
16
+ /** The boundary a `Content-Type` header names, or null when it is not a multipart type with one. */
17
+ export function boundaryOf(contentType: string | null): string | null {
18
+ if (!contentType || !/^multipart\/form-data\s*;/i.test(contentType)) return null;
19
+ const found = /boundary=(?:"([^"]{1,70})"|([^;\s]{1,70}))/i.exec(contentType);
20
+ return found ? (found[1] ?? found[2]) : null;
21
+ }
22
+
23
+ function header(head: string, key: string): string | null {
24
+ const found = new RegExp(`${key}="([^"]*)"`, "i").exec(head);
25
+ return found ? found[1] : null;
26
+ }
27
+
28
+ function partOf(chunk: Buffer): Part | null {
29
+ const split = chunk.indexOf("\r\n\r\n");
30
+ if (split < 0) return null;
31
+ const head = chunk.subarray(0, split).toString("latin1");
32
+ const name = header(head, "name");
33
+ if (name === null) return null;
34
+ return { name, filename: header(head, "filename"), data: chunk.subarray(split + 4) };
35
+ }
36
+
37
+ /** The parts of `body`, split on `boundary`; a body that is not well formed gives an empty list. */
38
+ export function parts(body: Uint8Array, boundary: string): Part[] {
39
+ const buffer = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
40
+ const delimiter = Buffer.from(`--${boundary}`);
41
+ const found: Part[] = [];
42
+ let at = buffer.indexOf(delimiter);
43
+ while (at >= 0 && found.length < MAX_PARTS) {
44
+ const start = at + delimiter.length;
45
+ if (buffer.subarray(start, start + 2).toString() === "--") break;
46
+ const next = buffer.indexOf(delimiter, start);
47
+ if (next < 0) return [];
48
+ const part = partOf(buffer.subarray(start + 2, Math.max(start + 2, next - 2)));
49
+ if (part) found.push(part);
50
+ at = next;
51
+ }
52
+ return found;
53
+ }
@@ -0,0 +1,94 @@
1
+ __BANNER__
2
+
3
+ // What a picture's bytes are. The type an upload claims is never trusted: the first bytes decide,
4
+ // against an allow-list of raster formats (JPEG, PNG, WebP, GIF, AVIF). SVG, which can carry script,
5
+ // and anything else is refused. The same bytes give the picture's size when the format makes it cheap
6
+ // to read, and the storage key is the bytes' own digest with the format's extension, so a key is never
7
+ // built from anything the client sent.
8
+
9
+ /** The extension each allowed type is stored under. */
10
+ export const EXTENSION_OF: Readonly<Record<string, string>> = {
11
+ "image/jpeg": "jpg",
12
+ "image/png": "png",
13
+ "image/webp": "webp",
14
+ "image/gif": "gif",
15
+ "image/avif": "avif",
16
+ };
17
+
18
+ /** The type a stored key's extension says it is. */
19
+ export const TYPE_OF: Readonly<Record<string, string>> = Object.fromEntries(Object.entries(EXTENSION_OF).map(([type, ext]) => [ext, type]));
20
+
21
+ /** A key the editor generated: a SHA-256 digest in hex, a dot, and an allowed extension. */
22
+ export const KEY_PATTERN = /^[0-9a-f]{64}\.(jpg|png|webp|gif|avif)$/;
23
+
24
+ const ascii = (bytes: Uint8Array, from: number, to: number): string => String.fromCharCode(...bytes.subarray(from, to));
25
+ const be16 = (b: Uint8Array, at: number): number => (b[at] << 8) | b[at + 1];
26
+ const be32 = (b: Uint8Array, at: number): number => ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0;
27
+ const le16 = (b: Uint8Array, at: number): number => b[at] | (b[at + 1] << 8);
28
+ const le24 = (b: Uint8Array, at: number): number => b[at] | (b[at + 1] << 8) | (b[at + 2] << 16);
29
+
30
+ const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
31
+
32
+ /** AVIF is an ISO file whose first box names an avif brand, as its major brand or a compatible one. */
33
+ function isAvif(bytes: Uint8Array): boolean {
34
+ if (bytes.length < 16 || ascii(bytes, 4, 8) !== "ftyp") return false;
35
+ const end = Math.min(be32(bytes, 0), 64, bytes.length);
36
+ for (let at = 8; at + 4 <= end; at += 4) {
37
+ const brand = ascii(bytes, at, at + 4);
38
+ if (brand === "avif" || brand === "avis") return true;
39
+ }
40
+ return false;
41
+ }
42
+
43
+ /** The allowed type the bytes are, or null when they are none of them. */
44
+ export function sniff(bytes: Uint8Array): string | null {
45
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
46
+ if (bytes.length >= 8 && PNG.every((value, at) => bytes[at] === value)) return "image/png";
47
+ if (bytes.length >= 6 && ["GIF87a", "GIF89a"].includes(ascii(bytes, 0, 6))) return "image/gif";
48
+ if (bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp";
49
+ return isAvif(bytes) ? "image/avif" : null;
50
+ }
51
+
52
+ export interface Size {
53
+ width: number;
54
+ height: number;
55
+ }
56
+
57
+ function jpegSize(bytes: Uint8Array): Size | null {
58
+ let at = 2;
59
+ while (at + 9 < bytes.length) {
60
+ if (bytes[at] !== 0xff) return null;
61
+ const marker = bytes[at + 1];
62
+ if (marker === 0xff) {
63
+ at += 1;
64
+ } else if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
65
+ return { height: be16(bytes, at + 5), width: be16(bytes, at + 7) };
66
+ } else if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
67
+ at += 2;
68
+ } else {
69
+ at += 2 + be16(bytes, at + 2);
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function webpSize(bytes: Uint8Array): Size | null {
76
+ const chunk = ascii(bytes, 12, 16);
77
+ if (chunk === "VP8X" && bytes.length >= 30) return { width: le24(bytes, 24) + 1, height: le24(bytes, 27) + 1 };
78
+ if (chunk === "VP8 " && bytes.length >= 30) return { width: le16(bytes, 26) & 0x3fff, height: le16(bytes, 28) & 0x3fff };
79
+ if (chunk === "VP8L" && bytes.length >= 25) {
80
+ const bits = (bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24)) >>> 0;
81
+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /** The picture's size in pixels when the format makes it cheap to read (not AVIF), else null. */
87
+ export function sizeOf(bytes: Uint8Array, type: string): Size | null {
88
+ let size: Size | null = null;
89
+ if (type === "image/png" && bytes.length >= 24) size = { width: be32(bytes, 16), height: be32(bytes, 20) };
90
+ else if (type === "image/gif" && bytes.length >= 10) size = { width: le16(bytes, 6), height: le16(bytes, 8) };
91
+ else if (type === "image/jpeg") size = jpegSize(bytes);
92
+ else if (type === "image/webp") size = webpSize(bytes);
93
+ return size && size.width > 0 && size.height > 0 ? size : null;
94
+ }
@@ -0,0 +1,50 @@
1
+ __BANNER__
2
+
3
+ // Where a picture's bytes are kept. The domain holds a picture's record (key, alt text, type, size)
4
+ // and never its bytes; the bytes go through this port. `diskStorage` writes them under the directory
5
+ // the Editor row names (`media_dir`). A project that keeps pictures in an object store implements the
6
+ // same interface (`put` a key's bytes, `read` them back, and `url` where a browser can show one) and
7
+ // passes it to `createApp({ storage })`; nothing else in the editor changes.
8
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
+
11
+ import { EDITOR } from "../config.ts";
12
+ import { KEY_PATTERN } from "./sniff.ts";
13
+
14
+ export interface MediaStorage {
15
+ /** Keeps `bytes` under `key`; keeping the same key again keeps the same bytes. */
16
+ put(key: string, bytes: Uint8Array, mime: string): Promise<void>;
17
+ /** The address a signed-in editor's browser shows the picture from. */
18
+ url(key: string): string;
19
+ /** The bytes kept under `key`, or null when there are none. */
20
+ read(key: string): Promise<Uint8Array | null>;
21
+ }
22
+
23
+ /** The directory's file for `key`; a key that is not one this editor generates is refused. */
24
+ function fileFor(directory: string, key: string): string {
25
+ if (!KEY_PATTERN.test(key)) throw new RangeError("not a picture key");
26
+ return join(directory, key);
27
+ }
28
+
29
+ /** Pictures on the local disk, under `directory` (resolved from the working directory). */
30
+ export function diskStorage(directory: string = EDITOR.mediaDir): MediaStorage {
31
+ const root = resolve(directory);
32
+ return {
33
+ async put(key, bytes) {
34
+ const file = fileFor(root, key);
35
+ await mkdir(root, { recursive: true });
36
+ const partial = `${file}.${process.pid}.part`;
37
+ await writeFile(partial, bytes);
38
+ await rename(partial, file);
39
+ },
40
+ url: (key) => `${EDITOR.basePath}/media/${key}`,
41
+ async read(key) {
42
+ try {
43
+ return await readFile(fileFor(root, key));
44
+ } catch (err) {
45
+ if ((err as NodeJS.ErrnoException).code === "ENOENT" || err instanceof RangeError) return null;
46
+ throw err;
47
+ }
48
+ },
49
+ };
50
+ }