@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.
- package/console/components/app.ts +55 -15
- package/console/components/bottom-drawer.ts +259 -0
- package/console/components/drawer.ts +14 -6
- package/console/components/fleet.ts +36 -13
- package/console/components/machine-pane.ts +61 -5
- package/console/components/nav.ts +54 -8
- package/console/conversation.ts +161 -0
- package/console/login.ts +123 -0
- package/console/main.ts +600 -60
- package/console/store.ts +611 -64
- package/console/style.css +231 -0
- package/docs/agents-block.md +2 -2
- package/docs/concepts/agents-and-turns.md +86 -9
- package/docs/concepts/architecture.md +41 -28
- package/docs/concepts/callers-and-hooks.md +205 -0
- package/docs/concepts/charts.md +7 -5
- package/docs/concepts/composition.md +10 -1
- package/docs/concepts/converge.md +4 -2
- package/docs/concepts/durable-runs.md +43 -16
- package/docs/concepts/effects.md +50 -22
- package/docs/concepts/gates.md +95 -25
- package/docs/concepts/machines-and-workflows.md +5 -4
- package/docs/concepts/pools-and-sources.md +187 -11
- package/docs/concepts/secrets-and-trust.md +73 -33
- package/docs/concepts/the-menu.md +11 -0
- package/docs/concepts/workspaces.md +6 -5
- package/docs/get-started/a-model-decides.md +14 -13
- package/docs/get-started/an-agent-in-a-sandbox.md +43 -34
- package/docs/get-started/connect-a-model.md +1 -1
- package/docs/get-started/create-an-instance.md +17 -14
- package/docs/guides/add-tools-to-the-sandbox.md +55 -11
- package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
- package/docs/guides/answer-a-gate.md +74 -37
- package/docs/guides/bind-open-parts.md +24 -8
- package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
- package/docs/guides/cancel-a-run.md +26 -16
- package/docs/guides/fix-a-failed-converge.md +18 -6
- package/docs/guides/give-a-caller-a-scope.md +203 -0
- package/docs/guides/give-an-agent-mcp-tools.md +422 -0
- package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
- package/docs/guides/let-an-agent-write-a-chart.md +9 -6
- package/docs/guides/look-inside-a-sandbox.md +101 -31
- package/docs/guides/open-the-console.md +63 -14
- package/docs/guides/read-a-conversation.md +225 -0
- package/docs/guides/run-a-chart-by-name.md +17 -16
- package/docs/guides/run-one-worker-per-item.md +15 -9
- package/docs/guides/set-an-agents-access-and-dials.md +2 -0
- package/docs/guides/start-a-run-for-each-alert.md +290 -0
- package/docs/guides/start-a-run-from-a-run.md +18 -16
- package/docs/guides/start-a-run.md +24 -18
- package/docs/guides/steer-an-agent.md +332 -0
- package/docs/guides/use-a-hosted-provider.md +1 -1
- package/docs/guides/watch-a-run.md +127 -121
- package/docs/guides/write-a-hook-kind.md +238 -0
- package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
- package/docs/guides/write-a-workflow.md +17 -7
- package/docs/llms.txt +81 -15
- package/docs/reference/authoring-surface/agent.md +1 -1
- package/docs/reference/authoring-surface/answer.md +1 -1
- package/docs/reference/authoring-surface/arg.md +1 -1
- package/docs/reference/authoring-surface/basic.md +1 -1
- package/docs/reference/authoring-surface/bearer.md +1 -1
- package/docs/reference/authoring-surface/branchname.md +1 -1
- package/docs/reference/authoring-surface/carry.md +23 -0
- package/docs/reference/authoring-surface/chartschema.md +1 -1
- package/docs/reference/authoring-surface/chartschemafor.md +1 -1
- package/docs/reference/authoring-surface/command.md +1 -1
- package/docs/reference/authoring-surface/customize.md +4 -1
- package/docs/reference/authoring-surface/defineconfig.md +1 -1
- package/docs/reference/authoring-surface/defineevent.md +1 -1
- package/docs/reference/authoring-surface/dispatch.md +5 -4
- package/docs/reference/authoring-surface/fail.md +1 -1
- package/docs/reference/authoring-surface/file.md +1 -1
- package/docs/reference/authoring-surface/frompick.md +1 -1
- package/docs/reference/authoring-surface/gate.md +118 -0
- package/docs/reference/authoring-surface/group-agents.md +15 -0
- package/docs/reference/authoring-surface/group-charts.md +16 -0
- package/docs/reference/authoring-surface/group-composition.md +25 -0
- package/docs/reference/authoring-surface/group-config.md +17 -0
- package/docs/reference/authoring-surface/group-effects.md +48 -0
- package/docs/reference/authoring-surface/group-failure.md +13 -0
- package/docs/reference/authoring-surface/group-gates.md +16 -0
- package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
- package/docs/reference/authoring-surface/group-steering.md +12 -0
- package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
- package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
- package/docs/reference/authoring-surface/http.md +1 -1
- package/docs/reference/authoring-surface/isfailure.md +1 -1
- package/docs/reference/authoring-surface/isready.md +1 -1
- package/docs/reference/authoring-surface/jr2setup.md +13 -3
- package/docs/reference/authoring-surface/mcp.md +107 -0
- package/docs/reference/authoring-surface/open.md +5 -3
- package/docs/reference/authoring-surface/openbody.md +1 -1
- package/docs/reference/authoring-surface/paramsof.md +1 -1
- package/docs/reference/authoring-surface/pool.md +77 -9
- package/docs/reference/authoring-surface/push.md +1 -1
- package/docs/reference/authoring-surface/readyof.md +1 -1
- package/docs/reference/authoring-surface/reread.md +19 -0
- package/docs/reference/authoring-surface/secret.md +1 -1
- package/docs/reference/authoring-surface/sentby.md +1 -1
- package/docs/reference/authoring-surface/skip.md +22 -0
- package/docs/reference/authoring-surface/source.md +56 -1
- package/docs/reference/authoring-surface/steer.md +93 -0
- package/docs/reference/authoring-surface/text.md +1 -1
- package/docs/reference/authoring-surface/type-agentdefinition.md +14 -1
- package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
- package/docs/reference/authoring-surface/type-anyframepath.md +1 -1
- package/docs/reference/authoring-surface/type-anyworktreepath.md +1 -1
- package/docs/reference/authoring-surface/type-binding.md +1 -1
- package/docs/reference/authoring-surface/type-caller.md +40 -0
- package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
- package/docs/reference/authoring-surface/type-carry.md +19 -0
- package/docs/reference/authoring-surface/type-chainlink.md +3 -3
- package/docs/reference/authoring-surface/type-commandoutput.md +1 -1
- package/docs/reference/authoring-surface/type-customize.md +16 -1
- package/docs/reference/authoring-surface/type-delivered.md +17 -0
- package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
- package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
- package/docs/reference/authoring-surface/type-dispatchoutput.md +1 -1
- package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
- package/docs/reference/authoring-surface/type-framepath.md +1 -1
- package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
- package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
- package/docs/reference/authoring-surface/type-gatelook.md +37 -0
- package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
- package/docs/reference/authoring-surface/type-gatereply.md +19 -0
- package/docs/reference/authoring-surface/type-gateslot.md +21 -0
- package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
- package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
- package/docs/reference/authoring-surface/type-gateslots.md +12 -0
- package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
- package/docs/reference/authoring-surface/type-hostinjectedinput.md +1 -1
- package/docs/reference/authoring-surface/type-httpoutput.md +1 -1
- package/docs/reference/authoring-surface/type-jr2config.md +37 -2
- package/docs/reference/authoring-surface/type-lateroutcome.md +1 -1
- package/docs/reference/authoring-surface/type-lends.md +5 -1
- package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
- package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
- package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
- package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
- package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
- package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
- package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
- package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
- package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
- package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
- package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
- package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
- package/docs/reference/authoring-surface/type-poolnextinput.md +1 -1
- package/docs/reference/authoring-surface/type-poolspec.md +11 -6
- package/docs/reference/authoring-surface/type-pushoutput.md +1 -1
- package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
- package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
- package/docs/reference/authoring-surface/type-reposlot.md +1 -1
- package/docs/reference/authoring-surface/type-reread.md +18 -0
- package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
- package/docs/reference/authoring-surface/type-sender.md +26 -1
- package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
- package/docs/reference/authoring-surface/type-size.md +1 -1
- package/docs/reference/authoring-surface/type-skip.md +19 -0
- package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
- package/docs/reference/authoring-surface/type-sourcein.md +25 -0
- package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
- package/docs/reference/authoring-surface/type-sourcespec.md +16 -7
- package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
- package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
- package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
- package/docs/reference/authoring-surface/type-steerargs.md +20 -0
- package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
- package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
- package/docs/reference/authoring-surface/type-workspaced.md +1 -1
- package/docs/reference/authoring-surface/type-worktreefound.md +3 -3
- package/docs/reference/authoring-surface/type-worktreeinput.md +1 -1
- package/docs/reference/authoring-surface/type-worktreeoptions.md +1 -1
- package/docs/reference/authoring-surface/type-worktreeoutput.md +1 -1
- package/docs/reference/authoring-surface/type-worktreepath.md +1 -1
- package/docs/reference/authoring-surface/type-worktreeready.md +1 -1
- package/docs/reference/authoring-surface/type-worktreerefused.md +4 -4
- package/docs/reference/authoring-surface/workspace.md +1 -1
- package/docs/reference/authoring-surface/worktree.md +1 -1
- package/docs/reference/authoring-surface.md +88 -167
- package/docs/reference/cli.md +413 -74
- package/docs/reference/config.md +235 -2
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +71 -11
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +14 -10
- package/docs/reference/decisions/0006-agent-control-surface.md +4 -4
- package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
- package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
- package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
- package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
- package/docs/reference/decisions/0012-workspace-wrapper-machine.md +9 -2
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +20 -14
- package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
- package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +18 -14
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -12
- package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
- package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +32 -18
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +14 -1
- package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
- package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +116 -34
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +38 -37
- package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
- package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +7 -6
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +23 -7
- package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
- package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
- package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -18
- package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
- package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +4 -3
- package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
- package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +5 -4
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +19 -19
- package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
- package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
- package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
- package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +7 -0
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +13 -7
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +38 -30
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +64 -17
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +2 -1
- package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
- package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
- 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
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +94 -54
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +101 -47
- package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
- package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +17 -13
- package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
- package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
- package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +4 -3
- package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +37 -10
- package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
- 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
- package/docs/reference/decisions.md +6 -4
- package/docs/reference/glossary.md +96 -42
- package/docs/reference/hooks.md +1077 -0
- package/docs/reference/http-api.md +976 -149
- package/docs/why.md +2 -1
- package/package.json +10 -3
- package/src/actor.ts +36 -10
- package/src/agent.ts +50 -0
- package/src/assembly-checks.ts +530 -4
- package/src/callers.ts +245 -0
- package/src/chart.ts +52 -7
- package/src/config.ts +550 -1
- package/src/conversations.ts +453 -0
- package/src/custodian.ts +8 -2
- package/src/customize.ts +143 -4
- package/src/dispatch-effect.ts +142 -26
- package/src/durability.ts +2 -0
- package/src/duration.ts +20 -0
- package/src/effect-checks.ts +2 -1
- package/src/effect.ts +60 -7
- package/src/effector-wire.ts +272 -27
- package/src/gate-brand.ts +16 -0
- package/src/gate.ts +634 -48
- package/src/harness-client.ts +10 -4
- package/src/held-secrets.ts +72 -23
- package/src/holds.ts +2 -2
- package/src/hook-kinds/discord.ts +92 -0
- package/src/hook-kinds/door.ts +107 -0
- package/src/hook-kinds/github.ts +89 -0
- package/src/hook-kinds/gitlab.ts +72 -0
- package/src/hook-kinds/hook.ts +7 -0
- package/src/hook-kinds/index.ts +465 -0
- package/src/hook-kinds/slack.ts +151 -0
- package/src/hook-kinds/webhook.ts +113 -0
- package/src/hooks.ts +506 -0
- package/src/http-effect.ts +76 -38
- package/src/http.ts +889 -94
- package/src/index.ts +16 -5
- package/src/instance.ts +196 -26
- package/src/internal.ts +22 -3
- package/src/machine-doc.ts +26 -1
- package/src/mcp-effect.ts +424 -0
- package/src/mount-digest.ts +41 -7
- package/src/names.ts +64 -2
- package/src/notices.ts +138 -10
- package/src/open-body.ts +25 -11
- package/src/open-effect.ts +17 -4
- package/src/open.ts +4 -2
- package/src/parts.ts +87 -9
- package/src/party.ts +2 -1
- package/src/pick.ts +2 -2
- package/src/pool.ts +1142 -163
- package/src/registration.ts +203 -13
- package/src/run-host.ts +1509 -171
- package/src/run-list.ts +191 -0
- package/src/sandbox-kube.ts +53 -5
- package/src/secret-placement.ts +239 -33
- package/src/server.ts +53 -4
- package/src/setup.ts +72 -32
- package/src/snapshot-store.ts +565 -48
- package/src/steer.ts +111 -0
- package/src/stub-harness.ts +6 -2
- package/src/tokens.ts +263 -22
- package/src/wire.ts +17 -0
- package/src/workspace.ts +50 -6
- package/src/worktree.ts +3 -3
- package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
- 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|