@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,198 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ControlPlaneConfig } from "../config/config";
|
|
5
|
+
import { ControlPlaneContext } from "../context";
|
|
6
|
+
import type { ControlPlaneConnection } from "../data/connection";
|
|
7
|
+
import { findVerifyingKey } from "../data/keyLifecycle";
|
|
8
|
+
import {
|
|
9
|
+
ControlPlaneInsufficientScopeError,
|
|
10
|
+
ControlPlaneInvalidCredentialError,
|
|
11
|
+
ControlPlaneNotConnectedError,
|
|
12
|
+
} from "../error/errors";
|
|
13
|
+
import type { ReplayGuard } from "../replay/guard";
|
|
14
|
+
import { type ControlPlaneRequirement, scopeCovers } from "../scope/scope";
|
|
15
|
+
import { sha256Base64Url, timingSafeEqual } from "../token/digest";
|
|
16
|
+
import { parseCompactJws, verifyEd25519 } from "../token/jws";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The verification pipeline — the one place a control-plane call is decided.
|
|
20
|
+
*
|
|
21
|
+
* Written against a dependency object rather than a Hono context so the whole decision is testable
|
|
22
|
+
* without a Worker: every step below is exercised in `verify.test.ts` with a fake connection loader and
|
|
23
|
+
* a fake replay guard. The middleware in `guard.ts` is the thin part that reads those dependencies off
|
|
24
|
+
* the request.
|
|
25
|
+
*
|
|
26
|
+
* ## The header is not `Authorization`, and that is deliberate
|
|
27
|
+
*
|
|
28
|
+
* `@pithy-sh/auth` installs a global middleware that, for **any** request carrying an `authorization`
|
|
29
|
+
* header, builds a Better Auth instance and resolves a session against D1. A control-plane token on
|
|
30
|
+
* that header would therefore hit the auth stack on every management call — wasted work at best, and
|
|
31
|
+
* an unnecessary adjacency between two strategies that must stay separate. On {@link CONTROL_PLANE_HEADER}
|
|
32
|
+
* the seam is invisible to it: no Better Auth instance is ever constructed for a control-plane call,
|
|
33
|
+
* which is the structural fact behind "a control-plane call creates no user and no session".
|
|
34
|
+
*
|
|
35
|
+
* ## Order is a security property, not a style choice
|
|
36
|
+
*
|
|
37
|
+
* Nothing with a side effect happens before the signature verifies. In particular the `jti` is claimed
|
|
38
|
+
* **last**: claim it earlier and an unauthenticated caller could burn the ids of tokens it merely
|
|
39
|
+
* observed, denying a legitimate management client by replaying its own forgeries. Everything before
|
|
40
|
+
* step 7 is a lookup or a comparison, and grants nothing on its own.
|
|
41
|
+
*
|
|
42
|
+
* ## One error for every failure, on purpose
|
|
43
|
+
*
|
|
44
|
+
* Every step but the scope check raises the same {@link ControlPlaneInvalidCredentialError}. A caller
|
|
45
|
+
* cannot tell an unknown key from a bad signature from a replayed token, and so cannot use the response
|
|
46
|
+
* to learn how far a forgery got. The step goes in `detail`, which the HTTP codec strips and the log
|
|
47
|
+
* keeps. The scope failure is distinguished because by then the caller is proven legitimate — telling
|
|
48
|
+
* them which grant they lack is actionable and leaks nothing they do not already hold.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The header names live in `../wire`, which imports nothing so that a browser can hold them — the far
|
|
53
|
+
* end of this seam calls the adopter's Worker directly, and importing them from here drags WebCrypto
|
|
54
|
+
* into a DOM-typed build.
|
|
55
|
+
*
|
|
56
|
+
* Re-exported because this path is already published: every caller outside the repo names
|
|
57
|
+
* `@pithy-sh/core/src/controlPlane/http/verify`, and moving a wire constant must not break the
|
|
58
|
+
* programs that speak the wire. Write new imports against `../wire`; everything in this repo already
|
|
59
|
+
* does.
|
|
60
|
+
*/
|
|
61
|
+
export { CONTROL_PLANE_HEADER, CONTROL_PLANE_VERSION_HEADER } from "../wire";
|
|
62
|
+
|
|
63
|
+
/** What the pipeline needs from the outside world. Every one is injectable, so every step is testable. */
|
|
64
|
+
export interface ControlPlaneVerifyDeps {
|
|
65
|
+
/** Load the connection a token addresses, by id. Null when no such connection exists here. */
|
|
66
|
+
loadConnection: (connectionId: string) => Promise<ControlPlaneConnection | null>;
|
|
67
|
+
/**
|
|
68
|
+
* How many connections this environment has at all. Called **only** when the lookup above missed, to
|
|
69
|
+
* separate "you have never connected anything" — worth saying plainly — from "that connection id is
|
|
70
|
+
* not one of ours", which must stay indistinguishable from every other credential failure or it
|
|
71
|
+
* becomes an oracle for enumerating connection ids.
|
|
72
|
+
*/
|
|
73
|
+
countConnections: () => Promise<number>;
|
|
74
|
+
/** The single-use gate over `jti`. */
|
|
75
|
+
replay: ReplayGuard;
|
|
76
|
+
/** This Worker's environment name, checked against the connection's. */
|
|
77
|
+
environment: string;
|
|
78
|
+
/** The resolved seam config — the token and window bounds this Worker enforces. */
|
|
79
|
+
config: ControlPlaneConfig;
|
|
80
|
+
/** The clock, injected so a test can stand at any instant. */
|
|
81
|
+
now: () => Date;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One inbound call, reduced to what verification actually needs. */
|
|
85
|
+
export interface ControlPlaneCall {
|
|
86
|
+
/** The compact JWS from {@link CONTROL_PLANE_HEADER}, or undefined when the caller sent none. */
|
|
87
|
+
token: string | undefined;
|
|
88
|
+
/** The raw request body bytes. Empty for a request that carries no body. */
|
|
89
|
+
body: Uint8Array;
|
|
90
|
+
/** What the route demands: one named scope, or merely a verified caller. */
|
|
91
|
+
requirement: ControlPlaneRequirement;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Raise the one credential error, naming the failing step for the log and nobody else. */
|
|
95
|
+
function deny(step: string): never {
|
|
96
|
+
throw new ControlPlaneInvalidCredentialError({ detail: step });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Verify one control-plane call, or throw. Returns the context the handler runs under.
|
|
101
|
+
*
|
|
102
|
+
* The steps are numbered to match `docs/CONTROL-PLANE.md` §9.
|
|
103
|
+
*/
|
|
104
|
+
export async function verifyControlPlaneCall(
|
|
105
|
+
call: ControlPlaneCall,
|
|
106
|
+
deps: ControlPlaneVerifyDeps,
|
|
107
|
+
): Promise<ControlPlaneContext> {
|
|
108
|
+
// 1. A credential was presented at all.
|
|
109
|
+
if (!call.token) deny("no control-plane credential presented");
|
|
110
|
+
|
|
111
|
+
// 2. Well-formed. Untrusted: a forged token parses exactly as cleanly as a genuine one.
|
|
112
|
+
const { header, claims, signingInput, signature } = parseCompactJws(call.token);
|
|
113
|
+
|
|
114
|
+
// 3. The connection the token addresses. A lookup, not a grant.
|
|
115
|
+
const connection = await deps.loadConnection(claims.aud);
|
|
116
|
+
if (!connection) {
|
|
117
|
+
if ((await deps.countConnections()) === 0) {
|
|
118
|
+
throw new ControlPlaneNotConnectedError({
|
|
119
|
+
detail: `no connection registered in environment ${deps.environment}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
deny(`aud ${claims.aud} names no connection here`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 4. Environment. A staging credential must not reach production, and this is where that is true.
|
|
126
|
+
if (connection.environment !== deps.environment) {
|
|
127
|
+
deny(`connection ${connection.id} is bound to ${connection.environment}, not ${deps.environment}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 5. Issuer. A validly-signed token minted by some other origin is still not for us.
|
|
131
|
+
if (claims.iss !== connection.issuer) {
|
|
132
|
+
deny(`iss ${claims.iss} is not the issuer connection ${connection.id} trusts`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 6. The key named by `kid`, if it is registered, unrevoked, and inside its window.
|
|
136
|
+
const key = findVerifyingKey(connection.keys, header.kid, deps.now());
|
|
137
|
+
if (!key) deny(`kid ${header.kid} is not a live key on connection ${connection.id}`);
|
|
138
|
+
|
|
139
|
+
// 7. The signature. Authenticity is established here and nowhere earlier.
|
|
140
|
+
if (!(await verifyEd25519(signingInput, signature, key.publicKey))) {
|
|
141
|
+
deny(`signature does not verify under kid ${header.kid}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 8. The lifetime this Worker is willing to honor, whatever the token asked for.
|
|
145
|
+
const lifetime = claims.exp - claims.iat;
|
|
146
|
+
if (lifetime <= 0 || lifetime > deps.config.maxTokenLifetimeSeconds) {
|
|
147
|
+
deny(`token lifetime ${lifetime}s exceeds the ${deps.config.maxTokenLifetimeSeconds}s maximum`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 9. Expiry and issue time, within the configured skew.
|
|
151
|
+
const nowSeconds = Math.floor(deps.now().getTime() / 1000);
|
|
152
|
+
const skew = deps.config.clockSkewSeconds;
|
|
153
|
+
if (claims.exp <= nowSeconds - skew) deny(`token expired at ${claims.exp}, now ${nowSeconds}`);
|
|
154
|
+
if (claims.iat > nowSeconds + skew) deny(`token issued at ${claims.iat} is in the future, now ${nowSeconds}`);
|
|
155
|
+
|
|
156
|
+
// 10. The body digest. The signature covers the header and claims only, so without this a token could
|
|
157
|
+
// be lifted onto a different body for the same route.
|
|
158
|
+
await verifyBodyDigest(call.body, claims.bodySha256);
|
|
159
|
+
|
|
160
|
+
// 11. Scope. Both sides must agree: what the adopter granted, and what this one token was minted for.
|
|
161
|
+
if (!scopeCovers(call.requirement, claims.scope, connection.scopes)) {
|
|
162
|
+
throw new ControlPlaneInsufficientScopeError({
|
|
163
|
+
detail: `required ${String(call.requirement)}; token carried ${claims.scope}; connection grants [${connection.scopes.join(", ")}]`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 12. Spend the token. Last, because it is the only step that writes anything.
|
|
168
|
+
if (!(await deps.replay.claim(claims.jti, connection.id))) {
|
|
169
|
+
deny(`jti ${claims.jti} was already spent`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return ControlPlaneContext.parse({
|
|
173
|
+
connectionId: connection.id,
|
|
174
|
+
environment: connection.environment,
|
|
175
|
+
issuer: connection.issuer,
|
|
176
|
+
subject: claims.sub,
|
|
177
|
+
scope: claims.scope,
|
|
178
|
+
grantedScopes: connection.scopes,
|
|
179
|
+
keyId: key.keyId,
|
|
180
|
+
tokenId: claims.jti,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The body digest check. `bodySha256` is null exactly when the body is empty — a bijection, so there is
|
|
186
|
+
* no third state a caller could steer into. A token minted for an empty body cannot be presented with
|
|
187
|
+
* one, and a token minted for a body cannot be presented without it.
|
|
188
|
+
*/
|
|
189
|
+
async function verifyBodyDigest(body: Uint8Array, claimed: string | null): Promise<void> {
|
|
190
|
+
if (body.byteLength === 0) {
|
|
191
|
+
if (claimed !== null) deny("token claims a body digest but the request carries no body");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (claimed === null) deny("request carries a body but the token claims no digest");
|
|
195
|
+
// Constant-time, though both values are already public: the habit is what keeps a future digest
|
|
196
|
+
// comparison over something secret from being written the easy way.
|
|
197
|
+
if (!timingSafeEqual(await sha256Base64Url(body), claimed)) deny("body digest does not match the token");
|
|
198
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Kysely } from "kysely";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The control-plane seam's tables: which management client may call this Worker, and which of its tokens
|
|
9
|
+
* have already been spent.
|
|
10
|
+
*
|
|
11
|
+
* This is the inbound seam — the hosted dashboard calling *into* the adopter's Worker. It is not
|
|
12
|
+
* Cloudflare's control plane, which is the outbound provisioning REST API and shares nothing with these
|
|
13
|
+
* tables.
|
|
14
|
+
*
|
|
15
|
+
* camelCase identifiers; `CamelCasePlugin` snake-cases them to `pithy_controlplane_*` in the DDL. `down`
|
|
16
|
+
* drops each index before its table, and is tested.
|
|
17
|
+
*
|
|
18
|
+
* ## `pithy_controlplane_connections`
|
|
19
|
+
*
|
|
20
|
+
* Two shapes are deliberate.
|
|
21
|
+
*
|
|
22
|
+
* `id` is text, not an autoincrementing integer. It is the token's `aud`, so it leaves the Worker on
|
|
23
|
+
* every call; a sequential id would tell a caller how many connections exist and let them guess the
|
|
24
|
+
* next one.
|
|
25
|
+
*
|
|
26
|
+
* `scopes` and `keys` are JSON columns rather than child tables. Keys especially: a rotation is then
|
|
27
|
+
* one `UPDATE` that cannot half-apply, and verification is one primary-key read with no join on a
|
|
28
|
+
* Worker hot path. SQLite cannot `CHECK` inside a JSON string, so the Zod schemas on
|
|
29
|
+
* `ControlPlaneConnection` are the only gate on their contents — `NOT NULL` here is the most the
|
|
30
|
+
* database itself can say.
|
|
31
|
+
*
|
|
32
|
+
* `basePath` records where the seam is mounted on the Worker this connection addresses. It is stored
|
|
33
|
+
* beside `workerUrl` because the two together fully determine the manifest address — and it is the one
|
|
34
|
+
* part a client cannot discover, since it *is* the manifest's own address. Everything else already
|
|
35
|
+
* solves this: `AdminRoute.path` carries the fully mounted path, so no client hardcodes a capability's
|
|
36
|
+
* mount point. Without it a client must assume `/control-plane`, and an adopter who moved the mount
|
|
37
|
+
* registers cleanly, passes the `ping` — called at that same assumed path — and then 404s on every call,
|
|
38
|
+
* with the operator diagnosing the wrong problem.
|
|
39
|
+
*
|
|
40
|
+
* ## `pithy_controlplane_replays`
|
|
41
|
+
*
|
|
42
|
+
* **`jti` is the primary key, and that is the entire replay mechanism.** The claim is
|
|
43
|
+
* `INSERT … ON CONFLICT DO NOTHING RETURNING`, so SQLite's uniqueness decides which of N concurrent
|
|
44
|
+
* presentations wins — no read-then-write, no window between checking and recording. The KV guard this
|
|
45
|
+
* replaced could not offer that: KV has no compare-and-set, so two colocations could both see a miss.
|
|
46
|
+
*
|
|
47
|
+
* The key is `jti` alone, deliberately, and **not** `(jti, connectionId)`. A composite would let a token
|
|
48
|
+
* captured from one connection be spent again against another, which is the property the guard exists to
|
|
49
|
+
* deny; `connectionId` is carried as a plain column, for the incident rather than the decision.
|
|
50
|
+
*
|
|
51
|
+
* `expiresAt` exists so the table can be pruned. A replay table that only grows is a slow leak in every
|
|
52
|
+
* adopter's database — the one thing KV gave for nothing, since its entries expired themselves. Its index
|
|
53
|
+
* is what makes the prune a range scan rather than a full table scan on an administrator-paced write path.
|
|
54
|
+
*/
|
|
55
|
+
export const controlplane_0001_init: Migration = {
|
|
56
|
+
up: async (db: Kysely<unknown>): Promise<void> => {
|
|
57
|
+
await db.schema
|
|
58
|
+
.createTable("pithyControlplaneConnections")
|
|
59
|
+
// The connection id is the token `aud`, and therefore externally exposed. Text, never a sequence.
|
|
60
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
61
|
+
.addColumn("environment", "text", (c) => c.notNull())
|
|
62
|
+
.addColumn("issuer", "text", (c) => c.notNull())
|
|
63
|
+
.addColumn("workerUrl", "text", (c) => c.notNull())
|
|
64
|
+
// Defaulted as well as `NOT NULL`: unlike an origin, there is no such thing as a connection with no
|
|
65
|
+
// base path — the seam is mounted somewhere, or the connection cannot be called at all.
|
|
66
|
+
.addColumn("basePath", "text", (c) => c.notNull().defaultTo("/control-plane"))
|
|
67
|
+
.addColumn("scopes", "text", (c) => c.notNull())
|
|
68
|
+
.addColumn("keys", "text", (c) => c.notNull())
|
|
69
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
70
|
+
.addColumn("updatedAt", "integer", (c) => c.notNull())
|
|
71
|
+
.execute();
|
|
72
|
+
|
|
73
|
+
// Verification loads by id, which is already the primary key. This index is for the other reader:
|
|
74
|
+
// the CLI and the key-listing route ask "what is connected to this environment", and there is no
|
|
75
|
+
// useful answer without a scan otherwise.
|
|
76
|
+
await db.schema
|
|
77
|
+
.createIndex("pithyControlplaneConnectionsEnvironmentIdx")
|
|
78
|
+
.on("pithyControlplaneConnections")
|
|
79
|
+
.columns(["environment"])
|
|
80
|
+
.execute();
|
|
81
|
+
|
|
82
|
+
await db.schema
|
|
83
|
+
.createTable("pithyControlplaneReplays")
|
|
84
|
+
// The token id, and the race decider. Caller-supplied, so it is bounded by the Zod schema before
|
|
85
|
+
// it ever reaches here.
|
|
86
|
+
.addColumn("jti", "text", (c) => c.primaryKey())
|
|
87
|
+
.addColumn("connectionId", "text", (c) => c.notNull())
|
|
88
|
+
.addColumn("expiresAt", "integer", (c) => c.notNull())
|
|
89
|
+
.execute();
|
|
90
|
+
|
|
91
|
+
// The prune's only predicate. Without it, reclaiming expired rows scans every spent token the
|
|
92
|
+
// adopter has ever recorded.
|
|
93
|
+
await db.schema
|
|
94
|
+
.createIndex("pithyControlplaneReplaysExpiresAtIdx")
|
|
95
|
+
.on("pithyControlplaneReplays")
|
|
96
|
+
.columns(["expiresAt"])
|
|
97
|
+
.execute();
|
|
98
|
+
},
|
|
99
|
+
down: async (db: Kysely<unknown>): Promise<void> => {
|
|
100
|
+
await db.schema.dropIndex("pithyControlplaneReplaysExpiresAtIdx").execute();
|
|
101
|
+
await db.schema.dropTable("pithyControlplaneReplays").execute();
|
|
102
|
+
await db.schema.dropIndex("pithyControlplaneConnectionsEnvironmentIdx").execute();
|
|
103
|
+
await db.schema.dropTable("pithyControlplaneConnections").execute();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ControlPlaneReplay } from "../data/replay";
|
|
5
|
+
import { CONTROL_PLANE_REPLAYS_TABLE, type ControlPlaneDatabase } from "../data/tables";
|
|
6
|
+
import type { ReplayGuard } from "./guard";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The D1-backed {@link ReplayGuard} — the seam's default.
|
|
10
|
+
*
|
|
11
|
+
* `INSERT … ON CONFLICT DO NOTHING RETURNING` is the whole thing. The insert either wins the `jti`
|
|
12
|
+
* primary key and returns a row, or conflicts and returns nothing; there is no read-then-write, so there
|
|
13
|
+
* is no window between deciding and recording. Of N concurrent presentations of one token, SQLite admits
|
|
14
|
+
* exactly one, and it does so identically whichever colocation each request landed in — which is
|
|
15
|
+
* precisely what the KV guard could not promise.
|
|
16
|
+
*
|
|
17
|
+
* It is the same move `@pithy-sh/auth` makes on the other side, consuming a refresh token with a
|
|
18
|
+
* conditional delete so that of N presentations exactly one wins. Single-use-by-constraint is the house
|
|
19
|
+
* pattern; KV was the outlier.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** What the guard needs from its caller. Injected, so nothing here reaches for a clock or a global. */
|
|
23
|
+
export interface D1ReplayGuardOptions {
|
|
24
|
+
/** The clock. Injected so a test can stand at any instant. */
|
|
25
|
+
now: () => Date;
|
|
26
|
+
/**
|
|
27
|
+
* How long a spent `jti` is remembered — `jtiTtlSeconds` from the seam config, which is itself
|
|
28
|
+
* cross-checked at assembly to outlive the widest window a token can be accepted in. A row's
|
|
29
|
+
* `expiresAt` is stamped from it, and the prune below is the only thing that reads it back.
|
|
30
|
+
*/
|
|
31
|
+
ttlSeconds: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Delete rows whose token could no longer be accepted under any clock this Worker honors.
|
|
36
|
+
*
|
|
37
|
+
* **Pruning is the one thing KV gave for nothing** — its entries expired themselves, and a table does
|
|
38
|
+
* not. A replay table that only grows is a slow leak in every adopter's database, so this is deliberate
|
|
39
|
+
* rather than left to a future sweep.
|
|
40
|
+
*
|
|
41
|
+
* It runs **only after a successful claim**, which is the load-bearing detail. Pruning on every call
|
|
42
|
+
* would let an attacker replaying one token in a loop drive an unbounded `DELETE` per attempt; a refused
|
|
43
|
+
* claim now costs exactly one conflicting insert and nothing else. And a successful claim is already a
|
|
44
|
+
* write, so the prune rides along on a path that was never read-only, on an administrator-paced route.
|
|
45
|
+
*
|
|
46
|
+
* A failure here is swallowed. Pruning is housekeeping: a claim that has already been decided must not
|
|
47
|
+
* be reported as failed because a cleanup that had nothing to do with the decision could not run.
|
|
48
|
+
*/
|
|
49
|
+
async function prune(db: ControlPlaneDatabase, now: Date): Promise<void> {
|
|
50
|
+
try {
|
|
51
|
+
await db
|
|
52
|
+
.deleteFrom(CONTROL_PLANE_REPLAYS_TABLE)
|
|
53
|
+
.where("expiresAt", "<", ControlPlaneReplay.shape.expiresAt.encode(now))
|
|
54
|
+
.execute();
|
|
55
|
+
} catch {
|
|
56
|
+
// Deliberate. See above.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A {@link ReplayGuard} over the seam's own D1 table — strongly consistent, and the default. */
|
|
61
|
+
export function d1ReplayGuard(db: ControlPlaneDatabase, options: D1ReplayGuardOptions): ReplayGuard {
|
|
62
|
+
return {
|
|
63
|
+
async claim(jti: string, connectionId: string): Promise<boolean> {
|
|
64
|
+
const now = options.now();
|
|
65
|
+
const record = ControlPlaneReplay.encode({
|
|
66
|
+
jti,
|
|
67
|
+
connectionId,
|
|
68
|
+
expiresAt: new Date(now.getTime() + options.ttlSeconds * 1000),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const won = await db
|
|
72
|
+
.insertInto(CONTROL_PLANE_REPLAYS_TABLE)
|
|
73
|
+
.values(record)
|
|
74
|
+
// The conflict IS the replay. `doNothing` rather than an upsert: overwriting would refresh the
|
|
75
|
+
// row's expiry on every replay attempt and keep a spent token remembered forever, and it would
|
|
76
|
+
// also return a row, which is the answer that admits the call.
|
|
77
|
+
.onConflict((oc) => oc.column("jti").doNothing())
|
|
78
|
+
.returning("jti")
|
|
79
|
+
.executeTakeFirst();
|
|
80
|
+
|
|
81
|
+
if (won === undefined) return false;
|
|
82
|
+
|
|
83
|
+
await prune(db, now);
|
|
84
|
+
return true;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The replay guard: a control-plane token is spendable exactly once.
|
|
6
|
+
*
|
|
7
|
+
* Every other check asks what the call *is* — signature, issuer, audience, environment, scope, body
|
|
8
|
+
* digest, expiry. None of them notices the same valid call arriving twice. {@link ReplayGuard.claim} is
|
|
9
|
+
* what notices: it records the token's `jti` and reports whether this caller was the first to do so. A
|
|
10
|
+
* second arrival is refused, and the caller denies.
|
|
11
|
+
*
|
|
12
|
+
* ## Two implementations, and D1 is the default
|
|
13
|
+
*
|
|
14
|
+
* `d1ReplayGuard` (`./d1Guard`) is the one the seam composes. It claims with
|
|
15
|
+
* `INSERT … ON CONFLICT DO NOTHING RETURNING`, so the primary key decides the winner and the decision is
|
|
16
|
+
* strongly consistent — of N concurrent presentations of one token, exactly one row is inserted and
|
|
17
|
+
* exactly one caller is told it won, wherever the requests landed.
|
|
18
|
+
*
|
|
19
|
+
* `kvReplayGuard` (`./kvGuard`) remains available and selectable, and is **best-effort by construction**.
|
|
20
|
+
* Workers KV has no compare-and-set and is eventually consistent across colocations, so a read-then-write
|
|
21
|
+
* claim admits a genuine race: two copies of one token presented in two PoPs inside the propagation
|
|
22
|
+
* window can both read a miss and both claim. That is why it is no longer the default. It is kept because
|
|
23
|
+
* the trade is legitimate for an adopter who would rather not pay a D1 write on this path and whose admin
|
|
24
|
+
* operations are all idempotent — but it is now an explicit choice with the cost written down, rather
|
|
25
|
+
* than the silent default it used to be.
|
|
26
|
+
*
|
|
27
|
+
* ## Why the default moved
|
|
28
|
+
*
|
|
29
|
+
* The exploitable surface is narrow: same connection, same scope, same body digest, inside a 60-second
|
|
30
|
+
* expiry. What can be replayed is *one exact call*. Most admin operations shrug that off. Not all do — a
|
|
31
|
+
* nudge sends real people a second email, a key registration appends, and anything that enqueues work
|
|
32
|
+
* enqueues it again.
|
|
33
|
+
*
|
|
34
|
+
* And the cost of closing it is close to nothing **here specifically**. A control-plane hot path is an
|
|
35
|
+
* administrator clicking something: low volume, high privilege. Trading a few milliseconds for
|
|
36
|
+
* correctness is the obvious side of that bargain, which is a different calculation from a per-request
|
|
37
|
+
* user path. The seam already owns a D1 namespace, so this is a second migration in an existing one
|
|
38
|
+
* rather than new infrastructure — and single-use-by-unique-constraint is the house pattern besides:
|
|
39
|
+
* `@pithy-sh/auth` consumes a refresh token exactly this way, and `@pithy-sh/payments` keys idempotency
|
|
40
|
+
* off a `UNIQUE`. KV was the outlier.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The single-use gate over a token's `jti`. One method, so the storage decision behind it stays
|
|
45
|
+
* replaceable — the interface is the substitution point, and it is why moving the default from KV to D1
|
|
46
|
+
* touched no call site.
|
|
47
|
+
*/
|
|
48
|
+
export interface ReplayGuard {
|
|
49
|
+
/**
|
|
50
|
+
* Claim `jti` for `connectionId`. Returns true when this call was the first to spend it, and
|
|
51
|
+
* **false when it was already spent — the caller must then deny.** The false case is not an error to
|
|
52
|
+
* log and continue past; it is the replay.
|
|
53
|
+
*/
|
|
54
|
+
claim(jti: string, connectionId: string): Promise<boolean>;
|
|
55
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { TypedKv } from "../../kv/kv";
|
|
6
|
+
import type { KvNamespaceSpecMap } from "../../kv/namespaces";
|
|
7
|
+
import { CONTROL_PLANE_JTI_TTL_SECONDS } from "../token/claims";
|
|
8
|
+
import type { ReplayGuard } from "./guard";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The KV-backed {@link ReplayGuard} — still selectable, no longer the default.
|
|
12
|
+
*
|
|
13
|
+
* See `./guard.ts` for what the guard is for and why D1 now holds the default. The part that belongs
|
|
14
|
+
* here is the caveat that is a property of *this* store and not of the seam:
|
|
15
|
+
*
|
|
16
|
+
* **Workers KV has no compare-and-set and is eventually consistent across colocations.** A `put` in one
|
|
17
|
+
* PoP is not immediately visible in another, so two copies of one token presented in two locations
|
|
18
|
+
* inside the propagation window can both read a miss and both claim. Read-then-write is the strongest
|
|
19
|
+
* claim KV offers; it is best-effort, not an atomic single-use gate.
|
|
20
|
+
*
|
|
21
|
+
* Three things bound the exposure for an adopter who chooses it anyway.
|
|
22
|
+
*
|
|
23
|
+
* 1. **The window is KV's propagation delay** — seconds, and independent of the 60-second token. The
|
|
24
|
+
* write is issued the moment the first call lands; once it has converged the jti is spent for a full
|
|
25
|
+
* {@link CONTROL_PLANE_JTI_TTL_SECONDS}, which deliberately outlives the token that carried it. So
|
|
26
|
+
* there is no window at the end of the token's life, only at the very start of it.
|
|
27
|
+
* 2. **The token is not a general capability.** It is bound to one connection, one scope, one request
|
|
28
|
+
* body digest, and that expiry. What can survive the window is a replay of *the exact same call* —
|
|
29
|
+
* not a forged one, not a broader one, not a later one.
|
|
30
|
+
* 3. **`d1ReplayGuard` is one config key away**, and closes the race outright. Choosing this one is
|
|
31
|
+
* choosing to skip a D1 write on an administrator-paced path, with the race as the stated price.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The KV binding the seam's replay set lives in.
|
|
36
|
+
*
|
|
37
|
+
* **Not Cloudflare's control plane.** That is the outbound provisioning REST API this project reaches
|
|
38
|
+
* through `@pithy-sh/cloudflare`. This is the inbound, adopter-authenticated admin seam, and it owns
|
|
39
|
+
* its own namespace rather than sharing `SESSIONS` — a junk-drawer KV would put user sessions and
|
|
40
|
+
* management-token receipts behind one binding, and they have nothing to do with each other.
|
|
41
|
+
*/
|
|
42
|
+
export const CONTROL_PLANE_KV_BINDING = "CONTROL_PLANE";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The fixed first key segment for everything the seam stores in KV.
|
|
46
|
+
*
|
|
47
|
+
* One word, no hyphen: `assertValidConfig` rejects a prefix containing the key separator, and the
|
|
48
|
+
* namespace tokens across this capability (migration namespace, table prefix, error domain) are all
|
|
49
|
+
* the single word `controlplane` for the same family of reasons. The `jti` segment below is what makes
|
|
50
|
+
* the physical key read `controlplane:jti:<id>` — a scope segment in the key, not in the prefix, so a
|
|
51
|
+
* second kind of seam entry can join the namespace without colliding.
|
|
52
|
+
*/
|
|
53
|
+
export const CONTROL_PLANE_KV_PREFIX = "controlplane";
|
|
54
|
+
|
|
55
|
+
/** A spent token's receipt: which connection burned it, and when. */
|
|
56
|
+
export const SeenJti = z
|
|
57
|
+
.object({
|
|
58
|
+
connectionId: z
|
|
59
|
+
.string()
|
|
60
|
+
.min(1)
|
|
61
|
+
.describe(
|
|
62
|
+
"The connection whose token claimed this jti. Recorded for forensics only — a replay is refused whatever connection presents it, because the jti alone is the key.",
|
|
63
|
+
),
|
|
64
|
+
seenAt: z
|
|
65
|
+
.number()
|
|
66
|
+
.int()
|
|
67
|
+
.nonnegative()
|
|
68
|
+
.describe(
|
|
69
|
+
"When the jti was claimed, as a ms-epoch number. A plain number rather than a date codec: nothing decodes this back into app types, and it exists to make a raw KV dump readable during an incident.",
|
|
70
|
+
),
|
|
71
|
+
})
|
|
72
|
+
.describe(
|
|
73
|
+
"The receipt written when a control-plane token is spent. Its presence is the whole signal; the fields are for the incident, not the decision.",
|
|
74
|
+
);
|
|
75
|
+
export type SeenJti = z.infer<typeof SeenJti>;
|
|
76
|
+
|
|
77
|
+
/** The key for one receipt. Physical key: `controlplane:jti:<jti>`. */
|
|
78
|
+
export const SeenJtiKey = z
|
|
79
|
+
.object({
|
|
80
|
+
scope: z
|
|
81
|
+
.literal("jti")
|
|
82
|
+
.describe(
|
|
83
|
+
"The fixed second key segment, naming what kind of entry this is. Carried in the key rather than the prefix so the namespace can hold another kind of seam entry later without a migration.",
|
|
84
|
+
),
|
|
85
|
+
jti: z
|
|
86
|
+
.string()
|
|
87
|
+
.min(1)
|
|
88
|
+
.max(128)
|
|
89
|
+
.describe(
|
|
90
|
+
"The token's unique id, minted by the management client. Bounded because it arrives from a caller and becomes a KV key — an unbounded id would be an unbounded write.",
|
|
91
|
+
),
|
|
92
|
+
})
|
|
93
|
+
.describe("The KV key identifying one spent token: the `jti` scope segment and the token id itself.");
|
|
94
|
+
export type SeenJtiKey = z.infer<typeof SeenJtiKey>;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The KV namespaces the control-plane capability registers, served on `c.var.kv.controlplane.jtis`.
|
|
98
|
+
*
|
|
99
|
+
* The TTL is the store's, not the caller's: an entry that expired while the token that spent it was
|
|
100
|
+
* still valid would silently reopen the replay window, so it is declared once here and every write
|
|
101
|
+
* inherits it.
|
|
102
|
+
*
|
|
103
|
+
* **A function of the config, not a constant.** It used to be a module-level literal pinned to
|
|
104
|
+
* {@link CONTROL_PLANE_JTI_TTL_SECONDS}, which made `jtiTtlSeconds` dead config: an adopter who
|
|
105
|
+
* lengthened `maxTokenLifetimeSeconds` and lengthened the replay memory to match — the pairing the
|
|
106
|
+
* config's own cross-field check tells them to make — still got a 180-second store, and their tokens
|
|
107
|
+
* outlived the memory of them. The one setting that governs this store now actually reaches it.
|
|
108
|
+
*/
|
|
109
|
+
export function controlPlaneKvNamespaces(jtiTtlSeconds: number = CONTROL_PLANE_JTI_TTL_SECONDS) {
|
|
110
|
+
return {
|
|
111
|
+
controlplane: {
|
|
112
|
+
binding: CONTROL_PLANE_KV_BINDING,
|
|
113
|
+
stores: {
|
|
114
|
+
jtis: {
|
|
115
|
+
prefix: CONTROL_PLANE_KV_PREFIX,
|
|
116
|
+
key: SeenJtiKey,
|
|
117
|
+
value: SeenJti,
|
|
118
|
+
ttlSeconds: jtiTtlSeconds,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
} satisfies KvNamespaceSpecMap;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The shape {@link controlPlaneKvNamespaces} produces — what `c.var.kv` is narrowed to. */
|
|
126
|
+
export type ControlPlaneKvNamespaces = ReturnType<typeof controlPlaneKvNamespaces>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A {@link ReplayGuard} over a typed KV store — the shipped implementation.
|
|
130
|
+
*
|
|
131
|
+
* Read-then-write, because KV offers nothing better. A stored receipt that fails validation throws
|
|
132
|
+
* rather than resolving, which fails closed: a poisoned entry denies the call instead of admitting it.
|
|
133
|
+
*/
|
|
134
|
+
export function kvReplayGuard<M extends z.ZodType>(store: TypedKv<typeof SeenJti, typeof SeenJtiKey, M>): ReplayGuard {
|
|
135
|
+
return {
|
|
136
|
+
async claim(jti: string, connectionId: string): Promise<boolean> {
|
|
137
|
+
const key = { scope: "jti", jti } as const;
|
|
138
|
+
if ((await store.get(key)) !== null) return false;
|
|
139
|
+
await store.put(key, { connectionId, seenAt: Date.now() });
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|