@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.
- package/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- 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
|
+
}
|