@jr2/orchestrator 0.6.0 → 0.7.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 (301) hide show
  1. package/console/components/app.ts +55 -15
  2. package/console/components/bottom-drawer.ts +259 -0
  3. package/console/components/drawer.ts +14 -6
  4. package/console/components/fleet.ts +36 -13
  5. package/console/components/machine-pane.ts +61 -5
  6. package/console/components/nav.ts +54 -8
  7. package/console/conversation.ts +161 -0
  8. package/console/login.ts +123 -0
  9. package/console/main.ts +600 -60
  10. package/console/store.ts +611 -64
  11. package/console/style.css +231 -0
  12. package/docs/agents-block.md +2 -2
  13. package/docs/concepts/agents-and-turns.md +86 -9
  14. package/docs/concepts/architecture.md +41 -28
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +7 -5
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +4 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +50 -22
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +5 -4
  23. package/docs/concepts/pools-and-sources.md +187 -11
  24. package/docs/concepts/secrets-and-trust.md +73 -33
  25. package/docs/concepts/the-menu.md +11 -0
  26. package/docs/concepts/workspaces.md +6 -5
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +43 -34
  29. package/docs/get-started/connect-a-model.md +1 -1
  30. package/docs/get-started/create-an-instance.md +17 -14
  31. package/docs/guides/add-tools-to-the-sandbox.md +55 -11
  32. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  33. package/docs/guides/answer-a-gate.md +74 -37
  34. package/docs/guides/bind-open-parts.md +24 -8
  35. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  36. package/docs/guides/cancel-a-run.md +26 -16
  37. package/docs/guides/fix-a-failed-converge.md +18 -6
  38. package/docs/guides/give-a-caller-a-scope.md +203 -0
  39. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  40. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  41. package/docs/guides/let-an-agent-write-a-chart.md +9 -6
  42. package/docs/guides/look-inside-a-sandbox.md +101 -31
  43. package/docs/guides/open-the-console.md +63 -14
  44. package/docs/guides/read-a-conversation.md +225 -0
  45. package/docs/guides/run-a-chart-by-name.md +17 -16
  46. package/docs/guides/run-one-worker-per-item.md +15 -9
  47. package/docs/guides/set-an-agents-access-and-dials.md +2 -0
  48. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  49. package/docs/guides/start-a-run-from-a-run.md +18 -16
  50. package/docs/guides/start-a-run.md +24 -18
  51. package/docs/guides/steer-an-agent.md +332 -0
  52. package/docs/guides/use-a-hosted-provider.md +1 -1
  53. package/docs/guides/watch-a-run.md +127 -121
  54. package/docs/guides/write-a-hook-kind.md +238 -0
  55. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  56. package/docs/guides/write-a-workflow.md +17 -7
  57. package/docs/llms.txt +81 -15
  58. package/docs/reference/authoring-surface/agent.md +1 -1
  59. package/docs/reference/authoring-surface/answer.md +1 -1
  60. package/docs/reference/authoring-surface/arg.md +1 -1
  61. package/docs/reference/authoring-surface/basic.md +1 -1
  62. package/docs/reference/authoring-surface/bearer.md +1 -1
  63. package/docs/reference/authoring-surface/branchname.md +1 -1
  64. package/docs/reference/authoring-surface/carry.md +23 -0
  65. package/docs/reference/authoring-surface/chartschema.md +1 -1
  66. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  67. package/docs/reference/authoring-surface/command.md +1 -1
  68. package/docs/reference/authoring-surface/customize.md +4 -1
  69. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  70. package/docs/reference/authoring-surface/defineevent.md +1 -1
  71. package/docs/reference/authoring-surface/dispatch.md +5 -4
  72. package/docs/reference/authoring-surface/fail.md +1 -1
  73. package/docs/reference/authoring-surface/file.md +1 -1
  74. package/docs/reference/authoring-surface/frompick.md +1 -1
  75. package/docs/reference/authoring-surface/gate.md +118 -0
  76. package/docs/reference/authoring-surface/group-agents.md +15 -0
  77. package/docs/reference/authoring-surface/group-charts.md +16 -0
  78. package/docs/reference/authoring-surface/group-composition.md +25 -0
  79. package/docs/reference/authoring-surface/group-config.md +17 -0
  80. package/docs/reference/authoring-surface/group-effects.md +48 -0
  81. package/docs/reference/authoring-surface/group-failure.md +13 -0
  82. package/docs/reference/authoring-surface/group-gates.md +16 -0
  83. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  84. package/docs/reference/authoring-surface/group-steering.md +12 -0
  85. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  86. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  87. package/docs/reference/authoring-surface/http.md +1 -1
  88. package/docs/reference/authoring-surface/isfailure.md +1 -1
  89. package/docs/reference/authoring-surface/isready.md +1 -1
  90. package/docs/reference/authoring-surface/jr2setup.md +13 -3
  91. package/docs/reference/authoring-surface/mcp.md +107 -0
  92. package/docs/reference/authoring-surface/open.md +5 -3
  93. package/docs/reference/authoring-surface/openbody.md +1 -1
  94. package/docs/reference/authoring-surface/paramsof.md +1 -1
  95. package/docs/reference/authoring-surface/pool.md +77 -9
  96. package/docs/reference/authoring-surface/push.md +1 -1
  97. package/docs/reference/authoring-surface/readyof.md +1 -1
  98. package/docs/reference/authoring-surface/reread.md +19 -0
  99. package/docs/reference/authoring-surface/secret.md +1 -1
  100. package/docs/reference/authoring-surface/sentby.md +1 -1
  101. package/docs/reference/authoring-surface/skip.md +22 -0
  102. package/docs/reference/authoring-surface/source.md +56 -1
  103. package/docs/reference/authoring-surface/steer.md +93 -0
  104. package/docs/reference/authoring-surface/text.md +1 -1
  105. package/docs/reference/authoring-surface/type-agentdefinition.md +14 -1
  106. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  107. package/docs/reference/authoring-surface/type-anyframepath.md +1 -1
  108. package/docs/reference/authoring-surface/type-anyworktreepath.md +1 -1
  109. package/docs/reference/authoring-surface/type-binding.md +1 -1
  110. package/docs/reference/authoring-surface/type-caller.md +40 -0
  111. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  112. package/docs/reference/authoring-surface/type-carry.md +19 -0
  113. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  114. package/docs/reference/authoring-surface/type-commandoutput.md +1 -1
  115. package/docs/reference/authoring-surface/type-customize.md +16 -1
  116. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  117. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  118. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  119. package/docs/reference/authoring-surface/type-dispatchoutput.md +1 -1
  120. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  121. package/docs/reference/authoring-surface/type-framepath.md +1 -1
  122. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  123. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  124. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  125. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  126. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  127. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  128. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  129. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  130. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  131. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  132. package/docs/reference/authoring-surface/type-hostinjectedinput.md +1 -1
  133. package/docs/reference/authoring-surface/type-httpoutput.md +1 -1
  134. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  135. package/docs/reference/authoring-surface/type-lateroutcome.md +1 -1
  136. package/docs/reference/authoring-surface/type-lends.md +5 -1
  137. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  138. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  139. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  140. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  141. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  142. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  143. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  144. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  145. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  146. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  147. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  148. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  149. package/docs/reference/authoring-surface/type-poolnextinput.md +1 -1
  150. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  151. package/docs/reference/authoring-surface/type-pushoutput.md +1 -1
  152. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  153. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  154. package/docs/reference/authoring-surface/type-reposlot.md +1 -1
  155. package/docs/reference/authoring-surface/type-reread.md +18 -0
  156. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  157. package/docs/reference/authoring-surface/type-sender.md +26 -1
  158. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  159. package/docs/reference/authoring-surface/type-size.md +1 -1
  160. package/docs/reference/authoring-surface/type-skip.md +19 -0
  161. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  162. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  163. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  164. package/docs/reference/authoring-surface/type-sourcespec.md +16 -7
  165. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  166. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  167. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  168. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  169. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  170. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  171. package/docs/reference/authoring-surface/type-workspaced.md +1 -1
  172. package/docs/reference/authoring-surface/type-worktreefound.md +3 -3
  173. package/docs/reference/authoring-surface/type-worktreeinput.md +1 -1
  174. package/docs/reference/authoring-surface/type-worktreeoptions.md +1 -1
  175. package/docs/reference/authoring-surface/type-worktreeoutput.md +1 -1
  176. package/docs/reference/authoring-surface/type-worktreepath.md +1 -1
  177. package/docs/reference/authoring-surface/type-worktreeready.md +1 -1
  178. package/docs/reference/authoring-surface/type-worktreerefused.md +4 -4
  179. package/docs/reference/authoring-surface/workspace.md +1 -1
  180. package/docs/reference/authoring-surface/worktree.md +1 -1
  181. package/docs/reference/authoring-surface.md +88 -167
  182. package/docs/reference/cli.md +413 -74
  183. package/docs/reference/config.md +235 -2
  184. package/docs/reference/decisions/0002-duplex-streaming-actor.md +71 -11
  185. package/docs/reference/decisions/0005-sandbox-pod-composition.md +14 -10
  186. package/docs/reference/decisions/0006-agent-control-surface.md +4 -4
  187. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  188. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  189. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  190. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  191. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +9 -2
  192. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +20 -14
  193. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  194. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +18 -14
  195. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -12
  196. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  197. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +32 -18
  198. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +14 -1
  199. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  200. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +116 -34
  201. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +38 -37
  202. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  203. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +7 -6
  204. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +23 -7
  205. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  206. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  207. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -18
  208. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  209. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +4 -3
  210. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  211. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +5 -4
  212. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +19 -19
  213. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  214. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  215. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  216. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +7 -0
  217. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +13 -7
  218. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +38 -30
  219. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +64 -17
  220. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +2 -1
  221. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  222. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  223. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +51 -23
  224. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +94 -54
  225. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +101 -47
  226. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  227. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +17 -13
  228. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  229. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  230. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +4 -3
  231. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +37 -10
  232. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  233. package/docs/reference/decisions/0079-an-agent-holds-mcp-tools-a-machine-calls-them-as-an-effect-and-the-machine-is-the-approval.md +198 -0
  234. package/docs/reference/decisions.md +6 -4
  235. package/docs/reference/glossary.md +96 -42
  236. package/docs/reference/hooks.md +1077 -0
  237. package/docs/reference/http-api.md +976 -149
  238. package/docs/why.md +2 -1
  239. package/package.json +10 -3
  240. package/src/actor.ts +36 -10
  241. package/src/agent.ts +50 -0
  242. package/src/assembly-checks.ts +530 -4
  243. package/src/callers.ts +245 -0
  244. package/src/chart.ts +52 -7
  245. package/src/config.ts +550 -1
  246. package/src/conversations.ts +453 -0
  247. package/src/custodian.ts +8 -2
  248. package/src/customize.ts +143 -4
  249. package/src/dispatch-effect.ts +142 -26
  250. package/src/durability.ts +2 -0
  251. package/src/duration.ts +20 -0
  252. package/src/effect-checks.ts +2 -1
  253. package/src/effect.ts +60 -7
  254. package/src/effector-wire.ts +272 -27
  255. package/src/gate-brand.ts +16 -0
  256. package/src/gate.ts +634 -48
  257. package/src/harness-client.ts +10 -4
  258. package/src/held-secrets.ts +72 -23
  259. package/src/holds.ts +2 -2
  260. package/src/hook-kinds/discord.ts +92 -0
  261. package/src/hook-kinds/door.ts +107 -0
  262. package/src/hook-kinds/github.ts +89 -0
  263. package/src/hook-kinds/gitlab.ts +72 -0
  264. package/src/hook-kinds/hook.ts +7 -0
  265. package/src/hook-kinds/index.ts +465 -0
  266. package/src/hook-kinds/slack.ts +151 -0
  267. package/src/hook-kinds/webhook.ts +113 -0
  268. package/src/hooks.ts +506 -0
  269. package/src/http-effect.ts +76 -38
  270. package/src/http.ts +889 -94
  271. package/src/index.ts +16 -5
  272. package/src/instance.ts +196 -26
  273. package/src/internal.ts +22 -3
  274. package/src/machine-doc.ts +26 -1
  275. package/src/mcp-effect.ts +424 -0
  276. package/src/mount-digest.ts +41 -7
  277. package/src/names.ts +64 -2
  278. package/src/notices.ts +138 -10
  279. package/src/open-body.ts +25 -11
  280. package/src/open-effect.ts +17 -4
  281. package/src/open.ts +4 -2
  282. package/src/parts.ts +87 -9
  283. package/src/party.ts +2 -1
  284. package/src/pick.ts +2 -2
  285. package/src/pool.ts +1142 -163
  286. package/src/registration.ts +203 -13
  287. package/src/run-host.ts +1509 -171
  288. package/src/run-list.ts +191 -0
  289. package/src/sandbox-kube.ts +53 -5
  290. package/src/secret-placement.ts +239 -33
  291. package/src/server.ts +53 -4
  292. package/src/setup.ts +72 -32
  293. package/src/snapshot-store.ts +565 -48
  294. package/src/steer.ts +111 -0
  295. package/src/stub-harness.ts +6 -2
  296. package/src/tokens.ts +263 -22
  297. package/src/wire.ts +17 -0
  298. package/src/workspace.ts +50 -6
  299. package/src/worktree.ts +3 -3
  300. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  301. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
