@pithy-sh/core 0.10.0 → 0.12.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/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- 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 +110 -28
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- 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/purge.d.ts +80 -0
- package/dist/capability/purge.d.ts.map +1 -0
- package/dist/capability/purge.js +76 -0
- 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 +238 -24
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +251 -22
- package/dist/cli/commandFacts.d.ts +2 -1
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +2 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +19 -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 +49 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +56 -2
- package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +3 -0
- package/dist/controlPlane/error/errors.d.ts +4 -3
- package/dist/controlPlane/error/errors.d.ts.map +1 -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 +139 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +67 -4
- 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 +245 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +101 -4
- package/dist/controlPlane/http/routes.d.ts +2 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +31 -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 +23 -2
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +24 -3
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +9 -2
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +5 -3
- package/dist/crypto/envelope.d.ts +86 -0
- package/dist/crypto/envelope.d.ts.map +1 -0
- package/dist/crypto/envelope.js +129 -0
- package/dist/crypto/fieldKeys.d.ts +41 -0
- package/dist/crypto/fieldKeys.d.ts.map +1 -0
- package/dist/crypto/fieldKeys.js +56 -0
- package/dist/data/codecs.d.ts +23 -10
- package/dist/data/codecs.d.ts.map +1 -1
- package/dist/data/codecs.js +50 -36
- 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/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +168 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +336 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +116 -4
- package/dist/error/pithyError.d.ts +18 -7
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +6 -0
- 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/logger.d.ts +12 -5
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/record.d.ts +98 -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/privacy/blindIndex.d.ts +27 -0
- package/dist/privacy/blindIndex.d.ts.map +1 -0
- package/dist/privacy/blindIndex.js +57 -0
- package/dist/privacy/classes.d.ts +142 -0
- package/dist/privacy/classes.d.ts.map +1 -0
- package/dist/privacy/classes.js +209 -0
- package/dist/privacy/classify.d.ts +336 -0
- package/dist/privacy/classify.d.ts.map +1 -0
- package/dist/privacy/classify.js +1024 -0
- package/dist/privacy/encrypted.d.ts +210 -0
- package/dist/privacy/encrypted.d.ts.map +1 -0
- package/dist/privacy/encrypted.js +350 -0
- package/dist/privacy/encryptedColumns.d.ts +64 -0
- package/dist/privacy/encryptedColumns.d.ts.map +1 -0
- package/dist/privacy/encryptedColumns.js +134 -0
- package/dist/privacy/fieldRotation.d.ts +34 -0
- package/dist/privacy/fieldRotation.d.ts.map +1 -0
- package/dist/privacy/fieldRotation.js +186 -0
- package/dist/privacy/inventory.d.ts +12 -0
- package/dist/privacy/inventory.d.ts.map +1 -0
- package/dist/privacy/inventory.js +154 -0
- package/dist/privacy/posture.d.ts +114 -0
- package/dist/privacy/posture.d.ts.map +1 -0
- package/dist/privacy/posture.js +136 -0
- package/dist/privacy/redact.d.ts +7 -0
- package/dist/privacy/redact.d.ts.map +1 -0
- package/dist/privacy/redact.js +404 -0
- package/dist/privacy/report.d.ts +58 -0
- package/dist/privacy/report.d.ts.map +1 -0
- package/dist/privacy/report.js +15 -0
- package/dist/privacy/retirement.d.ts +68 -0
- package/dist/privacy/retirement.d.ts.map +1 -0
- package/dist/privacy/retirement.js +72 -0
- package/dist/privacy/sealed.d.ts +87 -0
- package/dist/privacy/sealed.d.ts.map +1 -0
- package/dist/privacy/sealed.js +221 -0
- package/dist/privacy/types.d.ts +559 -0
- package/dist/privacy/types.d.ts.map +1 -0
- package/dist/privacy/types.js +2 -0
- 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/audit/recorder.ts +10 -2
- package/src/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +114 -29
- package/src/capability/purge.ts +124 -0
- package/src/capability/settings.ts +146 -2
- package/src/cli/commandCatalog.generated.ts +271 -22
- package/src/cli/commandFacts.ts +2 -0
- package/src/controlPlane/capability.ts +36 -1
- package/src/controlPlane/context.ts +4 -0
- package/src/controlPlane/data/connection.ts +90 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +21 -0
- package/src/controlPlane/http/handlers.ts +98 -4
- package/src/controlPlane/http/readiness.ts +50 -0
- package/src/controlPlane/http/responses.ts +240 -2
- package/src/controlPlane/http/routes.ts +55 -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 +25 -2
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +28 -6
- package/src/createEntrypoint.ts +9 -3
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/entitlement/allowance.ts +85 -0
- package/src/entitlement/entitlement.ts +38 -0
- package/src/entitlement/meter.ts +69 -0
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +253 -4
- package/src/error/pithyError.ts +21 -7
- package/src/http/authContext.ts +13 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/index.ts +2 -0
- package/src/logger/logger.ts +12 -5
- 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/privacy/blindIndex.ts +81 -0
- package/src/privacy/classes.ts +277 -0
- package/src/privacy/classify.ts +1447 -0
- package/src/privacy/encrypted.ts +513 -0
- package/src/privacy/encryptedColumns.ts +185 -0
- package/src/privacy/fieldRotation.ts +234 -0
- package/src/privacy/inventory.ts +147 -0
- package/src/privacy/posture.ts +205 -0
- package/src/privacy/redact.ts +423 -0
- package/src/privacy/report.ts +64 -0
- package/src/privacy/retirement.ts +102 -0
- package/src/privacy/sealed.ts +323 -0
- package/src/privacy/types.ts +804 -0
- 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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { commandName } from "../../cli/command";
|
|
5
5
|
import { PithyError } from "../../error/pithyError";
|
|
6
6
|
import type { MessageParams } from "../../i18n/catalog";
|
|
7
|
+
import type { UnmarkedString } from "../../privacy/types";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The `control-plane` strategy's throw sugar. The `controlplane/*` codes live in core's closed
|
|
@@ -20,9 +21,9 @@ import type { MessageParams } from "../../i18n/catalog";
|
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
interface ControlPlaneErrorArgs {
|
|
23
|
-
message?:
|
|
24
|
-
action?:
|
|
25
|
-
detail?:
|
|
24
|
+
message?: UnmarkedString;
|
|
25
|
+
action?: UnmarkedString;
|
|
26
|
+
detail?: UnmarkedString;
|
|
26
27
|
/**
|
|
27
28
|
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
28
29
|
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
@@ -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,7 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import type { Context } from "hono";
|
|
5
5
|
import type { PithyHonoEnv } from "../../capability/capability";
|
|
6
|
-
import {
|
|
6
|
+
import { causeMessage } from "../../error/cause";
|
|
7
|
+
import { privacyAttestation, privacyPosture } from "../../privacy/posture";
|
|
8
|
+
import type { PrivacyInventory } from "../../privacy/report";
|
|
9
|
+
import type { RetirementVerdict } from "../../privacy/retirement";
|
|
10
|
+
import { workerVersionMetadata } from "../../worker/identity";
|
|
7
11
|
import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
|
|
8
12
|
import type { ControlPlaneConfig } from "../config/config";
|
|
9
13
|
import type { ControlPlaneContext } from "../context";
|
|
@@ -14,9 +18,12 @@ import type { CapabilityDeclaration, ControlPlaneManifestWire } from "../discove
|
|
|
14
18
|
import { type CapabilityHealthSource, readCapabilityHealth } from "../discovery/health";
|
|
15
19
|
import { healthWire } from "../discovery/healthSummary";
|
|
16
20
|
import { ControlPlaneInvalidCredentialError, ControlPlaneKeyConflictError } from "../error/errors";
|
|
21
|
+
import { type ComposedMigrationDatabase, readDatabaseMigrations } from "../migrationState/migrationState";
|
|
17
22
|
import type {
|
|
18
23
|
ControlPlaneKeysResponse,
|
|
24
|
+
ControlPlaneMigrationsResponse,
|
|
19
25
|
ControlPlanePingResponse,
|
|
26
|
+
ControlPlanePrivacyResponse,
|
|
20
27
|
ExpireKeyResponse,
|
|
21
28
|
PublicKeyView,
|
|
22
29
|
RegisterKeyResponse,
|
|
@@ -68,6 +75,21 @@ export interface ControlPlaneHandlerDeps {
|
|
|
68
75
|
* value is not: the numbers are resolved per request, behind the scope each one is declared under.
|
|
69
76
|
*/
|
|
70
77
|
composedHealth: () => ReadonlyMap<string, CapabilityHealthSource>;
|
|
78
|
+
/**
|
|
79
|
+
* Every database this Worker binds, with the provider this build composes for it — captured by the same
|
|
80
|
+
* `compose` hook through `collectMigrationSets`, the function `pithy migrate` composes with (#729).
|
|
81
|
+
*/
|
|
82
|
+
composedMigrations: () => readonly ComposedMigrationDatabase[];
|
|
83
|
+
/**
|
|
84
|
+
* The privacy inventory of every storage schema this Worker composed (#132), walked on the first call and
|
|
85
|
+
* cached for the isolate. A thunk, so a Worker whose control plane is never called never walks.
|
|
86
|
+
*/
|
|
87
|
+
privacyInventory: () => PrivacyInventory;
|
|
88
|
+
/**
|
|
89
|
+
* The field key's retirement verdict (#390): the guard over this Worker's encrypted columns, read through its own
|
|
90
|
+
* bindings. Never throws: whatever it cannot read is incomplete coverage.
|
|
91
|
+
*/
|
|
92
|
+
retirement: (env: Record<string, unknown>) => Promise<RetirementVerdict>;
|
|
71
93
|
/** The clock, injected so a test can stand at any instant. */
|
|
72
94
|
now: () => Date;
|
|
73
95
|
}
|
|
@@ -191,18 +213,57 @@ export function manifestHandler(deps: ControlPlaneHandlerDeps) {
|
|
|
191
213
|
),
|
|
192
214
|
})),
|
|
193
215
|
);
|
|
216
|
+
// The build answering this call. Read per request rather than captured at assembly: it is the same
|
|
217
|
+
// binding every log record and audit event reads, and there is one reader for it.
|
|
218
|
+
const build = workerVersionMetadata(c.env);
|
|
194
219
|
return c.json({
|
|
195
220
|
environment: context.environment,
|
|
196
221
|
connectionId: context.connectionId,
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
version
|
|
222
|
+
version: build.id,
|
|
223
|
+
// Beside the id, because the id alone cannot tell a deploy from a rollback (#132): a rollback
|
|
224
|
+
// reactivates a version already seen, and this is the one field that moves backwards when it does.
|
|
225
|
+
versionCreatedAt: build.createdAt,
|
|
226
|
+
// Classes and counts, never field names — those are `GET /privacy`'s, behind their own scope. A project
|
|
227
|
+
// that declared nothing walks nothing; otherwise the walk is the isolate's cached one.
|
|
228
|
+
// The retirement verdict beside it (#390), so a dashboard reads the guard rather than inferring it.
|
|
229
|
+
privacy: {
|
|
230
|
+
...privacyPosture(privacyAttestation(c.env), deps.privacyInventory),
|
|
231
|
+
retirement: await deps.retirement(c.env as Record<string, unknown>),
|
|
232
|
+
},
|
|
200
233
|
capabilities,
|
|
201
234
|
grantedScopes: [...context.grantedScopes],
|
|
202
235
|
} satisfies ControlPlaneManifestWire);
|
|
203
236
|
};
|
|
204
237
|
}
|
|
205
238
|
|
|
239
|
+
/**
|
|
240
|
+
* `GET /control-plane/migrations` — where each database this Worker binds stands (#729).
|
|
241
|
+
*
|
|
242
|
+
* Read-only: it reports, and nothing over the seam runs, reverses or resets a migration.
|
|
243
|
+
*
|
|
244
|
+
* **One unreadable ledger costs one database its answer.** Each read catches its own failure and lands on
|
|
245
|
+
* `unreadable`, so `Promise.all` still resolves and the route answers 200 with what it has. The cause is
|
|
246
|
+
* logged here, for the operator, and never projected: a D1 error can name a table or a query.
|
|
247
|
+
*/
|
|
248
|
+
export function migrationsHandler(deps: ControlPlaneHandlerDeps) {
|
|
249
|
+
return async (c: Context<PithyHonoEnv>) => {
|
|
250
|
+
const context = caller(c);
|
|
251
|
+
const env = c.env as Record<string, unknown>;
|
|
252
|
+
const databases = await Promise.all(
|
|
253
|
+
deps.composedMigrations().map((entry) =>
|
|
254
|
+
readDatabaseMigrations(env, entry, (failed, cause) =>
|
|
255
|
+
c.var.log.warn("migration ledger unreadable", {
|
|
256
|
+
database: failed.database,
|
|
257
|
+
binding: failed.binding,
|
|
258
|
+
reason: causeMessage(cause) ?? String(cause),
|
|
259
|
+
}),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
return c.json({ environment: context.environment, databases } satisfies ControlPlaneMigrationsResponse);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
206
267
|
/** `GET /control-plane/keys` — the registration state, so a client can surface a stale key. */
|
|
207
268
|
export function listKeysHandler(deps: ControlPlaneHandlerDeps) {
|
|
208
269
|
return async (c: Context<PithyHonoEnv>) => {
|
|
@@ -212,6 +273,11 @@ export function listKeysHandler(deps: ControlPlaneHandlerDeps) {
|
|
|
212
273
|
connectionId: connection.id,
|
|
213
274
|
environment: connection.environment,
|
|
214
275
|
keys: connection.keys.map(publicKeyView),
|
|
276
|
+
// The review a credential listing exists for needs these, and it is the adopter's to perform —
|
|
277
|
+
// on their own side, from their own row (#721).
|
|
278
|
+
authorizedBy: connection.authorizedBy,
|
|
279
|
+
actorKind: connection.actorKind,
|
|
280
|
+
purpose: connection.purpose,
|
|
215
281
|
} satisfies ControlPlaneKeysResponse);
|
|
216
282
|
};
|
|
217
283
|
}
|
|
@@ -344,3 +410,31 @@ export function expireKeyHandler(deps: ControlPlaneHandlerDeps) {
|
|
|
344
410
|
} satisfies ExpireKeyResponse);
|
|
345
411
|
};
|
|
346
412
|
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* `GET /control-plane/privacy` — the full privacy inventory (#132).
|
|
416
|
+
*
|
|
417
|
+
* Every storage schema this Worker composed that holds a marked field, each field's path and class, and where it
|
|
418
|
+
* is stored — derived from the running code, so unmarking a field removes it on the next deploy with nothing in
|
|
419
|
+
* between. Behind `privacy:read`, because field names are the kind of thing an adopter may withhold from a client
|
|
420
|
+
* that has no business seeing them. Read-only.
|
|
421
|
+
*/
|
|
422
|
+
export function privacyHandler(deps: ControlPlaneHandlerDeps) {
|
|
423
|
+
return async (c: Context<PithyHonoEnv>) => {
|
|
424
|
+
const context = caller(c);
|
|
425
|
+
const inventory = deps.privacyInventory();
|
|
426
|
+
return c.json({
|
|
427
|
+
environment: context.environment,
|
|
428
|
+
posture: {
|
|
429
|
+
...privacyPosture(privacyAttestation(c.env), () => inventory),
|
|
430
|
+
retirement: await deps.retirement(c.env as Record<string, unknown>),
|
|
431
|
+
},
|
|
432
|
+
schemasWalked: inventory.schemasWalked,
|
|
433
|
+
schemas: inventory.schemas.map((schema) => ({
|
|
434
|
+
...schema,
|
|
435
|
+
fields: schema.fields.map((field) => ({ ...field })),
|
|
436
|
+
encrypted: schema.encrypted.map((field) => ({ ...field })),
|
|
437
|
+
})),
|
|
438
|
+
} satisfies ControlPlanePrivacyResponse);
|
|
439
|
+
};
|
|
440
|
+
}
|
|
@@ -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,9 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
5
|
+
import { PrivacyPosture } from "../../privacy/posture";
|
|
6
|
+
import { PRIVACY_SOURCES } from "../../privacy/report";
|
|
7
|
+
import { ConnectionPrincipal, ConnectionPurpose, ControlPlaneActorKind, Ed25519PublicJwk } from "../data/connection";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* What the seam's own routes return, as Zod objects a management client can validate against.
|
|
@@ -65,8 +67,25 @@ export const ControlPlaneKeysResponse = z
|
|
|
65
67
|
connectionId: z.string().describe("The connection these keys belong to."),
|
|
66
68
|
environment: z.string().describe("The environment that connection is bound to."),
|
|
67
69
|
keys: z.array(PublicKeyView).describe("Every registered key, expired and revoked ones included."),
|
|
70
|
+
// Added (#721), so `.optional()` as well as `.nullable()`: a client validating against a Worker that
|
|
71
|
+
// predates the three columns receives none of them, and must still parse the answer.
|
|
72
|
+
authorizedBy: ConnectionPrincipal.nullable()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe(
|
|
75
|
+
"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.",
|
|
76
|
+
),
|
|
77
|
+
actorKind: ControlPlaneActorKind.nullable()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe(
|
|
80
|
+
"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.",
|
|
81
|
+
),
|
|
82
|
+
purpose: ConnectionPurpose.nullable()
|
|
83
|
+
.optional()
|
|
84
|
+
.describe(
|
|
85
|
+
"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.",
|
|
86
|
+
),
|
|
68
87
|
})
|
|
69
|
-
.describe("Every key registered against this connection, with its window.");
|
|
88
|
+
.describe("Every key registered against this connection, with its window, and who authorized it for what.");
|
|
70
89
|
export type ControlPlaneKeysResponse = z.output<typeof ControlPlaneKeysResponse>;
|
|
71
90
|
|
|
72
91
|
/** `POST /control-plane/keys` — the appended key, and the set as it now stands. */
|
|
@@ -90,3 +109,222 @@ export const ExpireKeyResponse = z
|
|
|
90
109
|
})
|
|
91
110
|
.describe("The expired key, and the set it left behind.");
|
|
92
111
|
export type ExpireKeyResponse = z.output<typeof ExpireKeyResponse>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One migration as `GET {basePath}/migrations` reports it: its composed name, and whose it is.
|
|
115
|
+
*
|
|
116
|
+
* The namespace is a capability's name, which the manifest already reports in `capabilities[]`. So a
|
|
117
|
+
* client tells an adopter's own migrations from a composed capability's by comparing the two — no
|
|
118
|
+
* convention, and no list kept anywhere.
|
|
119
|
+
*/
|
|
120
|
+
export const MigrationView = z
|
|
121
|
+
.object({
|
|
122
|
+
name: z.string().describe("The composed migration name, exactly as the ledger records it: `0250_audit_0001_init`."),
|
|
123
|
+
namespace: z
|
|
124
|
+
.string()
|
|
125
|
+
.nullable()
|
|
126
|
+
.describe(
|
|
127
|
+
"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.",
|
|
128
|
+
),
|
|
129
|
+
})
|
|
130
|
+
.describe("One migration, and the capability namespace it belongs to.");
|
|
131
|
+
export type MigrationView = z.output<typeof MigrationView>;
|
|
132
|
+
|
|
133
|
+
/** Whether the last group is still reversible by name, or what is in its way. */
|
|
134
|
+
export const MigrationGroupPositionView = z
|
|
135
|
+
.discriminatedUnion("state", [
|
|
136
|
+
z
|
|
137
|
+
.object({
|
|
138
|
+
state: z
|
|
139
|
+
.literal("top")
|
|
140
|
+
.describe(
|
|
141
|
+
"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.",
|
|
142
|
+
),
|
|
143
|
+
})
|
|
144
|
+
.describe("A group nothing on this database's ledger sits over."),
|
|
145
|
+
z
|
|
146
|
+
.object({
|
|
147
|
+
state: z
|
|
148
|
+
.literal("buried")
|
|
149
|
+
.describe("Something sits over the group or inside it, so reversing it by name is refused."),
|
|
150
|
+
above: z.array(z.string()).describe("The groups in the way, newest first."),
|
|
151
|
+
ungrouped: z
|
|
152
|
+
.array(MigrationView)
|
|
153
|
+
.describe(
|
|
154
|
+
"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.",
|
|
155
|
+
),
|
|
156
|
+
})
|
|
157
|
+
.describe("A group with something in its way, and what that is."),
|
|
158
|
+
])
|
|
159
|
+
.describe("Where the last group sits in this database's chain: the top, or buried under named things.");
|
|
160
|
+
export type MigrationGroupPositionView = z.output<typeof MigrationGroupPositionView>;
|
|
161
|
+
|
|
162
|
+
/** The run that last moved a database, and whether it can still be taken back. */
|
|
163
|
+
export const MigrationGroupView = z
|
|
164
|
+
.object({
|
|
165
|
+
group: z
|
|
166
|
+
.string()
|
|
167
|
+
.describe("The group's value: the `--group` the run was given, or the ISO-8601 timestamp it generated."),
|
|
168
|
+
appliedAt: z.iso.datetime().describe("When this group last applied something here, ISO-8601."),
|
|
169
|
+
migrations: z
|
|
170
|
+
.array(MigrationView)
|
|
171
|
+
.describe("What this group applied in this database and is still applied, in application order."),
|
|
172
|
+
position: MigrationGroupPositionView.describe("Whether the group is the top of the chain, or what is in its way."),
|
|
173
|
+
})
|
|
174
|
+
.describe("The newest group with anything still applied in this database.");
|
|
175
|
+
export type MigrationGroupView = z.output<typeof MigrationGroupView>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* One database this Worker binds, read or not.
|
|
179
|
+
*
|
|
180
|
+
* **An unreadable database carries no list at all**, never empty ones. `pending: []` reads as up to date,
|
|
181
|
+
* and a database nobody read is not that. The state is on the value, so a client reaches a list only by
|
|
182
|
+
* narrowing.
|
|
183
|
+
*/
|
|
184
|
+
export const MigrationDatabaseView = z
|
|
185
|
+
.discriminatedUnion("state", [
|
|
186
|
+
z
|
|
187
|
+
.object({
|
|
188
|
+
state: z.literal("readable").describe("This database's ledger was read."),
|
|
189
|
+
database: z.string().describe("The database name, as capabilities declare it in `databases`."),
|
|
190
|
+
binding: z.string().describe("The D1 binding it is read through, as `wrangler.jsonc` declares it."),
|
|
191
|
+
applied: z.array(MigrationView).describe("Every applied migration, in the order the ledger applied them."),
|
|
192
|
+
pending: z
|
|
193
|
+
.array(MigrationView)
|
|
194
|
+
.describe("Declared by this build and not applied here, in the order they would run."),
|
|
195
|
+
undeclared: z
|
|
196
|
+
.array(MigrationView)
|
|
197
|
+
.describe(
|
|
198
|
+
"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.",
|
|
199
|
+
),
|
|
200
|
+
lastGroup: MigrationGroupView.nullable().describe(
|
|
201
|
+
"The newest group with anything applied here, or null when nothing applied here was ever grouped.",
|
|
202
|
+
),
|
|
203
|
+
})
|
|
204
|
+
.describe("A database whose ledger was read."),
|
|
205
|
+
z
|
|
206
|
+
.object({
|
|
207
|
+
state: z.literal("unreadable").describe("This database's ledger could not be read on this call."),
|
|
208
|
+
database: z.string().describe("The database name, as capabilities declare it in `databases`."),
|
|
209
|
+
binding: z.string().describe("The D1 binding it would be read through."),
|
|
210
|
+
})
|
|
211
|
+
.describe(
|
|
212
|
+
"A database that could not be read. No lists, so nothing about it reads as zero. The other databases still answer.",
|
|
213
|
+
),
|
|
214
|
+
])
|
|
215
|
+
.describe("One database this Worker binds: its migration state, or the fact that it could not be read.");
|
|
216
|
+
export type MigrationDatabaseView = z.output<typeof MigrationDatabaseView>;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `GET /control-plane/migrations` — where every database this Worker binds stands.
|
|
220
|
+
*
|
|
221
|
+
* Asked of the Worker because the Worker *is* the deployed build. `pithy doctor` reads the project on disk,
|
|
222
|
+
* which is what the operator has and not necessarily what is deployed.
|
|
223
|
+
*/
|
|
224
|
+
export const ControlPlaneMigrationsResponse = z
|
|
225
|
+
.object({
|
|
226
|
+
environment: z.string().describe("The environment this Worker answers for."),
|
|
227
|
+
databases: z
|
|
228
|
+
.array(MigrationDatabaseView)
|
|
229
|
+
.describe("One entry per database this Worker binds, sorted by database name. One unreadable costs only itself."),
|
|
230
|
+
})
|
|
231
|
+
.describe("What is applied, pending and undeclared in each bound database, and which run put it there.");
|
|
232
|
+
export type ControlPlaneMigrationsResponse = z.output<typeof ControlPlaneMigrationsResponse>;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* One marked field, as `GET {basePath}/privacy` reports it (#132).
|
|
236
|
+
*
|
|
237
|
+
* **The per-field record.** Since #390 it says whether the field rests encrypted beside `class`, so "which of
|
|
238
|
+
* your PHI fields are encrypted" is a derivation over this list rather than a second inventory to keep.
|
|
239
|
+
*/
|
|
240
|
+
export const PrivacyFieldView = z
|
|
241
|
+
.object({
|
|
242
|
+
path: z
|
|
243
|
+
.string()
|
|
244
|
+
.describe(
|
|
245
|
+
"Where the field sits. A D1 column path (`email`, `address.line1`, `profile.phone` inside a JSON column), or a KV entry's part first (`key.email`, `value.token`, `metadata.ip`, or `value` for a whole marked value). An array element is `[]`, a record's value `*`.",
|
|
246
|
+
),
|
|
247
|
+
class: z
|
|
248
|
+
.string()
|
|
249
|
+
.describe("The privacy class it is marked with: `secret`, `pii`, `phi`, `pci`, or an adopter's own."),
|
|
250
|
+
truncated: z
|
|
251
|
+
.literal(true)
|
|
252
|
+
.optional()
|
|
253
|
+
.describe(
|
|
254
|
+
"Set where the class is assumed rather than found: a read ran out of its budget there, or the schema could not be read at all, so any class may be marked below it. Absent where the schema was read to the end.",
|
|
255
|
+
),
|
|
256
|
+
sameAs: z
|
|
257
|
+
.string()
|
|
258
|
+
.optional()
|
|
259
|
+
.describe(
|
|
260
|
+
"Set on a reference: the path this field's position was first listed under, once the list reached its cap on paths. The class is marked somewhere beneath both, and the paths beneath this one are those beneath that one.",
|
|
261
|
+
),
|
|
262
|
+
encryptedAtRest: z
|
|
263
|
+
.boolean()
|
|
264
|
+
.optional()
|
|
265
|
+
.describe(
|
|
266
|
+
"Whether the field rests encrypted (#390). Absent where the core cannot say — an assumed class, a reference, or a core older than encrypted fields — and never defaulted to false.",
|
|
267
|
+
),
|
|
268
|
+
unrotatable: z
|
|
269
|
+
.literal(true)
|
|
270
|
+
.optional()
|
|
271
|
+
.describe(
|
|
272
|
+
"Set on an encrypted field whose storage is not D1: it can never be rotated, and it blocks retirement.",
|
|
273
|
+
),
|
|
274
|
+
})
|
|
275
|
+
.describe("One marked field and its class.");
|
|
276
|
+
export type PrivacyFieldView = z.output<typeof PrivacyFieldView>;
|
|
277
|
+
|
|
278
|
+
/** One storage schema holding at least one marked field. */
|
|
279
|
+
export const PrivacySchemaView = z
|
|
280
|
+
.object({
|
|
281
|
+
capability: z
|
|
282
|
+
.string()
|
|
283
|
+
.describe("The capability that declared the schema, as the manifest's `capabilities[]` names it."),
|
|
284
|
+
storage: z.enum(PRIVACY_SOURCES).describe("Where it is stored: a `d1` table or a `kv` store."),
|
|
285
|
+
container: z.string().describe("The D1 database name or the KV namespace name it lives in."),
|
|
286
|
+
name: z.string().describe("The table name, or the KV store name."),
|
|
287
|
+
fields: z.array(PrivacyFieldView).describe("Every marked field in it, in schema order."),
|
|
288
|
+
encrypted: z
|
|
289
|
+
.array(
|
|
290
|
+
z
|
|
291
|
+
.object({
|
|
292
|
+
path: z.string().describe("Where the encrypted field sits, on the same paths as `fields[].path`."),
|
|
293
|
+
unrotatable: z
|
|
294
|
+
.literal(true)
|
|
295
|
+
.optional()
|
|
296
|
+
.describe("Set where the storage is not D1, so the field can never be rotated."),
|
|
297
|
+
})
|
|
298
|
+
.describe("One encrypted field."),
|
|
299
|
+
)
|
|
300
|
+
.optional()
|
|
301
|
+
.describe(
|
|
302
|
+
"Every field marked encrypted at rest, classed or not (#390). Absent on a Worker whose core predates encrypted fields.",
|
|
303
|
+
),
|
|
304
|
+
})
|
|
305
|
+
.describe("One storage schema and the marked fields it holds.");
|
|
306
|
+
export type PrivacySchemaView = z.output<typeof PrivacySchemaView>;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `GET /control-plane/privacy` — the full inventory, behind `privacy:read` (#132).
|
|
310
|
+
*
|
|
311
|
+
* Derived at runtime from the schemas this Worker composed, walked once per isolate. The manifest's `privacy`
|
|
312
|
+
* block is the headline; this is the detail, and it names fields, which is why it has a scope of its own.
|
|
313
|
+
*/
|
|
314
|
+
export const ControlPlanePrivacyResponse = z
|
|
315
|
+
.object({
|
|
316
|
+
environment: z.string().describe("The environment this Worker answers for."),
|
|
317
|
+
posture: PrivacyPosture.describe("The declared and observed classes, the delta, and the console gate."),
|
|
318
|
+
schemasWalked: z
|
|
319
|
+
.number()
|
|
320
|
+
.int()
|
|
321
|
+
.nonnegative()
|
|
322
|
+
.describe("How many storage schemas were walked, marked or not, across the sources `posture.sources` names."),
|
|
323
|
+
schemas: z
|
|
324
|
+
.array(PrivacySchemaView)
|
|
325
|
+
.describe("Every schema holding a marked field, in composition order. A schema with none is not listed."),
|
|
326
|
+
})
|
|
327
|
+
.describe(
|
|
328
|
+
"Every schema this Worker stores that holds a marked field, each field's path and class, and where it lives.",
|
|
329
|
+
);
|
|
330
|
+
export type ControlPlanePrivacyResponse = z.output<typeof ControlPlanePrivacyResponse>;
|
|
@@ -6,19 +6,29 @@ 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 {
|
|
9
|
+
import {
|
|
10
|
+
ANY_VERIFIED_CALLER,
|
|
11
|
+
KEYS_ROTATE_SCOPE,
|
|
12
|
+
MANIFEST_READ_SCOPE,
|
|
13
|
+
MIGRATIONS_READ_SCOPE,
|
|
14
|
+
PRIVACY_READ_SCOPE,
|
|
15
|
+
} from "../scope/scope";
|
|
10
16
|
import { requireControlPlane } from "./guard";
|
|
11
17
|
import {
|
|
12
18
|
type ControlPlaneHandlerDeps,
|
|
13
19
|
expireKeyHandler,
|
|
14
20
|
listKeysHandler,
|
|
15
21
|
manifestHandler,
|
|
22
|
+
migrationsHandler,
|
|
16
23
|
pingHandler,
|
|
24
|
+
privacyHandler,
|
|
17
25
|
registerKeyHandler,
|
|
18
26
|
} from "./handlers";
|
|
19
27
|
import {
|
|
20
28
|
ControlPlaneKeysResponse,
|
|
29
|
+
ControlPlaneMigrationsResponse,
|
|
21
30
|
ControlPlanePingResponse,
|
|
31
|
+
ControlPlanePrivacyResponse,
|
|
22
32
|
ExpireKeyResponse,
|
|
23
33
|
RegisterKeyResponse,
|
|
24
34
|
} from "./responses";
|
|
@@ -32,6 +42,8 @@ import { ExpireKeyParams, ExpireKeyRequest, RegisterKeyRequest } from "./schemas
|
|
|
32
42
|
* GET /control-plane/keys → the registration state (keys:rotate)
|
|
33
43
|
* POST /control-plane/keys → register a key (keys:rotate)
|
|
34
44
|
* POST /control-plane/keys/:keyId/expire → retire a superseded key (keys:rotate)
|
|
45
|
+
* GET /control-plane/migrations → where each database stands (migrations:read)
|
|
46
|
+
* GET /control-plane/privacy → what each schema holds (privacy:read)
|
|
35
47
|
*
|
|
36
48
|
* Every one is `control-plane` and default-denied — with no connection registered they all answer 403,
|
|
37
49
|
* and that is the shipped state of a Worker nobody has connected.
|
|
@@ -90,6 +102,20 @@ export function controlPlaneRouteDescriptors(basePath: string): AdminRoute[] {
|
|
|
90
102
|
scope: KEYS_ROTATE_SCOPE,
|
|
91
103
|
summary: "Retire a superseded key. Must be signed with the successor it names as proven.",
|
|
92
104
|
},
|
|
105
|
+
{
|
|
106
|
+
method: "GET",
|
|
107
|
+
path: `${basePath}/migrations`,
|
|
108
|
+
scope: MIGRATIONS_READ_SCOPE,
|
|
109
|
+
summary:
|
|
110
|
+
"What each bound database has applied, what is pending, what is undeclared, and which run put it there. Undeclared is relative to this Worker.",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
method: "GET",
|
|
114
|
+
path: `${basePath}/privacy`,
|
|
115
|
+
scope: PRIVACY_READ_SCOPE,
|
|
116
|
+
summary:
|
|
117
|
+
"Every stored schema holding a privacy-marked field, each field's path and class, and where it lives. Derived from the running code.",
|
|
118
|
+
},
|
|
93
119
|
];
|
|
94
120
|
}
|
|
95
121
|
|
|
@@ -160,4 +186,32 @@ export function registerControlPlaneRoutes(
|
|
|
160
186
|
validate("json", ExpireKeyRequest),
|
|
161
187
|
(c) => expireKeyHandler(deps)(c, c.req.valid("param"), c.req.valid("json")),
|
|
162
188
|
);
|
|
189
|
+
|
|
190
|
+
app.get(
|
|
191
|
+
`${basePath}/migrations`,
|
|
192
|
+
describeRoute({
|
|
193
|
+
summary: "Report where each database this Worker binds stands.",
|
|
194
|
+
description:
|
|
195
|
+
"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.",
|
|
196
|
+
verification: "control-plane",
|
|
197
|
+
scopes: [MIGRATIONS_READ_SCOPE],
|
|
198
|
+
responses: { 200: ControlPlaneMigrationsResponse },
|
|
199
|
+
}),
|
|
200
|
+
requireControlPlane(MIGRATIONS_READ_SCOPE),
|
|
201
|
+
migrationsHandler(deps),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
app.get(
|
|
205
|
+
`${basePath}/privacy`,
|
|
206
|
+
describeRoute({
|
|
207
|
+
summary: "Report every stored field marked with a privacy class.",
|
|
208
|
+
description:
|
|
209
|
+
"Every D1 table and KV store this Worker composed that holds a privacy-marked field, with each field's path, its class and where it is stored, beside the declared-and-observed posture. Derived at runtime from the deployed schemas. Read-only.",
|
|
210
|
+
verification: "control-plane",
|
|
211
|
+
scopes: [PRIVACY_READ_SCOPE],
|
|
212
|
+
responses: { 200: ControlPlanePrivacyResponse },
|
|
213
|
+
}),
|
|
214
|
+
requireControlPlane(PRIVACY_READ_SCOPE),
|
|
215
|
+
privacyHandler(deps),
|
|
216
|
+
);
|
|
163
217
|
}
|
|
@@ -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
|
|