hecks 3.10.0 → 3.12.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 (542) 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 +158 -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 +70 -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/newcomer.rb +50 -0
  40. data/lib/hecks/adapters/driving/ruby/aggregate_module.rb +171 -0
  41. data/lib/hecks/adapters/driving/ruby/chapter.rb +91 -0
  42. data/lib/hecks/adapters/driving/ruby.rb +83 -0
  43. data/lib/hecks/adapters/driving/usage_cache.rb +144 -0
  44. data/lib/hecks/{doors.rb → adapters/driving.rb} +10 -6
  45. data/lib/hecks/behaviors/expectations/boot.rb +1 -1
  46. data/lib/hecks/bench/ruby_runner.rb +1 -1
  47. data/lib/hecks/bluebook/ask_resolution.rb +130 -0
  48. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +8 -2
  49. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  50. data/lib/hecks/bluebook/assembly/contracts/rules.rb +4 -2
  51. data/lib/hecks/bluebook/assembly/contracts/structure.rb +3 -1
  52. data/lib/hecks/bluebook/assembly/marks.rb +4 -15
  53. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  54. data/lib/hecks/bluebook/attribute.rb +1 -1
  55. data/lib/hecks/bluebook/behaviour/command.rb +13 -8
  56. data/lib/hecks/bluebook/behaviour/policy.rb +30 -0
  57. data/lib/hecks/bluebook/capabilities/attribute_values.rb +72 -0
  58. data/lib/hecks/bluebook/capabilities.rb +91 -9
  59. data/lib/hecks/bluebook/domain_port.rb +16 -4
  60. data/lib/hecks/bluebook/dsl/adapter_builder.rb +18 -6
  61. data/lib/hecks/bluebook/dsl/binding_proxy.rb +13 -0
  62. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/computed_sources.rb +98 -0
  63. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/fan_out_rows.rb +42 -0
  64. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/projected_fields.rb +14 -2
  65. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/provisions.rb +69 -3
  66. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/with_projections.rb +17 -10
  67. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  69. data/lib/hecks/bluebook/dsl/command_builder/mutations.rb +8 -1
  70. data/lib/hecks/bluebook/dsl/command_builder/operands.rb +121 -0
  71. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -0
  72. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +10 -1
  73. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +45 -2
  74. data/lib/hecks/bluebook/dsl/policy_builder.rb +47 -10
  75. data/lib/hecks/bluebook/dsl/rule_reference.rb +7 -4
  76. data/lib/hecks/bluebook/dsl/world_builder.rb +1 -1
  77. data/lib/hecks/bluebook/expression/ast_json/emitters.rb +6 -0
  78. data/lib/hecks/bluebook/expression/ast_json.rb +9 -6
  79. data/lib/hecks/bluebook/expression/ast_reader.rb +14 -9
  80. data/lib/hecks/bluebook/expression/projection.json +36 -0
  81. data/lib/hecks/bluebook/expression/resolver/operations.rb +31 -0
  82. data/lib/hecks/bluebook/expression/resolver/parsing.rb +38 -5
  83. data/lib/hecks/bluebook/expression/resolver/scanning.rb +22 -5
  84. data/lib/hecks/bluebook/expression/resolver/values.rb +10 -0
  85. data/lib/hecks/bluebook/expression/resolver.rb +20 -1
  86. data/lib/hecks/bluebook/hecksagon.rb +23 -3
  87. data/lib/hecks/bluebook/lifecycle.rb +22 -3
  88. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +13 -1
  89. data/lib/hecks/bluebook/meta_validator/modes.rb +4 -1
  90. data/lib/hecks/bluebook/meta_validator/readings.rb +24 -1
  91. data/lib/hecks/bluebook/meta_validator/reconstruction/heads.rb +2 -1
  92. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  93. data/lib/hecks/bluebook/meta_validator/shapes.rb +5 -0
  94. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +1 -1
  95. data/lib/hecks/bluebook/meta_validator.rb +12 -12
  96. data/lib/hecks/bluebook/model_check/ask_checks.rb +71 -0
  97. data/lib/hecks/bluebook/model_check/policy_checks.rb +2 -1
  98. data/lib/hecks/bluebook/model_check/reference_checks.rb +70 -0
  99. data/lib/hecks/bluebook/model_check.rb +8 -2
  100. data/lib/hecks/bluebook/policy.rb +4 -2
  101. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  102. data/lib/hecks/bluebook/synthesizer.rb +2 -0
  103. data/lib/hecks/bluebook.rb +2 -0
  104. data/lib/hecks/cli/command_table.rb +1 -1
  105. data/lib/hecks/cli/console.rb +2 -1
  106. data/lib/hecks/cli/document.rb +1 -1
  107. data/lib/hecks/cli/launcher_words.rb +4 -4
  108. data/lib/hecks/cli/mcp.rb +4 -4
  109. data/lib/hecks/cli/project_cli/launcher_source.rb +2 -2
  110. data/lib/hecks/cli/project_cli/templates.rb +5 -5
  111. data/lib/hecks/cli/project_cli.rb +9 -6
  112. data/lib/hecks/cli/run.rb +2 -2
  113. data/lib/hecks/cli/smoke_http/checks.rb +4 -1
  114. data/lib/hecks/cli/smoke_http/transport.rb +3 -3
  115. data/lib/hecks/cli/smoke_http.rb +3 -0
  116. data/lib/hecks/codemod.rb +5 -2
  117. data/lib/hecks/corpus/routes.rb +7 -0
  118. data/lib/hecks/corpus/rust.rb +3 -2
  119. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -4
  120. data/lib/hecks/doc/reference/rendering.rb +6 -3
  121. data/lib/hecks/forms/app/commands.rb +8 -7
  122. data/lib/hecks/forms/app/queries.rb +2 -1
  123. data/lib/hecks/forms/app.rb +1 -1
  124. data/lib/hecks/forms/record_renderer.rb +1 -1
  125. data/lib/hecks/framework/bluebook/privacy.bluebook +11 -0
  126. data/lib/hecks/fuzzing/concurrent_dispatch.rb +4 -1
  127. data/lib/hecks/fuzzing/failure_triage.rb +2 -1
  128. data/lib/hecks/fuzzing/mutation/run.rb +11 -4
  129. data/lib/hecks/fuzzing/properties/catalog.rb +6 -4
  130. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +18 -11
  131. data/lib/hecks/fuzzing/properties/guards.rb +1 -1
  132. data/lib/hecks/fuzzing/self_consistency/saga_redelivery.rb +1 -1
  133. data/lib/hecks/fuzzing/self_consistency.rb +6 -2
  134. data/lib/hecks/fuzzing/shrinker.rb +3 -1
  135. data/lib/hecks/fuzzing/value_generator.rb +19 -5
  136. data/lib/hecks/gate/stages.yml +33 -7
  137. data/lib/hecks/grammar/evolve.rb +1 -1
  138. data/lib/hecks/grammar/expression_operators.json +372 -0
  139. data/lib/hecks/grammar/operators.rb +3 -1
  140. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +10 -10
  141. data/lib/hecks/hecks/adapters/codebase/inspection/rubycritic.rb +35 -0
  142. data/lib/hecks/hecks/adapters/codebase/inspection.rb +104 -0
  143. data/lib/hecks/hecks/adapters/codebase/publishing.rb +2 -1
  144. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +12 -2
  145. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +2 -1
  146. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +1 -1
  147. data/lib/hecks/hecks/adapters/cost_explorer.rb +8 -5
  148. data/lib/hecks/hecks/adapters/deploy_toolchain/statuses.rb +2 -1
  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 +133 -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/terminal.rb +13 -11
  173. data/lib/hecks/hecks/codebase.behaviors +13 -1
  174. data/lib/hecks/hecks/codebase.bluebook +239 -5
  175. data/lib/hecks/hecks/custodian.behaviors +24 -6
  176. data/lib/hecks/hecks/custodian.bluebook +6 -1227
  177. data/lib/hecks/hecks/deploy.behaviors +1 -1
  178. data/lib/hecks/hecks/deploy.world +1 -3
  179. data/lib/hecks/hecks/hecks.behaviors +1 -1
  180. data/lib/hecks/hecks/hecks.hecksagon +53 -18
  181. data/lib/hecks/hecks/hecks.world +13 -10
  182. data/lib/hecks/hecks/rehearsal.bluebook +477 -0
  183. data/lib/hecks/hecks/site.behaviors +1 -1
  184. data/lib/hecks/hecks/site.world +0 -1
  185. data/lib/hecks/hecks/tooling.bluebook +1238 -0
  186. data/lib/hecks/indifferent_key.rb +20 -0
  187. data/lib/hecks/language/adapter.bluebook +13 -3
  188. data/lib/hecks/language/bluebook/aggregate.bluebook +23 -0
  189. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  190. data/lib/hecks/language/bluebook/bluebook.bluebook +15 -0
  191. data/lib/hecks/language/bluebook/entity.bluebook +18 -0
  192. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  193. data/lib/hecks/language/bluebook/policy.bluebook +9 -1
  194. data/lib/hecks/language/bluebook/query.bluebook +1 -1
  195. data/lib/hecks/language/bluebook/vocabulary.bluebook +63 -4
  196. data/lib/hecks/language/hecksagon/domain_port.bluebook +1 -1
  197. data/lib/hecks/language/hecksagon/hecksagon.bluebook +22 -0
  198. data/lib/hecks/language/oidc.json +10 -0
  199. data/lib/hecks/language/port.bluebook +4 -4
  200. data/lib/hecks/literal.rb +12 -3
  201. data/lib/hecks/ports/key_vault.rb +7 -2
  202. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +21 -10
  203. data/lib/hecks/ports/persistence/plugins/era/postgres_era/boot.rb +4 -2
  204. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +2 -1
  205. data/lib/hecks/ports/persistence/repository_factory.rb +6 -3
  206. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  207. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  208. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  209. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  210. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  211. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  212. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  213. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  214. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  215. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  216. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  217. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  218. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  219. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +32 -0
  220. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +1 -1
  221. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  222. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  223. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  224. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  225. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  226. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  227. data/lib/hecks/projections/deploy/box.rb +15 -3
  228. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  229. data/lib/hecks/projections/deploy/shared.rb +5 -2
  230. data/lib/hecks/projections/model/deviations.rb +2 -2
  231. data/lib/hecks/projections/model.rb +2 -2
  232. data/lib/hecks/projections/oidc.rb +13 -1
  233. data/lib/hecks/projections/shape.rb +5 -2
  234. data/lib/hecks/projections/site/cms_editor/attributes.rb +26 -6
  235. data/lib/hecks/projections/site/cms_editor/blocks.rb +157 -0
  236. data/lib/hecks/projections/site/cms_editor/checks.rb +14 -1
  237. data/lib/hecks/projections/site/cms_editor/clearing.rb +70 -0
  238. data/lib/hecks/projections/site/cms_editor/color.rb +124 -0
  239. data/lib/hecks/projections/site/cms_editor/composition.rb +42 -0
  240. data/lib/hecks/projections/site/cms_editor/destructive.rb +61 -0
  241. data/lib/hecks/projections/site/cms_editor/drafts.rb +102 -0
  242. data/lib/hecks/projections/site/cms_editor/hints.rb +74 -0
  243. data/lib/hecks/projections/site/cms_editor/icons.rb +86 -0
  244. data/lib/hecks/projections/site/cms_editor/key_kinds.rb +31 -0
  245. data/lib/hecks/projections/site/cms_editor/limits.rb +72 -0
  246. data/lib/hecks/projections/site/cms_editor/media.rb +1 -1
  247. data/lib/hecks/projections/site/cms_editor/moment.rb +34 -0
  248. data/lib/hecks/projections/site/cms_editor/palette.rb +115 -0
  249. data/lib/hecks/projections/site/cms_editor/pickers.rb +85 -0
  250. data/lib/hecks/projections/site/cms_editor/preview.rb +91 -0
  251. data/lib/hecks/projections/site/cms_editor/relations.rb +92 -0
  252. data/lib/hecks/projections/site/cms_editor/scale_checks.rb +66 -0
  253. data/lib/hecks/projections/site/cms_editor/scheduling.rb +150 -0
  254. data/lib/hecks/projections/site/cms_editor/schema.rb +96 -19
  255. data/lib/hecks/projections/site/cms_editor/setting.rb +52 -9
  256. data/lib/hecks/projections/site/cms_editor/targets.rb +86 -0
  257. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +18 -1
  258. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +251 -57
  259. data/lib/hecks/projections/site/cms_editor/templates/src/assets.ts.tmpl +98 -0
  260. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +32 -24
  261. data/lib/hecks/projections/site/cms_editor/templates/src/browser/app.css.tmpl +412 -0
  262. data/lib/hecks/projections/site/cms_editor/templates/src/browser/autosave.js.tmpl +144 -0
  263. data/lib/hecks/projections/site/cms_editor/templates/src/browser/blocks.js.tmpl +500 -0
  264. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_doc.js.tmpl +175 -0
  265. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_editor.js.tmpl +418 -0
  266. data/lib/hecks/projections/site/cms_editor/templates/src/browser/epoch.js.tmpl +61 -0
  267. data/lib/hecks/projections/site/cms_editor/templates/src/browser/forms.js.tmpl +210 -0
  268. data/lib/hecks/projections/site/cms_editor/templates/src/browser/lists.js.tmpl +40 -0
  269. data/lib/hecks/projections/site/cms_editor/templates/src/browser/main.js.tmpl +26 -0
  270. data/lib/hecks/projections/site/cms_editor/templates/src/browser/media_picker.js.tmpl +232 -0
  271. data/lib/hecks/projections/site/cms_editor/templates/src/browser/moments.js.tmpl +73 -0
  272. data/lib/hecks/projections/site/cms_editor/templates/src/browser/pickers.js.tmpl +152 -0
  273. data/lib/hecks/projections/site/cms_editor/templates/src/browser/preview.js.tmpl +98 -0
  274. data/lib/hecks/projections/site/cms_editor/templates/src/browser/shell.js.tmpl +140 -0
  275. data/lib/hecks/projections/site/cms_editor/templates/src/browser/toast.js.tmpl +66 -0
  276. data/lib/hecks/projections/site/cms_editor/templates/src/choices.ts.tmpl +99 -0
  277. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +78 -24
  278. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +6 -0
  279. data/lib/hecks/projections/site/cms_editor/templates/src/flash.ts.tmpl +52 -0
  280. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +19 -2
  281. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +31 -8
  282. data/lib/hecks/projections/site/cms_editor/templates/src/names.ts.tmpl +36 -0
  283. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +131 -2
  284. data/lib/hecks/projections/site/cms_editor/templates/src/ui/availability.ts.tmpl +15 -0
  285. data/lib/hecks/projections/site/cms_editor/templates/src/ui/block_list.js.tmpl +213 -0
  286. data/lib/hecks/projections/site/cms_editor/templates/src/ui/blocks.ts.tmpl +275 -0
  287. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +8 -5
  288. data/lib/hecks/projections/site/cms_editor/templates/src/ui/detail.ts.tmpl +88 -0
  289. data/lib/hecks/projections/site/cms_editor/templates/src/ui/dialogs.ts.tmpl +43 -0
  290. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +229 -34
  291. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +40 -81
  292. data/lib/hecks/projections/site/cms_editor/templates/src/ui/icons.ts.tmpl +15 -0
  293. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +29 -12
  294. data/lib/hecks/projections/site/cms_editor/templates/src/ui/links.ts.tmpl +100 -0
  295. data/lib/hecks/projections/site/cms_editor/templates/src/ui/moments.ts.tmpl +44 -0
  296. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +53 -17
  297. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +178 -88
  298. data/lib/hecks/projections/site/cms_editor/templates/src/ui/paging.ts.tmpl +82 -0
  299. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pickers.ts.tmpl +110 -0
  300. data/lib/hecks/projections/site/cms_editor/templates/src/ui/preview.ts.tmpl +87 -0
  301. data/lib/hecks/projections/site/cms_editor/templates/src/ui/related.ts.tmpl +97 -0
  302. data/lib/hecks/projections/site/cms_editor/templates/src/ui/schedule.ts.tmpl +160 -0
  303. data/lib/hecks/projections/site/cms_editor/templates/src/ui/shell.ts.tmpl +211 -0
  304. data/lib/hecks/projections/site/cms_editor/templates/src/ui/table.ts.tmpl +151 -0
  305. data/lib/hecks/projections/site/cms_editor/templates/src/ui/theme.js.tmpl +16 -0
  306. data/lib/hecks/projections/site/cms_editor/templates/src/ui/values.ts.tmpl +69 -0
  307. data/lib/hecks/projections/site/cms_editor/templates/src/ui/words.ts.tmpl +97 -0
  308. data/lib/hecks/projections/site/cms_editor/templates/src/ui/world.ts.tmpl +35 -0
  309. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +2 -1
  310. data/lib/hecks/projections/site/cms_editor/theme.rb +29 -0
  311. data/lib/hecks/projections/site/cms_editor/theme_checks.rb +57 -0
  312. data/lib/hecks/projections/site/cms_editor.rb +49 -23
  313. data/lib/hecks/projections/site/role_probe.rb +2 -0
  314. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  315. data/lib/hecks/projector/cli_audience.rb +22 -1
  316. data/lib/hecks/projector/cli_projector/listing.rb +1 -1
  317. data/lib/hecks/projector/cli_projector/option_specs.rb +1 -1
  318. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  319. data/lib/hecks/projector/docs_projector/closing.rb +1 -1
  320. data/lib/hecks/projector/docs_projector.rb +1 -1
  321. data/lib/hecks/projector/exporter/bindings.rb +40 -10
  322. data/lib/hecks/projector/exporter/capability_values.rb +55 -0
  323. data/lib/hecks/projector/narrate_projector/reactions.rb +1 -1
  324. data/lib/hecks/projector/target.rb +11 -0
  325. data/lib/hecks/projector.rb +32 -5
  326. data/lib/hecks/quality_control/adapters/github_checks.rb +12 -8
  327. data/lib/hecks/quality_control/adapters/github_issues.rb +8 -4
  328. data/lib/hecks/quality_control/cli/qa_open_pr.rb +2 -2
  329. data/lib/hecks/quality_control/cli/qa_sweep/checks/shrinking.rb +1 -1
  330. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +1 -1
  331. data/lib/hecks/quality_control/cli/qa_sweep.rb +1 -1
  332. data/lib/hecks/quality_control/quality_control.bluebook +6 -2
  333. data/lib/hecks/release/approval.rb +64 -0
  334. data/lib/hecks/router/{door.rb → aggregate_reader.rb} +5 -4
  335. data/lib/hecks/router/namespace_installer.rb +16 -16
  336. data/lib/hecks/router.rb +1 -1
  337. data/lib/hecks/runtime/best_effort.rb +22 -0
  338. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -2
  339. data/lib/hecks/runtime/command_interpreter.rb +3 -2
  340. data/lib/hecks/runtime/command_rules/emission.rb +1 -1
  341. data/lib/hecks/runtime/dispatcher.rb +4 -4
  342. data/lib/hecks/runtime/entity_element/location.rb +1 -1
  343. data/lib/hecks/runtime/event.rb +5 -0
  344. data/lib/hecks/runtime/identity.rb +7 -6
  345. data/lib/hecks/runtime/interpreting.rb +2 -1
  346. data/lib/hecks/runtime/invocation_builder.rb +1 -1
  347. data/lib/hecks/runtime/loader/boot_steps.rb +8 -5
  348. data/lib/hecks/runtime/loader.rb +10 -10
  349. data/lib/hecks/runtime/policy_interpreter/arguments.rb +7 -4
  350. data/lib/hecks/runtime/policy_interpreter/fan_out.rb +3 -3
  351. data/lib/hecks/runtime/policy_interpreter.rb +6 -6
  352. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -1
  353. data/lib/hecks/runtime/reaction_invocation.rb +1 -1
  354. data/lib/hecks/runtime/rebuild_sweep.rb +11 -1
  355. data/lib/hecks/runtime/registry/ask_verification.rb +35 -0
  356. data/lib/hecks/runtime/registry/hecksagon_rules.rb +10 -0
  357. data/lib/hecks/runtime/registry/merging.rb +1 -1
  358. data/lib/hecks/runtime/registry/providers.rb +16 -0
  359. data/lib/hecks/runtime/registry/read_repository.rb +6 -4
  360. data/lib/hecks/runtime/registry/verification.rb +4 -1
  361. data/lib/hecks/runtime/registry/world_defaults.rb +29 -0
  362. data/lib/hecks/runtime/registry.rb +1 -1
  363. data/lib/hecks/runtime/saga_interpreter/compensation.rb +1 -1
  364. data/lib/hecks/runtime/saga_interpreter/delivery.rb +4 -4
  365. data/lib/hecks/runtime/saga_interpreter/transitions.rb +1 -1
  366. data/lib/hecks/runtime/saga_interpreter.rb +3 -3
  367. data/lib/hecks/runtime/tenant_check.rb +5 -4
  368. data/lib/hecks/runtime/value/coercion.rb +2 -2
  369. data/lib/hecks/runtime/value/validation.rb +16 -4
  370. data/lib/hecks/runtime.rb +9 -9
  371. data/lib/hecks/rust_build/host.rb +11 -4
  372. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  373. data/lib/hecks/rust_build/project_rust/target_ir.rb +1 -1
  374. data/lib/hecks/rust_build/wasm.rb +1 -1
  375. data/lib/hecks/rust_build/wasm_browser.rb +1 -1
  376. data/lib/hecks/rust_build.rb +11 -0
  377. data/lib/hecks/served_options.rb +53 -0
  378. data/lib/hecks/site/bluebook/site.bluebook +1 -0
  379. data/lib/hecks/storehouse/audit.rb +1 -1
  380. data/lib/hecks/storehouse/dispatching.rb +8 -6
  381. data/lib/hecks/storehouse/introspection.rb +2 -2
  382. data/lib/hecks/storehouse/querying.rb +6 -4
  383. data/lib/hecks/storehouse.rb +4 -4
  384. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  385. data/lib/hecks/tools/ci_gates.rb +20 -1
  386. data/lib/hecks/tools/comment_style/source_file.rb +4 -1
  387. data/lib/hecks/tools/deploy_recipe_lint/fixtures.rb +4 -1
  388. data/lib/hecks/tools/lanes/files.rb +8 -0
  389. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  390. data/lib/hecks/tools/lanes/rows.rb +6 -0
  391. data/lib/hecks/tools/lanes.rb +1 -0
  392. data/lib/hecks/tools/oidc_manifests.rb +4 -5
  393. data/lib/hecks/tools/regeneration_run.rb +11 -4
  394. data/lib/hecks/tools/release_only.rb +111 -0
  395. data/lib/hecks/tools/site_routes/editor_files.rb +12 -5
  396. data/lib/hecks/tools/tenant_provisioning.rb +2 -2
  397. data/lib/hecks/tools/tools_doc.rb +4 -4
  398. data/lib/hecks/version.rb +1 -1
  399. data/lib/hecks/vocabulary.rb +19 -5
  400. data/lib/hecks.rb +15 -15
  401. data/lib/rubocop/cop/hecks/env_dump.rb +69 -0
  402. data/lib/rubocop/cop/hecks/monotonic_duration.rb +53 -0
  403. data/lib/rubocop/cop/hecks/no_wall_clock_in_runtime.rb +34 -0
  404. data/lib/rubocop/cop/hecks/raw_shell_interpolation.rb +79 -0
  405. data/lib/rubocop/cop/hecks/sleep_in_spec.rb +34 -0
  406. data/lib/rubocop/cop/hecks/swallowed_rescue.rb +37 -0
  407. data/lib/rubocop/cop/hecks/symbol_string_key_mix.rb +50 -0
  408. data/lib/rubocop/cop/hecks/untyped_raise.rb +37 -0
  409. data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
  410. data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
  411. data/prebuilt/verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json +1 -0
  412. data/rust/.gitignore +6 -0
  413. data/rust/Cargo.toml +6 -0
  414. data/rust/build/src/binding_pass.rs +103 -0
  415. data/rust/build/src/cargo_sync.rs +6 -0
  416. data/rust/build/src/lineage_pass.rs +3 -3
  417. data/rust/build/src/main.rs +17 -5
  418. data/rust/build/src/pipeline.rs +48 -5
  419. data/rust/build/src/seams_pass.rs +123 -0
  420. data/rust/build/src/translation_pass.rs +167 -0
  421. data/rust/codegen/src/asks.rs +103 -0
  422. data/rust/codegen/src/commands.rs +13 -8
  423. data/rust/codegen/src/constraints.rs +25 -0
  424. data/rust/codegen/src/domain_generator.rs +2 -0
  425. data/rust/codegen/src/expr_emitter.rs +13 -0
  426. data/rust/codegen/src/json_codec.rs +54 -15
  427. data/rust/codegen/src/main.rs +1 -0
  428. data/rust/codegen/src/mutations.rs +128 -3
  429. data/rust/codegen/src/queries.rs +11 -2
  430. data/rust/codegen/src/reactions.rs +1 -1
  431. data/rust/codegen/src/types.rs +166 -8
  432. data/rust/host/HECKS_RELEASE +1 -1
  433. data/rust/host/src/api.rs +4 -4
  434. data/rust/host/src/bin/validate_harness.rs +43 -0
  435. data/rust/host/src/boundary_fuzz/expr_json.rs +2 -2
  436. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  437. data/rust/host/src/boundary_fuzz/server.rs +1 -1
  438. data/rust/host/src/checkout.rs +35 -19
  439. data/rust/host/src/commerce_ir.rs +408 -2
  440. data/rust/host/src/expr_json.rs +118 -0
  441. data/rust/host/src/extension.rs +13 -1
  442. data/rust/host/src/lib.rs +1 -0
  443. data/rust/host/src/mint.rs +1 -1
  444. data/rust/host/src/payments/tests.rs +27 -1
  445. data/rust/host/src/payments.rs +12 -4
  446. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  447. data/rust/host/src/platform_signin/config.rs +512 -0
  448. data/rust/host/src/platform_signin/cookie.rs +237 -0
  449. data/rust/host/src/platform_signin/extension.rs +190 -0
  450. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  451. data/rust/host/src/platform_signin/handler.rs +251 -0
  452. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  453. data/rust/host/src/platform_signin/jti.rs +196 -0
  454. data/rust/host/src/platform_signin/jwks.rs +542 -0
  455. data/rust/host/src/platform_signin/jws.rs +249 -0
  456. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  457. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  458. data/rust/host/src/platform_signin/mod.rs +75 -0
  459. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  460. data/rust/host/src/platform_signin/pages.rs +204 -0
  461. data/rust/host/src/platform_signin/query.rs +233 -0
  462. data/rust/host/src/platform_signin/session.rs +151 -0
  463. data/rust/host/src/platform_signin/settings.rs +385 -0
  464. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  465. data/rust/host/src/platform_signin/test_support.rs +235 -0
  466. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  467. data/rust/host/src/platform_signin/verify.rs +604 -0
  468. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  469. data/rust/host/src/query_step.rs +22 -4
  470. data/rust/host/src/reference_validate.rs +653 -108
  471. data/rust/host/src/resend.rs +2 -0
  472. data/rust/host/src/server.rs +19 -6
  473. data/rust/host/src/ui_schema.rs +132 -5
  474. data/rust/host/src/web/newsletter.rs +7 -8
  475. data/rust/host/src/web/newsletter_send.rs +38 -13
  476. data/rust/host/src/web/registrations.rs +229 -26
  477. data/rust/host/src/web.rs +53 -9
  478. data/rust/parser/src/emit.rs +38 -6
  479. data/rust/parser/src/expr/ast_json.rs +7 -1
  480. data/rust/parser/src/expr/resolver.rs +171 -6
  481. data/rust/parser/src/ir.rs +7 -0
  482. data/rust/parser/src/keywords.rs +24 -0
  483. data/rust/parser/src/main.rs +3 -0
  484. data/rust/parser/src/parse/aggregate.rs +2 -1
  485. data/rust/parser/src/parse/chapter.rs +2 -2
  486. data/rust/parser/src/parse/command.rs +2 -0
  487. data/rust/parser/src/parse/hecksagon.rs +5 -2
  488. data/rust/parser/src/parse/lifecycle.rs +81 -0
  489. data/rust/parser/src/parse/mod.rs +15 -1
  490. data/rust/parser/src/parse/policy.rs +13 -2
  491. data/rust/parser/src/parse/value_object.rs +34 -17
  492. data/rust/parser/src/ruby_value.rs +1 -1
  493. data/rust/src/exemplar/commands.rs +4 -4
  494. data/rust/src/exemplar/constraints.rs +1 -1
  495. data/rust/src/exemplar/json.rs +43 -1
  496. data/rust/src/kernel/cli.rs +7 -5
  497. data/rust/src/kernel/dispatch.rs +2 -2
  498. data/rust/src/kernel/dispatch_guard_tests.rs +1 -1
  499. data/rust/src/kernel/expr.rs +66 -3
  500. data/rust/src/kernel/expression_operators/arithmetic.rs +130 -5
  501. data/rust/src/kernel/expression_operators/text.rs +26 -2
  502. data/rust/src/kernel/json.rs +15 -0
  503. data/rust/src/kernel/mod.rs +1 -1
  504. data/rust/src/kernel/orchestrate.rs +54 -5
  505. data/rust/src/kernel/orchestrate_guard_tests.rs +67 -0
  506. data/rust/src/kernel/pattern.rs +60 -0
  507. data/rust/src/kernel/reference_lookup.rs +90 -7
  508. metadata +178 -38
  509. data/lib/hecks/doors/cli_door/nesting.rb +0 -34
  510. data/lib/hecks/doors/cli_door/shorthand.rb +0 -85
  511. data/lib/hecks/doors/cli_door.rb +0 -124
  512. data/lib/hecks/doors/cli_runner/answers.rb +0 -138
  513. data/lib/hecks/doors/cli_runner/resolution.rb +0 -146
  514. data/lib/hecks/doors/cli_runner/settling.rb +0 -115
  515. data/lib/hecks/doors/cli_runner/streaming.rb +0 -90
  516. data/lib/hecks/doors/cli_runner/suggestions.rb +0 -43
  517. data/lib/hecks/doors/cli_runner.rb +0 -66
  518. data/lib/hecks/doors/command_request.rb +0 -119
  519. data/lib/hecks/doors/handle.rb +0 -208
  520. data/lib/hecks/doors/json_door.rb +0 -132
  521. data/lib/hecks/doors/launcher_options.rb +0 -247
  522. data/lib/hecks/doors/mcp_door/properties.rb +0 -47
  523. data/lib/hecks/doors/mcp_door/tool_calls.rb +0 -113
  524. data/lib/hecks/doors/mcp_door/tool_definitions/auditing.rb +0 -51
  525. data/lib/hecks/doors/mcp_door/tool_definitions/calling.rb +0 -69
  526. data/lib/hecks/doors/mcp_door/tool_definitions/reading.rb +0 -97
  527. data/lib/hecks/doors/mcp_door/tool_definitions.rb +0 -15
  528. data/lib/hecks/doors/mcp_door.rb +0 -156
  529. data/lib/hecks/doors/mcp_door_scope/argument_policy.rb +0 -92
  530. data/lib/hecks/doors/mcp_door_scope/env_problems.rb +0 -55
  531. data/lib/hecks/doors/mcp_door_scope/presenter.rb +0 -78
  532. data/lib/hecks/doors/mcp_door_scope.rb +0 -201
  533. data/lib/hecks/doors/mcp_guide_cache.rb +0 -75
  534. data/lib/hecks/doors/ruby_door/aggregate_door.rb +0 -162
  535. data/lib/hecks/doors/ruby_door/chapter.rb +0 -89
  536. data/lib/hecks/doors/ruby_door.rb +0 -80
  537. data/lib/hecks/doors/usage_cache.rb +0 -139
  538. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +0 -282
  539. data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +0 -109
  540. data/prebuilt/c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal +0 -0
  541. data/prebuilt/cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal +0 -0
  542. data/prebuilt/verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json +0 -1