@@ -0,0 +1,465 @@
1
+ // `@jr2/orchestrator/hooks`: the interface between the Hook's door (jr2) and a Hook kind (ADR-0078).
2
+ //
3
+ // A Hook kind is one outside service's protocol, as a plain value: data, and pure, synchronous
4
+ // functions. jr2's own kinds (`@jr2/orchestrator/hooks/github` and the others) use this interface and nothing
5
+ // more, so a kind from another package can do all that they do.
6
+ //
7
+ // THE DOOR (jr2, the same for every kind) THE KIND (one for each service)
8
+ // POST /hooks/:name, 401, the hook log what the service signed, and the signature
9
+ // the repeat check (id AND digest) the service's timestamp, if it is signed
10
+ // the clock and the window (300 s) the decoding of the body (JSON, form)
11
+ // RUNS the verifier: `mac` in the Instance the answer to a handshake
12
+ // Effector, `token` and `ed25519` in the the event key, the delivery id, `onBehalfOf`
13
+ // Orchestrator a zod schema for each event key
14
+ // RUNS the parse of the payload who reads the answer: the service or a person
15
+ // the fan-out to Gates and Sources the envelope of a text that a person reads
16
+ // `reply`: the join, the skip rule, the the limit on the text, and the hosts of a
17
+ // door's own text, the cut to `maxText` follow-up
18
+ // the follow-up POST (Instance Effector `http`)
19
+ //
20
+ // The rules:
21
+ // 1. A kind never gets a credential. jr2 makes the kind's constructor (`defineHookKind`), so the
22
+ // credential goes from the config to the door and never through code of the kind. For
23
+ // `token`, the door removes the token's header before it calls the kind.
24
+ // 2. A kind is pure and synchronous: no I/O, no Promise, no clock. The door owns the time.
25
+ // 3. `jr2.config.ts` declares a kind by `import`, and `jr2 up` bundles it into the Assembly. jr2
26
+ // finds no kind in a folder and loads none at run time.
27
+ // 4. A kind adds its id and its payloads to `HookKinds` (below), so the `hooks` of a Gate or a
28
+ // Source are typed by kind id and event key (ADR-0011).
29
+ // 5. A kind gives a zod schema for each event key. The door parses each payload with it, after
30
+ // the verifier and before the fan-out, and a handler's payload has the schema's output type.
31
+
32
+ import type { z } from "zod";
33
+ import { HOOK } from "./hook.ts";
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
36
+ // 1. What the door gives a kind: the request, and nothing more.
37
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * One delivery, as the door got it. All of it is data. Header names are lowercase.
41
+ *
42
+ * For a kind with a `token` verifier, `headers` does not hold the token's header: the door removes
43
+ * it before it calls the kind. For `mac` and `ed25519`, no part of the request is secret: a
44
+ * signature cannot make another signature.
45
+ */
46
+ export type HookRequest = {
47
+ readonly headers: { readonly [name: string]: string | undefined };
48
+ /** The raw body. A `mac` or `ed25519` signature covers these bytes. */
49
+ readonly body: Uint8Array;
50
+ /** The raw body as UTF-8. */
51
+ readonly text: string;
52
+ };
53
+
54
+ /** A JSON value: what a kind puts in an answer. */
55
+ export type Json = null | boolean | number | string | readonly Json[] | { readonly [k: string]: Json };
56
+
57
+ /**
58
+ * The body as JSON. It throws when the body is not JSON. A throw from `read` is a 400, and the
59
+ * hook log keeps the error.
60
+ */
61
+ export function json(req: HookRequest): unknown {
62
+ try {
63
+ return JSON.parse(req.text) as unknown;
64
+ } catch {
65
+ throw new Error("the body is not JSON");
66
+ }
67
+ }
68
+
69
+ /**
70
+ * The body as `application/x-www-form-urlencoded` fields. When a field occurs more than one time,
71
+ * the first value is the field's value.
72
+ */
73
+ export function form(req: HookRequest): { readonly [field: string]: string | undefined } {
74
+ const out: { [field: string]: string } = Object.create(null) as { [field: string]: string };
75
+ for (const [name, value] of new URLSearchParams(req.text)) {
76
+ if (!Object.hasOwn(out, name)) out[name] = value;
77
+ }
78
+ return Object.freeze(out);
79
+ }
80
+
81
+ /**
82
+ * The value at a dotted path (`sender.login`, `actions.0.action_id`) of a decoded body, or
83
+ * `undefined`. It reads own properties only.
84
+ */
85
+ export function at(value: unknown, path: string): unknown {
86
+ let here: unknown = value;
87
+ for (const step of path.split(".")) {
88
+ if (here === null || typeof here !== "object" || !Object.hasOwn(here, step)) return undefined;
89
+ here = (here as { readonly [k: string]: unknown })[step];
90
+ }
91
+ return here;
92
+ }
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
95
+ // 2. The verifier: a closed set of three (ADR-0078). The kind says WHAT to check; the door checks.
96
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * What the service signed, from one request.
100
+ *
101
+ * The door makes the checks. For `mac`, it first checks `at` against its clock, then sends the
102
+ * bytes, the signature, the algorithm and the NAME of the Hook's secret to the Instance Effector,
103
+ * which answers "match" or "no match" (ADR-0068). For `ed25519`, it checks `at`, then the
104
+ * signature against the Hook's public key, in the Orchestrator.
105
+ */
106
+ export type Signed = {
107
+ /** The bytes that the service signed, in sequence. The door joins them. A string is UTF-8.
108
+ * GitHub: `[req.body]`. Slack: `["v0:", timestamp, ":", req.body]`. */
109
+ readonly bytes: readonly (string | Uint8Array)[];
110
+ /** The signature, with no prefix (`sha256=`, `v0=`): the kind removes the prefix. */
111
+ readonly signature: { readonly hex: string } | { readonly base64: string };
112
+ /**
113
+ * The service's time of the delivery, in seconds since the epoch. Give it ONLY when the
114
+ * signature covers it (Slack, Discord). The door refuses a delivery whose `at` is more than
115
+ * 300 s from its clock: 401. A kind cannot change the window.
116
+ */
117
+ readonly at?: number;
118
+ };
119
+
120
+ /** The request does not hold what the verifier needs. The door answers 401 and logs `unsigned`. */
121
+ export type Unsigned = { readonly unsigned: string };
122
+
123
+ /** The HMAC algorithms that a `mac` verifier can name. */
124
+ export type MacAlg = "sha256" | "sha1" | "sha512";
125
+
126
+ /** How the service signs. One of three. */
127
+ export type Verify =
128
+ /** An HMAC that a shared secret makes. Only the Instance Effector holds the secret. */
129
+ | {
130
+ readonly mac: {
131
+ readonly alg: MacAlg;
132
+ readonly signed: (req: HookRequest) => Signed | Unsigned;
133
+ };
134
+ }
135
+ /** An Ed25519 signature that the service's private key makes. The Hook's config holds the PUBLIC
136
+ * key as a literal: it can check a signature and cannot make one. */
137
+ | { readonly ed25519: { readonly signed: (req: HookRequest) => Signed | Unsigned } }
138
+ /** A shared token in a header (GitLab). The door compares its sha-256 with the digest that
139
+ * `jr2 up` gave it, then removes the header. The kind never reads the token. */
140
+ | { readonly token: { readonly header: string } };
141
+
142
+ /**
143
+ * A named secret: the value of `secret("NAME")` from `@jr2/orchestrator`. The config holds the
144
+ * name only. The value is in the Instance Effector (`mac`), or its digest is in the Orchestrator
145
+ * (`token`).
146
+ */
147
+ export type HookSecret = { readonly secret: string };
148
+
149
+ /** What the Hook's config gives for each verifier. The kind never gets it. */
150
+ export type CredentialOf<V extends Verify> = V extends { readonly ed25519: unknown }
151
+ ? { readonly publicKey: string }
152
+ : { readonly secret: HookSecret };
153
+
154
+ /** The credential of a Hook, for each of the three verifiers. */
155
+ export type HookCredential = { readonly secret: HookSecret } | { readonly publicKey: string };
156
+
157
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
158
+ // 3. What a kind reads from a verified request: a handshake, or a delivery.
159
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * An answer that the kind writes. Its status is always 200: the door owns every other status.
163
+ * `body`: an object or an array is JSON, a string is `text/plain`, `null` is an empty body.
164
+ * `then`: one POST that the door makes AFTER the answer, through the Instance Effector's `http`,
165
+ * with no secret, one time (the `response_url` of a Slack click). Its url must start with one of
166
+ * the kind's `posts`. A failed post is a line in the hook log.
167
+ */
168
+ export type Answer = {
169
+ readonly body: Json;
170
+ readonly then?: { readonly post: `https://${string}`; readonly json: Json };
171
+ };
172
+
173
+ /** The service's handshake (Slack `url_verification`, Discord `PING`, GitHub `ping`). It comes
174
+ * after the verifier and gets this answer. It reaches no handler, and the hook log keeps it as
175
+ * `handshake`. */
176
+ export type Handshake = { readonly handshake: Answer };
177
+
178
+ /**
179
+ * One delivery, which the door gives to the Gates and the Sources whose handlers hear its kind and
180
+ * `key`. The kind's `events` gives the schema for each key. A key that `events` does not name is
181
+ * still a delivery: the door does not parse it, and no handler can hear it, so it reaches nothing.
182
+ */
183
+ export type Delivery = {
184
+ /** The event key. GitHub: `pull_request_review.submitted`. */
185
+ readonly key: string;
186
+ /** The delivery id. The door's repeat check uses it AND the digest of the body. */
187
+ readonly id: string;
188
+ /** The decoded body. The signature covers it. The door parses it with the kind's schema for
189
+ * `key` (see `events`), and a handler gets the schema's output. */
190
+ readonly payload: unknown;
191
+ /** The party that the service says acted. The signature covers it, so jr2 marks it checked. */
192
+ readonly onBehalfOf?: string;
193
+ /**
194
+ * Who reads the answer. `"service"` (the default): the answer is the outcome JSON with the
195
+ * status that ADR-0011 gives (429, 503). `"person"` (a chat command or a click): the door joins
196
+ * the `reply` texts, cuts them to `maxText`, and calls the kind's `answer`. The status is 200.
197
+ */
198
+ readonly readBy?: "service" | "person";
199
+ };
200
+
201
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
202
+ // 4. The kind.
203
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
204
+
205
+ /** The version of this interface. jr2 1.x calls each kind of `api: 1` as this module says. */
206
+ export type HookKindApi = 1;
207
+
208
+ /** The versions of the interface that this copy of `@jr2/orchestrator/hooks` knows. */
209
+ export const HOOK_KIND_APIS: readonly HookKindApi[] = Object.freeze([1]);
210
+
211
+ /**
212
+ * The schema of each event key: `{ "pull_request_review.submitted": z.looseObject({ … }) }`.
213
+ *
214
+ * - The door parses the payload of a delivery with the schema of its key, after the verifier and
215
+ * before the fan-out. A failed parse reaches no handler: the hook log keeps a `payload-refused`
216
+ * line with zod's issues, and the answer names the kind and the key. The status is 200, because
217
+ * a second try fails in the same way.
218
+ * - Use `z.looseObject` for each object. A loose object keeps the fields that it does not name, so
219
+ * a new field from the service breaks nothing. A named field that is missing, or that has the
220
+ * wrong type, fails the parse. Name only the fields that handlers need. A value that the service
221
+ * can extend (a review's `state`) is a string, not an enum.
222
+ * - A key that ends in `.*` is the schema of each key with that prefix: `"block_actions.*"` types
223
+ * `block_actions.approve` and `block_actions.reject`. An exact key comes first, then the longest
224
+ * prefix.
225
+ */
226
+ export type Events = { readonly [key: string]: z.ZodType };
227
+
228
+ /** A key of `events` as a handler's key: `"block_actions.*"` → `` `block_actions.${string}` ``. */
229
+ export type HandlerKey<K> = K extends `${infer P}.*` ? `${P}.${string}` : K;
230
+
231
+ /**
232
+ * The schema's output with only its named fields. The output of a loose object also has
233
+ * `[field: string]: unknown`. The fields that it does not name stay in the value, but this type
234
+ * removes the index signature, so a handler cannot read a field that its schema does not name.
235
+ */
236
+ export type Named<T> = T extends readonly unknown[]
237
+ ? { readonly [I in keyof T]: Named<T[I]> }
238
+ : T extends object
239
+ ? { readonly [K in keyof T as string extends K ? never : K]: Named<T[K]> }
240
+ : T;
241
+
242
+ /** The payload type of each handler key of `events`. */
243
+ export type Payloads<E extends Events> = {
244
+ readonly [K in keyof E & string as HandlerKey<K>]: Named<z.output<E[K]>>;
245
+ };
246
+
247
+ /**
248
+ * A Hook kind: one outside service's protocol.
249
+ *
250
+ * - `api`: the version of this interface that the kind was written for. Only `1` exists.
251
+ * - `id`: the key of the `hooks` of a Gate or a Source. One id is one kind in an Assembly.
252
+ * Lowercase letters, digits and `-`, and it starts with a letter.
253
+ * - `events`: a zod schema for each event key that a handler can hear ({@link Events}).
254
+ * - `verify`: one of the three verifiers, and what the service signed.
255
+ * - `read`: the verified request, as a handshake or a delivery. A throw is 400, and the hook log
256
+ * keeps the error.
257
+ * - `answer`: the envelope of the text for a delivery with `readBy: "person"`. The door gives the
258
+ * joined text, already cut to `maxText`. The kind escapes it. A kind with no `answer` cannot give
259
+ * `readBy: "person"`: the type of `read` refuses it ({@link defineHookKind}).
260
+ * - `maxText`: the service's limit on the length of the text (Discord: 2000).
261
+ * - `posts`: the url prefixes of `Answer.then`. `jr2 up` adds them to the Instance Effector's fence
262
+ * for each Hook of this kind (ADR-0068). The door refuses a `then` to another url.
263
+ */
264
+ export type HookKind<Id extends string = string, E extends Events = Events, V extends Verify = Verify> = {
265
+ readonly api: HookKindApi;
266
+ readonly id: Id;
267
+ readonly events: E;
268
+ readonly verify: V;
269
+ readonly read: (req: HookRequest) => Handshake | Delivery;
270
+ readonly answer?: (text: string, delivery: Delivery) => Answer;
271
+ readonly maxText?: number;
272
+ readonly posts?: readonly `https://${string}/`[];
273
+ };
274
+
275
+ /** The Workflows whose Gates a Hook may answer (ADR-0078). The default is `"all"`. A Hook never
276
+ * starts a run. */
277
+ export type HookMay = { readonly answer: "all" | readonly string[] };
278
+
279
+ /** The parts of a Hook, which only the door reads. */
280
+ export type HookParts<K extends HookKind = HookKind> = {
281
+ readonly kind: K;
282
+ readonly credential: HookCredential;
283
+ readonly may: HookMay;
284
+ };
285
+
286
+ /**
287
+ * A Hook: a kind, a credential and `may`. It is the value of one entry of `hooks` in
288
+ * `jr2.config.ts`, and the entry's key is its name, the last segment of `POST /hooks/:name`. Only
289
+ * the door reads it.
290
+ */
291
+ export type Hook<K extends HookKind = HookKind> = { readonly [HOOK]: HookParts<K> };
292
+
293
+ /** A kind is also the constructor of its Hooks: `github({ secret: secret("GH_HOOK"), may })`. */
294
+ export type HookKindOf<Id extends string, E extends Events, V extends Verify> = HookKind<Id, E, V> &
295
+ ((hook: CredentialOf<V> & { readonly may?: HookMay }) => Hook<HookKind<Id, E, V>>);
296
+
297
+ /**
298
+ * Define a kind. jr2 makes the constructor, so the credential goes from the config to the door and
299
+ * never through code of the kind.
300
+ *
301
+ * It throws on an `api` that it does not know, an `id` that is not lowercase, an `events` entry
302
+ * that is not a schema, a verifier that is not one of the three, a `posts` entry that is not an
303
+ * `https://` url that ends in `/`, and a `maxText` that is not a positive integer. Two different
304
+ * kinds with one `id` fail the typecheck (`HookKinds`) and `jr2 up`.
305
+ *
306
+ * Two overloads: a kind with `answer` can give `readBy: "person"`; a kind with no `answer` cannot.
307
+ */
308
+ export function defineHookKind<const Id extends string, const E extends Events, const V extends Verify>(
309
+ kind: HookKind<Id, E, V> & { readonly answer: NonNullable<HookKind["answer"]> },
310
+ ): HookKindOf<Id, E, V>;
311
+ export function defineHookKind<const Id extends string, const E extends Events, const V extends Verify>(
312
+ kind: Omit<HookKind<Id, E, V>, "read" | "answer" | "maxText" | "posts"> & {
313
+ readonly read: (req: HookRequest) => Handshake | (Delivery & { readonly readBy?: "service" });
314
+ },
315
+ ): HookKindOf<Id, E, V>;
316
+ export function defineHookKind(kind: HookKind): HookKindOf<string, Events, Verify> {
317
+ checkKind(kind);
318
+ const make = (hook: unknown): Hook => {
319
+ const parts: HookParts = Object.freeze({
320
+ kind: made,
321
+ credential: credentialOf(kind, hook),
322
+ may: mayOf(kind.id, (hook as { readonly may?: unknown } | undefined)?.may),
323
+ });
324
+ return Object.freeze({ [HOOK]: parts });
325
+ };
326
+ const fields: HookKind = {
327
+ api: kind.api,
328
+ id: kind.id,
329
+ events: Object.freeze({ ...kind.events }),
330
+ verify: Object.freeze({ ...kind.verify }),
331
+ read: kind.read,
332
+ ...(kind.answer ? { answer: kind.answer } : {}),
333
+ ...(kind.maxText !== undefined ? { maxText: kind.maxText } : {}),
334
+ ...(kind.posts ? { posts: Object.freeze([...kind.posts]) } : {}),
335
+ };
336
+ const made = Object.freeze(Object.assign(make, fields)) as unknown as HookKindOf<string, Events, Verify>;
337
+ return made;
338
+ }
339
+
340
+ /** The lowercase id of a kind. */
341
+ const KIND_ID = /^[a-z][a-z0-9-]*$/;
342
+
343
+ function checkKind(kind: HookKind): void {
344
+ const id: unknown = kind.id;
345
+ if (typeof id !== "string" || !KIND_ID.test(id)) {
346
+ throw new Error(`a Hook kind's id is lowercase letters, digits and "-", and starts with a letter: ${String(id)}`);
347
+ }
348
+ if (!HOOK_KIND_APIS.includes(kind.api)) {
349
+ throw new Error(`Hook kind ${id} states api ${String(kind.api)}; this @jr2/orchestrator/hooks knows api 1 only`);
350
+ }
351
+ if (kind.events === null || typeof kind.events !== "object") throw new Error(`Hook kind ${id} has no events`);
352
+ for (const [key, schema] of Object.entries(kind.events)) {
353
+ if (typeof (schema as { safeParse?: unknown } | null)?.safeParse !== "function") {
354
+ throw new Error(`Hook kind ${id}: the event key ${key} has no zod schema`);
355
+ }
356
+ }
357
+ const verifiers = ["mac", "ed25519", "token"].filter((v) => Object.hasOwn(kind.verify ?? {}, v));
358
+ if (verifiers.length !== 1) {
359
+ throw new Error(`Hook kind ${id}: verify is one of mac, ed25519 or token`);
360
+ }
361
+ const v = kind.verify as {
362
+ mac?: { alg?: unknown; signed?: unknown };
363
+ ed25519?: { signed?: unknown };
364
+ token?: { header?: unknown };
365
+ };
366
+ if (v.mac && (!["sha256", "sha1", "sha512"].includes(v.mac.alg as string) || typeof v.mac.signed !== "function")) {
367
+ throw new Error(`Hook kind ${id}: a mac verifier has an alg (sha256, sha1, sha512) and a signed function`);
368
+ }
369
+ if (v.ed25519 && typeof v.ed25519.signed !== "function") {
370
+ throw new Error(`Hook kind ${id}: an ed25519 verifier has a signed function`);
371
+ }
372
+ if (
373
+ v.token &&
374
+ (typeof v.token.header !== "string" || v.token.header !== v.token.header.toLowerCase() || v.token.header === "")
375
+ ) {
376
+ throw new Error(`Hook kind ${id}: a token verifier names its header in lowercase`);
377
+ }
378
+ if (typeof kind.read !== "function") throw new Error(`Hook kind ${id} has no read function`);
379
+ if (kind.answer !== undefined && typeof kind.answer !== "function") {
380
+ throw new Error(`Hook kind ${id}: answer is a function`);
381
+ }
382
+ if (kind.maxText !== undefined && !(Number.isInteger(kind.maxText) && kind.maxText > 0)) {
383
+ throw new Error(`Hook kind ${id}: maxText is a positive integer`);
384
+ }
385
+ for (const post of kind.posts ?? []) {
386
+ let url: URL | undefined;
387
+ try {
388
+ url = new URL(post);
389
+ } catch {
390
+ url = undefined;
391
+ }
392
+ if (!url || url.protocol !== "https:" || !post.endsWith("/") || url.href !== post) {
393
+ throw new Error(`Hook kind ${id}: a posts entry is an https:// url that ends in "/": ${post}`);
394
+ }
395
+ }
396
+ }
397
+
398
+ /** The public key of an Ed25519 verifier: 32 bytes in hex, as Discord shows it. */
399
+ const PUBLIC_KEY = /^[0-9a-f]{64}$/i;
400
+
401
+ function credentialOf(kind: HookKind, hook: unknown): HookCredential {
402
+ const h = (hook ?? {}) as { readonly secret?: unknown; readonly publicKey?: unknown };
403
+ if ("ed25519" in kind.verify) {
404
+ if (typeof h.publicKey !== "string" || !PUBLIC_KEY.test(h.publicKey)) {
405
+ throw new Error(`a ${kind.id} Hook takes publicKey: the Ed25519 public key in hex (64 characters)`);
406
+ }
407
+ return Object.freeze({ publicKey: h.publicKey.toLowerCase() });
408
+ }
409
+ const s = h.secret as { readonly secret?: unknown } | undefined;
410
+ if (typeof s?.secret !== "string" || s.secret === "") {
411
+ throw new Error(`a ${kind.id} Hook takes secret: a named secret, secret("NAME")`);
412
+ }
413
+ return Object.freeze({ secret: s as HookSecret });
414
+ }
415
+
416
+ function mayOf(id: string, may: unknown): HookMay {
417
+ if (may === undefined) return Object.freeze({ answer: "all" });
418
+ const answer = (may as { readonly answer?: unknown } | null)?.answer;
419
+ if (answer === "all") return Object.freeze({ answer });
420
+ if (Array.isArray(answer) && answer.every((w) => typeof w === "string" && w !== "")) {
421
+ return Object.freeze({ answer: Object.freeze([...(answer as string[])]) });
422
+ }
423
+ throw new Error(`a ${id} Hook's may.answer is "all" or a list of Workflow names`);
424
+ }
425
+
426
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
427
+ // 5. The registry of TYPES: kind id → kind. Each kind module adds itself:
428
+ // declare module "./index.ts" { interface HookKinds { github: typeof github } }
429
+ // There is no registry at run time. Two kinds that give one id with different types do not
430
+ // compile ("Subsequent property declarations must have the same type").
431
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
432
+
433
+ /** Each kind module adds its id and its kind here by declaration merging. */
434
+ // biome-ignore lint: an interface for declaration merging.
435
+ export interface HookKinds {}
436
+
437
+ /** The payload map of a kind: handler key → the output of its schema. */
438
+ export type EventsOf<K> = K extends HookKind<string, infer E, Verify> ? Payloads<E> : never;
439
+
440
+ /** A kind's `events` keys with `*`, which take no handler: the door matches a key exactly (ADR-0011). */
441
+ export type NoWildcardKeys<K> =
442
+ K extends HookKind<string, infer E, Verify>
443
+ ? { readonly [Key in keyof E & string as Key extends `${string}*${string}` ? Key : never]?: never }
444
+ : never;
445
+
446
+ /** The payload of one handler key of a kind in `HookKinds`: `PayloadOf<"github", "issue_comment.created">`.
447
+ * A forge part types its handlers with it. */
448
+ export type PayloadOf<Id extends keyof HookKinds, Key extends keyof EventsOf<HookKinds[Id]>> = EventsOf<
449
+ HookKinds[Id]
450
+ >[Key];
451
+
452
+ /**
453
+ * Handlers by kind id, then by event key. An entry of `HookKinds` whose key is not its kind's `id`
454
+ * is left out, so no handler is keyed by a name that the door never matches. `In` is what the Gate
455
+ * or the Source gives a handler beside `payload`; `Out` is what the handler gives. A key that the
456
+ * kind's payloads do not name is an excess property: it does not compile. A key that holds `*`
457
+ * does not compile ({@link NoWildcardKeys}).
458
+ */
459
+ export type KindHooks<In, Out> = {
460
+ readonly [Id in keyof HookKinds as HookKinds[Id] extends { readonly id: Id } ? Id : never]?: {
461
+ readonly [Key in keyof EventsOf<HookKinds[Id]>]?: (
462
+ d: In & { readonly payload: EventsOf<HookKinds[Id]>[Key] },
463
+ ) => Out;
464
+ } & NoWildcardKeys<HookKinds[Id]>;
465
+ };
@@ -0,0 +1,151 @@
1
+ // `@jr2/orchestrator/hooks/slack`: jr2's Slack kind. One Slack app at one address: its slash commands, its
2
+ // interactivity (Block Kit clicks) and its Events API.
3
+
4
+ import { z } from "zod";
5
+ import { defineHookKind, form, json, type Delivery, type HookRequest } from "./index.ts";
6
+
7
+ /**
8
+ * The schema of each event key. A key with `.*` covers a family: the command or the action id is
9
+ * part of the key, so one schema types `block_actions.approve` and `block_actions.reject`.
10
+ */
11
+ export const slackEvents = {
12
+ /** A slash command, decoded from its form body: each field is a string. */
13
+ "slash_command.*": z.looseObject({ text: z.string(), user_id: z.string(), channel_id: z.string() }),
14
+ /** A Block Kit click, decoded from the JSON in its `payload` form field. */
15
+ "block_actions.*": z.looseObject({
16
+ user: z.looseObject({ id: z.string() }),
17
+ channel: z.looseObject({ id: z.string() }).optional(),
18
+ message: z.looseObject({ ts: z.string() }).optional(),
19
+ actions: z.array(z.looseObject({ action_id: z.string(), value: z.string().optional() })),
20
+ /** The kind's `answer` posts the text here. A click with no `response_url` fails the parse. */
21
+ response_url: z.string(),
22
+ }),
23
+ "event_callback.app_mention": z.looseObject({
24
+ event: z.looseObject({ user: z.string(), text: z.string(), channel: z.string(), ts: z.string() }),
25
+ }),
26
+ };
27
+
28
+ const text = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
29
+
30
+ const need = (v: string | undefined, what: string): string => {
31
+ if (v === undefined) throw new Error(`no ${what}`);
32
+ return v;
33
+ };
34
+
35
+ /** Slack's three characters that a `text` must escape. */
36
+ const escape = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
37
+
38
+ const isForm = (req: HookRequest): boolean =>
39
+ (req.headers["content-type"] ?? "").toLowerCase().startsWith("application/x-www-form-urlencoded");
40
+
41
+ /** The host of a click's `response_url`. */
42
+ const RESPONSE_HOST = "https://hooks.slack.com/";
43
+
44
+ /**
45
+ * - verifier: `mac`, HMAC-SHA256 of `v0:<X-Slack-Request-Timestamp>:<raw body>`,
46
+ * `X-Slack-Signature: v0=<hex>`. The signature covers the timestamp, so the kind gives it as
47
+ * `at`, and the door's 300 s window applies (Slack asks for 5 minutes).
48
+ * - handshake: `url_verification` gets `{ challenge }`. The door verifies it first. (Slack's docs
49
+ * do not say that Slack signs it; check it at the first real setup.)
50
+ * - body: a command is a form; a click is a form with JSON in `payload`; an event is JSON.
51
+ * - event keys: `slash_command./jr2`, `block_actions.<action_id>`, `event_callback.<event.type>`.
52
+ * - delivery id: `event_id` for an event, `trigger_id` for a command or a click. (Slack's docs do
53
+ * not say that a `trigger_id` is unique for each request; the door's repeat check also uses the
54
+ * digest of the body.)
55
+ * - `onBehalfOf`: the Slack user id, never a display name.
56
+ * - who reads the answer: a person for a command or a click; Slack for an event (so a 503 stays a
57
+ * 503, and Slack sends the event again).
58
+ * - answer: ephemeral text. The answer to a click can only be an empty 200 (Block Kit), so the text
59
+ * goes to the click's `response_url` as the answer's `then`.
60
+ * - text limit: 4000 characters, the length that Slack's docs advise for a message's `text`. (Not
61
+ * checked for the answer to a command or a `response_url` post.)
62
+ */
63
+ export const slack = defineHookKind({
64
+ api: 1,
65
+ id: "slack",
66
+ events: slackEvents,
67
+ verify: {
68
+ mac: {
69
+ alg: "sha256",
70
+ signed: (req) => {
71
+ const ts = req.headers["x-slack-request-timestamp"];
72
+ const sig = req.headers["x-slack-signature"];
73
+ if (ts === undefined || !/^\d+$/.test(ts) || !sig?.startsWith("v0=")) {
74
+ return { unsigned: "no Slack signature or timestamp" };
75
+ }
76
+ return { bytes: ["v0:", ts, ":", req.body], signature: { hex: sig.slice("v0=".length) }, at: Number(ts) };
77
+ },
78
+ },
79
+ },
80
+ read: (req) => {
81
+ if (isForm(req)) {
82
+ const f = form(req);
83
+ if (f.payload !== undefined) {
84
+ let p: { readonly type?: unknown; readonly trigger_id?: unknown; readonly user?: { readonly id?: unknown } };
85
+ try {
86
+ p = JSON.parse(f.payload) as typeof p;
87
+ } catch {
88
+ throw new Error("the payload field is not JSON");
89
+ }
90
+ const type = need(text(p?.type), "payload type");
91
+ const action = text(
92
+ (p as { readonly actions?: readonly { readonly action_id?: unknown }[] }).actions?.[0]?.action_id,
93
+ );
94
+ const by = text(p.user?.id);
95
+ return {
96
+ key: action ? `${type}.${action}` : type,
97
+ id: need(text(p.trigger_id), "trigger_id"),
98
+ payload: p,
99
+ ...(by ? { onBehalfOf: by } : {}),
100
+ readBy: "person",
101
+ };
102
+ }
103
+ return {
104
+ key: `slash_command.${need(text(f.command), "command")}`,
105
+ id: need(text(f.trigger_id), "trigger_id"),
106
+ payload: f,
107
+ ...(text(f.user_id) ? { onBehalfOf: f.user_id } : {}),
108
+ readBy: "person",
109
+ };
110
+ }
111
+ const p = json(req) as {
112
+ readonly type?: unknown;
113
+ readonly challenge?: unknown;
114
+ readonly event_id?: unknown;
115
+ readonly event?: { readonly type?: unknown; readonly user?: unknown };
116
+ } | null;
117
+ const type = need(text(p?.type), "type");
118
+ if (type === "url_verification") return { handshake: { body: { challenge: text(p?.challenge) ?? "" } } };
119
+ const event = text(p?.event?.type);
120
+ const by = text(p?.event?.user);
121
+ return {
122
+ key: event ? `${type}.${event}` : type,
123
+ id: need(text(p?.event_id), "event_id"),
124
+ payload: p,
125
+ ...(by ? { onBehalfOf: by } : {}),
126
+ };
127
+ },
128
+ answer: (t: string, d: Delivery) => {
129
+ if (d.key.startsWith("block_actions.")) {
130
+ const url = text((d.payload as { readonly response_url?: unknown }).response_url);
131
+ // The door refuses a `then` outside `posts`; a url of another host gets no post at all.
132
+ if (url === undefined || !url.startsWith(RESPONSE_HOST)) return { body: null };
133
+ return {
134
+ body: null,
135
+ then: {
136
+ post: url as `https://${string}`,
137
+ json: { response_type: "ephemeral", replace_original: false, text: escape(t) },
138
+ },
139
+ };
140
+ }
141
+ return { body: { response_type: "ephemeral", text: escape(t) } };
142
+ },
143
+ maxText: 4000,
144
+ posts: [RESPONSE_HOST],
145
+ });
146
+
147
+ declare module "./index.ts" {
148
+ interface HookKinds {
149
+ slack: typeof slack;
150
+ }
151
+ }