@pithy-sh/core 0.11.0 → 0.13.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 (157) hide show
  1. package/dist/audit/composed.d.ts +18 -0
  2. package/dist/audit/composed.d.ts.map +1 -0
  3. package/dist/audit/composed.js +28 -0
  4. package/dist/audit/recorder.d.ts +10 -2
  5. package/dist/audit/recorder.d.ts.map +1 -1
  6. package/dist/capability/capability.d.ts +23 -0
  7. package/dist/capability/capability.d.ts.map +1 -1
  8. package/dist/capability/capability.js +2 -0
  9. package/dist/capability/purge.d.ts +122 -0
  10. package/dist/capability/purge.d.ts.map +1 -0
  11. package/dist/capability/purge.js +131 -0
  12. package/dist/cli/commandCatalog.generated.d.ts +54 -2
  13. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  14. package/dist/cli/commandCatalog.generated.js +61 -0
  15. package/dist/cli/commandFacts.d.ts +1 -0
  16. package/dist/cli/commandFacts.d.ts.map +1 -1
  17. package/dist/cli/commandFacts.js +1 -0
  18. package/dist/controlPlane/capability.d.ts.map +1 -1
  19. package/dist/controlPlane/capability.js +7 -0
  20. package/dist/controlPlane/data/connection.d.ts +2 -12
  21. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  22. package/dist/controlPlane/data/connection.js +1 -1
  23. package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
  24. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  25. package/dist/controlPlane/discovery/adminRoute.js +3 -0
  26. package/dist/controlPlane/error/errors.d.ts +4 -3
  27. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  28. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  29. package/dist/controlPlane/http/guard.js +40 -1
  30. package/dist/controlPlane/http/handlers.d.ts +79 -0
  31. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  32. package/dist/controlPlane/http/handlers.js +37 -3
  33. package/dist/controlPlane/http/responses.d.ts +98 -0
  34. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  35. package/dist/controlPlane/http/responses.js +41 -1
  36. package/dist/controlPlane/http/routes.d.ts +1 -0
  37. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  38. package/dist/controlPlane/http/routes.js +17 -3
  39. package/dist/controlPlane/scope/scope.d.ts +13 -3
  40. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  41. package/dist/controlPlane/scope/scope.js +14 -4
  42. package/dist/createBackend.d.ts.map +1 -1
  43. package/dist/createBackend.js +2 -0
  44. package/dist/createEntrypoint.d.ts +7 -1
  45. package/dist/createEntrypoint.d.ts.map +1 -1
  46. package/dist/createEntrypoint.js +15 -5
  47. package/dist/crypto/envelope.d.ts +86 -0
  48. package/dist/crypto/envelope.d.ts.map +1 -0
  49. package/dist/crypto/envelope.js +129 -0
  50. package/dist/crypto/fieldKeys.d.ts +41 -0
  51. package/dist/crypto/fieldKeys.d.ts.map +1 -0
  52. package/dist/crypto/fieldKeys.js +56 -0
  53. package/dist/data/codecs.d.ts +23 -10
  54. package/dist/data/codecs.d.ts.map +1 -1
  55. package/dist/data/codecs.js +50 -36
  56. package/dist/error/extend.d.ts +6 -5
  57. package/dist/error/extend.d.ts.map +1 -1
  58. package/dist/error/http.d.ts +72 -0
  59. package/dist/error/http.d.ts.map +1 -1
  60. package/dist/error/payload.d.ts +144 -0
  61. package/dist/error/payload.d.ts.map +1 -1
  62. package/dist/error/payload.js +48 -0
  63. package/dist/error/pithyError.d.ts +18 -7
  64. package/dist/error/pithyError.d.ts.map +1 -1
  65. package/dist/error/pithyError.js +6 -0
  66. package/dist/http/validation.d.ts +11 -2
  67. package/dist/http/validation.d.ts.map +1 -1
  68. package/dist/http/validation.js +20 -3
  69. package/dist/index.d.ts +1 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +2 -2
  72. package/dist/logger/logger.d.ts +12 -5
  73. package/dist/logger/logger.d.ts.map +1 -1
  74. package/dist/logger/record.d.ts +42 -0
  75. package/dist/logger/record.d.ts.map +1 -1
  76. package/dist/privacy/blindIndex.d.ts +27 -0
  77. package/dist/privacy/blindIndex.d.ts.map +1 -0
  78. package/dist/privacy/blindIndex.js +57 -0
  79. package/dist/privacy/classes.d.ts +142 -0
  80. package/dist/privacy/classes.d.ts.map +1 -0
  81. package/dist/privacy/classes.js +209 -0
  82. package/dist/privacy/classify.d.ts +336 -0
  83. package/dist/privacy/classify.d.ts.map +1 -0
  84. package/dist/privacy/classify.js +1024 -0
  85. package/dist/privacy/encrypted.d.ts +210 -0
  86. package/dist/privacy/encrypted.d.ts.map +1 -0
  87. package/dist/privacy/encrypted.js +350 -0
  88. package/dist/privacy/encryptedColumns.d.ts +64 -0
  89. package/dist/privacy/encryptedColumns.d.ts.map +1 -0
  90. package/dist/privacy/encryptedColumns.js +134 -0
  91. package/dist/privacy/fieldRotation.d.ts +34 -0
  92. package/dist/privacy/fieldRotation.d.ts.map +1 -0
  93. package/dist/privacy/fieldRotation.js +186 -0
  94. package/dist/privacy/inventory.d.ts +12 -0
  95. package/dist/privacy/inventory.d.ts.map +1 -0
  96. package/dist/privacy/inventory.js +154 -0
  97. package/dist/privacy/posture.d.ts +114 -0
  98. package/dist/privacy/posture.d.ts.map +1 -0
  99. package/dist/privacy/posture.js +136 -0
  100. package/dist/privacy/redact.d.ts +7 -0
  101. package/dist/privacy/redact.d.ts.map +1 -0
  102. package/dist/privacy/redact.js +404 -0
  103. package/dist/privacy/report.d.ts +58 -0
  104. package/dist/privacy/report.d.ts.map +1 -0
  105. package/dist/privacy/report.js +15 -0
  106. package/dist/privacy/retirement.d.ts +68 -0
  107. package/dist/privacy/retirement.d.ts.map +1 -0
  108. package/dist/privacy/retirement.js +72 -0
  109. package/dist/privacy/sealed.d.ts +87 -0
  110. package/dist/privacy/sealed.d.ts.map +1 -0
  111. package/dist/privacy/sealed.js +221 -0
  112. package/dist/privacy/types.d.ts +559 -0
  113. package/dist/privacy/types.d.ts.map +1 -0
  114. package/dist/privacy/types.js +2 -0
  115. package/dist/version.generated.d.ts +1 -1
  116. package/dist/version.generated.js +1 -1
  117. package/package.json +1 -1
  118. package/src/audit/composed.ts +30 -0
  119. package/src/audit/recorder.ts +10 -2
  120. package/src/capability/capability.ts +26 -0
  121. package/src/capability/purge.ts +187 -0
  122. package/src/cli/commandCatalog.generated.ts +65 -0
  123. package/src/cli/commandFacts.ts +1 -0
  124. package/src/controlPlane/capability.ts +11 -0
  125. package/src/controlPlane/discovery/adminRoute.ts +11 -0
  126. package/src/controlPlane/error/errors.ts +4 -3
  127. package/src/controlPlane/http/guard.ts +60 -1
  128. package/src/controlPlane/http/handlers.ts +57 -4
  129. package/src/controlPlane/http/responses.ts +100 -0
  130. package/src/controlPlane/http/routes.ts +31 -1
  131. package/src/controlPlane/scope/scope.ts +14 -3
  132. package/src/controlPlane/token/base64url.ts +1 -1
  133. package/src/createBackend.ts +4 -0
  134. package/src/createEntrypoint.ts +17 -2
  135. package/src/crypto/envelope.ts +196 -0
  136. package/src/crypto/fieldKeys.ts +76 -0
  137. package/src/data/codecs.ts +69 -37
  138. package/src/error/extend.ts +6 -5
  139. package/src/error/payload.ts +110 -0
  140. package/src/error/pithyError.ts +21 -7
  141. package/src/http/validation.ts +35 -3
  142. package/src/index.ts +1 -0
  143. package/src/logger/logger.ts +12 -5
  144. package/src/privacy/blindIndex.ts +81 -0
  145. package/src/privacy/classes.ts +277 -0
  146. package/src/privacy/classify.ts +1447 -0
  147. package/src/privacy/encrypted.ts +513 -0
  148. package/src/privacy/encryptedColumns.ts +185 -0
  149. package/src/privacy/fieldRotation.ts +234 -0
  150. package/src/privacy/inventory.ts +147 -0
  151. package/src/privacy/posture.ts +205 -0
  152. package/src/privacy/redact.ts +423 -0
  153. package/src/privacy/report.ts +64 -0
  154. package/src/privacy/retirement.ts +102 -0
  155. package/src/privacy/sealed.ts +323 -0
  156. package/src/privacy/types.ts +804 -0
  157. 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.13.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "../capability/capability";
