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