@@ -7,25 +7,40 @@ __BANNER__
7
7
  // (auth/membership.ts). A visitor with neither is sent to the site's login page; nothing in the
8
8
  // domain is read or changed before that. Posts must come from this site (the cookie is SameSite=Lax
9
9
  // and the Origin header is checked too).
10
- import { readFileSync } from "node:fs";
10
+ import { randomUUID } from "node:crypto";
11
11
 
12
- import { DomainRefusal, DomainUnavailable, rowsOf } from "@hecks/client";
13
- import type { HostClient } from "@hecks/client";
12
+ import { DomainRefusal, DomainUnavailable } from "@hecks/client";
13
+ import type { Answer, HostClient } from "@hecks/client";
14
14
 
15
+ import { serveAsset } from "./assets.ts";
15
16
  import { authSecret, membershipCheck } from "./auth/membership.ts";
16
17
  import type { Membership } from "./auth/membership.ts";
17
- import { readSession } from "./auth/session.ts";
18
+ import { clearedCookie, readSession } from "./auth/session.ts";
18
19
  import { signIn } from "./auth/sso.ts";
19
- import { idOf, instanceIn, runCommand } from "./commands.ts";
20
+ import { choicesFor, unknownKey } from "./choices.ts";
21
+ import { idOf, instanceIn, reading, runCommand } from "./commands.ts";
20
22
  import type { State } from "./commands.ts";
