@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
@@ -0,0 +1,205 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { PrivacyClassName } from "./classes";
6
+ import { PRIVACY_SOURCES, type PrivacyInventory } from "./report";
7
+ import { RetirementVerdict } from "./retirement";
8
+
9
+ /**
10
+ * **What a project declares about privacy, and what its running code holds — side by side** (#132).
11
+ *
12
+ * Two facts with two provenances, and the asymmetry between them is the useful part.
13
+ *
14
+ * - **Declared** is an **attestation**. The root `pithy.config.ts` names the classes a project handles
15
+ * (`privacy: { classes: ["pii", "phi"] }`), and `pithy deploy` writes them, with the console gate's verdict, into
16
+ * {@link PRIVACY_ATTESTATION_VAR} on every Worker it ships — the mechanism a Worker already learns its own name
17
+ * and project by. It is dated, and it describes the last deploy Pithy performed.
18
+ * - **Observed** is a **derivation** — the inventory, walked from the composed schemas inside the Worker. It
19
+ * cannot be edited and cannot be stale.
20
+ *
21
+ * An attestation that disagrees with what the running code holds is itself the finding, which is why both are
22
+ * reported and neither is an error Pithy raises. A project declaring `phi` with no PHI field marked is an
23
+ * unfinished migration; one marking `phi` it never declared is a posture change nobody escalated.
24
+ *
25
+ * ## How stale an attestation can be
26
+ *
27
+ * **Usually it is absent rather than stale.** `pithy deploy` passes the var on wrangler's argv (`--var`), and a
28
+ * plain `wrangler deploy` uploads only the vars its config names — wrangler sends `keep_bindings` for plain-text
29
+ * vars only under `keep_vars: true` (read in wrangler 4.151.0's deploy path) — so a deploy Pithy did not perform
30
+ * drops the attestation, and the posture reads `unattested`. Under `keep_vars: true` the var survives a raw
31
+ * deploy and describes an older build. The Cloudflare version id cannot pair the two, because it is assigned at
32
+ * upload, after the var is written; the manifest carries {@link PrivacyAttestation.attestedAt} beside the
33
+ * version's own creation time instead, and a version created long after its attestation is that case.
34
+ */
35
+
36
+ /** The var `pithy deploy` writes the attestation into. JSON, parsed by {@link privacyAttestation}. */
37
+ export const PRIVACY_ATTESTATION_VAR = "PITHY_PRIVACY";
38
+
39
+ /**
40
+ * The console gate's verdict at the deploy that wrote the attestation. `off` never appears: `pithy deploy`
41
+ * refuses a project whose rule is off, so no attestation is ever written for one.
42
+ */
43
+ export const ConsoleGateVerdict = z
44
+ .enum(["enforced", "unverifiable", "not_required"])
45
+ .describe(
46
+ "The console gate at the last `pithy deploy`: `enforced` (a Biome config was read and `suspicious/noConsole` is on), `unverifiable` (no Biome config Pithy could read — never reported as enforced), or `not_required` (the project declares no privacy class, so the gate does not apply). `off` never ships: deploy refuses it.",
47
+ );
48
+ export type ConsoleGateVerdict = z.output<typeof ConsoleGateVerdict>;
49
+
50
+ /** What `pithy deploy` attests on a Worker it ships. */
51
+ export const PrivacyAttestation = z
52
+ .object({
53
+ classes: z
54
+ .array(PrivacyClassName)
55
+ .describe("The classes the root `pithy.config.ts` declared at that deploy. Empty when it declared none."),
56
+ consoleGate: ConsoleGateVerdict.describe("The console gate's verdict at that deploy."),
57
+ reason: z
58
+ .string()
59
+ .nullable()
60
+ .describe("Why the gate is `unverifiable`, in one sentence. Null for every other verdict."),
61
+ attestedAt: z.iso.datetime().describe("When `pithy deploy` wrote this attestation, ISO-8601."),
62
+ })
63
+ .describe(
64
+ "The privacy declaration `pithy deploy` writes into a Worker's `PITHY_PRIVACY` var: an attestation, dated.",
65
+ );
66
+ export type PrivacyAttestation = z.output<typeof PrivacyAttestation>;
67
+
68
+ /**
69
+ * Read the attestation off a Worker's `env`. **Never throws**: an absent var, a value that is not JSON, and one
70
+ * that does not parse are all `null`, which the posture reports as `unattested`. A string is parsed; an object
71
+ * — what wrangler hands a JSON-typed var — is taken as it is.
72
+ */
73
+ export function privacyAttestation(env: unknown): PrivacyAttestation | null {
74
+ if (typeof env !== "object" || env === null) return null;
75
+ const raw = (env as Record<string, unknown>)[PRIVACY_ATTESTATION_VAR];
76
+ if (raw === undefined || raw === null) return null;
77
+ let value: unknown = raw;
78
+ if (typeof raw === "string") {
79
+ try {
80
+ value = JSON.parse(raw);
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+ const parsed = PrivacyAttestation.safeParse(value);
86
+ return parsed.success ? parsed.data : null;
87
+ }
88
+
89
+ /** The console gate as the manifest reports it: the attested verdict, or `unattested` when there is none. */
90
+ export const ConsoleGateState = z
91
+ .enum(["enforced", "unverifiable", "not_required", "unattested"])
92
+ .describe(
93
+ "The console gate, as last attested: `enforced`, `unverifiable` (no readable Biome config — rendered as unverified, never as enforced), `not_required` (no class declared), or `unattested` (no `pithy deploy` attestation reached this Worker: a raw `wrangler deploy`, or a deploy from before this release).",
94
+ );
95
+ export type ConsoleGateState = z.output<typeof ConsoleGateState>;
96
+
97
+ /**
98
+ * The privacy block on `GET /control-plane/manifest`. Counts and classes only — no field names.
99
+ *
100
+ * **A project that declared nothing costs nothing.** When the attestation says no class is declared, the block
101
+ * says exactly that and the walk does not run: `observed`, `counts` and both deltas are null, and `sources` is
102
+ * empty, because nothing was looked at. That is the "behaves exactly as it does today" a project omitting the
103
+ * key is promised. Every other case walks — a declared project to compare against, and an **unattested** one
104
+ * because a Worker shipped around `pithy deploy` is precisely the one whose holdings someone needs to see.
105
+ */
106
+ export const PrivacyPosture = z
107
+ .object({
108
+ declared: z
109
+ .array(z.string().describe("A class name."))
110
+ .nullable()
111
+ .describe(
112
+ "The classes the project declared, as attested by the last `pithy deploy`. Empty when it declared none; null when no attestation reached this Worker.",
113
+ ),
114
+ observed: z
115
+ .array(z.string().describe("A class name."))
116
+ .nullable()
117
+ .describe(
118
+ "Every class with at least one marked field in this Worker's storage schemas, derived at runtime. Null when the project declared no class, because nothing was walked.",
119
+ ),
120
+ counts: z
121
+ .record(z.string(), z.number().int().nonnegative())
122
+ .nullable()
123
+ .describe(
124
+ "Marked fields per observed class. A class with none is absent rather than zero. Null when nothing was walked.",
125
+ ),
126
+ observedNotDeclared: z
127
+ .array(z.string().describe("A class name."))
128
+ .nullable()
129
+ .describe(
130
+ "Classes the code holds that the project never declared — a posture change nobody escalated. Null when nothing was walked.",
131
+ ),
132
+ declaredNotObserved: z
133
+ .array(z.string().describe("A class name."))
134
+ .nullable()
135
+ .describe(
136
+ "Classes declared with no marked field behind them — an unfinished migration. Null when nothing was walked.",
137
+ ),
138
+ consoleGate: ConsoleGateState.describe("The console gate, as last attested."),
139
+ consoleGateReason: z
140
+ .string()
141
+ .nullable()
142
+ .describe("Why the gate is `unverifiable`, in one sentence. Null otherwise."),
143
+ attestedAt: z.iso
144
+ .datetime()
145
+ .nullable()
146
+ .describe(
147
+ "When the attestation was written, ISO-8601, or null when there is none. A version created long after it is a build the attestation does not describe.",
148
+ ),
149
+ sources: z
150
+ .array(z.enum(PRIVACY_SOURCES).describe("A storage kind: `d1` tables, or `kv` stores."))
151
+ .describe(
152
+ "The storage the observed classes were derived from. Anything not listed was not walked; empty when nothing was.",
153
+ ),
154
+ retirement: RetirementVerdict.optional().describe(
155
+ "The field key's retirement guard (#390): whether a version due to leave is held live, and by what. Absent on a Worker whose core predates encrypted fields.",
156
+ ),
157
+ })
158
+ .describe(
159
+ "What this project declares about privacy and what its running code holds. Declared is an attestation dated to the last `pithy deploy`; observed is derived from the deployed schemas and cannot be edited.",
160
+ );
161
+ export type PrivacyPosture = z.output<typeof PrivacyPosture>;
162
+
163
+ /** Whether an attestation says, explicitly, that the project declares no class — the one case that walks nothing. */
164
+ export function declaresNothing(attestation: PrivacyAttestation | null): boolean {
165
+ return attestation !== null && attestation.classes.length === 0;
166
+ }
167
+
168
+ /**
169
+ * The posture: the attestation beside the inventory, and the delta both ways. `inventory` is a thunk, read only
170
+ * when something is declared or nothing is attested — see {@link PrivacyPosture}.
171
+ */
172
+ export function privacyPosture(
173
+ attestation: PrivacyAttestation | null,
174
+ inventory: () => PrivacyInventory,
175
+ ): PrivacyPosture {
176
+ const gate = {
177
+ consoleGate: attestation?.consoleGate ?? "unattested",
178
+ consoleGateReason: attestation?.reason ?? null,
179
+ attestedAt: attestation?.attestedAt ?? null,
180
+ } as const;
181
+ if (declaresNothing(attestation)) {
182
+ return {
183
+ declared: [],
184
+ observed: null,
185
+ counts: null,
186
+ observedNotDeclared: null,
187
+ declaredNotObserved: null,
188
+ ...gate,
189
+ sources: [],
190
+ };
191
+ }
192
+ const walked = inventory();
193
+ const declared = attestation?.classes ?? null;
194
+ const declaredSet = new Set(declared ?? []);
195
+ const observedSet = new Set(walked.observed);
196
+ return {
197
+ declared: declared === null ? null : [...declared],
198
+ observed: [...walked.observed],
199
+ counts: { ...walked.counts },
200
+ observedNotDeclared: walked.observed.filter((name) => !declaredSet.has(name)),
201
+ declaredNotObserved: (declared ?? []).filter((name) => !observedSet.has(name)),
202
+ ...gate,
203
+ sources: [...walked.sources],
204
+ };
205
+ }
@@ -0,0 +1,423 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import { privacyClass } from "./classes";
6
+ import { MAX_RECURSION_DEPTH, type Mask, type Position, rootPosition, TRUNCATED_MASK } from "./classify";
7
+ import type { RedactedOutput } from "./types";
8
+
9
+ /**
10
+ * **The masked view: safe for a log, an audit record and an error detail** (#132).
11
+ *
12
+ * `redact(User, user)` walks the schema and the value together and returns a copy in which every unmarked field
13
+ * is verbatim and every marked one is its class's mask — `[pii]`, `[phi]`, `[secret]`, a PAN's first six and
14
+ * last four, or whatever an adopter's class says. The result carries no brand, so it compiles into every
15
+ * surface the type gate guards.
16
+ *
17
+ * ## The contract
18
+ *
19
+ * `redact(schema, value)` promises exact results for a stated set of schema shapes and documents everything else. It
20
+ * does not try to defend against every construction Zod allows, because a schema built to dodge it can be built, and
21
+ * following every such construction costs more than it protects. What it covers, it covers perfectly; what it does not,
22
+ * it masks whole and says so.
23
+ *
24
+ * **Redacted exactly.** Every marked field is masked with its class placeholder, every unmarked field is kept as
25
+ * `parse` returned it, nothing is dropped, and `Redacted<T>` is the exact type of the result, save three positions it
26
+ * types `unknown` rather than guess: one that nests wrappers and choices deeper than the type can follow, an
27
+ * intersection with a record on either side, because a record's masked keys rename the other side's keys too, and a
28
+ * `.catch()` over an object, an array or another structure with a mark beneath, because the type cannot tell a
29
+ * constant fallback from a function.
30
+ *
31
+ * 1. Rows and objects: `z.object`, strict, loose or with a catchall, and `pick`, `omit`, `partial` and `extend` of one.
32
+ * A mark on a whole object does not survive Zod's object methods, `extend`, `safeExtend`, `merge`, `pick`, `omit`,
33
+ * `partial`, `required`, `strict` and `catchall`, because each builds a fresh schema: mark the object after
34
+ * deriving it. A field marked inside survives every one.
35
+ * 2. Collections: arrays, tuples including rest elements, records with marked keys or values, `Map` and `Set`.
36
+ * 3. Wrappers: `optional`, `nullable`, `default`, `catch` with a constant value, `readonly`, `brand`.
37
+ * 4. Choices: unions, discriminated unions and intersections. An option's marks apply where the value has that option's
38
+ * shape: an object option to an object value, an array option to an array value. A field is masked when any option
39
+ * or side that applies marks it, and a key no option names is kept. Where one side of an intersection narrows a
40
+ * field so it can never hold the type the other side marks, `classifyPrivacy` may still list that field: the
41
+ * inventory can list more than `redact` masks, and never less.
42
+ * 5. The kit's database codecs: `SQLiteDate`, `SQLiteBoolean` and `sqliteJson(schema)`. A JSON column's inner fields
43
+ * are redacted field by field.
44
+ * 6. Recursion and depth: `z.lazy(() => Node)` and getters, whether a getter returns one schema or builds a new one
45
+ * each read, such as trees and linked lists. Walked exactly to a fixed depth, 256 levels of nesting, unless the
46
+ * call's budget ends the walk sooner. One call of `redact`, `classifyPrivacy`, `hasPrivacy` or `walkPrivacy` reads
47
+ * at most 10,000 schemas, and every position and every search in it draws on that one budget. A call's time and
48
+ * memory grow linearly with the size of the schema and the value, and are bounded beyond that by the budget and by
49
+ * what the schema's own getters build. A schema that builds new ones as it is read can spend the budget first. Past
50
+ * the budget the call fails closed: `redact` masks each position it has not resolved whole, with the strongest class
51
+ * it found there, or as `[truncated]` where it found none, and `classifyPrivacy` lists every class at each position
52
+ * it has not read, each `truncated`. The depth limit applies to all nesting, not only recursion: anything nested
53
+ * deeper than the limit is masked `[truncated]`, a safety cutoff rather than a privacy class, and the inventory
54
+ * assumes every class there unless the chain repeats a position (the same set of schemas) already read. A recursion
55
+ * that repeats a position (the same set of schemas) already read on its path needs no cut: its classes are exactly
56
+ * those of the position already read, so a tree or a linked list defined once is listed exactly, with no `truncated`
57
+ * entry. A position is every schema that may hold the value there, so recursions defined once that run side by side
58
+ * in one union, with cycles of different lengths, can reach the limit before any position repeats, though each
59
+ * schema on the chain is one already read: there the inventory assumes every class, each `truncated`, and lists more
60
+ * than is marked, never less. Where the walk reaches the limit on a chain that keeps producing positions not yet
61
+ * seen on that path, a factory recursion or plain nesting deeper than the limit, `classifyPrivacy` lists every class
62
+ * at that position, each `truncated`, beside any class it found there, exactly as past the budget, and `hasPrivacy`
63
+ * answers yes for each. So the list never says less than is marked.
64
+ * `Redacted<T>` types a position past the limit `unknown` from that same depth: one limit, shared by the runtime and
65
+ * the type, however the nesting is written. A self-referencing `z.lazy` has to be annotated `z.ZodType<T>`, which
66
+ * names no kind, so `Redacted<T>` types it, and every field that holds it, `unknown`. Write the recursion with a
67
+ * getter and the type keeps its shape to the limit.
68
+ * 7. Values copied out of a parsed row into a plain object, array, `Map` or `Set` described by any of the above.
69
+ *
70
+ * **Outside the contract.** Custom transforms and pipes, `preprocess`, codecs other than the kit's own (one built the
71
+ * way `sqliteJson` is built included), `z.custom`, `any`, `unknown`, `.overwrite()` and `.catch()` with a fallback
72
+ * function. `redact` does not look inside these. When anything beneath one is marked, the whole value is masked with
73
+ * the strongest class it carries, and `Redacted<T>` types that position `unknown`. When nothing beneath it is marked,
74
+ * it is kept as it is, including when it is a union option beside a marked one. The one exception is a value the call's
75
+ * budget ran out on before it resolved: it is masked `[truncated]`, as any class may be marked past the budget (item
76
+ * 6). A recursion beneath it that reaches the item 6 depth limit counts as possibly marked, so that value is masked
77
+ * whole and typed `unknown`: with the strongest class marked anywhere else beneath it, or as `[truncated]` where none
78
+ * is. None of this ever throws or leaks a raw marked value. `Redacted<T>` cannot see an `.overwrite()`, which keeps the
79
+ * type of the schema it is called on, so it types that position as the shape beneath it. A `.refine` or `.superRefine`
80
+ * that mutates its input is outside the contract; `redact` does not defend against a check written to move a marked
81
+ * value. `redact` copies only the enumerable own properties of a plain object passed through as it is. A value whose
82
+ * kind or prototype does not match what its position's schema expects is not walked either: a value that is not a plain
83
+ * object, such as a class instance, an ORM entity or an object whose prototype is not `Object.prototype`, where an
84
+ * object is expected; a `Date`, an array, a `Map`, a `Set` or a function there, a subclass of one or one carrying
85
+ * fields included; a plain object where an array or a map is expected; an array-like class instance where an array is;
86
+ * an object or an array where only scalars are. A schema expects a plain object, an `Array.prototype` array, a `Map`, a
87
+ * `Set` or a `Date` of its own prototype, or a primitive of the scalar's kind, and a constant `.catch()` fallback's
88
+ * kind is one its schema names. When anything beneath the position is marked, the whole value is masked with the
89
+ * strongest class it carries, at the top level, inside an array and as a field of a row alike, and when nothing is, it
90
+ * is kept as it is. Where a schema at the position names the value's kind, beneath means beneath the schemas of that
91
+ * kind, so a class instance beside a marked string option and an object option that marks nothing is kept. A value of a
92
+ * kind only an opaque node at its position takes is that node's, and kept as it is when nothing beneath that node is
93
+ * marked. `Redacted<T>`, which cannot tell a class instance from a plain object, types it as the schema's shape.
94
+ *
95
+ * **Five constructions nothing written to the contract uses** are outside it too. Each is tested for exactly this
96
+ * behavior, none throws, and all but the second and the fourth can keep a marked value as it is:
97
+ *
98
+ * 1. A symbol key in a `z.object` shape is not listed by `classifyPrivacy` or seen by `hasPrivacy`, and beneath an
99
+ * opaque node or a mark `redact` does not see a mark on it.
100
+ * 2. A value that is not a plain object, such as a class instance or an object whose prototype is not
101
+ * `Object.prototype`, passed through as it is beside an object option is read as one where an object is expected:
102
+ * masked whole with the strongest class marked beneath that option, and kept as it is where nothing is.
103
+ * 3. An `.overwrite()` or a `.catch()` with a fallback function on a scalar option of a union is read by the scalar's
104
+ * kind, so an object or an array it makes is read as any value of that kind at its position: walked by an object or
105
+ * an array option beside it, which can keep it as it is, and masked whole as a value of a kind its position does not
106
+ * name where no such option is and anything beneath is marked.
107
+ * 4. Wrappers, choices or intersections nested deeper than the JavaScript stack can follow, and a field cloned by
108
+ * `.describe()` or `.meta()` more times than Zod can read its metadata back through: `redact` masks the value they
109
+ * sit in `[truncated]`, `classifyPrivacy`, `hasPrivacy` and `walkPrivacy` list that position with every class,
110
+ * each `truncated`, and `privacyOf` answers the strongest class, rather than throw.
111
+ * 5. An array, a `Map` or a `Set` passed through as it is is copied by its elements or entries, and no other own
112
+ * property it holds is copied.
113
+ *
114
+ * **Where `redact` masks more than the marks name.** It never masks less, and it masks more in five places, each
115
+ * tested. Anything nested deeper than the depth limit is masked `[truncated]`, marked or not (item 6). A position the
116
+ * call ran out of its budget before it resolved is masked whole, with the strongest class found
117
+ * there or as `[truncated]`, as any class may be marked past it (item 6). A value whose kind or prototype does not
118
+ * match its position is masked whole over a mark beneath it, as above. A lazy whose union reaches itself through
119
+ * another lazy reads as every kind where the cycle is cut, so a marked option that wraps it applies to every value at
120
+ * its position, an unmarked sibling option's value included: with `A = z.lazy(() => z.union([z.number(), B]))` and `B =
121
+ * z.lazy(() => z.union([z.string(), A]))`, `z.object({ f: z.union([z.boolean(), secret(A)]) })` masks `{ f: true }` as
122
+ * `{ f: "[secret]" }`. And a loose record passes a key its key schema does not match through unvalidated, and `redact`
123
+ * reads that entry at the record's own positions, so the value mark masks its value and the key mark its key:
124
+ * `z.looseRecord(z.string().regex(/^a/), pii(z.string()))` masks `{ a1: "ada", b: "bob" }` as `{ a1: "[pii]", b:
125
+ * "[pii]" }`.
126
+ *
127
+ * A read of a value that throws — a getter, a proxy's trap, an iterator — makes that position `[truncated]`, and the
128
+ * walk goes on: `redact` never throws.
129
+ *
130
+ * ## The edges, exactly
131
+ *
132
+ * - **A position two classes reach takes the strongest one's placeholder**, never one class's own mask: a PAN's
133
+ * first six and last four apply only where `pci` is the only class there.
134
+ * - **An option's marks apply where the value has that option's shape.** An object option's apply to an object value,
135
+ * an array option's to an array value, and a marked string option's to a string: beside `z.array(z.string())` an
136
+ * array is walked, and beside `z.number()` a number is kept. A mark reached through no option applies to every value.
137
+ * A key or an element no option names is kept, and so is a value of a kind only an opaque option takes, such as an
138
+ * array a `z.unknown()` beside an object option let through; one of a kind no schema there names is masked whole over
139
+ * a mark. A record's string key is a string whatever its schema says, a number key's included, so a key schema that
140
+ * marks any shape but a symbol masks every string key; a symbol key is masked only by a marked symbol option.
141
+ * - **A record's or a map's key is redacted by its key schema**, and so is a set's element. Two that mask alike come
142
+ * back as `[pii]` and `[pii] #2`, so the copy holds as many entries as the value did. Only a masked one is ever
143
+ * numbered: a kept one stays as it is, even one that reads `[pii]`, and a masked one takes the first name no kept
144
+ * one holds. Every key is defined on the copy, not assigned, so an own `__proto__` key stays a key.
145
+ * - **The one recursion limit is {@link MAX_RECURSION_DEPTH} levels of nesting**, and it applies to all nesting, not
146
+ * only recursion: every object, array, tuple, record, map and set a position sits inside counts one. A position deeper than that is masked whole as `[truncated]`, a
147
+ * safety cutoff rather than a privacy class, and `Redacted<T>` types it `unknown` from the same depth. Nothing tells
148
+ * one recursion from another, so a lazy, a getter that returns one schema and one that builds a new schema each read
149
+ * all end at the same level, and the result is a function of the schema and the value alone. The walk keeps its own
150
+ * stack, so a deep value never overflows the call stack. A recursion beneath an opaque node or a marked container
151
+ * that reaches the limit ends that path alone: every other path beneath is read, and the value is masked whole with
152
+ * the strongest class found, or as `[truncated]` where none is. A lazy that builds a new choice each read ends at the
153
+ * limit the same way: every lazy nested in another counts one. A union of many lazies, none inside another, counts
154
+ * one. A class known at a position is always its mask; `[truncated]` stands only where none is.
155
+ * - **One budget per call, `MAX_READ_SCHEMAS` schemas**, shared by every position and every search in it. Past it
156
+ * nothing was read, so any class may be marked there: each position not yet resolved is masked whole with the
157
+ * strongest class found there, or as `[truncated]` where none was, never with a class only assumed.
158
+ *
159
+ * ## The placeholder, and why it is not a hash
160
+ *
161
+ * `[pii]` loses correlation: two log lines about one person look unrelated. A keyed per-class hash would keep it,
162
+ * and an email is low-entropy enough to brute-force from an unkeyed one — so the hash would need a secret salt,
163
+ * which would pull `@pithy-sh/secrets` into core. The placeholder is the v1 answer, and correlation is what the
164
+ * unmarked id beside it is for.
165
+ *
166
+ * It reads the app shape — what `Schema.parse(row)` returns — and never mutates its argument. `null` and `undefined`
167
+ * stay as they are, because an absent value discloses nothing.
168
+ */
169
+
170
+ /** A plain object — not an array, not a `Date`, not a class instance. */
171
+ function isPlainObject(value: unknown): value is Record<PropertyKey, unknown> {
172
+ if (typeof value !== "object" || value === null) return false;
173
+ const prototype = Object.getPrototypeOf(value);
174
+ return prototype === Object.prototype || prototype === null;
175
+ }
176
+
177
+ /**
178
+ * How a mask masks a value: its class's own mask, or the placeholder of the strongest class found there, cut off or
179
+ * not. {@link TRUNCATED_MASK} only where no class was found: a recursion here reached the limit, or the call ran out of
180
+ * its budget before it read one, and a class it only assumed is never the placeholder.
181
+ */
182
+ function masked(mask: Mask, value: unknown): string {
183
+ if (mask.className === "") return TRUNCATED_MASK;
184
+ let own: string | undefined;
185
+ try {
186
+ own = mask.own ? privacyClass(mask.className)?.mask(value) : undefined;
187
+ } catch {
188
+ // A class's own mask is the adopter's code: where it throws, its placeholder stands.
189
+ }
190
+ return own ?? `[${mask.className}]`;
191
+ }
192
+
193
+ /**
194
+ * What a read of a value put in place of what it could not read: a getter or a proxy trap that threw. Its position is
195
+ * masked {@link TRUNCATED_MASK}, and the walk goes on.
196
+ */
197
+ const UNREADABLE: unique symbol = Symbol("unreadable");
198
+
199
+ /** `value[key]`, or {@link UNREADABLE} where reading it throws. */
200
+ function read(value: object, key: PropertyKey): unknown {
201
+ try {
202
+ return (value as Record<PropertyKey, unknown>)[key];
203
+ } catch {
204
+ return UNREADABLE;
205
+ }
206
+ }
207
+
208
+ /** A child's result, and whether it is a mask: only a masked key or element is ever renamed. */
209
+ type Result = { value: unknown; masked: boolean };
210
+
211
+ /**
212
+ * Keys or elements, each kept or masked, made distinct: every kept one first, as it is, then each masked one at the
213
+ * first name, `[pii]`, `[pii] #2` …, that no kept one and no earlier masked one holds. In the value's order. Each name
214
+ * keeps the next number to try, so a thousand keys that mask alike cost a thousand steps, not half a million: every
215
+ * number below it is already taken, and a name once taken stays taken.
216
+ */
217
+ function distinctAll(results: readonly Result[]): unknown[] {
218
+ const taken = new Set<unknown>();
219
+ for (const result of results) if (!result.masked) taken.add(result.value);
220
+ const next = new Map<string, number>();
221
+ return results.map((result) => {
222
+ const name = result.value;
223
+ if (!result.masked) return name;
224
+ if (typeof name !== "string" || !taken.has(name)) {
225
+ taken.add(name);
226
+ return name;
227
+ }
228
+ let index = next.get(name) ?? 2;
229
+ while (taken.has(`${name} #${index}`)) index += 1;
230
+ next.set(name, index + 1);
231
+ const numbered = `${name} #${index}`;
232
+ taken.add(numbered);
233
+ return numbered;
234
+ });
235
+ }
236
+
237
+ /**
238
+ * One step of the walk. `visit` redacts one value at one position, `depth` levels of nesting down, and hands the
239
+ * result to `put`; `after` runs once every step above it on the stack is done, to fill a map or a set in order. The
240
+ * walk keeps its own stack rather than the call stack, so a deep value never overflows it.
241
+ */
242
+ type Step = { visit: unknown; at: Position | undefined; depth: number; put: Put } | { after: () => void };
243
+
244
+ /** Where a result goes, and whether it is a mask. */
245
+ type Put = (result: unknown, masked: boolean) => void;
246
+
247
+ /** How a child is handed to the walk: its position, its value, and where its result goes. */
248
+ type Walk = (child: Position | undefined, item: unknown, put: Put) => void;
249
+
250
+ /** The walk: every value at its position, depth first and in order. */
251
+ function walkValue(root: Position, value: unknown): unknown {
252
+ let result: unknown;
253
+ const steps: Step[] = [
254
+ {
255
+ visit: value,
256
+ at: root,
257
+ depth: 0,
258
+ put: (out) => {
259
+ result = out;
260
+ },
261
+ },
262
+ ];
263
+ for (let step = steps.pop(); step; step = steps.pop()) {
264
+ if ("after" in step) step.after();
265
+ else redactAt(step, steps);
266
+ }
267
+ return result;
268
+ }
269
+
270
+ /**
271
+ * One value at one position. A position nested deeper than {@link MAX_RECURSION_DEPTH} is masked whole as
272
+ * {@link TRUNCATED_MASK}, however the recursion that reached it is written. A key or an element no schema names is
273
+ * kept: an option's marks apply only where the value has that option's shape.
274
+ */
275
+ function redactAt(step: Extract<Step, { visit: unknown }>, steps: Step[]): void {
276
+ const { at, depth, visit: value, put } = step;
277
+ if (value === null || value === undefined) put(value, false);
278
+ else if (value === UNREADABLE || depth > MAX_RECURSION_DEPTH) put(TRUNCATED_MASK, true);
279
+ else if (at === undefined) put(value, false);
280
+ else {
281
+ const below: Step[] = [];
282
+ let result: Result;
283
+ try {
284
+ // A mark applies to a value of the shape of the option it was reached through, and to no other. A value of a
285
+ // kind or a prototype the schemas here do not expect is never walked: masked whole over a mark, or kept.
286
+ const mask = at.maskFor(value);
287
+ const misfit = mask ? undefined : at.misfit(value);
288
+ result = mask
289
+ ? { value: masked(mask, value), masked: true }
290
+ : misfit
291
+ ? misfit.mask
292
+ ? { value: masked(misfit.mask, value), masked: true }
293
+ : { value, masked: false }
294
+ : {
295
+ value: redactKind(at, value, below, (child, item, out) => {
296
+ below.push({ visit: item, at: child, depth: depth + 1, put: out });
297
+ }),
298
+ masked: false,
299
+ };
300
+ } catch {
301
+ // A value whose shape, keys or entries throw when read: a proxy's trap, a getter, an iterator. Masked whole, and
302
+ // nothing read below it is kept.
303
+ put(TRUNCATED_MASK, true);
304
+ return;
305
+ }
306
+ put(result.value, result.masked);
307
+ // Popped last to first: what is below runs in order. One push each, never a spread: a value may hold more
308
+ // children than a call takes arguments.
309
+ for (let index = below.length - 1; index >= 0; index -= 1) steps.push(below[index] as Step);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * A value walked by its kind against the structural nodes at its position, as the copy its children are put into.
315
+ * A value of a kind no node here names, and a scalar, is kept. Each child is a step in `below`, in order.
316
+ */
317
+ function redactKind(at: Position, value: unknown, below: Step[], walk: Walk): unknown {
318
+ const { kinds } = at;
319
+ if (Array.isArray(value)) {
320
+ if (!kinds.has("array") && !kinds.has("tuple")) return value;
321
+ // Sized, so a hole stays a hole, as it did in the value.
322
+ const { length } = value;
323
+ const out = new Array<unknown>(length);
324
+ for (let index = 0; index < length; index += 1) {
325
+ if (!(index in value)) continue;
326
+ walk(at.item(index), read(value, index), (result) => {
327
+ out[index] = result;
328
+ });
329
+ }
330
+ return out;
331
+ }
332
+ if (value instanceof Map) {
333
+ if (!kinds.has("map")) return value;
334
+ const out = new Map<unknown, unknown>();
335
+ const keys: Result[] = [];
336
+ const items: unknown[] = [];
337
+ for (const [key, item] of value) {
338
+ const index = items.push(undefined) - 1;
339
+ keys.push({ value: undefined, masked: false });
340
+ walk(at.mapKey, key, (result, isMask) => {
341
+ keys[index] = { value: result, masked: isMask };
342
+ });
343
+ walk(at.mapValue, item, (result) => {
344
+ items[index] = result;
345
+ });
346
+ }
347
+ below.push({
348
+ after: () => {
349
+ distinctAll(keys).forEach((key, index) => {
350
+ out.set(key, items[index]);
351
+ });
352
+ },
353
+ });
354
+ return out;
355
+ }
356
+ if (value instanceof Set) {
357
+ if (!kinds.has("set")) return value;
358
+ const out = new Set<unknown>();
359
+ const items: Result[] = [];
360
+ for (const item of value) {
361
+ const index = items.push({ value: undefined, masked: false }) - 1;
362
+ walk(at.element, item, (result, isMask) => {
363
+ items[index] = { value: result, masked: isMask };
364
+ });
365
+ }
366
+ below.push({
367
+ after: () => {
368
+ for (const item of distinctAll(items)) out.add(item);
369
+ },
370
+ });
371
+ return out;
372
+ }
373
+ if (isPlainObject(value) && (kinds.has("object") || kinds.has("record"))) return redactObject(at, value, walk);
374
+ return value;
375
+ }
376
+
377
+ /**
378
+ * An object's entries, each value redacted at its key's position and each key at a record's key position: a string
379
+ * key by every mark there but a symbol's, since it is a string whatever its schema says, and a symbol key by a
380
+ * symbol's alone. A masked key takes the first name, `[pii]`, `[pii] #2` …, that no kept key and no earlier masked
381
+ * key holds. Each value is put in when its step runs, and the steps run in key order, so the copy keeps the value's.
382
+ */
383
+ function redactObject(at: Position, value: Record<PropertyKey, unknown>, walk: Walk): Record<PropertyKey, unknown> {
384
+ const keyAt = at.key;
385
+ // Only the enumerable own keys are copied, as a spread copies them. A key whose descriptor cannot be read is kept,
386
+ // and its value masked as unreadable.
387
+ const unreadable = new Set<PropertyKey>();
388
+ const keys = Reflect.ownKeys(value).filter((key) => {
389
+ try {
390
+ return Object.prototype.propertyIsEnumerable.call(value, key);
391
+ } catch {
392
+ unreadable.add(key);
393
+ return true;
394
+ }
395
+ });
396
+ const names = distinctAll(
397
+ keys.map((key): Result => {
398
+ const mask = typeof key === "symbol" ? keyAt?.maskFor(key) : keyAt?.nameMask;
399
+ return mask ? { value: masked(mask, key), masked: true } : { value: key, masked: false };
400
+ }),
401
+ ) as PropertyKey[];
402
+ const out: Record<PropertyKey, unknown> = {};
403
+ keys.forEach((key, index) => {
404
+ const name = names[index] as PropertyKey;
405
+ // Defined, not assigned: an own `__proto__` key that parse kept stays a key, and never becomes the prototype.
406
+ walk(at.field(key), unreadable.has(key) ? UNREADABLE : read(value, key), (result) => {
407
+ Object.defineProperty(out, name, { value: result, enumerable: true, writable: true, configurable: true });
408
+ });
409
+ });
410
+ return out;
411
+ }
412
+
413
+ /** The masked view of a value, against the schema it is an instance of. See the module note for the contract. */
414
+ export function redact<S extends z.ZodType>(schema: S, value: z.output<S>): RedactedOutput<S> {
415
+ let root: Position;
416
+ try {
417
+ root = rootPosition(schema);
418
+ } catch {
419
+ // A schema that cannot be read, nested deeper than the stack can follow: the whole value is cut off.
420
+ return (value === null || value === undefined ? value : TRUNCATED_MASK) as RedactedOutput<S>;
421
+ }
422
+ return walkValue(root, value) as RedactedOutput<S>;
423
+ }