@pithy-sh/core 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/dist/access/access.d.ts +172 -0
  2. package/dist/access/access.d.ts.map +1 -0
  3. package/dist/access/access.js +147 -0
  4. package/dist/access/acting.d.ts +49 -0
  5. package/dist/access/acting.d.ts.map +1 -0
  6. package/dist/access/acting.js +21 -0
  7. package/dist/access/policyCheck.d.ts +33 -0
  8. package/dist/access/policyCheck.d.ts.map +1 -0
  9. package/dist/access/policyCheck.js +48 -0
  10. package/dist/access/usage.d.ts +83 -0
  11. package/dist/access/usage.d.ts.map +1 -0
  12. package/dist/access/usage.js +104 -0
  13. package/dist/access/visibility.d.ts +66 -0
  14. package/dist/access/visibility.d.ts.map +1 -0
  15. package/dist/access/visibility.js +86 -0
  16. package/dist/audit/auditEvent.d.ts +5 -0
  17. package/dist/audit/auditEvent.d.ts.map +1 -1
  18. package/dist/audit/auditEvent.js +2 -0
  19. package/dist/audit/recorder.d.ts +10 -2
  20. package/dist/audit/recorder.d.ts.map +1 -1
  21. package/dist/capability/bindings.d.ts +47 -2
  22. package/dist/capability/bindings.d.ts.map +1 -1
  23. package/dist/capability/bindings.js +117 -7
  24. package/dist/capability/capability.d.ts +110 -28
  25. package/dist/capability/capability.d.ts.map +1 -1
  26. package/dist/capability/capability.js +2 -0
  27. package/dist/capability/manifest.d.ts +2 -1
  28. package/dist/capability/manifest.d.ts.map +1 -1
  29. package/dist/capability/manifest.js +1 -1
  30. package/dist/capability/purge.d.ts +80 -0
  31. package/dist/capability/purge.d.ts.map +1 -0
  32. package/dist/capability/purge.js +76 -0
  33. package/dist/capability/settings.d.ts +86 -2
  34. package/dist/capability/settings.d.ts.map +1 -1
  35. package/dist/capability/settings.js +80 -1
  36. package/dist/cli/commandCatalog.generated.d.ts +238 -24
  37. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  38. package/dist/cli/commandCatalog.generated.js +251 -22
  39. package/dist/cli/commandFacts.d.ts +2 -1
  40. package/dist/cli/commandFacts.d.ts.map +1 -1
  41. package/dist/cli/commandFacts.js +2 -0
  42. package/dist/controlPlane/capability.d.ts.map +1 -1
  43. package/dist/controlPlane/capability.js +19 -2
  44. package/dist/controlPlane/context.d.ts +5 -0
  45. package/dist/controlPlane/context.d.ts.map +1 -1
  46. package/dist/controlPlane/context.js +3 -1
  47. package/dist/controlPlane/data/connection.d.ts +49 -12
  48. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  49. package/dist/controlPlane/data/connection.js +56 -2
  50. package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
  51. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  52. package/dist/controlPlane/discovery/adminRoute.js +3 -0
  53. package/dist/controlPlane/error/errors.d.ts +4 -3
  54. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  55. package/dist/controlPlane/http/cors.d.ts +4 -4
  56. package/dist/controlPlane/http/cors.js +4 -4
  57. package/dist/controlPlane/http/guard.d.ts +11 -0
  58. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  59. package/dist/controlPlane/http/guard.js +17 -1
  60. package/dist/controlPlane/http/handlers.d.ts +139 -0
  61. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  62. package/dist/controlPlane/http/handlers.js +67 -4
  63. package/dist/controlPlane/http/readiness.d.ts +33 -0
  64. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  65. package/dist/controlPlane/http/readiness.js +39 -0
  66. package/dist/controlPlane/http/responses.d.ts +245 -0
  67. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  68. package/dist/controlPlane/http/responses.js +101 -4
  69. package/dist/controlPlane/http/routes.d.ts +2 -0
  70. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  71. package/dist/controlPlane/http/routes.js +31 -3
  72. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  73. package/dist/controlPlane/http/verify.js +2 -1
  74. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  75. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  76. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  77. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  78. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  79. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  80. package/dist/controlPlane/scope/scope.d.ts +23 -2
  81. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  82. package/dist/controlPlane/scope/scope.js +24 -3
  83. package/dist/createBackend.d.ts.map +1 -1
  84. package/dist/createBackend.js +9 -2
  85. package/dist/createEntrypoint.d.ts.map +1 -1
  86. package/dist/createEntrypoint.js +5 -3
  87. package/dist/crypto/envelope.d.ts +86 -0
  88. package/dist/crypto/envelope.d.ts.map +1 -0
  89. package/dist/crypto/envelope.js +129 -0
  90. package/dist/crypto/fieldKeys.d.ts +41 -0
  91. package/dist/crypto/fieldKeys.d.ts.map +1 -0
  92. package/dist/crypto/fieldKeys.js +56 -0
  93. package/dist/data/codecs.d.ts +23 -10
  94. package/dist/data/codecs.d.ts.map +1 -1
  95. package/dist/data/codecs.js +50 -36
  96. package/dist/entitlement/allowance.d.ts +25 -0
  97. package/dist/entitlement/allowance.d.ts.map +1 -0
  98. package/dist/entitlement/allowance.js +80 -0
  99. package/dist/entitlement/entitlement.d.ts +41 -0
  100. package/dist/entitlement/entitlement.d.ts.map +1 -1
  101. package/dist/entitlement/meter.d.ts +57 -0
  102. package/dist/entitlement/meter.d.ts.map +1 -0
  103. package/dist/entitlement/meter.js +13 -0
  104. package/dist/error/extend.d.ts +6 -5
  105. package/dist/error/extend.d.ts.map +1 -1
  106. package/dist/error/http.d.ts +168 -0
  107. package/dist/error/http.d.ts.map +1 -1
  108. package/dist/error/payload.d.ts +336 -0
  109. package/dist/error/payload.d.ts.map +1 -1
  110. package/dist/error/payload.js +116 -4
  111. package/dist/error/pithyError.d.ts +18 -7
  112. package/dist/error/pithyError.d.ts.map +1 -1
  113. package/dist/error/pithyError.js +6 -0
  114. package/dist/http/authContext.d.ts +7 -0
  115. package/dist/http/authContext.d.ts.map +1 -1
  116. package/dist/http/authContext.js +8 -1
  117. package/dist/http/scopeGates.d.ts +20 -0
  118. package/dist/http/scopeGates.d.ts.map +1 -0
  119. package/dist/http/scopeGates.js +36 -0
  120. package/dist/index.d.ts +1 -1
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +2 -2
  123. package/dist/logger/logger.d.ts +12 -5
  124. package/dist/logger/logger.d.ts.map +1 -1
  125. package/dist/logger/record.d.ts +98 -0
  126. package/dist/logger/record.d.ts.map +1 -1
  127. package/dist/migrations/capabilitySets.d.ts +23 -0
  128. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  129. package/dist/migrations/capabilitySets.js +74 -0
  130. package/dist/migrations/registry.d.ts +26 -0
  131. package/dist/migrations/registry.d.ts.map +1 -1
  132. package/dist/migrations/registry.js +38 -7
  133. package/dist/migrations/state.d.ts +60 -0
  134. package/dist/migrations/state.d.ts.map +1 -0
  135. package/dist/migrations/state.js +46 -0
  136. package/dist/naming/feature.d.ts +27 -5
  137. package/dist/naming/feature.d.ts.map +1 -1
  138. package/dist/naming/feature.js +36 -6
  139. package/dist/naming/limits.d.ts +47 -3
  140. package/dist/naming/limits.d.ts.map +1 -1
  141. package/dist/naming/limits.js +47 -3
  142. package/dist/naming/provisionScope.d.ts +6 -0
  143. package/dist/naming/provisionScope.d.ts.map +1 -1
  144. package/dist/naming/provisionScope.js +8 -3
  145. package/dist/naming/resourceNames.d.ts +21 -2
  146. package/dist/naming/resourceNames.d.ts.map +1 -1
  147. package/dist/naming/resourceNames.js +21 -3
  148. package/dist/privacy/blindIndex.d.ts +27 -0
  149. package/dist/privacy/blindIndex.d.ts.map +1 -0
  150. package/dist/privacy/blindIndex.js +57 -0
  151. package/dist/privacy/classes.d.ts +142 -0
  152. package/dist/privacy/classes.d.ts.map +1 -0
  153. package/dist/privacy/classes.js +209 -0
  154. package/dist/privacy/classify.d.ts +336 -0
  155. package/dist/privacy/classify.d.ts.map +1 -0
  156. package/dist/privacy/classify.js +1024 -0
  157. package/dist/privacy/encrypted.d.ts +210 -0
  158. package/dist/privacy/encrypted.d.ts.map +1 -0
  159. package/dist/privacy/encrypted.js +350 -0
  160. package/dist/privacy/encryptedColumns.d.ts +64 -0
  161. package/dist/privacy/encryptedColumns.d.ts.map +1 -0
  162. package/dist/privacy/encryptedColumns.js +134 -0
  163. package/dist/privacy/fieldRotation.d.ts +34 -0
  164. package/dist/privacy/fieldRotation.d.ts.map +1 -0
  165. package/dist/privacy/fieldRotation.js +186 -0
  166. package/dist/privacy/inventory.d.ts +12 -0
  167. package/dist/privacy/inventory.d.ts.map +1 -0
  168. package/dist/privacy/inventory.js +154 -0
  169. package/dist/privacy/posture.d.ts +114 -0
  170. package/dist/privacy/posture.d.ts.map +1 -0
  171. package/dist/privacy/posture.js +136 -0
  172. package/dist/privacy/redact.d.ts +7 -0
  173. package/dist/privacy/redact.d.ts.map +1 -0
  174. package/dist/privacy/redact.js +404 -0
  175. package/dist/privacy/report.d.ts +58 -0
  176. package/dist/privacy/report.d.ts.map +1 -0
  177. package/dist/privacy/report.js +15 -0
  178. package/dist/privacy/retirement.d.ts +68 -0
  179. package/dist/privacy/retirement.d.ts.map +1 -0
  180. package/dist/privacy/retirement.js +72 -0
  181. package/dist/privacy/sealed.d.ts +87 -0
  182. package/dist/privacy/sealed.d.ts.map +1 -0
  183. package/dist/privacy/sealed.js +221 -0
  184. package/dist/privacy/types.d.ts +559 -0
  185. package/dist/privacy/types.d.ts.map +1 -0
  186. package/dist/privacy/types.js +2 -0
  187. package/dist/seed/writeD1.d.ts +7 -1
  188. package/dist/seed/writeD1.d.ts.map +1 -1
  189. package/dist/seed/writeD1.js +9 -3
  190. package/dist/seed/writeKv.d.ts +2 -0
  191. package/dist/seed/writeKv.d.ts.map +1 -1
  192. package/dist/seed/writeKv.js +6 -2
  193. package/dist/version.generated.d.ts +1 -1
  194. package/dist/version.generated.js +1 -1
  195. package/dist/worker/serviceLoopback.d.ts +36 -0
  196. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  197. package/dist/worker/serviceLoopback.js +74 -0
  198. package/dist/workflow/faults.d.ts +12 -1
  199. package/dist/workflow/faults.d.ts.map +1 -1
  200. package/dist/workflow/faults.js +22 -15
  201. package/package.json +2 -2
  202. package/src/access/access.ts +298 -0
  203. package/src/access/acting.ts +59 -0
  204. package/src/access/policyCheck.ts +77 -0
  205. package/src/access/usage.ts +141 -0
  206. package/src/access/visibility.ts +109 -0
  207. package/src/audit/auditEvent.ts +4 -0
  208. package/src/audit/recorder.ts +10 -2
  209. package/src/capability/bindings.ts +159 -13
  210. package/src/capability/capability.ts +114 -29
  211. package/src/capability/purge.ts +124 -0
  212. package/src/capability/settings.ts +146 -2
  213. package/src/cli/commandCatalog.generated.ts +271 -22
  214. package/src/cli/commandFacts.ts +2 -0
  215. package/src/controlPlane/capability.ts +36 -1
  216. package/src/controlPlane/context.ts +4 -0
  217. package/src/controlPlane/data/connection.ts +90 -0
  218. package/src/controlPlane/discovery/adminRoute.ts +11 -0
  219. package/src/controlPlane/error/errors.ts +4 -3
  220. package/src/controlPlane/http/cors.ts +4 -4
  221. package/src/controlPlane/http/guard.ts +21 -0
  222. package/src/controlPlane/http/handlers.ts +98 -4
  223. package/src/controlPlane/http/readiness.ts +50 -0
  224. package/src/controlPlane/http/responses.ts +240 -2
  225. package/src/controlPlane/http/routes.ts +55 -1
  226. package/src/controlPlane/http/verify.ts +2 -0
  227. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  228. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  229. package/src/controlPlane/scope/scope.ts +25 -2
  230. package/src/controlPlane/token/base64url.ts +1 -1
  231. package/src/createBackend.ts +28 -6
  232. package/src/createEntrypoint.ts +9 -3
  233. package/src/crypto/envelope.ts +196 -0
  234. package/src/crypto/fieldKeys.ts +76 -0
  235. package/src/data/codecs.ts +69 -37
  236. package/src/entitlement/allowance.ts +85 -0
  237. package/src/entitlement/entitlement.ts +38 -0
  238. package/src/entitlement/meter.ts +69 -0
  239. package/src/error/extend.ts +6 -5
  240. package/src/error/payload.ts +253 -4
  241. package/src/error/pithyError.ts +21 -7
  242. package/src/http/authContext.ts +13 -1
  243. package/src/http/scopeGates.ts +34 -0
  244. package/src/index.ts +2 -0
  245. package/src/logger/logger.ts +12 -5
  246. package/src/migrations/capabilitySets.ts +87 -0
  247. package/src/migrations/registry.ts +46 -2
  248. package/src/migrations/state.ts +94 -0
  249. package/src/naming/feature.ts +46 -7
  250. package/src/naming/limits.ts +35 -3
  251. package/src/naming/provisionScope.ts +19 -1
  252. package/src/naming/resourceNames.ts +52 -5
  253. package/src/privacy/blindIndex.ts +81 -0
  254. package/src/privacy/classes.ts +277 -0
  255. package/src/privacy/classify.ts +1447 -0
  256. package/src/privacy/encrypted.ts +513 -0
  257. package/src/privacy/encryptedColumns.ts +185 -0
  258. package/src/privacy/fieldRotation.ts +234 -0
  259. package/src/privacy/inventory.ts +147 -0
  260. package/src/privacy/posture.ts +205 -0
  261. package/src/privacy/redact.ts +423 -0
  262. package/src/privacy/report.ts +64 -0
  263. package/src/privacy/retirement.ts +102 -0
  264. package/src/privacy/sealed.ts +323 -0
  265. package/src/privacy/types.ts +804 -0
  266. package/src/seed/writeD1.ts +13 -4
  267. package/src/seed/writeKv.ts +6 -2
  268. package/src/version.generated.ts +1 -1
  269. package/src/worker/serviceLoopback.ts +105 -0
  270. package/src/workflow/faults.ts +51 -24
