@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,555 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ExecutionContext, ForwardableEmailMessage } from "@cloudflare/workers-types";
|
|
5
|
+
import type { Hono } from "hono";
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
import type { AuditEmit } from "../audit/recorder";
|
|
8
|
+
import type { ControlPlaneContext } from "../controlPlane/context";
|
|
9
|
+
import type { AdminRoute } from "../controlPlane/discovery/adminRoute";
|
|
10
|
+
import type { CapabilityManifestConfig } from "../controlPlane/discovery/configuration";
|
|
11
|
+
import type { CapabilityHealth } from "../controlPlane/discovery/health";
|
|
12
|
+
import type { ControlPlaneVerifier } from "../controlPlane/http/guard";
|
|
13
|
+
import type { DatabaseSpecMap } from "../data/databases";
|
|
14
|
+
import type { EntitlementResolver } from "../entitlement/entitlement";
|
|
15
|
+
import type { AuthContext } from "../http/authContext";
|
|
16
|
+
import type { SameOriginGate } from "../http/sameOrigin";
|
|
17
|
+
import type { LocaleCatalogs } from "../i18n/catalog";
|
|
18
|
+
import type { LocaleContext } from "../i18n/locale";
|
|
19
|
+
import type { Translator } from "../i18n/translator";
|
|
20
|
+
import type { KvNamespaceSpecMap } from "../kv/namespaces";
|
|
21
|
+
import type { Logger } from "../logger/logger";
|
|
22
|
+
import type { SeedSet } from "../seed/seed";
|
|
23
|
+
import type { WorkflowSpecMap } from "../workflow/spec";
|
|
24
|
+
import { BindingSpec, type BindingSpecInput } from "./bindings";
|
|
25
|
+
import type { ClientProjection, ClientProjectionContext } from "./client";
|
|
26
|
+
import type { CapabilitySettings } from "./settings";
|
|
27
|
+
|
|
28
|
+
/** Hono `Variables` every capability's routes are typed against. `createBackend` seeds these per request. */
|
|
29
|
+
export interface PithyVars {
|
|
30
|
+
/** The authenticated identity, populated by `@pithy-sh/auth`; `null` until a strategy sets it. */
|
|
31
|
+
auth: AuthContext | null;
|
|
32
|
+
/**
|
|
33
|
+
* The resolved request locale, populated by `@pithy-sh/i18n`'s middleware; `null` in a Worker that
|
|
34
|
+
* does not compose it — which is what "nothing was negotiated" looks like, as distinct from "the
|
|
35
|
+
* default was chosen".
|
|
36
|
+
*
|
|
37
|
+
* Read {@link PithyVars.t} to render a message. This is here for the surfaces that need the tags
|
|
38
|
+
* themselves: `lang`/`dir` on a document, the locale stamped onto an email job's row.
|
|
39
|
+
*/
|
|
40
|
+
locale: LocaleContext | null;
|
|
41
|
+
/**
|
|
42
|
+
* The translator seam (`c.var.t`) — every user-facing string a capability renders goes through it.
|
|
43
|
+
*
|
|
44
|
+
* **Zero-config, so nothing null-checks it**, exactly like {@link PithyVars.log}. With no i18n
|
|
45
|
+
* capability composed it is a translator over the baked English every composed capability
|
|
46
|
+
* contributed through {@link Capability.messages}, which is why a project that never opts in behaves
|
|
47
|
+
* byte for byte as it did before.
|
|
48
|
+
*/
|
|
49
|
+
t: Translator;
|
|
50
|
+
/**
|
|
51
|
+
* The verified control-plane caller (`c.var.controlPlane`), populated only by the `control-plane`
|
|
52
|
+
* middleware; `null` on every other request. Read through `requireControlPlane(scope)`.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately a second variable rather than a flavour of {@link auth}. A management client is not a
|
|
55
|
+
* user of the adopter's app — it holds no session and owns no user row — so if a control-plane call
|
|
56
|
+
* set `auth`, every `requireAuth()` in every capability would pass for it. That is a scope escalation
|
|
57
|
+
* across the whole tree, and separating the two seams is what makes it impossible rather than merely
|
|
58
|
+
* unlikely.
|
|
59
|
+
*/
|
|
60
|
+
controlPlane: ControlPlaneContext | null;
|
|
61
|
+
/**
|
|
62
|
+
* The seam's verifier, published by the `controlplane()` capability's middleware and consumed by
|
|
63
|
+
* `requireControlPlane(scope)`; `null` when that capability is not composed.
|
|
64
|
+
*
|
|
65
|
+
* It exists so a capability contributing admin routes — `@pithy-sh/payments` is the first — can write
|
|
66
|
+
* `requireControlPlane(...)` at module scope without holding the adopter's seam config and without
|
|
67
|
+
* importing from a sibling package. `null` is what makes those routes **deny** in a Worker that never
|
|
68
|
+
* enabled the seam, rather than stand open because the thing meant to protect them is absent.
|
|
69
|
+
*/
|
|
70
|
+
controlPlaneVerifier: ControlPlaneVerifier | null;
|
|
71
|
+
/**
|
|
72
|
+
* The same-origin CSRF gate, published **already bound** to this Worker's trusted origins by the
|
|
73
|
+
* capability that resolved them (`@pithy-sh/auth`, from its `baseURL` and `trustedOrigins`); `null`
|
|
74
|
+
* when none is composed. Read through `requireSameOrigin()`, which takes no argument.
|
|
75
|
+
*
|
|
76
|
+
* A bound gate rather than the origin list, and for the same reason {@link controlPlaneVerifier} is a
|
|
77
|
+
* verifier rather than the seam's config: an adopter's routes need the decision, not the material to
|
|
78
|
+
* rebuild it. Handing over the list is how a Worker ends up with two same-origin implementations,
|
|
79
|
+
* free to disagree — and the weaker one is then its real policy.
|
|
80
|
+
*/
|
|
81
|
+
sameOrigin: SameOriginGate | null;
|
|
82
|
+
/**
|
|
83
|
+
* The audit recorder seam. Any capability records a security-relevant action with `c.var.emit(...)`
|
|
84
|
+
* (CLAUDE.md §Security). `@pithy-sh/audit` replaces the default with a D1-backed recorder; with no
|
|
85
|
+
* audit capability composed it is the no-op `noopEmit`. Non-fatal by contract — never throws — so
|
|
86
|
+
* an audited action is never broken by an audit write.
|
|
87
|
+
*/
|
|
88
|
+
emit: AuditEmit;
|
|
89
|
+
/**
|
|
90
|
+
* The entitlement resolver seam — every entitlement the current caller holds. A paid route gates on
|
|
91
|
+
* it with `requireEntitlement("pro")`; `@pithy-sh/payments` replaces the default with a D1-backed
|
|
92
|
+
* resolver over its materialized read model.
|
|
93
|
+
*
|
|
94
|
+
* **The default denies**, which is the one place this seam deliberately differs from `emit`: a
|
|
95
|
+
* missing audit write cannot grant access, but a missing entitlement check can. With no provider
|
|
96
|
+
* composed it is {@link noEntitlementProvider} — holds nothing, so every gate 403s.
|
|
97
|
+
*/
|
|
98
|
+
entitlements: EntitlementResolver;
|
|
99
|
+
/**
|
|
100
|
+
* The logger seam (`c.var.log`). `createBackend` binds a per-request logger carrying request
|
|
101
|
+
* correlation (`request`/`method`/`path`/`env`/`version`); capabilities log through it instead of
|
|
102
|
+
* `console`, and derive a namespaced sub-logger with `c.var.log.child("<capability>")`. Zero-config:
|
|
103
|
+
* a real logger is always present, so nothing null-checks it. Never wire it to a client surface — it
|
|
104
|
+
* carries `PithyError` `detail`, the inverse of the HTTP codec.
|
|
105
|
+
*/
|
|
106
|
+
log: Logger;
|
|
107
|
+
/**
|
|
108
|
+
* Per-request D1 database registry. Loosely `unknown` on this base seam so the contract stays
|
|
109
|
+
* decoupled from any schema; `createBackend`'s return types it precisely as `DbRegistry`
|
|
110
|
+
* (`c.var.db.<database>`). Inside a capability, cast to `DbRegistry<YourDatabases>`.
|
|
111
|
+
*/
|
|
112
|
+
db: unknown;
|
|
113
|
+
/**
|
|
114
|
+
* Per-request KV namespace registry. Loosely `unknown` here; `createBackend`'s return types it as
|
|
115
|
+
* the merged `KvRegistry` (`c.var.kv.<namespace>.<store>`). Inside a capability, cast to
|
|
116
|
+
* `KvRegistry<YourNamespaces>`.
|
|
117
|
+
*/
|
|
118
|
+
kv: unknown;
|
|
119
|
+
/**
|
|
120
|
+
* The durable-job dispatcher — `c.var.workflows.trigger("media/image-to-text", { id })`. Loosely
|
|
121
|
+
* `unknown` on this base seam for the same reason as `db`/`kv`: the precise key and parameter
|
|
122
|
+
* types depend on which capabilities are composed, and `createBackend`'s return narrows it.
|
|
123
|
+
* Inside a capability, cast to `WorkflowDispatcher<YourParams>`.
|
|
124
|
+
*/
|
|
125
|
+
workflows: unknown;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The Hono env. `Bindings` and `Variables` are the base seam; `createBackend` returns a precisely-typed env. */
|
|
129
|
+
export type PithyHonoEnv = { Bindings: Record<string, unknown>; Variables: PithyVars };
|
|
130
|
+
|
|
131
|
+
export type PithyMiddleware = (app: Hono<PithyHonoEnv>) => void;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The structural seam for one capability's secret-registry slice — secret name → its declaration.
|
|
135
|
+
* Core carries it on the {@link Capability} contract so a capability can declare which secrets it
|
|
136
|
+
* reads (CLAUDE.md §secrets: every secret is declared in a registry), without core depending on
|
|
137
|
+
* `@pithy-sh/secrets`. The concrete entry shape, the `defineSecretRegistry` authoring helper, the
|
|
138
|
+
* `secretsStore` reader, and the startup aggregation all live in `@pithy-sh/secrets`; core only
|
|
139
|
+
* needs the common axes (read, never interpreted here) so the seam stays a precise supertype of
|
|
140
|
+
* that package's `SecretRegistry`.
|
|
141
|
+
*/
|
|
142
|
+
export interface SecretRegistryEntrySeam {
|
|
143
|
+
/** Storage backend (`d1` | `cf-secrets-store`). */
|
|
144
|
+
readonly backend: string;
|
|
145
|
+
/** Whether the value differs per environment or is identical everywhere. */
|
|
146
|
+
readonly scope: string;
|
|
147
|
+
/** Whether a future value-rotator may manage the secret. */
|
|
148
|
+
readonly rotatable: boolean;
|
|
149
|
+
/** How a decrypted value is interpreted (`text` | `json`). */
|
|
150
|
+
readonly valueType: string;
|
|
151
|
+
/**
|
|
152
|
+
* How a dev value for this secret may be minted (`random`), or absent when it may not be — because
|
|
153
|
+
* the value must match something outside the project (an OAuth app, a Stripe account). The
|
|
154
|
+
* capability that owns the secret decides; `pithy add` reads the same declaration off the manifest,
|
|
155
|
+
* which it can do without executing the package.
|
|
156
|
+
*/
|
|
157
|
+
readonly devValue?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A capability's secret-registry slice: secret name → {@link SecretRegistryEntrySeam}. */
|
|
161
|
+
export type SecretRegistrySeam = Record<string, SecretRegistryEntrySeam>;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The structural seam for one capability's token-profile slice — profile name → the scoped CF API
|
|
165
|
+
* token that capability's code needs. Declared next to the code that uses it (alongside
|
|
166
|
+
* {@link Capability.secretRegistry}), so a capability owns its token's least-privilege scope and where
|
|
167
|
+
* the minted value lands. Core carries only the structural axes (never interpreted here); the concrete
|
|
168
|
+
* `TokenProfile`, the permission catalog, `defineTokenProfile`, and the mint engine live in
|
|
169
|
+
* `@pithy-sh/cloudflare`/the CLI. `pithy token` aggregates every capability's slice into one registry.
|
|
170
|
+
*/
|
|
171
|
+
export interface TokenProfileSeam {
|
|
172
|
+
/** The CF permissions the token grants, as short catalog keys (`d1:read`, `secrets:write`). */
|
|
173
|
+
readonly permissions: readonly string[];
|
|
174
|
+
/** The resource scope: `"account"` (the whole account, the default) or an explicit CF resource map. */
|
|
175
|
+
readonly resources?: "account" | Record<string, string>;
|
|
176
|
+
/** The secret-registry name the minted value is stored under — its backend decides the destination. */
|
|
177
|
+
readonly secret?: string;
|
|
178
|
+
/** A built-in destination override (`dev-vars` | `ephemeral` | `secrets-store`) for a tooling profile. */
|
|
179
|
+
readonly defaultStore?: string;
|
|
180
|
+
/** Why the profile exists / what consumes it — surfaced in `pithy token` help and docs. */
|
|
181
|
+
readonly description?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A capability's token-profile slice: profile name → {@link TokenProfileSeam}. */
|
|
185
|
+
export type TokenProfileSeamMap = Record<string, TokenProfileSeam>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* One thing an **adopter** plugged into a capability — the capability's own name for something the kit
|
|
189
|
+
* did not ship and cannot enumerate. `@pithy-sh/auth` declares one per additional Better Auth plugin
|
|
190
|
+
* (`{ kind: "better-auth-plugin", id: "organization", tables: ["organization", "member", "invitation"] }`).
|
|
191
|
+
*
|
|
192
|
+
* It exists because an extension is otherwise **invisible**. A capability composed from a package has a
|
|
193
|
+
* name and a version the CLI can read off `package.json`; something an adopter passed into a
|
|
194
|
+
* capability's config has neither, and it can still add routes to the Worker and tables to the database.
|
|
195
|
+
* `pithy doctor` reports these the way it reports capabilities, from this one field, so a new extension
|
|
196
|
+
* point in any capability is a line in the report rather than a new branch in the CLI.
|
|
197
|
+
*
|
|
198
|
+
* Descriptive, never behavioral: nothing in the runtime reads it, and no command acts on it. A
|
|
199
|
+
* capability that lies here has told a diagnostic something untrue, which is why every field is
|
|
200
|
+
* something the capability has already resolved rather than something it intends.
|
|
201
|
+
*/
|
|
202
|
+
export interface CapabilityExtension {
|
|
203
|
+
/** What kind of extension point this is, in the capability's own vocabulary (e.g. `better-auth-plugin`). */
|
|
204
|
+
readonly kind: string;
|
|
205
|
+
/** The extension's identity within that kind — what an adopter would go and delete. */
|
|
206
|
+
readonly id: string;
|
|
207
|
+
/** The database tables it introduced, if any, by the name the migration creates them under. */
|
|
208
|
+
readonly tables?: readonly string[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Context handed to a capability's {@link Capability.compose} hook at worker startup: every composed
|
|
213
|
+
* capability (libraries + app), so a capability can perform cross-capability wiring it could not do
|
|
214
|
+
* at construction time (when it sees only itself). `@pithy-sh/secrets` uses it to aggregate every
|
|
215
|
+
* capability's {@link Capability.secretRegistry} slice into one combined registry for the shared
|
|
216
|
+
* per-invocation secrets accessor.
|
|
217
|
+
*/
|
|
218
|
+
export interface CapabilityComposeContext {
|
|
219
|
+
/** Every capability composed into this backend, in composition order (libraries first, app last). */
|
|
220
|
+
capabilities: readonly Capability[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* An inbound-email handler. A Worker has a single `email()` entry, so the Pithy entrypoint
|
|
225
|
+
* (`createEntrypoint`) fans every incoming message out to each capability that declares one —
|
|
226
|
+
* used for bounce/complaint processing (`@pithy-sh/email`) and any other inbound-mail concern.
|
|
227
|
+
* `env` is the Worker's per-invocation bindings; the handler must consume, forward, or reject the
|
|
228
|
+
* message (an untouched message is dropped by the runtime).
|
|
229
|
+
*
|
|
230
|
+
* **`ForwardableEmailMessage` and `ExecutionContext` are imported rather than assumed ambient**, and
|
|
231
|
+
* that is the whole of #315's second half. This module is reachable from a client: a browser program
|
|
232
|
+
* that names a control-plane scope through a capability's guards lands here, and a browser program
|
|
233
|
+
* has no Workers globals. Read off the global scope, the two names resolved only for a consumer that
|
|
234
|
+
* had `@cloudflare/workers-types` in its `types` — so an adopter's DOM program failed on two errors
|
|
235
|
+
* in *our* source, with no way to fix them short of excluding the kit from typechecking. Imported by
|
|
236
|
+
* name they resolve from the package, which `@pithy-sh/core` now declares as a dependency rather than
|
|
237
|
+
* a devDependency for exactly this reason: a type this file names is a type its consumers must be
|
|
238
|
+
* able to get. `tooling/browser-scopes` compiles with `types: []` and keeps it that way.
|
|
239
|
+
*/
|
|
240
|
+
export type CapabilityEmailHandler = (
|
|
241
|
+
message: ForwardableEmailMessage,
|
|
242
|
+
env: Record<string, unknown>,
|
|
243
|
+
ctx: ExecutionContext,
|
|
244
|
+
) => void | Promise<void>;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The single composition contract. `core`, each capability, and the app all implement it,
|
|
248
|
+
* contributing any subset of {config, migrations, routes, middleware, workflows, databases,
|
|
249
|
+
* kvNamespaces, bindings}. Capabilities depend on core seams (e.g. AuthContext), never on each
|
|
250
|
+
* other's internals.
|
|
251
|
+
*
|
|
252
|
+
* `Databases`/`Namespaces` carry this capability's database and KV-namespace slices so
|
|
253
|
+
* `createBackend` can infer the project-wide merged types. A bare `Capability` annotation widens
|
|
254
|
+
* them; `defineCapability` infers the precise literals.
|
|
255
|
+
*/
|
|
256
|
+
export interface Capability<
|
|
257
|
+
Databases extends DatabaseSpecMap = DatabaseSpecMap,
|
|
258
|
+
Namespaces extends KvNamespaceSpecMap = KvNamespaceSpecMap,
|
|
259
|
+
Name extends string = string,
|
|
260
|
+
Workflows extends WorkflowSpecMap = WorkflowSpecMap,
|
|
261
|
+
> {
|
|
262
|
+
/**
|
|
263
|
+
* The capability's identity: the `pithy add <name>` argument, the migration namespace, the
|
|
264
|
+
* `pithy_<name>_*` table prefix, the error-code domain, and the first segment of every workflow
|
|
265
|
+
* dispatch key. `defineCapability` captures it as a literal so those keys type precisely.
|
|
266
|
+
*/
|
|
267
|
+
name: Name;
|
|
268
|
+
/**
|
|
269
|
+
* Other capabilities this one needs composed alongside it (by `name`) — the runtime mirror of the
|
|
270
|
+
* manifest's `peerCapabilities`. `createBackend` fails fast at assembly if a listed peer is absent,
|
|
271
|
+
* so a capability that reads another's seam (e.g. turnstile reading secrets) surfaces a missing
|
|
272
|
+
* dependency at startup rather than as a per-request error.
|
|
273
|
+
*/
|
|
274
|
+
dependsOn?: readonly string[];
|
|
275
|
+
/** Validated env/config/secrets for this capability. */
|
|
276
|
+
config?: z.ZodType;
|
|
277
|
+
/**
|
|
278
|
+
* The user-facing strings this capability owns, keyed by locale then by `<domain>/<path>` —
|
|
279
|
+
* the peer of {@link Capability.migrations} and {@link Capability.seeds}, merged the same way.
|
|
280
|
+
*
|
|
281
|
+
* **A capability may only declare keys under its own `name`**, and `composeMessages` refuses
|
|
282
|
+
* anything else. That is the `pithy_<capability>_<table>` rule and the `auth/invalid_token` rule,
|
|
283
|
+
* for the third time and for the same reason: the domain segment is what makes two capabilities'
|
|
284
|
+
* contributions incapable of colliding. For an error the key *is* the code, so `KitErrorCode` is the
|
|
285
|
+
* checklist a locale has to cover and there is no second identifier to keep in sync.
|
|
286
|
+
*
|
|
287
|
+
* Kit packages contribute their **English**, which is what makes the i18n capability optional: with
|
|
288
|
+
* nothing composed, `c.var.t` is a translator over exactly this. Translations ship in
|
|
289
|
+
* `@pithy-sh/i18n`, so a typo fix or a new locale reaches every adopter as a package upgrade rather
|
|
290
|
+
* than as a merge into files they own. An adopter's own `app` capability contributes here too, under
|
|
291
|
+
* its own domain, exactly as a kit package does.
|
|
292
|
+
*/
|
|
293
|
+
messages?: LocaleCatalogs;
|
|
294
|
+
/**
|
|
295
|
+
* The secrets this capability reads, as a registry slice (CLAUDE.md §secrets). Additive and
|
|
296
|
+
* optional. `@pithy-sh/secrets` aggregates every capability's slice into one combined registry at
|
|
297
|
+
* worker startup (via {@link Capability.compose}), so the shared per-invocation accessor resolves
|
|
298
|
+
* every declared secret in one batch — and no capability needs to know another's secrets.
|
|
299
|
+
*/
|
|
300
|
+
secretRegistry?: SecretRegistrySeam;
|
|
301
|
+
/**
|
|
302
|
+
* The scoped CF API tokens this capability's code needs, as a token-profile slice (profile name →
|
|
303
|
+
* {@link TokenProfileSeam}). Additive and optional, and declared next to {@link Capability.secretRegistry}
|
|
304
|
+
* — the value of a minted token lands in the secret the profile names. `pithy token` aggregates every
|
|
305
|
+
* capability's slice with the built-in tooling profiles into one registry.
|
|
306
|
+
*/
|
|
307
|
+
tokenProfiles?: TokenProfileSeamMap;
|
|
308
|
+
/**
|
|
309
|
+
* Extra CF permissions this capability needs the **CI system token** to hold, as short catalog keys
|
|
310
|
+
* (e.g. `email:routing` for email provisioning, `kv:write` for a module that seeds KV in CI). They
|
|
311
|
+
* union into the one `ci-system` token `pithy token mint ci-system` produces — so a new capability
|
|
312
|
+
* extends what CI can do without the adopter hand-editing token scopes. Additive and optional.
|
|
313
|
+
*/
|
|
314
|
+
ciPermissions?: readonly string[];
|
|
315
|
+
/**
|
|
316
|
+
* The admin routes this capability contributes behind the `control-plane` strategy, described well
|
|
317
|
+
* enough for a management client to call them — full mounted path, required scope, and a one-line
|
|
318
|
+
* summary. Omit it, or leave it empty, when a capability has no management surface; that is the
|
|
319
|
+
* normal case.
|
|
320
|
+
*
|
|
321
|
+
* Federated the same way migrations, error codes, and audit actions are: a capability declares its
|
|
322
|
+
* own and `GET /control-plane/manifest` reports the union, so a management client composes both its
|
|
323
|
+
* navigation *and* its calls from what the Worker says about itself. Knowing a capability is
|
|
324
|
+
* installed is not enough to call it — `basePath` is configurable, so a client that hardcoded
|
|
325
|
+
* `/payments` breaks against an adopter who mounted it at `/billing`.
|
|
326
|
+
*
|
|
327
|
+
* **Build the paths from the capability's resolved config, never from its defaults**, or the
|
|
328
|
+
* declaration describes a Worker other than this one. `missingAdminRoutes` checks each entry against
|
|
329
|
+
* the router that actually mounted; a stale declaration is worse than none, because a client
|
|
330
|
+
* believes it.
|
|
331
|
+
*/
|
|
332
|
+
adminRoutes?: readonly AdminRoute[];
|
|
333
|
+
/**
|
|
334
|
+
* The bounded health summary this capability contributes to its own manifest entry — a small, fixed
|
|
335
|
+
* set of scalars a management client can render beside a rail without a second round trip (#317).
|
|
336
|
+
* Omit it when a capability has nothing to say, which is the normal case.
|
|
337
|
+
*
|
|
338
|
+
* **Declared alongside the routes, and only through `defineCapabilityHealth`.** The type is branded,
|
|
339
|
+
* so an inline object literal does not compile: every declaration in the tree is parsed, which is what
|
|
340
|
+
* keeps the vocabulary closed, the values scalar, and each value's cost stated. See
|
|
341
|
+
* `controlPlane/discovery/healthSummary.ts` for why those three rules are in the type rather than in
|
|
342
|
+
* a comment.
|
|
343
|
+
*
|
|
344
|
+
* Each key is behind a scope this capability's own `adminRoutes` already require — never a new one,
|
|
345
|
+
* because the scopes an adopter is offered at connect are read off those routes, and a value behind
|
|
346
|
+
* anything else could never be granted. `capabilityHealthSources` refuses that at assembly.
|
|
347
|
+
*/
|
|
348
|
+
health?: CapabilityHealth;
|
|
349
|
+
/**
|
|
350
|
+
* The configured facts this capability states into its own manifest entry — the decisions an adopter
|
|
351
|
+
* already made that a management client has to respect to call anything (#422). Omit it when nothing
|
|
352
|
+
* this capability is configured with changes how a client calls it, which is the normal case.
|
|
353
|
+
*
|
|
354
|
+
* **A third field, because the two obvious names are taken and neither is this.**
|
|
355
|
+
* {@link Capability.config} is the Zod type this capability validates its own env with — a schema, not
|
|
356
|
+
* a value, and it holds provider credentials. {@link Capability.settings} is `pithy doctor`'s seam,
|
|
357
|
+
* which reads local config *before* anything is deployed. This one is a value, resolved at assembly,
|
|
358
|
+
* on the wire, behind `manifest:read`.
|
|
359
|
+
*
|
|
360
|
+
* **Declared only through `defineManifestConfig`.** The type is branded, so an inline literal does not
|
|
361
|
+
* compile: every fact in the tree is checked against a declaration that travels with it, which is what
|
|
362
|
+
* keeps the values scalar and stops `manifestConfig: resolved` from ever being written.
|
|
363
|
+
*
|
|
364
|
+
* **Build it from the capability's resolved config, never from its defaults** — the same rule
|
|
365
|
+
* {@link Capability.adminRoutes} states, and here it is the only defense there is. Nothing downstream
|
|
366
|
+
* compares a fact against the capability's behavior, because the declaration and the value come from
|
|
367
|
+
* one object.
|
|
368
|
+
*/
|
|
369
|
+
manifestConfig?: CapabilityManifestConfig;
|
|
370
|
+
/**
|
|
371
|
+
* How this capability checks that its own **settings work**, as opposed to being merely present — the
|
|
372
|
+
* seam `pithy doctor` runs for every capability every Worker composes (#411). Omit it when a capability
|
|
373
|
+
* has nothing worth checking, which is the normal case and is silent rather than a fault.
|
|
374
|
+
*
|
|
375
|
+
* Declared here, on the instance, and **never keyed off `pithy.manifest.json`**: `@pithy-sh/matchmaking`
|
|
376
|
+
* and `@pithy-sh/rating` are published capability packages that ship no manifest, so a manifest-keyed
|
|
377
|
+
* rule would skip both without saying so — the same trap the version stamper documents.
|
|
378
|
+
*
|
|
379
|
+
* Beside {@link Capability.health} and deliberately not part of it. That one is a bounded scalar summary
|
|
380
|
+
* a **deployed** Worker reports into `GET /control-plane/manifest` for a dashboard; this one reads local
|
|
381
|
+
* config **before** anything is deployed, which is where a wrong `fromAddress` or a `BASE_URL` from the
|
|
382
|
+
* other environment is cheapest to find. See `capability/settings.ts` for the two tiers and why an
|
|
383
|
+
* unreachable account is *skipped* rather than passed.
|
|
384
|
+
*/
|
|
385
|
+
settings?: CapabilitySettings;
|
|
386
|
+
/**
|
|
387
|
+
* The npm version of the package that supplies this capability, or `null` where there is none.
|
|
388
|
+
*
|
|
389
|
+
* Every `@pithy-sh/*` capability sets it from its generated `PACKAGE_VERSION` constant
|
|
390
|
+
* (`scripts/stampVersions.ts`) — a Worker cannot read its own `package.json`, so the value has to be
|
|
391
|
+
* written into the source. **Nullable, and not merely optional**, because the adopter's own `app`
|
|
392
|
+
* capability has a name and no npm version at all; that is an ordinary permanent state, not a gap.
|
|
393
|
+
*
|
|
394
|
+
* `GET /control-plane/manifest` reports it **per capability, never aggregated**. The package name is
|
|
395
|
+
* the join key against a release feed, and a project composes some capabilities and not others — so
|
|
396
|
+
* only the intersection of what a project composes and what actually changed is worth reporting, and
|
|
397
|
+
* that intersection is computable only if both sides stay per-module. Aggregate them and a client
|
|
398
|
+
* tells someone they are "five versions behind" counting packages they never installed.
|
|
399
|
+
*
|
|
400
|
+
* Distinct from the Cloudflare version id the manifest reports beside it. That id is opaque and
|
|
401
|
+
* per-deploy: it says *which build*, which is what forensics needs and what `pithy deploy` verifies.
|
|
402
|
+
* This says *which features*, which is what an upgrade decision needs. Neither answers the other's
|
|
403
|
+
* question.
|
|
404
|
+
*/
|
|
405
|
+
version?: string | null;
|
|
406
|
+
/**
|
|
407
|
+
* Optional startup hook, called once when {@link createBackend} assembles the backend, with every
|
|
408
|
+
* composed capability. Runs after binding and `dependsOn` validation, before middleware and routes
|
|
409
|
+
* mount. Lets a capability wire across capabilities at startup — `@pithy-sh/secrets` aggregates
|
|
410
|
+
* every {@link Capability.secretRegistry} slice here into one combined registry.
|
|
411
|
+
*/
|
|
412
|
+
compose?: (context: CapabilityComposeContext) => void;
|
|
413
|
+
/** Mounts a Hono sub-router. */
|
|
414
|
+
routes?: (app: Hono<PithyHonoEnv>) => void;
|
|
415
|
+
/** Composable middleware (e.g. turnstile(), requireAuth()). */
|
|
416
|
+
middleware?: PithyMiddleware[];
|
|
417
|
+
/**
|
|
418
|
+
* Whether this capability fills the entitlement seam — replaces `c.var.entitlements` with a real
|
|
419
|
+
* resolver. `@pithy-sh/payments` declares it; nothing else does today.
|
|
420
|
+
*
|
|
421
|
+
* It exists so the **CLI** can answer a question no amount of runtime care can: a Worker whose routes
|
|
422
|
+
* call `requireEntitlement()` while composing no provider is not broken, it is silently paywalled
|
|
423
|
+
* shut. The seam already fails closed, so the runtime cannot tell that mistake from a legitimately
|
|
424
|
+
* unentitled user. `pithy doctor` and `pithy dev` compare the gates in a Worker's source against this
|
|
425
|
+
* flag and say so, which turns a fleet of production 403s into one line at startup.
|
|
426
|
+
*/
|
|
427
|
+
providesEntitlements?: boolean;
|
|
428
|
+
/**
|
|
429
|
+
* This capability's client-safe projection — the **only** values of its config that may reach a
|
|
430
|
+
* browser bundle. Resolved at build time against a {@link ClientProjectionContext} (the environment),
|
|
431
|
+
* validated through {@link ClientProjection}, and inlined by the Vite plugin as `virtual:pithy/<name>`.
|
|
432
|
+
* A capability with no browser surface declares none, and the front end reads `{ enabled: false }`.
|
|
433
|
+
* Opt-in by construction: a new config field never ships to a browser unless it is projected here.
|
|
434
|
+
*/
|
|
435
|
+
client?: (context: ClientProjectionContext) => ClientProjection;
|
|
436
|
+
/**
|
|
437
|
+
* Durable jobs this capability registers (job name → {@link WorkflowSpec}) — the peer of
|
|
438
|
+
* `databases` and `kvNamespaces`. `createBackend` merges every capability's map into one registry
|
|
439
|
+
* keyed `<capability>/<job>`, derives each job's Workflow binding so a missing one fails at boot,
|
|
440
|
+
* and serves the typed dispatcher on `c.var.workflows`. The CLI reads the same specs to write the
|
|
441
|
+
* host worker's `workflows` array and its cron triggers.
|
|
442
|
+
*/
|
|
443
|
+
workflows?: Workflows;
|
|
444
|
+
/**
|
|
445
|
+
* Inbound-email handler. The entrypoint (`createEntrypoint`) fans every incoming message to each
|
|
446
|
+
* capability that declares one (e.g. `@pithy-sh/email`'s bounce/complaint handler).
|
|
447
|
+
*/
|
|
448
|
+
email?: CapabilityEmailHandler;
|
|
449
|
+
/**
|
|
450
|
+
* Named D1 databases this capability contributes tables to (database name → {@link DatabaseSpec}).
|
|
451
|
+
* `createBackend` merges every capability's slices per database (via `composeDatabases`) and serves
|
|
452
|
+
* one typed `Kysely` per database on `c.var.db.<name>`. Multiple databases (app, analytics, …)
|
|
453
|
+
* coexist; no central schema file.
|
|
454
|
+
*/
|
|
455
|
+
databases?: Databases;
|
|
456
|
+
/**
|
|
457
|
+
* Named KV namespaces this capability registers (namespace name → {@link KvNamespaceSpec}). Each
|
|
458
|
+
* namespace is a binding holding named typed stores; `createBackend` merges them and serves each
|
|
459
|
+
* store as a live `TypedKv` on `c.var.kv.<namespace>.<store>`. The peer of `databases`.
|
|
460
|
+
*/
|
|
461
|
+
kvNamespaces?: Namespaces;
|
|
462
|
+
/**
|
|
463
|
+
* Seed sets this capability contributes to `pithy seed` (the peer of {@link Capability.databases}).
|
|
464
|
+
* Additive and optional. `composeSeeds` merges every capability's sets library-before-app by
|
|
465
|
+
* `order` and filters them by the target environment; the CLI writes them idempotently and
|
|
466
|
+
* non-destructively. App-shape fixtures are re-encoded and Zod-validated at write time.
|
|
467
|
+
*/
|
|
468
|
+
seeds?: readonly SeedSet[];
|
|
469
|
+
/**
|
|
470
|
+
* What an adopter plugged into this capability — see {@link CapabilityExtension}. Additive and
|
|
471
|
+
* optional; the normal case is none. Resolved from this capability's own config at construction
|
|
472
|
+
* time, and read by `pithy doctor`.
|
|
473
|
+
*/
|
|
474
|
+
extensions?: readonly CapabilityExtension[];
|
|
475
|
+
/** Bindings this capability needs in the env (normalized — `optional` is always set). */
|
|
476
|
+
requiredBindings: BindingSpec[];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Authoring shape: `requiredBindings` may omit `optional`; `defineCapability` normalizes them. */
|
|
480
|
+
export type CapabilityInput<
|
|
481
|
+
Databases extends DatabaseSpecMap = Record<never, never>,
|
|
482
|
+
Namespaces extends KvNamespaceSpecMap = Record<never, never>,
|
|
483
|
+
Name extends string = string,
|
|
484
|
+
Workflows extends WorkflowSpecMap = Record<never, never>,
|
|
485
|
+
> = Omit<Capability<Databases, Namespaces, Name, Workflows>, "requiredBindings"> & {
|
|
486
|
+
requiredBindings: BindingSpecInput[];
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Author a capability. Parses each binding through `BindingSpec` — normalizing `optional`
|
|
491
|
+
* and validating at define time, so an invalid binding fails here (attributed to this
|
|
492
|
+
* capability) rather than deep in backend assembly. The `const` type params capture the precise
|
|
493
|
+
* `databases`/`kvNamespaces` literals so `createBackend` can infer the merged registries.
|
|
494
|
+
*/
|
|
495
|
+
export function defineCapability<
|
|
496
|
+
const Databases extends DatabaseSpecMap = Record<never, never>,
|
|
497
|
+
const Namespaces extends KvNamespaceSpecMap = Record<never, never>,
|
|
498
|
+
const Name extends string = string,
|
|
499
|
+
const Workflows extends WorkflowSpecMap = Record<never, never>,
|
|
500
|
+
>(input: CapabilityInput<Databases, Namespaces, Name, Workflows>): Capability<Databases, Namespaces, Name, Workflows> {
|
|
501
|
+
return {
|
|
502
|
+
...input,
|
|
503
|
+
requiredBindings: input.requiredBindings.map((binding) => BindingSpec.parse(binding)),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Distribute a union into an intersection — merges each capability's slice into one combined map. */
|
|
508
|
+
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
|
509
|
+
|
|
510
|
+
/** This capability's database specs (or `never` when it isn't a `Capability`). */
|
|
511
|
+
type DatabasesOf<C> = C extends Capability<infer D, KvNamespaceSpecMap, string, WorkflowSpecMap> ? D : never;
|
|
512
|
+
|
|
513
|
+
/** This capability's KV namespace specs (or `never` when it isn't a `Capability`). */
|
|
514
|
+
type NamespacesOf<C> = C extends Capability<DatabaseSpecMap, infer N, string, WorkflowSpecMap> ? N : never;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* This capability's jobs as a dispatch-key map: `<capability>/<job>` → that job's parameter type
|
|
518
|
+
* (the schema's **input** side, since the caller supplies pre-parse values). Re-keying here is what
|
|
519
|
+
* lets `trigger` accept a flat, namespaced key while a capability still declares its jobs by bare
|
|
520
|
+
* name.
|
|
521
|
+
*/
|
|
522
|
+
type WorkflowParamsOf<C> =
|
|
523
|
+
C extends Capability<DatabaseSpecMap, KvNamespaceSpecMap, infer Name, infer W>
|
|
524
|
+
? { [Job in keyof W & string as `${Name}/${Job}`]: z.input<W[Job]["params"]> }
|
|
525
|
+
: never;
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Merge a union of per-capability slices into one combined map, clamped to `Bound` so it is always
|
|
529
|
+
* usable: an empty capability set merges to `unknown`, which falls back to the empty map. The
|
|
530
|
+
* intersection unions same-named groups (table union per database, store union per namespace).
|
|
531
|
+
*/
|
|
532
|
+
type MergeClamped<Slices, Bound> =
|
|
533
|
+
UnionToIntersection<Slices> extends infer Merged ? (Merged extends Bound ? Merged : Record<never, never>) : never;
|
|
534
|
+
|
|
535
|
+
/** The project-wide databases: every capability's `databases` merged into one map. */
|
|
536
|
+
export type MergedDatabases<Caps extends readonly Capability[]> = MergeClamped<
|
|
537
|
+
DatabasesOf<Caps[number]>,
|
|
538
|
+
DatabaseSpecMap
|
|
539
|
+
>;
|
|
540
|
+
|
|
541
|
+
/** The project-wide KV namespaces: every capability's `kvNamespaces` merged into one map. */
|
|
542
|
+
export type MergedKvNamespaces<Caps extends readonly Capability[]> = MergeClamped<
|
|
543
|
+
NamespacesOf<Caps[number]>,
|
|
544
|
+
KvNamespaceSpecMap
|
|
545
|
+
>;
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The project-wide workflow parameter map: dispatch key → parameter type, across every composed
|
|
549
|
+
* capability. `createBackend` uses it to type `c.var.workflows.trigger`, so an unregistered key or a
|
|
550
|
+
* payload that does not match the declaring capability's schema is a compile error, not a 500.
|
|
551
|
+
*/
|
|
552
|
+
export type MergedWorkflowParams<Caps extends readonly Capability[]> = MergeClamped<
|
|
553
|
+
WorkflowParamsOf<Caps[number]>,
|
|
554
|
+
Record<string, unknown>
|
|
555
|
+
>;
|