@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,68 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
/**
|
|
5
|
+
* **The retirement guard's verdict, apart from the counts that feed it** (#390).
|
|
6
|
+
*
|
|
7
|
+
* Its own module, and a leaf, so a browser may import it: the manifest's posture block names
|
|
8
|
+
* {@link RetirementVerdict}, and the management client validates that block in a browser. The counts are read by
|
|
9
|
+
* `./fieldRotation`, which reaches D1 and the Workers types; it imports this, never the other way.
|
|
10
|
+
*/
|
|
11
|
+
/** Why the guard is not clean: what holds a version that is due to leave. */
|
|
12
|
+
export declare const RetirementReason: z.ZodEnum<{
|
|
13
|
+
"incomplete-coverage": "incomplete-coverage";
|
|
14
|
+
stale: "stale";
|
|
15
|
+
unrotatable: "unrotatable";
|
|
16
|
+
}>;
|
|
17
|
+
export type RetirementReason = z.output<typeof RetirementReason>;
|
|
18
|
+
/** The guard's verdict, as the manifest's posture block reports it. */
|
|
19
|
+
export declare const RetirementVerdict: z.ZodObject<{
|
|
20
|
+
blocked: z.ZodBoolean;
|
|
21
|
+
reasons: z.ZodArray<z.ZodEnum<{
|
|
22
|
+
"incomplete-coverage": "incomplete-coverage";
|
|
23
|
+
stale: "stale";
|
|
24
|
+
unrotatable: "unrotatable";
|
|
25
|
+
}>>;
|
|
26
|
+
liveVersions: z.ZodNumber;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
export type RetirementVerdict = z.output<typeof RetirementVerdict>;
|
|
29
|
+
/** What the guard reads. */
|
|
30
|
+
export interface RetirementInput {
|
|
31
|
+
/** Every live version, newest first. */
|
|
32
|
+
versions: readonly number[];
|
|
33
|
+
/** The version every write seals under. Absent where the key set could not be read: then nothing is due. */
|
|
34
|
+
current?: number | undefined;
|
|
35
|
+
/** Rows still under any version due to leave, summed over every stamped envelope and index column. */
|
|
36
|
+
stale: number;
|
|
37
|
+
/** Whether every schema source the stamp derives from was read. */
|
|
38
|
+
coverageComplete: boolean;
|
|
39
|
+
/** How many encrypted fields rest outside D1. */
|
|
40
|
+
unrotatable: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The version `current` replaced: the highest live version below it. A version above current is staged and never
|
|
44
|
+
* promoted, so it replaced nothing and is never the previous one.
|
|
45
|
+
*/
|
|
46
|
+
export declare function previousVersion(versions: readonly number[], current: number): number | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* The versions staged over `current` and never promoted, oldest first. A rotation whose confirm failed leaves one in
|
|
49
|
+
* the entry; the next rotation promotes it rather than minting past it, so the window stays anchored on current.
|
|
50
|
+
*/
|
|
51
|
+
export declare function stagedVersions(versions: readonly number[], current: number): number[];
|
|
52
|
+
/**
|
|
53
|
+
* The versions due to leave the window: every live version older than the one `current` replaced. Current and
|
|
54
|
+
* previous are the normal state, and the previous version is still sealing rows lazily, so only an older one is
|
|
55
|
+
* due. Anchored on the pointer, never on the two highest numbers: a version staged over current and never promoted
|
|
56
|
+
* would otherwise take the previous slot, and make the version that was current, or current itself, due.
|
|
57
|
+
*/
|
|
58
|
+
export declare function versionsDue(versions: readonly number[], current: number): number[];
|
|
59
|
+
/**
|
|
60
|
+
* **The retirement guard.** Clean only when all three hold: the stale count is zero across every stamped envelope
|
|
61
|
+
* and index column, coverage is complete, and no unrotatable field was observed. `blocked` is whether a version is
|
|
62
|
+
* due and the guard is not clean. Retiring a version is the one irreversible act in the feature — every ciphertext
|
|
63
|
+
* still under it becomes unopenable — so nothing short of clean retires one, and nothing implies it.
|
|
64
|
+
*/
|
|
65
|
+
export declare function retirementVerdict(input: RetirementInput): RetirementVerdict;
|
|
66
|
+
/** Whether a verdict lets the rotator retire: nothing at all holds a version. */
|
|
67
|
+
export declare function guardIsClean(verdict: RetirementVerdict): boolean;
|
|
68
|
+
//# sourceMappingURL=retirement.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retirement.d.ts","sourceRoot":"","sources":["../../src/privacy/retirement.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;GAMG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,gBAAgB;;;;EAI1B,CAAC;AACJ,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEjE,uEAAuE;AACvE,eAAO,MAAM,iBAAiB;;;;;;;;iBAY6D,CAAC;AAC5F,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEnE,4BAA4B;AAC5B,MAAM,WAAW,eAAe;IAC9B,wCAAwC;IACxC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,4GAA4G;IAC5G,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,sGAAsG;IACtG,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iDAAiD;IACjD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGhG;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAErF;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAIlF;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,eAAe,GAAG,iBAAiB,CAU3E;AAED,iFAAiF;AACjF,wBAAgB,YAAY,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAEhE"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/privacy/retirement.ts
|
|
5
|
+
/**
|
|
6
|
+
* **The retirement guard's verdict, apart from the counts that feed it** (#390).
|
|
7
|
+
*
|
|
8
|
+
* Its own module, and a leaf, so a browser may import it: the manifest's posture block names
|
|
9
|
+
* {@link RetirementVerdict}, and the management client validates that block in a browser. The counts are read by
|
|
10
|
+
* `./fieldRotation`, which reaches D1 and the Workers types; it imports this, never the other way.
|
|
11
|
+
*/
|
|
12
|
+
/** Why the guard is not clean: what holds a version that is due to leave. */
|
|
13
|
+
const RetirementReason = z.enum([
|
|
14
|
+
"unrotatable",
|
|
15
|
+
"incomplete-coverage",
|
|
16
|
+
"stale"
|
|
17
|
+
]).describe("`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.");
|
|
18
|
+
/** The guard's verdict, as the manifest's posture block reports it. */
|
|
19
|
+
const RetirementVerdict = z.object({
|
|
20
|
+
blocked: z.boolean().describe("Whether a version due to leave the window is being held live by one of `reasons`."),
|
|
21
|
+
reasons: z.array(RetirementReason).describe("Every reason the guard is not clean now, empty when it is. Read directly: never inferred."),
|
|
22
|
+
liveVersions: z.number().int().nonnegative().describe("How many field-key versions are live. Two is the normal state: current and previous.")
|
|
23
|
+
}).describe("Whether the field key's oldest live version can be retired, and if not, why.");
|
|
24
|
+
/**
|
|
25
|
+
* The version `current` replaced: the highest live version below it. A version above current is staged and never
|
|
26
|
+
* promoted, so it replaced nothing and is never the previous one.
|
|
27
|
+
*/
|
|
28
|
+
function previousVersion(versions, current) {
|
|
29
|
+
const below = versions.filter((version) => version < current);
|
|
30
|
+
return below.length === 0 ? void 0 : Math.max(...below);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The versions staged over `current` and never promoted, oldest first. A rotation whose confirm failed leaves one in
|
|
34
|
+
* the entry; the next rotation promotes it rather than minting past it, so the window stays anchored on current.
|
|
35
|
+
*/
|
|
36
|
+
function stagedVersions(versions, current) {
|
|
37
|
+
return versions.filter((version) => version > current).sort((left, right) => left - right);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The versions due to leave the window: every live version older than the one `current` replaced. Current and
|
|
41
|
+
* previous are the normal state, and the previous version is still sealing rows lazily, so only an older one is
|
|
42
|
+
* due. Anchored on the pointer, never on the two highest numbers: a version staged over current and never promoted
|
|
43
|
+
* would otherwise take the previous slot, and make the version that was current, or current itself, due.
|
|
44
|
+
*/
|
|
45
|
+
function versionsDue(versions, current) {
|
|
46
|
+
const previous = previousVersion(versions, current);
|
|
47
|
+
if (previous === void 0) return [];
|
|
48
|
+
return versions.filter((version) => version < previous).sort((left, right) => right - left);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* **The retirement guard.** Clean only when all three hold: the stale count is zero across every stamped envelope
|
|
52
|
+
* and index column, coverage is complete, and no unrotatable field was observed. `blocked` is whether a version is
|
|
53
|
+
* due and the guard is not clean. Retiring a version is the one irreversible act in the feature — every ciphertext
|
|
54
|
+
* still under it becomes unopenable — so nothing short of clean retires one, and nothing implies it.
|
|
55
|
+
*/
|
|
56
|
+
function retirementVerdict(input) {
|
|
57
|
+
const reasons = [];
|
|
58
|
+
if (input.unrotatable > 0) reasons.push("unrotatable");
|
|
59
|
+
if (!input.coverageComplete) reasons.push("incomplete-coverage");
|
|
60
|
+
if (input.stale > 0) reasons.push("stale");
|
|
61
|
+
return {
|
|
62
|
+
blocked: input.current !== void 0 && versionsDue(input.versions, input.current).length > 0 && reasons.length > 0,
|
|
63
|
+
reasons,
|
|
64
|
+
liveVersions: input.versions.length
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Whether a verdict lets the rotator retire: nothing at all holds a version. */
|
|
68
|
+
function guardIsClean(verdict) {
|
|
69
|
+
return verdict.reasons.length === 0;
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
export { RetirementReason, RetirementVerdict, guardIsClean, previousVersion, retirementVerdict, stagedVersions, versionsDue };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import { type FieldKeys } from "../crypto/fieldKeys";
|
|
5
|
+
import { type SqliteJsonSchema } from "../data/codecs";
|
|
6
|
+
import { type EncryptedIndexOf, type EncryptedInner } from "./encrypted";
|
|
7
|
+
/**
|
|
8
|
+
* **`sealed()`: the round trip for a table with an encrypted field** (#390).
|
|
9
|
+
*
|
|
10
|
+
* The codecs stay synchronous (`./encrypted`), so AES-GCM happens one step above them, in a seam the repository builds
|
|
11
|
+
* per request from `env` — which is how the key reaches the encode without a module-level schema ever holding one.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const patients = sealed(Patient, { keys: env.PITHY_FIELD_KEYS });
|
|
15
|
+
*
|
|
16
|
+
* const record = await patients.seal(value); // seal each marked field, then Patient.encode
|
|
17
|
+
* await db.insertInto("patient").values(record).execute();
|
|
18
|
+
*
|
|
19
|
+
* const value = await patients.open(row); // Patient.parse, then open each marked field
|
|
20
|
+
*
|
|
21
|
+
* const hashes = await patients.match("email", input); // one per live version
|
|
22
|
+
* await db.selectFrom("patient").where("emailIndex", "in", hashes).selectAll().execute();
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* ## What each seal binds
|
|
26
|
+
*
|
|
27
|
+
* The AAD is `pithy/enc/1:<name>:<field path>:<pk>` ({@link fieldContext}): the stable name `encryptedTable()`
|
|
28
|
+
* declared, the field's path, and the row's primary key. A ciphertext moved to another field, row or table does not
|
|
29
|
+
* open. The declared name is not the SQL table name, so renaming a table in a migration leaves every row openable. A
|
|
30
|
+
* column's SQL name is always its key in snake case, so renaming an encrypted column changes its field path. The key
|
|
31
|
+
* is app-assigned and never updated, which is the immutability guarantee. Changing the name, a path (a column rename
|
|
32
|
+
* included) or a key is a re-seal migration through `sealed()`: read with the old schema, write with the new.
|
|
33
|
+
*
|
|
34
|
+
* ## What it refuses, at build
|
|
35
|
+
*
|
|
36
|
+
* A schema with an encrypted field and no `encryptedTable()` declaration, and every problem `encryptedLayout` names:
|
|
37
|
+
* an encrypted field no path of object keys reaches, an indexed field with no index column, an index column naming no
|
|
38
|
+
* indexed field. Each is refused when `sealed()` is called, before any key is read, naming the schema.
|
|
39
|
+
*
|
|
40
|
+
* Every write seals under the key set's current version. Every read opens under whichever version sealed the value,
|
|
41
|
+
* for as long as that version is live.
|
|
42
|
+
*/
|
|
43
|
+
/** The versioned prefix of every field's AAD. Changing what is bound changes this, so the break is deliberate. */
|
|
44
|
+
export declare const FIELD_CONTEXT_PREFIX = "pithy/enc/1";
|
|
45
|
+
/** The AAD one field binds: `pithy/enc/1:<name>:<field path>:<pk>`. */
|
|
46
|
+
export declare function fieldContext(name: string, path: string, pk: string): string;
|
|
47
|
+
/** Depth countdown for {@link Opened}. */
|
|
48
|
+
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8];
|
|
49
|
+
/**
|
|
50
|
+
* The value `open` returns and `seal` takes: the schema's output, with every encrypted field the output of the schema
|
|
51
|
+
* it seals — the plaintext, carrying that schema's privacy brand — instead of an envelope. Read through the presence
|
|
52
|
+
* wrappers, objects, and the kit's JSON columns, to eight levels.
|
|
53
|
+
*/
|
|
54
|
+
export type Opened<S, D extends number = 8> = [D] extends [never] ? z.output<S> : [EncryptedInner<S>] extends [never] ? S extends z.ZodOptional<infer I> ? Opened<I, Prev[D]> | undefined : S extends z.ZodNullable<infer I> ? Opened<I, Prev[D]> | null : S extends z.ZodDefault<infer I> ? Exclude<Opened<I, Prev[D]>, undefined> : S extends z.ZodReadonly<infer I> ? Readonly<Opened<I, Prev[D]>> : [SqliteJsonSchema<S>] extends [never] ? S extends z.ZodObject<infer Shape> ? {
|
|
55
|
+
[K in keyof z.output<S>]: K extends keyof Shape ? Opened<Shape[K], Prev[D]> : z.output<S>[K];
|
|
56
|
+
} : z.output<S> : Opened<SqliteJsonSchema<S>, Prev[D]> : z.output<EncryptedInner<S>>;
|
|
57
|
+
/** A field through its presence wrappers. */
|
|
58
|
+
type Unwrapped<S> = S extends z.ZodOptional<infer I> ? Unwrapped<I> : S extends z.ZodNullable<infer I> ? Unwrapped<I> : S extends z.ZodDefault<infer I> ? Unwrapped<I> : S;
|
|
59
|
+
/** The top-level fields of a table schema that declare a blind index: what `match` takes. */
|
|
60
|
+
export type IndexedField<S> = S extends z.ZodObject<infer Shape> ? {
|
|
61
|
+
[K in keyof Shape & string]: [EncryptedIndexOf<Unwrapped<Shape[K]>>] extends [never] ? never : K;
|
|
62
|
+
}[keyof Shape & string] : never;
|
|
63
|
+
/** What {@link sealed} takes. */
|
|
64
|
+
export interface SealedOptions {
|
|
65
|
+
/** The field key: `env.PITHY_FIELD_KEYS`, or a resolved key set. Read on the first seal, open or match. */
|
|
66
|
+
keys: FieldKeys | undefined;
|
|
67
|
+
}
|
|
68
|
+
/** The repository seam over one table with encrypted fields. */
|
|
69
|
+
export interface Sealed<S extends z.ZodType> {
|
|
70
|
+
/** The table schema. */
|
|
71
|
+
readonly schema: S;
|
|
72
|
+
/** The schema of the opened value: every encrypted field replaced by the schema it seals. `redact` takes it. */
|
|
73
|
+
readonly opened: z.ZodType<Opened<S>>;
|
|
74
|
+
/** Seal every encrypted field under the current version, write each blind index, then `Schema.encode`. */
|
|
75
|
+
seal(value: Opened<S>): Promise<z.input<S>>;
|
|
76
|
+
/** `Schema.parse`, then open every encrypted field and validate it against the schema it seals. */
|
|
77
|
+
open(row: z.input<S>): Promise<Opened<S>>;
|
|
78
|
+
/** The blind index of `value` for `field` under every live version, newest first: what an `IN (…)` looks for. */
|
|
79
|
+
match(field: IndexedField<S>, value: string): Promise<string[]>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build the seam for one table, from the request's `env`. Refuses, before any key is read, a schema with an encrypted
|
|
83
|
+
* field and no `encryptedTable()` declaration, and every layout problem. See the module note.
|
|
84
|
+
*/
|
|
85
|
+
export declare function sealed<S extends z.ZodType>(schema: S, options: SealedOptions): Sealed<S>;
|
|
86
|
+
export {};
|
|
87
|
+
//# sourceMappingURL=sealed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sealed.d.ts","sourceRoot":"","sources":["../../src/privacy/sealed.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,OAAO,EAAsB,KAAK,SAAS,EAAkC,MAAM,qBAAqB,CAAC;AACzG,OAAO,EAAgB,KAAK,gBAAgB,EAAc,MAAM,gBAAgB,CAAC;AAGjF,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,cAAc,EAIpB,MAAM,aAAa,CAAC;AAErB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,kHAAkH;AAClH,eAAO,MAAM,oBAAoB,gBAAgB,CAAC;AAElD,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAE3E;AAED,0CAA0C;AAC1C,KAAK,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AAE/C;;;;GAIG;AACH,MAAM,MAAM,MAAM,CAAC,CAAC,EAAE,CAAC,SAAS,MAAM,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAC7D,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GACX,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GACjC,CAAC,SAAS,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,GAC9B,MAAM,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,SAAS,GAC9B,CAAC,SAAS,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,GAC9B,MAAM,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,GACzB,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,GAC7B,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,SAAS,CAAC,GACtC,CAAC,SAAS,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,GAC9B,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,GAC5B,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GACnC,CAAC,SAAS,CAAC,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC,GAChC;KAAG,CAAC,IAAI,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,GAChG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GACb,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,GAC9C,CAAC,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC;AAElC,6CAA6C;AAC7C,KAAK,SAAS,CAAC,CAAC,IACd,CAAC,SAAS,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,GAC5B,SAAS,CAAC,CAAC,CAAC,GACZ,CAAC,SAAS,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,GAC9B,SAAS,CAAC,CAAC,CAAC,GACZ,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,GAC7B,SAAS,CAAC,CAAC,CAAC,GACZ,CAAC,CAAC;AAEZ,6FAA6F;AAC7F,MAAM,MAAM,YAAY,CAAC,CAAC,IACxB,CAAC,SAAS,CAAC,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC,GAC9B;KAAG,CAAC,IAAI,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,gBAAgB,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,CAAC;CAAE,CAAC,MAAM,KAAK,GAC9G,MAAM,CAAC,GACT,KAAK,CAAC;AAEZ,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,2GAA2G;IAC3G,IAAI,EAAE,SAAS,GAAG,SAAS,CAAC;CAC7B;AAED,gEAAgE;AAChE,MAAM,WAAW,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO;IACzC,wBAAwB;IACxB,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;IACnB,gHAAgH;IAChH,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IACtC,0GAA0G;IAC1G,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5C,mGAAmG;IACnG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1C,iHAAiH;IACjH,KAAK,CAAC,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACjE;AA8ED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,aAAa,GAAG,MAAM,CAAC,CAAC,CAAC,CAoHxF"}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { ValidationError, fromZodError } from "../error/pithyError.js";
|
|
4
|
+
import { decryptValue, encryptValue } from "../crypto/envelope.js";
|
|
5
|
+
import { FIELD_KEYS_BINDING, liveVersions, resolveFieldKeys } from "../crypto/fieldKeys.js";
|
|
6
|
+
import { isSqliteJson, sqliteJson } from "../data/codecs.js";
|
|
7
|
+
import { encryptedLayout, encryptedSpecOf, hasEncryptedFields } from "./encrypted.js";
|
|
8
|
+
import { blindIndexValue } from "./blindIndex.js";
|
|
9
|
+
//#region src/privacy/sealed.ts
|
|
10
|
+
/**
|
|
11
|
+
* **`sealed()`: the round trip for a table with an encrypted field** (#390).
|
|
12
|
+
*
|
|
13
|
+
* The codecs stay synchronous (`./encrypted`), so AES-GCM happens one step above them, in a seam the repository builds
|
|
14
|
+
* per request from `env` — which is how the key reaches the encode without a module-level schema ever holding one.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const patients = sealed(Patient, { keys: env.PITHY_FIELD_KEYS });
|
|
18
|
+
*
|
|
19
|
+
* const record = await patients.seal(value); // seal each marked field, then Patient.encode
|
|
20
|
+
* await db.insertInto("patient").values(record).execute();
|
|
21
|
+
*
|
|
22
|
+
* const value = await patients.open(row); // Patient.parse, then open each marked field
|
|
23
|
+
*
|
|
24
|
+
* const hashes = await patients.match("email", input); // one per live version
|
|
25
|
+
* await db.selectFrom("patient").where("emailIndex", "in", hashes).selectAll().execute();
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* ## What each seal binds
|
|
29
|
+
*
|
|
30
|
+
* The AAD is `pithy/enc/1:<name>:<field path>:<pk>` ({@link fieldContext}): the stable name `encryptedTable()`
|
|
31
|
+
* declared, the field's path, and the row's primary key. A ciphertext moved to another field, row or table does not
|
|
32
|
+
* open. The declared name is not the SQL table name, so renaming a table in a migration leaves every row openable. A
|
|
33
|
+
* column's SQL name is always its key in snake case, so renaming an encrypted column changes its field path. The key
|
|
34
|
+
* is app-assigned and never updated, which is the immutability guarantee. Changing the name, a path (a column rename
|
|
35
|
+
* included) or a key is a re-seal migration through `sealed()`: read with the old schema, write with the new.
|
|
36
|
+
*
|
|
37
|
+
* ## What it refuses, at build
|
|
38
|
+
*
|
|
39
|
+
* A schema with an encrypted field and no `encryptedTable()` declaration, and every problem `encryptedLayout` names:
|
|
40
|
+
* an encrypted field no path of object keys reaches, an indexed field with no index column, an index column naming no
|
|
41
|
+
* indexed field. Each is refused when `sealed()` is called, before any key is read, naming the schema.
|
|
42
|
+
*
|
|
43
|
+
* Every write seals under the key set's current version. Every read opens under whichever version sealed the value,
|
|
44
|
+
* for as long as that version is live.
|
|
45
|
+
*/
|
|
46
|
+
/** The versioned prefix of every field's AAD. Changing what is bound changes this, so the break is deliberate. */
|
|
47
|
+
const FIELD_CONTEXT_PREFIX = "pithy/enc/1";
|
|
48
|
+
/** The AAD one field binds: `pithy/enc/1:<name>:<field path>:<pk>`. */
|
|
49
|
+
function fieldContext(name, path, pk) {
|
|
50
|
+
return `${FIELD_CONTEXT_PREFIX}:${name}:${path}:${pk}`;
|
|
51
|
+
}
|
|
52
|
+
/** How a refusal names a schema: its description, or what it holds. */
|
|
53
|
+
function schemaLabel(schema, paths) {
|
|
54
|
+
const description = schema.description;
|
|
55
|
+
if (description) return `The schema "${description}"`;
|
|
56
|
+
return paths.length > 0 ? `The schema holding ${paths.join(", ")}` : "This schema";
|
|
57
|
+
}
|
|
58
|
+
/** The value at a path, or `undefined` where any step is absent. */
|
|
59
|
+
function readPath(value, segments) {
|
|
60
|
+
let at = value;
|
|
61
|
+
for (const segment of segments) {
|
|
62
|
+
if (typeof at !== "object" || at === null) return void 0;
|
|
63
|
+
at = at[segment];
|
|
64
|
+
}
|
|
65
|
+
return at;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A copy of `value` with `next` at `segments`, copying each object on the way so the caller's value is never
|
|
69
|
+
* written to. A step that is absent stays absent: there is nothing there to seal.
|
|
70
|
+
*/
|
|
71
|
+
function writePath(value, segments, next) {
|
|
72
|
+
const [head, ...rest] = segments;
|
|
73
|
+
if (head === void 0) return next;
|
|
74
|
+
if (typeof value !== "object" || value === null) return value;
|
|
75
|
+
const copy = { ...value };
|
|
76
|
+
copy[head] = rest.length === 0 ? next : writePath(copy[head], rest, next);
|
|
77
|
+
return copy;
|
|
78
|
+
}
|
|
79
|
+
/** The plaintext a value seals to: its schema's encoded form, as JSON. Refused without echoing the value. */
|
|
80
|
+
function plaintextOf(field, value) {
|
|
81
|
+
const encoded = field.spec.inner.safeEncode(value);
|
|
82
|
+
if (!encoded.success) throw fromZodError(encoded.error, { message: `The encrypted field "${field.path}" does not hold a value its schema accepts.` });
|
|
83
|
+
const json = JSON.stringify(encoded.data);
|
|
84
|
+
if (typeof json !== "string") throw new ValidationError({ message: `The encrypted field "${field.path}" holds a value JSON cannot carry.` });
|
|
85
|
+
return json;
|
|
86
|
+
}
|
|
87
|
+
/** The opened schema: each encrypted field replaced by the schema it seals, along the paths that reach one. */
|
|
88
|
+
function openedSchemaOf(schema) {
|
|
89
|
+
if (encryptedSpecOf(schema)) {
|
|
90
|
+
const def = schema._zod.def;
|
|
91
|
+
if (def.innerType && (def.type === "optional" || def.type === "nullable" || def.type === "readonly")) return schema.clone({
|
|
92
|
+
...def,
|
|
93
|
+
innerType: openedSchemaOf(def.innerType)
|
|
94
|
+
});
|
|
95
|
+
return encryptedSpecOf(schema).inner;
|
|
96
|
+
}
|
|
97
|
+
if (!hasEncryptedFields(schema)) return schema;
|
|
98
|
+
const def = schema._zod.def;
|
|
99
|
+
if (def.innerType && [
|
|
100
|
+
"optional",
|
|
101
|
+
"nullable",
|
|
102
|
+
"default",
|
|
103
|
+
"prefault",
|
|
104
|
+
"readonly"
|
|
105
|
+
].includes(def.type)) return schema.clone({
|
|
106
|
+
...def,
|
|
107
|
+
innerType: openedSchemaOf(def.innerType)
|
|
108
|
+
});
|
|
109
|
+
if (isSqliteJson(schema) && def.out) {
|
|
110
|
+
const meta = schema.meta();
|
|
111
|
+
const column = sqliteJson(openedSchemaOf(def.out));
|
|
112
|
+
return meta ? column.meta(meta) : column;
|
|
113
|
+
}
|
|
114
|
+
if (def.type === "object") {
|
|
115
|
+
const object = schema;
|
|
116
|
+
const changed = {};
|
|
117
|
+
for (const [key, field] of Object.entries(object.shape)) if (hasEncryptedFields(field)) changed[key] = openedSchemaOf(field);
|
|
118
|
+
return object.extend(changed);
|
|
119
|
+
}
|
|
120
|
+
return schema;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Build the seam for one table, from the request's `env`. Refuses, before any key is read, a schema with an encrypted
|
|
124
|
+
* field and no `encryptedTable()` declaration, and every layout problem. See the module note.
|
|
125
|
+
*/
|
|
126
|
+
function sealed(schema, options) {
|
|
127
|
+
const layout = encryptedLayout(schema);
|
|
128
|
+
const paths = layout.fields.map((field) => field.path);
|
|
129
|
+
if (layout.declaration === null) {
|
|
130
|
+
if (layout.fields.length > 0 || hasEncryptedFields(schema)) throw new ValidationError({
|
|
131
|
+
message: `${schemaLabel(schema, paths)} holds an encrypted field and declares no table.`,
|
|
132
|
+
action: "Wrap it in encryptedTable(schema, { name, key }): the name and key are what every sealed field binds."
|
|
133
|
+
});
|
|
134
|
+
throw new ValidationError({
|
|
135
|
+
message: `${schemaLabel(schema, paths)} holds no encrypted field.`,
|
|
136
|
+
action: "Read and write it with Schema.parse and Schema.encode; sealed() is for a table with an encrypted field."
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (layout.problems.length > 0) throw new ValidationError({
|
|
140
|
+
message: `${schemaLabel(schema, paths)} cannot be sealed as declared. ${layout.problems.join(" ")}`,
|
|
141
|
+
action: "Fix the schema's encrypted fields and their blindIndex columns."
|
|
142
|
+
});
|
|
143
|
+
const { name, key } = layout.declaration;
|
|
144
|
+
let resolved;
|
|
145
|
+
const config = () => {
|
|
146
|
+
resolved ??= resolveFieldKeys(options.keys);
|
|
147
|
+
return resolved;
|
|
148
|
+
};
|
|
149
|
+
let opened;
|
|
150
|
+
const primaryKey = (value, doing) => {
|
|
151
|
+
const pk = readPath(value, [key]);
|
|
152
|
+
if (typeof pk !== "string" || pk.length === 0) throw new ValidationError({
|
|
153
|
+
message: `The ${name} row has no ${key} to bind, so it cannot be ${doing}.`,
|
|
154
|
+
action: `Assign ${key} in the app before the insert: every encrypted field binds it.`
|
|
155
|
+
});
|
|
156
|
+
return pk;
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
schema,
|
|
160
|
+
get opened() {
|
|
161
|
+
opened ??= openedSchemaOf(schema);
|
|
162
|
+
return opened;
|
|
163
|
+
},
|
|
164
|
+
async seal(value) {
|
|
165
|
+
const pk = primaryKey(value, "sealed");
|
|
166
|
+
const keys = await config();
|
|
167
|
+
const current = Number(keys.currentVersion);
|
|
168
|
+
let out = value;
|
|
169
|
+
for (const field of layout.fields) {
|
|
170
|
+
const plain = readPath(value, field.segments);
|
|
171
|
+
if (plain === void 0 || plain === null) {
|
|
172
|
+
if (field.indexField) out = writePath(out, [field.indexField], plain === null ? null : void 0);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const envelope = await encryptValue(keys, fieldContext(name, field.path, pk), plaintextOf(field, plain), { keys: FIELD_KEYS_BINDING });
|
|
176
|
+
out = writePath(out, field.segments, envelope);
|
|
177
|
+
if (field.indexField && field.spec.index) {
|
|
178
|
+
if (typeof plain !== "string") throw new ValidationError({ message: `The encrypted field "${field.path}" has a blind index, so it holds a string.` });
|
|
179
|
+
out = writePath(out, [field.indexField], await blindIndexValue(keys, current, field.spec.index, plain));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const encoded = schema.safeEncode(out);
|
|
183
|
+
if (!encoded.success) throw fromZodError(encoded.error);
|
|
184
|
+
return encoded.data;
|
|
185
|
+
},
|
|
186
|
+
async open(row) {
|
|
187
|
+
const parsed = schema.safeParse(row);
|
|
188
|
+
if (!parsed.success) throw fromZodError(parsed.error);
|
|
189
|
+
const pk = primaryKey(parsed.data, "opened");
|
|
190
|
+
const keys = await config();
|
|
191
|
+
let out = parsed.data;
|
|
192
|
+
for (const field of layout.fields) {
|
|
193
|
+
const envelope = readPath(parsed.data, field.segments);
|
|
194
|
+
if (envelope === void 0 || envelope === null) continue;
|
|
195
|
+
const plaintext = await decryptValue(keys, fieldContext(name, field.path, pk), envelope, { keys: FIELD_KEYS_BINDING });
|
|
196
|
+
let json;
|
|
197
|
+
try {
|
|
198
|
+
json = JSON.parse(plaintext);
|
|
199
|
+
} catch {
|
|
200
|
+
throw new ValidationError({ message: `The encrypted field "${field.path}" opened to something that is not JSON.` });
|
|
201
|
+
}
|
|
202
|
+
const value = field.spec.inner.safeParse(json);
|
|
203
|
+
if (!value.success) throw fromZodError(value.error);
|
|
204
|
+
out = writePath(out, field.segments, value.data);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
},
|
|
208
|
+
async match(field, value) {
|
|
209
|
+
const located = layout.fields.find((one) => one.path === field && one.indexField !== null);
|
|
210
|
+
if (!located?.spec.index) throw new ValidationError({
|
|
211
|
+
message: `"${String(field)}" is not a field of the ${name} table with a blind index.`,
|
|
212
|
+
action: `Declare it encrypted(x, { index: "exact" | "email" }) with a blindIndex("${String(field)}") column.`
|
|
213
|
+
});
|
|
214
|
+
const keys = await config();
|
|
215
|
+
const normalizer = located.spec.index;
|
|
216
|
+
return Promise.all(liveVersions(keys).map((version) => blindIndexValue(keys, version, normalizer, value)));
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
export { FIELD_CONTEXT_PREFIX, fieldContext, sealed };
|