21
23
  import { EDITOR } from "./config.ts";
22
- import { hostClient } from "./host.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";
23
28
  import { SCHEMA } from "./schema.ts";
24
29
  import type { Aggregate, Command } from "./schema.ts";
30
+ import { available, needsPage } from "./ui/detail.ts";
31
+ import { href } from "./ui/html.ts";
25
32
  import { commandArguments, parseForm } from "./ui/input.ts";
26
- import { commandPage, detailPage, home, listPage, notFound } from "./ui/pages.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";
27
36
  import type { Submitted } from "./ui/pages.ts";
28
- import { href } from "./ui/html.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";
29
44
  __MEDIA_IMPORT__
30
45
 
31
46
  export interface AppOptions {
@@ -38,12 +53,39 @@ export interface AppOptions {
38
53
  __MEDIA_OPTION__
39
54
  }
40
55
 
41
- const html = (body: string, status = 200): Response =>
42
- new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
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" } });
43
85
 
44
- const redirect = (location: string, status = 303, cookie?: string): Response => {
86
+ const redirect = (location: string, status = 303, ...cookies: string[]): Response => {
45
87
  const headers = new Headers({ Location: location });
46
- if (cookie) headers.append("Set-Cookie", cookie);
88
+ for (const cookie of cookies) headers.append("Set-Cookie", cookie);
47
89
  return new Response(null, { status, headers });
48
90
  };
49
91
 
@@ -54,6 +96,14 @@ function sameSite(request: Request): boolean {
54
96
  return (request.headers.get("sec-fetch-site") ?? "same-origin") !== "cross-site";
55
97
  }
56
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
+
57
107
  /** The editor, as a handler. */
58
108
  export function createApp(options: AppOptions = {}): (request: Request) => Promise<Response> {
59
109
  const secret = options.secret ?? authSecret();
@@ -62,94 +112,193 @@ export function createApp(options: AppOptions = {}): (request: Request) => Promi
62
112
  const client = hostClient({ url: options.url, fetch: options.fetch });
63
113
  __MEDIA_SETUP__
64
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
+
65
142
  async function handle(request: Request): Promise<Response> {
66
143
  const url = new URL(request.url);
67
144
  const path = url.pathname.replace(/\/+$/, "") || "/";
68
145
  if (path === EDITOR.ssoPath) return enter(url, secret, membership, now);
69
- if (path !== EDITOR.basePath && !path.startsWith(`${EDITOR.basePath}/`)) return html(notFound(path), 404);
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);
70
148
 
71
149
  const email = readSession(request.headers.get("cookie"), secret, now);
72
150
  if (!email || !(await membership.isAdmitted(email))) {
73
- return request.method === "GET" ? redirect(EDITOR.login, 302) : new Response("Sign in again.", { status: 403 });
151
+ return request.method === "GET" ? redirect(EDITOR.login, 302) : plain("Sign in again.", 403);
74
152
  }
75
- if (request.method === "POST" && !sameSite(request)) return new Response("Cross-site post refused.", { status: 403 });
153
+ if (request.method === "POST" && !sameSite(request)) return plain("Cross-site post refused.", 403);
76
154
  const segments = path.slice(EDITOR.basePath.length).split("/").filter(Boolean).map(decodeURIComponent);
155
+ const role = membership.roleOf ? await membership.roleOf(email) : null;
77
156
  __MEDIA_ROUTE__
78
- return route(client, request, url, segments);
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);
79
160
  }
80
161
 
81
162
  return async (request) => {
82
163
  try {
83
164
  return await handle(request);
84
165
  } catch (err) {
85
- if (err instanceof DomainUnavailable) return html(notFound(`The domain could not be reached: ${err.message}`), 502);
166
+ if (err instanceof DomainUnavailable) return anonymous(notFound(`The domain could not be reached: ${err.message}`, "The domain is not answering"), 502);
86
167
  throw err;
87
168
  }
88
169
  };
89
170
  }
