@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,228 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
import type { BindingSpecInput } from "../capability/bindings";
|
|
7
|
+
import { type Capability, defineCapability, type PithyHonoEnv } from "../capability/capability";
|
|
8
|
+
import type { DatabaseSpecMap } from "../data/databases";
|
|
9
|
+
import type { KvNamespaceSpecMap, KvRegistry } from "../kv/namespaces";
|
|
10
|
+
import { PACKAGE_VERSION } from "../version.generated";
|
|
11
|
+
import { ENVIRONMENT_VAR } from "../worker/identity";
|
|
12
|
+
import { ControlPlaneConfig, type ControlPlaneConfigInput } from "./config/config";
|
|
13
|
+
import { ControlPlaneConnection } from "./data/connection";
|
|
14
|
+
import { CONTROL_PLANE_CONNECTIONS_TABLE, type ControlPlaneDatabase, controlPlaneTables } from "./data/tables";
|
|
15
|
+
import type { CapabilityDeclaration } from "./discovery/adminRoute";
|
|
16
|
+
import { type CapabilityHealthSource, capabilityHealthSources } from "./discovery/health";
|
|
17
|
+
import { createControlPlaneVerifier } from "./http/guard";
|
|
18
|
+
import { controlPlaneRouteDescriptors, registerControlPlaneRoutes } from "./http/routes";
|
|
19
|
+
import { controlplane_0001_init } from "./migrations/0001_init";
|
|
20
|
+
import { d1ReplayGuard } from "./replay/d1Guard";
|
|
21
|
+
import type { ReplayGuard } from "./replay/guard";
|
|
22
|
+
import {
|
|
23
|
+
CONTROL_PLANE_KV_BINDING,
|
|
24
|
+
type ControlPlaneKvNamespaces,
|
|
25
|
+
controlPlaneKvNamespaces,
|
|
26
|
+
kvReplayGuard,
|
|
27
|
+
} from "./replay/kvGuard";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where the control-plane registration sorts in the app database. Composed key:
|
|
31
|
+
* `1100_controlplane_0001_init`.
|
|
32
|
+
*
|
|
33
|
+
* Taken from `NEXT_FREE_ORDER` in `packages/cli/src/migrations/orders.test.ts`, which is the one place
|
|
34
|
+
* an order may be allocated, and registered in that file's `DECLARED` table. Core used to be skipped by
|
|
35
|
+
* that scanner on the reasoning that it owns the ceiling rather than an order; that stopped being true
|
|
36
|
+
* here, and the skip was removed with this change. Stable forever — renumbering would rename the
|
|
37
|
+
* composed key and make Kysely re-run an applied migration.
|
|
38
|
+
*/
|
|
39
|
+
export const CONTROLPLANE_MIGRATION_ORDER = 1100;
|
|
40
|
+
|
|
41
|
+
export type ControlPlaneOptions = ControlPlaneConfigInput;
|
|
42
|
+
|
|
43
|
+
/** The control-plane capability, with its resolved config attached. */
|
|
44
|
+
export interface ControlPlaneCapability extends Capability<DatabaseSpecMap, KvNamespaceSpecMap, "controlplane"> {
|
|
45
|
+
/** The resolved seam config. */
|
|
46
|
+
controlPlaneConfig: ControlPlaneConfig;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Narrow a composed capability to this one — the CLI and tests use it to find the seam's config. */
|
|
50
|
+
export function isControlPlaneCapability(capability: Capability): capability is ControlPlaneCapability {
|
|
51
|
+
return capability.name === "controlplane";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `control-plane` seam: inbound, adopter-authenticated M2M admin access to this Worker.
|
|
56
|
+
*
|
|
57
|
+
* **Not Cloudflare's control plane.** That is the outbound REST API `@pithy-sh/cloudflare` calls to
|
|
58
|
+
* provision D1, KV, and Workers, and it carries `cloudflare/*` codes. This is the inverse — a
|
|
59
|
+
* management client calling *in*, authenticated by a key the adopter registered. See
|
|
60
|
+
* `docs/CONTROL-PLANE.md`, whose first section exists entirely to keep the two apart.
|
|
61
|
+
*
|
|
62
|
+
* **Composing it grants nobody anything.** The capability ships present-and-denying: with no connection
|
|
63
|
+
* registered, every route it guards answers `controlplane/not_connected`, and there is no flag that
|
|
64
|
+
* changes that. `pithy dashboard connect` is the deliberate second step, and revoking is a row the
|
|
65
|
+
* adopter deletes without asking anyone.
|
|
66
|
+
*
|
|
67
|
+
* It lives in `@pithy-sh/core` rather than its own package because every capability contributing admin
|
|
68
|
+
* routes imports `requireControlPlane`, and a capability may depend on a core seam but never on a
|
|
69
|
+
* sibling package (principle 4). MIT with the rest of core, and never gated by tier: this is the code
|
|
70
|
+
* that runs in the adopter's own Worker, so restricting it would make "build your own client against
|
|
71
|
+
* your own Worker" untrue.
|
|
72
|
+
*/
|
|
73
|
+
export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCapability {
|
|
74
|
+
// Parsed at assembly, so a config whose replay memory is shorter than a token's life fails on deploy
|
|
75
|
+
// rather than by silently admitting a replay months later.
|
|
76
|
+
const config = ControlPlaneConfig.parse(options);
|
|
77
|
+
|
|
78
|
+
const migrations: Record<string, Migration> = {
|
|
79
|
+
"0001_init": controlplane_0001_init,
|
|
80
|
+
};
|
|
81
|
+
const requiredBindings: BindingSpecInput[] = [
|
|
82
|
+
// The app database — the connections table lives here, beside auth's and audit's. Not the secrets
|
|
83
|
+
// database: a public key is not confidential, and what it needs is a queryable, auditable lifecycle
|
|
84
|
+
// with room for two valid keys during a rotation overlap.
|
|
85
|
+
{ type: "d1", name: "DB" },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// The replay set's namespace — required only when the adopter selected the KV backend.
|
|
89
|
+
//
|
|
90
|
+
// Under the `d1` default nothing reads this namespace, and demanding it anyway would make every
|
|
91
|
+
// project provision a KV it never touches, which is exactly what "a Worker composes only what it
|
|
92
|
+
// declares" rules out. `createBackend` derives the binding from `kvNamespaces` too, so declaring it
|
|
93
|
+
// here is redundant for assembly — `dedupeBindings` ANDs `optional`, and a duplicate can only make a
|
|
94
|
+
// requirement stricter. It is declared anyway, because `requiredBindings` is also the list
|
|
95
|
+
// `pithy remove` strips from `wrangler.jsonc`: `@pithy-sh/matchmaking` and `@pithy-sh/media` both
|
|
96
|
+
// repeat their KV bindings for the same reason, and omitting it left `CONTROL_PLANE` behind after a
|
|
97
|
+
// removal, demanding a namespace nothing used.
|
|
98
|
+
const usesKvReplay = config.replayBackend === "kv";
|
|
99
|
+
if (usesKvReplay) requiredBindings.push({ type: "kv", name: CONTROL_PLANE_KV_BINDING });
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The app database for this request, off the derived registry. `c.var.db` is `unknown` on the base
|
|
103
|
+
* seam by design — its precise type depends on which capabilities a project composed — so each
|
|
104
|
+
* capability narrows it to its own slice, the same cast every other capability makes.
|
|
105
|
+
*/
|
|
106
|
+
const database = (c: Context<PithyHonoEnv>): ControlPlaneDatabase => (c.var.db as { app: ControlPlaneDatabase }).app;
|
|
107
|
+
|
|
108
|
+
const now = () => new Date();
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The replay guard for this request, chosen once by config.
|
|
112
|
+
*
|
|
113
|
+
* `d1` is the default and claims on a primary key, so one token is spendable exactly once wherever the
|
|
114
|
+
* requests land. `kv` remains selectable and is best-effort — see `./replay/guard.ts`. The interface is
|
|
115
|
+
* what made moving the default a one-line change here rather than an edit at every call site.
|
|
116
|
+
*/
|
|
117
|
+
const replay = (c: Context<PithyHonoEnv>): ReplayGuard =>
|
|
118
|
+
config.replayBackend === "kv"
|
|
119
|
+
? kvReplayGuard((c.var.kv as KvRegistry<ControlPlaneKvNamespaces>).controlplane.jtis)
|
|
120
|
+
: d1ReplayGuard(database(c), { now, ttlSeconds: config.jtiTtlSeconds });
|
|
121
|
+
|
|
122
|
+
// What this Worker composed and what each part exposes, filled by the `compose` hook below.
|
|
123
|
+
// Assembly-time knowledge a capability has no other way to reach — `GET /control-plane/manifest`
|
|
124
|
+
// reports it, which is what lets a management client discover this Worker's surface, and how to call
|
|
125
|
+
// it, instead of being configured with both.
|
|
126
|
+
let composed: readonly CapabilityDeclaration[] = [];
|
|
127
|
+
|
|
128
|
+
// The bounded summaries those capabilities contribute, by name (#317). Captured by the same hook and
|
|
129
|
+
// checked there: a value behind a scope no admin route of its own capability requires is refused at
|
|
130
|
+
// assembly, because an adopter is never offered such a scope and the number could never be granted.
|
|
131
|
+
let health: ReadonlyMap<string, CapabilityHealthSource> = new Map();
|
|
132
|
+
|
|
133
|
+
const capability = defineCapability({
|
|
134
|
+
name: "controlplane",
|
|
135
|
+
// `@pithy-sh/core`'s version, stamped by `scripts/stampVersions.ts` — a Worker cannot read its own
|
|
136
|
+
// package.json. The seam reports itself in the manifest beside every other composed capability, so a
|
|
137
|
+
// client can see which core an adopter is on without a separate question.
|
|
138
|
+
version: PACKAGE_VERSION,
|
|
139
|
+
// No `dependsOn`. The seam must work in a Worker composing neither auth nor audit nor secrets: it
|
|
140
|
+
// holds no secret, mints no session, and emits through a seam that is a no-op when absent.
|
|
141
|
+
requiredBindings,
|
|
142
|
+
config: ControlPlaneConfig,
|
|
143
|
+
databases: {
|
|
144
|
+
app: {
|
|
145
|
+
binding: "DB",
|
|
146
|
+
tables: controlPlaneTables(),
|
|
147
|
+
migrationOrder: CONTROLPLANE_MIGRATION_ORDER,
|
|
148
|
+
migrations,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
// Registered only for the KV backend, and built from the resolved config so `jtiTtlSeconds` actually
|
|
152
|
+
// governs how long a spent token id is remembered. A constant here would make that setting
|
|
153
|
+
// decorative; registering it unconditionally would make every `d1` project carry a namespace nothing
|
|
154
|
+
// in the tree ever reads.
|
|
155
|
+
kvNamespaces: usesKvReplay ? controlPlaneKvNamespaces(config.jtiTtlSeconds) : {},
|
|
156
|
+
// The seam describes itself too. Built from the same resolved `basePath` the routes mount on, so a
|
|
157
|
+
// moved mount point is reported rather than becoming a lie a client believes.
|
|
158
|
+
adminRoutes: controlPlaneRouteDescriptors(config.basePath),
|
|
159
|
+
compose: ({ capabilities }) => {
|
|
160
|
+
// Every capability, including those exposing nothing. A client rendering a capability it cannot
|
|
161
|
+
// act on is useful; a client that cannot tell "no admin surface" from "not installed" is not.
|
|
162
|
+
composed = capabilities.map((cap) => ({
|
|
163
|
+
name: cap.name,
|
|
164
|
+
// `?? null`, never undefined: the adopter's own `app` capability has a name and no package
|
|
165
|
+
// version, and that must render as an explicit null rather than vanish from the JSON.
|
|
166
|
+
version: cap.version ?? null,
|
|
167
|
+
adminRoutes: [...(cap.adminRoutes ?? [])],
|
|
168
|
+
// The vocabulary, always — a capability declaring a number this connection cannot see must be
|
|
169
|
+
// visible as a withheld number rather than as silence.
|
|
170
|
+
healthKeys: [...(cap.health?.keys ?? [])],
|
|
171
|
+
// The configured facts, read once here and the same for every caller (#422). No producer and no
|
|
172
|
+
// per-request cost: a fact is resolved config, so there is nothing to ask and nothing to fail.
|
|
173
|
+
configKeys: [...(cap.manifestConfig?.keys ?? [])],
|
|
174
|
+
config: { ...(cap.manifestConfig?.values ?? {}) },
|
|
175
|
+
}));
|
|
176
|
+
health = capabilityHealthSources(capabilities);
|
|
177
|
+
},
|
|
178
|
+
middleware: [
|
|
179
|
+
(app) => {
|
|
180
|
+
// Publish the verifier every `requireControlPlane()` in the tree consumes — including the ones
|
|
181
|
+
// in capabilities that know nothing about this config. Publishing it is all this does: no
|
|
182
|
+
// verification happens here, because a request merely carrying the header must not be rejected
|
|
183
|
+
// on a route that has nothing to do with the seam.
|
|
184
|
+
app.use("*", async (c, next) => {
|
|
185
|
+
const db = database(c);
|
|
186
|
+
c.set(
|
|
187
|
+
"controlPlaneVerifier",
|
|
188
|
+
createControlPlaneVerifier({
|
|
189
|
+
loadConnection: async (connectionId) => {
|
|
190
|
+
const row = await db
|
|
191
|
+
.selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE)
|
|
192
|
+
.selectAll()
|
|
193
|
+
.where("id", "=", connectionId)
|
|
194
|
+
.executeTakeFirst();
|
|
195
|
+
return row ? ControlPlaneConnection.parse(row) : null;
|
|
196
|
+
},
|
|
197
|
+
countConnections: async () => {
|
|
198
|
+
const rows = await db.selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE).select("id").limit(1).execute();
|
|
199
|
+
return rows.length;
|
|
200
|
+
},
|
|
201
|
+
replay: replay(c),
|
|
202
|
+
// The environment a credential is bound to. Absent off-platform, where the empty string
|
|
203
|
+
// matches no stored connection and every call therefore denies — the right failure.
|
|
204
|
+
environment: String((c.env as Record<string, unknown>)[ENVIRONMENT_VAR] ?? ""),
|
|
205
|
+
config,
|
|
206
|
+
now,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
await next();
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
routes: (app) => {
|
|
214
|
+
registerControlPlaneRoutes(app, config.basePath, {
|
|
215
|
+
config,
|
|
216
|
+
database,
|
|
217
|
+
composedCapabilities: () => composed,
|
|
218
|
+
composedHealth: () => health,
|
|
219
|
+
now,
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return { ...capability, controlPlaneConfig: config } as ControlPlaneCapability;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Re-exported so a Worker's wrangler config and this capability cannot disagree about the binding name. */
|
|
228
|
+
export { CONTROL_PLANE_KV_BINDING };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import {
|
|
6
|
+
CONTROL_PLANE_CLOCK_SKEW_SECONDS,
|
|
7
|
+
CONTROL_PLANE_JTI_TTL_SECONDS,
|
|
8
|
+
CONTROL_PLANE_MAX_TOKEN_LIFETIME_SECONDS,
|
|
9
|
+
} from "../token/claims";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The `control-plane` seam's config — the thin, user-owned surface in a Worker's `pithy.config.ts`.
|
|
13
|
+
* Every field is `.describe()`d: the descriptions feed the self-documenting CLI (CLAUDE.md §Config).
|
|
14
|
+
*
|
|
15
|
+
* Parsed once at assembly, so a bad value fails on deploy rather than on the first management call.
|
|
16
|
+
* That matters more here than anywhere else in the tree: the failure mode of a loose setting is not a
|
|
17
|
+
* broken feature, it is an admin surface that accepts something it should have refused, and nothing
|
|
18
|
+
* observable goes wrong until someone uses it.
|
|
19
|
+
*
|
|
20
|
+
* The defaults are deliberately tight. A management token lives one minute, is accepted one minute
|
|
21
|
+
* either side of the clock, and its `jti` is remembered for three — comfortably longer than the widest
|
|
22
|
+
* window any such token can be accepted in, which is the whole point of the cross-field rule below.
|
|
23
|
+
*
|
|
24
|
+
* None of this concerns Cloudflare's control plane. That is the outbound provisioning API behind
|
|
25
|
+
* `@pithy-sh/cloudflare`; this is an inbound seam the adopter's own Worker exposes.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** A rooted path with no trailing slash — Hono mounts on exactly this string. */
|
|
29
|
+
const BASE_PATH_PATTERN = /^\/[a-zA-Z0-9\-._~]+(?:\/[a-zA-Z0-9\-._~]+)*$/;
|
|
30
|
+
|
|
31
|
+
/** Five minutes. The ceiling on skew and on a token's life: past it, "short-lived" stops being true. */
|
|
32
|
+
const MAX_WINDOW_SECONDS = 300;
|
|
33
|
+
|
|
34
|
+
/** An hour. The ceiling on replay memory — beyond it the set costs more than the risk it retires. */
|
|
35
|
+
const MAX_JTI_TTL_SECONDS = 3600;
|
|
36
|
+
|
|
37
|
+
/** Two hours. Chrome's own ceiling on a cached preflight; asking for more is asking for nothing. */
|
|
38
|
+
const MAX_CORS_MAX_AGE_SECONDS = 7200;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Exactly an origin — what a browser puts in `Origin`, and nothing else.
|
|
42
|
+
*
|
|
43
|
+
* `z.url()` is not this check: it accepts `https://ops.example.com/`, a path, a wildcard, and `ftp://`.
|
|
44
|
+
* Every one of those would sit in the config file looking correct and then match no browser `Origin`,
|
|
45
|
+
* which fails as a bare refusal with nothing to read. The trailing-slash form is the one to expect,
|
|
46
|
+
* because it is what an address bar shows the person copying the value.
|
|
47
|
+
*/
|
|
48
|
+
/** A DNS hostname, or a bracketed IP literal — `[::1]`, the address a Vite dev server binds by default. */
|
|
49
|
+
const ORIGIN_HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
|
50
|
+
const IP_LITERAL = /^\[[0-9a-fA-F:.]+\]$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Exactly an origin: a scheme a browser sends, a real host, and nothing after it.
|
|
54
|
+
*
|
|
55
|
+
* **It refuses a misunderstanding and forgives a spelling.** A path, a query, a fragment or a
|
|
56
|
+
* credential means the writer thinks this value scopes something it does not — `https://ops.example.com/admin`
|
|
57
|
+
* does not restrict anything to `/admin` — so those are refused at deploy, where the message can say so.
|
|
58
|
+
* A trailing slash and an explicit `:443` are the same origin written a different way, and they are what
|
|
59
|
+
* an address bar hands the person copying the value, so they are accepted and normalized by
|
|
60
|
+
* `allowedOriginSet` rather than turned into a puzzle.
|
|
61
|
+
*
|
|
62
|
+
* `z.url()` is not this check: it accepts `https://*.example.com` — a wildcard is a legal hostname to
|
|
63
|
+
* the URL parser and means nothing to a browser — and `ftp://x.com`, which has an origin too.
|
|
64
|
+
*/
|
|
65
|
+
const ORIGIN_ONLY = (value: string): boolean => {
|
|
66
|
+
let url: URL;
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(value);
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return false;
|
|
73
|
+
if (url.username !== "" || url.password !== "") return false;
|
|
74
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return false;
|
|
75
|
+
return ORIGIN_HOSTNAME.test(url.hostname) || IP_LITERAL.test(url.hostname);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const ControlPlaneConfig = z
|
|
79
|
+
.object({
|
|
80
|
+
basePath: z
|
|
81
|
+
.string()
|
|
82
|
+
.regex(
|
|
83
|
+
BASE_PATH_PATTERN,
|
|
84
|
+
"A base path must start with `/`, carry no trailing slash, and contain no spaces (e.g. `/control-plane`).",
|
|
85
|
+
)
|
|
86
|
+
.default("/control-plane")
|
|
87
|
+
.describe(
|
|
88
|
+
"Where the seam's own routes mount — ping, manifest, and key management. Move it to sit under an existing admin prefix; it is not a security control, since every route behind it is default-denied anyway.",
|
|
89
|
+
),
|
|
90
|
+
issuer: z
|
|
91
|
+
.url()
|
|
92
|
+
.default("https://app.pithy.sh")
|
|
93
|
+
.describe(
|
|
94
|
+
"The management-client origin a NEW connection is registered against — trust-critical, and effectively permanent. Every registered Worker verifies the `iss` on every call, so changing this is a migration across every connection, not a config edit. Verification reads the issuer stored on the connection; this only supplies the default written at connect time. It also seeds the browser origins this Worker answers a CORS preflight for (see `allowedOrigins`), which is the one thing about this value the Worker itself reads at runtime.",
|
|
95
|
+
),
|
|
96
|
+
allowedOrigins: z
|
|
97
|
+
.array(
|
|
98
|
+
z
|
|
99
|
+
.url()
|
|
100
|
+
.refine(
|
|
101
|
+
ORIGIN_ONLY,
|
|
102
|
+
"An allowed origin is a scheme and a host — `https://ops.example.com`, `http://localhost:5173`, `http://[::1]:5173`. A path, a query or a credential is refused, because it looks like it scopes the entry and does not.",
|
|
103
|
+
)
|
|
104
|
+
.describe("One exact browser origin, spelled the way a browser sends it in `Origin`."),
|
|
105
|
+
)
|
|
106
|
+
.default([])
|
|
107
|
+
.describe(
|
|
108
|
+
"Browser origins allowed to call this Worker's control-plane surface cross-origin, **in addition to** `issuer`. Additive by construction: an entry here never removes `issuer`, so adding your own console cannot lock out the dashboard that was already working. Read from this config alone and never from a connection row — a preflight carries no credential, so answering one must cost no database read and must reveal nothing about which origins are registered. A management client you host yourself belongs here, or in `issuer` if it is the only one.",
|
|
109
|
+
),
|
|
110
|
+
corsMaxAgeSeconds: z
|
|
111
|
+
.number()
|
|
112
|
+
.int()
|
|
113
|
+
.min(0)
|
|
114
|
+
.max(MAX_CORS_MAX_AGE_SECONDS)
|
|
115
|
+
.default(600)
|
|
116
|
+
.describe(
|
|
117
|
+
"How long a browser may cache a preflight for this Worker's admin surface, in seconds. The allow-list it caches is a compile-time constant, so ten minutes costs nothing and saves the dashboard a second round trip on every call. **Set it to 0 while you are working out an allow-list**: a browser that cached a refusal keeps refusing for the full window after you have fixed `allowedOrigins`, which reads exactly like a change that did not take. Browsers cap this themselves, so a larger number here is a request, not a guarantee.",
|
|
118
|
+
),
|
|
119
|
+
clockSkewSeconds: z
|
|
120
|
+
.number()
|
|
121
|
+
.int()
|
|
122
|
+
.min(0)
|
|
123
|
+
.max(MAX_WINDOW_SECONDS)
|
|
124
|
+
.default(CONTROL_PLANE_CLOCK_SKEW_SECONDS)
|
|
125
|
+
.describe(
|
|
126
|
+
"How far a token's `iat`/`exp` may sit outside this Worker's clock and still be accepted. It absorbs real drift between two machines; it also widens the window a captured token stays live in, which is why it is capped rather than left to taste.",
|
|
127
|
+
),
|
|
128
|
+
maxTokenLifetimeSeconds: z
|
|
129
|
+
.number()
|
|
130
|
+
.int()
|
|
131
|
+
.positive()
|
|
132
|
+
.max(MAX_WINDOW_SECONDS)
|
|
133
|
+
.default(CONTROL_PLANE_MAX_TOKEN_LIFETIME_SECONDS)
|
|
134
|
+
.describe(
|
|
135
|
+
"The longest `exp - iat` this Worker will honor, whatever the token asks for. The adopter's ceiling, not the management client's choice: a client that mints hour-long tokens still gets one minute of them here.",
|
|
136
|
+
),
|
|
137
|
+
jtiTtlSeconds: z
|
|
138
|
+
.number()
|
|
139
|
+
.int()
|
|
140
|
+
.positive()
|
|
141
|
+
.max(MAX_JTI_TTL_SECONDS)
|
|
142
|
+
.default(CONTROL_PLANE_JTI_TTL_SECONDS)
|
|
143
|
+
.describe(
|
|
144
|
+
"How long a spent `jti` is remembered, so the same token cannot be presented twice. It must outlive the widest window a token is accepted in (lifetime plus skew) — a shorter memory forgets a token that is still valid, which is precisely the replay this exists to stop.",
|
|
145
|
+
),
|
|
146
|
+
replayBackend: z
|
|
147
|
+
.enum(["d1", "kv"])
|
|
148
|
+
.default("d1")
|
|
149
|
+
.describe(
|
|
150
|
+
"Where spent token ids are recorded. `d1` claims with `INSERT … ON CONFLICT DO NOTHING`, so the primary key decides the race and one token is spendable exactly once, wherever the requests land — it costs one write on a path an administrator paces. `kv` skips that write and is best-effort: Workers KV has no compare-and-set and is eventually consistent across colocations, so one token presented twice in two places inside the propagation window can pass twice. Choose `kv` only where every management operation is idempotent.",
|
|
151
|
+
),
|
|
152
|
+
keyRetentionDays: z
|
|
153
|
+
.number()
|
|
154
|
+
.int()
|
|
155
|
+
.positive()
|
|
156
|
+
.max(365)
|
|
157
|
+
.default(30)
|
|
158
|
+
.describe(
|
|
159
|
+
"How long an expired or revoked key stays on the connection row before pruning. Long enough to read a rotation back; short enough that verification never parses a growing blob. The audit trail is the history of record — the row is working state, not an archive.",
|
|
160
|
+
),
|
|
161
|
+
maxKeys: z
|
|
162
|
+
.number()
|
|
163
|
+
.int()
|
|
164
|
+
.min(2)
|
|
165
|
+
.max(32)
|
|
166
|
+
.default(8)
|
|
167
|
+
.describe(
|
|
168
|
+
"The most keys one connection may hold, live and superseded together. At least two, because a rotation overlap needs the new key registered while the old one still works — a ceiling of one would make safe rotation impossible.",
|
|
169
|
+
),
|
|
170
|
+
})
|
|
171
|
+
.describe(
|
|
172
|
+
"Configuration for the inbound `control-plane` seam: where its routes mount, which management-client origin new connections trust, and the token and key-lifecycle bounds this Worker enforces.",
|
|
173
|
+
)
|
|
174
|
+
.check((ctx) => {
|
|
175
|
+
// The replay set is only a defense while it still remembers a token that is still valid. If the
|
|
176
|
+
// `jti` is forgotten first, the same signed token replays cleanly for the rest of its window — the
|
|
177
|
+
// one misconfiguration that quietly reopens exactly what the set was added to close.
|
|
178
|
+
//
|
|
179
|
+
// The window is `lifetime + 2 × skew`, not `lifetime + skew`. Skew is allowed on **both** ends:
|
|
180
|
+
// `iat` may be up to a skew in the future and `exp` is honored up to a skew after it passes, so a
|
|
181
|
+
// token minted at the earliest instant this Worker would accept it is still accepted a full
|
|
182
|
+
// `lifetime + 2 × skew` later. The check used to count one skew and was therefore satisfied by
|
|
183
|
+
// settings that still left a replay window open.
|
|
184
|
+
const widestWindow = ctx.value.maxTokenLifetimeSeconds + 2 * ctx.value.clockSkewSeconds;
|
|
185
|
+
if (ctx.value.jtiTtlSeconds <= widestWindow) {
|
|
186
|
+
ctx.issues.push({
|
|
187
|
+
code: "custom",
|
|
188
|
+
input: ctx.value,
|
|
189
|
+
path: ["jtiTtlSeconds"],
|
|
190
|
+
message: `jtiTtlSeconds (${ctx.value.jtiTtlSeconds}) must exceed maxTokenLifetimeSeconds + 2 × clockSkewSeconds (${widestWindow}). A token is accepted a clock skew either side of its window, so a shorter memory lets it outlive the record of it and replay.`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
export type ControlPlaneConfig = z.output<typeof ControlPlaneConfig>;
|
|
195
|
+
export type ControlPlaneConfigInput = z.input<typeof ControlPlaneConfig>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ControlPlaneScope } from "./scope/scope";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The verified control-plane caller, on `c.var.controlPlane`.
|
|
9
|
+
*
|
|
10
|
+
* **This is deliberately not `AuthContext`, and the two must never be conflated.** A management client
|
|
11
|
+
* is not a user of the adopter's app: it holds no session, owns no user row, and signed up for
|
|
12
|
+
* nothing. If a control-plane call populated `c.var.auth`, every `requireAuth()` in every capability
|
|
13
|
+
* would pass for it — a scope escalation across the whole tree, from one seemingly convenient
|
|
14
|
+
* assignment. So the seam gets its own request variable, and `requireControlPlane` is the only gate
|
|
15
|
+
* that reads it.
|
|
16
|
+
*
|
|
17
|
+
* Statelessness is the feature. Nothing here is persisted: the context is derived from one token,
|
|
18
|
+
* lives for one request, and is discarded. There is no session to hijack and no row to leak. Exactly
|
|
19
|
+
* two things outlive the call — the `jti` in the replay set, and the audit event.
|
|
20
|
+
*/
|
|
21
|
+
export const ControlPlaneContext = z
|
|
22
|
+
.object({
|
|
23
|
+
connectionId: z
|
|
24
|
+
.string()
|
|
25
|
+
.describe(
|
|
26
|
+
"The connection this call authenticated against — the token's `aud`, verified against the loaded registration. Identifies the management client, per adopter, per project, per environment.",
|
|
27
|
+
),
|
|
28
|
+
environment: z
|
|
29
|
+
.string()
|
|
30
|
+
.describe(
|
|
31
|
+
"The environment the connection is bound to, already checked against this Worker's own. A staging credential never reaches production, so by the time this is set the two agree.",
|
|
32
|
+
),
|
|
33
|
+
issuer: z
|
|
34
|
+
.string()
|
|
35
|
+
.describe(
|
|
36
|
+
"The `iss` this connection trusts and this token carried. Verified, not merely recorded — it is the origin the adopter agreed to accept calls from.",
|
|
37
|
+
),
|
|
38
|
+
subject: z
|
|
39
|
+
.string()
|
|
40
|
+
.describe(
|
|
41
|
+
"The management client's own user id from the token's `sub` — who, on their side, is acting. Recorded as the audit `actorId` so the trail answers 'which person at the dashboard did this', not just 'the dashboard'.",
|
|
42
|
+
),
|
|
43
|
+
scope: ControlPlaneScope.describe(
|
|
44
|
+
"The single scope this token was minted for. One call, one operation — a token carrying the whole grant would make every call as dangerous as the most dangerous one.",
|
|
45
|
+
),
|
|
46
|
+
grantedScopes: z
|
|
47
|
+
.array(ControlPlaneScope)
|
|
48
|
+
.describe(
|
|
49
|
+
"Every scope the adopter granted this connection, read from their own row. The authority: a scope absent here is denied however the token is written.",
|
|
50
|
+
),
|
|
51
|
+
keyId: z
|
|
52
|
+
.string()
|
|
53
|
+
.describe(
|
|
54
|
+
"Which registered key verified this call. Recorded so a rotation can be traced through the trail, and so a key still in use is visible before it is expired.",
|
|
55
|
+
),
|
|
56
|
+
tokenId: z
|
|
57
|
+
.string()
|
|
58
|
+
.describe("The token's `jti`, already claimed in the replay set. Recorded to tie one audit event to one call."),
|
|
59
|
+
})
|
|
60
|
+
.describe(
|
|
61
|
+
"The verified control-plane caller for one request. Never an AuthContext: a management client is not a user of the adopter's app.",
|
|
62
|
+
);
|
|
63
|
+
export type ControlPlaneContext = z.output<typeof ControlPlaneContext>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { JsonDate, SQLiteDate, sqliteJson } from "../../data/codecs";
|
|
6
|
+
import { ControlPlaneScope } from "../scope/scope";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The registration record: who may call this Worker's control-plane routes, from where, with what
|
|
10
|
+
* keys, for which operations.
|
|
11
|
+
*
|
|
12
|
+
* **This lives in D1, not the Secrets Store.** A public key is not confidential — but it *is*
|
|
13
|
+
* integrity-critical, because whoever can change it owns the seam. D1 gives it a queryable lifecycle,
|
|
14
|
+
* an audit trail, and room for two valid keys during a rotation overlap. Writes come only from the CLI
|
|
15
|
+
* provisioning path or from a route authenticated by the key being replaced; no runtime route with a
|
|
16
|
+
* lesser credential can touch it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** An Ed25519 public key in JWK form — the only key type this seam accepts. */
|
|
20
|
+
export const Ed25519PublicJwk = z
|
|
21
|
+
.object({
|
|
22
|
+
kty: z.literal("OKP").describe("Key type. Ed25519 is an octet key pair, so this is always `OKP` (RFC 8037)."),
|
|
23
|
+
crv: z
|
|
24
|
+
.literal("Ed25519")
|
|
25
|
+
.describe(
|
|
26
|
+
"The curve, pinned to Ed25519. Pinned rather than accepted from the key so a weaker curve cannot be smuggled in at registration and honored at verification.",
|
|
27
|
+
),
|
|
28
|
+
x: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.max(128)
|
|
32
|
+
.describe(
|
|
33
|
+
"The base64url-encoded public key point. Public by definition — safe to store, and safe to return from `GET /control-plane/keys`.",
|
|
34
|
+
),
|
|
35
|
+
})
|
|
36
|
+
.describe("An Ed25519 public key as a JWK: what a management client registers, and what verification imports.");
|
|
37
|
+
export type Ed25519PublicJwk = z.output<typeof Ed25519PublicJwk>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One registered public key and its validity window — an element of the connection's versioned array.
|
|
41
|
+
*
|
|
42
|
+
* Several keys are valid at once during a rotation overlap. That is a normal state, not an exception:
|
|
43
|
+
* the new key is appended and proven *before* the old one is given an end date, so there is always a
|
|
44
|
+
* live key to authenticate the next step with.
|
|
45
|
+
*/
|
|
46
|
+
export const RegisteredKey = z
|
|
47
|
+
.object({
|
|
48
|
+
keyId: z
|
|
49
|
+
.string()
|
|
50
|
+
.min(1)
|
|
51
|
+
.max(64)
|
|
52
|
+
.describe("The key's id, matched against a token's `kid` header. Opaque, and minted by the management client."),
|
|
53
|
+
publicKey: Ed25519PublicJwk.describe(
|
|
54
|
+
"The public key this id names. Imported per request to verify a signature; nothing secret is stored here.",
|
|
55
|
+
),
|
|
56
|
+
validFrom: JsonDate.describe(
|
|
57
|
+
"When this key became valid. A call signed before it is rejected, so a key registered ahead of time cannot be used early.",
|
|
58
|
+
),
|
|
59
|
+
validUntil: JsonDate.nullable().describe(
|
|
60
|
+
"When this key stops being accepted, or null while it is open-ended. Set only by the expire route, never at registration — the open-ended overlap is what keeps a failed rotation from locking anyone out.",
|
|
61
|
+
),
|
|
62
|
+
revokedAt: JsonDate.nullable().describe(
|
|
63
|
+
"When this key was revoked outright, or null. Revocation ignores `validUntil` and takes effect immediately — it is the adopter's unilateral control, and it must not wait for a window to close.",
|
|
64
|
+
),
|
|
65
|
+
})
|
|
66
|
+
.describe("One registered public key with its validity window. Two live at once during a rotation overlap.");
|
|
67
|
+
export type RegisteredKey = z.output<typeof RegisteredKey>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One management-client connection — the row in `pithy_controlplane_connections`.
|
|
71
|
+
*
|
|
72
|
+
* Keys are a versioned JSON column rather than a child table for two reasons. **Rotation becomes one
|
|
73
|
+
* atomic `UPDATE`**: appending the new key and expiring the old cannot half-apply, where separate rows
|
|
74
|
+
* would need a batch. And **verification stays a single read** with no join on a Worker hot path —
|
|
75
|
+
* scanning two or three keys for a matching `kid` is free.
|
|
76
|
+
*/
|
|
77
|
+
export const ControlPlaneConnection = z
|
|
78
|
+
.object({
|
|
79
|
+
id: z
|
|
80
|
+
.uuid()
|
|
81
|
+
.describe(
|
|
82
|
+
"The connection id — a UUID because it is the token `aud` and therefore externally exposed. Text rather than an autoincrementing integer, so connections cannot be enumerated.",
|
|
83
|
+
),
|
|
84
|
+
environment: z
|
|
85
|
+
.string()
|
|
86
|
+
.min(1)
|
|
87
|
+
.max(32)
|
|
88
|
+
.describe(
|
|
89
|
+
"The environment this connection is valid in (`dev`, `staging`, `production`). Checked against the Worker's own on every call, so a staging credential cannot reach production.",
|
|
90
|
+
),
|
|
91
|
+
issuer: z
|
|
92
|
+
.url()
|
|
93
|
+
.describe(
|
|
94
|
+
"The exact `iss` this connection accepts. Verified on every call, and effectively permanent — changing the management client's origin is a migration across every registered connection, not a DNS change.",
|
|
95
|
+
),
|
|
96
|
+
workerUrl: z
|
|
97
|
+
.url()
|
|
98
|
+
.describe(
|
|
99
|
+
"This environment's deployed Worker URL, captured at connect. The address the management client calls; re-pointed by `pithy dashboard connect --update` when a custom domain or a rename moves it.",
|
|
100
|
+
),
|
|
101
|
+
basePath: z
|
|
102
|
+
.string()
|
|
103
|
+
.min(1)
|
|
104
|
+
.default("/control-plane")
|
|
105
|
+
.describe(
|
|
106
|
+
"Where this Worker mounts the control-plane seam, captured at connect from its resolved config. Stored beside `workerUrl` because the two together fully determine the manifest address — and this is the one part of it a client cannot discover, since it *is* the manifest's own address. Defaulted rather than required so a connection registered before the column existed still parses, reading as the default it was necessarily using.",
|
|
107
|
+
),
|
|
108
|
+
scopes: sqliteJson(z.array(ControlPlaneScope)).describe(
|
|
109
|
+
"The operations this connection was granted, stored and enforced on the adopter's side. Enforced only by the caller, a scope would not be a limit at all.",
|
|
110
|
+
),
|
|
111
|
+
keys: sqliteJson(z.array(RegisteredKey)).describe(
|
|
112
|
+
"Every key this connection may sign with, current and superseded. One JSON column rather than a child table, so a rotation is one atomic UPDATE and verification is one read.",
|
|
113
|
+
),
|
|
114
|
+
createdAt: SQLiteDate.describe("When the connection was registered. Ms-epoch in SQLite, a Date in TypeScript."),
|
|
115
|
+
updatedAt: SQLiteDate.describe(
|
|
116
|
+
"When the row last changed — a key registered or expired, or the Worker URL re-pointed.",
|
|
117
|
+
),
|
|
118
|
+
})
|
|
119
|
+
.describe(
|
|
120
|
+
"One management-client connection: its identity, the environment and URL it is bound to, the operations it was granted, and its versioned public keys.",
|
|
121
|
+
);
|
|
122
|
+
export type ControlPlaneConnection = z.output<typeof ControlPlaneConnection>;
|
|
123
|
+
export type ControlPlaneConnectionRow = z.input<typeof ControlPlaneConnection>;
|