@pithy-sh/core 0.11.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.
Files changed (142) hide show
  1. package/dist/audit/recorder.d.ts +10 -2
  2. package/dist/audit/recorder.d.ts.map +1 -1
  3. package/dist/capability/capability.d.ts +12 -0
  4. package/dist/capability/capability.d.ts.map +1 -1
  5. package/dist/capability/capability.js +2 -0
  6. package/dist/capability/purge.d.ts +80 -0
  7. package/dist/capability/purge.d.ts.map +1 -0
  8. package/dist/capability/purge.js +76 -0
  9. package/dist/cli/commandCatalog.generated.d.ts +54 -2
  10. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  11. package/dist/cli/commandCatalog.generated.js +55 -0
  12. package/dist/cli/commandFacts.d.ts +1 -0
  13. package/dist/cli/commandFacts.d.ts.map +1 -1
  14. package/dist/cli/commandFacts.js +1 -0
  15. package/dist/controlPlane/capability.d.ts.map +1 -1
  16. package/dist/controlPlane/capability.js +7 -0
  17. package/dist/controlPlane/data/connection.d.ts +2 -12
  18. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  19. package/dist/controlPlane/data/connection.js +1 -1
  20. package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
  21. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  22. package/dist/controlPlane/discovery/adminRoute.js +3 -0
  23. package/dist/controlPlane/error/errors.d.ts +4 -3
  24. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  25. package/dist/controlPlane/http/handlers.d.ts +79 -0
  26. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  27. package/dist/controlPlane/http/handlers.js +37 -3
  28. package/dist/controlPlane/http/responses.d.ts +98 -0
  29. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  30. package/dist/controlPlane/http/responses.js +41 -1
  31. package/dist/controlPlane/http/routes.d.ts +1 -0
  32. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  33. package/dist/controlPlane/http/routes.js +17 -3
  34. package/dist/controlPlane/scope/scope.d.ts +13 -3
  35. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  36. package/dist/controlPlane/scope/scope.js +14 -4
  37. package/dist/createBackend.d.ts.map +1 -1
  38. package/dist/createBackend.js +2 -0
  39. package/dist/crypto/envelope.d.ts +86 -0
  40. package/dist/crypto/envelope.d.ts.map +1 -0
  41. package/dist/crypto/envelope.js +129 -0
  42. package/dist/crypto/fieldKeys.d.ts +41 -0
  43. package/dist/crypto/fieldKeys.d.ts.map +1 -0
  44. package/dist/crypto/fieldKeys.js +56 -0
  45. package/dist/data/codecs.d.ts +23 -10
  46. package/dist/data/codecs.d.ts.map +1 -1
  47. package/dist/data/codecs.js +50 -36
  48. package/dist/error/extend.d.ts +6 -5
  49. package/dist/error/extend.d.ts.map +1 -1
  50. package/dist/error/http.d.ts +24 -0
  51. package/dist/error/http.d.ts.map +1 -1
  52. package/dist/error/payload.d.ts +48 -0
  53. package/dist/error/payload.d.ts.map +1 -1
  54. package/dist/error/payload.js +16 -0
  55. package/dist/error/pithyError.d.ts +18 -7
  56. package/dist/error/pithyError.d.ts.map +1 -1
  57. package/dist/error/pithyError.js +6 -0
  58. package/dist/index.d.ts +1 -1
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +2 -2
  61. package/dist/logger/logger.d.ts +12 -5
  62. package/dist/logger/logger.d.ts.map +1 -1
  63. package/dist/logger/record.d.ts +14 -0
  64. package/dist/logger/record.d.ts.map +1 -1
  65. package/dist/privacy/blindIndex.d.ts +27 -0
  66. package/dist/privacy/blindIndex.d.ts.map +1 -0
  67. package/dist/privacy/blindIndex.js +57 -0
  68. package/dist/privacy/classes.d.ts +142 -0
  69. package/dist/privacy/classes.d.ts.map +1 -0
  70. package/dist/privacy/classes.js +209 -0
  71. package/dist/privacy/classify.d.ts +336 -0
  72. package/dist/privacy/classify.d.ts.map +1 -0
  73. package/dist/privacy/classify.js +1024 -0
  74. package/dist/privacy/encrypted.d.ts +210 -0
  75. package/dist/privacy/encrypted.d.ts.map +1 -0
  76. package/dist/privacy/encrypted.js +350 -0
  77. package/dist/privacy/encryptedColumns.d.ts +64 -0
  78. package/dist/privacy/encryptedColumns.d.ts.map +1 -0
  79. package/dist/privacy/encryptedColumns.js +134 -0
  80. package/dist/privacy/fieldRotation.d.ts +34 -0
  81. package/dist/privacy/fieldRotation.d.ts.map +1 -0
  82. package/dist/privacy/fieldRotation.js +186 -0
  83. package/dist/privacy/inventory.d.ts +12 -0
  84. package/dist/privacy/inventory.d.ts.map +1 -0
  85. package/dist/privacy/inventory.js +154 -0
  86. package/dist/privacy/posture.d.ts +114 -0
  87. package/dist/privacy/posture.d.ts.map +1 -0
  88. package/dist/privacy/posture.js +136 -0
  89. package/dist/privacy/redact.d.ts +7 -0
  90. package/dist/privacy/redact.d.ts.map +1 -0
  91. package/dist/privacy/redact.js +404 -0
  92. package/dist/privacy/report.d.ts +58 -0
  93. package/dist/privacy/report.d.ts.map +1 -0
  94. package/dist/privacy/report.js +15 -0
  95. package/dist/privacy/retirement.d.ts +68 -0
  96. package/dist/privacy/retirement.d.ts.map +1 -0
  97. package/dist/privacy/retirement.js +72 -0
  98. package/dist/privacy/sealed.d.ts +87 -0
  99. package/dist/privacy/sealed.d.ts.map +1 -0
  100. package/dist/privacy/sealed.js +221 -0
  101. package/dist/privacy/types.d.ts +559 -0
  102. package/dist/privacy/types.d.ts.map +1 -0
  103. package/dist/privacy/types.js +2 -0
  104. package/dist/version.generated.d.ts +1 -1
  105. package/dist/version.generated.js +1 -1
  106. package/package.json +1 -1
  107. package/src/audit/recorder.ts +10 -2
  108. package/src/capability/capability.ts +15 -0
  109. package/src/capability/purge.ts +124 -0
  110. package/src/cli/commandCatalog.generated.ts +59 -0
  111. package/src/cli/commandFacts.ts +1 -0
  112. package/src/controlPlane/capability.ts +11 -0
  113. package/src/controlPlane/discovery/adminRoute.ts +11 -0
  114. package/src/controlPlane/error/errors.ts +4 -3
  115. package/src/controlPlane/http/handlers.ts +57 -4
  116. package/src/controlPlane/http/responses.ts +100 -0
  117. package/src/controlPlane/http/routes.ts +31 -1
  118. package/src/controlPlane/scope/scope.ts +14 -3
  119. package/src/controlPlane/token/base64url.ts +1 -1
  120. package/src/createBackend.ts +4 -0
  121. package/src/crypto/envelope.ts +196 -0
  122. package/src/crypto/fieldKeys.ts +76 -0
  123. package/src/data/codecs.ts +69 -37
  124. package/src/error/extend.ts +6 -5
  125. package/src/error/payload.ts +42 -0
  126. package/src/error/pithyError.ts +21 -7
  127. package/src/index.ts +1 -0
  128. package/src/logger/logger.ts +12 -5
  129. package/src/privacy/blindIndex.ts +81 -0
  130. package/src/privacy/classes.ts +277 -0
  131. package/src/privacy/classify.ts +1447 -0
  132. package/src/privacy/encrypted.ts +513 -0
  133. package/src/privacy/encryptedColumns.ts +185 -0
  134. package/src/privacy/fieldRotation.ts +234 -0
  135. package/src/privacy/inventory.ts +147 -0
  136. package/src/privacy/posture.ts +205 -0
  137. package/src/privacy/redact.ts +423 -0
  138. package/src/privacy/report.ts +64 -0
  139. package/src/privacy/retirement.ts +102 -0
  140. package/src/privacy/sealed.ts +323 -0
  141. package/src/privacy/types.ts +804 -0
  142. package/src/version.generated.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/core",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,7 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import type { AuditEventInput } from "./auditEvent";
