@pithy-sh/core 0.1.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 (134) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/pithy.manifest.json +74 -0
  4. package/src/address/address.ts +83 -0
  5. package/src/audit/auditEvent.ts +130 -0
  6. package/src/audit/recorder.ts +22 -0
  7. package/src/capability/bindings.ts +196 -0
  8. package/src/capability/capability.ts +555 -0
  9. package/src/capability/client.ts +136 -0
  10. package/src/capability/compose.ts +76 -0
  11. package/src/capability/composition.ts +98 -0
  12. package/src/capability/config.ts +19 -0
  13. package/src/capability/devSecret.ts +42 -0
  14. package/src/capability/manifest.ts +580 -0
  15. package/src/capability/secretOrigin.ts +253 -0
  16. package/src/capability/settings.ts +155 -0
  17. package/src/capability/validateBindings.ts +43 -0
  18. package/src/capability/vanishingKey.ts +92 -0
  19. package/src/cloudflare-test.d.ts +20 -0
  20. package/src/controlPlane/audit/actions.ts +81 -0
  21. package/src/controlPlane/capability.ts +228 -0
  22. package/src/controlPlane/config/config.ts +195 -0
  23. package/src/controlPlane/context.ts +63 -0
  24. package/src/controlPlane/data/connection.ts +123 -0
  25. package/src/controlPlane/data/keyLifecycle.ts +159 -0
  26. package/src/controlPlane/data/replay.ts +39 -0
  27. package/src/controlPlane/data/tables.ts +51 -0
  28. package/src/controlPlane/discovery/adminRoute.ts +250 -0
  29. package/src/controlPlane/discovery/configuration.ts +280 -0
  30. package/src/controlPlane/discovery/drift.ts +100 -0
  31. package/src/controlPlane/discovery/health.ts +213 -0
  32. package/src/controlPlane/discovery/healthSummary.ts +486 -0
  33. package/src/controlPlane/error/errors.ts +125 -0
  34. package/src/controlPlane/http/cors.ts +244 -0
  35. package/src/controlPlane/http/guard.ts +223 -0
  36. package/src/controlPlane/http/handlers.ts +346 -0
  37. package/src/controlPlane/http/responses.ts +92 -0
  38. package/src/controlPlane/http/routes.ts +115 -0
  39. package/src/controlPlane/http/schemas.ts +70 -0
  40. package/src/controlPlane/http/verify.ts +198 -0
  41. package/src/controlPlane/migrations/0001_init.ts +105 -0
  42. package/src/controlPlane/replay/d1Guard.ts +87 -0
  43. package/src/controlPlane/replay/guard.ts +55 -0
  44. package/src/controlPlane/replay/kvGuard.ts +143 -0
  45. package/src/controlPlane/scope/scope.ts +102 -0
  46. package/src/controlPlane/token/base64url.ts +65 -0
  47. package/src/controlPlane/token/claims.ts +151 -0
  48. package/src/controlPlane/token/digest.ts +63 -0
  49. package/src/controlPlane/token/jws.ts +112 -0
  50. package/src/controlPlane/token/mint.ts +93 -0
  51. package/src/controlPlane/wire.ts +138 -0
  52. package/src/createBackend.ts +292 -0
  53. package/src/createEntrypoint.ts +125 -0
  54. package/src/data/boundParameters.ts +197 -0
  55. package/src/data/codecs.ts +160 -0
  56. package/src/data/cursor.ts +127 -0
  57. package/src/data/databases.ts +84 -0
  58. package/src/data/db.ts +53 -0
  59. package/src/data/withD1Retry.ts +176 -0
  60. package/src/entitlement/entitlement.ts +191 -0
  61. package/src/entitlement/gateScan.ts +107 -0
  62. package/src/entitlement/require.ts +199 -0
  63. package/src/env/ambient.ts +67 -0
  64. package/src/env/ci.ts +43 -0
  65. package/src/env/stem.ts +34 -0
  66. package/src/error/cause.ts +208 -0
  67. package/src/error/client.ts +43 -0
  68. package/src/error/extend.ts +135 -0
  69. package/src/error/http.ts +92 -0
  70. package/src/error/payload.ts +2195 -0
  71. package/src/error/pithyError.ts +281 -0
  72. package/src/error/terminal.ts +36 -0
  73. package/src/http/authContext.ts +29 -0
  74. package/src/http/routeContract.ts +115 -0
  75. package/src/http/sameOrigin.ts +67 -0
  76. package/src/http/signedWebhook.ts +415 -0
  77. package/src/http/validation.ts +41 -0
  78. package/src/http/verification.ts +25 -0
  79. package/src/i18n/acceptLanguage.ts +70 -0
  80. package/src/i18n/catalog.ts +113 -0
  81. package/src/i18n/locale.ts +153 -0
  82. package/src/i18n/localeMarker.ts +116 -0
  83. package/src/i18n/match.ts +111 -0
  84. package/src/i18n/registry.ts +78 -0
  85. package/src/i18n/translator.ts +168 -0
  86. package/src/index.ts +116 -0
  87. package/src/kv/kv.ts +437 -0
  88. package/src/kv/namespaces.ts +102 -0
  89. package/src/logger/local.ts +91 -0
  90. package/src/logger/logger.ts +145 -0
  91. package/src/logger/record.ts +83 -0
  92. package/src/logger/worker.ts +117 -0
  93. package/src/migrations/batch.ts +226 -0
  94. package/src/migrations/bookkeeping.ts +85 -0
  95. package/src/migrations/owner.ts +166 -0
  96. package/src/migrations/registry.ts +121 -0
  97. package/src/migrations/runner.ts +295 -0
  98. package/src/naming/domains.ts +194 -0
  99. package/src/naming/environment.ts +224 -0
  100. package/src/naming/feature.ts +162 -0
  101. package/src/naming/limits.ts +223 -0
  102. package/src/naming/provisionScope.ts +143 -0
  103. package/src/naming/resource.ts +266 -0
  104. package/src/naming/resourceNames.ts +174 -0
  105. package/src/naming/segment.ts +32 -0
  106. package/src/projection/asRead.ts +211 -0
  107. package/src/projection/published.ts +210 -0
  108. package/src/schema/describedness.ts +250 -0
  109. package/src/seed/compose.ts +94 -0
  110. package/src/seed/devLogin.ts +67 -0
  111. package/src/seed/exampleIdentities.ts +43 -0
  112. package/src/seed/metadata.ts +27 -0
  113. package/src/seed/seed.ts +306 -0
  114. package/src/seed/seededRows.ts +41 -0
  115. package/src/seed/writeD1.ts +103 -0
  116. package/src/seed/writeKv.ts +99 -0
  117. package/src/semver/semver.ts +156 -0
  118. package/src/text/comments.ts +165 -0
  119. package/src/version.generated.ts +16 -0
  120. package/src/worker/health.ts +42 -0
  121. package/src/worker/identity.ts +243 -0
  122. package/src/workflow/bindings.ts +58 -0
  123. package/src/workflow/dispatch.ts +240 -0
  124. package/src/workflow/dispatchRoute.ts +184 -0
  125. package/src/workflow/faults.ts +219 -0
  126. package/src/workflow/host.ts +307 -0
  127. package/src/workflow/hostEntry.ts +71 -0
  128. package/src/workflow/hostEnv.ts +258 -0
  129. package/src/workflow/loopback.ts +149 -0
  130. package/src/workflow/naming.ts +170 -0
  131. package/src/workflow/register.ts +44 -0
  132. package/src/workflow/schemas.ts +84 -0
  133. package/src/workflow/spec.ts +86 -0
  134. package/src/workflow/stepMessage.ts +160 -0
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { BindingSpecInput } from "../capability/bindings";
5
+ import type { RegisteredWorkflow, WorkflowSpec, WorkflowSpecMap } from "./spec";
6
+
7
+ /**
8
+ * The one derivation from a durable job to the `workflow` binding that job needs.
9
+ *
10
+ * **Every capability that owns a Workflow wrote this by hand, and six of them wrote it wrong.**
11
+ * `Object.values(map).map((spec) => ({ type: "workflow", name: spec.binding, optional: spec.optional }))`
12
+ * appeared verbatim in payments, storage, support, vector, testers and media, and once more in
13
+ * `createBackend`. It drops two fields, and dropping them is not cosmetic: `project/bindingEntries.ts`
14
+ * needs `job` to compose the deployed Workflow's name and `className` to emit `class_name`, refuses to
15
+ * write a partial `workflows` entry because wrangler rejects one, and returns `undefined` instead. So
16
+ * `pithy upgrade` reported adding five bindings it had silently declined to write, and `pithy doctor`
17
+ * — run seconds later against the same tree — correctly still called them missing (#318).
18
+ *
19
+ * **The `job` is the map key.** That is the whole reason `Object.values` was the wrong iterator: the
20
+ * field the writer needs most is the one a values-only walk cannot see. `Object.entries` is not a
21
+ * detail to remember at six call sites; it is this function.
22
+ *
23
+ * `className` rides through as declared — a spec may honestly omit it, for a job whose host config is
24
+ * hand-maintained — and `capabilities/requiredBindings.test.ts` is what turns an omission that reaches
25
+ * a shipped manifest into a build failure rather than a binding nothing writes.
26
+ */
27
+ export function workflowBinding(job: string, spec: WorkflowSpec): BindingSpecInput {
28
+ return {
29
+ type: "workflow",
30
+ name: spec.binding,
31
+ job,
32
+ ...(spec.className === undefined ? {} : { className: spec.className }),
33
+ optional: spec.optional ?? false,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Every binding a capability's durable jobs require, derived from the job map itself.
39
+ *
40
+ * What a capability's `requiredBindings` spreads, so the declaration is one line that cannot lose a
41
+ * field. A binding rename, a new job, or a class rename travels from the spec to `wrangler.jsonc`
42
+ * without anything in between being edited.
43
+ */
44
+ export function workflowBindings(workflows: WorkflowSpecMap): BindingSpecInput[] {
45
+ return Object.entries(workflows).map(([job, spec]) => workflowBinding(job, spec));
46
+ }
47
+
48
+ /**
49
+ * The same derivation from a {@link RegisteredWorkflow} — a spec already resolved against the
50
+ * capability that declared it, which is the shape `createBackend`'s composed registry holds.
51
+ *
52
+ * It exists so the assembly-time derivation and the capability-time one are the same function rather
53
+ * than the same four lines twice. `entry.job` is the registry's own copy of the map key, so the
54
+ * binding it derives is byte-identical to the one the capability declared.
55
+ */
56
+ export function registeredWorkflowBinding(entry: RegisteredWorkflow): BindingSpecInput {
57
+ return workflowBinding(entry.job, entry.spec);
58
+ }
@@ -0,0 +1,240 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import { originVarName } from "../env/stem";
6
+ import { messageOf, PithyError } from "../error/pithyError";
7
+ import type { Logger } from "../logger/logger";
8
+ import { noopLogger } from "../logger/logger";
9
+ import { ENVIRONMENT_VAR } from "../worker/identity";
10
+ import { type LoopbackFetch, loopbackWorkflowBinding } from "./loopback";
11
+ import type { WorkflowBinding, WorkflowRegistry } from "./spec";
12
+
13
+ /**
14
+ * Dispatch: start a registered job by its `<capability>/<job>` key.
15
+ *
16
+ * Two things happen before the binding is touched, in this order. The key resolves against the
17
+ * registry, so a typo is `core/unknown_workflow` rather than a `TypeError` on `undefined.create`.
18
+ * Then the params parse through the spec's schema, so a malformed payload is
19
+ * `core/invalid_workflow_params` at the call site — with the offending field named — instead of a
20
+ * failed step inside a running instance, where the only signal is a retry budget burning down and
21
+ * the payload is no longer in anyone's hands.
22
+ *
23
+ * The dispatcher is a closure over the per-request `env`, built once per request by
24
+ * `createBackend` and served as `c.var.workflows`.
25
+ */
26
+
27
+ /**
28
+ * The typed dispatcher. `Params` maps each `<capability>/<job>` key to that job's parameter type,
29
+ * derived by `createBackend` from the composed capabilities — so `trigger` autocompletes the key
30
+ * set and type-checks the payload against the declaring capability's schema.
31
+ */
32
+ export interface WorkflowDispatcher<Params extends Record<string, unknown> = Record<string, unknown>> {
33
+ /**
34
+ * Start an instance of one job. Resolves once Cloudflare has accepted the instance; it does not
35
+ * wait for the Workflow to finish. Instance status and termination are deliberately not on this
36
+ * seam — a Workflow is durable precisely so the caller need not hold its progress.
37
+ */
38
+ trigger<Key extends keyof Params & string>(key: Key, params: Params[Key]): Promise<void>;
39
+ }
40
+
41
+ /**
42
+ * Read a binding off the per-request env, narrowed to the one method dispatch needs.
43
+ *
44
+ * Exported because the host's dispatch route ({@link ./dispatchRoute.ts}) asks the same question of
45
+ * the same env, and a second duck-type would be a second answer to "is this a Workflow binding" the
46
+ * first day one of them learned about a new shape.
47
+ */
48
+ export function workflowBindingFor(env: Record<string, unknown>, name: string): WorkflowBinding | undefined {
49
+ const value = env[name];
50
+ if (typeof value !== "object" || value === null) return undefined;
51
+ const create = (value as { create?: unknown }).create;
52
+ return typeof create === "function" ? (value as WorkflowBinding) : undefined;
53
+ }
54
+
55
+ /** The one environment a loopback substitution is made in. Verbatim, and the only one. */
56
+ const LOOPBACK_ENVIRONMENT = "dev";
57
+
58
+ /** What resolving a binding needs to know: which one, whose, and how to reach a sibling. */
59
+ export interface WorkflowBindingRequest {
60
+ /** The binding name on the caller's own env — `EMAIL_SENDER`. */
61
+ binding: string;
62
+ /** The capability that owns the host. Names the origin var, and names the worker in a failure. */
63
+ capability: string;
64
+ /** Where a substitution is noted. Defaults to silence. */
65
+ log?: Logger;
66
+ /** The loopback transport. Defaults to the runtime's `fetch`; injectable for tests. */
67
+ fetch?: LoopbackFetch;
68
+ }
69
+
70
+ /** A non-blank string off the env, or `undefined`. Anything else on that key is not an address. */
71
+ function stringVar(env: Record<string, unknown>, name: string): string | undefined {
72
+ const value = env[name];
73
+ if (typeof value !== "string") return undefined;
74
+ const trimmed = value.trim();
75
+ return trimmed.length > 0 ? trimmed : undefined;
76
+ }
77
+
78
+ /**
79
+ * The binding to dispatch on: the real one, or — under `pithy dev` — a loopback stand-in for it.
80
+ *
81
+ * ## Why a stand-in exists at all
82
+ *
83
+ * A deployed app Worker starts a capability's Workflow through a **cross-script** binding pointing at
84
+ * `<project>-<env>-email`. `pithy dev` runs no script under that name, so locally that binding cannot
85
+ * work — it is absent, or it is present and every `create` on it fails — and every dispatch it carried
86
+ * went nowhere (pithy-sh/pithy#410). What *does* exist locally is the capability host itself, on a
87
+ * pinned port, with its address in this Worker's own vars as `<STEM>_ORIGIN`. So the seam is filled
88
+ * rather than left empty, and the call site — `enqueueEmail`, the cron handler, `c.var.workflows` — is
89
+ * byte-identical either way.
90
+ *
91
+ * ## Three rules, and each of them is a refusal
92
+ *
93
+ * **In `dev`, a published origin wins over the binding.** The deliberate order, and the one that
94
+ * closes #410 whatever wrangler decides to hand a local Worker for a script it is not running: a
95
+ * binding that is present but cannot reach anything is indistinguishable at runtime from one that
96
+ * works, so preferring it would leave the silence in place and depend on a wrangler behavior nothing
97
+ * here controls. Preferring the origin costs nothing, because a published origin is not something a
98
+ * composition can have by accident — see the next rule.
99
+ *
100
+ * **A published origin names a host `pithy dev` is running.** The orchestrator writes one `--var` per
101
+ * *capability host* in the dev set, and never hands a host its own address. So `EMAIL_ORIGIN` on a
102
+ * Worker's env means exactly one thing: the email host is up, locally, there. An adopter's own
103
+ * app-owned Workflow — the same-script shape, which `wrangler dev` implements unchanged — is never
104
+ * published an origin and so is never diverted.
105
+ *
106
+ * **Only `dev`.** Not `staging`, not `prod`, and — the case that matters — not a composition that
107
+ * stamped no environment at all. A gate that reads silence as `dev` opens itself in exactly the
108
+ * deployment whose `wrangler.jsonc` lost the var.
109
+ *
110
+ * **The environment comes off the request env, not off the host's shell.** `--var ENVIRONMENT` is
111
+ * what puts it on a Worker, and the shell that ran `wrangler dev` does not cross into workerd. The
112
+ * dispatch *route* reads the ambient env instead, because it is registered before any request exists;
113
+ * here there is a request env in hand, and it is the truthful one.
114
+ */
115
+ export function resolveWorkflowBinding(
116
+ env: Record<string, unknown>,
117
+ request: WorkflowBindingRequest,
118
+ ): WorkflowBinding | undefined {
119
+ const origin =
120
+ stringVar(env, ENVIRONMENT_VAR) === LOOPBACK_ENVIRONMENT
121
+ ? stringVar(env, originVarName(request.capability))
122
+ : undefined;
123
+
124
+ if (origin) {
125
+ request.log?.debug("workflow dispatch over loopback", {
126
+ binding: request.binding,
127
+ capability: request.capability,
128
+ origin,
129
+ });
130
+ return loopbackWorkflowBinding({
131
+ origin,
132
+ binding: request.binding,
133
+ capability: request.capability,
134
+ fetch: request.fetch,
135
+ });
136
+ }
137
+
138
+ return workflowBindingFor(env, request.binding);
139
+ }
140
+
141
+ /**
142
+ * Start one registered job. The single dispatch path — `createBackend`'s per-request dispatcher and
143
+ * `createEntrypoint`'s cron handler both route through here, so a scheduled run and a manual one
144
+ * validate identically.
145
+ */
146
+ export async function triggerWorkflow(
147
+ env: Record<string, unknown>,
148
+ registry: WorkflowRegistry,
149
+ key: string,
150
+ params: unknown,
151
+ log: Logger = noopLogger,
152
+ ): Promise<void> {
153
+ const entry = registry[key];
154
+ if (!entry) {
155
+ const known = Object.keys(registry).sort().join(", ");
156
+ throw new PithyError({
157
+ code: "core/unknown_workflow",
158
+ status: 500,
159
+ message: `No workflow is registered as "${key}".`,
160
+ action: "Check the key, and compose the capability that declares the job.",
161
+ detail: known ? `Registered workflows: ${known}.` : "No capability registered any workflow.",
162
+ });
163
+ }
164
+
165
+ // Params first: a bad payload is the caller's fault and must not depend on whether the binding
166
+ // happens to be wired, so the same mistake reports the same way in every environment.
167
+ const parsed = entry.spec.params.safeParse(params);
168
+ if (!parsed.success) {
169
+ throw new PithyError({
170
+ code: "core/invalid_workflow_params",
171
+ status: 400,
172
+ message: `The parameters for workflow "${key}" are not valid.`,
173
+ action: "Correct the parameters to match the workflow's schema.",
174
+ detail: (parsed.error as z.ZodError).issues
175
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
176
+ .join("; "),
177
+ });
178
+ }
179
+
180
+ const binding = resolveWorkflowBinding(env, {
181
+ binding: entry.spec.binding,
182
+ capability: entry.capability,
183
+ log,
184
+ });
185
+ if (!binding) {
186
+ // An optional job is one whose host may not be provisioned yet. Throwing would take down a
187
+ // request path that works perfectly well without the job — `@pithy-sh/media` finalizes an upload
188
+ // and merely skips enrichment. So it degrades instead. It degrades *loudly*, though: the previous
189
+ // shape of this (an `?.create()` in media) made a whole capability silently do nothing, with no
190
+ // error and no log, which is the harder failure to diagnose by far.
191
+ if (entry.spec.optional) {
192
+ log.warn("workflow skipped", {
193
+ workflow: key,
194
+ binding: entry.spec.binding,
195
+ reason: "binding absent — the workflow host is not deployed or not bound",
196
+ });
197
+ return;
198
+ }
199
+ throw new PithyError({
200
+ code: "core/missing_workflow_binding",
201
+ status: 500,
202
+ message: `The workflow binding "${entry.spec.binding}" is not available.`,
203
+ action: `Deploy the ${entry.capability} workflow host, then bind ${entry.spec.binding} in wrangler.jsonc.`,
204
+ detail: `Dispatching "${key}" needs a Workflow binding named ${entry.spec.binding} on the Worker env.`,
205
+ });
206
+ }
207
+
208
+ try {
209
+ await binding.create({ params: parsed.data });
210
+ } catch (error) {
211
+ // **An optional job degrades on the dispatch too, not only on the binding.** `optional` is a
212
+ // promise about the caller's request path — it works without this job — and a binding that is
213
+ // there and will not start anything is the same fact to that path as one that is absent.
214
+ //
215
+ // Under `pithy dev` this is the ordinary case rather than the exotic one: the loopback stand-in is
216
+ // composed the moment `<STEM>_ORIGIN` is published, so the binding is never *absent*, and a host
217
+ // that has not matched its ready signal yet would otherwise turn a media finalize into a 502.
218
+ // Loudly, like the other half: the reason is logged, so a skipped job is never silent.
219
+ if (!entry.spec.optional) throw error;
220
+ log.warn("workflow skipped", {
221
+ workflow: key,
222
+ binding: entry.spec.binding,
223
+ reason: messageOf(error),
224
+ });
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Build the per-request dispatcher over one registry. A closure rather than a lazily-built registry
230
+ * object: there is one method and no per-job resource to construct, so there is nothing to defer.
231
+ */
232
+ export function buildWorkflowDispatcher<Params extends Record<string, unknown> = Record<string, unknown>>(
233
+ env: Record<string, unknown>,
234
+ registry: WorkflowRegistry,
235
+ log: Logger = noopLogger,
236
+ ): WorkflowDispatcher<Params> {
237
+ return {
238
+ trigger: (key, params) => triggerWorkflow(env, registry, key, params, log),
239
+ };
240
+ }
@@ -0,0 +1,184 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { zValidator } from "@hono/zod-validator";
5
+ import type { Context, Hono, Next } from "hono";
6
+ import type { PithyHonoEnv } from "../capability/capability";
7
+ import { type AmbientEnv, ambientEnv, compositionEnvironment } from "../env/ambient";
8
+ import { ForbiddenError, PithyError } from "../error/pithyError";
9
+ import { validationHook } from "../http/validation";
10
+ import type { VerificationStrategy } from "../http/verification";
11
+ import { workflowBindingFor } from "./dispatch";
12
+ import {
13
+ WORKFLOW_DISPATCH_ROUTE,
14
+ WorkflowDispatchParams,
15
+ WorkflowDispatchRequest,
16
+ type WorkflowDispatchResponse,
17
+ } from "./schemas";
18
+ import type { RegisteredWorkflow, WorkflowRegistry } from "./spec";
19
+
20
+ /**
21
+ * The host's dispatch route — the loopback door `pithy dev` starts an instance through.
22
+ *
23
+ * ## Why a host needs an HTTP door at all
24
+ *
25
+ * A deployed app Worker starts a capability's Workflow through a **cross-script** binding:
26
+ * `{ binding: "EMAIL_SENDER", script_name: "<project>-<env>-email" }`. `pithy dev` does not run that
27
+ * second Worker's script under that name, and CLAUDE.md rules out wrangler's cross-`wrangler dev`
28
+ * registry, so locally the binding names nothing and the call throws (pithy-sh/pithy#410). What
29
+ * *does* exist locally is loopback: every worker in the dev set has a pinned port and an
30
+ * `<STEM>_ORIGIN` in every sibling's env. This route is the other end of that wire.
31
+ *
32
+ * The instance is started on the **host's own same-script binding** — the one with no `script_name`,
33
+ * which `wrangler dev` implements unchanged. So the local path and the deployed path run the same
34
+ * Workflow class, from the same registry, with the same params schema. Only the hop differs.
35
+ *
36
+ * ## The environment gate is the security boundary
37
+ *
38
+ * An HTTP door into another Worker's Workflows is not something the kit leaves open. The route is
39
+ * served in a `dev` composition and refused in every other, by a guard that runs before anything
40
+ * reads the request.
41
+ *
42
+ * **It is a mounted refusal, not an unmounted route** — deliberately the opposite of
43
+ * `@pithy-sh/auth`'s dev-login, and the difference is what the two routes do. Dev-login mints an
44
+ * authenticated session with no credential presented, so its existence in a production route table is
45
+ * itself the finding, and it registers nothing outside `dev`. This route starts a job the host
46
+ * already runs on its own cron, on a binding the caller must already have, and it starts nothing at
47
+ * all before the guard has run. What an operator gains from mounting it is the answer: a loopback
48
+ * dispatcher wired into `staging` by mistake gets `auth/forbidden` naming the binding as the path,
49
+ * rather than a 404 that reads as a typo and sends them looking at the URL.
50
+ *
51
+ * **There is no second CI gate**, and that too is the opposite of dev-login's, on purpose. CI is
52
+ * precisely where the local loop has to work — `pithy dev` under an integration suite is a `dev`
53
+ * composition dispatching over loopback — and a gate that shut it there would turn the silence #410
54
+ * repairs back on in the one place nobody is watching for it.
55
+ *
56
+ * ## Contract
57
+ *
58
+ * `public` verification and a declared request contract, with the guard ahead of both validators so
59
+ * a refusal is never downgraded to a 400 that tells a caller which request shapes are well-formed.
60
+ */
61
+
62
+ /**
63
+ * How the route verifies its caller: it does not.
64
+ *
65
+ * Stated rather than omitted, because every route declares one (CLAUDE.md §HTTP) and `public` is the
66
+ * honest answer — there is no credential on this wire. What stands in for one is the environment
67
+ * gate plus the address: the route answers only in a `dev` composition, and a `dev` composition is a
68
+ * `wrangler dev` bound to loopback on a port `pithy dev` pinned. Deployed environments reach the same
69
+ * Workflow through the cross-script binding and never through this.
70
+ */
71
+ export const WORKFLOW_DISPATCH_VERIFICATION: VerificationStrategy = "public";
72
+
73
+ /** The environment this route serves in. Verbatim, and the only one. */
74
+ const DISPATCH_ENVIRONMENT = "dev";
75
+
76
+ /** What the route needs to serve: whose host this is, what it hosts, and where it is running. */
77
+ export interface WorkflowDispatchOptions {
78
+ /** The capability that owns the host. Named in refusals, so an operator knows which Worker answered. */
79
+ capability: string;
80
+ /**
81
+ * The composed registry this host runs. The route resolves `:binding` against it, so a binding the
82
+ * host hosts no Workflow for is refused by name — and the params validate against the declaring
83
+ * spec's own schema rather than against anything restated here.
84
+ */
85
+ registry: WorkflowRegistry;
86
+ /**
87
+ * The ambient environment the gate reads. Defaults to the process env, which in a Worker is the
88
+ * script's own vars — where `pithy init` stamps `ENVIRONMENT`. Injectable for tests.
89
+ */
90
+ env?: AmbientEnv;
91
+ }
92
+
93
+ /** The job this host hosts on a given binding, or `undefined`. */
94
+ function specForBinding(registry: WorkflowRegistry, binding: string): RegisteredWorkflow | undefined {
95
+ return Object.values(registry).find((entry) => entry.spec.binding === binding);
96
+ }
97
+
98
+ /**
99
+ * The refusal. `auth/forbidden` (403) rather than a 404: the door exists, it is shut because of
100
+ * *where* this Worker is running, and pretending otherwise costs the operator the sentence that ends
101
+ * their search. `action` names the real path and is stripped before it reaches the caller, like every
102
+ * other action.
103
+ */
104
+ function refuseOutsideDev(capability: string, environment: string | undefined): ForbiddenError {
105
+ return new ForbiddenError({
106
+ message: `Workflow dispatch over HTTP is served only in the ${DISPATCH_ENVIRONMENT} environment.`,
107
+ action: `Dispatch through the cross-script ${capability} Workflow binding, or run this host under pithy dev.`,
108
+ detail: `The composition's ENVIRONMENT is ${environment ?? "unstamped"}.`,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Mount the dispatch route on a host worker's app.
114
+ *
115
+ * The guard is registered as middleware ahead of the validators so the order is structural rather
116
+ * than remembered: outside `dev` nothing reads the path, the body, or the env.
117
+ */
118
+ export function registerWorkflowDispatchRoute(app: Hono<PithyHonoEnv>, options: WorkflowDispatchOptions): void {
119
+ const { capability, registry } = options;
120
+ const ambient = options.env;
121
+
122
+ // Read at call time, never captured: a module-scope snapshot freezes the answer for the life of an
123
+ // isolate and is unstubbable in a test.
124
+ const guard = async (_c: Context<PithyHonoEnv>, next: Next): Promise<void> => {
125
+ const environment = compositionEnvironment(ambient ?? ambientEnv());
126
+ if (environment !== DISPATCH_ENVIRONMENT) throw refuseOutsideDev(capability, environment);
127
+ await next();
128
+ };
129
+
130
+ app.post(
131
+ WORKFLOW_DISPATCH_ROUTE,
132
+ guard,
133
+ zValidator("param", WorkflowDispatchParams, validationHook),
134
+ zValidator("json", WorkflowDispatchRequest, validationHook),
135
+ async (c) => {
136
+ const { binding } = c.req.valid("param");
137
+ const { id, params } = c.req.valid("json");
138
+
139
+ const entry = specForBinding(registry, binding);
140
+ if (!entry) {
141
+ const known = Object.values(registry)
142
+ .map((registered) => registered.spec.binding)
143
+ .sort()
144
+ .join(", ");
145
+ throw new PithyError({
146
+ code: "core/unknown_workflow",
147
+ status: 500,
148
+ message: `No workflow on this host is bound to "${binding}".`,
149
+ action: `Check the binding name against the ${capability} capability's declared workflows.`,
150
+ detail: known ? `This host hosts: ${known}.` : "This host hosts no workflows at all.",
151
+ });
152
+ }
153
+
154
+ // Params before the binding, exactly as `triggerWorkflow` does it: a malformed payload is the
155
+ // caller's fault and must report the same way whether or not the binding happens to be wired.
156
+ const parsed = entry.spec.params.safeParse(params);
157
+ if (!parsed.success) {
158
+ throw new PithyError({
159
+ code: "core/invalid_workflow_params",
160
+ status: 400,
161
+ message: `The parameters for workflow "${entry.key}" are not valid.`,
162
+ action: "Correct the parameters to match the workflow's schema.",
163
+ detail: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "),
164
+ });
165
+ }
166
+
167
+ const target = workflowBindingFor(c.env as Record<string, unknown>, binding);
168
+ if (!target) {
169
+ throw new PithyError({
170
+ code: "core/missing_workflow_binding",
171
+ status: 500,
172
+ message: `The workflow binding "${binding}" is not available on this host.`,
173
+ action: `Bind ${binding} in the ${capability} host's wrangler.jsonc, then restart it.`,
174
+ detail: `Dispatching "${entry.key}" needs a same-script Workflow binding named ${binding}.`,
175
+ });
176
+ }
177
+
178
+ await target.create({ id, params: parsed.data });
179
+ // 202, not 200: Cloudflare has accepted the instance, and a Workflow is durable precisely so
180
+ // nobody holds its progress. `satisfies` binds the body to the response schema at compile time.
181
+ return c.json({ binding, id, started: true } satisfies WorkflowDispatchResponse, 202);
182
+ },
183
+ );
184
+ }