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