@pithy-sh/core 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/capability/capability.d.ts +12 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- package/dist/capability/purge.d.ts +80 -0
- package/dist/capability/purge.d.ts.map +1 -0
- package/dist/capability/purge.js +76 -0
- package/dist/cli/commandCatalog.generated.d.ts +54 -2
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +55 -0
- package/dist/cli/commandFacts.d.ts +1 -0
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +1 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +7 -0
- package/dist/controlPlane/data/connection.d.ts +2 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +1 -1
- package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +3 -0
- package/dist/controlPlane/error/errors.d.ts +4 -3
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.d.ts +79 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +37 -3
- package/dist/controlPlane/http/responses.d.ts +98 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +41 -1
- package/dist/controlPlane/http/routes.d.ts +1 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +17 -3
- package/dist/controlPlane/scope/scope.d.ts +13 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +14 -4
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +2 -0
- package/dist/crypto/envelope.d.ts +86 -0
- package/dist/crypto/envelope.d.ts.map +1 -0
- package/dist/crypto/envelope.js +129 -0
- package/dist/crypto/fieldKeys.d.ts +41 -0
- package/dist/crypto/fieldKeys.d.ts.map +1 -0
- package/dist/crypto/fieldKeys.js +56 -0
- package/dist/data/codecs.d.ts +23 -10
- package/dist/data/codecs.d.ts.map +1 -1
- package/dist/data/codecs.js +50 -36
- package/dist/error/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +24 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +48 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +16 -0
- package/dist/error/pithyError.d.ts +18 -7
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/logger.d.ts +12 -5
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/record.d.ts +14 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/privacy/blindIndex.d.ts +27 -0
- package/dist/privacy/blindIndex.d.ts.map +1 -0
- package/dist/privacy/blindIndex.js +57 -0
- package/dist/privacy/classes.d.ts +142 -0
- package/dist/privacy/classes.d.ts.map +1 -0
- package/dist/privacy/classes.js +209 -0
- package/dist/privacy/classify.d.ts +336 -0
- package/dist/privacy/classify.d.ts.map +1 -0
- package/dist/privacy/classify.js +1024 -0
- package/dist/privacy/encrypted.d.ts +210 -0
- package/dist/privacy/encrypted.d.ts.map +1 -0
- package/dist/privacy/encrypted.js +350 -0
- package/dist/privacy/encryptedColumns.d.ts +64 -0
- package/dist/privacy/encryptedColumns.d.ts.map +1 -0
- package/dist/privacy/encryptedColumns.js +134 -0
- package/dist/privacy/fieldRotation.d.ts +34 -0
- package/dist/privacy/fieldRotation.d.ts.map +1 -0
- package/dist/privacy/fieldRotation.js +186 -0
- package/dist/privacy/inventory.d.ts +12 -0
- package/dist/privacy/inventory.d.ts.map +1 -0
- package/dist/privacy/inventory.js +154 -0
- package/dist/privacy/posture.d.ts +114 -0
- package/dist/privacy/posture.d.ts.map +1 -0
- package/dist/privacy/posture.js +136 -0
- package/dist/privacy/redact.d.ts +7 -0
- package/dist/privacy/redact.d.ts.map +1 -0
- package/dist/privacy/redact.js +404 -0
- package/dist/privacy/report.d.ts +58 -0
- package/dist/privacy/report.d.ts.map +1 -0
- package/dist/privacy/report.js +15 -0
- package/dist/privacy/retirement.d.ts +68 -0
- package/dist/privacy/retirement.d.ts.map +1 -0
- package/dist/privacy/retirement.js +72 -0
- package/dist/privacy/sealed.d.ts +87 -0
- package/dist/privacy/sealed.d.ts.map +1 -0
- package/dist/privacy/sealed.js +221 -0
- package/dist/privacy/types.d.ts +559 -0
- package/dist/privacy/types.d.ts.map +1 -0
- package/dist/privacy/types.js +2 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +1 -1
- package/src/audit/recorder.ts +10 -2
- package/src/capability/capability.ts +15 -0
- package/src/capability/purge.ts +124 -0
- package/src/cli/commandCatalog.generated.ts +59 -0
- package/src/cli/commandFacts.ts +1 -0
- package/src/controlPlane/capability.ts +11 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/handlers.ts +57 -4
- package/src/controlPlane/http/responses.ts +100 -0
- package/src/controlPlane/http/routes.ts +31 -1
- package/src/controlPlane/scope/scope.ts +14 -3
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +4 -0
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +42 -0
- package/src/error/pithyError.ts +21 -7
- package/src/index.ts +1 -0
- package/src/logger/logger.ts +12 -5
- package/src/privacy/blindIndex.ts +81 -0
- package/src/privacy/classes.ts +277 -0
- package/src/privacy/classify.ts +1447 -0
- package/src/privacy/encrypted.ts +513 -0
- package/src/privacy/encryptedColumns.ts +185 -0
- package/src/privacy/fieldRotation.ts +234 -0
- package/src/privacy/inventory.ts +147 -0
- package/src/privacy/posture.ts +205 -0
- package/src/privacy/redact.ts +423 -0
- package/src/privacy/report.ts +64 -0
- package/src/privacy/retirement.ts +102 -0
- package/src/privacy/sealed.ts +323 -0
- package/src/privacy/types.ts +804 -0
- 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 };
|