5
+ import { type AuditEmit, noopEmit } from "./recorder";
6
+
7
+ /**
8
+ * The audit recorder for code with no request — a Workflow step (#622).
9
+ *
10
+ * A route records through `c.var.emit`. A Workflow has no `c`: the runtime constructs it with `env` and nothing
11
+ * else. So it finds the composed capability that declares {@link Capability.auditRecorder} — `@pithy-sh/audit`
12
+ * — and records through that, built over this run's env. With none composed it is {@link noopEmit}, exactly as
13
+ * `c.var.emit` is in a Worker that composes no audit, so a step can always record without asking first.
14
+ *
15
+ * **Non-fatal, whatever the recorder does.** The contract says a recorder never throws; this holds a recorder
16
+ * to it rather than trusting it, because what a step records has already happened and a throw here would turn
17
+ * it into a retried step.
18
+ */
19
+ export function composedAuditEmit(capabilities: readonly Capability[], env: Record<string, unknown>): AuditEmit {
20
+ const provider = capabilities.find((capability) => capability.auditRecorder !== undefined);
21
+ if (!provider?.auditRecorder) return noopEmit;
22
+ const record = provider.auditRecorder(env);
23
+ return async (event) => {
24
+ try {
25
+ await record(event);
26
+ } catch {
27
+ // The recorder broke its own contract. The event is lost, and the step it recorded stands.
28
+ }
29
+ };
30
+ }
@@ -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. */
@@ -632,6 +633,17 @@ export interface Capability<
632
633
  * capability that declares one (e.g. `@pithy-sh/email`'s bounce/complaint handler).
633
634
  */
634
635
  email?: CapabilityEmailHandler;
636
+ /**
637
+ * The audit recorder this capability provides **for code that has no request** — a Workflow step, which the
638
+ * runtime constructs with `env` and nothing else (#622). `@pithy-sh/audit` declares it; nothing else does.
639
+ *
640
+ * A route records through `c.var.emit`, which the same capability's middleware fills, and keeps doing that.
641
+ * This is the other door, for the purge that erases a deleted account: it runs in a Workflow and still owes
642
+ * the trail its event. `../audit/composed.ts` finds it among the composed capabilities, so a capability that
643
+ * audits from a step imports no audit package — principle 4, the same reason `c.var.emit` is a seam. Built
644
+ * from the env per call, like the request recorder is built per request. Non-fatal by the same contract.
645
+ */
646
+ auditRecorder?: (env: Record<string, unknown>) => AuditEmit;
635
647
  /**
636
648
  * Named D1 databases this capability contributes tables to (database name → {@link DatabaseSpec}).
637
649
  * `createBackend` merges every capability's slices per database (via `composeDatabases`) and serves
@@ -658,6 +670,17 @@ export interface Capability<
658
670
  * time, and read by `pithy doctor`.
659
671
  */
660
672
  extensions?: readonly CapabilityExtension[];
673
+ /**
674
+ * How this capability erases one subject's rows when that subject is purged — see `./purge.ts`. Omit it
675
+ * when this capability holds nothing keyed to a person, which is the ordinary case.
676
+ *
677
+ * **The purge Workflow runs each composed contribution as its own step**, named for this capability, so a
678
+ * failure retries this capability alone and the steps that finished are not re-run. That is why there is
679
+ * at most one per subject here, and why `erase` must succeed when there is nothing left to erase.
680
+ * `defineCapability` refuses a contribution for a subject the kit does not know. An adopter declares one
681
+ * on their own `app` capability for their own tables, with the same types.
682
+ */
683
+ purge?: readonly PurgeContribution[];
661
684
  /**
662
685
  * The role powers this capability's own routes gate on, through the acting-organization seam —
663
686
  * `media:manage`, `storage:manage`. Omit it when a capability asks no power, which is the normal case.
@@ -700,6 +723,9 @@ export function defineCapability<
700
723
  for (const spec of Object.values(input.databases ?? {})) {
701
724
  if (spec.migrations && spec.retained) declareRetained(spec.migrations, spec.retained);
702
725
  }
726
+ // A purge contribution no purge could run — a subject the kit does not know, or two for one — is refused
727
+ // where it is written, naming the capability, rather than on the day somebody is erased.
728
+ if (input.purge) assertPurgeContributions(input.name, input.purge);
703
729
  return {
704
730
  ...input,
705
731
  requiredBindings: input.requiredBindings.map((binding) => BindingSpec.parse(binding)),
@@ -0,0 +1,187 @@
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
+ * ## The same contribution answers an export (#622)
27
+ *
28
+ * An access request arrives before an erasure, not after. So a contribution may also say what this capability
29
+ * holds about the subject, on the read side: `export` answers one JSON object, and the control plane's export
30
+ * route assembles one document from every composed contribution, in the same order the purge runs them. A
31
+ * contribution with no `export` is listed as holding rows it did not say how to export, which is the honest
32
+ * answer and the one an operator can act on.
33
+ *
34
+ * ## An adopter declares one too
35
+ *
36
+ * On their own `app` capability, for their own tables, with the same types. The contract is exported for
37
+ * exactly that, and nothing distinguishes a kit contribution from theirs.
38
+ */
39
+
40
+ /**
41
+ * The kinds of subject a purge erases. Closed, so a typo is refused where it is written rather than becoming
42
+ * a contribution no purge ever asks for. `organization` joined with organization state (#623): a capability
43
+ * holding rows scoped to a tenant declares one for it, and the organization purge runs it.
44
+ */
45
+ export const PurgeSubject = z
46
+ .enum(["user", "organization"])
47
+ .describe(
48
+ "The kind of subject a purge erases. `user` is one person's account in `@pithy-sh/auth`, identified by its user id. `organization` is one tenant in `@pithy-sh/organization`, identified by its organization id.",
49
+ );
50
+ export type PurgeSubject = z.output<typeof PurgeSubject>;
51
+
52
+ /** What one step is handed: who is being erased, and this run's bindings. */
53
+ export interface PurgeContext {
54
+ /** The subject being erased — its kind, and its id in that kind's own table. */
55
+ readonly subject: { readonly type: PurgeSubject; readonly id: string };
56
+ /** The Worker's bindings for this run, from which the contribution opens its own database. */
57
+ readonly env: Record<string, unknown>;
58
+ }
59
+
60
+ /** One capability's answer to "how are this subject's rows erased". */
61
+ export interface PurgeContribution {
62
+ /** The kind of subject this erases. */
63
+ readonly subject: PurgeSubject;
64
+ /**
65
+ * One sentence on what is erased or anonymized — `Deletes the user's uploads and their stored bytes.`
66
+ * Recorded with the step that ran it, so the trail says what a purge did and not only that it ran.
67
+ */
68
+ readonly summary: string;
69
+ /**
70
+ * Erase or anonymize this subject's rows. **Idempotent**: run again after it succeeded, or after a failure
71
+ * that came once the rows were gone, it must succeed having found nothing to do. Throwing fails this step
72
+ * and only this step.
73
+ */
74
+ readonly erase: (context: PurgeContext) => Promise<void>;
75
+ /**
76
+ * What this capability holds about the subject, as one JSON object — the read side of `erase` (#622). It
77
+ * answers an access request, so it carries the subject's own data and never a credential: no session token,
78
+ * no provider token, no push token. Optional: a contribution without one is listed in the export as rows it
79
+ * erases and did not say how to export. A throw costs this capability's entry, never the document.
80
+ */
81
+ readonly export?: (context: PurgeContext) => Promise<Record<string, unknown>>;
82
+ }
83
+
84
+ /** One step of a purge: which capability it runs, and the contribution that capability declared. */
85
+ export interface PurgeStep {
86
+ /** The step's name — the capability's, so a retried step names who failed. */
87
+ readonly step: string;
88
+ /** The contribution this step runs. */
89
+ readonly contribution: PurgeContribution;
90
+ }
91
+
92
+ /**
93
+ * Refuse a capability's purge contributions that no purge could run as declared: an unknown subject, two for
94
+ * one subject, or one with nothing to record. Called by `defineCapability`, so the refusal names the capability
95
+ * where it is written rather than surfacing in a Workflow on the day somebody is deleted.
96
+ */
97
+ export function assertPurgeContributions(capability: string, contributions: readonly PurgeContribution[]): void {
98
+ const seen = new Set<string>();
99
+ for (const contribution of contributions) {
100
+ const subject = PurgeSubject.safeParse(contribution.subject);
101
+ if (!subject.success) {
102
+ throw new ValidationError({
103
+ message: `The ${capability} capability declares a purge for "${String(contribution.subject)}", which is not a subject a purge erases.`,
104
+ action: `Declare it for one of: ${PurgeSubject.options.join(", ")}.`,
105
+ });
106
+ }
107
+ if (seen.has(subject.data)) {
108
+ throw new ValidationError({
109
+ message: `The ${capability} capability declares two purge contributions for ${subject.data}.`,
110
+ action: "Fold them into one: each capability is one step of the purge, and a retried step re-runs all of it.",
111
+ });
112
+ }
113
+ seen.add(subject.data);
114
+ if (contribution.summary.trim() === "") {
115
+ throw new ValidationError({
116
+ message: `The ${capability} capability's ${subject.data} purge has no summary.`,
117
+ action: "Say in one sentence what it erases. The purge records it with the step.",
118
+ });
119
+ }
120
+ if (contribution.export !== undefined && typeof contribution.export !== "function") {
121
+ throw new ValidationError({
122
+ message: `The ${capability} capability's ${subject.data} purge has an export that is not a function.`,
123
+ action: "Give it an `export` that answers what this capability holds about the subject, or leave it out.",
124
+ });
125
+ }
126
+ if (typeof contribution.erase !== "function") {
127
+ throw new ValidationError({
128
+ message: `The ${capability} capability's ${subject.data} purge has no erase function.`,
129
+ action: "Give it an `erase` that removes or anonymizes the subject's rows.",
130
+ });
131
+ }
132
+ }
133
+ }
134
+
135
+ /** The steps a purge of `subject` runs, in composition order — libraries first, the adopter's `app` last. */
136
+ export function purgeSteps(
137
+ capabilities: readonly { readonly name: string; readonly purge?: readonly PurgeContribution[] }[],
138
+ subject: PurgeSubject,
139
+ ): PurgeStep[] {
140
+ const steps: PurgeStep[] = [];
141
+ for (const capability of capabilities) {
142
+ const contribution = capability.purge?.find((candidate) => candidate.subject === subject);
143
+ if (contribution) steps.push({ step: capability.name, contribution });
144
+ }
145
+ return steps;
146
+ }
147
+
148
+ /** What one step's export answered: its data, that it declared none, or that it could not be read. */
149
+ export type PurgeExportEntry =
150
+ | {
151
+ readonly capability: string;
152
+ readonly summary: string;
153
+ readonly state: "exported";
154
+ readonly data: Record<string, unknown>;
155
+ }
156
+ | { readonly capability: string; readonly summary: string; readonly state: "not_exported" }
157
+ | { readonly capability: string; readonly summary: string; readonly state: "unavailable" };
158
+
159
+ /** Whether an export answered what the contract promises: one plain object. */
160
+ function isRecord(value: unknown): value is Record<string, unknown> {
161
+ return typeof value === "object" && value !== null && !Array.isArray(value);
162
+ }
163
+
164
+ /**
165
+ * Read every step's export for one subject — the export document's body, in step order (#622).
166
+ *
167
+ * **Each read is guarded on its own and they run concurrently**, the shape the user pane's sub-reads have
168
+ * (#380): one capability whose table will not read costs its own entry as `unavailable`, and the rest of the
169
+ * document still answers the request. What the read threw is not carried — it names a query and a table, and
170
+ * the document crosses a trust boundary. An answer that is not one object is `unavailable` too, because the
171
+ * document promises an object per capability and a client validates it.
172
+ */
173
+ export async function purgeExports(steps: readonly PurgeStep[], context: PurgeContext): Promise<PurgeExportEntry[]> {
174
+ return Promise.all(
175
+ steps.map(async ({ step, contribution }): Promise<PurgeExportEntry> => {
176
+ const base = { capability: step, summary: contribution.summary };
177
+ const read = contribution.export;
178
+ if (read === undefined) return { ...base, state: "not_exported" };
179
+ try {
180
+ const data = await read(context);
181
+ return isRecord(data) ? { ...base, state: "exported", data } : { ...base, state: "unavailable" };
182
+ } catch {
183
+ return { ...base, state: "unavailable" };
184
+ }
185
+ }),
186
+ );
187
+ }
@@ -899,6 +899,16 @@ export const COMMANDS = {
899
899
  "invite",
900
900
  "testerNudge",
901
901
  "passwordChanged",
902
+ "accountSuspended",
903
+ "accountReinstated",
904
+ "deletionScheduled",
905
+ "deletionComplete",
906
+ "organizationMemberAdded",
907
+ "organizationMemberRemoved",
908
+ "organizationOwnershipTransferred",
909
+ "organizationSuspended",
910
+ "organizationReinstated",
911
+ "organizationDeletionScheduled",
902
912
  "operationalNotice",
903
913
  "newsletter",
904
914
  "leadCapture",
@@ -1230,6 +1240,33 @@ export const COMMANDS = {
1230
1240
  },
1231
1241
  "groups": []
1232
1242
  },
1243
+ "privacy enable": {
1244
+ "positionals": [],
1245
+ "flags": {
1246
+ "--class": {
1247
+ "type": "string",
1248
+ "required": false,
1249
+ "repeatable": true
1250
+ },
1251
+ "--json": {
1252
+ "type": "boolean",
1253
+ "required": false,
1254
+ "repeatable": false
1255
+ }
1256
+ },
1257
+ "groups": []
1258
+ },
1259
+ "privacy scan": {
1260
+ "positionals": [],
1261
+ "flags": {
1262
+ "--json": {
1263
+ "type": "boolean",
1264
+ "required": false,
1265
+ "repeatable": false
1266
+ }
1267
+ },
1268
+ "groups": []
1269
+ },
1233
1270
  "provision": {
1234
1271
  "positionals": [],
1235
1272
  "flags": {
@@ -1433,6 +1470,33 @@ export const COMMANDS = {
1433
1470
  },
1434
1471
  "groups": []
1435
1472
  },
1473
+ "secrets retire-version": {
1474
+ "positionals": [
1475
+ {
1476
+ "name": "version",
1477
+ "required": true,
1478
+ "variadic": false
1479
+ }
1480
+ ],
1481
+ "flags": {
1482
+ "--env": {
1483
+ "type": "string",
1484
+ "required": false,
1485
+ "repeatable": false
1486
+ },
1487
+ "--past-guard": {
1488
+ "type": "boolean",
1489
+ "required": false,
1490
+ "repeatable": false
1491
+ },
1492
+ "--json": {
1493
+ "type": "boolean",
1494
+ "required": false,
1495
+ "repeatable": false
1496
+ }
1497
+ },
1498
+ "groups": []
1499
+ },
1436
1500
  "secrets rm": {
1437
1501
  "positionals": [
1438
1502
  {
@@ -2711,6 +2775,7 @@ export const GROUPS = [
2711
2775
  "feature",
2712
2776
  "media",
2713
2777
  "payments",
2778
+ "privacy",
2714
2779
  "secrets",
2715
2780
  "storage",
2716
2781
  "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`.
@@ -186,7 +186,21 @@ export function requireControlPlane(requirement: ControlPlaneRequirement): Middl
186
186
  },
187
187
  c.var.log,
188
188
  );
189
- await next();
189
+ // A handler can refuse a call the gate's requirement let through — an operator roster write that
190
+ // would move the owning role is refused `controlplane/insufficient_scope`, naming the ownership route,
191
+ // even on a connection granted `organization:ownership:transfer`, because a token exercises the one
192
+ // scope it was minted for — and it refuses it itself, past this gate. That refusal is still a blocked attempt, and the trail must
193
+ // say so: `call_allowed` alone would hide a credential probing for the grant it lacks.
194
+ //
195
+ // Both paths, as the organization routes' refusal recorder reads them: a throw that reaches here, and
196
+ // `c.error`, which is where Hono's `compose` leaves a downstream throw it rendered through `onError`.
197
+ try {
198
+ await next();
199
+ } catch (error) {
200
+ await auditHandlerDenial(c, requirement, context, error);
201
+ throw error;
202
+ }
203
+ await auditHandlerDenial(c, requirement, context, c.error);
190
204
  };
191
205
  (guard as unknown as Record<symbol, unknown>)[CONTROL_PLANE_GUARD] = true;
192
206
  return guard;
@@ -245,6 +259,51 @@ async function auditDenial(
245
259
  );
246
260
  }
247
261
 
262
+ /**
263
+ * Record a scope refusal a handler raised after the gate let the call in.
264
+ *
265
+ * Only `controlplane/insufficient_scope`: every other refusal past the gate is the handler's own to
266
+ * record, and a 404 or a validation error is not a credential reaching beyond its grant. The same row as
267
+ * {@link auditDenial}, with two differences that follow from the call having been verified: the actor is
268
+ * the proven subject and its connection, and `refusedBy` says the gate passed it — `required` is the
269
+ * gate's requirement, which this call met; what it lacked is in the log, with the rest of `detail`.
270
+ */
271
+ async function auditHandlerDenial(
272
+ c: Parameters<MiddlewareHandler<PithyHonoEnv>>[0],
273
+ requirement: ControlPlaneRequirement,
274
+ context: ControlPlaneContext,
275
+ cause: unknown,
276
+ ): Promise<void> {
277
+ // Read by its code, not `instanceof`: a capability that throws it may hold its own copy of this module,
278
+ // and a refusal that slipped past a class check would be exactly the unaudited attempt this exists for.
279
+ const code = (cause as { payload?: { code?: unknown } } | null)?.payload?.code;
280
+ if (code !== "controlplane/insufficient_scope") return;
281
+ await safeEmit(
282
+ c.var.emit,
283
+ {
284
+ action: ControlPlaneAuditActions.callDenied,
285
+ outcome: "denied",
286
+ severity: "warning",
287
+ actorType: "control-plane",
288
+ actorId: context.subject,
289
+ resourceType: "controlplane_connection",
290
+ resourceId: context.connectionId,
291
+ requestId: c.req.header("cf-ray"),
292
+ ip: c.req.header("cf-connecting-ip"),
293
+ userAgent: c.req.header("user-agent"),
294
+ metadata: {
295
+ reason: code,
296
+ required: String(requirement),
297
+ refusedBy: "handler",
298
+ connectionId: context.connectionId,
299
+ method: c.req.method,
300
+ path: c.req.path,
301
+ },
302
+ },
303
+ c.var.log,
304
+ );
305
+ }
306
+
248
307
  /**
249
308
  * The step that connects a management client, as this deployed Worker can name it: with its own environment when
250
309
  * it knows it, and never with a Worker — it cannot know the `apps/` directory that provisioned it (#711).
@@ -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
+ }