@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.
Files changed (142) hide show
  1. package/dist/audit/recorder.d.ts +10 -2
  2. package/dist/audit/recorder.d.ts.map +1 -1
  3. package/dist/capability/capability.d.ts +12 -0
  4. package/dist/capability/capability.d.ts.map +1 -1
  5. package/dist/capability/capability.js +2 -0
  6. package/dist/capability/purge.d.ts +80 -0
  7. package/dist/capability/purge.d.ts.map +1 -0
  8. package/dist/capability/purge.js +76 -0
  9. package/dist/cli/commandCatalog.generated.d.ts +54 -2
  10. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  11. package/dist/cli/commandCatalog.generated.js +55 -0
  12. package/dist/cli/commandFacts.d.ts +1 -0
  13. package/dist/cli/commandFacts.d.ts.map +1 -1
  14. package/dist/cli/commandFacts.js +1 -0
  15. package/dist/controlPlane/capability.d.ts.map +1 -1
  16. package/dist/controlPlane/capability.js +7 -0
  17. package/dist/controlPlane/data/connection.d.ts +2 -12
  18. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  19. package/dist/controlPlane/data/connection.js +1 -1
  20. package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
  21. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  22. package/dist/controlPlane/discovery/adminRoute.js +3 -0
  23. package/dist/controlPlane/error/errors.d.ts +4 -3
  24. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  25. package/dist/controlPlane/http/handlers.d.ts +79 -0
  26. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  27. package/dist/controlPlane/http/handlers.js +37 -3
  28. package/dist/controlPlane/http/responses.d.ts +98 -0
  29. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  30. package/dist/controlPlane/http/responses.js +41 -1
  31. package/dist/controlPlane/http/routes.d.ts +1 -0
  32. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  33. package/dist/controlPlane/http/routes.js +17 -3
  34. package/dist/controlPlane/scope/scope.d.ts +13 -3
  35. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  36. package/dist/controlPlane/scope/scope.js +14 -4
  37. package/dist/createBackend.d.ts.map +1 -1
  38. package/dist/createBackend.js +2 -0
  39. package/dist/crypto/envelope.d.ts +86 -0
  40. package/dist/crypto/envelope.d.ts.map +1 -0
  41. package/dist/crypto/envelope.js +129 -0
  42. package/dist/crypto/fieldKeys.d.ts +41 -0
  43. package/dist/crypto/fieldKeys.d.ts.map +1 -0
  44. package/dist/crypto/fieldKeys.js +56 -0
  45. package/dist/data/codecs.d.ts +23 -10
  46. package/dist/data/codecs.d.ts.map +1 -1
  47. package/dist/data/codecs.js +50 -36
  48. package/dist/error/extend.d.ts +6 -5
  49. package/dist/error/extend.d.ts.map +1 -1
  50. package/dist/error/http.d.ts +24 -0
  51. package/dist/error/http.d.ts.map +1 -1
  52. package/dist/error/payload.d.ts +48 -0
  53. package/dist/error/payload.d.ts.map +1 -1
  54. package/dist/error/payload.js +16 -0
  55. package/dist/error/pithyError.d.ts +18 -7
  56. package/dist/error/pithyError.d.ts.map +1 -1
  57. package/dist/error/pithyError.js +6 -0
  58. package/dist/index.d.ts +1 -1
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +2 -2
  61. package/dist/logger/logger.d.ts +12 -5
  62. package/dist/logger/logger.d.ts.map +1 -1
  63. package/dist/logger/record.d.ts +14 -0
  64. package/dist/logger/record.d.ts.map +1 -1
  65. package/dist/privacy/blindIndex.d.ts +27 -0
  66. package/dist/privacy/blindIndex.d.ts.map +1 -0
  67. package/dist/privacy/blindIndex.js +57 -0
  68. package/dist/privacy/classes.d.ts +142 -0
  69. package/dist/privacy/classes.d.ts.map +1 -0
  70. package/dist/privacy/classes.js +209 -0
  71. package/dist/privacy/classify.d.ts +336 -0
  72. package/dist/privacy/classify.d.ts.map +1 -0
  73. package/dist/privacy/classify.js +1024 -0
  74. package/dist/privacy/encrypted.d.ts +210 -0
  75. package/dist/privacy/encrypted.d.ts.map +1 -0
  76. package/dist/privacy/encrypted.js +350 -0
  77. package/dist/privacy/encryptedColumns.d.ts +64 -0
  78. package/dist/privacy/encryptedColumns.d.ts.map +1 -0
  79. package/dist/privacy/encryptedColumns.js +134 -0
  80. package/dist/privacy/fieldRotation.d.ts +34 -0
  81. package/dist/privacy/fieldRotation.d.ts.map +1 -0
  82. package/dist/privacy/fieldRotation.js +186 -0
  83. package/dist/privacy/inventory.d.ts +12 -0
  84. package/dist/privacy/inventory.d.ts.map +1 -0
  85. package/dist/privacy/inventory.js +154 -0
  86. package/dist/privacy/posture.d.ts +114 -0
  87. package/dist/privacy/posture.d.ts.map +1 -0
  88. package/dist/privacy/posture.js +136 -0
  89. package/dist/privacy/redact.d.ts +7 -0
  90. package/dist/privacy/redact.d.ts.map +1 -0
  91. package/dist/privacy/redact.js +404 -0
  92. package/dist/privacy/report.d.ts +58 -0
  93. package/dist/privacy/report.d.ts.map +1 -0
  94. package/dist/privacy/report.js +15 -0
  95. package/dist/privacy/retirement.d.ts +68 -0
  96. package/dist/privacy/retirement.d.ts.map +1 -0
  97. package/dist/privacy/retirement.js +72 -0
  98. package/dist/privacy/sealed.d.ts +87 -0
  99. package/dist/privacy/sealed.d.ts.map +1 -0
  100. package/dist/privacy/sealed.js +221 -0
  101. package/dist/privacy/types.d.ts +559 -0
  102. package/dist/privacy/types.d.ts.map +1 -0
  103. package/dist/privacy/types.js +2 -0
  104. package/dist/version.generated.d.ts +1 -1
  105. package/dist/version.generated.js +1 -1
  106. package/package.json +1 -1
  107. package/src/audit/recorder.ts +10 -2
  108. package/src/capability/capability.ts +15 -0
  109. package/src/capability/purge.ts +124 -0
  110. package/src/cli/commandCatalog.generated.ts +59 -0
  111. package/src/cli/commandFacts.ts +1 -0
  112. package/src/controlPlane/capability.ts +11 -0
  113. package/src/controlPlane/discovery/adminRoute.ts +11 -0
  114. package/src/controlPlane/error/errors.ts +4 -3
  115. package/src/controlPlane/http/handlers.ts +57 -4
  116. package/src/controlPlane/http/responses.ts +100 -0
  117. package/src/controlPlane/http/routes.ts +31 -1
  118. package/src/controlPlane/scope/scope.ts +14 -3
  119. package/src/controlPlane/token/base64url.ts +1 -1
  120. package/src/createBackend.ts +4 -0
  121. package/src/crypto/envelope.ts +196 -0
  122. package/src/crypto/fieldKeys.ts +76 -0
  123. package/src/data/codecs.ts +69 -37
  124. package/src/error/extend.ts +6 -5
  125. package/src/error/payload.ts +42 -0
  126. package/src/error/pithyError.ts +21 -7
  127. package/src/index.ts +1 -0
  128. package/src/logger/logger.ts +12 -5
  129. package/src/privacy/blindIndex.ts +81 -0
  130. package/src/privacy/classes.ts +277 -0
  131. package/src/privacy/classify.ts +1447 -0
  132. package/src/privacy/encrypted.ts +513 -0
  133. package/src/privacy/encryptedColumns.ts +185 -0
  134. package/src/privacy/fieldRotation.ts +234 -0
  135. package/src/privacy/inventory.ts +147 -0
  136. package/src/privacy/posture.ts +205 -0
  137. package/src/privacy/redact.ts +423 -0
  138. package/src/privacy/report.ts +64 -0
  139. package/src/privacy/retirement.ts +102 -0
  140. package/src/privacy/sealed.ts +323 -0
  141. package/src/privacy/types.ts +804 -0
  142. package/src/version.generated.ts +1 -1
