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