90
171
 
91
- /** The browser modules the rich-text widget loads (with the picture picker when there are pictures), for signed-in editors only. */
92
- const ASSETS = __ASSETS__;
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
+ }
93
176
 
94
- function asset(name: string): Response {
95
- if (!ASSETS.includes(name)) return html(notFound(`No asset ${name}.`), 404);
96
- const text = readFileSync(new URL(`./ui/${name}`, import.meta.url), "utf8");
97
- return new Response(text, { headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "private, max-age=300" } });
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());
98
181
  }
99
182
 
100
183
  async function enter(url: URL, secret: string, membership: Membership, now: () => number): Promise<Response> {
101
184
  const result = await signIn(url.searchParams.get("token"), secret, membership, now);
102
- if (!result.ok) return new Response(result.message, { status: result.status });
185
+ if (!result.ok) return plain(result.message, result.status);
103
186
  return redirect(result.location, 302, result.cookie);
104
187
  }
105
188
 
106
- function route(client: HostClient, request: Request, url: URL, segments: string[]): Promise<Response> | Response {
107
- const [name, verb, ...rest] = segments;
108
- if (segments.length === 0) return html(home());
109
- if (name === "assets" && segments.length === 2 && request.method === "GET") return asset(verb);
110
- const agg = SCHEMA.aggregates.find((candidate) => candidate.name === name);
111
- if (!agg) return html(notFound(`No aggregate ${name}.`), 404);
112
- if (verb === undefined) return list(client, agg, url);
113
- if (verb === "id" && rest.length === 1 && request.method === "GET") return detail(client, agg, rest[0], null);
114
- if (request.method === "POST") return post(client, request, agg, verb, rest);
115
- if ((verb === "new" || verb === "run") && rest.length === 1) return commandView(agg, verb, rest[0]);
116
- return html(notFound(`No page ${segments.join("/")}.`), 404);
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
+ };
117
217
  }