4
+ import type { Loggable } from "../privacy/types";
5
+ import type { AuditEventInput, AuditMetadata } from "./auditEvent";
5
6
 
6
7
  /**
7
8
  * The recorder seam. `emit()` on the request context (`c.var.emit`) takes one {@link AuditEventInput}
@@ -12,8 +13,15 @@ import type { AuditEventInput } from "./auditEvent";
12
13
  * `emit()` is **non-fatal by contract**: it never throws and never rejects, so an audited action is
13
14
  * never broken by an audit write. The real recorder swallows and logs its own failures; the no-op
14
15
  * simply resolves.
16
+ *
17
+ * **`metadata` refuses a value marked with a privacy class** (#132). The trail is the longest-lived internal
18
+ * surface there is — a row in the adopter's D1 with no retention window — so a `pii()`/`phi()`/`secret()`/
19
+ * `pci()` value in it, or inside an object in it, is a compile error, by the same `Loggable` rule the logger
20
+ * applies. `AuditMetadata` the schema stays permissive: a stored row still parses.
15
21
  */
16
- export type AuditEmit = (event: AuditEventInput) => Promise<void>;
22
+ export type AuditEmit = <M extends AuditMetadata = AuditMetadata>(
23
+ event: Omit<AuditEventInput, "metadata"> & { metadata?: (M & Loggable<M>) | null | undefined },
24
+ ) => Promise<void>;
17
25
 
