@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,346 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Context } from "hono";
5
+ import type { PithyHonoEnv } from "../../capability/capability";
6
+ import { workerVersion } from "../../worker/identity";
7
+ import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
8
+ import type { ControlPlaneConfig } from "../config/config";
9
+ import type { ControlPlaneContext } from "../context";
10
+ import { ControlPlaneConnection, type RegisteredKey } from "../data/connection";
11
+ import { appendKey, expireKey, pruneKeys } from "../data/keyLifecycle";
12
+ import { CONTROL_PLANE_CONNECTIONS_TABLE, type ControlPlaneDatabase } from "../data/tables";
13
+ import type { CapabilityDeclaration, ControlPlaneManifestWire } from "../discovery/adminRoute";
14
+ import { type CapabilityHealthSource, readCapabilityHealth } from "../discovery/health";
15
+ import { healthWire } from "../discovery/healthSummary";
16
+ import { ControlPlaneInvalidCredentialError, ControlPlaneKeyConflictError } from "../error/errors";
17
+ import type {
18
+ ControlPlaneKeysResponse,
19
+ ControlPlanePingResponse,
20
+ ExpireKeyResponse,
21
+ PublicKeyView,
22
+ RegisterKeyResponse,
23
+ } from "./responses";
24
+ import type { ExpireKeyParams, ExpireKeyRequest, RegisterKeyRequest } from "./schemas";
25
+
26
+ /**
27
+ * The seam's own route handlers.
28
+ *
29
+ * Every one runs behind `requireControlPlane`, so `c.var.controlPlane` is set by the time any of them
30
+ * is entered. They read it rather than re-deriving anything: the verification decision is made once,
31
+ * in `verify.ts`, and a handler that second-guessed it would be a second place for the rules to drift.
32
+ *
33
+ * **Nothing here returns a key's private half, because none is ever held.** `GET /control-plane/keys`
34
+ * returns public keys and their windows, which is exactly what a management client needs to see that a
35
+ * key is ageing and exactly what an attacker learns nothing from.
36
+ *
37
+ * **Every response has an exported schema**, in `responses.ts`, and each `c.json` below is
38
+ * `satisfies`-checked against it. A management client imports the same object and validates with it
39
+ * rather than hand-writing a mirror that drifts. The check is at compile time on purpose: parsing every
40
+ * response would spend a validation pass on values this Worker just built, and would turn a shape
41
+ * mistake into a 500 in production rather than a red build.
42
+ */
43
+
44
+ /** A day, in ms — `keyRetentionDays` is expressed in days because that is how anyone reasons about it. */
45
+ const MS_PER_DAY = 86_400_000;
46
+
47
+ /** The pieces of the composed capability a handler needs. Passed in, so nothing reaches for a global. */
48
+ export interface ControlPlaneHandlerDeps {
49
+ /** The resolved seam config — pruning bounds, mostly. */
50
+ config: ControlPlaneConfig;
51
+ /** The app database, resolved per request from the `DB` binding. */
52
+ database: (c: Context<PithyHonoEnv>) => ControlPlaneDatabase;
53
+ /**
54
+ * The names of every capability this Worker composed, captured by the capability's `compose` hook at
55
+ * assembly. A thunk because that hook runs after the routes closure is built.
56
+ *
57
+ * This is assembly-time knowledge no capability has about its siblings, and it is what makes
58
+ * **discovery over configuration** work: a management client builds its navigation *and its calls*
59
+ * from what a Worker declares, so a Worker without payments simply has no purchases pane, and
60
+ * `pithy add support` produces a working support pane with nothing for either side to configure.
61
+ */
62
+ composedCapabilities: () => readonly CapabilityDeclaration[];
63
+ /**
64
+ * The health summary each capability contributes, by capability name — captured by the same `compose`
65
+ * hook, and checked there against the scopes that capability's own routes require.
66
+ *
67
+ * Separate from {@link composedCapabilities} because a declaration is the same for every caller and a
68
+ * value is not: the numbers are resolved per request, behind the scope each one is declared under.
69
+ */
70
+ composedHealth: () => ReadonlyMap<string, CapabilityHealthSource>;
71
+ /** The clock, injected so a test can stand at any instant. */
72
+ now: () => Date;
73
+ }
74
+
75
+ /** The verified caller. Never null here — `requireControlPlane` ran first, or this code is unreachable. */
76
+ function caller(c: Context<PithyHonoEnv>): ControlPlaneContext {
77
+ const context = c.var.controlPlane;
78
+ if (!context) {
79
+ // Defense in depth against a route line assembled without the gate. It cannot happen through
80
+ // `registerControlPlaneRoutes`, and if it ever did the failure must be a denial rather than a
81
+ // handler reading `undefined` and carrying on.
82
+ throw new ControlPlaneInvalidCredentialError({ detail: "handler reached with no verified control-plane caller" });
83
+ }
84
+ return context;
85
+ }
86
+
87
+ /** Load the connection this call authenticated against. It verified moments ago, so it exists. */
88
+ async function loadConnection(
89
+ db: ControlPlaneDatabase,
90
+ connectionId: string,
91
+ ): Promise<{ connection: ControlPlaneConnection }> {
92
+ const row = await db
93
+ .selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE)
94
+ .selectAll()
95
+ .where("id", "=", connectionId)
96
+ .executeTakeFirst();
97
+ if (!row) {
98
+ // The row was deleted between verification and here — a revocation landing mid-request. Denying is
99
+ // the correct answer: revocation is meant to be immediate.
100
+ throw new ControlPlaneInvalidCredentialError({ detail: `connection ${connectionId} disappeared mid-request` });
101
+ }
102
+ return { connection: ControlPlaneConnection.parse(row) };
103
+ }
104
+
105
+ /** Write a connection's key array back, stamping `updatedAt`. One UPDATE — a rotation cannot half-apply. */
106
+ async function saveKeys(
107
+ db: ControlPlaneDatabase,
108
+ connectionId: string,
109
+ keys: readonly RegisteredKey[],
110
+ now: Date,
111
+ ): Promise<void> {
112
+ const encoded = ControlPlaneConnection.shape.keys.encode([...keys]);
113
+ await db
114
+ .updateTable(CONTROL_PLANE_CONNECTIONS_TABLE)
115
+ .set({ keys: encoded, updatedAt: ControlPlaneConnection.shape.updatedAt.encode(now) })
116
+ .where("id", "=", connectionId)
117
+ .execute();
118
+ }
119
+
120
+ /**
121
+ * The public view of one registered key. Windows and ages, never anything secret.
122
+ *
123
+ * The return type is `z.output` of the exported schema, so what this sends and what a management
124
+ * client validates against are one declaration rather than two that drift. It was
125
+ * `Record<string, unknown>`, which is not a contract at all.
126
+ */
127
+ function publicKeyView(key: RegisteredKey): PublicKeyView {
128
+ return {
129
+ keyId: key.keyId,
130
+ publicKey: key.publicKey,
131
+ validFrom: key.validFrom.toISOString(),
132
+ validUntil: key.validUntil?.toISOString() ?? null,
133
+ revokedAt: key.revokedAt?.toISOString() ?? null,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * `GET /control-plane/ping` — connectivity and key proof.
139
+ *
140
+ * The most important route here despite doing nothing, because it is what a management client calls to
141
+ * prove a newly registered key **before** the key it replaces is expired. Requires a verified caller
142
+ * and no scope: a connection granted nothing must still be able to prove a key, or rotation has a state
143
+ * it cannot get out of.
144
+ *
145
+ * It echoes the `keyId` that verified the call, so the client can confirm *which* key answered rather
146
+ * than inferring it from a 200.
147
+ */
148
+ export function pingHandler(deps: ControlPlaneHandlerDeps) {
149
+ return (c: Context<PithyHonoEnv>) => {
150
+ const context = caller(c);
151
+ return c.json({
152
+ status: "ok",
153
+ connectionId: context.connectionId,
154
+ environment: context.environment,
155
+ keyId: context.keyId,
156
+ now: deps.now().toISOString(),
157
+ } satisfies ControlPlanePingResponse);
158
+ };
159
+ }
160
+
161
+ /**
162
+ * `GET /control-plane/manifest` — what this Worker is, what it composes, and how each part is doing.
163
+ *
164
+ * Discovery over configuration. A client builds its navigation from this rather than from settings
165
+ * someone maintains, so a Worker with no payments capability has no purchases pane as a matter of fact.
166
+ *
167
+ * **And the numbers a client would otherwise pay a second round trip for** (#317). Each capability's
168
+ * bounded summary is resolved here, behind the scope that capability's own read is behind: a connection
169
+ * without it gets no number rather than a zero, and never costs the adopter's Worker the query.
170
+ *
171
+ * **One capability's failure costs one capability's number** (#350). A producer that throws, or that
172
+ * reports something its declaration cannot name, lands on `unavailable` for that entry alone —
173
+ * `readCapabilityHealth` catches it, so nothing rejects, so `Promise.all` still resolves and every
174
+ * sibling still reports. This route is what the whole management surface is built from; a manifest that
175
+ * 500s takes every pane with it, and the capability at fault is the one thing the screen could not then
176
+ * name.
177
+ */
178
+ export function manifestHandler(deps: ControlPlaneHandlerDeps) {
179
+ return async (c: Context<PithyHonoEnv>) => {
180
+ const context = caller(c);
181
+ const sources = deps.composedHealth();
182
+ // In parallel: each producer is bounded by its own declaration, and a Worker composing several has
183
+ // no reason to serialize numbers that do not depend on each other.
184
+ const capabilities = await Promise.all(
185
+ deps.composedCapabilities().map(async (declaration) => ({
186
+ ...declaration,
187
+ // Spread, so the values and the failure flag are written together or not at all. A handler that
188
+ // set one and forgot the other is the shape of defect the four-state value exists to prevent.
189
+ ...healthWire(
190
+ await readCapabilityHealth(sources.get(declaration.name), context.grantedScopes, (source) => source.read(c)),
191
+ ),
192
+ })),
193
+ );
194
+ return c.json({
195
+ environment: context.environment,
196
+ connectionId: context.connectionId,
197
+ // The build answering this call. Read per request rather than captured at assembly: it is the
198
+ // same binding every log record and audit event reads, and there is one reader for it.
199
+ version: workerVersion(c.env),
200
+ capabilities,
201
+ grantedScopes: [...context.grantedScopes],
202
+ } satisfies ControlPlaneManifestWire);
203
+ };
204
+ }
205
+
206
+ /** `GET /control-plane/keys` — the registration state, so a client can surface a stale key. */
207
+ export function listKeysHandler(deps: ControlPlaneHandlerDeps) {
208
+ return async (c: Context<PithyHonoEnv>) => {
209
+ const context = caller(c);
210
+ const { connection } = await loadConnection(deps.database(c), context.connectionId);
211
+ return c.json({
212
+ connectionId: connection.id,
213
+ environment: connection.environment,
214
+ keys: connection.keys.map(publicKeyView),
215
+ } satisfies ControlPlaneKeysResponse);
216
+ };
217
+ }
218
+
219
+ /**
220
+ * `POST /control-plane/keys` — register a new public key, authenticated by the key it succeeds.
221
+ *
222
+ * This is the route the whole rotation story rests on, and its authentication is the subtle part: the
223
+ * request is signed with the key being replaced. That is what makes rotation self-bootstrapping —
224
+ * trust flows forward from existing trust, exactly as a rotated refresh token does — and what makes an
225
+ * unreachable Worker a deferral rather than a lockout.
226
+ *
227
+ * It appends and only appends. The new key's window opens now and no existing key's window moves.
228
+ */
229
+ export function registerKeyHandler(deps: ControlPlaneHandlerDeps) {
230
+ return async (c: Context<PithyHonoEnv>, body: RegisterKeyRequest) => {
231
+ const context = caller(c);
232
+ const db = deps.database(c);
233
+ const now = deps.now();
234
+ const { connection } = await loadConnection(db, context.connectionId);
235
+
236
+ const appended = appendKey(
237
+ connection.keys,
238
+ { keyId: body.keyId, publicKey: body.publicKey, validFrom: now, validUntil: null, revokedAt: null },
239
+ now,
240
+ );
241
+ const pruned = pruneKeys(appended, {
242
+ now,
243
+ retentionMs: deps.config.keyRetentionDays * MS_PER_DAY,
244
+ maxKeys: deps.config.maxKeys,
245
+ });
246
+ await saveKeys(db, connection.id, pruned, now);
247
+
248
+ await safeEmit(
249
+ c.var.emit,
250
+ {
251
+ action: ControlPlaneAuditActions.keyRegistered,
252
+ outcome: "success",
253
+ severity: "warning",
254
+ actorType: "control-plane",
255
+ actorId: context.subject,
256
+ resourceType: "controlplane_connection",
257
+ resourceId: connection.id,
258
+ requestId: c.req.header("cf-ray"),
259
+ ip: c.req.header("cf-connecting-ip"),
260
+ userAgent: c.req.header("user-agent"),
261
+ // Key ids, never key material. A registered key changing in an adopter's system is exactly what
262
+ // looks alarming during a security review, so the trail names both sides of the handover.
263
+ metadata: {
264
+ connectionId: connection.id,
265
+ registeredKeyId: body.keyId,
266
+ signedWithKeyId: context.keyId,
267
+ connectionEnvironment: connection.environment,
268
+ },
269
+ },
270
+ c.var.log,
271
+ );
272
+
273
+ return c.json(
274
+ {
275
+ keyId: body.keyId,
276
+ validFrom: now.toISOString(),
277
+ keys: pruned.map(publicKeyView),
278
+ } satisfies RegisterKeyResponse,
279
+ 201,
280
+ );
281
+ };
282
+ }
283
+
284
+ /**
285
+ * `POST /control-plane/keys/:keyId/expire` — retire a superseded key, once its replacement is proven.
286
+ *
287
+ * A separate call from registration, deliberately. Folding them into one would make append-and-expire
288
+ * atomic, and atomic is precisely wrong here: the point is that the old key survives until the new one
289
+ * has been proven against a live ping. `expireKey` enforces both halves of that — the named successor
290
+ * must currently verify, and the expiry must not empty the connection's live set.
291
+ *
292
+ * **And the successor must be the key that signed this very request.** That is what "proof by use"
293
+ * means, and checking only that `provenKeyId` names *some* live key would not deliver it: a client
294
+ * still signing with the old key could name a successor it has never successfully used and retire the
295
+ * one thing that currently works. The client asserts which key it believes proved itself, the seam
296
+ * compares that against which key actually did, and a client that is wrong about its own rotation gets
297
+ * a 409 instead of a lockout.
298
+ */
299
+ export function expireKeyHandler(deps: ControlPlaneHandlerDeps) {
300
+ return async (c: Context<PithyHonoEnv>, params: ExpireKeyParams, body: ExpireKeyRequest) => {
301
+ const context = caller(c);
302
+ const db = deps.database(c);
303
+ const now = deps.now();
304
+ const { connection } = await loadConnection(db, context.connectionId);
305
+
306
+ if (body.provenKeyId !== context.keyId) {
307
+ throw new ControlPlaneKeyConflictError({
308
+ message: "Sign this request with the replacement key you are naming as proven.",
309
+ action: "Rotate to the new key, make a call with it, then expire the old one in that same call.",
310
+ detail: `provenKeyId ${body.provenKeyId} is not the key that signed this request (${context.keyId})`,
311
+ });
312
+ }
313
+
314
+ const expired = expireKey(connection.keys, params.keyId, { provenKeyId: body.provenKeyId, now });
315
+ await saveKeys(db, connection.id, expired, now);
316
+
317
+ await safeEmit(
318
+ c.var.emit,
319
+ {
320
+ action: ControlPlaneAuditActions.keyExpired,
321
+ outcome: "success",
322
+ severity: "warning",
323
+ actorType: "control-plane",
324
+ actorId: context.subject,
325
+ resourceType: "controlplane_connection",
326
+ resourceId: connection.id,
327
+ requestId: c.req.header("cf-ray"),
328
+ ip: c.req.header("cf-connecting-ip"),
329
+ userAgent: c.req.header("user-agent"),
330
+ metadata: {
331
+ connectionId: connection.id,
332
+ expiredKeyId: params.keyId,
333
+ provenKeyId: body.provenKeyId,
334
+ connectionEnvironment: connection.environment,
335
+ },
336
+ },
337
+ c.var.log,
338
+ );
339
+
340
+ return c.json({
341
+ keyId: params.keyId,
342
+ validUntil: now.toISOString(),
343
+ keys: expired.map(publicKeyView),
344
+ } satisfies ExpireKeyResponse);
345
+ };
346
+ }
@@ -0,0 +1,92 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { Ed25519PublicJwk } from "../data/connection";
6
+
7
+ /**
8
+ * What the seam's own routes return, as Zod objects a management client can validate against.
9
+ *
10
+ * `schemas.ts` bounds what a caller may send; this file states what it gets back. Both halves are
11
+ * runtime values for the same reason: a management client reading a customer's Worker is crossing a
12
+ * trust boundary and must validate what comes back, and a TypeScript interface is erased before it
13
+ * can help. `GET /control-plane/manifest` already had a schema — {@link ControlPlaneManifest}, in
14
+ * `discovery/adminRoute.ts`, because it is the discovery contract rather than a projection — and the
15
+ * other four routes had none at all, `publicKeyView` returning a bare `Record<string, unknown>`.
16
+ *
17
+ * **No codecs, and no transform anywhere in this file.** These describe JSON on the wire, so parsing
18
+ * one hands back exactly what went in — which is what lets a test compare the parsed value with a live
19
+ * response and fail on a field either side forgot. A `JsonDate` here would decode an ISO string into a
20
+ * `Date` and make that comparison meaningless.
21
+ *
22
+ * **Nothing here can carry a private key, because none is ever held.** The connection stores public
23
+ * halves only, so `keys` is exactly what a client needs to see a key ageing and exactly what an
24
+ * attacker learns nothing from.
25
+ */
26
+
27
+ /** One registered key as a client sees it: the public half and its window. */
28
+ export const PublicKeyView = z
29
+ .object({
30
+ keyId: z.string().describe("The key's id, matched against a token's `kid` header."),
31
+ publicKey: Ed25519PublicJwk.describe("The public key this id names. The private half is never held here."),
32
+ validFrom: z.iso.datetime().describe("When this key became valid, ISO-8601. A call signed before it is rejected."),
33
+ validUntil: z.iso
34
+ .datetime()
35
+ .nullable()
36
+ .describe("When it stops being accepted, ISO-8601, or null while open-ended. Set only by the expire route."),
37
+ revokedAt: z.iso
38
+ .datetime()
39
+ .nullable()
40
+ .describe("When it was revoked outright, ISO-8601, or null. Revocation ignores the window and is immediate."),
41
+ })
42
+ .describe("One registered public key and its validity window. Two live at once during a rotation overlap.");
43
+ export type PublicKeyView = z.output<typeof PublicKeyView>;
44
+
45
+ /**
46
+ * `GET /control-plane/ping`.
47
+ *
48
+ * It echoes the `keyId` that verified the call, so a client rotating a key can confirm *which* key
49
+ * answered rather than inferring it from a 200 — which is the whole point of the route.
50
+ */
51
+ export const ControlPlanePingResponse = z
52
+ .object({
53
+ status: z.literal("ok").describe("Always `ok`. A failure is an error payload, never this shape."),
54
+ connectionId: z.string().describe("The connection this call authenticated as."),
55
+ environment: z.string().describe("The environment that connection is bound to."),
56
+ keyId: z.string().describe("The key that verified this call. The proof a newly registered key works."),
57
+ now: z.iso.datetime().describe("This Worker's clock, ISO-8601 — what a token's lifetime is judged against."),
58
+ })
59
+ .describe("Connectivity, and proof of which key answered.");
60
+ export type ControlPlanePingResponse = z.output<typeof ControlPlanePingResponse>;
61
+
62
+ /** `GET /control-plane/keys` — the registration state, so a client can surface a stale key. */
63
+ export const ControlPlaneKeysResponse = z
64
+ .object({
65
+ connectionId: z.string().describe("The connection these keys belong to."),
66
+ environment: z.string().describe("The environment that connection is bound to."),
67
+ keys: z.array(PublicKeyView).describe("Every registered key, expired and revoked ones included."),
68
+ })
69
+ .describe("Every key registered against this connection, with its window.");
70
+ export type ControlPlaneKeysResponse = z.output<typeof ControlPlaneKeysResponse>;
71
+
72
+ /** `POST /control-plane/keys` — the appended key, and the set as it now stands. */
73
+ export const RegisterKeyResponse = z
74
+ .object({
75
+ keyId: z.string().describe("The key just registered."),
76
+ validFrom: z.iso.datetime().describe("When its window opened, ISO-8601 — now, by construction."),
77
+ keys: z
78
+ .array(PublicKeyView)
79
+ .describe("The connection's keys after the append and the prune, so a client sees what it now holds."),
80
+ })
81
+ .describe("The registered key, and the set it joined.");
82
+ export type RegisterKeyResponse = z.output<typeof RegisterKeyResponse>;
83
+
84
+ /** `POST /control-plane/keys/:keyId/expire` — the retired key, and the set as it now stands. */
85
+ export const ExpireKeyResponse = z
86
+ .object({
87
+ keyId: z.string().describe("The key just given an end date."),
88
+ validUntil: z.iso.datetime().describe("When its window closed, ISO-8601 — now, by construction."),
89
+ keys: z.array(PublicKeyView).describe("The connection's keys after the expiry."),
90
+ })
91
+ .describe("The expired key, and the set it left behind.");
92
+ export type ExpireKeyResponse = z.output<typeof ExpireKeyResponse>;
@@ -0,0 +1,115 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { zValidator } from "@hono/zod-validator";
5
+ import type { Hono } from "hono";
6
+ import type { PithyHonoEnv } from "../../capability/capability";
7
+ import { validationHook } from "../../http/validation";
8
+ import type { AdminRoute } from "../discovery/adminRoute";
9
+ import { ANY_VERIFIED_CALLER, KEYS_ROTATE_SCOPE, MANIFEST_READ_SCOPE } from "../scope/scope";
10
+ import { requireControlPlane } from "./guard";
11
+ import {
12
+ type ControlPlaneHandlerDeps,
13
+ expireKeyHandler,
14
+ listKeysHandler,
15
+ manifestHandler,
16
+ pingHandler,
17
+ registerKeyHandler,
18
+ } from "./handlers";
19
+ import { ExpireKeyParams, ExpireKeyRequest, RegisterKeyRequest } from "./schemas";
20
+
21
+ /**
22
+ * The seam's own routes, distinct from the admin routes capabilities contribute.
23
+ *
24
+ * GET /control-plane/ping → connectivity and key proof (any verified caller)
25
+ * GET /control-plane/manifest → what this Worker composes (manifest:read)
26
+ * GET /control-plane/keys → the registration state (keys:rotate)
27
+ * POST /control-plane/keys → register a key (keys:rotate)
28
+ * POST /control-plane/keys/:keyId/expire → retire a superseded key (keys:rotate)
29
+ *
30
+ * Every one is `control-plane` and default-denied — with no connection registered they all answer 403,
31
+ * and that is the shipped state of a Worker nobody has connected.
32
+ *
33
+ * **Guards precede validators on every line.** A validator first would turn a 401 into a 400 and tell a
34
+ * caller with no credential which request shapes were well-formed. On these routes that is a live
35
+ * oracle over key registration, which is the last thing that should be probeable.
36
+ *
37
+ * The three key routes share one scope. Reading which keys are live, registering the next one, and
38
+ * retiring the last are one lifecycle, and an adopter deciding whether a management client may touch
39
+ * their keys is making one decision, not three.
40
+ *
41
+ * The table is declared twice on purpose — once as descriptors a client can read, once as the
42
+ * registrations themselves — and `routeContract.test.ts` proves the two agree.
43
+ */
44
+
45
+ /**
46
+ * The seam's own routes, described for `GET /control-plane/manifest`.
47
+ *
48
+ * Derived from the same `basePath` the routes below mount on, so the two cannot disagree — and the
49
+ * seam holds itself to the rule it imposes on every capability: `controlPlaneRouteDescriptors` is
50
+ * checked against the mounted router by `missingAdminRoutes` in `routeContract.test.ts`.
51
+ *
52
+ * `ping` carries a null scope. It requires a verified caller and no authorization, and saying so is
53
+ * how a client knows it can always prove a key — including on a connection granted nothing at all.
54
+ */
55
+ export function controlPlaneRouteDescriptors(basePath: string): AdminRoute[] {
56
+ return [
57
+ {
58
+ method: "GET",
59
+ path: `${basePath}/ping`,
60
+ scope: null,
61
+ summary: "Prove connectivity and which key answered. Always available to a verified caller.",
62
+ },
63
+ {
64
+ method: "GET",
65
+ path: `${basePath}/manifest`,
66
+ scope: MANIFEST_READ_SCOPE,
67
+ summary: "What this Worker composes, and how to call each capability's admin surface.",
68
+ },
69
+ {
70
+ method: "GET",
71
+ path: `${basePath}/keys`,
72
+ scope: KEYS_ROTATE_SCOPE,
73
+ summary: "The registered keys, their validity windows, and their ages.",
74
+ },
75
+ {
76
+ method: "POST",
77
+ path: `${basePath}/keys`,
78
+ scope: KEYS_ROTATE_SCOPE,
79
+ summary: "Register a successor public key. Signed with the key it replaces; appends only.",
80
+ },
81
+ {
82
+ method: "POST",
83
+ path: `${basePath}/keys/:keyId/expire`,
84
+ scope: KEYS_ROTATE_SCOPE,
85
+ summary: "Retire a superseded key. Must be signed with the successor it names as proven.",
86
+ },
87
+ ];
88
+ }
89
+
90
+ export function registerControlPlaneRoutes(
91
+ app: Hono<PithyHonoEnv>,
92
+ basePath: string,
93
+ deps: ControlPlaneHandlerDeps,
94
+ ): void {
95
+ app.get(`${basePath}/ping`, requireControlPlane(ANY_VERIFIED_CALLER), pingHandler(deps));
96
+
97
+ app.get(`${basePath}/manifest`, requireControlPlane(MANIFEST_READ_SCOPE), manifestHandler(deps));
98
+
99
+ app.get(`${basePath}/keys`, requireControlPlane(KEYS_ROTATE_SCOPE), listKeysHandler(deps));
100
+
101
+ app.post(
102
+ `${basePath}/keys`,
103
+ requireControlPlane(KEYS_ROTATE_SCOPE),
104
+ zValidator("json", RegisterKeyRequest, validationHook),
105
+ (c) => registerKeyHandler(deps)(c, c.req.valid("json")),
106
+ );
107
+
108
+ app.post(
109
+ `${basePath}/keys/:keyId/expire`,
110
+ requireControlPlane(KEYS_ROTATE_SCOPE),
111
+ zValidator("param", ExpireKeyParams, validationHook),
112
+ zValidator("json", ExpireKeyRequest, validationHook),
113
+ (c) => expireKeyHandler(deps)(c, c.req.valid("param"), c.req.valid("json")),
114
+ );
115
+ }
@@ -0,0 +1,70 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { Ed25519PublicJwk } from "../data/connection";
6
+
7
+ /**
8
+ * The request contract for the seam's own routes. Every input surface is validated here and read in a
9
+ * handler through `c.req.valid(target)` — a handler never parses input (CLAUDE.md §HTTP).
10
+ *
11
+ * These are the highest-risk routes in the product, so two of the choices below are deliberate and
12
+ * would be wrong to "tighten" later:
13
+ *
14
+ * **`keyId` is a bounded string, not a `z.uuid()`.** A key id is opaque and minted by the management
15
+ * client, so its format is not ours to constrain. More importantly, an unknown-but-well-formed key id
16
+ * must reach the handler and answer `controlplane/key_not_found` rather than bouncing off a 400 — a
17
+ * validator that rejected unfamiliar shapes would turn the route into an enumeration oracle telling a
18
+ * caller which ids exist.
19
+ *
20
+ * **Nothing here validates a scope or a connection id.** Those arrive in the signed token, not the
21
+ * request, and are verified against the adopter's own row before a handler runs. A body that could
22
+ * name its own scope would be a body that could grant itself one.
23
+ */
24
+
25
+ /** `POST /control-plane/keys` — register a new public key, signed by the key it succeeds. */
26
+ export const RegisterKeyRequest = z
27
+ .object({
28
+ keyId: z
29
+ .string()
30
+ .min(1)
31
+ .max(64)
32
+ .describe("The new key's id, which a later token names in its `kid` header. Must not already be registered."),
33
+ publicKey: Ed25519PublicJwk.describe(
34
+ "The Ed25519 public key to trust. Only the public half ever crosses the wire — the management client keeps the private key and the adopter never holds anything worth stealing.",
35
+ ),
36
+ })
37
+ .describe(
38
+ "A request to append one public key to this connection. Appends only: it never touches an existing key's validity, which is what makes a failed rotation harmless.",
39
+ );
40
+ export type RegisterKeyRequest = z.infer<typeof RegisterKeyRequest>;
41
+
42
+ /** `POST /control-plane/keys/:keyId/expire` — the path segment naming the key to retire. */
43
+ export const ExpireKeyParams = z
44
+ .object({
45
+ keyId: z
46
+ .string()
47
+ .min(1)
48
+ .max(64)
49
+ .describe(
50
+ "The key to give an end date. Bounded but unconstrained in shape — an unknown id answers 404, not 400.",
51
+ ),
52
+ })
53
+ .describe("The path parameters of the key-expiry route.");
54
+ export type ExpireKeyParams = z.infer<typeof ExpireKeyParams>;
55
+
56
+ /** `POST /control-plane/keys/:keyId/expire` — the body, carrying the proof that a successor works. */
57
+ export const ExpireKeyRequest = z
58
+ .object({
59
+ provenKeyId: z
60
+ .string()
61
+ .min(1)
62
+ .max(64)
63
+ .describe(
64
+ "The replacement key that has already been proven against a live `ping`. Required, and checked to be currently active: expiring a key without naming a working successor is the one failure mode with no recovery path, so the route refuses rather than trusting the caller to have checked.",
65
+ ),
66
+ })
67
+ .describe(
68
+ "A request to expire one superseded key. The successor must be named and must already be live — append, verify, then expire, never replace.",
69
+ );
70
+ export type ExpireKeyRequest = z.infer<typeof ExpireKeyRequest>;