118
218
 
119
- async function list(client: HostClient, agg: Aggregate, url: URL): Promise<Response> {
219
+ async function list(client: HostClient, agg: Aggregate, url: URL, scene: Scene): Promise<Response> {
120
220
  const query = agg.queries.find((candidate) => candidate.name === url.searchParams.get("query")) ?? null;
121
221
  const form = parseForm(url.search.slice(1));
122
222
  const args = query ? commandArguments(agg, query.attributes, form) : {};
123
- const asked = query !== null && (query.attributes.length === 0 || Object.keys(args).length > 0);
124
- if (query && !asked) return html(listPage(agg, [], query, null));
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));
125
229
  try {
126
- const rows = await rowsFor(client, agg, asked ? query : null, args);
127
- return html(listPage(agg, rows, query, null));
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));
128
234
  } catch (err) {
129
- if (err instanceof DomainRefusal) return html(listPage(agg, [], query, err.message), 200);
235
+ if (err instanceof DomainRefusal) return scene.show(listPage(agg, [], query, err.message, true, none, choices), 200);
130
236
  throw err;
131
237
  }
132
238
  }
133
239
 
134
240
  async function rowsFor(client: HostClient, agg: Aggregate, query: { name: string } | null, args: State): Promise<[string, State][]> {
135
- if (!query) return client.instancesOf(await client.read(), agg.name);
136
- const rows = rowsOf(await client.query(`${agg.name}.${query.name}`, args), `${agg.name}.${query.name}`) ?? [];
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);
137
244
  return rows.flatMap((row): [string, State][] => {
138
245
  const id = idOf(agg, row);
139
246
  return id === null ? [] : [[id, row]];
140
247
  });
