@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,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
|
+
}
|