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