141
248
  }
142
249
 
143
- async function detail(client: HostClient, agg: Aggregate, id: string, shown: Submitted | null, status = 200): Promise<Response> {
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> {
144
287
  const values = instanceIn(client, await client.read(), agg, id);
145
- if (!values) return html(notFound(`No ${agg.name} ${id}.`), 404);
146
- return html(detailPage(agg, id, values, shown), status);
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"))));
147
293
  }
148
294
 
149
- function commandView(agg: Aggregate, verb: string, name: string): Response {
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> {
150
297
  const command = commandFor(agg, verb, name);
151
- if (!command) return html(notFound(`No command ${name}.`), 404);
152
- return html(commandPage(agg, command, href(agg.name, verb, name), null));
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 }));
153
302
  }
154
303
 
155
304
  /** The command a `new` (creating) or `run` (acting on another aggregate) path names, or null. */
@@ -161,16 +310,61 @@ function commandFor(agg: Aggregate, verb: string, name: string): Command | null
161
310
  return command.on === agg.name ? command : null;
162
311
  }
163
312
 
164
- async function post(client: HostClient, request: Request, agg: Aggregate, verb: string, rest: string[]): Promise<Response> {
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> {
165
348
  const onInstance = verb === "id" && rest.length === 2;
166
349
  const command = onInstance ? commandFor(agg, "id", rest[1]) : rest.length === 1 ? commandFor(agg, verb, rest[0]) : null;
167
- if (!command) return html(notFound("No such command."), 404);
350
+ if (!command) return scene.show(notFound("No such command."), 404);
168
351
  const form = parseForm(await request.text());
169
- const args = commandArguments(agg, command.attributes, form);
352
+ const args = withMadeIdentity(agg, command, commandArguments(agg, command.attributes, form, command.empty));
170
353
  const to = onInstance ? rest[0] : command.creates ? undefined : (form.get("__to") ?? "");
171
- const outcome = to === "" ? { ok: false as const, message: "Name the instance to act on." } : await runCommand(client, agg, command, args, to);
172
- if (outcome.ok) return redirect(href(agg.name, "id", outcome.id));
173
- const shown: Submitted = { command: command.name, message: outcome.message, values: { ...args, __to: to } };
174
- if (onInstance) return detail(client, agg, rest[0], shown, 422);
175
- return html(commandPage(agg, command, href(agg.name, verb, command.name), shown), 422);
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));
176
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
+ }
@@ -24,6 +24,8 @@ export function authSecret(): string {
24
24
  export interface Membership {
25
25
  /** True while `email` holds an editor role on the host; false when it does not, or cannot be asked. */
26
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>;
27
29
  }
28
30
 
29
31
  export interface MembershipOptions {
@@ -33,47 +35,53 @@ export interface MembershipOptions {
33
35
  now?: () => number;
34
36
  }
35
37
 
36
- /** True when the host's members list holds `email` with an editor role and not disabled. */
37
- export function holdsRole(rows: unknown, email: string): boolean {
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 {
38
40
  const roles: readonly string[] = EDITOR.roles;
39
- return (
40
- Array.isArray(rows) &&
41
- rows.some(
42
- (row: any) =>
43
- typeof row?.email === "string" && row.email.trim().toLowerCase() === email && roles.includes(row.role) && row.disabled !== true,
44
- )
45
- );
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;
46
51
  }
47
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
+
48
56
  /** A membership check with its own one-minute memory. */
49
57
  export function membershipCheck(options: MembershipOptions = {}): Membership {
50
- const remembered = new Map<string, { admitted: boolean; until: number }>();
58
+ const remembered = new Map<string, { role: string | null; until: number }>();
51
59
  const now = options.now ?? Date.now;
52
60
  const send = options.fetch ?? fetch;
53
61
 
54
- async function ask(email: string): Promise<boolean> {
62
+ async function ask(email: string): Promise<string | null> {
55
63
  const secret = options.secret ?? authSecret();
56
- if (!secret) return false;
64
+ if (!secret) return null;
57
65
  const cookieName = resolveAccountCookieName(process.env.HECKS_SESSION_COOKIE || EDITOR.hostCookie);
58
66
  try {
59
67
  const res = await send(`${hostUrl({ url: options.url })}${EDITOR.membersPath}`, {
60
68
  headers: { Cookie: `${cookieName}=${accountToken(secret, email, TOKEN_TTL_SECONDS)}` },
61
69
  signal: AbortSignal.timeout(EDITOR.timeoutMs),
62
70
  });
63
- return res.ok && holdsRole(await res.json().catch(() => null), email);
71
+ return res.ok ? roleHeld(await res.json().catch(() => null), email) : null;
64
72
  } catch {
65
- return false;
73
+ return null;
66
74
  }
67
75
  }
68
76
 
69
- return {
70
- async isAdmitted(email: string): Promise<boolean> {
71
- const key = email.trim().toLowerCase();
72
- const hit = remembered.get(key);
73
- if (hit && hit.until > now()) return hit.admitted;
74
- const admitted = await ask(key);
75
- remembered.set(key, { admitted, until: now() + EDITOR.rememberMs });
76
- return admitted;
77
- },
78
- };
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 };
79
87
  }