@pithy-sh/core 0.10.0 → 0.11.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/dist/access/access.d.ts +172 -0
- package/dist/access/access.d.ts.map +1 -0
- package/dist/access/access.js +147 -0
- package/dist/access/acting.d.ts +49 -0
- package/dist/access/acting.d.ts.map +1 -0
- package/dist/access/acting.js +21 -0
- package/dist/access/policyCheck.d.ts +33 -0
- package/dist/access/policyCheck.d.ts.map +1 -0
- package/dist/access/policyCheck.js +48 -0
- package/dist/access/usage.d.ts +83 -0
- package/dist/access/usage.d.ts.map +1 -0
- package/dist/access/usage.js +104 -0
- package/dist/access/visibility.d.ts +66 -0
- package/dist/access/visibility.d.ts.map +1 -0
- package/dist/access/visibility.js +86 -0
- package/dist/audit/auditEvent.d.ts +5 -0
- package/dist/audit/auditEvent.d.ts.map +1 -1
- package/dist/audit/auditEvent.js +2 -0
- package/dist/capability/bindings.d.ts +47 -2
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +117 -7
- package/dist/capability/capability.d.ts +98 -28
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/manifest.d.ts +2 -1
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/manifest.js +1 -1
- package/dist/capability/settings.d.ts +86 -2
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/settings.js +80 -1
- package/dist/cli/commandCatalog.generated.d.ts +184 -22
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +196 -22
- package/dist/cli/commandFacts.d.ts +1 -1
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +1 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +12 -2
- package/dist/controlPlane/context.d.ts +5 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -1
- package/dist/controlPlane/data/connection.d.ts +47 -0
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +55 -1
- package/dist/controlPlane/http/cors.d.ts +4 -4
- package/dist/controlPlane/http/cors.js +4 -4
- package/dist/controlPlane/http/guard.d.ts +11 -0
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +17 -1
- package/dist/controlPlane/http/handlers.d.ts +60 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +31 -2
- package/dist/controlPlane/http/readiness.d.ts +33 -0
- package/dist/controlPlane/http/readiness.d.ts.map +1 -0
- package/dist/controlPlane/http/readiness.js +39 -0
- package/dist/controlPlane/http/responses.d.ts +147 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +61 -4
- package/dist/controlPlane/http/routes.d.ts +1 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +17 -3
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +2 -1
- package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
- package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
- package/dist/controlPlane/migrationState/migrationState.js +142 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
- package/dist/controlPlane/scope/scope.d.ts +13 -2
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +14 -3
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +7 -2
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +5 -3
- package/dist/entitlement/allowance.d.ts +25 -0
- package/dist/entitlement/allowance.d.ts.map +1 -0
- package/dist/entitlement/allowance.js +80 -0
- package/dist/entitlement/entitlement.d.ts +41 -0
- package/dist/entitlement/entitlement.d.ts.map +1 -1
- package/dist/entitlement/meter.d.ts +57 -0
- package/dist/entitlement/meter.d.ts.map +1 -0
- package/dist/entitlement/meter.js +13 -0
- package/dist/error/http.d.ts +144 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +288 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +100 -4
- package/dist/http/authContext.d.ts +7 -0
- package/dist/http/authContext.d.ts.map +1 -1
- package/dist/http/authContext.js +8 -1
- package/dist/http/scopeGates.d.ts +20 -0
- package/dist/http/scopeGates.d.ts.map +1 -0
- package/dist/http/scopeGates.js +36 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/record.d.ts +84 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/migrations/capabilitySets.d.ts +23 -0
- package/dist/migrations/capabilitySets.d.ts.map +1 -0
- package/dist/migrations/capabilitySets.js +74 -0
- package/dist/migrations/registry.d.ts +26 -0
- package/dist/migrations/registry.d.ts.map +1 -1
- package/dist/migrations/registry.js +38 -7
- package/dist/migrations/state.d.ts +60 -0
- package/dist/migrations/state.d.ts.map +1 -0
- package/dist/migrations/state.js +46 -0
- package/dist/naming/feature.d.ts +27 -5
- package/dist/naming/feature.d.ts.map +1 -1
- package/dist/naming/feature.js +36 -6
- package/dist/naming/limits.d.ts +47 -3
- package/dist/naming/limits.d.ts.map +1 -1
- package/dist/naming/limits.js +47 -3
- package/dist/naming/provisionScope.d.ts +6 -0
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +8 -3
- package/dist/naming/resourceNames.d.ts +21 -2
- package/dist/naming/resourceNames.d.ts.map +1 -1
- package/dist/naming/resourceNames.js +21 -3
- package/dist/seed/writeD1.d.ts +7 -1
- package/dist/seed/writeD1.d.ts.map +1 -1
- package/dist/seed/writeD1.js +9 -3
- package/dist/seed/writeKv.d.ts +2 -0
- package/dist/seed/writeKv.d.ts.map +1 -1
- package/dist/seed/writeKv.js +6 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worker/serviceLoopback.d.ts +36 -0
- package/dist/worker/serviceLoopback.d.ts.map +1 -0
- package/dist/worker/serviceLoopback.js +74 -0
- package/dist/workflow/faults.d.ts +12 -1
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +22 -15
- package/package.json +2 -2
- package/src/access/access.ts +298 -0
- package/src/access/acting.ts +59 -0
- package/src/access/policyCheck.ts +77 -0
- package/src/access/usage.ts +141 -0
- package/src/access/visibility.ts +109 -0
- package/src/audit/auditEvent.ts +4 -0
- package/src/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +99 -29
- package/src/capability/settings.ts +146 -2
- package/src/cli/commandCatalog.generated.ts +212 -22
- package/src/cli/commandFacts.ts +1 -0
- package/src/controlPlane/capability.ts +25 -1
- package/src/controlPlane/context.ts +4 -0
- package/src/controlPlane/data/connection.ts +90 -0
- package/src/controlPlane/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +21 -0
- package/src/controlPlane/http/handlers.ts +41 -0
- package/src/controlPlane/http/readiness.ts +50 -0
- package/src/controlPlane/http/responses.ts +140 -2
- package/src/controlPlane/http/routes.ts +25 -1
- package/src/controlPlane/http/verify.ts +2 -0
- package/src/controlPlane/migrationState/migrationState.ts +200 -0
- package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
- package/src/controlPlane/scope/scope.ts +14 -2
- package/src/createBackend.ts +24 -6
- package/src/createEntrypoint.ts +9 -3
- package/src/entitlement/allowance.ts +85 -0
- package/src/entitlement/entitlement.ts +38 -0
- package/src/entitlement/meter.ts +69 -0
- package/src/error/payload.ts +211 -4
- package/src/http/authContext.ts +13 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/index.ts +1 -0
- package/src/migrations/capabilitySets.ts +87 -0
- package/src/migrations/registry.ts +46 -2
- package/src/migrations/state.ts +94 -0
- package/src/naming/feature.ts +46 -7
- package/src/naming/limits.ts +35 -3
- package/src/naming/provisionScope.ts +19 -1
- package/src/naming/resourceNames.ts +52 -5
- package/src/seed/writeD1.ts +13 -4
- package/src/seed/writeKv.ts +6 -2
- package/src/version.generated.ts +1 -1
- package/src/worker/serviceLoopback.ts +105 -0
- package/src/workflow/faults.ts +51 -24
|
@@ -66,6 +66,81 @@ export const RegisteredKey = z
|
|
|
66
66
|
.describe("One registered public key with its validity window. Two live at once during a rotation overlap.");
|
|
67
67
|
export type RegisteredKey = z.output<typeof RegisteredKey>;
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* What kind of client a connection is for (#721): `agent`, `dashboard` or `script`.
|
|
71
|
+
*
|
|
72
|
+
* The audit trail's `actorType: "control-plane"` names all three identically, and the questions asked
|
|
73
|
+
* after an incident are exactly the ones that tell them apart — was it a person at the dashboard, a
|
|
74
|
+
* script somebody wrote, or an agent running unattended. So the kind is recorded on the connection when
|
|
75
|
+
* it is registered and stamped from there onto every row its calls write.
|
|
76
|
+
*
|
|
77
|
+
* It follows the path, never a guess: the device-code flow is a dashboard, always, and a key the
|
|
78
|
+
* operator registered themselves is whichever of the other two they say, out loud, with `--kind`.
|
|
79
|
+
*/
|
|
80
|
+
export const ControlPlaneActorKind = z
|
|
81
|
+
.enum(["agent", "dashboard", "script"])
|
|
82
|
+
.describe(
|
|
83
|
+
"What kind of client a connection is for: `agent` (an automated client acting on its own judgment, often unattended), `dashboard` (a management dashboard a person drives, registered through the device-code flow), or `script` (an operator's own automation, doing one fixed thing).",
|
|
84
|
+
);
|
|
85
|
+
export type ControlPlaneActorKind = z.output<typeof ControlPlaneActorKind>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Who sanctioned a connection: the email address of the signed-in Cloudflare session that registered it.
|
|
89
|
+
*
|
|
90
|
+
* **A plain string, deliberately not a reference.** An adopter's `pithy_auth_users` are their customers,
|
|
91
|
+
* and the operator who registers a connection is their staff, so a join into the organization model would
|
|
92
|
+
* be empty for most of them. A string is readable by anybody, and matchable against a directory where the
|
|
93
|
+
* operator is in one.
|
|
94
|
+
*
|
|
95
|
+
* **Derived, never asserted.** No flag supplies it; the CLI reads it from `GET /user` under the session.
|
|
96
|
+
*/
|
|
97
|
+
export const ConnectionPrincipal = z
|
|
98
|
+
.email()
|
|
99
|
+
.max(254)
|
|
100
|
+
.describe(
|
|
101
|
+
"The email address of the person who sanctioned this connection, read from their signed-in Cloudflare session at registration. Never typed, so never asserted.",
|
|
102
|
+
);
|
|
103
|
+
export type ConnectionPrincipal = z.output<typeof ConnectionPrincipal>;
|
|
104
|
+
|
|
105
|
+
/** The longest purpose a connection may carry. One line on a review screen, with room to say what it does. */
|
|
106
|
+
export const MAX_CONNECTION_PURPOSE = 200;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Characters a purpose may not carry: every C0 and C1 control (so no terminal escape, no newline and no
|
|
110
|
+
* carriage return), the Unicode line and paragraph separators, every `Bidi_Control` character — the
|
|
111
|
+
* embeddings, overrides and isolates, and the LRM, RLM and ALM marks — that lets text render in a
|
|
112
|
+
* different order from the one it is stored in, and the invisible zero-width space, word joiner and
|
|
113
|
+
* byte-order mark that split a word without showing it. The zero-width joiner and non-joiner stay: Persian
|
|
114
|
+
* and the Indic scripts cannot be written without them.
|
|
115
|
+
*
|
|
116
|
+
* Each of those is a way for a sentence to look like something else once it reaches a renderer: a line
|
|
117
|
+
* in `pithy dashboard status` forged beneath the real one, a screen cleared, a scope name read backwards.
|
|
118
|
+
* Escaping them at every renderer is a promise each renderer has to keep. Refusing them at the boundary
|
|
119
|
+
* is one promise, kept here.
|
|
120
|
+
*/
|
|
121
|
+
const UNRENDERABLE = /[\p{Cc}\p{Bidi_Control}\u2028\u2029\u200B\u2060\uFEFF]/u;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What a connection is for, in the words of whoever registered it (#721).
|
|
125
|
+
*
|
|
126
|
+
* **Required at registration, and refused when absent**, because a review listing credentials by key id
|
|
127
|
+
* cannot support the decision it exists for. **Untrusted input**: a customer writes it and something else
|
|
128
|
+
* renders it, so it is validated on the way in, and again on every read of the row.
|
|
129
|
+
*/
|
|
130
|
+
export const ConnectionPurpose = z
|
|
131
|
+
.string()
|
|
132
|
+
.trim()
|
|
133
|
+
.min(1, "Say what this client is for.")
|
|
134
|
+
.max(MAX_CONNECTION_PURPOSE, `A purpose is ${MAX_CONNECTION_PURPOSE} characters at most.`)
|
|
135
|
+
.refine(
|
|
136
|
+
(purpose) => !UNRENDERABLE.test(purpose),
|
|
137
|
+
"A purpose is one line of plain text: no control, bidi or invisible characters.",
|
|
138
|
+
)
|
|
139
|
+
.describe(
|
|
140
|
+
`What this connection's client is for, in the registering operator's words — one line of plain text, ${MAX_CONNECTION_PURPOSE} characters at most, with no control, bidi or invisible characters. Written by a customer and rendered elsewhere, so validated at the boundary.`,
|
|
141
|
+
);
|
|
142
|
+
export type ConnectionPurpose = z.output<typeof ConnectionPurpose>;
|
|
143
|
+
|
|
69
144
|
/**
|
|
70
145
|
* One management-client connection — the row in `pithy_controlplane_connections`.
|
|
71
146
|
*
|
|
@@ -112,6 +187,21 @@ export const ControlPlaneConnection = z
|
|
|
112
187
|
.describe(
|
|
113
188
|
"Where the management client that registered this connection answers — the origin the CLI calls for a verify, a rotation or a disconnect. Stored because it is not derivable: `issuer` is what that client *signs as*, and nothing says a dashboard's API answers at its own `iss`. Null for a connection registered before this column, which reads as the issuer rather than as the hosted dashboard — an older row is still a row about somebody's own deployment. Nothing on the Worker's side reads it; verification cares about `issuer` alone.",
|
|
114
189
|
),
|
|
190
|
+
authorizedBy: ConnectionPrincipal.nullable()
|
|
191
|
+
.default(null)
|
|
192
|
+
.describe(
|
|
193
|
+
"Who sanctioned this connection: the email of the signed-in Cloudflare session that registered it, never a flag. Null for a connection registered before principals were recorded (#721), which keeps working and reads as exactly that; registering again is the only way to set it.",
|
|
194
|
+
),
|
|
195
|
+
actorKind: ControlPlaneActorKind.nullable()
|
|
196
|
+
.default(null)
|
|
197
|
+
.describe(
|
|
198
|
+
"What kind of client this connection is for — `agent`, `dashboard` or `script` — stamped onto every audit row its calls write so the trail can tell them apart. Null for a connection registered before it was recorded.",
|
|
199
|
+
),
|
|
200
|
+
purpose: ConnectionPurpose.nullable()
|
|
201
|
+
.default(null)
|
|
202
|
+
.describe(
|
|
203
|
+
"What this connection's client is for, in the registering operator's words. Required at registration and editable with `pithy dashboard connect --update --purpose`; null only for a connection registered before it was recorded.",
|
|
204
|
+
),
|
|
115
205
|
scopes: sqliteJson(z.array(ControlPlaneScope)).describe(
|
|
116
206
|
"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.",
|
|
117
207
|
),
|
|
@@ -217,10 +217,10 @@ export function corsMiddleware(
|
|
|
217
217
|
/**
|
|
218
218
|
* Mount one `app.use(path, …)` per entry.
|
|
219
219
|
*
|
|
220
|
-
* **Called from
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
220
|
+
* **Called from `createBackend` ahead of every capability's `middleware`, never from a `routes` hook,
|
|
221
|
+
* and that is load-bearing.** Registered there, it is independent of the order an adopter happens to
|
|
222
|
+
* list their capabilities in, and it can put a header on whatever a capability's middleware or a
|
|
223
|
+
* readiness guard short-circuits.
|
|
224
224
|
*
|
|
225
225
|
* From `routes` it looks like it works and does not. The preflight still answers — an `OPTIONS` has no
|
|
226
226
|
* route handler to lose to, so this `use` is the only thing that matches it — but a capability composed
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import type { MiddlewareHandler } from "hono";
|
|
5
|
+
import type { AuditEmit } from "../../audit/recorder";
|
|
5
6
|
import type { PithyHonoEnv } from "../../capability/capability";
|
|
6
7
|
import { commandName, remedy } from "../../cli/command";
|
|
7
8
|
import { type AmbientEnv, compositionEnvironment } from "../../env/ambient";
|
|
@@ -9,6 +10,7 @@ import { PithyError } from "../../error/pithyError";
|
|
|
9
10
|
import { workerVersionMetadata } from "../../worker/identity";
|
|
10
11
|
import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
|
|
11
12
|
import type { ControlPlaneContext } from "../context";
|
|
13
|
+
import type { ControlPlaneActorKind } from "../data/connection";
|
|
12
14
|
import { ControlPlaneNotConnectedError } from "../error/errors";
|
|
13
15
|
import type { ControlPlaneRequirement } from "../scope/scope";
|
|
14
16
|
import { CONTROL_PLANE_HEADER, CONTROL_PLANE_VERSION_CREATED_HEADER, CONTROL_PLANE_VERSION_HEADER } from "../wire";
|
|
@@ -156,6 +158,11 @@ export function requireControlPlane(requirement: ControlPlaneRequirement): Middl
|
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
c.set("controlPlane", context);
|
|
161
|
+
// Every control-plane row this call writes says which kind of client wrote it (#721). Stamped here,
|
|
162
|
+
// once, rather than at each of the dozens of emit sites across the capabilities, because a rule kept
|
|
163
|
+
// at the call sites regrows the moment someone adds one — and `actorType` alone names an agent, the
|
|
164
|
+
// dashboard and a script identically.
|
|
165
|
+
c.set("emit", stampActorKind(c.var.emit, context.actorKind));
|
|
159
166
|
await safeEmit(
|
|
160
167
|
c.var.emit,
|
|
161
168
|
{
|
|
@@ -185,6 +192,20 @@ export function requireControlPlane(requirement: ControlPlaneRequirement): Middl
|
|
|
185
192
|
return guard;
|
|
186
193
|
}
|
|
187
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The recorder a verified call's handlers write through: the request's own, with the connection's actor
|
|
197
|
+
* kind added to every `control-plane` event that does not already carry one.
|
|
198
|
+
*
|
|
199
|
+
* Only `control-plane` events, because the kind describes a management client and nothing else — a
|
|
200
|
+
* handler recording what happened to a user is recording a user. An event that states a kind keeps it.
|
|
201
|
+
* A connection with no recorded kind adds nothing, so its rows read exactly as they did before.
|
|
202
|
+
*/
|
|
203
|
+
export function stampActorKind(emit: AuditEmit, actorKind: ControlPlaneActorKind | null): AuditEmit {
|
|
204
|
+
if (actorKind === null) return emit;
|
|
205
|
+
return (event) =>
|
|
206
|
+
emit(event.actorType === "control-plane" && event.actorKind == null ? { ...event, actorKind } : event);
|
|
207
|
+
}
|
|
208
|
+
|
|
188
209
|
/**
|
|
189
210
|
* Record a refused call.
|
|
190
211
|
*
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { Context } from "hono";
|
|
5
5
|
import type { PithyHonoEnv } from "../../capability/capability";
|
|
6
|
+
import { causeMessage } from "../../error/cause";
|
|
6
7
|
import { workerVersion } from "../../worker/identity";
|
|
7
8
|
import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
|
|
8
9
|
import type { ControlPlaneConfig } from "../config/config";
|
|
@@ -14,8 +15,10 @@ import type { CapabilityDeclaration, ControlPlaneManifestWire } from "../discove
|
|
|
14
15
|
import { type CapabilityHealthSource, readCapabilityHealth } from "../discovery/health";
|
|
15
16
|
import { healthWire } from "../discovery/healthSummary";
|
|
16
17
|
import { ControlPlaneInvalidCredentialError, ControlPlaneKeyConflictError } from "../error/errors";
|
|
18
|
+
import { type ComposedMigrationDatabase, readDatabaseMigrations } from "../migrationState/migrationState";
|
|
17
19
|
import type {
|
|
18
20
|
ControlPlaneKeysResponse,
|
|
21
|
+
ControlPlaneMigrationsResponse,
|
|
19
22
|
ControlPlanePingResponse,
|
|
20
23
|
ExpireKeyResponse,
|
|
21
24
|
PublicKeyView,
|
|
@@ -68,6 +71,11 @@ export interface ControlPlaneHandlerDeps {
|
|
|
68
71
|
* value is not: the numbers are resolved per request, behind the scope each one is declared under.
|
|
69
72
|
*/
|
|
70
73
|
composedHealth: () => ReadonlyMap<string, CapabilityHealthSource>;
|
|
74
|
+
/**
|
|
75
|
+
* Every database this Worker binds, with the provider this build composes for it — captured by the same
|
|
76
|
+
* `compose` hook through `collectMigrationSets`, the function `pithy migrate` composes with (#729).
|
|
77
|
+
*/
|
|
78
|
+
composedMigrations: () => readonly ComposedMigrationDatabase[];
|
|
71
79
|
/** The clock, injected so a test can stand at any instant. */
|
|
72
80
|
now: () => Date;
|
|
73
81
|
}
|
|
@@ -203,6 +211,34 @@ export function manifestHandler(deps: ControlPlaneHandlerDeps) {
|
|
|
203
211
|
};
|
|
204
212
|
}
|
|
205
213
|
|
|
214
|
+
/**
|
|
215
|
+
* `GET /control-plane/migrations` — where each database this Worker binds stands (#729).
|
|
216
|
+
*
|
|
217
|
+
* Read-only: it reports, and nothing over the seam runs, reverses or resets a migration.
|
|
218
|
+
*
|
|
219
|
+
* **One unreadable ledger costs one database its answer.** Each read catches its own failure and lands on
|
|
220
|
+
* `unreadable`, so `Promise.all` still resolves and the route answers 200 with what it has. The cause is
|
|
221
|
+
* logged here, for the operator, and never projected: a D1 error can name a table or a query.
|
|
222
|
+
*/
|
|
223
|
+
export function migrationsHandler(deps: ControlPlaneHandlerDeps) {
|
|
224
|
+
return async (c: Context<PithyHonoEnv>) => {
|
|
225
|
+
const context = caller(c);
|
|
226
|
+
const env = c.env as Record<string, unknown>;
|
|
227
|
+
const databases = await Promise.all(
|
|
228
|
+
deps.composedMigrations().map((entry) =>
|
|
229
|
+
readDatabaseMigrations(env, entry, (failed, cause) =>
|
|
230
|
+
c.var.log.warn("migration ledger unreadable", {
|
|
231
|
+
database: failed.database,
|
|
232
|
+
binding: failed.binding,
|
|
233
|
+
reason: causeMessage(cause) ?? String(cause),
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
return c.json({ environment: context.environment, databases } satisfies ControlPlaneMigrationsResponse);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
206
242
|
/** `GET /control-plane/keys` — the registration state, so a client can surface a stale key. */
|
|
207
243
|
export function listKeysHandler(deps: ControlPlaneHandlerDeps) {
|
|
208
244
|
return async (c: Context<PithyHonoEnv>) => {
|
|
@@ -212,6 +248,11 @@ export function listKeysHandler(deps: ControlPlaneHandlerDeps) {
|
|
|
212
248
|
connectionId: connection.id,
|
|
213
249
|
environment: connection.environment,
|
|
214
250
|
keys: connection.keys.map(publicKeyView),
|
|
251
|
+
// The review a credential listing exists for needs these, and it is the adopter's to perform —
|
|
252
|
+
// on their own side, from their own row (#721).
|
|
253
|
+
authorizedBy: connection.authorizedBy,
|
|
254
|
+
actorKind: connection.actorKind,
|
|
255
|
+
purpose: connection.purpose,
|
|
215
256
|
} satisfies ControlPlaneKeysResponse);
|
|
216
257
|
};
|
|
217
258
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Hono } from "hono";
|
|
5
|
+
import type { PithyHonoEnv, PithyReadinessGuard } from "../../capability/capability";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mount every capability's readiness guard, exempting the admin surface.
|
|
9
|
+
*
|
|
10
|
+
* **The control plane answers whatever else is wrong with the Worker.** It is the diagnostic surface: a
|
|
11
|
+
* management client reads the manifest, migration state and drift through it. A guard that refuses every
|
|
12
|
+
* request because an index drifted would take that surface down on exactly the fault it exists to report,
|
|
13
|
+
* and the client could not tell drift from a Worker that is simply down.
|
|
14
|
+
*
|
|
15
|
+
* **The exemption is the guard, not the middleware.** The seam itself runs on capability middleware —
|
|
16
|
+
* `controlplane()` publishes the verifier and `audit()` publishes `emit` — so skipping middleware on a
|
|
17
|
+
* seam request would trade a 500 for a seam that denies everything, or one that stops auditing without a
|
|
18
|
+
* word. Readiness guards are the only middleware that refuses because the Worker is broken, so they are
|
|
19
|
+
* the only thing skipped.
|
|
20
|
+
*
|
|
21
|
+
* **Hono decides what is on the surface, not a matcher written here.** One `app.use` per admin path marks
|
|
22
|
+
* the request, so a `:segment` matches exactly as it does for the route and the CORS layer, and a literal
|
|
23
|
+
* path never matches what is beneath it. The surface is the one `corsSurface(all)` computes, which
|
|
24
|
+
* `routeContract.test.ts` already holds to the registered routes, so there is no second list to drift.
|
|
25
|
+
*
|
|
26
|
+
* The mark is a `WeakSet` over the raw `Request`, closed over here. A context variable would put an
|
|
27
|
+
* internal flag on `PithyVars`, where every capability could read it and one could set it.
|
|
28
|
+
*
|
|
29
|
+
* Call after every capability's `middleware` and before any capability's `routes`. Before the routes,
|
|
30
|
+
* because a terminal handler never calls `next()` and a guard mounted after it never runs.
|
|
31
|
+
*/
|
|
32
|
+
export function registerReadinessGuards(
|
|
33
|
+
app: Hono<PithyHonoEnv>,
|
|
34
|
+
surface: readonly { path: string }[],
|
|
35
|
+
guards: readonly PithyReadinessGuard[],
|
|
36
|
+
): void {
|
|
37
|
+
if (guards.length === 0) return;
|
|
38
|
+
|
|
39
|
+
const onSurface = new WeakSet<Request>();
|
|
40
|
+
for (const path of new Set(surface.map((entry) => entry.path))) {
|
|
41
|
+
app.use(path, async (c, next) => {
|
|
42
|
+
onSurface.add(c.req.raw);
|
|
43
|
+
await next();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const guard of guards) {
|
|
48
|
+
app.use("*", (c, next) => (onSurface.has(c.req.raw) ? next() : guard(c, next)));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { Ed25519PublicJwk } from "../data/connection";
|
|
5
|
+
import { ConnectionPrincipal, ConnectionPurpose, ControlPlaneActorKind, Ed25519PublicJwk } from "../data/connection";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* What the seam's own routes return, as Zod objects a management client can validate against.
|
|
@@ -65,8 +65,25 @@ export const ControlPlaneKeysResponse = z
|
|
|
65
65
|
connectionId: z.string().describe("The connection these keys belong to."),
|
|
66
66
|
environment: z.string().describe("The environment that connection is bound to."),
|
|
67
67
|
keys: z.array(PublicKeyView).describe("Every registered key, expired and revoked ones included."),
|
|
68
|
+
// Added (#721), so `.optional()` as well as `.nullable()`: a client validating against a Worker that
|
|
69
|
+
// predates the three columns receives none of them, and must still parse the answer.
|
|
70
|
+
authorizedBy: ConnectionPrincipal.nullable()
|
|
71
|
+
.optional()
|
|
72
|
+
.describe(
|
|
73
|
+
"Who sanctioned this connection — the signed-in session's email at registration. Null for a connection registered before principals were recorded; absent from a Worker older than the field.",
|
|
74
|
+
),
|
|
75
|
+
actorKind: ControlPlaneActorKind.nullable()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe(
|
|
78
|
+
"What kind of client this connection is for: `agent`, `dashboard` or `script`. Null for a connection registered before it was recorded; absent from an older Worker.",
|
|
79
|
+
),
|
|
80
|
+
purpose: ConnectionPurpose.nullable()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe(
|
|
83
|
+
"What the client is for, in the registering operator's words. Untrusted text: render it escaped. Null for a connection registered before it was recorded; absent from an older Worker.",
|
|
84
|
+
),
|
|
68
85
|
})
|
|
69
|
-
.describe("Every key registered against this connection, with its window.");
|
|
86
|
+
.describe("Every key registered against this connection, with its window, and who authorized it for what.");
|
|
70
87
|
export type ControlPlaneKeysResponse = z.output<typeof ControlPlaneKeysResponse>;
|
|
71
88
|
|
|
72
89
|
/** `POST /control-plane/keys` — the appended key, and the set as it now stands. */
|
|
@@ -90,3 +107,124 @@ export const ExpireKeyResponse = z
|
|
|
90
107
|
})
|
|
91
108
|
.describe("The expired key, and the set it left behind.");
|
|
92
109
|
export type ExpireKeyResponse = z.output<typeof ExpireKeyResponse>;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One migration as `GET {basePath}/migrations` reports it: its composed name, and whose it is.
|
|
113
|
+
*
|
|
114
|
+
* The namespace is a capability's name, which the manifest already reports in `capabilities[]`. So a
|
|
115
|
+
* client tells an adopter's own migrations from a composed capability's by comparing the two — no
|
|
116
|
+
* convention, and no list kept anywhere.
|
|
117
|
+
*/
|
|
118
|
+
export const MigrationView = z
|
|
119
|
+
.object({
|
|
120
|
+
name: z.string().describe("The composed migration name, exactly as the ledger records it: `0250_audit_0001_init`."),
|
|
121
|
+
namespace: z
|
|
122
|
+
.string()
|
|
123
|
+
.nullable()
|
|
124
|
+
.describe(
|
|
125
|
+
"The capability this migration belongs to — its name, as `capabilities[]` in the manifest reports it. Null only for an undeclared migration whose name no Pithy registry could have composed.",
|
|
126
|
+
),
|
|
127
|
+
})
|
|
128
|
+
.describe("One migration, and the capability namespace it belongs to.");
|
|
129
|
+
export type MigrationView = z.output<typeof MigrationView>;
|
|
130
|
+
|
|
131
|
+
/** Whether the last group is still reversible by name, or what is in its way. */
|
|
132
|
+
export const MigrationGroupPositionView = z
|
|
133
|
+
.discriminatedUnion("state", [
|
|
134
|
+
z
|
|
135
|
+
.object({
|
|
136
|
+
state: z
|
|
137
|
+
.literal("top")
|
|
138
|
+
.describe(
|
|
139
|
+
"The group's migrations are the contiguous tip of this database's chain. Not a promise that `pithy migrate --rollback --group <value>` runs: the CLI also refuses an undeclared migration in the ledger, and keeps back or refuses a database another environment binds.",
|
|
140
|
+
),
|
|
141
|
+
})
|
|
142
|
+
.describe("A group nothing on this database's ledger sits over."),
|
|
143
|
+
z
|
|
144
|
+
.object({
|
|
145
|
+
state: z
|
|
146
|
+
.literal("buried")
|
|
147
|
+
.describe("Something sits over the group or inside it, so reversing it by name is refused."),
|
|
148
|
+
above: z.array(z.string()).describe("The groups in the way, newest first."),
|
|
149
|
+
ungrouped: z
|
|
150
|
+
.array(MigrationView)
|
|
151
|
+
.describe(
|
|
152
|
+
"The migrations in the way that no group claims, newest first. Nothing can be reversed by group while one of them is at the tip.",
|
|
153
|
+
),
|
|
154
|
+
})
|
|
155
|
+
.describe("A group with something in its way, and what that is."),
|
|
156
|
+
])
|
|
157
|
+
.describe("Where the last group sits in this database's chain: the top, or buried under named things.");
|
|
158
|
+
export type MigrationGroupPositionView = z.output<typeof MigrationGroupPositionView>;
|
|
159
|
+
|
|
160
|
+
/** The run that last moved a database, and whether it can still be taken back. */
|
|
161
|
+
export const MigrationGroupView = z
|
|
162
|
+
.object({
|
|
163
|
+
group: z
|
|
164
|
+
.string()
|
|
165
|
+
.describe("The group's value: the `--group` the run was given, or the ISO-8601 timestamp it generated."),
|
|
166
|
+
appliedAt: z.iso.datetime().describe("When this group last applied something here, ISO-8601."),
|
|
167
|
+
migrations: z
|
|
168
|
+
.array(MigrationView)
|
|
169
|
+
.describe("What this group applied in this database and is still applied, in application order."),
|
|
170
|
+
position: MigrationGroupPositionView.describe("Whether the group is the top of the chain, or what is in its way."),
|
|
171
|
+
})
|
|
172
|
+
.describe("The newest group with anything still applied in this database.");
|
|
173
|
+
export type MigrationGroupView = z.output<typeof MigrationGroupView>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One database this Worker binds, read or not.
|
|
177
|
+
*
|
|
178
|
+
* **An unreadable database carries no list at all**, never empty ones. `pending: []` reads as up to date,
|
|
179
|
+
* and a database nobody read is not that. The state is on the value, so a client reaches a list only by
|
|
180
|
+
* narrowing.
|
|
181
|
+
*/
|
|
182
|
+
export const MigrationDatabaseView = z
|
|
183
|
+
.discriminatedUnion("state", [
|
|
184
|
+
z
|
|
185
|
+
.object({
|
|
186
|
+
state: z.literal("readable").describe("This database's ledger was read."),
|
|
187
|
+
database: z.string().describe("The database name, as capabilities declare it in `databases`."),
|
|
188
|
+
binding: z.string().describe("The D1 binding it is read through, as `wrangler.jsonc` declares it."),
|
|
189
|
+
applied: z.array(MigrationView).describe("Every applied migration, in the order the ledger applied them."),
|
|
190
|
+
pending: z
|
|
191
|
+
.array(MigrationView)
|
|
192
|
+
.describe("Declared by this build and not applied here, in the order they would run."),
|
|
193
|
+
undeclared: z
|
|
194
|
+
.array(MigrationView)
|
|
195
|
+
.describe(
|
|
196
|
+
"Applied here and not declared by this Worker, sorted. Relative to this Worker: a database shared with another Worker lists that Worker's migrations here. Otherwise Kysely reads one as a corrupted chain and applies nothing.",
|
|
197
|
+
),
|
|
198
|
+
lastGroup: MigrationGroupView.nullable().describe(
|
|
199
|
+
"The newest group with anything applied here, or null when nothing applied here was ever grouped.",
|
|
200
|
+
),
|
|
201
|
+
})
|
|
202
|
+
.describe("A database whose ledger was read."),
|
|
203
|
+
z
|
|
204
|
+
.object({
|
|
205
|
+
state: z.literal("unreadable").describe("This database's ledger could not be read on this call."),
|
|
206
|
+
database: z.string().describe("The database name, as capabilities declare it in `databases`."),
|
|
207
|
+
binding: z.string().describe("The D1 binding it would be read through."),
|
|
208
|
+
})
|
|
209
|
+
.describe(
|
|
210
|
+
"A database that could not be read. No lists, so nothing about it reads as zero. The other databases still answer.",
|
|
211
|
+
),
|
|
212
|
+
])
|
|
213
|
+
.describe("One database this Worker binds: its migration state, or the fact that it could not be read.");
|
|
214
|
+
export type MigrationDatabaseView = z.output<typeof MigrationDatabaseView>;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `GET /control-plane/migrations` — where every database this Worker binds stands.
|
|
218
|
+
*
|
|
219
|
+
* Asked of the Worker because the Worker *is* the deployed build. `pithy doctor` reads the project on disk,
|
|
220
|
+
* which is what the operator has and not necessarily what is deployed.
|
|
221
|
+
*/
|
|
222
|
+
export const ControlPlaneMigrationsResponse = z
|
|
223
|
+
.object({
|
|
224
|
+
environment: z.string().describe("The environment this Worker answers for."),
|
|
225
|
+
databases: z
|
|
226
|
+
.array(MigrationDatabaseView)
|
|
227
|
+
.describe("One entry per database this Worker binds, sorted by database name. One unreadable costs only itself."),
|
|
228
|
+
})
|
|
229
|
+
.describe("What is applied, pending and undeclared in each bound database, and which run put it there.");
|
|
230
|
+
export type ControlPlaneMigrationsResponse = z.output<typeof ControlPlaneMigrationsResponse>;
|
|
@@ -6,18 +6,20 @@ import type { PithyHonoEnv } from "../../capability/capability";
|
|
|
6
6
|
import { describeRoute } from "../../http/routeSpec";
|
|
7
7
|
import { validate } from "../../http/validation";
|
|
8
8
|
import { type AdminRoute, ControlPlaneManifest } from "../discovery/adminRoute";
|
|
9
|
-
import { ANY_VERIFIED_CALLER, KEYS_ROTATE_SCOPE, MANIFEST_READ_SCOPE } from "../scope/scope";
|
|
9
|
+
import { ANY_VERIFIED_CALLER, KEYS_ROTATE_SCOPE, MANIFEST_READ_SCOPE, MIGRATIONS_READ_SCOPE } from "../scope/scope";
|
|
10
10
|
import { requireControlPlane } from "./guard";
|
|
11
11
|
import {
|
|
12
12
|
type ControlPlaneHandlerDeps,
|
|
13
13
|
expireKeyHandler,
|
|
14
14
|
listKeysHandler,
|
|
15
15
|
manifestHandler,
|
|
16
|
+
migrationsHandler,
|
|
16
17
|
pingHandler,
|
|
17
18
|
registerKeyHandler,
|
|
18
19
|
} from "./handlers";
|
|
19
20
|
import {
|
|
20
21
|
ControlPlaneKeysResponse,
|
|
22
|
+
ControlPlaneMigrationsResponse,
|
|
21
23
|
ControlPlanePingResponse,
|
|
22
24
|
ExpireKeyResponse,
|
|
23
25
|
RegisterKeyResponse,
|
|
@@ -32,6 +34,7 @@ import { ExpireKeyParams, ExpireKeyRequest, RegisterKeyRequest } from "./schemas
|
|
|
32
34
|
* GET /control-plane/keys → the registration state (keys:rotate)
|
|
33
35
|
* POST /control-plane/keys → register a key (keys:rotate)
|
|
34
36
|
* POST /control-plane/keys/:keyId/expire → retire a superseded key (keys:rotate)
|
|
37
|
+
* GET /control-plane/migrations → where each database stands (migrations:read)
|
|
35
38
|
*
|
|
36
39
|
* Every one is `control-plane` and default-denied — with no connection registered they all answer 403,
|
|
37
40
|
* and that is the shipped state of a Worker nobody has connected.
|
|
@@ -90,6 +93,13 @@ export function controlPlaneRouteDescriptors(basePath: string): AdminRoute[] {
|
|
|
90
93
|
scope: KEYS_ROTATE_SCOPE,
|
|
91
94
|
summary: "Retire a superseded key. Must be signed with the successor it names as proven.",
|
|
92
95
|
},
|
|
96
|
+
{
|
|
97
|
+
method: "GET",
|
|
98
|
+
path: `${basePath}/migrations`,
|
|
99
|
+
scope: MIGRATIONS_READ_SCOPE,
|
|
100
|
+
summary:
|
|
101
|
+
"What each bound database has applied, what is pending, what is undeclared, and which run put it there. Undeclared is relative to this Worker.",
|
|
102
|
+
},
|
|
93
103
|
];
|
|
94
104
|
}
|
|
95
105
|
|
|
@@ -160,4 +170,18 @@ export function registerControlPlaneRoutes(
|
|
|
160
170
|
validate("json", ExpireKeyRequest),
|
|
161
171
|
(c) => expireKeyHandler(deps)(c, c.req.valid("param"), c.req.valid("json")),
|
|
162
172
|
);
|
|
173
|
+
|
|
174
|
+
app.get(
|
|
175
|
+
`${basePath}/migrations`,
|
|
176
|
+
describeRoute({
|
|
177
|
+
summary: "Report where each database this Worker binds stands.",
|
|
178
|
+
description:
|
|
179
|
+
"Applied, pending and undeclared migrations per database, each naming its namespace, and the last group with whether it is still reversible. Undeclared is relative to this Worker: a database shared with another Worker lists that Worker's migrations there. Read-only.",
|
|
180
|
+
verification: "control-plane",
|
|
181
|
+
scopes: [MIGRATIONS_READ_SCOPE],
|
|
182
|
+
responses: { 200: ControlPlaneMigrationsResponse },
|
|
183
|
+
}),
|
|
184
|
+
requireControlPlane(MIGRATIONS_READ_SCOPE),
|
|
185
|
+
migrationsHandler(deps),
|
|
186
|
+
);
|
|
163
187
|
}
|
|
@@ -178,6 +178,8 @@ export async function verifyControlPlaneCall(
|
|
|
178
178
|
grantedScopes: connection.scopes,
|
|
179
179
|
keyId: key.keyId,
|
|
180
180
|
tokenId: claims.jti,
|
|
181
|
+
// From the adopter's row, never the token: a client does not get to say what kind of client it is.
|
|
182
|
+
actorKind: connection.actorKind,
|
|
181
183
|
});
|
|
182
184
|
}
|
|
183
185
|
|