@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,138 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The control-plane wire contract: the names both ends of the seam must spell identically, and the one
6
+ * rule for reading what they carry.
7
+ *
8
+ * **This module imports nothing, and that is its whole job.** Per `docs/CONTROL-PLANE.md` §4 a browser
9
+ * calls the adopter's Worker *directly* — the management client's server mints the token, the browser
10
+ * sends it — so a DOM-typed module has to name the request header, and has to read the version header
11
+ * off the response to notice a customer deploying mid-session. Reaching these constants through
12
+ * `http/verify` drags the verifier in with them: WebCrypto, the replay guard, the seam config. A
13
+ * program compiled against `lib.dom` then fails on `token/digest.ts` and `token/jws.ts`, where a
14
+ * `Uint8Array<ArrayBufferLike>` is not the `BufferSource` the DOM lib declares — the Workers and DOM
15
+ * type libraries meeting, which no amount of care at the call site resolves.
16
+ *
17
+ * The alternative was to let each client re-declare the strings. That is what the dashboard did, with
18
+ * a comment and an assertion holding the copy honest, and two copies of a wire constant is exactly the
19
+ * drift a constant exists to prevent. `wire.test.ts` asserts the module graph here stays empty, because
20
+ * the property is only true until someone adds the first convenient import.
21
+ *
22
+ * {@link workerBuildChanged} is here for the same reason one level up. The dashboard did not only copy
23
+ * the header name — it wrote its own rule for reading the value, "invalidate when the id differs", and
24
+ * that rule was wrong in a way a copied constant could never have caught. A rule both ends must agree on
25
+ * belongs beside the names both ends must spell.
26
+ */
27
+
28
+ /**
29
+ * The header a control-plane token is presented on.
30
+ *
31
+ * Not `Authorization`, deliberately: `@pithy-sh/auth` builds a Better Auth instance for any request
32
+ * carrying that header, so a management call would drag the whole auth stack in. See `http/verify.ts`.
33
+ */
34
+ export const CONTROL_PLANE_HEADER = "pithy-control-plane";
35
+
36
+ /**
37
+ * The response header carrying the build that answered a control-plane call.
38
+ *
39
+ * Set on every control-plane response — allowed and denied alike, and on every capability's admin
40
+ * routes rather than only the seam's own. A management client reads it to pin each recorded action to
41
+ * the exact build it hit, and to notice a version changing mid-session, which is the moment a rendered
42
+ * pane has quietly gone out of date. Absent where the `CF_VERSION_METADATA` binding is, which reads as
43
+ * "this Worker cannot say" rather than as a value to trust.
44
+ *
45
+ * **Carries the id and nothing else, permanently.** Its companion below is a second header rather than
46
+ * a second field in this one for one reason: a client already deployed compares this whole string. Fold
47
+ * anything into it and the day the adopter upgrades the kit reads as a version change that never
48
+ * happened — a false invalidation, which is the failure the pair exists to prevent, arriving from the
49
+ * fix for it.
50
+ */
51
+ export const CONTROL_PLANE_VERSION_HEADER = "pithy-worker-version";
52
+
53
+ /**
54
+ * The response header carrying **when the running version was uploaded** — ISO-8601, verbatim, off the
55
+ * same `CF_VERSION_METADATA` binding as the id.
56
+ *
57
+ * Read beside {@link CONTROL_PLANE_VERSION_HEADER}, never folded into it, so a client can compare two
58
+ * values separately. {@link workerBuildChanged} is the rule; do not hand-write one.
59
+ *
60
+ * **Cloudflare has two objects, and this is the first of them.** A *version* is an immutable upload of
61
+ * code and config — id, created timestamp, tag, fixed at upload and never again. A *deployment* points
62
+ * at one or more versions with traffic percentages, and is its own object with its own id and time. The
63
+ * `CF_VERSION_METADATA` binding reports the version, and **the runtime hands a Worker no binding for the
64
+ * deployment**. So this header is the upload moment, and it is fixed for as long as that version exists.
65
+ *
66
+ * That mechanism predicts the behavior rather than merely recording it: **a rollback creates a new
67
+ * deployment aimed at an existing version, so nothing here moves** — the binding is not stale and not
68
+ * ambiguous, the thing that changed was a different object. It answers the next question too. A traffic
69
+ * split, a gradual rollout, which deployment is serving: none of it is visible from inside a Worker.
70
+ *
71
+ * Measured on a real account, 2026-08-10, which is what made it a fact rather than a reading of the
72
+ * docs: version `A` uploaded at `22:28:56.762349Z`, `B` four seconds later, `wrangler rollback` to `A`
73
+ * at ~`22:29:20`. Reading at `22:31:37` returned `A`'s id **and `A`'s original timestamp**, unmoved,
74
+ * while wrangler's own output named the new deployment's version list. Two details from the same run
75
+ * that a client will meet:
76
+ *
77
+ * - **Every `wrangler deploy` mints a new version**, even for a one-character change — that is why `B`
78
+ * exists. An ordinary redeploy therefore always moves the id, and is never invisible.
79
+ * - **Propagation lags.** Twenty seconds after wrangler reported the rollback at 100%, the URL still
80
+ * answered from the older version. A client watching closely sees the pair flip and settle; that is
81
+ * the platform converging, not two deploys.
82
+ */
83
+ export const CONTROL_PLANE_VERSION_CREATED_HEADER = "pithy-worker-version-created";
84
+
85
+ /**
86
+ * The pair a client reads off one control-plane response: two `headers.get()` calls, each `string | null`.
87
+ */
88
+ export interface WorkerBuild {
89
+ /** {@link CONTROL_PLANE_VERSION_HEADER}, or `null` where the response carried none. */
90
+ version: string | null;
91
+ /** {@link CONTROL_PLANE_VERSION_CREATED_HEADER}, or `null` where the response carried none. */
92
+ createdAt: string | null;
93
+ }
94
+
95
+ /**
96
+ * Did the Worker answering now differ from the Worker that answered before?
97
+ *
98
+ * **The rule, total over the pair.** A client compares field by field and only where both sides carried
99
+ * a value; anything that differs is a change. Four states:
100
+ *
101
+ * 1. nothing differs — the same build, still answering. Say nothing.
102
+ * 2. `version` differs — a different build is live, and what is rendered came from one that no longer
103
+ * serves.
104
+ * 3. `version` the same, `createdAt` differs — **the same build was deployed again.** Same consequence.
105
+ * 4. either side silent on a field — that field says nothing.
106
+ *
107
+ * **State 3 is not reachable on today's platform, and it stays anyway.** A version's timestamp
108
+ * is fixed at upload and a rollback only makes a new deployment, so the pair cannot show a moved time
109
+ * against an unmoved id (see {@link CONTROL_PLANE_VERSION_CREATED_HEADER}). It is here because a total
110
+ * comparison over the pair is the correct shape, not because a rollback was expected to trip it — **a
111
+ * comparison that enumerates which fields are allowed to move is wrong the day the platform moves a
112
+ * different one**, and it is wrong silently, as "nothing changed". It costs one branch.
113
+ *
114
+ * Direction is *not* interpreted. A time that moved is a fact; "it moved backwards, therefore a
115
+ * rollback" is not, and would be wrong twice over — a rollback moves neither field, and propagation lag
116
+ * makes an older version answer for a while after a deploy.
117
+ *
118
+ * **What no state here reaches: a deployment.** Rollbacks, traffic splits, gradual rollouts — a Worker
119
+ * has no binding for the object that carries them, so this is a boundary of what the runtime exposes
120
+ * rather than a gap in the seam. A client that needs a deployment reads Cloudflare's deployments API.
121
+ *
122
+ * **Absence is never change**, in every direction: a Worker that declares no `version_metadata` sends
123
+ * neither header, a call that failed before its headers were read has none, and a client that has not
124
+ * looked yet holds nulls. A blank value counts as absent too — the seam never sends one, but a proxy in
125
+ * between can blank a header it does not understand, and an empty string differs from every real id. The
126
+ * failure this shape refuses is the one that costs an adopter trust: invalidating a rendered pane on a
127
+ * deploy that never happened.
128
+ */
129
+ export function workerBuildChanged(before: WorkerBuild, after: WorkerBuild): boolean {
130
+ return differs(before.version, after.version) || differs(before.createdAt, after.createdAt);
131
+ }
132
+
133
+ /** One field of the pair: a difference only where both sides actually said something. */
134
+ function differs(before: string | null, after: string | null): boolean {
135
+ if (typeof before !== "string" || before.trim() === "") return false;
136
+ if (typeof after !== "string" || after.trim() === "") return false;
137
+ return before !== after;
138
+ }
@@ -0,0 +1,292 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { Hono } from "hono";
5
+ import { noopEmit } from "./audit/recorder";
6
+ import { BindingSpec } from "./capability/bindings";
7
+ import type {
8
+ Capability,
9
+ MergedDatabases,
10
+ MergedKvNamespaces,
11
+ MergedWorkflowParams,
12
+ PithyHonoEnv,
13
+ PithyVars,
14
+ } from "./capability/capability";
15
+ import { recordComposition } from "./capability/composition";
16
+ import { validateBindings } from "./capability/validateBindings";
17
+ import { isControlPlaneCapability } from "./controlPlane/capability";
18
+ import { allowedOriginSet, corsSurface, registerControlPlaneCors } from "./controlPlane/http/cors";
19
+ import { buildDbRegistry, composeDatabases, type DbRegistry } from "./data/databases";
20
+ import { noEntitlementProvider } from "./entitlement/entitlement";
21
+ import { pithyErrorHandler } from "./error/http";
22
+ import { ValidationError } from "./error/pithyError";
23
+ import { catalogFor, composeMessages } from "./i18n/registry";
24
+ import { bakedTranslator, DEFAULT_LOCALE } from "./i18n/translator";
25
+ import { buildKvRegistry, composeKv, type KvRegistry } from "./kv/namespaces";
26
+ import type { Logger } from "./logger/logger";
27
+ import { bindRequestContext, createWorkerLogger } from "./logger/worker";
28
+ import { HEALTH_PATH } from "./worker/health";
29
+ import { workerVersion } from "./worker/identity";
30
+ import { registeredWorkflowBinding } from "./workflow/bindings";
31
+ import { buildWorkflowDispatcher, type WorkflowDispatcher } from "./workflow/dispatch";
32
+ import { composeWorkflows } from "./workflow/register";
33
+
34
+ /** Inputs to {@link createBackend}: the capabilities to assemble, plus the app's own capability. */
35
+ export interface CreateBackendOptions<Caps extends readonly Capability[], App extends Capability> {
36
+ /** Library capabilities (core + each `@pithy-sh/*`), composed in order. */
37
+ capabilities: Caps;
38
+ /**
39
+ * The app itself, as a `Capability` — principle 4: the app is just another capability. Its
40
+ * databases, KV namespaces, middleware, routes, and bindings compose with the rest; routes last.
41
+ */
42
+ app?: App;
43
+ /**
44
+ * The base logger bound to `c.var.log`, set **once** here at assembly (the per-request `env`
45
+ * constraint is honored: correlation fields are bound per request, not at module load). Defaults to
46
+ * the CF-native Mode 2 worker logger (`info`, structured records to Workers Logs). Pass a configured
47
+ * one to change the level or attach a tail/Logpush transport.
48
+ */
49
+ logger?: Logger;
50
+ /**
51
+ * Fallback environment name (`dev` | `staging` | `production`) for the log correlation `env` field.
52
+ * The primary signal is the per-request `ENVIRONMENT` var (the convention `@pithy-sh/secrets` and
53
+ * `@pithy-sh/email` already stamp into each deployed Worker); this is only used where that var is
54
+ * absent — e.g. a non-provisioned local run. Falls through to `unknown` when neither is present.
55
+ */
56
+ env?: string;
57
+ }
58
+
59
+ /** The Hono env `createBackend` returns — `db`/`kv`/`workflows` typed precisely from the merged capabilities. */
60
+ type BackendEnv<Caps extends readonly Capability[]> = {
61
+ Bindings: Record<string, unknown>;
62
+ Variables: Omit<PithyVars, "db" | "kv" | "workflows"> & {
63
+ db: DbRegistry<MergedDatabases<Caps>>;
64
+ kv: KvRegistry<MergedKvNamespaces<Caps>>;
65
+ workflows: WorkflowDispatcher<MergedWorkflowParams<Caps>>;
66
+ };
67
+ };
68
+
69
+ /**
70
+ * Collapse duplicate bindings (same `type:name`) — capability and derived specs overlap. A binding
71
+ * required by **any** source stays required: a derived database/namespace binding (structurally
72
+ * required — the request context builds a handle on it) must not be masked by an author marking the
73
+ * same binding `optional`. So `optional` is the AND of every occurrence.
74
+ */
75
+ function dedupeBindings(specs: BindingSpec[]): BindingSpec[] {
76
+ const byKey = new Map<string, BindingSpec>();
77
+ for (const spec of specs) {
78
+ const key = `${spec.type}:${spec.name}`;
79
+ const existing = byKey.get(key);
80
+ if (!existing) byKey.set(key, { ...spec });
81
+ else if (!spec.optional) existing.optional = false;
82
+ }
83
+ return [...byKey.values()];
84
+ }
85
+
86
+ /**
87
+ * The deployed Worker version id, for the log record's `version` correlation field.
88
+ *
89
+ * Delegates to `workerVersion` rather than re-reading the binding: this used to be a private copy here,
90
+ * and a second reader of one binding is how the name and the declaration drift apart. `undefined` rather
91
+ * than `null` because the logger omits an undefined field.
92
+ */
93
+ function versionOf(env: Record<string, unknown>): string | undefined {
94
+ return workerVersion(env) ?? undefined;
95
+ }
96
+
97
+ /**
98
+ * The environment name for log correlation, resolved from context: the per-request `ENVIRONMENT` var
99
+ * (the signal `@pithy-sh/secrets`/`@pithy-sh/email` stamp into each deployed Worker), else the explicit
100
+ * `createBackend({ env })` fallback, else `unknown`. No caller effort where `ENVIRONMENT` is present.
101
+ */
102
+ function envNameOf(env: Record<string, unknown>, fallback: string | undefined): string {
103
+ return typeof env.ENVIRONMENT === "string" && env.ENVIRONMENT.length > 0 ? env.ENVIRONMENT : (fallback ?? "unknown");
104
+ }
105
+
106
+ /**
107
+ * Assemble capabilities into a deployable Hono app — a valid Worker `fetch` handler.
108
+ *
109
+ * Serves `GET /health`; merges every capability's `databases` and `kvNamespaces` into registries
110
+ * (tables/stores unioned per group); validates that every required binding is present; composes
111
+ * each capability's middleware; and mounts the capability (then app) routes. On every request
112
+ * `c.var.db` is the typed database registry — one `Kysely` per named database (`c.var.db.app`) —
113
+ * and `c.var.kv` the typed namespace registry — one `TypedKv` per store (`c.var.kv.cms.pages`).
114
+ * `PithyError`s become their declared HTTP status via {@link pithyErrorHandler}.
115
+ *
116
+ * Binding validation runs **once on the first request**, not at module load: in Workers `env` is
117
+ * per-request, so there is no env to check until a request arrives. The result is memoized.
118
+ *
119
+ * The return is typed precisely from the capabilities array — `createBackend({ capabilities }).get(
120
+ * "/x", (c) => c.var.db.app.selectFrom("…"))` gets autocomplete on every registered table.
121
+ */
122
+ export function createBackend<
123
+ const Caps extends readonly Capability[],
124
+ const App extends Capability = Capability<Record<never, never>, Record<never, never>>,
125
+ >(options: CreateBackendOptions<Caps, App>): Hono<BackendEnv<readonly [...Caps, App]>> {
126
+ // The app is just another capability, composed last so its routes mount after the libraries'.
127
+ const all: Capability[] = options.app ? [...options.capabilities, options.app] : [...options.capabilities];
128
+
129
+ // Fail fast on a missing peer capability: a capability that reads another's seam (e.g. turnstile
130
+ // reading secrets) must be composed with it, or its requests would only fail one-by-one at runtime.
131
+ const present = new Set(all.map((cap) => cap.name));
132
+ for (const cap of all) {
133
+ for (const dep of cap.dependsOn ?? []) {
134
+ if (!present.has(dep)) {
135
+ throw new ValidationError({
136
+ message: `Capability "${cap.name}" requires the "${dep}" capability, which is not composed.`,
137
+ action: `Add ${dep}() to createBackend's capabilities (run \`pithy add ${dep}\`).`,
138
+ });
139
+ }
140
+ }
141
+ }
142
+
143
+ // Startup hooks: each capability may wire across the full composed set (e.g. @pithy-sh/secrets
144
+ // aggregates every capability's secretRegistry into one combined registry). Runs once at assembly,
145
+ // after dependsOn validation so a hook can rely on its peers being present.
146
+ for (const cap of all) cap.compose?.({ capabilities: all });
147
+
148
+ // Recorded after the hooks, so a capability found later is one whose own wiring is complete. This is
149
+ // the only way back to a composed seam from a Workflow step, which the runtime constructs with `env`
150
+ // and nothing else — see `capability/composition.ts` (pithy-sh/pithy#356).
151
+ recordComposition(all);
152
+
153
+ const databases = composeDatabases(all);
154
+ // Every composed capability's English, merged under the domain rule. Built once at assembly rather
155
+ // than per request: it is a pure function of the capability set, which cannot change between them.
156
+ const messages = composeMessages(all);
157
+ // The seam's behavior with no i18n capability composed — the baked English, no negotiation, no merge.
158
+ // `@pithy-sh/i18n`'s middleware replaces it per request; nothing else ever does.
159
+ const defaultTranslator = bakedTranslator(catalogFor(messages, DEFAULT_LOCALE));
160
+ const namespaces = composeKv(all);
161
+ const workflows = composeWorkflows(all);
162
+
163
+ // Each named database implies its D1 binding; each KV namespace implies its KV binding; each
164
+ // registered job implies its Workflow binding. Derive them so the fail-fast check covers every
165
+ // binding the request context will actually build or dispatch to.
166
+ //
167
+ // A job's `optional` rides through: `@pithy-sh/media` declares its enrichment Workflows optional
168
+ // so an app still boots before `pithy media provision` has deployed the host, and `dedupeBindings`
169
+ // ANDs `optional` across occurrences — deriving these as unconditionally required would override
170
+ // that and break every project that has not provisioned yet.
171
+ const required = dedupeBindings([
172
+ ...all.flatMap((cap) => cap.requiredBindings),
173
+ ...Object.values(databases).map((db) => BindingSpec.parse({ type: "d1", name: db.binding })),
174
+ ...Object.values(namespaces).map((ns) => BindingSpec.parse({ type: "kv", name: ns.binding })),
175
+ ...Object.values(workflows).map((entry) => BindingSpec.parse(registeredWorkflowBinding(entry))),
176
+ ]);
177
+
178
+ // Build internally against the loose base env (db/kv are `unknown`, so `c.set` accepts the merged
179
+ // registries); the precise types ride on the return below.
180
+ const app = new Hono<PithyHonoEnv>();
181
+ app.onError(pithyErrorHandler);
182
+
183
+ // Set once at assembly (the per-request `env` constraint holds: correlation binds per request below,
184
+ // not here). Defaults to the CF-native Mode 2 logger — structured records to Workers Logs, zero-config.
185
+ const baseLogger = options.logger ?? createWorkerLogger();
186
+
187
+ // **CORS first — before this Worker's own base middleware and before every capability's.**
188
+ //
189
+ // Hono runs middleware in registration order, so whatever registers first is the only thing that can
190
+ // put a header on a response somebody else short-circuits. Capabilities short-circuit for real
191
+ // reasons: `@pithy-sh/vector`'s `provisionGuard` is an `app.use("*")` that throws on every request
192
+ // when the provision record disagrees with config. Register CORS after that and a misprovisioned
193
+ // Worker answers a management call with no `Access-Control-Allow-Origin`, so the dashboard sees the
194
+ // opaque `fetch` TypeError this exists to remove — on precisely the misconfiguration where the
195
+ // operator most needs to read the real message. Ahead of `validateBindings` for the same reason.
196
+ //
197
+ // It lives here rather than in the capability's `middleware` hook because that hook runs inside the
198
+ // loop below, in the adopter's composition order, and this must not depend on where they listed
199
+ // `controlplane()`. The surface is every composed capability's `adminRoutes` — `corsSurface` reads
200
+ // exactly the shape a `Capability` already has — so nothing outside that table gains a header.
201
+ const controlPlane = all.find(isControlPlaneCapability);
202
+ if (controlPlane) {
203
+ const cors = controlPlane.controlPlaneConfig;
204
+ registerControlPlaneCors(app, corsSurface(all), allowedOriginSet(cors), cors.corsMaxAgeSeconds);
205
+ }
206
+
207
+ let validated = false;
208
+ app.use("*", async (c, next) => {
209
+ const env = c.env as Record<string, unknown>;
210
+ if (!validated) {
211
+ validateBindings(env, required);
212
+ validated = true;
213
+ }
214
+ if (c.get("auth") === undefined) c.set("auth", null);
215
+ // Null means nothing negotiated, which is not the same fact as "the default was chosen" — only
216
+ // `@pithy-sh/i18n` ever sets it, and `c.var.t` works either way.
217
+ if (c.get("locale") === undefined) c.set("locale", null);
218
+ // Zero-config like `log`: a real translator is always present, so no capability null-checks one.
219
+ if (c.get("t") === undefined) c.set("t", defaultTranslator);
220
+ // Its own variable, never folded into `auth`: a management client is not a user of this app, so a
221
+ // control-plane call must not satisfy any capability's `requireAuth()`. Null until the seam's
222
+ // middleware verifies a credential, which is what makes every control-plane route default-denied.
223
+ if (c.get("controlPlane") === undefined) c.set("controlPlane", null);
224
+ // Null unless the `controlplane()` capability's middleware publishes one. Every
225
+ // `requireControlPlane()` gate in the tree denies while it is null, so an admin route in a Worker
226
+ // that never composed the seam is closed rather than unguarded.
227
+ if (c.get("controlPlaneVerifier") === undefined) c.set("controlPlaneVerifier", null);
228
+ // Null unless a capability publishes a gate bound to the origins it resolved. Every
229
+ // `requireSameOrigin()` denies while it is null — a CSRF check whose policy is missing must refuse,
230
+ // not wave the request through.
231
+ if (c.get("sameOrigin") === undefined) c.set("sameOrigin", null);
232
+ if (c.get("emit") === undefined) c.set("emit", noopEmit);
233
+ // Fail closed: with no payments capability composed, nothing is held and every gate denies.
234
+ if (c.get("entitlements") === undefined) c.set("entitlements", noEntitlementProvider);
235
+ if (c.get("log") === undefined) {
236
+ c.set(
237
+ "log",
238
+ bindRequestContext(baseLogger, {
239
+ // The CF ray id correlates a request across Cloudflare's logs; fall back to a uuid off-platform.
240
+ request: c.req.header("cf-ray") ?? crypto.randomUUID(),
241
+ method: c.req.method,
242
+ path: c.req.path,
243
+ env: envNameOf(env, options.env),
244
+ version: versionOf(env),
245
+ }),
246
+ );
247
+ }
248
+ if (c.get("db") === undefined) c.set("db", buildDbRegistry(env, databases));
249
+ if (c.get("kv") === undefined) c.set("kv", buildKvRegistry(env, namespaces, c.var.log));
250
+ if (c.get("workflows") === undefined) c.set("workflows", buildWorkflowDispatcher(env, workflows, c.var.log));
251
+
252
+ // Auto access-log: one record per request carrying the response `status` and `elapsed`, resolved
253
+ // from context with no caller effort — the completion of the request-correlation seam.
254
+ const start = Date.now();
255
+ await next();
256
+ c.var.log.info("request", { status: c.res.status, elapsed: Date.now() - start });
257
+ });
258
+
259
+ /**
260
+ * `GET {@link HEALTH_PATH}` — liveness, and **which build is answering**.
261
+ *
262
+ * The path is `worker/health.ts`'s, not a literal here. Three other sites need the same string — the
263
+ * CLI's route allowlist, `pithy deploy`'s post-deploy probe, and the bare home screen a no-auth
264
+ * scaffold seeds — and that last one is in somebody else's repository, rendering nothing but this
265
+ * request's answer (#400).
266
+ *
267
+ * The version is what turns `pithy deploy`'s post-deploy check from a liveness probe into an
268
+ * assertion. `status: "ok"` at the declared domain proves *a* Worker is there; it does not prove it is
269
+ * the one just shipped, and the old version answering happily is exactly the failure worth catching —
270
+ * a deploy that landed somewhere else while the declared domain kept serving what was already on it.
271
+ * With the id here, `deploy` compares what it shipped against what replies.
272
+ *
273
+ * **Public, deliberately.** A Cloudflare version id is an opaque UUID carrying no version semantics
274
+ * and no exploitable detail, and most platforms expose a build identifier. The alternative — reporting
275
+ * it only through the authenticated control-plane manifest — is better for privacy and useless for
276
+ * deploy verification, since `deploy` holds no control-plane credential.
277
+ *
278
+ * `null` where the binding is absent, which is honest rather than misleading: a project scaffolded
279
+ * before `version_metadata` was declared reports "I cannot tell you", and `deploy` reports the check
280
+ * as inconclusive instead of failing it.
281
+ */
282
+ app.get(HEALTH_PATH, (c) => c.json({ status: "ok", version: workerVersion(c.env) }));
283
+
284
+ for (const cap of all) {
285
+ for (const middleware of cap.middleware ?? []) middleware(app);
286
+ }
287
+ for (const cap of all) {
288
+ cap.routes?.(app);
289
+ }
290
+
291
+ return app as unknown as Hono<BackendEnv<readonly [...Caps, App]>>;
292
+ }
@@ -0,0 +1,125 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { ExecutionContext, ForwardableEmailMessage } from "@cloudflare/workers-types";
5
+ import type { Capability, CapabilityEmailHandler } from "./capability/capability";
6
+ import { type CreateBackendOptions, createBackend } from "./createBackend";
7
+ import { triggerWorkflow } from "./workflow/dispatch";
8
+ import { composeWorkflows, scheduledWorkflows } from "./workflow/register";
9
+
10
+ /**
11
+ * A deployable Worker entrypoint: `fetch`, the single inbound `email` handler the runtime permits
12
+ * per Worker, and the single `scheduled` handler it permits for cron triggers. `createBackend`
13
+ * already produces the `fetch` side (a Hono app); this wraps it and composes every capability's
14
+ * inbound-email handler and every cron-carrying durable job behind those two entries, so a Worker
15
+ * that mounts `@pithy-sh/email` receives bounce/complaint mail — and fires its scheduler — without
16
+ * the user authoring a handler.
17
+ */
18
+ export interface PithyEntrypoint {
19
+ /** The Worker `fetch` handler — the composed Hono app. */
20
+ fetch: (request: Request, env: Record<string, unknown>, ctx: ExecutionContext) => Response | Promise<Response>;
21
+ /**
22
+ * The Worker `email` handler — fans each incoming message to every capability that declares one.
23
+ * **Present only when at least one capability handles inbound mail.** Omitted otherwise, so a Worker
24
+ * that no capability wired for email doesn't expose a no-op `email()` that would silently *drop* any
25
+ * message routed to it (an exported-but-inert handler consumes the message without forwarding or
26
+ * rejecting it).
27
+ */
28
+ email?: (message: ForwardableEmailMessage, env: Record<string, unknown>, ctx: ExecutionContext) => Promise<void>;
29
+ /**
30
+ * The Worker `scheduled` handler — starts every registered job that declares a `schedule`.
31
+ *
32
+ * **Present only when at least one job carries a cron.** The reason differs from `email`'s: an
33
+ * inert `email()` silently *drops* mail, which is a correctness bug, whereas an inert `scheduled()`
34
+ * is merely harmless. It is omitted anyway so that the export tracks the composition: a Worker
35
+ * mounting no scheduled job does not advertise a schedule it has none of.
36
+ *
37
+ * The converse does happen and is deliberate. A capability whose cron belongs to its *prebuilt host*
38
+ * — `@pithy-sh/email`'s every-minute scheduler is the shipped example — still declares that schedule
39
+ * on its spec, so an app worker composing it exports a `scheduled` handler while its own
40
+ * `wrangler.jsonc` declares no `triggers.crons`. Nothing ever invokes it, and if something did, the
41
+ * job's binding lives only on the host, so dispatch degrades with a logged warning. Deriving the
42
+ * export from "does this deployment declare crons" is not available here: `createEntrypoint` sees
43
+ * the composed capabilities, never the wrangler config.
44
+ *
45
+ * A cron is an *additional* entry point, never the only one: every scheduled job stays dispatchable
46
+ * through `c.var.workflows.trigger(...)`, because a backfill nobody can run on demand cannot be
47
+ * tested in staging.
48
+ */
49
+ scheduled?: (controller: unknown, env: Record<string, unknown>, ctx: ExecutionContext) => Promise<void>;
50
+ }
51
+
52
+ /**
53
+ * The raw MIME stream of a `ForwardableEmailMessage` is **single-use** — once one handler reads it,
54
+ * the next sees an empty stream. When more than one capability handles inbound mail we buffer the
55
+ * body once and hand each handler a proxy whose `raw` yields a fresh stream over that buffer; every
56
+ * other property/method delegates to (and stays bound to) the real message.
57
+ */
58
+ function replayable(message: ForwardableEmailMessage, raw: ArrayBuffer): ForwardableEmailMessage {
59
+ return new Proxy(message, {
60
+ get(target, prop, receiver) {
61
+ if (prop === "raw") return new Response(raw.slice(0)).body as ReadableStream<Uint8Array>;
62
+ const value = Reflect.get(target, prop, receiver);
63
+ return typeof value === "function" ? value.bind(target) : value;
64
+ },
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Assemble capabilities into a Worker entrypoint object — `{ fetch, email }`. Use this instead of
70
+ * `createBackend` directly when any capability handles inbound email (e.g. `@pithy-sh/email`'s bounce
71
+ * handler). `fetch` is the same Hono app `createBackend` returns; `email` fans each incoming message
72
+ * to every capability's `email` handler, replaying the single-use raw stream so each sees the full
73
+ * message. Handlers run sequentially in registration order.
74
+ */
75
+ export function createEntrypoint<
76
+ const Caps extends readonly Capability[],
77
+ const App extends Capability = Capability<Record<never, never>, Record<never, never>>,
78
+ >(options: CreateBackendOptions<Caps, App>): PithyEntrypoint {
79
+ const app = createBackend(options);
80
+ const all: Capability[] = options.app ? [...options.capabilities, options.app] : [...options.capabilities];
81
+ const handlers: CapabilityEmailHandler[] = all.flatMap((cap) => (cap.email ? [cap.email] : []));
82
+
83
+ const registry = composeWorkflows(all);
84
+ const scheduled = scheduledWorkflows(registry);
85
+
86
+ const entrypoint: PithyEntrypoint = { fetch: (request, env, ctx) => app.fetch(request, env, ctx) };
87
+
88
+ // Cron-carrying jobs get the Worker's one `scheduled` entry. Each is started independently so one
89
+ // job whose binding is missing cannot stop the rest of the schedule from running; the failure is
90
+ // rethrown after the pass so the invocation is still recorded as failed.
91
+ if (scheduled.length > 0) {
92
+ entrypoint.scheduled = async (_controller, env) => {
93
+ const failures: unknown[] = [];
94
+ for (const entry of scheduled) {
95
+ // A cron supplies no caller input, which is an *empty* parameter object, not an absent one —
96
+ // `z.object({})` accepts `{}` and rejects `undefined`. A job whose schema requires fields it
97
+ // can never receive on a schedule therefore fails loudly here, which is the author's bug and
98
+ // is exactly what should surface.
99
+ try {
100
+ await triggerWorkflow(env, registry, entry.key, {});
101
+ } catch (error) {
102
+ failures.push(error);
103
+ }
104
+ }
105
+ if (failures[0] !== undefined) throw failures[0];
106
+ };
107
+ }
108
+
109
+ // Only expose an `email` handler when something actually handles inbound mail — an inert one would
110
+ // silently drop any message Email Routing delivered to this Worker.
111
+ if (handlers.length > 0) {
112
+ entrypoint.email = async (message, env, ctx) => {
113
+ if (handlers.length === 1) {
114
+ await handlers[0]?.(message, env, ctx);
115
+ return;
116
+ }
117
+ // Multiple consumers: buffer the raw body once, then replay it per handler.
118
+ const raw = await new Response(message.raw).arrayBuffer();
119
+ for (const handler of handlers) {
120
+ await handler(replayable(message, raw), env, ctx);
121
+ }
122
+ };
123
+ }
124
+ return entrypoint;
125
+ }