@@ -0,0 +1,64 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PrivacyField } from "./classify";
5
+
6
+ /**
7
+ * **The inventory's shape, apart from the walk that fills it** (#132).
8
+ *
9
+ * Its own module so a browser may import it. The manifest's response schema names {@link PRIVACY_SOURCES}, and
10
+ * every capability's scope declarations reach that schema; the walk in `./inventory` takes a `Capability`, which
11
+ * reaches Hono, Kysely and the Workers types. A type-only edge is still an edge to the compiler a browser build
12
+ * uses, so the shape lives here and the walk imports it, never the other way.
13
+ */
14
+
15
+ /** The storage the inventory walks, in the order it reports them. */
16
+ export const PRIVACY_SOURCES = ["d1", "kv"] as const;
17
+
18
+ /** One storage kind the inventory walks. */
19
+ export type PrivacySource = (typeof PRIVACY_SOURCES)[number];
20
+
21
+ /** One schema holding at least one marked field. */
22
+ export interface PrivacyInventorySchema {
23
+ /** The capability that declared it. */
24
+ capability: string;
25
+ /** Where it is stored. */
26
+ storage: PrivacySource;
27
+ /** The D1 database name or the KV namespace name it lives in. */
28
+ container: string;
29
+ /** The table name or the KV store name. */
30
+ name: string;
31
+ /** Every marked field in it, with its class. */
32
+ fields: PrivacyField[];
33
+ /**
34
+ * Every field in it marked `encrypted()`, classed or not (#390): encryption may be marked alone, so this is where an
35
+ * unclassed one is listed. `unrotatable` where the storage is not D1.
36
+ */
37
+ encrypted: EncryptedFieldRecord[];
38
+ }
39
+
40
+ /** One encrypted field of a schema. */
41
+ export interface EncryptedFieldRecord {
42
+ /** Where it sits, on the same paths as {@link PrivacyField.path}. */
43
+ path: string;
44
+ /** Set where the storage is not D1, so it can never be rotated. */
45
+ unrotatable?: true;
46
+ }
47
+
48
+ /** Everything one Worker's storage schemas say about privacy. */
49
+ export interface PrivacyInventory {
50
+ /** The storage kinds walked. Anything not listed was not. */
51
+ sources: PrivacySource[];
52
+ /** How many schemas were walked, marked or not. */
53
+ schemasWalked: number;
54
+ /** Every schema that holds a marked field, in composition order. */
55
+ schemas: PrivacyInventorySchema[];
56
+ /** Marked fields per class. A class with none is absent rather than zero. */
57
+ counts: Record<string, number>;
58
+ /** Every class with at least one marked field: the built-ins in their fixed order, then the rest by name. */
59
+ observed: string[];
60
+ /** How many encrypted fields the walked schemas hold, classed or not (#390). */
61
+ encryptedFields: number;
62
+ /** How many of them rest outside D1 and so can never be rotated. */
63
+ unrotatable: number;
64
+ }
@@ -0,0 +1,102 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * **The retirement guard's verdict, apart from the counts that feed it** (#390).
8
+ *
9
+ * Its own module, and a leaf, so a browser may import it: the manifest's posture block names
10
+ * {@link RetirementVerdict}, and the management client validates that block in a browser. The counts are read by
11
+ * `./fieldRotation`, which reaches D1 and the Workers types; it imports this, never the other way.
12
+ */
13
+
14
+ /** Why the guard is not clean: what holds a version that is due to leave. */
15
+ export const RetirementReason = z
16
+ .enum(["unrotatable", "incomplete-coverage", "stale"])
17
+ .describe(
18
+ "`unrotatable` — an encrypted field rests outside D1, so nothing can prove it re-sealed. `incomplete-coverage` — a schema source the guard derives from was not read. `stale` — a row still holds an envelope or an index under a version due to leave.",
19
+ );
20
+ export type RetirementReason = z.output<typeof RetirementReason>;
21
+
22
+ /** The guard's verdict, as the manifest's posture block reports it. */
23
+ export const RetirementVerdict = z
24
+ .object({
25
+ blocked: z.boolean().describe("Whether a version due to leave the window is being held live by one of `reasons`."),
26
+ reasons: z
27
+ .array(RetirementReason)
28
+ .describe("Every reason the guard is not clean now, empty when it is. Read directly: never inferred."),
29
+ liveVersions: z
30
+ .number()
31
+ .int()
32
+ .nonnegative()
33
+ .describe("How many field-key versions are live. Two is the normal state: current and previous."),
34
+ })
35
+ .describe("Whether the field key's oldest live version can be retired, and if not, why.");
36
+ export type RetirementVerdict = z.output<typeof RetirementVerdict>;
37
+
38
+ /** What the guard reads. */
39
+ export interface RetirementInput {
40
+ /** Every live version, newest first. */
41
+ versions: readonly number[];
42
+ /** The version every write seals under. Absent where the key set could not be read: then nothing is due. */
43
+ current?: number | undefined;
44
+ /** Rows still under any version due to leave, summed over every stamped envelope and index column. */
45
+ stale: number;
46
+ /** Whether every schema source the stamp derives from was read. */
47
+ coverageComplete: boolean;
48
+ /** How many encrypted fields rest outside D1. */
49
+ unrotatable: number;
50
+ }
51
+
52
+ /**
53
+ * The version `current` replaced: the highest live version below it. A version above current is staged and never
54
+ * promoted, so it replaced nothing and is never the previous one.
55
+ */
56
+ export function previousVersion(versions: readonly number[], current: number): number | undefined {
57
+ const below = versions.filter((version) => version < current);
58
+ return below.length === 0 ? undefined : Math.max(...below);
59
+ }
60
+
61
+ /**
62
+ * The versions staged over `current` and never promoted, oldest first. A rotation whose confirm failed leaves one in
63
+ * the entry; the next rotation promotes it rather than minting past it, so the window stays anchored on current.
64
+ */
65
+ export function stagedVersions(versions: readonly number[], current: number): number[] {
66
+ return versions.filter((version) => version > current).sort((left, right) => left - right);
67
+ }
68
+
69
+ /**
70
+ * The versions due to leave the window: every live version older than the one `current` replaced. Current and
71
+ * previous are the normal state, and the previous version is still sealing rows lazily, so only an older one is
72
+ * due. Anchored on the pointer, never on the two highest numbers: a version staged over current and never promoted
73
+ * would otherwise take the previous slot, and make the version that was current, or current itself, due.
74
+ */
75
+ export function versionsDue(versions: readonly number[], current: number): number[] {
76
+ const previous = previousVersion(versions, current);
77
+ if (previous === undefined) return [];
78
+ return versions.filter((version) => version < previous).sort((left, right) => right - left);
79
+ }
80
+
81
+ /**
82
+ * **The retirement guard.** Clean only when all three hold: the stale count is zero across every stamped envelope
83
+ * and index column, coverage is complete, and no unrotatable field was observed. `blocked` is whether a version is
84
+ * due and the guard is not clean. Retiring a version is the one irreversible act in the feature — every ciphertext
85
+ * still under it becomes unopenable — so nothing short of clean retires one, and nothing implies it.
86
+ */
87
+ export function retirementVerdict(input: RetirementInput): RetirementVerdict {
88
+ const reasons: RetirementReason[] = [];
89
+ if (input.unrotatable > 0) reasons.push("unrotatable");
90
+ if (!input.coverageComplete) reasons.push("incomplete-coverage");
91
+ if (input.stale > 0) reasons.push("stale");
92
+ return {
93
+ blocked: input.current !== undefined && versionsDue(input.versions, input.current).length > 0 && reasons.length > 0,
94
+ reasons,
95
+ liveVersions: input.versions.length,
96
+ };
97
+ }
98
+
99
+ /** Whether a verdict lets the rotator retire: nothing at all holds a version. */
100
+ export function guardIsClean(verdict: RetirementVerdict): boolean {
101
+ return verdict.reasons.length === 0;
102
+ }
@@ -0,0 +1,323 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import { decryptValue, type EncryptedEnvelope, type EncryptionConfig, encryptValue } from "../crypto/envelope";
6
+ import { FIELD_KEYS_BINDING, type FieldKeys, liveVersions, resolveFieldKeys } from "../crypto/fieldKeys";
7
+ import { isSqliteJson, type SqliteJsonSchema, sqliteJson } from "../data/codecs";
8
+ import { fromZodError, ValidationError } from "../error/pithyError";
9
+ import { blindIndexValue } from "./blindIndex";
10
+ import {
11
+ type EncryptedFieldLayout,
12
+ type EncryptedIndexOf,
13
+ type EncryptedInner,
14
+ encryptedLayout,
15
+ encryptedSpecOf,
16
+ hasEncryptedFields,
17
+ } from "./encrypted";
18
+
19
+ /**
20
+ * **`sealed()`: the round trip for a table with an encrypted field** (#390).
21
+ *
22
+ * The codecs stay synchronous (`./encrypted`), so AES-GCM happens one step above them, in a seam the repository builds
23
+ * per request from `env` — which is how the key reaches the encode without a module-level schema ever holding one.
24
+ *
25
+ * ```ts
26
+ * const patients = sealed(Patient, { keys: env.PITHY_FIELD_KEYS });
27
+ *
28
+ * const record = await patients.seal(value); // seal each marked field, then Patient.encode
29
+ * await db.insertInto("patient").values(record).execute();
30
+ *
31
+ * const value = await patients.open(row); // Patient.parse, then open each marked field
32
+ *
33
+ * const hashes = await patients.match("email", input); // one per live version
34
+ * await db.selectFrom("patient").where("emailIndex", "in", hashes).selectAll().execute();
35
+ * ```
36
+ *
37
+ * ## What each seal binds
38
+ *
39
+ * The AAD is `pithy/enc/1:<name>:<field path>:<pk>` ({@link fieldContext}): the stable name `encryptedTable()`
40
+ * declared, the field's path, and the row's primary key. A ciphertext moved to another field, row or table does not
41
+ * open. The declared name is not the SQL table name, so renaming a table in a migration leaves every row openable. A
42
+ * column's SQL name is always its key in snake case, so renaming an encrypted column changes its field path. The key
43
+ * is app-assigned and never updated, which is the immutability guarantee. Changing the name, a path (a column rename
44
+ * included) or a key is a re-seal migration through `sealed()`: read with the old schema, write with the new.
45
+ *
46
+ * ## What it refuses, at build
47
+ *
48
+ * A schema with an encrypted field and no `encryptedTable()` declaration, and every problem `encryptedLayout` names:
49
+ * an encrypted field no path of object keys reaches, an indexed field with no index column, an index column naming no
50
+ * indexed field. Each is refused when `sealed()` is called, before any key is read, naming the schema.
51
+ *
52
+ * Every write seals under the key set's current version. Every read opens under whichever version sealed the value,
53
+ * for as long as that version is live.
54
+ */
55
+
56
+ /** The versioned prefix of every field's AAD. Changing what is bound changes this, so the break is deliberate. */
57
+ export const FIELD_CONTEXT_PREFIX = "pithy/enc/1";
58
+
59
+ /** The AAD one field binds: `pithy/enc/1:<name>:<field path>:<pk>`. */
60
+ export function fieldContext(name: string, path: string, pk: string): string {
61
+ return `${FIELD_CONTEXT_PREFIX}:${name}:${path}:${pk}`;
62
+ }
63
+
64
+ /** Depth countdown for {@link Opened}. */
65
+ type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8];
66
+
67
+ /**
68
+ * The value `open` returns and `seal` takes: the schema's output, with every encrypted field the output of the schema
69
+ * it seals — the plaintext, carrying that schema's privacy brand — instead of an envelope. Read through the presence
70
+ * wrappers, objects, and the kit's JSON columns, to eight levels.
71
+ */
72
+ export type Opened<S, D extends number = 8> = [D] extends [never]
73
+ ? z.output<S>
74
+ : [EncryptedInner<S>] extends [never]
75
+ ? S extends z.ZodOptional<infer I>
76
+ ? Opened<I, Prev[D]> | undefined
77
+ : S extends z.ZodNullable<infer I>
78
+ ? Opened<I, Prev[D]> | null
79
+ : S extends z.ZodDefault<infer I>
80
+ ? Exclude<Opened<I, Prev[D]>, undefined>
81
+ : S extends z.ZodReadonly<infer I>
82
+ ? Readonly<Opened<I, Prev[D]>>
83
+ : [SqliteJsonSchema<S>] extends [never]
84
+ ? S extends z.ZodObject<infer Shape>
85
+ ? { [K in keyof z.output<S>]: K extends keyof Shape ? Opened<Shape[K], Prev[D]> : z.output<S>[K] }
86
+ : z.output<S>
87
+ : Opened<SqliteJsonSchema<S>, Prev[D]>
88
+ : z.output<EncryptedInner<S>>;
89
+
90
+ /** A field through its presence wrappers. */
91
+ type Unwrapped<S> =
92
+ S extends z.ZodOptional<infer I>
93
+ ? Unwrapped<I>
94
+ : S extends z.ZodNullable<infer I>
95
+ ? Unwrapped<I>
96
+ : S extends z.ZodDefault<infer I>
97
+ ? Unwrapped<I>
98
+ : S;
99
+
100
+ /** The top-level fields of a table schema that declare a blind index: what `match` takes. */
101
+ export type IndexedField<S> =
102
+ S extends z.ZodObject<infer Shape>
103
+ ? { [K in keyof Shape & string]: [EncryptedIndexOf<Unwrapped<Shape[K]>>] extends [never] ? never : K }[keyof Shape &
104
+ string]
105
+ : never;
106
+
107
+ /** What {@link sealed} takes. */
108
+ export interface SealedOptions {
109
+ /** The field key: `env.PITHY_FIELD_KEYS`, or a resolved key set. Read on the first seal, open or match. */
110
+ keys: FieldKeys | undefined;
111
+ }
112
+
113
+ /** The repository seam over one table with encrypted fields. */
114
+ export interface Sealed<S extends z.ZodType> {
115
+ /** The table schema. */
116
+ readonly schema: S;
117
+ /** The schema of the opened value: every encrypted field replaced by the schema it seals. `redact` takes it. */
118
+ readonly opened: z.ZodType<Opened<S>>;
119
+ /** Seal every encrypted field under the current version, write each blind index, then `Schema.encode`. */
120
+ seal(value: Opened<S>): Promise<z.input<S>>;
121
+ /** `Schema.parse`, then open every encrypted field and validate it against the schema it seals. */
122
+ open(row: z.input<S>): Promise<Opened<S>>;
123
+ /** The blind index of `value` for `field` under every live version, newest first: what an `IN (…)` looks for. */
124
+ match(field: IndexedField<S>, value: string): Promise<string[]>;
125
+ }
126
+
127
+ /** How a refusal names a schema: its description, or what it holds. */
128
+ function schemaLabel(schema: z.ZodType, paths: readonly string[]): string {
129
+ const description = schema.description;
130
+ if (description) return `The schema "${description}"`;
131
+ return paths.length > 0 ? `The schema holding ${paths.join(", ")}` : "This schema";
132
+ }
133
+
134
+ /** The value at a path, or `undefined` where any step is absent. */
135
+ function readPath(value: unknown, segments: readonly string[]): unknown {
136
+ let at: unknown = value;
137
+ for (const segment of segments) {
138
+ if (typeof at !== "object" || at === null) return undefined;
139
+ at = (at as Record<string, unknown>)[segment];
140
+ }
141
+ return at;
142
+ }
143
+
144
+ /**
145
+ * A copy of `value` with `next` at `segments`, copying each object on the way so the caller's value is never
146
+ * written to. A step that is absent stays absent: there is nothing there to seal.
147
+ */
148
+ function writePath(value: unknown, segments: readonly string[], next: unknown): unknown {
149
+ const [head, ...rest] = segments;
150
+ if (head === undefined) return next;
151
+ if (typeof value !== "object" || value === null) return value;
152
+ const copy = { ...(value as Record<string, unknown>) };
153
+ copy[head] = rest.length === 0 ? next : writePath(copy[head], rest, next);
154
+ return copy;
155
+ }
156
+
157
+ /** The plaintext a value seals to: its schema's encoded form, as JSON. Refused without echoing the value. */
158
+ function plaintextOf(field: EncryptedFieldLayout, value: unknown): string {
159
+ const encoded = field.spec.inner.safeEncode(value);
160
+ if (!encoded.success) {
161
+ throw fromZodError(encoded.error, {
162
+ message: `The encrypted field "${field.path}" does not hold a value its schema accepts.`,
163
+ });
164
+ }
165
+ const json = JSON.stringify(encoded.data);
166
+ if (typeof json !== "string") {
167
+ throw new ValidationError({ message: `The encrypted field "${field.path}" holds a value JSON cannot carry.` });
168
+ }
169
+ return json;
170
+ }
171
+
172
+ /** The opened schema: each encrypted field replaced by the schema it seals, along the paths that reach one. */
173
+ function openedSchemaOf(schema: z.ZodType): z.ZodType {
174
+ if (encryptedSpecOf(schema)) {
175
+ // Keep the presence the field was given outside the marker.
176
+ const def = schema._zod.def as unknown as { type: string; innerType?: z.ZodType };
177
+ if (def.innerType && (def.type === "optional" || def.type === "nullable" || def.type === "readonly")) {
178
+ return schema.clone({ ...def, innerType: openedSchemaOf(def.innerType) } as never);
179
+ }
180
+ return (encryptedSpecOf(schema) as { inner: z.ZodType }).inner;
181
+ }
182
+ if (!hasEncryptedFields(schema)) return schema;
183
+ const def = schema._zod.def as unknown as { type: string; innerType?: z.ZodType; out?: z.ZodType };
184
+ if (def.innerType && ["optional", "nullable", "default", "prefault", "readonly"].includes(def.type)) {
185
+ return schema.clone({ ...def, innerType: openedSchemaOf(def.innerType) } as never);
186
+ }
187
+ if (isSqliteJson(schema) && def.out) {
188
+ const meta = schema.meta();
189
+ const column = sqliteJson(openedSchemaOf(def.out));
190
+ return meta ? column.meta(meta) : column;
191
+ }
192
+ if (def.type === "object") {
193
+ const object = schema as z.ZodObject;
194
+ const changed: Record<string, z.ZodType> = {};
195
+ for (const [key, field] of Object.entries(object.shape)) {
196
+ if (hasEncryptedFields(field)) changed[key] = openedSchemaOf(field);
197
+ }
198
+ return object.extend(changed);
199
+ }
200
+ return schema;
201
+ }
202
+
203
+ /**
204
+ * Build the seam for one table, from the request's `env`. Refuses, before any key is read, a schema with an encrypted
205
+ * field and no `encryptedTable()` declaration, and every layout problem. See the module note.
206
+ */
207
+ export function sealed<S extends z.ZodType>(schema: S, options: SealedOptions): Sealed<S> {
208
+ const layout = encryptedLayout(schema);
209
+ const paths = layout.fields.map((field) => field.path);
210
+ if (layout.declaration === null) {
211
+ if (layout.fields.length > 0 || hasEncryptedFields(schema)) {
212
+ throw new ValidationError({
213
+ message: `${schemaLabel(schema, paths)} holds an encrypted field and declares no table.`,
214
+ action: "Wrap it in encryptedTable(schema, { name, key }): the name and key are what every sealed field binds.",
215
+ });
216
+ }
217
+ throw new ValidationError({
218
+ message: `${schemaLabel(schema, paths)} holds no encrypted field.`,
219
+ action: "Read and write it with Schema.parse and Schema.encode; sealed() is for a table with an encrypted field.",
220
+ });
221
+ }
222
+ if (layout.problems.length > 0) {
223
+ throw new ValidationError({
224
+ message: `${schemaLabel(schema, paths)} cannot be sealed as declared. ${layout.problems.join(" ")}`,
225
+ action: "Fix the schema's encrypted fields and their blindIndex columns.",
226
+ });
227
+ }
228
+ const { name, key } = layout.declaration;
229
+ let resolved: Promise<EncryptionConfig> | undefined;
230
+ const config = (): Promise<EncryptionConfig> => {
231
+ resolved ??= resolveFieldKeys(options.keys);
232
+ return resolved;
233
+ };
234
+ let opened: z.ZodType | undefined;
235
+
236
+ const primaryKey = (value: unknown, doing: string): string => {
237
+ const pk = readPath(value, [key]);
238
+ if (typeof pk !== "string" || pk.length === 0) {
239
+ throw new ValidationError({
240
+ message: `The ${name} row has no ${key} to bind, so it cannot be ${doing}.`,
241
+ action: `Assign ${key} in the app before the insert: every encrypted field binds it.`,
242
+ });
243
+ }
244
+ return pk;
245
+ };
246
+
247
+ return {
248
+ schema,
249
+ get opened() {
250
+ opened ??= openedSchemaOf(schema);
251
+ return opened as z.ZodType<Opened<S>>;
252
+ },
253
+
254
+ async seal(value) {
255
+ const pk = primaryKey(value, "sealed");
256
+ const keys = await config();
257
+ const current = Number(keys.currentVersion);
258
+ let out: unknown = value;
259
+ for (const field of layout.fields) {
260
+ const plain = readPath(value, field.segments);
261
+ if (plain === undefined || plain === null) {
262
+ if (field.indexField) out = writePath(out, [field.indexField], plain === null ? null : undefined);
263
+ continue;
264
+ }
265
+ const envelope = await encryptValue(keys, fieldContext(name, field.path, pk), plaintextOf(field, plain), {
266
+ keys: FIELD_KEYS_BINDING,
267
+ });
268
+ out = writePath(out, field.segments, envelope);
269
+ if (field.indexField && field.spec.index) {
270
+ if (typeof plain !== "string") {
271
+ throw new ValidationError({
272
+ message: `The encrypted field "${field.path}" has a blind index, so it holds a string.`,
273
+ });
274
+ }
275
+ out = writePath(out, [field.indexField], await blindIndexValue(keys, current, field.spec.index, plain));
276
+ }
277
+ }
278
+ const encoded = schema.safeEncode(out as z.output<S>);
279
+ if (!encoded.success) throw fromZodError(encoded.error);
280
+ return encoded.data;
281
+ },
282
+
283
+ async open(row) {
284
+ const parsed = schema.safeParse(row);
285
+ if (!parsed.success) throw fromZodError(parsed.error);
286
+ const pk = primaryKey(parsed.data, "opened");
287
+ const keys = await config();
288
+ let out: unknown = parsed.data;
289
+ for (const field of layout.fields) {
290
+ const envelope = readPath(parsed.data, field.segments) as EncryptedEnvelope | null | undefined;
291
+ if (envelope === undefined || envelope === null) continue;
292
+ const plaintext = await decryptValue(keys, fieldContext(name, field.path, pk), envelope, {
293
+ keys: FIELD_KEYS_BINDING,
294
+ });
295
+ let json: unknown;
296
+ try {
297
+ json = JSON.parse(plaintext);
298
+ } catch {
299
+ throw new ValidationError({
300
+ message: `The encrypted field "${field.path}" opened to something that is not JSON.`,
301
+ });
302
+ }
303
+ const value = field.spec.inner.safeParse(json);
304
+ if (!value.success) throw fromZodError(value.error);
305
+ out = writePath(out, field.segments, value.data);
306
+ }
307
+ return out as Opened<S>;
308
+ },
309
+
310
+ async match(field, value) {
311
+ const located = layout.fields.find((one) => one.path === field && one.indexField !== null);
312
+ if (!located?.spec.index) {
313
+ throw new ValidationError({
314
+ message: `"${String(field)}" is not a field of the ${name} table with a blind index.`,
315
+ action: `Declare it encrypted(x, { index: "exact" | "email" }) with a blindIndex("${String(field)}") column.`,
316
+ });
317
+ }
318
+ const keys = await config();
319
+ const normalizer = located.spec.index;
320
+ return Promise.all(liveVersions(keys).map((version) => blindIndexValue(keys, version, normalizer, value)));
321
+ },
322
+ };
323
+ }