@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,244 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Hono, MiddlewareHandler } from "hono";
5
+ import type { PithyHonoEnv } from "../../capability/capability";
6
+ import { LOCAL_ENVIRONMENT } from "../../naming/environment";
7
+ import { ENVIRONMENT_VAR } from "../../worker/identity";
8
+ import type { AdminRoute } from "../discovery/adminRoute";
9
+ import { CONTROL_PLANE_HEADER, CONTROL_PLANE_VERSION_CREATED_HEADER, CONTROL_PLANE_VERSION_HEADER } from "../wire";
10
+
11
+ /**
12
+ * CORS for the control-plane surface — the one thing standing between a browser and a Worker that is
13
+ * up, healthy, and answering.
14
+ *
15
+ * **Why this exists at all.** A control-plane call carries its token on {@link CONTROL_PLANE_HEADER},
16
+ * which is not a CORS-safelisted request header, so a browser sends `OPTIONS` first and refuses to send
17
+ * the real request until something answers it. Nothing did. The failure surfaced as a `TypeError` from
18
+ * `fetch` naming the host, so a reachable Worker read as an unreachable one — and the browser-direct
19
+ * path is the whole reason the seam is shaped this way, since the alternative is proxying an adopter's
20
+ * data through a management client's origin.
21
+ *
22
+ * **The allow-list is static config and never a connection row.** A preflight is the one request on
23
+ * this surface that is answered before any credential is read: there is nothing to authenticate it
24
+ * with, because the browser sends it before it will send the token. Consulting D1 would therefore turn
25
+ * it into an oracle over which origins are registered, answerable by anyone. So the list is
26
+ * `[issuer, ...allowedOrigins]` off the parsed config, computed once at composition, and this module
27
+ * reads no database.
28
+ *
29
+ * **An unlisted origin is refused by omission.** It gets the same `204` and the same empty body as an
30
+ * allowed one, with no `Access-Control-Allow-Origin` — the browser blocks the read, and the response
31
+ * says nothing about what the list contains. A `403` here would be more honest as an API and would also
32
+ * be the oracle above, so it is deliberately not one; the caller already sees a clear console error.
33
+ *
34
+ * **No `hono/cors`.** `worker-safety.test.ts` freezes the import allowlist and the middleware is not on
35
+ * it (`docs/STACK.md` notes the ReDoS advisory against it). It also cannot express either of the two
36
+ * rules above — a refusal that is byte-identical to an acceptance, and credentials that are never
37
+ * allowed — so hand-rolling costs about forty lines and buys the behavior the seam actually needs.
38
+ */
39
+
40
+ /** The hostnames a browser reaches a developer's own machine at, and the only ones §dev will allow. */
41
+ const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "[::1]"]);
42
+
43
+ /**
44
+ * Whether this request may use the **local dev** allowance: a loopback origin, in the dev environment.
45
+ *
46
+ * A console an adopter runs against a local Worker sits on `http://localhost:<port>`, on a port the dev
47
+ * port allocator picked. Nobody can write that into `allowedOrigins` and have it stay true across
48
+ * checkouts, so in dev the seam simply allows this machine.
49
+ *
50
+ * **Two guards: `ENVIRONMENT` is `dev`, and the hostname is loopback.** Take the first for what it is
51
+ * — a var the adopter stamps, and `pithy init` writes `dev` into the top-level `wrangler.jsonc` stanza
52
+ * (`cli/src/project/workerScaffold.ts`) that a bare `pithy deploy` publishes. So this is not a claim
53
+ * that the allowance cannot appear on a deployed Worker.
54
+ *
55
+ * It is a claim about what the allowance is *worth*, and that holds wherever it appears. The origins it
56
+ * admits name the caller's **own machine**. No ambient authority crosses, because
57
+ * `Access-Control-Allow-Credentials` is never set on this surface and the token is an explicit header.
58
+ * And every admin route still demands a verified control-plane token. So the most this permits is a
59
+ * local page reading a reply it could have fetched directly, which is not a capability anyone gains.
60
+ *
61
+ * **Do not reach for `<STEM>_ORIGIN` here.** It looks like the precise version of this and it does not
62
+ * work: `pithy dev` publishes those into each child *process*, and the host environment does not cross
63
+ * into workerd — `cli/src/dev/env.ts:157-165`. Only capability *hosts* are forwarded as `--var`
64
+ * (`hostVarArgs`), deliberately (#410), and a UI worker is not one. So a Worker's `env` never carries
65
+ * the console's address, and a version of this that read one allowed exactly the origins no browser
66
+ * calls while reading as though it did more.
67
+ *
68
+ * It is a per-request read rather than part of {@link allowedOriginSet} because in Workers there is no
69
+ * `env` until a request arrives.
70
+ */
71
+ export function devLoopbackAllows(env: Record<string, unknown>, origin: string): boolean {
72
+ if (env[ENVIRONMENT_VAR] !== LOCAL_ENVIRONMENT) return false;
73
+ try {
74
+ return LOOPBACK_HOSTS.has(new URL(origin).hostname);
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /** The two response headers a client must be allowed to read off an admin response, as one value. */
81
+ const EXPOSE_HEADERS = `${CONTROL_PLANE_VERSION_HEADER}, ${CONTROL_PLANE_VERSION_CREATED_HEADER}`;
82
+
83
+ /**
84
+ * One origin, normalized the way a browser spells it, or `null` when the value is not one.
85
+ *
86
+ * Applied to both ends deliberately. `issuer` is a `z.url()` that predates this module and may legally
87
+ * carry a path or a trailing slash, and an `Access-Control-Allow-Origin` carrying either matches no
88
+ * browser `Origin` — so it is normalized rather than trusted. The incoming header is normalized for the
89
+ * same reason from the other direction: it is not ours, and comparing raw strings would turn a spelling
90
+ * difference into a refusal nobody can diagnose.
91
+ */
92
+ export function originOf(value: string | null | undefined): string | null {
93
+ if (!value) return null;
94
+ try {
95
+ const origin = new URL(value).origin;
96
+ // Opaque origins stringify as "null" — a data: or sandboxed frame. Never echo that back: it is a
97
+ // literal match for the string a browser sends for an origin it deliberately refuses to name.
98
+ return origin === "null" ? null : origin;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The effective allow-list: `[issuer, ...allowedOrigins]`, normalized, then deduped.
106
+ *
107
+ * Additive in that order and never replacing, which is the property the whole shape rests on: an
108
+ * adopter who adds their own console cannot silently drop the dashboard that was already working, and
109
+ * a replacing default would have done exactly that the first time someone wrote a single entry.
110
+ * Dedupe happens **after** normalization, so `https://app.pithy.sh` and `https://app.pithy.sh/` are one
111
+ * entry rather than two.
112
+ */
113
+ export function allowedOriginSet(config: { issuer: string; allowedOrigins: readonly string[] }): ReadonlySet<string> {
114
+ const origins = new Set<string>();
115
+ for (const value of [config.issuer, ...config.allowedOrigins]) {
116
+ const origin = originOf(value);
117
+ if (origin !== null) origins.add(origin);
118
+ }
119
+ return origins;
120
+ }
121
+
122
+ /** One path's precomputed answer. Built once at composition; nothing here is derived per request. */
123
+ export interface CorsSurfaceEntry {
124
+ /** The fully mounted path, exactly as the descriptor declares it — `:segment` params included. */
125
+ path: string;
126
+ /** `Access-Control-Allow-Methods`: every method declared on this path, plus `OPTIONS`. */
127
+ allowMethods: string;
128
+ /** `Access-Control-Allow-Headers`: the token header, plus `content-type` where a body is plausible. */
129
+ allowHeaders: string;
130
+ }
131
+
132
+ /**
133
+ * Fold every composed capability's `adminRoutes` into one entry per distinct path.
134
+ *
135
+ * **The descriptors are the surface.** They already exist, a management client already navigates by
136
+ * them, and `routeContract.test.ts` already proves they agree with the mounted router — so deriving
137
+ * CORS from the same table means a capability that adds an admin route gets its preflight for free and
138
+ * there is no second list to drift. The alternative considered and rejected was an app-wide
139
+ * `app.use("*", …)`, which would attach dashboard origins to the adopter's own API — a different
140
+ * surface, with different origins, that is not the kit's decision to make.
141
+ *
142
+ * **`content-type` keys on the method, not on whether the route takes a body.** {@link AdminRoute}
143
+ * carries no body flag, and adding one is worse than the proxy it would replace: that type is read
144
+ * across a version boundary by the management client, so a new field must be `.optional()` and an
145
+ * absent optional says less than the method already does.
146
+ */
147
+ export function corsSurface(capabilities: readonly { adminRoutes?: readonly AdminRoute[] }[]): CorsSurfaceEntry[] {
148
+ const methodsByPath = new Map<string, Set<string>>();
149
+ for (const capability of capabilities) {
150
+ for (const route of capability.adminRoutes ?? []) {
151
+ const methods = methodsByPath.get(route.path) ?? new Set<string>();
152
+ methods.add(route.method);
153
+ methodsByPath.set(route.path, methods);
154
+ }
155
+ }
156
+
157
+ return [...methodsByPath.entries()]
158
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
159
+ .map(([path, methods]) => {
160
+ const carriesBody = [...methods].some((method) => method !== "GET");
161
+ return {
162
+ path,
163
+ allowMethods: [...methods, "OPTIONS"].join(", "),
164
+ allowHeaders: carriesBody ? `${CONTROL_PLANE_HEADER}, content-type` : CONTROL_PLANE_HEADER,
165
+ };
166
+ });
167
+ }
168
+
169
+ /**
170
+ * The per-path middleware: answer the preflight, and mark the real response readable.
171
+ *
172
+ * Both halves are here rather than in an `app.options(…)` of their own, because a preflight that is
173
+ * allowed and a response that cannot be read is the same failure as no preflight at all — the browser
174
+ * blocks a `GET` whose response carries no `Access-Control-Allow-Origin`, `204` or not.
175
+ */
176
+ export function corsMiddleware(
177
+ entry: CorsSurfaceEntry,
178
+ origins: ReadonlySet<string>,
179
+ maxAgeSeconds: number,
180
+ ): MiddlewareHandler<PithyHonoEnv> {
181
+ return async (c, next) => {
182
+ // On every response this layer touches, echoing or not. The allowed and the refused answer differ
183
+ // by exactly one header, so a shared cache keyed on the URL alone would serve one origin's
184
+ // `Access-Control-Allow-Origin` to another. Deliberately wider than "responses that echo an origin".
185
+ c.header("Vary", "Origin", { append: true });
186
+
187
+ const origin = originOf(c.req.raw.headers.get("origin"));
188
+ const allowed =
189
+ origin !== null && (origins.has(origin) || devLoopbackAllows(c.env as Record<string, unknown>, origin));
190
+ if (allowed) c.header("Access-Control-Allow-Origin", origin);
191
+
192
+ // Never `Access-Control-Allow-Credentials`. The token rides a header and this surface sets no
193
+ // cookie, so the kit never opts into credentialed CORS — and the day someone wants cookies here,
194
+ // that is a decision with a threat model, not a header to add.
195
+
196
+ if (c.req.method === "OPTIONS") {
197
+ // The preflight's own headers only where the origin is allowed: a refusal that names the methods
198
+ // and headers of a route it will not let the caller call is disclosure for nothing.
199
+ if (allowed) {
200
+ c.header("Access-Control-Allow-Methods", entry.allowMethods);
201
+ c.header("Access-Control-Allow-Headers", entry.allowHeaders);
202
+ c.header("Access-Control-Max-Age", String(maxAgeSeconds));
203
+ }
204
+ // `c.body(null, 204)`, never `new Response(null, …)`: a raw Response drops everything set above,
205
+ // because Hono only merges headers onto a response it built itself.
206
+ return c.body(null, 204);
207
+ }
208
+
209
+ // On the real response, and only there — `Access-Control-Expose-Headers` means nothing on a
210
+ // preflight. Set even where the origin is not allowed, because the header is inert without the
211
+ // `Access-Control-Allow-Origin` that gates it, and branching would be a second rule to keep true.
212
+ c.header("Access-Control-Expose-Headers", EXPOSE_HEADERS);
213
+ await next();
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Mount one `app.use(path, …)` per entry.
219
+ *
220
+ * **Called from the capability's `middleware` hook, never its `routes` hook, and that is load-bearing.**
221
+ * `createBackend` runs every capability's `middleware` hook before **any** capability's `routes` hook
222
+ * (`createBackend.ts:262-267`), which is what makes this independent of the order an adopter happens to
223
+ * list their capabilities in.
224
+ *
225
+ * From `routes` it looks like it works and does not. The preflight still answers — an `OPTIONS` has no
226
+ * route handler to lose to, so this `use` is the only thing that matches it — but a capability composed
227
+ * *before* the seam has already registered its terminal handler, and a terminal handler never calls
228
+ * `next()`, so the middleware after it never runs. The real response then carries no
229
+ * `Access-Control-Allow-Origin` and the browser blocks a `200` the caller was entitled to read: a
230
+ * preflight that passes and a read that fails, which is a worse failure than the one this fixes because
231
+ * it looks configured. `cors.test.ts` §"the registration slot" pins it by composing the seam last.
232
+ *
233
+ * Exact paths, never a wildcard: `app.use` on a literal path does not match subpaths, so "nothing
234
+ * outside the control-plane surface gains a CORS header" is structural rather than a check that could
235
+ * be got wrong.
236
+ */
237
+ export function registerControlPlaneCors(
238
+ app: Hono<PithyHonoEnv>,
239
+ surface: readonly CorsSurfaceEntry[],
240
+ origins: ReadonlySet<string>,
241
+ maxAgeSeconds: number,
242
+ ): void {
243
+ for (const entry of surface) app.use(entry.path, corsMiddleware(entry, origins, maxAgeSeconds));
244
+ }
@@ -0,0 +1,223 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MiddlewareHandler } from "hono";
5
+ import type { PithyHonoEnv } from "../../capability/capability";
6
+ import { PithyError } from "../../error/pithyError";
7
+ import { workerVersionMetadata } from "../../worker/identity";
8
+ import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
9
+ import type { ControlPlaneContext } from "../context";
10
+ import { ControlPlaneNotConnectedError } from "../error/errors";
11
+ import type { ControlPlaneRequirement } from "../scope/scope";
12
+ import { CONTROL_PLANE_HEADER, CONTROL_PLANE_VERSION_CREATED_HEADER, CONTROL_PLANE_VERSION_HEADER } from "../wire";
13
+ import { type ControlPlaneVerifyDeps, verifyControlPlaneCall } from "./verify";
14
+
15
+ /**
16
+ * `requireControlPlane(scope)` — the gate every control-plane route wears, including the ones
17
+ * capabilities contribute.
18
+ *
19
+ * ## Why a verifier on the request, and not a configured factory
20
+ *
21
+ * `@pithy-sh/payments` writes `requireControlPlane(PAYMENTS_ENTITLEMENT_GRANT_SCOPE)` at module scope. It cannot
22
+ * hold the seam's config — that is the adopter's, resolved when they compose `controlplane()` — and it
23
+ * must not import anything from a sibling package. So the seam contributes one middleware that puts a
24
+ * {@link ControlPlaneVerifier} on the request, and the gate below consumes it. A capability depends on
25
+ * a core seam and nothing else, which is principle 4 doing exactly what it exists for.
26
+ *
27
+ * ## It fails closed, and that is the point
28
+ *
29
+ * With no `controlplane()` composed there is no verifier, and every gate raises
30
+ * `controlplane/not_connected`. A capability that contributes admin routes into a Worker that never
31
+ * enabled the seam therefore denies them, rather than leaving them open because the thing meant to
32
+ * protect them is absent. **A package that imports its authorization from elsewhere must deny when
33
+ * that elsewhere is missing** — the same reason `requireAuth` is copied per capability rather than
34
+ * imported from `@pithy-sh/auth`.
35
+ *
36
+ * ## The requirement is mandatory
37
+ *
38
+ * There is no zero-argument form. An optional scope is a thing you can forget, and forgetting it on an
39
+ * admin route ships an unscoped one; requiring the argument means the only way to write a route with no
40
+ * authorization is to name `ANY_VERIFIED_CALLER` out loud, which is as deliberate as naming a scope.
41
+ */
42
+
43
+ /**
44
+ * Verify one call against this Worker's registrations. Contributed by the seam's middleware, consumed
45
+ * by {@link requireControlPlane}, and absent when the seam is not composed.
46
+ */
47
+ export type ControlPlaneVerifier = (
48
+ request: Request,
49
+ requirement: ControlPlaneRequirement,
50
+ ) => Promise<ControlPlaneContext>;
51
+
52
+ /**
53
+ * Build the verifier the seam's middleware publishes. Split out from the middleware so the wiring is
54
+ * testable without a Worker, and so `verify.ts` stays free of Hono.
55
+ */
56
+ export function createControlPlaneVerifier(deps: ControlPlaneVerifyDeps): ControlPlaneVerifier {
57
+ return async (request, requirement) => {
58
+ // Clone before reading. The handler still has to parse this body through `zValidator`, and a stream
59
+ // consumed here would reach it empty — the digest check would pass and the request would then fail
60
+ // validation for no visible reason.
61
+ const body = new Uint8Array(await request.clone().arrayBuffer());
62
+ // `return await`, not `return`. Returning a promise from an async function makes this frame *adopt*
63
+ // the rejection instead of raising it, and workerd then reports the adopted promise as an unhandled
64
+ // rejection even though the gate catches it and `onError` answers correctly. Refusals are the normal
65
+ // traffic of this seam — an expired token, an ungranted scope — so they must not read as runtime
66
+ // faults in an adopter's logs. Same trap `submit()` in `@pithy-sh/payments` documents.
67
+ return await verifyControlPlaneCall(
68
+ { token: request.headers.get(CONTROL_PLANE_HEADER) ?? undefined, body, requirement },
69
+ deps,
70
+ );
71
+ };
72
+ }
73
+
74
+ /**
75
+ * What this Worker may say about its own build, as headers — everything the binding handed it, and
76
+ * nothing else.
77
+ *
78
+ * A function rather than two lines in the middleware because this is where the seam's one invariant
79
+ * lives and one invariant wants one place to be gated: **every value here was handed to this Worker by
80
+ * the platform, byte for byte.** Nothing is defaulted, nothing is computed at request time, and a field
81
+ * the binding did not carry produces no header at all. That last clause is the load-bearing one — this
82
+ * pair is what a management client invalidates a rendered pane on, and an empty header is a value, so
83
+ * "cannot say" has to arrive as silence rather than as an empty string a client can compare.
84
+ *
85
+ * The tag stays off the wire. It is read by the identity reader and answers neither of the two
86
+ * questions a client asks, and this header crosses to a management client on adopter-authored text.
87
+ */
88
+ export function workerVersionHeaders(env: unknown): Record<string, string> {
89
+ const meta = workerVersionMetadata(env);
90
+ const headers: Record<string, string> = {};
91
+ if (meta.id !== null) headers[CONTROL_PLANE_VERSION_HEADER] = meta.id;
92
+ if (meta.createdAt !== null) headers[CONTROL_PLANE_VERSION_CREATED_HEADER] = meta.createdAt;
93
+ return headers;
94
+ }
95
+
96
+ /**
97
+ * Require a verified control-plane caller carrying `requirement`.
98
+ *
99
+ * Goes **before** any validator on the route line. A validator ahead of the gate turns a 401 into a
100
+ * 400 and tells an unauthenticated caller which requests were well-formed — on this seam that is a
101
+ * live oracle, letting someone with no credential at all probe the shape of key registration.
102
+ */
103
+ /**
104
+ * The brand every {@link requireControlPlane} gate carries, so a mounted router can be read back.
105
+ *
106
+ * A middleware is an opaque function to Hono, so "which routes are control-plane routes" is knowable
107
+ * only if the gate says so about itself. `undeclaredAdminRoutes` needs exactly that: it walks the
108
+ * router looking for guarded routes nobody declared, and without a mark it would have to guess from
109
+ * path shape. `Symbol.for` so the check still works across duplicated copies of this module.
110
+ */
111
+ export const CONTROL_PLANE_GUARD = Symbol.for("pithy.controlPlaneGuard");
112
+
113
+ /** Whether a mounted handler is a control-plane gate. */
114
+ export function isControlPlaneGuard(handler: unknown): boolean {
115
+ return typeof handler === "function" && (handler as unknown as Record<symbol, unknown>)[CONTROL_PLANE_GUARD] === true;
116
+ }
117
+
118
+ export function requireControlPlane(requirement: ControlPlaneRequirement): MiddlewareHandler<PithyHonoEnv> {
119
+ const guard: MiddlewareHandler<PithyHonoEnv> = async (c, next) => {
120
+ // Every control-plane response carries the build that produced it, allowed or denied.
121
+ //
122
+ // **On the guard, not on the seam's own routes**, because the guard is the one thing every
123
+ // control-plane route wears — including the ones capabilities contribute. Stamping it in
124
+ // `manifestHandler` would give the header to the manifest and to nothing else.
125
+ //
126
+ // **Per response, not once at connect.** A client that captured the version when it connected is
127
+ // holding a stale value the moment the adopter deploys — which is exactly when it matters. Per
128
+ // response, each recorded action pins the build it actually hit, and a client can notice the version
129
+ // changing mid-session, which is the moment a rendered pane has quietly gone out of date.
130
+ //
131
+ // **Two headers, because one value cannot answer both questions.** The id says which build; the
132
+ // timestamp beside it says whether the same build has been deployed again, which an id compared
133
+ // against an id can never report. `workerVersionHeaders` owns what may be said and what absence
134
+ // means; `workerBuildChanged` in `../wire` owns how the pair is read.
135
+ for (const [name, value] of Object.entries(workerVersionHeaders(c.env))) c.header(name, value);
136
+
137
+ const verify = c.var.controlPlaneVerifier;
138
+ if (!verify) {
139
+ const denial = new ControlPlaneNotConnectedError({
140
+ message: "This deployment does not accept management-client calls.",
141
+ action: "Add `controlplane()` to this Worker's pithy.config.ts, then run pithy dashboard connect.",
142
+ detail: "no control-plane verifier on the request — the controlplane capability is not composed",
143
+ });
144
+ await auditDenial(c, requirement, denial);
145
+ throw denial;
146
+ }
147
+
148
+ let context: ControlPlaneContext;
149
+ try {
150
+ context = await verify(c.req.raw, requirement);
151
+ } catch (cause) {
152
+ await auditDenial(c, requirement, cause);
153
+ throw cause;
154
+ }
155
+
156
+ c.set("controlPlane", context);
157
+ await safeEmit(
158
+ c.var.emit,
159
+ {
160
+ action: ControlPlaneAuditActions.callAllowed,
161
+ outcome: "success",
162
+ actorType: "control-plane",
163
+ actorId: context.subject,
164
+ resourceType: "controlplane_connection",
165
+ resourceId: context.connectionId,
166
+ requestId: c.req.header("cf-ray"),
167
+ ip: c.req.header("cf-connecting-ip"),
168
+ userAgent: c.req.header("user-agent"),
169
+ metadata: {
170
+ connectionId: context.connectionId,
171
+ connectionEnvironment: context.environment,
172
+ scope: context.scope,
173
+ keyId: context.keyId,
174
+ method: c.req.method,
175
+ path: c.req.path,
176
+ },
177
+ },
178
+ c.var.log,
179
+ );
180
+ await next();
181
+ };
182
+ (guard as unknown as Record<symbol, unknown>)[CONTROL_PLANE_GUARD] = true;
183
+ return guard;
184
+ }
185
+
186
+ /**
187
+ * Record a refused call.
188
+ *
189
+ * **Denials are audited, not only successes.** `denied` is a first-class outcome precisely for this, and
190
+ * this is the surface where an unaudited blocked attempt is least acceptable — a run of them is what a
191
+ * credential being probed looks like, and it is invisible if only the successes are written down.
192
+ *
193
+ * The reason lands in `metadata.reason` from the error's `code`, never from its `detail`: `detail` names
194
+ * the exact verification step, and the audit trail is queryable and long-lived. The step belongs in the
195
+ * log, which is bounded and already carries the whole payload.
196
+ *
197
+ * Nothing here can turn a correct denial into a 500 — `safeEmit` swallows, and the caller rethrows the
198
+ * original error regardless.
199
+ */
200
+ async function auditDenial(
201
+ c: Parameters<MiddlewareHandler<PithyHonoEnv>>[0],
202
+ requirement: ControlPlaneRequirement,
203
+ cause: unknown,
204
+ ): Promise<void> {
205
+ const code = cause instanceof PithyError ? cause.payload.code : "core/internal";
206
+ await safeEmit(
207
+ c.var.emit,
208
+ {
209
+ action: ControlPlaneAuditActions.callDenied,
210
+ outcome: "denied",
211
+ severity: "warning",
212
+ actorType: "control-plane",
213
+ // Null, deliberately. The `sub` claim of a call that failed verification is unproven, and writing
214
+ // an unverified identity into the trail as the actor is how a forged token gets to name someone.
215
+ actorId: null,
216
+ requestId: c.req.header("cf-ray"),
217
+ ip: c.req.header("cf-connecting-ip"),
218
+ userAgent: c.req.header("user-agent"),
219
+ metadata: { reason: code, required: String(requirement), method: c.req.method, path: c.req.path },
220
+ },
221
+ c.var.log,
222
+ );
223
+ }