18
26
  /**
19
27
  * The default recorder when no audit capability is composed: accept the event and drop it. Resolves
@@ -26,6 +26,7 @@ import type { SeedSet } from "../seed/seed";
26
26
  import type { WorkflowSpecMap } from "../workflow/spec";
27
27
  import { BindingSpec, type BindingSpecInput } from "./bindings";
28
28
  import type { ClientProjection, ClientProjectionContext } from "./client";
29
+ import { assertPurgeContributions, type PurgeContribution } from "./purge";
29
30
  import type { CapabilitySettings } from "./settings";
30
31
 
31
32
  /** Hono `Variables` every capability's routes are typed against. `createBackend` seeds these per request. */
@@ -658,6 +659,17 @@ export interface Capability<
658
659
  * time, and read by `pithy doctor`.
659
660
  */
660
661
  extensions?: readonly CapabilityExtension[];
662
+ /**
663
+ * How this capability erases one subject's rows when that subject is purged — see `./purge.ts`. Omit it
664
+ * when this capability holds nothing keyed to a person, which is the ordinary case.
665
+ *
666
+ * **The purge Workflow runs each composed contribution as its own step**, named for this capability, so a
667
+ * failure retries this capability alone and the steps that finished are not re-run. That is why there is
668
+ * at most one per subject here, and why `erase` must succeed when there is nothing left to erase.
669
+ * `defineCapability` refuses a contribution for a subject the kit does not know. An adopter declares one
670
+ * on their own `app` capability for their own tables, with the same types.
671
+ */
672
+ purge?: readonly PurgeContribution[];
661
673
  /**
662
674
  * The role powers this capability's own routes gate on, through the acting-organization seam —
663
675
  * `media:manage`, `storage:manage`. Omit it when a capability asks no power, which is the normal case.
@@ -700,6 +712,9 @@ export function defineCapability<
700
712
  for (const spec of Object.values(input.databases ?? {})) {
701
713
  if (spec.migrations && spec.retained) declareRetained(spec.migrations, spec.retained);
702
714
  }
715
+ // A purge contribution no purge could run — a subject the kit does not know, or two for one — is refused
716
+ // where it is written, naming the capability, rather than on the day somebody is erased.
717
+ if (input.purge) assertPurgeContributions(input.name, input.purge);
703
718
  return {
704
719
  ...input,
705
720
  requiredBindings: input.requiredBindings.map((binding) => BindingSpec.parse(binding)),
@@ -0,0 +1,124 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ValidationError } from "../error/pithyError";
6
+
7
+ /**
8
+ * How a capability says what erasing one subject means for its own rows — the purge contribution.
9
+ *
10
+ * ## Why a contribution, and not a cascade
11
+ *
12
+ * A hard delete has to reach rows that `@pithy-sh/media`, `@pithy-sh/support`, `@pithy-sh/testers` and the
13
+ * adopter's own tables hold, and no foreign key reaches across capabilities: each owns its tables, and none
14
+ * references another's. So the only party that knows how one person's rows are erased — deleted outright,
15
+ * or anonymized because a ledger must still add up — is the capability that owns them. It says so here,
16
+ * the way it states its migrations and its seeds, and the purge runs what was composed.
17
+ *
18
+ * ## One capability, one step
19
+ *
20
+ * The purge Workflow (#622) runs every composed contribution as **its own step**, named for the
21
+ * capability. A step that fails retries alone, and the ones that finished are not re-run — which is why a
22
+ * capability may declare at most one contribution per subject, and why every `erase` must be idempotent:
23
+ * a step is retried after a failure that may have come after its rows were already gone, and erasing
24
+ * nothing must then succeed.
25
+ *
26
+ * ## An adopter declares one too
27
+ *
28
+ * On their own `app` capability, for their own tables, with the same types. The contract is exported for
29
+ * exactly that, and nothing distinguishes a kit contribution from theirs.
30
+ */
31
+
32
+ /**
33
+ * The kinds of subject a purge erases. Closed, so a typo is refused where it is written rather than becoming
34
+ * a contribution no purge ever asks for. `organization` joins when organization state does (#623).
35
+ */
36
+ export const PurgeSubject = z
37
+ .enum(["user"])
38
+ .describe(
39
+ "The kind of subject a purge erases. `user` is one person's account in `@pithy-sh/auth`, identified by its user id.",
40
+ );
41
+ export type PurgeSubject = z.output<typeof PurgeSubject>;
42
+
43
+ /** What one step is handed: who is being erased, and this run's bindings. */
44
+ export interface PurgeContext {
45
+ /** The subject being erased — its kind, and its id in that kind's own table. */
46
+ readonly subject: { readonly type: PurgeSubject; readonly id: string };
47
+ /** The Worker's bindings for this run, from which the contribution opens its own database. */
48
+ readonly env: Record<string, unknown>;
49
+ }
50
+
51
+ /** One capability's answer to "how are this subject's rows erased". */
52
+ export interface PurgeContribution {
53
+ /** The kind of subject this erases. */
54
+ readonly subject: PurgeSubject;
55
+ /**
56
+ * One sentence on what is erased or anonymized — `Deletes the user's uploads and their stored bytes.`
57
+ * Recorded with the step that ran it, so the trail says what a purge did and not only that it ran.
58
+ */
59
+ readonly summary: string;
60
+ /**
61
+ * Erase or anonymize this subject's rows. **Idempotent**: run again after it succeeded, or after a failure
62
+ * that came once the rows were gone, it must succeed having found nothing to do. Throwing fails this step
63
+ * and only this step.
64
+ */
65
+ readonly erase: (context: PurgeContext) => Promise<void>;
66
+ }
67
+
68
+ /** One step of a purge: which capability it runs, and the contribution that capability declared. */
69
+ export interface PurgeStep {
70
+ /** The step's name — the capability's, so a retried step names who failed. */
71
+ readonly step: string;
72
+ /** The contribution this step runs. */
73
+ readonly contribution: PurgeContribution;
74
+ }
75
+
76
+ /**
77
+ * Refuse a capability's purge contributions that no purge could run as declared: an unknown subject, two for
78
+ * one subject, or one with nothing to record. Called by `defineCapability`, so the refusal names the capability
79
+ * where it is written rather than surfacing in a Workflow on the day somebody is deleted.
80
+ */
81
+ export function assertPurgeContributions(capability: string, contributions: readonly PurgeContribution[]): void {
82
+ const seen = new Set<string>();
83
+ for (const contribution of contributions) {
84
+ const subject = PurgeSubject.safeParse(contribution.subject);
85
+ if (!subject.success) {
86
+ throw new ValidationError({
87
+ message: `The ${capability} capability declares a purge for "${String(contribution.subject)}", which is not a subject a purge erases.`,
88
+ action: `Declare it for one of: ${PurgeSubject.options.join(", ")}.`,
89
+ });
90
+ }
91
+ if (seen.has(subject.data)) {
92
+ throw new ValidationError({
93
+ message: `The ${capability} capability declares two purge contributions for ${subject.data}.`,
94
+ action: "Fold them into one: each capability is one step of the purge, and a retried step re-runs all of it.",
95
+ });
96
+ }
97
+ seen.add(subject.data);
98
+ if (contribution.summary.trim() === "") {
99
+ throw new ValidationError({
100
+ message: `The ${capability} capability's ${subject.data} purge has no summary.`,
101
+ action: "Say in one sentence what it erases. The purge records it with the step.",
102
+ });
103
+ }
104
+ if (typeof contribution.erase !== "function") {
105
+ throw new ValidationError({
106
+ message: `The ${capability} capability's ${subject.data} purge has no erase function.`,
107
+ action: "Give it an `erase` that removes or anonymizes the subject's rows.",
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ /** The steps a purge of `subject` runs, in composition order — libraries first, the adopter's `app` last. */
114
+ export function purgeSteps(
115
+ capabilities: readonly { readonly name: string; readonly purge?: readonly PurgeContribution[] }[],
116
+ subject: PurgeSubject,
117
+ ): PurgeStep[] {
118
+ const steps: PurgeStep[] = [];
119
+ for (const capability of capabilities) {
120
+ const contribution = capability.purge?.find((candidate) => candidate.subject === subject);
121
+ if (contribution) steps.push({ step: capability.name, contribution });
122
+ }
123
+ return steps;
124
+ }
@@ -899,6 +899,10 @@ export const COMMANDS = {
899
899
  "invite",
900
900
  "testerNudge",
901
901
  "passwordChanged",
902
+ "accountSuspended",
903
+ "accountReinstated",
904
+ "deletionScheduled",
905
+ "deletionComplete",
902
906
  "operationalNotice",
903
907
  "newsletter",
904
908
  "leadCapture",
@@ -1230,6 +1234,33 @@ export const COMMANDS = {
1230
1234
  },
1231
1235
  "groups": []
1232
1236
  },
1237
+ "privacy enable": {
1238
+ "positionals": [],
1239
+ "flags": {
1240
+ "--class": {
1241
+ "type": "string",
1242
+ "required": false,
1243
+ "repeatable": true
1244
+ },
1245
+ "--json": {
1246
+ "type": "boolean",
1247
+ "required": false,
1248
+ "repeatable": false
1249
+ }
1250
+ },
1251
+ "groups": []
1252
+ },
1253
+ "privacy scan": {
1254
+ "positionals": [],
1255
+ "flags": {
1256
+ "--json": {
1257
+ "type": "boolean",
1258
+ "required": false,
1259
+ "repeatable": false
1260
+ }
1261
+ },
1262
+ "groups": []
1263
+ },
1233
1264
  "provision": {
1234
1265
  "positionals": [],
1235
1266
  "flags": {
@@ -1433,6 +1464,33 @@ export const COMMANDS = {
1433
1464
  },
1434
1465
  "groups": []
1435
1466
  },
1467
+ "secrets retire-version": {
1468
+ "positionals": [
1469
+ {
1470
+ "name": "version",
1471
+ "required": true,
1472
+ "variadic": false
1473
+ }
1474
+ ],
1475
+ "flags": {
1476
+ "--env": {
1477
+ "type": "string",
1478
+ "required": false,
1479
+ "repeatable": false
1480
+ },
1481
+ "--past-guard": {
1482
+ "type": "boolean",
1483
+ "required": false,
1484
+ "repeatable": false
1485
+ },
1486
+ "--json": {
1487
+ "type": "boolean",
1488
+ "required": false,
1489
+ "repeatable": false
1490
+ }
1491
+ },
1492
+ "groups": []
1493
+ },
1436
1494
  "secrets rm": {
1437
1495
  "positionals": [
1438
1496
  {
@@ -2711,6 +2769,7 @@ export const GROUPS = [
2711
2769
  "feature",
2712
2770
  "media",
2713
2771
  "payments",
2772
+ "privacy",
2714
2773
  "secrets",
2715
2774
  "storage",
2716
2775
  "support",
@@ -105,6 +105,7 @@ export const DESTRUCTIVE_COMMANDS = {
105
105
  "media deprovision": "the media Worker, and with --storage its bucket",
106
106
  remove: "a capability's wiring",
107
107
  "secrets deprovision": "the secrets manager, and with --keys the store's keys",
108
+ "secrets retire-version": "a field-key version, and every ciphertext still sealed under it",
108
109
  "secrets rm": "a stored secret",
109
110
  "storage deprovision": "the storage Worker, and with --storage its bucket",
110
111
  "support deprovision": "the support Workers, and with --storage its attachments",
@@ -7,6 +7,8 @@ import type { BindingSpecInput } from "../capability/bindings";
7
7
  import { type Capability, defineCapability, type PithyHonoEnv } from "../capability/capability";
8
8
  import type { DatabaseSpecMap } from "../data/databases";
9
9
  import type { KvNamespaceSpecMap, KvRegistry } from "../kv/namespaces";
10
+ import { readRetirement } from "../privacy/fieldRotation";
11
+ import { lazyPrivacyInventory } from "../privacy/inventory";
10
12
  import { PACKAGE_NAME, PACKAGE_VERSION } from "../version.generated";
11
13
  import { ENVIRONMENT_VAR } from "../worker/identity";
12
14
  import { ControlPlaneConfig, type ControlPlaneConfigInput } from "./config/config";
@@ -146,6 +148,12 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
146
148
  // Worker's sets and this Worker holds only its own, so `pending` and `undeclared` can differ there.
147
149
  let migrationDatabases: readonly ComposedMigrationDatabase[] = [];
148
150
 
151
+ // Every capability this Worker composed, kept so the privacy inventory can walk their storage schemas
152
+ // (#132) — lazily, on the first control-plane read, and once for the isolate. Capturing the list costs
153
+ // nothing; walking it is deferred, so a Worker whose control plane is never called never walks.
154
+ let composedCapabilities: readonly Capability[] = [];
155
+ const privacy = lazyPrivacyInventory(() => composedCapabilities);
156
+
149
157
  const capability = defineCapability({
150
158
  name: "controlplane",
151
159
  // `@pithy-sh/core`'s version, stamped by `scripts/stampVersions.ts` — a Worker cannot read its own
@@ -209,6 +217,7 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
209
217
  }));
210
218
  health = capabilityHealthSources(capabilities);
211
219
  migrationDatabases = composeMigrationDatabases(capabilities);
220
+ composedCapabilities = [...capabilities];
212
221
  },
213
222
  middleware: [
214
223
  (app) => {
@@ -252,6 +261,8 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
252
261
  composedCapabilities: () => composed,
253
262
  composedHealth: () => health,
254
263
  composedMigrations: () => migrationDatabases,
264
+ privacyInventory: privacy,
265
+ retirement: (env) => readRetirement(env, composedCapabilities),
255
266
  now,
256
267
  });
257
268
  },
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { PrivacyPosture } from "../../privacy/posture";
5
6
  import { ControlPlaneScope } from "../scope/scope";
6
7
  import { ManifestConfigKey, ManifestConfigValues } from "./configuration";
7
8
  import { CapabilityHealthReport, HealthSummary, HealthSummaryKey, healthReport, healthWire } from "./healthSummary";
@@ -246,6 +247,16 @@ export const ControlPlaneManifest = z
246
247
  .describe(
247
248
  "The Cloudflare version id of the build answering this call, from the `CF_VERSION_METADATA` binding, or null where the binding is absent. Opaque and per-deploy: it identifies which build is running, never which features it has. Null is honest — a Worker scaffolded before the binding was declared genuinely cannot say.",
248
249
  ),
250
+ versionCreatedAt: z.iso
251
+ .datetime()
252
+ .nullable()
253
+ .optional()
254
+ .describe(
255
+ "When the version answering this call was uploaded, ISO-8601, from the same `CF_VERSION_METADATA` binding as `version` — or null where the binding is absent. The id alone cannot tell a deploy from a rollback: a rollback reactivates an existing version, so the id returns to one already seen while this moves backwards. Relayed, never interpreted. Absent from a Worker older than #132.",
256
+ ),
257
+ privacy: PrivacyPosture.optional().describe(
258
+ "What this project declares about privacy and what its running code holds: classes and counts, never field names (`GET /control-plane/privacy` has those, behind `privacy:read`). Present on every Worker from #132, `declared` explicitly empty when nothing was declared; absent from an older Worker.",
259
+ ),
249
260
  capabilities: z
250
261
  .array(CapabilityDescriptor)
251
262
  .describe(
@@ -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?: string;
24
- action?: string;
25
- detail?: string;
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`.
@@ -4,7 +4,10 @@
4
4
  import type { Context } from "hono";
5
5
  import type { PithyHonoEnv } from "../../capability/capability";
6
6
  import { causeMessage } from "../../error/cause";
7
- import { workerVersion } from "../../worker/identity";
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";
8
11
  import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
9
12
  import type { ControlPlaneConfig } from "../config/config";
10
13
  import type { ControlPlaneContext } from "../context";
@@ -20,6 +23,7 @@ import type {
20
23
  ControlPlaneKeysResponse,
21
24
  ControlPlaneMigrationsResponse,
22
25
  ControlPlanePingResponse,
26
+ ControlPlanePrivacyResponse,
23
27
  ExpireKeyResponse,
24
28
  PublicKeyView,
25
29
  RegisterKeyResponse,
@@ -76,6 +80,16 @@ export interface ControlPlaneHandlerDeps {
76
80
  * `compose` hook through `collectMigrationSets`, the function `pithy migrate` composes with (#729).
77
81
  */
78
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>;
79
93
  /** The clock, injected so a test can stand at any instant. */
80
94
  now: () => Date;
81
95
  }
@@ -199,12 +213,23 @@ export function manifestHandler(deps: ControlPlaneHandlerDeps) {
199
213
  ),
200
214
  })),
201
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);
202
219
  return c.json({
203
220
  environment: context.environment,
204
221
  connectionId: context.connectionId,
205
- // The build answering this call. Read per request rather than captured at assembly: it is the
206
- // same binding every log record and audit event reads, and there is one reader for it.
207
- version: workerVersion(c.env),
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
+ },
208
233
  capabilities,
209
234
  grantedScopes: [...context.grantedScopes],
210
235
  } satisfies ControlPlaneManifestWire);
@@ -385,3 +410,31 @@ export function expireKeyHandler(deps: ControlPlaneHandlerDeps) {
385
410
  } satisfies ExpireKeyResponse);
386
411
  };
387
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
+ }
@@ -2,6 +2,8 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { PrivacyPosture } from "../../privacy/posture";
6
+ import { PRIVACY_SOURCES } from "../../privacy/report";
5
7
  import { ConnectionPrincipal, ConnectionPurpose, ControlPlaneActorKind, Ed25519PublicJwk } from "../data/connection";
6
8
 
7
9
  /**
@@ -228,3 +230,101 @@ export const ControlPlaneMigrationsResponse = z
228
230
  })
229
231
  .describe("What is applied, pending and undeclared in each bound database, and which run put it there.");
230
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,7 +6,13 @@ 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, MIGRATIONS_READ_SCOPE } from "../scope/scope";
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,
@@ -15,12 +21,14 @@ import {
15
21
  manifestHandler,
16
22
  migrationsHandler,
17
23
  pingHandler,
24
+ privacyHandler,
18
25
  registerKeyHandler,
19
26
  } from "./handlers";
20
27
  import {
21
28
  ControlPlaneKeysResponse,
22
29
  ControlPlaneMigrationsResponse,
23
30
  ControlPlanePingResponse,
31
+ ControlPlanePrivacyResponse,
24
32
  ExpireKeyResponse,
25
33
  RegisterKeyResponse,
26
34
  } from "./responses";
@@ -35,6 +43,7 @@ import { ExpireKeyParams, ExpireKeyRequest, RegisterKeyRequest } from "./schemas
35
43
  * POST /control-plane/keys → register a key (keys:rotate)
36
44
  * POST /control-plane/keys/:keyId/expire → retire a superseded key (keys:rotate)
37
45
  * GET /control-plane/migrations → where each database stands (migrations:read)
46
+ * GET /control-plane/privacy → what each schema holds (privacy:read)
38
47
  *
39
48
  * Every one is `control-plane` and default-denied — with no connection registered they all answer 403,
40
49
  * and that is the shipped state of a Worker nobody has connected.
@@ -100,6 +109,13 @@ export function controlPlaneRouteDescriptors(basePath: string): AdminRoute[] {
100
109
  summary:
101
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.",
102
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
+ },
103
119
  ];
104
120
  }
105
121
 
@@ -184,4 +200,18 @@ export function registerControlPlaneRoutes(
184
200
  requireControlPlane(MIGRATIONS_READ_SCOPE),
185
201
  migrationsHandler(deps),
186
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
+ );
187
217
  }