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,370 @@
1
+ __BANNER__
2
+
3
+ // The editor as a function from a web `Request` to a `Response`, with its host client, clock and
4
+ // secret passed in, so it runs the same under src/server.ts and under a test with a fake `fetch`.
5
+ //
6
+ // Every request is authenticated here: a signed session cookie, then the host's membership list
7
+ // (auth/membership.ts). A visitor with neither is sent to the site's login page; nothing in the
8
+ // domain is read or changed before that. Posts must come from this site (the cookie is SameSite=Lax
9
+ // and the Origin header is checked too).
10
+ import { randomUUID } from "node:crypto";
11
+
12
+ import { DomainRefusal, DomainUnavailable } from "@hecks/client";
13
+ import type { Answer, HostClient } from "@hecks/client";
14
+
15
+ import { serveAsset } from "./assets.ts";
16
+ import { authSecret, membershipCheck } from "./auth/membership.ts";
17
+ import type { Membership } from "./auth/membership.ts";
18
+ import { clearedCookie, readSession } from "./auth/session.ts";
19
+ import { signIn } from "./auth/sso.ts";
20
+ import { choicesFor, unknownKey } from "./choices.ts";
21
+ import { idOf, instanceIn, reading, runCommand } from "./commands.ts";
22
+ import type { State } from "./commands.ts";
23
+ import { EDITOR } from "./config.ts";
24
+ import { flashCookie, readFlash, spentFlash } from "./flash.ts";
25
+ import type { Flash } from "./flash.ts";
26
+ import { hostClient, rowsAnswered } from "./host.ts";
27
+ import { at, keyOf, locate, mayEdit, qualified } from "./names.ts";
28
+ import { SCHEMA } from "./schema.ts";
29
+ import type { Aggregate, Command } from "./schema.ts";
30
+ import { available, needsPage } from "./ui/detail.ts";
31
+ import { href } from "./ui/html.ts";
32
+ import { commandArguments, parseForm } from "./ui/input.ts";
33
+ import { backOf, plainValue } from "./ui/links.ts";
34
+ import type { Back } from "./ui/links.ts";
35
+ import { commandPage, detailPage, home, instanceCommandPage, listPage, notFound, scheduledPage } from "./ui/pages.ts";
36
+ import type { Submitted } from "./ui/pages.ts";
37
+ import { knownStatus, narrow, slice, viewOf } from "./ui/paging.ts";
38
+ import type { View } from "./ui/paging.ts";
39
+ import { schedules, subjectQuery } from "./ui/schedule.ts";
40
+ import { decorate } from "./ui/shell.ts";
41
+ import type { World } from "./ui/world.ts";
42
+ import { schedulingAggregate } from "./ui/world.ts";
43
+ import { done, commandLabel, plural } from "./ui/words.ts";
44
+ __MEDIA_IMPORT__
45
+
46
+ export interface AppOptions {
47
+ /** The host's address, when not read from the environment variable the Editor row names. */
48
+ url?: string;
49
+ fetch?: typeof fetch;
50
+ secret?: string;
51
+ now?: () => number;
52
+ membership?: Membership;
53
+ __MEDIA_OPTION__
54
+ }
55
+
56
+ /**
57
+ * What every page is sent with. The editor loads its own script, stylesheet, pictures and icons and
58
+ * nothing else, so the policy names no other origin and allows no inline script and no inline style
59
+ * (no `style` attribute, no style element: the stylesheet is a file, and the writing surface is told
60
+ * to inject none). `data:` in `img-src` is for the sprite-free case of a picture drawn from a data
61
+ * address; `blob:` shows a picture chosen for upload before it is sent. Script may still set styles
62
+ * through the page's object model, which the policy allows.
63
+ */
64
+ const POLICY = [
65
+ "default-src 'none'",
66
+ "script-src 'self'",
67
+ "style-src 'self'",
68
+ "img-src 'self' data: blob:",
69
+ "connect-src 'self'",
70
+ "form-action 'self'",
71
+ __FRAME_POLICY__
72
+ "base-uri 'none'",
73
+ "frame-ancestors 'none'",
74
+ ].join("; ");
75
+
76
+ const PAGE_HEADERS = {
77
+ "Content-Type": "text/html; charset=utf-8",
78
+ "Cache-Control": "no-store",
79
+ "Content-Security-Policy": POLICY,
80
+ "Referrer-Policy": "same-origin",
81
+ "X-Content-Type-Options": "nosniff",
82
+ };
83
+
84
+ const plain = (body: string, status: number): Response => new Response(body, { status, headers: { "Content-Type": "text/plain; charset=utf-8" } });
85
+
86
+ const redirect = (location: string, status = 303, ...cookies: string[]): Response => {
87
+ const headers = new Headers({ Location: location });
88
+ for (const cookie of cookies) headers.append("Set-Cookie", cookie);
89
+ return new Response(null, { status, headers });
90
+ };
91
+
92
+ /** A post is refused when the browser says it came from another site. */
93
+ function sameSite(request: Request): boolean {
94
+ const origin = request.headers.get("origin");
95
+ if (origin) return new URL(origin).host === new URL(request.url).host;
96
+ return (request.headers.get("sec-fetch-site") ?? "same-origin") !== "cross-site";
97
+ }
98
+
99
+ /** What a route needs to answer: show a page for this person, or send them on with a notice. */
100
+ interface Scene {
101
+ /** The person's role, which decides the chapters they may edit. */
102
+ role: string | null;
103
+ show(body: string, status?: number, notice?: Flash): Promise<Response>;
104
+ sent(location: string, message: string): Response;
105
+ }
106
+
107
+ /** The editor, as a handler. */
108
+ export function createApp(options: AppOptions = {}): (request: Request) => Promise<Response> {
109
+ const secret = options.secret ?? authSecret();
110
+ const now = options.now ?? Date.now;
111
+ const membership = options.membership ?? membershipCheck({ url: options.url, fetch: options.fetch, secret, now });
112
+ const client = hostClient({ url: options.url, fetch: options.fetch });
113
+ __MEDIA_SETUP__
114
+
115
+ /**
116
+ * The instances each aggregate holds, for the navigation; a host that cannot say shows no counts.
117
+ * The host has no count to ask for, so they are the lengths of what one read answers, the same
118
+ * read a page of a list is made from.
119
+ */
120
+ async function counts(from: HostClient): Promise<Record<string, number>> {
121
+ try {
122
+ const answer = await from.read();
123
+ return Object.fromEntries(SCHEMA.aggregates.map((agg) => [keyOf(agg), from.instancesOf(answer, qualified(agg)).length]));
124
+ } catch {
125
+ return {};
126
+ }
127
+ }
128
+
129
+ function sceneFor(email: string, role: string | null, flash: Flash | null, from: HostClient): Scene {
130
+ return {
131
+ role,
132
+ async show(body, status = 200, notice) {
133
+ const text = decorate(body, { email, role, counts: await counts(from), flash: notice ?? flash });
134
+ const response = new Response(text, { status, headers: PAGE_HEADERS });
135
+ if (flash) response.headers.append("Set-Cookie", spentFlash());
136
+ return response;
137
+ },
138
+ sent: (location, message) => redirect(location, 303, flashCookie({ kind: "success", message }, secret, now)),
139
+ };
140
+ }
141
+
142
+ async function handle(request: Request): Promise<Response> {
143
+ const url = new URL(request.url);
144
+ const path = url.pathname.replace(/\/+$/, "") || "/";
145
+ if (path === EDITOR.ssoPath) return enter(url, secret, membership, now);
146
+ if (path !== EDITOR.basePath && !path.startsWith(`${EDITOR.basePath}/`)) return anonymous(notFound(path), 404);
147
+ if (path === href("logout") && request.method === "POST") return signOut(request);
148
+
149
+ const email = readSession(request.headers.get("cookie"), secret, now);
150
+ if (!email || !(await membership.isAdmitted(email))) {
151
+ return request.method === "GET" ? redirect(EDITOR.login, 302) : plain("Sign in again.", 403);
152
+ }
153
+ if (request.method === "POST" && !sameSite(request)) return plain("Cross-site post refused.", 403);
154
+ const segments = path.slice(EDITOR.basePath.length).split("/").filter(Boolean).map(decodeURIComponent);
155
+ const role = membership.roleOf ? await membership.roleOf(email) : null;
156
+ __MEDIA_ROUTE__
157
+ const reader = request.method === "GET" ? reading(client) : client;
158
+ const scene = sceneFor(email, role, readFlash(request.headers.get("cookie"), secret, now), reader);
159
+ return route(reader, request, url, segments, scene);
160
+ }
161
+
162
+ return async (request) => {
163
+ try {
164
+ return await handle(request);
165
+ } catch (err) {
166
+ if (err instanceof DomainUnavailable) return anonymous(notFound(`The domain could not be reached: ${err.message}`, "The domain is not answering"), 502);
167
+ throw err;
168
+ }
169
+ };
170
+ }
171
+
172
+ /** A page for someone with no session to show, such as a path the editor does not serve. */
173
+ function anonymous(body: string, status: number): Response {
174
+ return new Response(decorate(body, { email: "", role: null, counts: {}, flash: null }), { status, headers: PAGE_HEADERS });
175
+ }
176
+
177
+ /** Ends the session: the cookie is cleared and the person is sent to the site's login page. */
178
+ function signOut(request: Request): Response {
179
+ if (!sameSite(request)) return plain("Cross-site post refused.", 403);
180
+ return redirect(EDITOR.login, 303, clearedCookie());
181
+ }
182
+
183
+ async function enter(url: URL, secret: string, membership: Membership, now: () => number): Promise<Response> {
184
+ const result = await signIn(url.searchParams.get("token"), secret, membership, now);
185
+ if (!result.ok) return plain(result.message, result.status);
186
+ return redirect(result.location, 302, result.cookie);
187
+ }
188
+
189
+ function route(client: HostClient, request: Request, url: URL, segments: string[], scene: Scene): Promise<Response> | Response {
190
+ if (segments.length === 0) return scene.show(home());
191
+ if (segments[0] === "assets" && segments.length === 2 && request.method === "GET") return serveAsset(segments[1], request);
192
+ if (segments[0] === "scheduled" && segments.length === 1 && request.method === "GET" && SCHEMA.scheduling) return scheduledView(client, scene);
193
+ const { agg, rest: [verb, ...rest] } = locate(segments);
194
+ if (!agg) return scene.show(notFound(`No aggregate ${segments.slice(0, SCHEMA.chapters ? 2 : 1).join("/")}.`), 404);
195
+ if (!mayEdit(agg, scene.role)) return scene.show(notFound(`${plural(agg.name)} are not open to your role.`, "Not open to your role"), 403);
196
+ if (verb === undefined) return list(client, agg, url, scene);
197
+ if (verb === "id" && rest.length === 1 && request.method === "GET") return detail(client, agg, rest[0], null, scene);
198
+ if (verb === "id" && rest.length === 2 && request.method === "GET") return instanceView(client, agg, rest[0], rest[1], url, scene);
199
+ if (request.method === "POST") return post(client, request, agg, verb, rest, scene);
200
+ if ((verb === "new" || verb === "run") && rest.length === 1) return commandView(client, agg, verb, rest[0], url, scene);
201
+ return scene.show(notFound(`No page ${segments.join("/")}.`), 404);
202
+ }
203
+
204
+ /** The pairs of an address a list keeps on every link: its view and that view's arguments, not the filters or the page. */
205
+ const KEPT = (key: string): boolean => !["_page", "_q", "_status"].includes(key);
206
+
207
+ /** The filters and the page an address asks for. */
208
+ function asked(url: URL): { q: string; status: string; page: number; size: number; params: [string, string][] } {
209
+ const page = Number.parseInt(url.searchParams.get("_page") ?? "1", 10);
210
+ return {
211
+ q: (url.searchParams.get("_q") ?? "").trim().slice(0, 200),
212
+ status: url.searchParams.get("_status") ?? "",
213
+ page: Number.isFinite(page) ? page : 1,
214
+ size: EDITOR.pageSize,
215
+ params: [...url.searchParams].filter(([key]) => KEPT(key)),
216
+ };
217
+ }
218
+
219
+ async function list(client: HostClient, agg: Aggregate, url: URL, scene: Scene): Promise<Response> {
220
+ const query = agg.queries.find((candidate) => candidate.name === url.searchParams.get("query")) ?? null;
221
+ const form = parseForm(url.search.slice(1));
222
+ const args = query ? commandArguments(agg, query.attributes, form) : {};
223
+ const wanted = query !== null && (query.attributes.length === 0 || Object.keys(args).length > 0);
224
+ const raw = asked(url);
225
+ const filters = { ...raw, status: knownStatus(agg, raw.status) };
226
+ const choices = query ? await choicesFor(client, agg, query.attributes) : {};
227
+ const none = viewOf(0, 0, filters);
228
+ if (query && !wanted) return scene.show(listPage(agg, [], query, null, false, none, choices));
229
+ try {
230
+ const found = await rowsFor(client, agg, wanted ? query : null, args);
231
+ const matching = narrow(agg, found, filters.q, filters.status);
232
+ const view: View = viewOf(matching.length, found.length, filters);
233
+ return scene.show(listPage(agg, slice(matching, view), query, null, true, view, choices));
234
+ } catch (err) {
235
+ if (err instanceof DomainRefusal) return scene.show(listPage(agg, [], query, err.message, true, none, choices), 200);
236
+ throw err;
237
+ }
238
+ }
239
+
240
+ async function rowsFor(client: HostClient, agg: Aggregate, query: { name: string } | null, args: State): Promise<[string, State][]> {
241
+ if (!query) return client.instancesOf(await client.read(), qualified(agg));
242
+ const name = `${qualified(agg)}.${query.name}`;
243
+ const rows = rowsAnswered(await client.query(name, args), name);
244
+ return rows.flatMap((row): [string, State][] => {
245
+ const id = idOf(agg, row);
246
+ return id === null ? [] : [[id, row]];
247
+ });
248
+ }
249
+
250
+ const refused = (agg: Aggregate, command: Command): Flash => ({ kind: "error", message: `${commandLabel(agg, command)} was not applied. Fix what is marked and try again.` });
251
+
252
+ /** What a page of one record may read of the rest of the domain, from the read the page already made. */
253
+ async function worldFor(client: HostClient, answer: Answer, role: string | null, record?: { agg: Aggregate; id: string }): Promise<World> {
254
+ const world: World = { role, lookup: (agg, id) => instanceIn(client, answer, agg, id), rows: (agg) => client.instancesOf(answer, qualified(agg)) };
255
+ const asked = record && schedules(record.agg, role) ? subjectQuery(record.agg, record.id) : null;
256
+ const sched = schedulingAggregate();
257
+ if (!asked || !sched) return world;
258
+ const name = `${qualified(sched)}.${asked.name}`;
259
+ try {
260
+ const rows = rowsAnswered(await client.query(name, asked.args), name);
261
+ return { ...world, actions: rows.flatMap((row): [string, State][] => (idOf(sched, row) === null ? [] : [[idOf(sched, row) as string, row]])) };
262
+ } catch (err) {
263
+ if (err instanceof DomainRefusal || err instanceof DomainUnavailable) return world;
264
+ throw err;
265
+ }
266
+ }
267
+
268
+ /** The screen of every pending action, for those who may edit them. */
269
+ async function scheduledView(client: HostClient, scene: Scene): Promise<Response> {
270
+ const sched = schedulingAggregate();
271
+ if (!sched) return scene.show(notFound("Nothing is scheduled here."), 404);
272
+ if (!mayEdit(sched, scene.role)) return scene.show(notFound("Scheduled actions are not open to your role.", "Not open to your role"), 403);
273
+ return scene.show(scheduledPage(await worldFor(client, await client.read(), scene.role)));
274
+ }
275
+
276
+ async function detail(client: HostClient, agg: Aggregate, id: string, shown: Submitted | null, scene: Scene, status = 200): Promise<Response> {
277
+ const answer = await client.read();
278
+ const values = instanceIn(client, answer, agg, id);
279
+ if (!values) return scene.show(notFound(`No ${agg.name} ${id}.`), 404);
280
+ const command = agg.commands.find((candidate) => candidate.name === shown?.command);
281
+ const world = await worldFor(client, answer, scene.role, { agg, id });
282
+ return scene.show(detailPage(agg, id, values, shown, world), status, shown && command ? refused(agg, command) : undefined);
283
+ }
284
+
285
+ /** The page of a command that acts on one instance, when that instance is in a state it applies to. */
286
+ async function instanceView(client: HostClient, agg: Aggregate, id: string, name: string, url: URL, scene: Scene): Promise<Response> {
287
+ const values = instanceIn(client, await client.read(), agg, id);
288
+ if (!values) return scene.show(notFound(`No ${agg.name} ${id}.`), 404);
289
+ const command = commandFor(agg, "id", name);
290
+ if (!command || !available(agg, command, values)) return scene.show(notFound(`${name} does not apply to ${id} now.`), 404);
291
+ const choices = await choicesFor(client, agg, command.attributes);
292
+ return scene.show(instanceCommandPage(agg, id, command, values, null, choices, backOf(url.searchParams.get("_back"))));
293
+ }
294
+
295
+ /** A command's page; a link may fill its inputs from the address and name the page to return to. */
296
+ async function commandView(client: HostClient, agg: Aggregate, verb: string, name: string, url: URL, scene: Scene): Promise<Response> {
297
+ const command = commandFor(agg, verb, name);
298
+ if (!command) return scene.show(notFound(`No command ${name}.`), 404);
299
+ const state = commandArguments(agg, command.attributes, parseForm(url.search.slice(1)));
300
+ const back = backOf(url.searchParams.get("_back"));
301
+ return scene.show(commandPage(agg, command, href(...at(agg), verb, name), null, await choicesFor(client, agg, command.attributes), { state, back }));
302
+ }
303
+
304
+ /** The command a `new` (creating) or `run` (acting on another aggregate) path names, or null. */
305
+ function commandFor(agg: Aggregate, verb: string, name: string): Command | null {
306
+ const command = agg.commands.find((candidate) => candidate.name === name);
307
+ if (!command) return null;
308
+ if (verb === "new") return command.creates ? command : null;
309
+ if (verb === "run") return !command.creates && command.on !== agg.name ? command : null;
310
+ return command.on === agg.name ? command : null;
311
+ }
312
+
313
+ /** What a form is refused for before the command is sent: a key a picker names that is not there. */
314
+ async function unchecked(client: HostClient, agg: Aggregate, command: Command, args: State): Promise<{ ok: false; message: string; field: string } | null> {
315
+ const problem = await unknownKey(client, agg, command.attributes, args);
316
+ return problem ? { ok: false, message: problem.message, field: problem.field } : null;
317
+ }
318
+
319
+ /** The scheduled-action record the editor makes itself: a creating command gets an identity nobody typed. */
320
+ function withMadeIdentity(agg: Aggregate, command: Command, args: State): State {
321
+ const spec = SCHEMA.scheduling;
322
+ if (!spec?.generated || !command.creates || command.name !== spec.schedule || agg.name !== spec.aggregate || agg.chapter !== spec.chapter) return args;
323
+ const identity = agg.attributes.find((attr) => attr.name === agg.identity);
324
+ return identity ? { ...args, [identity.name]: plainValue(agg, identity, randomUUID()) } : args;
325
+ }
326
+
327
+ /**
328
+ * A draft saved by the page's script while the person writes. It is answered in plain text, never
329
+ * with a redirect or a notice, and it only ever runs the aggregate's draft-saving command, so the
330
+ * live attribute is never written from here.
331
+ */
332
+ async function autosave(client: HostClient, agg: Aggregate, command: Command, args: State, to: string | undefined): Promise<Response> {
333
+ if (!to || agg.drafts?.save !== command.name) return plain("Only a draft can be saved this way.", 400);
334
+ const outcome = (await unchecked(client, agg, command, args)) ?? (await runCommand(client, agg, command, args, to));
335
+ if (outcome.ok) return plain("Saved.", 200);
336
+ const refused = plain(outcome.message, 422);
337
+ if ("problems" in outcome && outcome.problems?.length) refused.headers.set("x-editor-problems", encodeURIComponent(JSON.stringify(outcome.problems)));
338
+ return refused;
339
+ }
340
+
341
+ /** Where a command that worked leads: the page the person came from, another blank form, or the instance. */
342
+ function afterwards(agg: Aggregate, command: Command, again: boolean, id: string, back: Back | null): string {
343
+ if (back) return back.path;
344
+ return command.creates && again ? href(...at(agg), "new", command.name) : href(...at(agg), "id", id);
345
+ }
346
+
347
+ async function post(client: HostClient, request: Request, agg: Aggregate, verb: string, rest: string[], scene: Scene): Promise<Response> {
348
+ const onInstance = verb === "id" && rest.length === 2;
349
+ const command = onInstance ? commandFor(agg, "id", rest[1]) : rest.length === 1 ? commandFor(agg, verb, rest[0]) : null;
350
+ if (!command) return scene.show(notFound("No such command."), 404);
351
+ const form = parseForm(await request.text());
352
+ const args = withMadeIdentity(agg, command, commandArguments(agg, command.attributes, form, command.empty));
353
+ const to = onInstance ? rest[0] : command.creates ? undefined : (form.get("__to") ?? "");
354
+ if (request.headers.get("x-editor-autosave") === "1") return autosave(client, agg, command, args, to);
355
+ const outcome = to === "" ? { ok: false as const, message: "Name the instance to act on." } : ((await unchecked(client, agg, command, args)) ?? (await runCommand(client, agg, command, args, to)));
356
+ const back = backOf(form.get("__back"));
357
+ if (outcome.ok) return scene.sent(afterwards(agg, command, form.get("__again") === "1", outcome.id, back), done(agg, command));
358
+ const shown: Submitted = { command: command.name, message: outcome.message, values: { ...args, __to: to }, field: outcome.field, problems: "problems" in outcome ? outcome.problems : undefined };
359
+ if (onInstance) return refusedOnInstance(client, agg, rest[0], command, shown, scene, back);
360
+ const choices = await choicesFor(client, agg, command.attributes);
361
+ return scene.show(commandPage(agg, command, href(...at(agg), verb, command.name), shown, choices, { back }), 422, refused(agg, command));
362
+ }
363
+
364
+ /** The refusal of a command run on one instance: shown on its form, or on the page it was run from. */
365
+ async function refusedOnInstance(client: HostClient, agg: Aggregate, id: string, command: Command, shown: Submitted, scene: Scene, back: Back | null): Promise<Response> {
366
+ if (!needsPage(command)) return detail(client, agg, id, shown, scene, 422);
367
+ const values = instanceIn(client, await client.read(), agg, id);
368
+ if (!values) return scene.show(notFound(`No ${agg.name} ${id}.`), 404);
369
+ return scene.show(instanceCommandPage(agg, id, command, values, shown, await choicesFor(client, agg, command.attributes), back), 422, refused(agg, command));
370
+ }
@@ -0,0 +1,98 @@
1
+ __BANNER__
2
+
3
+ // The editor's static files, for a signed-in editor only: its theme script (read from `src/ui/`),
4
+ // the stylesheet and script `npm run build` writes to `dist/`, and the Editor row's logo.
5
+ //
6
+ // Every file is sent with an ETag made from its bytes. A page asks for it as `name?v=<version>`
7
+ // where the version is the same hash, so a changed file has a new address and a request that names
8
+ // the current version may be kept for a year (`immutable`); any other request is revalidated and
9
+ // answered 304 when nothing changed. Files are `private`: they are behind the session, and a
10
+ // shared cache must not hold them for someone who has not signed in.
11
+ import { createHash } from "node:crypto";
12
+ import { existsSync, readFileSync, statSync } from "node:fs";
13
+ import { extname } from "node:path";
14
+
15
+ import { EDITOR } from "./config.ts";
16
+
17
+ const SOURCE = new URL("./ui/", import.meta.url);
18
+ const DIST = new URL("../dist/", import.meta.url);
19
+
20
+ /** The files read from `src/ui/`; everything else comes from `dist/`. */
21
+ const SOURCES = ["theme.js"];
22
+
23
+ /** A built file's name: plain characters and a `.js` or `.css` ending, so no path leaves `dist/`. */
24
+ const BUILT = /^[A-Za-z0-9][A-Za-z0-9._-]*\.(js|css)$/;
25
+
26
+ const TYPES: Record<string, string> = {
27
+ js: "text/javascript; charset=utf-8",
28
+ css: "text/css; charset=utf-8",
29
+ png: "image/png",
30
+ jpg: "image/jpeg",
31
+ jpeg: "image/jpeg",
32
+ webp: "image/webp",
33
+ gif: "image/gif",
34
+ avif: "image/avif",
35
+ svg: "image/svg+xml",
36
+ };
37
+
38
+ interface Held {
39
+ bytes: Buffer;
40
+ type: string;
41
+ version: string;
42
+ stamp: number;
43
+ }
44
+
45
+ const held = new Map<string, Held>();
46
+
47
+ function locate(name: string): URL | null {
48
+ if (SOURCES.includes(name)) return new URL(name, SOURCE);
49
+ if (!BUILT.test(name)) return null;
50
+ const built = new URL(name, DIST);
51
+ return existsSync(built) ? built : null;
52
+ }
53
+
54
+ function load(name: string): Held | null {
55
+ const where = name === "logo" ? logoPath() : locate(name);
56
+ if (!where || !existsSync(where)) return null;
57
+ const stamp = statSync(where).mtimeMs;
58
+ const known = held.get(name);
59
+ if (known && known.stamp === stamp) return known;
60
+ const bytes = readFileSync(where);
61
+ const type = TYPES[extname(String(where)).slice(1).toLowerCase()] ?? "application/octet-stream";
62
+ const made = { bytes, type, version: createHash("sha256").update(bytes).digest("hex").slice(0, 12), stamp };
63
+ held.set(name, made);
64
+ return made;
65
+ }
66
+
67
+ /** Where the row's logo is kept: a path from the server's working directory, or null when none is named. */
68
+ function logoPath(): string | null {
69
+ return EDITOR.logo === "" ? null : EDITOR.logo;
70
+ }
71
+
72
+ /** Whether `npm run build` has written the stylesheet and the script. */
73
+ export const isBuilt = (): boolean => existsSync(new URL("editor.css", DIST)) && existsSync(new URL("editor.js", DIST));
74
+
75
+ /** Whether the row names a logo that is there to be shown. */
76
+ export const hasLogo = (): boolean => load("logo") !== null;
77
+
78
+ /** The address of a file, with the version that makes it cacheable for good. */
79
+ export function assetUrl(name: string): string {
80
+ const version = load(name)?.version;
81
+ return `${EDITOR.basePath}/assets/${name}${version ? `?v=${version}` : ""}`;
82
+ }
83
+
84
+ /** Answers a request for one asset: the bytes, a 304 when the browser holds them, or a 404. */
85
+ export function serveAsset(name: string, request: Request): Response {
86
+ const file = load(name);
87
+ if (!file) return new Response("Not found.", { status: 404, headers: { "Content-Type": "text/plain" } });
88
+ const etag = `"${file.version}"`;
89
+ const headers = new Headers({
90
+ "Content-Type": file.type,
91
+ ETag: etag,
92
+ "X-Content-Type-Options": "nosniff",
93
+ "Cache-Control": new URL(request.url).searchParams.get("v") === file.version ? "private, max-age=31536000, immutable" : "private, no-cache",
94
+ });
95
+ if (name === "logo") headers.set("Content-Security-Policy", "sandbox");
96
+ if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
97
+ return new Response(new Uint8Array(file.bytes), { headers });
98
+ }
@@ -0,0 +1,87 @@
1
+ __BANNER__
2
+
3
+ // Is this person still an editor? The domain's membership list decides, not this server. Signing in
4
+ // (auth/sso.ts) proved the person was admitted then; this asks again on every request, so that
5
+ // disabling or removing someone locks them out at once, not when their session runs out.
6
+ //
7
+ // The question is the host's members list, asked with a short account token signed by the secret the
8
+ // host checks (AUTH_SECRET here is the host's SESSION_SECRET). The person counts when the list holds
9
+ // their email with one of the configured roles and not disabled. The answer is remembered for a
10
+ // minute per email so each request does not cost a round trip; a question that cannot be asked is
11
+ // answered no.
12
+ import { accountToken, resolveAccountCookieName } from "@hecks/client";
13
+
14
+ import { EDITOR } from "../config.ts";
15
+ import { hostUrl } from "../host.ts";
16
+
17
+ const TOKEN_TTL_SECONDS = 60;
18
+
19
+ /** The secret shared with the host. Outside production it falls back to the host's development default. */
20
+ export function authSecret(): string {
21
+ return process.env.AUTH_SECRET || (process.env.NODE_ENV === "production" ? "" : "dev_only_change_me_in_production");
22
+ }
23
+
24
+ export interface Membership {
25
+ /** True while `email` holds an editor role on the host; false when it does not, or cannot be asked. */
26
+ isAdmitted(email: string): Promise<boolean>;
27
+ /** The role `email` holds, for the header to show; null when it holds none. Answered from the same memory. */
28
+ roleOf?(email: string): Promise<string | null>;
29
+ }
30
+
31
+ export interface MembershipOptions {
32
+ url?: string;
33
+ secret?: string;
34
+ fetch?: typeof fetch;
35
+ now?: () => number;
36
+ }
37
+
38
+ /** The editor role the host's members list gives `email`, or null when it holds none or is disabled. */
39
+ export function roleHeld(rows: unknown, email: string): string | null {
40
+ const roles: readonly string[] = EDITOR.roles;
41
+ const row = Array.isArray(rows)
42
+ ? rows.find(
43
+ (candidate: any) =>
44
+ typeof candidate?.email === "string" &&
45
+ candidate.email.trim().toLowerCase() === email &&
46
+ roles.includes(candidate.role) &&
47
+ candidate.disabled !== true,
48
+ )
49
+ : undefined;
50
+ return row ? String(row.role) : null;
51
+ }
52
+
53
+ /** True when the host's members list holds `email` with an editor role and not disabled. */
54
+ export const holdsRole = (rows: unknown, email: string): boolean => roleHeld(rows, email) !== null;
55
+
56
+ /** A membership check with its own one-minute memory. */
57
+ export function membershipCheck(options: MembershipOptions = {}): Membership {
58
+ const remembered = new Map<string, { role: string | null; until: number }>();
59
+ const now = options.now ?? Date.now;
60
+ const send = options.fetch ?? fetch;
61
+
62
+ async function ask(email: string): Promise<string | null> {
63
+ const secret = options.secret ?? authSecret();
64
+ if (!secret) return null;
65
+ const cookieName = resolveAccountCookieName(process.env.HECKS_SESSION_COOKIE || EDITOR.hostCookie);
66
+ try {
67
+ const res = await send(`${hostUrl({ url: options.url })}${EDITOR.membersPath}`, {
68
+ headers: { Cookie: `${cookieName}=${accountToken(secret, email, TOKEN_TTL_SECONDS)}` },
69
+ signal: AbortSignal.timeout(EDITOR.timeoutMs),
70
+ });
71
+ return res.ok ? roleHeld(await res.json().catch(() => null), email) : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ async function roleOf(email: string): Promise<string | null> {
78
+ const key = email.trim().toLowerCase();
79
+ const hit = remembered.get(key);
80
+ if (hit && hit.until > now()) return hit.role;
81
+ const role = await ask(key);
82
+ remembered.set(key, { role, until: now() + EDITOR.rememberMs });
83
+ return role;
84
+ }
85
+
86
+ return { roleOf, isAdmitted: async (email: string): Promise<boolean> => (await roleOf(email)) !== null };
87
+ }
@@ -0,0 +1,36 @@
1
+ __BANNER__
2
+
3
+ // The editor's own session: a signed cookie naming who signed in and until when. It is not the
4
+ // host's session cookie and carries no authority by itself; every request still asks the membership
5
+ // list (membership.ts). The value has the host's account-token format, signed with the shared secret
6
+ // plus a suffix, so a host token cannot be passed off as an editor session.
7
+ import { accountToken, cookieValue, verifyAccountToken } from "@hecks/client";
8
+
9
+ import { EDITOR } from "../config.ts";
10
+
11
+ const SUFFIX = ":editor-session";
12
+
13
+ /** The cookie value for `email`, good for the configured session age. */
14
+ export function signSession(secret: string, email: string, now: () => number = Date.now): string {
15
+ return accountToken(secret + SUFFIX, email, EDITOR.sessionMaxAge, { now });
16
+ }
17
+
18
+ /** The email a `Cookie` header's session names, or null when it is absent, forged or expired. */
19
+ export function readSession(header: string | null, secret: string, now: () => number = Date.now): string | null {
20
+ const value = cookieValue(header, EDITOR.sessionCookie);
21
+ if (!value || !secret) return null;
22
+ return verifyAccountToken(value, secret + SUFFIX, { now, normalizeEmail: true })?.email ?? null;
23
+ }
24
+
25
+ const ATTRIBUTES = `Path=${EDITOR.basePath}; HttpOnly; SameSite=Lax`;
26
+
27
+ /** The `Set-Cookie` value that starts a session. `Secure` is added outside local development. */
28
+ export function sessionCookie(value: string): string {
29
+ const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
30
+ return `${EDITOR.sessionCookie}=${value}; ${ATTRIBUTES}; Max-Age=${EDITOR.sessionMaxAge}${secure}`;
31
+ }
32
+
33
+ /** The `Set-Cookie` value that ends a session. */
34
+ export function clearedCookie(): string {
35
+ return `${EDITOR.sessionCookie}=; ${ATTRIBUTES}; Max-Age=0`;
36
+ }
@@ -0,0 +1,20 @@
1
+ __BANNER__
2
+
3
+ // The sign-in. The site's admin hand-off sends a signed-in admin here with a short-lived account
4
+ // token from the host (the same hand-off that opens a content system). The token proves an account,
5
+ // not an editor, so nobody gets a session unless the membership list admits them now.
6
+ import { verifyAccountToken } from "@hecks/client";
7
+
8
+ import { EDITOR } from "../config.ts";
9
+ import type { Membership } from "./membership.ts";
10
+ import { signSession, sessionCookie } from "./session.ts";
11
+
12
+ export type SignIn = { ok: true; location: string; cookie: string } | { ok: false; status: number; message: string };
13
+
14
+ /** Checks the hand-off token and starts a session for the person it names. */
15
+ export async function signIn(token: string | null, secret: string, membership: Membership, now: () => number = Date.now): Promise<SignIn> {
16
+ const claims = token && secret ? verifyAccountToken(token, secret, { now, normalizeEmail: true }) : null;
17
+ if (!claims) return { ok: false, status: 401, message: "Invalid or expired sign-in token." };
18
+ if (!(await membership.isAdmitted(claims.email))) return { ok: false, status: 403, message: "This account is not an editor." };
19
+ return { ok: true, location: EDITOR.basePath, cookie: sessionCookie(signSession(secret, claims.email, now)) };
20
+ }