@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,219 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { d1TransientFault } from "../data/withD1Retry";
5
+ import { PithyError } from "../error/pithyError";
6
+ import { encodeWorkflowStepMessage } from "./stepMessage";
7
+
8
+ /**
9
+ * **Every fault that reaches a durable step is classified — retryable or terminal — at a stated site.**
10
+ *
11
+ * A Workflow is chosen *because of* its retry semantics: durable execution, a journal, steps that resume,
12
+ * backoff that survives an eviction. A step that inherits the platform default has not decided; it has
13
+ * deferred, and the default is retry-everything. So a deterministic refusal — a name that already exists,
14
+ * a value too large, a payload that will not parse — is answered slowly instead of immediately, the retry
15
+ * budget burns down on an answer that cannot change, and an instant refusal looks like an outage
16
+ * (pithy-sh/pithy#338).
17
+ *
18
+ * ## The three layers, and which owns what
19
+ *
20
+ * 1. **The capability states its own codes.** A {@link WorkflowRetryPolicy} names the `PithyError` codes
21
+ * that capability retries, each with the reason a second attempt can answer differently. One statement
22
+ * per capability, in one file, the way `@pithy-sh/email`'s `errorMapping.ts` already states the Email
23
+ * Service's `E_*` codes.
24
+ * 2. **Core owns D1.** `withD1Retry` already classifies D1's fault vocabulary, so this asks it rather than
25
+ * growing a second copy — and that is also the no-double-retry rule: a fault the inner layer refused to
26
+ * retry (a constraint violation, a SQL error) is terminal here too, while a transient one stays
27
+ * retryable so the step re-drives it with backoff far longer than the inner loop's.
28
+ * 3. **Everything else is terminal.** Retry is opted into, never inherited. The platform default is the
29
+ * opposite, and it is the wrong way round: a terminal fault wrongly called transient burns the whole
30
+ * budget before surfacing, while a transient one wrongly called terminal surfaces immediately with the
31
+ * fault written on it. One of those is a diagnosis; the other is a hang.
32
+ *
33
+ * ## Where the wrapping happens, and why it cannot be anywhere else
34
+ *
35
+ * {@link classifiedSteps} converts a terminal fault **inside** the step callback. The platform's retry loop
36
+ * lives inside `step.do`: by the time a `step.do` promise rejects, every retry has already been spent. A
37
+ * wrapper around the call would classify a fault the engine had already re-driven five times — correct
38
+ * looking, and exactly as slow as doing nothing.
39
+ */
40
+
41
+ /** Whether the platform should try a step's body again, or stop with the answer it has. */
42
+ export type WorkflowFaultDisposition = "retry" | "terminal";
43
+
44
+ /**
45
+ * One capability's stated retry classification — the whole of what it retries, and why.
46
+ *
47
+ * A record rather than a set, because the value is the argument: "what would a second attempt see that
48
+ * this one did not" is the question that decides, and writing the answer down is what keeps the next code
49
+ * from being added to the list by reflex.
50
+ */
51
+ export interface WorkflowRetryPolicy {
52
+ /** The capability whose Workflows this governs — `secrets`, `payments`, `email`. Named in the fault's reason. */
53
+ capability: string;
54
+ /**
55
+ * `PithyError` code → why a retry can answer differently. **Everything absent is terminal**, including
56
+ * codes another capability retries: a 502 from a payment rail is worth re-driving, and a 502 from a
57
+ * store that has already taken the write is not.
58
+ */
59
+ retryable: Readonly<Record<string, string>>;
60
+ }
61
+
62
+ /** One fault, classified: what it was, what happens next, and the stated reason for it. */
63
+ export interface WorkflowFault {
64
+ /** Retry, or stop. */
65
+ disposition: WorkflowFaultDisposition;
66
+ /** The `PithyError` code, `d1/<fault>` for a D1 fault class, or `unclassified`. */
67
+ code: string;
68
+ /** Why — the policy's own sentence for a retry, or what made this terminal. */
69
+ reason: string;
70
+ }
71
+
72
+ /** The `code` reported for a throw no layer recognizes. */
73
+ const UNCLASSIFIED = "unclassified";
74
+
75
+ /**
76
+ * The code a thrown value carries, or `null` when it is not a `PithyError`.
77
+ *
78
+ * The structural read is not belt-and-braces: `instanceof` is per module instance, and a bundler that
79
+ * gives a host worker two copies of core would silently answer `false` for a perfectly good
80
+ * `PithyError` — which here means classifying a stated retryable fault as terminal. The shape is
81
+ * checked, not assumed.
82
+ */
83
+ function pithyCodeOf(error: unknown): string | null {
84
+ if (error instanceof PithyError) return error.payload.code;
85
+ if (error && typeof error === "object" && "payload" in error) {
86
+ const payload = (error as { payload: unknown }).payload;
87
+ if (payload && typeof payload === "object" && "code" in payload) {
88
+ const code = (payload as { code: unknown }).code;
89
+ if (typeof code === "string") return code;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /** The public message of a thrown value, for the terminal error's own text. */
96
+ function messageOf(error: unknown): string {
97
+ return error instanceof Error ? error.message : String(error);
98
+ }
99
+
100
+ /**
101
+ * The operator's remedy a thrown value carries, or `undefined` when it states none.
102
+ *
103
+ * Read structurally for the same reason {@link pithyCodeOf} is: `instanceof` is per module instance,
104
+ * and two copies of core in one bundle would answer `false` for a perfectly good `PithyError` — here
105
+ * meaning a remedy the raising error stated is silently dropped at the boundary. That is the bug this
106
+ * function exists to end, so it is not reintroduced by the check.
107
+ *
108
+ * **`detail` is deliberately not read here, and there is no sibling for it.** `action` is already
109
+ * operator-facing — the CLI prints it, `operatorError` includes it. `detail` is throw-site context and
110
+ * stays at the throw site.
111
+ */
112
+ function pithyActionOf(error: unknown): string | undefined {
113
+ if (error instanceof PithyError) return error.payload.action;
114
+ if (error && typeof error === "object" && "payload" in error) {
115
+ const payload = (error as { payload: unknown }).payload;
116
+ if (payload && typeof payload === "object" && "action" in payload) {
117
+ const action = (payload as { action: unknown }).action;
118
+ if (typeof action === "string") return action;
119
+ }
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ /**
125
+ * Classify one fault against a capability's policy. Pure, so a capability's classification is a unit
126
+ * test rather than a claim — and so the same function decides in a test, in Miniflare, and in production.
127
+ */
128
+ export function classifyWorkflowFault(error: unknown, policy: WorkflowRetryPolicy): WorkflowFault {
129
+ const code = pithyCodeOf(error);
130
+ if (code !== null) {
131
+ const reason = policy.retryable[code];
132
+ if (reason !== undefined) return { disposition: "retry", code, reason };
133
+ return {
134
+ disposition: "terminal",
135
+ code,
136
+ reason: `${policy.capability} does not retry ${code}: a second attempt reaches the same answer.`,
137
+ };
138
+ }
139
+
140
+ const d1 = d1TransientFault(error);
141
+ if (d1 !== null) {
142
+ return {
143
+ disposition: "retry",
144
+ code: `d1/${d1}`,
145
+ reason: `D1 reported a transient ${d1} fault; withD1Retry classifies it retryable.`,
146
+ };
147
+ }
148
+
149
+ return {
150
+ disposition: "terminal",
151
+ code: UNCLASSIFIED,
152
+ reason: `Nothing classifies this fault, and ${policy.capability} retries only what it states.`,
153
+ };
154
+ }
155
+
156
+ /** The half of a Workflow step this kit uses: run a named body, or serve its journalled result. */
157
+ export interface WorkflowStepLike {
158
+ /** Run a named step, or return its journalled result if this instance already completed it. */
159
+ do<T>(name: string, fn: () => Promise<T>): Promise<T>;
160
+ }
161
+
162
+ /**
163
+ * The platform's terminal-error class — `NonRetryableError` from `cloudflare:workflows`, taken as a
164
+ * parameter.
165
+ *
166
+ * Injected rather than imported because `@pithy-sh/core` is bundled into every adopter's Worker *and*
167
+ * imported by the CLI under node, so it may not import a `cloudflare:` module. The Workflow class is
168
+ * already inside workerd and already imports one; handing the class in costs it a word and keeps the
169
+ * classification itself testable anywhere.
170
+ *
171
+ * **One argument, deliberately.** `NonRetryableError`'s constructor also takes a `name`, and the engine
172
+ * recognizes the error *by* that name: measured against a real Workflow in `wrangler dev`, passing any
173
+ * name but the default turned an instance that failed in 0.7s back into one that retried for 32s and
174
+ * then failed. The signature is narrowed so nobody can reintroduce that by being helpful.
175
+ */
176
+ export interface TerminalErrorConstructor {
177
+ new (message: string): Error;
178
+ }
179
+
180
+ /**
181
+ * Wrap a Workflow's step runner so every fault it raises is classified against `policy`, and a terminal
182
+ * one is re-thrown as the platform's terminal error — **inside the step**, before the retry loop reads it.
183
+ *
184
+ * The result is structurally a step runner, so a body that already takes one (`reconcilePayments`,
185
+ * `runSendBatch`, `runAtRestKeyRotation`) takes this instead with no change: the classification is
186
+ * declared once, at the entrypoint, and every step below it inherits the decision.
187
+ */
188
+ export function classifiedSteps(
189
+ step: WorkflowStepLike,
190
+ policy: WorkflowRetryPolicy,
191
+ terminalError: TerminalErrorConstructor,
192
+ ): WorkflowStepLike {
193
+ return {
194
+ do<T>(name: string, fn: () => Promise<T>): Promise<T> {
195
+ return step.do(name, async () => {
196
+ try {
197
+ return await fn();
198
+ } catch (error) {
199
+ const fault = classifyWorkflowFault(error, policy);
200
+ if (fault.disposition === "retry") throw error;
201
+ // The one sanctioned non-`PithyError` throw in the kit: a platform contract, at the durable
202
+ // boundary, carrying the `PithyError` it replaced as its `cause` so nothing is lost in this
203
+ // process. Across the boundary the text is the whole channel — the engine records it and
204
+ // discards the throw — so what an operator is owed is encoded into it, at one stated site,
205
+ // and read back at one (./stepMessage). The `cause` does not travel; `detail` never did.
206
+ const terminal = new terminalError(
207
+ encodeWorkflowStepMessage({
208
+ code: fault.code,
209
+ message: messageOf(error),
210
+ action: pithyActionOf(error),
211
+ }),
212
+ );
213
+ terminal.cause = error;
214
+ throw terminal;
215
+ }
216
+ });
217
+ },
218
+ };
219
+ }
@@ -0,0 +1,307 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { InternalError } from "../error/pithyError";
5
+ import { ENVIRONMENT_VAR, PROJECT_VAR } from "../worker/identity";
6
+ import { type WorkflowHostNameParts, workflowHostName, workflowScriptName } from "./naming";
7
+ import type { WorkflowRegistry } from "./spec";
8
+
9
+ /**
10
+ * The prebuilt-worker host contract.
11
+ *
12
+ * A capability that owns Workflows ships a committed `wrangler.jsonc` **template** beside its
13
+ * worker entry — not an env-stanza file, because staging and production are genuinely separate
14
+ * Workers. `pithy <capability> provision` resolves that template into one complete config per
15
+ * environment and deploys each with `wrangler deploy --config <resolved>`. The adopter authors
16
+ * none of it.
17
+ *
18
+ * Email, secrets, and media each grew their own copy of that resolver. This module is the one they
19
+ * converge on: a template shape wide enough for all three, and a pure resolver that fills it.
20
+ *
21
+ * Pure by construction — no filesystem, no Cloudflare, no `cloudflare:workers`. The caller parses
22
+ * the template and writes the result. That keeps core node-safe (its `.describe()` meta-test
23
+ * imports every module eagerly under node) and keeps the ordering and idempotency logic unit-
24
+ * testable without an account.
25
+ *
26
+ * **Workflows cannot use remote bindings**, so a host always runs locally in `wrangler dev`. Any
27
+ * binding it needs that has no local emulation — Vectorize and Workers AI both lack one — must
28
+ * carry `remote: true`, which is why {@link WorkflowHostParams.remoteBindings} exists rather than
29
+ * the flag being inferred per binding kind.
30
+ */
31
+
32
+ /** A D1 binding in a host template. `database_name` is rewritten only when the caller supplies one. */
33
+ export interface HostD1Binding {
34
+ binding: string;
35
+ database_name: string;
36
+ database_id: string;
37
+ }
38
+
39
+ /** A KV namespace binding in a host template. */
40
+ export interface HostKvBinding {
41
+ binding: string;
42
+ id: string;
43
+ }
44
+
45
+ /** An R2 bucket binding in a host template. */
46
+ export interface HostR2Binding {
47
+ binding: string;
48
+ bucket_name: string;
49
+ remote?: boolean;
50
+ }
51
+
52
+ /** A Vectorize index binding in a host template. Always `remote: true` — Vectorize has no local emulation. */
53
+ export interface HostVectorizeBinding {
54
+ binding: string;
55
+ index_name: string;
56
+ remote?: boolean;
57
+ }
58
+
59
+ /** The Workers AI binding in a host template. Always `remote: true` — Workers AI has no local emulation. */
60
+ export interface HostAiBinding {
61
+ binding: string;
62
+ remote?: boolean;
63
+ }
64
+
65
+ /** A Cloudflare Email Service send binding in a host template. */
66
+ export interface HostSendEmailBinding {
67
+ name: string;
68
+ remote?: boolean;
69
+ }
70
+
71
+ /** A Secrets Store entry in a host template — the master key a host reads its secrets through. */
72
+ export interface HostSecretsStoreBinding {
73
+ binding: string;
74
+ store_id: string;
75
+ secret_name: string;
76
+ }
77
+
78
+ /** One Workflow the host worker hosts: the binding, the deployed name, and the class that runs it. */
79
+ export interface HostWorkflowBinding {
80
+ binding: string;
81
+ name: string;
82
+ class_name: string;
83
+ }
84
+
85
+ /**
86
+ * A capability's committed `wrangler.jsonc` template, typed. Every field is optional but `name` and
87
+ * `main` — a host that binds no D1 is legal, and email binds no R2. Unknown keys survive
88
+ * `structuredClone` untouched, so a template may carry fields this contract does not model.
89
+ */
90
+ export interface WorkflowHostTemplate {
91
+ name: string;
92
+ main: string;
93
+ compatibility_date?: string;
94
+ compatibility_flags?: string[];
95
+ workers_dev?: boolean;
96
+ d1_databases?: HostD1Binding[];
97
+ kv_namespaces?: HostKvBinding[];
98
+ r2_buckets?: HostR2Binding[];
99
+ vectorize?: HostVectorizeBinding[];
100
+ ai?: HostAiBinding;
101
+ send_email?: HostSendEmailBinding[];
102
+ secrets_store_secrets?: HostSecretsStoreBinding[];
103
+ workflows?: HostWorkflowBinding[];
104
+ triggers?: { crons: string[] };
105
+ vars?: Record<string, string>;
106
+ }
107
+
108
+ /** What one environment's resolution needs. Everything absent is left exactly as the template had it. */
109
+ export interface WorkflowHostParams {
110
+ /**
111
+ * The project name — the `<project>` segment every derived name leads with. The root
112
+ * `pithy.config.ts` `name`, resolved by `requireProjectName` and **never guessed**: Worker script
113
+ * and Workflow names are account-scoped, so a wrong value here overwrites another project's
114
+ * running Worker rather than colliding with it.
115
+ */
116
+ project: string;
117
+ /** The capability that owns the host — the `<capability>` segment of every derived name. */
118
+ capability: string;
119
+ /** The target environment (`dev` | `staging` | `production`). */
120
+ env: string;
121
+ /** D1 binding name → database id. An unlisted binding keeps the template's id. */
122
+ databaseIds?: Record<string, string>;
123
+ /**
124
+ * D1 binding name → database name. Rewritten **only** for the bindings listed here. Email
125
+ * deliberately lets `pithy-app`/`pithy-secrets` pass through while secrets rewrites its own, so
126
+ * the choice cannot be a blanket rule.
127
+ */
128
+ databaseNames?: Record<string, string>;
129
+ /** KV binding name → namespace id. */
130
+ kvNamespaceIds?: Record<string, string>;
131
+ /** R2 binding name → bucket name. */
132
+ r2BucketNames?: Record<string, string>;
133
+ /** Vectorize binding name → index name. */
134
+ vectorizeIndexNames?: Record<string, string>;
135
+ /** The Secrets Store id, set on every `secrets_store_secrets` entry. */
136
+ secretsStoreId?: string;
137
+ /**
138
+ * The environment-scoped name of the master-key secret. Passed in rather than derived: core must
139
+ * not depend on `@pithy-sh/secrets`, which owns that naming.
140
+ */
141
+ masterKeySecretName?: string;
142
+ /** Vars merged over the template's, e.g. the capability's config serialized as one JSON blob. */
143
+ vars?: Record<string, string>;
144
+ /**
145
+ * Binding names that must carry `remote: true`. A Workflow host runs locally in dev, so any
146
+ * binding without local emulation has to reach the real service.
147
+ */
148
+ remoteBindings?: readonly string[];
149
+ /**
150
+ * KV binding names to drop. A template declares every binding a capability *might* need; a mode
151
+ * that does not use one must not ship a binding pointing at a namespace that was never created.
152
+ */
153
+ omitKvBindings?: readonly string[];
154
+ /**
155
+ * The host's `workflows` array, already derived from the capability's registry by
156
+ * {@link hostWorkflowsFor}. **Required whenever the template declares any `workflows`.**
157
+ *
158
+ * The resolver used to build these by appending `-<env>` to the template's own name, which cannot
159
+ * produce a project-scoped name: the template says `pithy-email-send`, and the resolver has no way
160
+ * to recover the job from it. Deriving them from the registry is the only path that knows both the
161
+ * project and the job, so the textual suffix is gone and its absence is an error rather than a
162
+ * silent fallback to an unscoped, account-colliding Workflow name.
163
+ */
164
+ workflows?: readonly HostWorkflowBinding[];
165
+ }
166
+
167
+ /** Apply `remote: true` to an entry whose binding the caller listed. */
168
+ function withRemote<T extends { remote?: boolean }>(entry: T, name: string, remote: ReadonlySet<string>): T {
169
+ return remote.has(name) ? { ...entry, remote: true } : entry;
170
+ }
171
+
172
+ /**
173
+ * Resolve a host template into one environment's complete, standalone config.
174
+ *
175
+ * Structural, never textual: the template is `structuredClone`d and fields are overwritten by
176
+ * binding-name lookup. The `<filled-at-provision>` markers in the committed templates are
177
+ * documentation for whoever reads the file — nothing matches or replaces them, so a template that
178
+ * spells a placeholder differently still resolves correctly.
179
+ *
180
+ * The worker name becomes `<project>-<env>-<capability>` through {@link workflowHostName}, and the
181
+ * `workflows` array is replaced wholesale by `params.workflows` (derived from the registry by
182
+ * {@link hostWorkflowsFor}), so the deployed names cannot drift from what the dispatcher and the
183
+ * CLI compute. `binding` and
184
+ * `class_name` are never rewritten — they are code references, not deployment identities.
185
+ */
186
+ export function resolveWorkflowHost(template: WorkflowHostTemplate, params: WorkflowHostParams): WorkflowHostTemplate {
187
+ const resolved = structuredClone(template);
188
+ const remote = new Set(params.remoteBindings ?? []);
189
+
190
+ resolved.name = workflowHostName({ project: params.project, capability: params.capability, env: params.env });
191
+
192
+ if (resolved.d1_databases) {
193
+ resolved.d1_databases = resolved.d1_databases.map((entry) => ({
194
+ ...entry,
195
+ database_name: params.databaseNames?.[entry.binding] ?? entry.database_name,
196
+ database_id: params.databaseIds?.[entry.binding] ?? entry.database_id,
197
+ }));
198
+ }
199
+
200
+ if (resolved.kv_namespaces) {
201
+ const omit = new Set(params.omitKvBindings ?? []);
202
+ const kept = resolved.kv_namespaces.filter((entry) => !omit.has(entry.binding));
203
+ // Drop the key entirely when nothing survives — wrangler treats an empty array as a declaration.
204
+ if (kept.length > 0) {
205
+ resolved.kv_namespaces = kept.map((entry) => ({
206
+ ...entry,
207
+ id: params.kvNamespaceIds?.[entry.binding] ?? entry.id,
208
+ }));
209
+ } else {
210
+ resolved.kv_namespaces = undefined;
211
+ }
212
+ }
213
+
214
+ if (resolved.r2_buckets) {
215
+ resolved.r2_buckets = resolved.r2_buckets.map((entry) =>
216
+ withRemote(
217
+ { ...entry, bucket_name: params.r2BucketNames?.[entry.binding] ?? entry.bucket_name },
218
+ entry.binding,
219
+ remote,
220
+ ),
221
+ );
222
+ }
223
+
224
+ if (resolved.vectorize) {
225
+ resolved.vectorize = resolved.vectorize.map((entry) =>
226
+ withRemote(
227
+ { ...entry, index_name: params.vectorizeIndexNames?.[entry.binding] ?? entry.index_name },
228
+ entry.binding,
229
+ remote,
230
+ ),
231
+ );
232
+ }
233
+
234
+ if (resolved.ai) {
235
+ resolved.ai = withRemote(resolved.ai, resolved.ai.binding, remote);
236
+ }
237
+
238
+ if (resolved.send_email) {
239
+ resolved.send_email = resolved.send_email.map((entry) => withRemote(entry, entry.name, remote));
240
+ }
241
+
242
+ if (resolved.secrets_store_secrets && params.secretsStoreId !== undefined) {
243
+ const storeId = params.secretsStoreId;
244
+ const masterKey = params.masterKeySecretName;
245
+ resolved.secrets_store_secrets = resolved.secrets_store_secrets.map((entry) => ({
246
+ ...entry,
247
+ store_id: storeId,
248
+ // Only the master key is environment-scoped; every other entry keeps its declared name.
249
+ secret_name:
250
+ masterKey !== undefined && entry.binding === "SECRETS_ENCRYPTION_KEYS" ? masterKey : entry.secret_name,
251
+ }));
252
+ }
253
+
254
+ if (resolved.workflows) {
255
+ if (!params.workflows) {
256
+ throw new InternalError({
257
+ message: `The ${params.capability} host template declares workflows but none were derived.`,
258
+ action: "Pass `workflows` from hostWorkflowsFor(registry, { project, capability, env }).",
259
+ detail:
260
+ "A Workflow name is account-scoped and must carry the project; the template name alone cannot produce one.",
261
+ });
262
+ }
263
+ resolved.workflows = params.workflows.map((entry) => ({ ...entry }));
264
+ }
265
+
266
+ // Identity last: a caller's vars can add anything, but never restate who this worker is.
267
+ resolved.vars = {
268
+ ...resolved.vars,
269
+ ...params.vars,
270
+ [ENVIRONMENT_VAR]: params.env,
271
+ [PROJECT_VAR]: params.project,
272
+ };
273
+
274
+ return resolved;
275
+ }
276
+
277
+ /**
278
+ * Derive a host's `workflows` array and `triggers.crons` from the registry, so a capability's specs
279
+ * — not a hand-maintained block in its template — are the source of both. A spec without a
280
+ * `className` cannot be hosted: there would be no class for wrangler to instantiate.
281
+ */
282
+ export function hostWorkflowsFor(
283
+ registry: WorkflowRegistry,
284
+ parts: WorkflowHostNameParts,
285
+ ): { workflows: HostWorkflowBinding[]; crons: string[] } {
286
+ const { project, capability, env } = parts;
287
+ const owned = Object.values(registry).filter((entry) => entry.capability === capability);
288
+ const workflows: HostWorkflowBinding[] = [];
289
+ const crons: string[] = [];
290
+
291
+ for (const entry of owned) {
292
+ if (!entry.spec.className) {
293
+ throw new InternalError({
294
+ message: `Workflow "${entry.key}" cannot be hosted without a className.`,
295
+ action: "Add className to the spec, naming the exported WorkflowEntrypoint class.",
296
+ });
297
+ }
298
+ workflows.push({
299
+ binding: entry.spec.binding,
300
+ name: workflowScriptName({ project, capability, job: entry.job, env }),
301
+ class_name: entry.spec.className,
302
+ });
303
+ if (entry.spec.schedule) crons.push(entry.spec.schedule);
304
+ }
305
+
306
+ return { workflows, crons };
307
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { HttpError } from "../error/http";
5
+ import { NotFoundError } from "../error/pithyError";
6
+
7
+ /**
8
+ * The default export of a Workflow host that serves nothing over HTTP (#426).
9
+ *
10
+ * ## Why a host needs a default export at all
11
+ *
12
+ * wrangler infers a worker's **module format** from one thing: whether the entry has a default export. A
13
+ * module that exports only classes is read as a service worker, and a service worker may not import
14
+ * `cloudflare:workers` — so the build does not warn and continue, it fails:
15
+ *
16
+ * ▲ [WARNING] The entrypoint … has exports like an ES Module, but hasn't defined a default export
17
+ * like a module worker normally would. Building the worker using "service-worker" format...
18
+ * ✘ [ERROR] Unexpected external import of "cloudflare:workers" and "cloudflare:workflows".
19
+ *
20
+ * `pithy dev` builds each worker separately and carries on past one that fails, so the whole of what an
21
+ * adopter sees is a host that is not there. `support`, `media` and `vector` each shipped that way, and every
22
+ * host that did not was a host with a cron — `export default { async scheduled(…) }`, written for the cron,
23
+ * making the module an ES module by accident. The four authors who satisfied the rule had a reason unrelated
24
+ * to it, and the three who did not had no reason at all. `cli/src/ci/workflowModuleFormat.test.ts` is what
25
+ * now asks the question of the class rather than of the author.
26
+ *
27
+ * ## Why it refuses rather than being empty
28
+ *
29
+ * `export default {}` would build. It would also say nothing: an operator who reaches this Worker in a
30
+ * browser or with a stray `curl` gets an empty 200-shaped nothing from a Worker they were told hosts a job.
31
+ * A Workflow host has no request surface — an app Worker starts its jobs through a cross-script Workflow
32
+ * binding, and a host with a cron fires its own `scheduled` — so an HTTP request arriving at one is a
33
+ * misconfiguration, and the refusal is the sentence that ends the operator's search. `core/not_found`,
34
+ * because that is the truth: this Worker has no HTTP resources. The `action` names the path that does work
35
+ * and is stripped before the body goes out, like every other action.
36
+ *
37
+ * ## The one host this is not for
38
+ *
39
+ * A host that *does* serve HTTP mounts `registerWorkflowDispatchRoute` (./dispatchRoute) and exports its own
40
+ * handler. `@pithy-sh/email` is the case, and its door is the loopback dispatch `pithy dev` knocks on
41
+ * (#410). The other hosts have not been given that door yet; when one is, its `export default` becomes the
42
+ * Hono app and this refusal becomes that app's fallthrough. Nothing here has to change for that.
43
+ */
44
+
45
+ /** What a refusing host exports: a `fetch`, and nothing else. Every other entry a Worker has is absent. */
46
+ export interface WorkflowHostEntry {
47
+ /** Refuse the request. The platform passes a request, an env and a context; none is read, so none is declared. */
48
+ fetch(): Promise<Response>;
49
+ }
50
+
51
+ /**
52
+ * Build the default export for a Workflow host with no HTTP surface.
53
+ *
54
+ * Takes the capability so the refusal says which Worker answered — an operator holding a port number and a
55
+ * 404 needs that before anything else.
56
+ */
57
+ export function workflowHostEntry(capability: string): WorkflowHostEntry {
58
+ return {
59
+ async fetch(): Promise<Response> {
60
+ const refusal = new NotFoundError({
61
+ message: `The ${capability} workflow host serves no HTTP requests.`,
62
+ action: `Start its Workflows through the ${capability} Workflow binding, not over HTTP.`,
63
+ detail: "This Worker hosts Workflow classes and registers no routes.",
64
+ });
65
+ return new Response(JSON.stringify({ error: HttpError.encode(refusal.payload) }), {
66
+ status: refusal.payload.status,
67
+ headers: { "content-type": "application/json" },
68
+ });
69
+ },
70
+ };
71
+ }