@@ -0,0 +1,277 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ConflictError, ValidationError } from "../error/pithyError";
6
+
7
+ /**
8
+ * **A field declares its own privacy class, and the declaration drives everything downstream** (#132).
9
+ *
10
+ * One marker, several consumers — the shape `@pithy-sh/vector` already uses, where `.meta({ filterable: true
11
+ * })` drives provisioning, the filter type, the drift check and the boot check. Here the marker drives the
12
+ * type gate on the three internal surfaces (the logger, `AuditEvent.metadata`, `PithyError.detail`), the
13
+ * masked view `redact` returns, the classification `classifyPrivacy` derives, and the inventory the control
14
+ * plane reports.
15
+ *
16
+ * ## What a marker does, in one call
17
+ *
18
+ * `pii(schema)` does two things. It attaches `.meta({ privacy: "pii" })`, which is **introspectable** — that is
19
+ * what `redact`, `classifyPrivacy` and the inventory read. And it applies Zod's `.brand()`, which changes the
20
+ * **inferred type** — that is what makes a log call carrying the value fail to compile. The brand lands on the
21
+ * field, not on the object, so extracting the field does not launder it: `user.email` is as branded as `user`.
22
+ *
23
+ * **A marker over another marker adds its class.** `pii(secret(x))` and `secret(pii(x))` are the same field: it
24
+ * carries both classes, in its `meta` (as a list, strongest first) and in its type (both brands). It is masked with the
25
+ * strongest one's placeholder and listed once per class. The same marker twice is one class.
26
+ *
27
+ * **The brand key is `privacy:<class>`, not the bare class name.** An adopter's own `.brand<"pii">()` for some
28
+ * unrelated purpose must not trip a gate it never opted into, and an adopter's `UserId` brand must not either:
29
+ * the gate reads a brand under the `privacy:` namespace and nothing else, which is the federated-key rule
30
+ * (`auth/invalid_token`, `pithy_auth_users`) applied one more time.
31
+ *
32
+ * **Nothing at runtime changes.** Zod's `.brand()` returns the same schema object — the brand exists only in
33
+ * the type — and `.meta()` returns a clone that parses exactly as its source. `Schema.parse(row)` returns the
34
+ * same data it did before the marker, and no column is added anywhere.
35
+ *
36
+ * ## The set is open
37
+ *
38
+ * Four classes ship named — `secret`, `pii`, `phi`, `pci` — and {@link definePrivacyClass} registers more
39
+ * (`genetic`, `biometric`, whatever a regulator names) without a change to core. A closed union is what #117
40
+ * records the cost of, and a healthcare adopter should not have to wait on us.
41
+ *
42
+ * ## Where `secret` stops and `@pithy-sh/secrets` starts
43
+ *
44
+ * Two different facts, kept apart on purpose. `@pithy-sh/secrets` decides **where a credential the Worker reads
45
+ * is stored** — its registry names a backend, and its reader redacts the value it hands back. `secret()` says
46
+ * **what a field in a data shape is** — a receipt, a magic-link token, a session id, a key hash — values that
47
+ * live in rows and payloads and that no secrets registry ever holds. Merging them would make core depend on a
48
+ * capability (principle 4) and would make a storage decision answer a classification question.
49
+ */
50
+
51
+ /** The `meta` key a marker writes and every reader reads. */
52
+ export const PRIVACY_META_KEY = "privacy";
53
+
54
+ /** The prefix of every privacy brand, so the gate never reads a brand that means something else. */
55
+ export const PRIVACY_BRAND_PREFIX = "privacy:";
56
+
57
+ /** A class name: a lowercase identifier. It becomes a brand key, a config value and a report column. */
58
+ export const PrivacyClassName = z
59
+ .string()
60
+ .regex(/^[a-z][a-z0-9_]*$/, "A privacy class name is a lowercase identifier: letters, digits and underscores.")
61
+ .describe(
62
+ "A privacy class name — `secret`, `pii`, `phi`, `pci`, or one an adopter registered with `definePrivacyClass`. A lowercase identifier.",
63
+ );
64
+ export type PrivacyClassName = z.output<typeof PrivacyClassName>;
65
+
66
+ /** The four classes the kit names, in the order every report lists them. */
67
+ export const BUILT_IN_PRIVACY_CLASSES = ["secret", "pii", "phi", "pci"] as const;
68
+
69
+ /** One of the four built-in class names. */
70
+ export type BuiltInPrivacyClass = (typeof BUILT_IN_PRIVACY_CLASSES)[number];
71
+
72
+ /** How a class masks a value: the string that stands in its place on every internal surface. */
73
+ export type PrivacyMask = (value: unknown) => string;
74
+
75
+ /** One registered class: its name, what it means, and how it masks. */
76
+ export interface PrivacyClass {
77
+ /** The class name — the `meta` value and the brand's suffix. */
78
+ readonly name: string;
79
+ /** What the class means, in one sentence an auditor reads. */
80
+ readonly description: string;
81
+ /** The masked form of a value of this class. */
82
+ readonly mask: PrivacyMask;
83
+ }
84
+
85
+ /**
86
+ * The brand a marker puts on a schema's output type. Zod's own brand machinery, under the `privacy:` key.
87
+ *
88
+ * `"out"` on purpose: the **input** side stays unbranded, so `z.input` — the SQLite row shape, the Kysely
89
+ * table type, a KV write's pre-parse value — is exactly what it was. A row read from D1 parses into the
90
+ * branded app shape, and `Schema.encode()` hands Kysely the plain row back.
91
+ */
92
+ export type Marked<S extends z.ZodType, C extends string> = z.core.$ZodBranded<
93
+ S,
94
+ `${typeof PRIVACY_BRAND_PREFIX}${C}`,
95
+ "out"
96
+ >;
97
+
98
+ /** A marker for one class: wraps a schema and returns it marked. Carries its class name for a reader. */
99
+ export interface PrivacyMarker<C extends string> {
100
+ <S extends z.ZodType>(schema: S): Marked<S, C>;
101
+ /** The class this marker applies. */
102
+ readonly className: C;
103
+ }
104
+
105
+ /** The placeholder every class but `pci` masks to: `[pii]`, `[phi]`, `[genetic]`. */
106
+ function placeholder(name: string): PrivacyMask {
107
+ return () => `[${name}]`;
108
+ }
109
+
110
+ /** The shortest and longest primary account numbers ISO/IEC 7812 issues. */
111
+ const PAN_DIGITS = { min: 13, max: 19 } as const;
112
+
113
+ /**
114
+ * Mask a primary account number to its first six and last four digits — `424242******4242`.
115
+ *
116
+ * **The one class whose mask is not a placeholder, because the masked form is the useful one.** First-six and
117
+ * last-four is the display PCI-DSS permits, and it is what an operator reading a log needs to match a
118
+ * complaint to a card without holding the card. Spaces and dashes are separators and are dropped; anything
119
+ * that is not 13 to 19 digits once they are is not a card number, and masks to `[pci]` rather than to a
120
+ * partial reveal of something else.
121
+ *
122
+ * Every payment rail Pithy ships is hosted, so no PAN ever reaches an adopter's Worker through the kit. The
123
+ * class is here for the adopter's own schemas.
124
+ */
125
+ export function maskPan(value: unknown): string {
126
+ if (typeof value !== "string") return "[pci]";
127
+ const digits = value.replace(/[\s-]/g, "");
128
+ if (!/^\d+$/.test(digits) || digits.length < PAN_DIGITS.min || digits.length > PAN_DIGITS.max) return "[pci]";
129
+ return `${digits.slice(0, 6)}${"*".repeat(digits.length - 10)}${digits.slice(-4)}`;
130
+ }
131
+
132
+ /** The built-ins, stated once. The descriptions are what `pithy privacy` and the inventory docs read. */
133
+ const BUILT_INS: Record<BuiltInPrivacyClass, PrivacyClass> = {
134
+ secret: {
135
+ name: "secret",
136
+ description:
137
+ "A credential or a bearer artifact — a session id, a magic-link token, a receipt, a key. The harm is impersonation, not exposure.",
138
+ mask: placeholder("secret"),
139
+ },
140
+ pii: {
141
+ name: "pii",
142
+ description:
143
+ "Personal data that identifies a person under GDPR or CCPA — an email address, a name, a phone number, a postal address.",
144
+ mask: placeholder("pii"),
145
+ },
146
+ phi: {
147
+ name: "phi",
148
+ description:
149
+ "Protected Health Information under HIPAA — a diagnosis, a treatment, a result, any health record tied to a person.",
150
+ mask: placeholder("phi"),
151
+ },
152
+ pci: {
153
+ name: "pci",
154
+ description:
155
+ "Cardholder data under PCI-DSS — a primary account number. Masks to its first six and last four digits.",
156
+ mask: maskPan,
157
+ },
158
+ };
159
+
160
+ /**
161
+ * Every registered class, by name. Module state, deliberately: a class is declared once, at module load, the
162
+ * way a capability's error codes are — and an isolate holds one registry, so a class registered by the
163
+ * adopter's schema module is known to the logger, the inventory and `redact` in the same isolate.
164
+ */
165
+ const registry = new Map<string, PrivacyClass>(Object.entries(BUILT_INS));
166
+
167
+ /** The class a name refers to, or `undefined` for a name nothing registered. */
168
+ export function privacyClass(name: string): PrivacyClass | undefined {
169
+ return registry.get(name);
170
+ }
171
+
172
+ /** Every registered class, built-ins first in their fixed order, then each adopter class in registration order. */
173
+ export function privacyClasses(): PrivacyClass[] {
174
+ return [...registry.values()];
175
+ }
176
+
177
+ /** The built-ins from the most harmful disclosure to the least: a credential, then health, then card, then identity. */
178
+ const STRENGTH: readonly BuiltInPrivacyClass[] = ["secret", "phi", "pci"];
179
+
180
+ /**
181
+ * `names` from the strongest to the weakest, each once: `secret`, then `phi`, then `pci`, then each adopter class, then
182
+ * `pii`. An adopter registers a class because its data is more specific than `pii`, so it ranks above it; adopter
183
+ * classes rank among themselves by name. The order a marker records several classes in, whatever order they came.
184
+ */
185
+ export function rankPrivacyClasses(names: Iterable<string>): string[] {
186
+ const all = [...new Set(names)];
187
+ const builtIn = STRENGTH.filter((name) => all.includes(name));
188
+ const adopter = all.filter((name) => name !== "pii" && !(STRENGTH as readonly string[]).includes(name)).sort();
189
+ return [...builtIn, ...adopter, ...(all.includes("pii") ? ["pii"] : [])];
190
+ }
191
+
192
+ /**
193
+ * The strongest of `names`, the class a value is masked with when it carries several and is masked whole. See
194
+ * {@link rankPrivacyClasses}. `undefined` for none.
195
+ */
196
+ export function strongestPrivacyClass(names: Iterable<string>): string | undefined {
197
+ return rankPrivacyClasses(names)[0];
198
+ }
199
+
200
+ /**
201
+ * The classes a schema's own `privacy` meta records: one class as its name, several as a list, strongest first. Read
202
+ * from this exact schema, never through a wrapper. Empty where it records none.
203
+ */
204
+ export function ownPrivacyClasses(schema: z.ZodType): string[] {
205
+ const value = (schema.meta() as Record<string, unknown> | undefined)?.[PRIVACY_META_KEY];
206
+ if (typeof value === "string") return [value];
207
+ if (Array.isArray(value)) return rankPrivacyClasses(value.filter((one): one is string => typeof one === "string"));
208
+ return [];
209
+ }
210
+
211
+ /** Mark a schema with a class: the introspectable `meta`, and the type-level brand. */
212
+ function marker<const C extends string>(name: C): PrivacyMarker<C> {
213
+ const mark = <S extends z.ZodType>(schema: S): Marked<S, C> => {
214
+ // `.meta()` clones and registers; the clone inherits its parent's metadata (the description included),
215
+ // so `{ privacy }` lands beside whatever the schema already said about itself. A class already there is kept:
216
+ // `pii(secret(x))` is one node carrying both, recorded strongest first whatever the order, and one class twice
217
+ // is one. `.brand()` returns the same object — the brand is a type and nothing else.
218
+ const classes = rankPrivacyClasses([...ownPrivacyClasses(schema), name]);
219
+ const recorded = classes.length === 1 ? name : classes;
220
+ return schema
221
+ .meta({ ...(schema.meta() ?? {}), [PRIVACY_META_KEY]: recorded })
222
+ .brand<`${typeof PRIVACY_BRAND_PREFIX}${C}`>() as Marked<S, C>;
223
+ };
224
+ return Object.assign(mark, { className: name });
225
+ }
226
+
227
+ /** What {@link definePrivacyClass} takes. */
228
+ export interface PrivacyClassSpec<C extends string> {
229
+ /** The class name: a lowercase identifier, unique across the isolate. */
230
+ name: C;
231
+ /** What the class means. Defaults to a sentence naming it as adopter-registered. */
232
+ description?: string;
233
+ /** How a value masks. Defaults to the `[name]` placeholder. */
234
+ mask?: PrivacyMask;
235
+ }
236
+
237
+ /**
238
+ * Register a class of the adopter's own — `genetic`, `biometric`, whatever their regulator names — and get its
239
+ * marker back. Usable exactly like the built-ins: the same `meta`, the same brand, the same gate, the same
240
+ * inventory row.
241
+ *
242
+ * **A duplicate is refused at registration**, a built-in name included. Two modules each believing they own
243
+ * `genetic` would disagree about its mask, and the one that loaded second would win silently.
244
+ */
245
+ export function definePrivacyClass<const C extends string>(spec: PrivacyClassSpec<C>): PrivacyMarker<C> {
246
+ const parsed = PrivacyClassName.safeParse(spec.name);
247
+ if (!parsed.success) {
248
+ throw new ValidationError({
249
+ message: `"${spec.name}" is not a privacy class name.`,
250
+ action: "Name the class with a lowercase identifier — letters, digits and underscores, e.g. `genetic`.",
251
+ });
252
+ }
253
+ if (registry.has(spec.name)) {
254
+ throw new ConflictError({
255
+ message: `The privacy class "${spec.name}" is already registered.`,
256
+ action: `Register "${spec.name}" once, in one module, and import its marker everywhere else.`,
257
+ });
258
+ }
259
+ registry.set(spec.name, {
260
+ name: spec.name,
261
+ description: spec.description ?? `An adopter-registered privacy class, "${spec.name}".`,
262
+ mask: spec.mask ?? placeholder(spec.name),
263
+ });
264
+ return marker(spec.name);
265
+ }
266
+
267
+ /** Mark a field as a credential or bearer artifact. See {@link BUILT_INS}. */
268
+ export const secret: PrivacyMarker<"secret"> = marker("secret");
269
+
270
+ /** Mark a field as personal data under GDPR or CCPA. */
271
+ export const pii: PrivacyMarker<"pii"> = marker("pii");
272
+
273
+ /** Mark a field as Protected Health Information under HIPAA. */
274
+ export const phi: PrivacyMarker<"phi"> = marker("phi");
275
+
276
+ /** Mark a field as cardholder data under PCI-DSS. Masks to first six and last four. */
277
+ export const pci: PrivacyMarker<"pci"> = marker("pci");