@pithy-sh/core 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access/access.d.ts +172 -0
- package/dist/access/access.d.ts.map +1 -0
- package/dist/access/access.js +147 -0
- package/dist/access/acting.d.ts +49 -0
- package/dist/access/acting.d.ts.map +1 -0
- package/dist/access/acting.js +21 -0
- package/dist/access/policyCheck.d.ts +33 -0
- package/dist/access/policyCheck.d.ts.map +1 -0
- package/dist/access/policyCheck.js +48 -0
- package/dist/access/usage.d.ts +83 -0
- package/dist/access/usage.d.ts.map +1 -0
- package/dist/access/usage.js +104 -0
- package/dist/access/visibility.d.ts +66 -0
- package/dist/access/visibility.d.ts.map +1 -0
- package/dist/access/visibility.js +86 -0
- package/dist/audit/auditEvent.d.ts +5 -0
- package/dist/audit/auditEvent.d.ts.map +1 -1
- package/dist/audit/auditEvent.js +2 -0
- package/dist/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/capability/bindings.d.ts +47 -2
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +117 -7
- package/dist/capability/capability.d.ts +110 -28
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- package/dist/capability/manifest.d.ts +2 -1
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/manifest.js +1 -1
- 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/capability/settings.d.ts +86 -2
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/settings.js +80 -1
- package/dist/cli/commandCatalog.generated.d.ts +238 -24
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +251 -22
- package/dist/cli/commandFacts.d.ts +2 -1
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +2 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +19 -2
- package/dist/controlPlane/context.d.ts +5 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -1
- package/dist/controlPlane/data/connection.d.ts +49 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +56 -2
- 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/cors.d.ts +4 -4
- package/dist/controlPlane/http/cors.js +4 -4
- package/dist/controlPlane/http/guard.d.ts +11 -0
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +17 -1
- package/dist/controlPlane/http/handlers.d.ts +139 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +67 -4
- package/dist/controlPlane/http/readiness.d.ts +33 -0
- package/dist/controlPlane/http/readiness.d.ts.map +1 -0
- package/dist/controlPlane/http/readiness.js +39 -0
- package/dist/controlPlane/http/responses.d.ts +245 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +101 -4
- package/dist/controlPlane/http/routes.d.ts +2 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +31 -3
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +2 -1
- package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
- package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
- package/dist/controlPlane/migrationState/migrationState.js +142 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
- package/dist/controlPlane/scope/scope.d.ts +23 -2
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +24 -3
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +9 -2
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +5 -3
- 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/entitlement/allowance.d.ts +25 -0
- package/dist/entitlement/allowance.d.ts.map +1 -0
- package/dist/entitlement/allowance.js +80 -0
- package/dist/entitlement/entitlement.d.ts +41 -0
- package/dist/entitlement/entitlement.d.ts.map +1 -1
- package/dist/entitlement/meter.d.ts +57 -0
- package/dist/entitlement/meter.d.ts.map +1 -0
- package/dist/entitlement/meter.js +13 -0
- package/dist/error/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +168 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +336 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +116 -4
- 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/http/authContext.d.ts +7 -0
- package/dist/http/authContext.d.ts.map +1 -1
- package/dist/http/authContext.js +8 -1
- package/dist/http/scopeGates.d.ts +20 -0
- package/dist/http/scopeGates.d.ts.map +1 -0
- package/dist/http/scopeGates.js +36 -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 +98 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/migrations/capabilitySets.d.ts +23 -0
- package/dist/migrations/capabilitySets.d.ts.map +1 -0
- package/dist/migrations/capabilitySets.js +74 -0
- package/dist/migrations/registry.d.ts +26 -0
- package/dist/migrations/registry.d.ts.map +1 -1
- package/dist/migrations/registry.js +38 -7
- package/dist/migrations/state.d.ts +60 -0
- package/dist/migrations/state.d.ts.map +1 -0
- package/dist/migrations/state.js +46 -0
- package/dist/naming/feature.d.ts +27 -5
- package/dist/naming/feature.d.ts.map +1 -1
- package/dist/naming/feature.js +36 -6
- package/dist/naming/limits.d.ts +47 -3
- package/dist/naming/limits.d.ts.map +1 -1
- package/dist/naming/limits.js +47 -3
- package/dist/naming/provisionScope.d.ts +6 -0
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +8 -3
- package/dist/naming/resourceNames.d.ts +21 -2
- package/dist/naming/resourceNames.d.ts.map +1 -1
- package/dist/naming/resourceNames.js +21 -3
- 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/seed/writeD1.d.ts +7 -1
- package/dist/seed/writeD1.d.ts.map +1 -1
- package/dist/seed/writeD1.js +9 -3
- package/dist/seed/writeKv.d.ts +2 -0
- package/dist/seed/writeKv.d.ts.map +1 -1
- package/dist/seed/writeKv.js +6 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worker/serviceLoopback.d.ts +36 -0
- package/dist/worker/serviceLoopback.d.ts.map +1 -0
- package/dist/worker/serviceLoopback.js +74 -0
- package/dist/workflow/faults.d.ts +12 -1
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +22 -15
- package/package.json +2 -2
- package/src/access/access.ts +298 -0
- package/src/access/acting.ts +59 -0
- package/src/access/policyCheck.ts +77 -0
- package/src/access/usage.ts +141 -0
- package/src/access/visibility.ts +109 -0
- package/src/audit/auditEvent.ts +4 -0
- package/src/audit/recorder.ts +10 -2
- package/src/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +114 -29
- package/src/capability/purge.ts +124 -0
- package/src/capability/settings.ts +146 -2
- package/src/cli/commandCatalog.generated.ts +271 -22
- package/src/cli/commandFacts.ts +2 -0
- package/src/controlPlane/capability.ts +36 -1
- package/src/controlPlane/context.ts +4 -0
- package/src/controlPlane/data/connection.ts +90 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +21 -0
- package/src/controlPlane/http/handlers.ts +98 -4
- package/src/controlPlane/http/readiness.ts +50 -0
- package/src/controlPlane/http/responses.ts +240 -2
- package/src/controlPlane/http/routes.ts +55 -1
- package/src/controlPlane/http/verify.ts +2 -0
- package/src/controlPlane/migrationState/migrationState.ts +200 -0
- package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
- package/src/controlPlane/scope/scope.ts +25 -2
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +28 -6
- package/src/createEntrypoint.ts +9 -3
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/entitlement/allowance.ts +85 -0
- package/src/entitlement/entitlement.ts +38 -0
- package/src/entitlement/meter.ts +69 -0
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +253 -4
- package/src/error/pithyError.ts +21 -7
- package/src/http/authContext.ts +13 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/index.ts +2 -0
- package/src/logger/logger.ts +12 -5
- package/src/migrations/capabilitySets.ts +87 -0
- package/src/migrations/registry.ts +46 -2
- package/src/migrations/state.ts +94 -0
- package/src/naming/feature.ts +46 -7
- package/src/naming/limits.ts +35 -3
- package/src/naming/provisionScope.ts +19 -1
- package/src/naming/resourceNames.ts +52 -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/seed/writeD1.ts +13 -4
- package/src/seed/writeKv.ts +6 -2
- package/src/version.generated.ts +1 -1
- package/src/worker/serviceLoopback.ts +105 -0
- package/src/workflow/faults.ts +51 -24
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { EncryptedEnvelope } from "../crypto/envelope";
|
|
5
|
+
import { type SqliteJson } from "../data/codecs";
|
|
6
|
+
/**
|
|
7
|
+
* **A field says how it rests, beside what it is** (#390).
|
|
8
|
+
*
|
|
9
|
+
* #132 gave a field a privacy class: a claim about what the field **is**, which changes no stored column. This is the
|
|
10
|
+
* other half of the sentence, the claim about how it is **stored**. `phi()` says a diagnosis is health data;
|
|
11
|
+
* `encrypted()` says it rests in D1 as an AES-256-GCM envelope rather than as plaintext. Either may be marked alone,
|
|
12
|
+
* and the two compose: the inventory reports both, so *which of your PHI fields are encrypted at rest?* is a
|
|
13
|
+
* derivation, not a second list.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* export const Patient = encryptedTable(
|
|
17
|
+
* z.object({
|
|
18
|
+
* id: z.string().describe("The patient's stable id, assigned by the app before the insert."),
|
|
19
|
+
* diagnosis: encrypted(phi(z.string().describe("The recorded condition."))),
|
|
20
|
+
* email: encrypted(pii(z.string().describe("Where we write.")), { index: "email" }),
|
|
21
|
+
* emailIndex: blindIndex("email"),
|
|
22
|
+
* }),
|
|
23
|
+
* { name: "patient", key: "id" },
|
|
24
|
+
* );
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* ## The codec is synchronous, and the cryptography is one step above it
|
|
28
|
+
*
|
|
29
|
+
* An encrypted field's schema is `sqliteJson(EncryptedEnvelope)`: its stored side is the envelope as a JSON string,
|
|
30
|
+
* its parsed side the envelope object. `Patient.parse(row)` therefore hands back envelopes, and `Patient.encode` takes
|
|
31
|
+
* them, so CLAUDE.md §Data layer's round-trip rule stands unchanged. AES-GCM is asynchronous and needs a key, which a
|
|
32
|
+
* module-level schema must never hold, so it happens in the repository seam `sealed()` builds per request
|
|
33
|
+
* (`./sealed`). **Forgetting the seam cannot write plaintext**: a plain `Patient.encode` handed a string where the
|
|
34
|
+
* codec expects an envelope fails validation.
|
|
35
|
+
*
|
|
36
|
+
* ## It keeps the class it was handed
|
|
37
|
+
*
|
|
38
|
+
* The marker copies every class marked inside the schema it wraps onto itself, in its `meta` and in its output type's
|
|
39
|
+
* brand. So an encrypted `phi` field is still listed as `phi`, still masked by `redact`, and still refused by the log
|
|
40
|
+
* gate — as an envelope after a parse, and as the value itself once `sealed().open` has decrypted it, because the
|
|
41
|
+
* opened value is the wrapped schema's own output, brand and all.
|
|
42
|
+
*
|
|
43
|
+
* ## Presence is outside it
|
|
44
|
+
*
|
|
45
|
+
* `encrypted(x).optional()` and `encrypted(x).nullable()` are the optional field. A null stays SQL `NULL` rather than
|
|
46
|
+
* being sealed, so `IS NULL` still works, and that is a leak the docs name: whether a field holds anything is visible.
|
|
47
|
+
* The schema handed to the marker is the value that is sealed, so a wrapper that admits `null` or `undefined` there is
|
|
48
|
+
* refused, rather than sealing the word `null`.
|
|
49
|
+
*/
|
|
50
|
+
/** The `meta` key an encrypted field carries: `{ index }`, the normalizer its blind index uses, or `null`. */
|
|
51
|
+
export declare const ENCRYPTED_META_KEY = "encrypted";
|
|
52
|
+
/** The `meta` key a blind-index column carries: `{ field }`, the encrypted field it indexes. */
|
|
53
|
+
export declare const BLIND_INDEX_META_KEY = "blindIndex";
|
|
54
|
+
/** The `meta` key `encryptedTable()` writes on the table's schema: `{ name, key }`. */
|
|
55
|
+
export declare const ENCRYPTED_TABLE_META_KEY = "encryptedTable";
|
|
56
|
+
/**
|
|
57
|
+
* **The normalizers a blind index may use, by name, and only these.** A name rather than a function, because the
|
|
58
|
+
* rotator re-indexes inside the manager Worker, which runs no adopter code: it has to be able to compute the same
|
|
59
|
+
* hash from the name alone. A normalizer the kit adds later is one more name here.
|
|
60
|
+
*/
|
|
61
|
+
export declare const BLIND_INDEX_NORMALIZERS: readonly ["exact", "email"];
|
|
62
|
+
/** One of the kit's blind-index normalizers. */
|
|
63
|
+
export type BlindIndexNormalizer = (typeof BLIND_INDEX_NORMALIZERS)[number];
|
|
64
|
+
/** The normalizer names as a schema, for a stamp or a response that carries one. */
|
|
65
|
+
export declare const BlindIndexNormalizerName: z.ZodEnum<{
|
|
66
|
+
email: "email";
|
|
67
|
+
exact: "exact";
|
|
68
|
+
}>;
|
|
69
|
+
/** Whether a string names one of the kit's normalizers. */
|
|
70
|
+
export declare function isBlindIndexNormalizer(name: unknown): name is BlindIndexNormalizer;
|
|
71
|
+
/**
|
|
72
|
+
* Normalize a value the way its blind index does. `exact` is the value as written; `email` is Unicode NFC, trimmed,
|
|
73
|
+
* lowercased — so `Ada@Example.com ` and `ada@example.com` find one row.
|
|
74
|
+
*/
|
|
75
|
+
export declare function normalizeForIndex(normalizer: BlindIndexNormalizer, value: string): string;
|
|
76
|
+
/** Options for {@link encrypted}. */
|
|
77
|
+
export interface EncryptedOptions<N extends BlindIndexNormalizer | undefined = undefined> {
|
|
78
|
+
/**
|
|
79
|
+
* Give the field a blind index, normalized by this name: `exact` or `email`. The table then declares a sibling
|
|
80
|
+
* `blindIndex("<field>")` column, and `sealed().match` finds a row by it.
|
|
81
|
+
*/
|
|
82
|
+
index?: N;
|
|
83
|
+
}
|
|
84
|
+
/** The type-level carrier of the schema an encrypted field seals. A type only: no value carries it. */
|
|
85
|
+
declare const encryptedInner: unique symbol;
|
|
86
|
+
/** The type-level carrier of an encrypted field's blind-index normalizer. A type only. */
|
|
87
|
+
declare const encryptedIndex: unique symbol;
|
|
88
|
+
/** Depth countdown for {@link PrivacyBrandsWithin}. */
|
|
89
|
+
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
|
|
90
|
+
/** Values whose structure is not data, so the brand walk does not enter them. */
|
|
91
|
+
type NotData = Date | RegExp | ((...args: never[]) => unknown) | PromiseLike<unknown>;
|
|
92
|
+
/** The privacy brand keys on `T` itself. Distributes. */
|
|
93
|
+
type OwnPrivacyBrands<T> = T extends {
|
|
94
|
+
readonly [z.core.$brand]: infer M;
|
|
95
|
+
} ? Extract<keyof M, `privacy:${string}`> : never;
|
|
96
|
+
/**
|
|
97
|
+
* Every privacy brand key anywhere in `T`, to six levels of object or array: the classes an encrypted field carries
|
|
98
|
+
* onto its envelope, so the envelope is branded exactly as its contents were.
|
|
99
|
+
*/
|
|
100
|
+
export type PrivacyBrandsWithin<T, D extends number = 6> = [D] extends [never] ? never : T extends NotData ? never : OwnPrivacyBrands<T> | (T extends readonly (infer E)[] ? PrivacyBrandsWithin<E, Prev[D]> : T extends object ? {
|
|
101
|
+
[K in keyof T]-?: PrivacyBrandsWithin<T[K], Prev[D]>;
|
|
102
|
+
}[keyof T] : never);
|
|
103
|
+
/** The envelope schema an encrypted field parses to, carrying the brands of what it seals. */
|
|
104
|
+
export type BrandedEnvelope<I extends z.ZodType> = z.core.$ZodBranded<typeof EncryptedEnvelope, PrivacyBrandsWithin<z.output<I>>, "out">;
|
|
105
|
+
/**
|
|
106
|
+
* What `encrypted(schema)` returns: the kit's JSON column over the envelope, carrying the schema it seals and the
|
|
107
|
+
* normalizer of its blind index, if any, at the type level.
|
|
108
|
+
*/
|
|
109
|
+
export type Encrypted<I extends z.ZodType, N extends BlindIndexNormalizer | undefined = undefined> = SqliteJson<BrandedEnvelope<I>> & {
|
|
110
|
+
readonly [encryptedInner]: I;
|
|
111
|
+
readonly [encryptedIndex]: N;
|
|
112
|
+
};
|
|
113
|
+
/** The schema an encrypted field seals, read off its type. `never` for any other schema. */
|
|
114
|
+
export type EncryptedInner<S> = S extends {
|
|
115
|
+
readonly [encryptedInner]: infer I;
|
|
116
|
+
} ? I : never;
|
|
117
|
+
/** The blind-index normalizer an encrypted field declared, read off its type. `never` without one. */
|
|
118
|
+
export type EncryptedIndexOf<S> = S extends {
|
|
119
|
+
readonly [encryptedIndex]: infer N extends BlindIndexNormalizer;
|
|
120
|
+
} ? N : never;
|
|
121
|
+
/** What the runtime knows about one encrypted field: the schema it seals and its index's normalizer. */
|
|
122
|
+
export interface EncryptedSpec {
|
|
123
|
+
/** The schema the plaintext is validated against, before a seal and after an open. */
|
|
124
|
+
readonly inner: z.ZodType;
|
|
125
|
+
/** The blind-index normalizer, or `null` for a field with no index. */
|
|
126
|
+
readonly index: BlindIndexNormalizer | null;
|
|
127
|
+
}
|
|
128
|
+
/** A schema with its presence wrappers peeled off, and whether any of them admits `null` or `undefined`. */
|
|
129
|
+
export declare function unwrapPresence(schema: z.ZodType): {
|
|
130
|
+
node: z.ZodType;
|
|
131
|
+
absent: boolean;
|
|
132
|
+
};
|
|
133
|
+
/** The spec of an encrypted field, read through its presence wrappers, or `undefined` for any other schema. */
|
|
134
|
+
export declare function encryptedSpecOf(schema: z.ZodType): EncryptedSpec | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* Mark a field encrypted at rest. It rests in its own column as an AES-256-GCM envelope sealed by `sealed()`, and
|
|
137
|
+
* `{ index }` gives it a blind index for equality lookups. See the module note for the whole contract.
|
|
138
|
+
*
|
|
139
|
+
* Refused at module load, where the mistake is cheapest: a schema that admits `null` or `undefined` (put presence
|
|
140
|
+
* outside: `encrypted(x).optional()`), a schema that is already encrypted, and a normalizer that is not the kit's.
|
|
141
|
+
*/
|
|
142
|
+
export declare function encrypted<I extends z.ZodType, const N extends BlindIndexNormalizer | undefined = undefined>(schema: I, options?: EncryptedOptions<N>): Encrypted<I, N>;
|
|
143
|
+
/** The value a blind-index column holds: `<version>:<hmac>`, the HMAC-SHA256 as unpadded base64url. */
|
|
144
|
+
export declare const BLIND_INDEX_VALUE: RegExp;
|
|
145
|
+
/**
|
|
146
|
+
* A blind-index column for the encrypted field `field`: a keyed hash of its normalized value, stored as
|
|
147
|
+
* `<version>:<hmac>`, so `WHERE emailIndex IN (…)` finds a row without decrypting anything. `sealed()` writes it on
|
|
148
|
+
* every seal and `sealed().match` computes what to look for. Add `.nullable()` when the field it indexes is optional:
|
|
149
|
+
* an absent value has no index.
|
|
150
|
+
*
|
|
151
|
+
* **It leaks equality, and that is the trade.** Two rows with the same normalized value have the same index. Ordering,
|
|
152
|
+
* ranges, prefixes and `LIKE` do not come back at all.
|
|
153
|
+
*/
|
|
154
|
+
export declare function blindIndex(field: string): z.ZodString;
|
|
155
|
+
/** The encrypted field a blind-index column indexes, read through its presence wrappers, or `undefined`. */
|
|
156
|
+
export declare function blindIndexOf(schema: z.ZodType): string | undefined;
|
|
157
|
+
/** What `encryptedTable()` records: the table's stable name and its primary-key column. */
|
|
158
|
+
export interface EncryptedTableDeclaration {
|
|
159
|
+
/** The name the AAD binds: stable, never the SQL table name, so a rename does not orphan a row. */
|
|
160
|
+
readonly name: string;
|
|
161
|
+
/** The primary-key field, app-assigned and never updated. */
|
|
162
|
+
readonly key: string;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Declare a table's identity on its schema: the stable `name` its AAD binds, and the primary-key field `key` whose
|
|
166
|
+
* value each row binds. `sealed()` and the deploy stamp both read it from here, so what the app seals with and what
|
|
167
|
+
* the rotator re-seals with cannot disagree.
|
|
168
|
+
*
|
|
169
|
+
* **The key is app-assigned, before the insert**, because a seal binds its value. So it is a text id — the house rule
|
|
170
|
+
* for a security-sensitive entity anyway — and an integer key, which SQLite assigns at the insert, is refused.
|
|
171
|
+
*/
|
|
172
|
+
export declare function encryptedTable<S extends z.ZodType>(schema: S, declaration: EncryptedTableDeclaration): S;
|
|
173
|
+
/** What `encryptedTable()` recorded on a schema, or `null` where nothing was declared. */
|
|
174
|
+
export declare function encryptedTableOf(schema: z.ZodType): EncryptedTableDeclaration | null;
|
|
175
|
+
/** The SQL name a camelCase identifier is written under, as the kit's `createDatabase` writes it. */
|
|
176
|
+
export declare function sqlIdentifier(identifier: string): string;
|
|
177
|
+
/** One encrypted field of a table, located. */
|
|
178
|
+
export interface EncryptedFieldLayout {
|
|
179
|
+
/** The field path the AAD binds: `diagnosis`, or `profile.phone` for a field inside a JSON column. */
|
|
180
|
+
readonly path: string;
|
|
181
|
+
/** The path's segments: the column's field name first, then each key inside its JSON. */
|
|
182
|
+
readonly segments: readonly string[];
|
|
183
|
+
/** The field's spec. */
|
|
184
|
+
readonly spec: EncryptedSpec;
|
|
185
|
+
/** The blind-index column's field name, when the field is indexed. */
|
|
186
|
+
readonly indexField: string | null;
|
|
187
|
+
}
|
|
188
|
+
/** What {@link encryptedLayout} found in one schema. */
|
|
189
|
+
export interface EncryptedLayout {
|
|
190
|
+
/** The `encryptedTable()` declaration, or `null`. */
|
|
191
|
+
readonly declaration: EncryptedTableDeclaration | null;
|
|
192
|
+
/** Every encrypted field reached by a path of object keys. */
|
|
193
|
+
readonly fields: readonly EncryptedFieldLayout[];
|
|
194
|
+
/** Every reason the table cannot be sealed or rotated as declared, in one sentence each. Empty when it can. */
|
|
195
|
+
readonly problems: readonly string[];
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Locate every encrypted field in a table schema, and say what keeps it from being sealed or rotated.
|
|
199
|
+
*
|
|
200
|
+
* A field is reached by a path of object keys: a column, or a key inside a `sqliteJson` object column, at any depth of
|
|
201
|
+
* objects. That path is what the AAD binds and what the rotator's `json_extract` names. An encrypted field anywhere
|
|
202
|
+
* else — inside an array, a record, a union, a transform — has no such path, so it is a problem rather than a field.
|
|
203
|
+
* So is a blind index on a field inside a JSON column (an index is a sibling column), an index column naming no
|
|
204
|
+
* indexed field, an indexed field with no index column, and a key no JSON path can name.
|
|
205
|
+
*/
|
|
206
|
+
export declare function encryptedLayout(schema: z.ZodType): EncryptedLayout;
|
|
207
|
+
/** Whether a schema holds an encrypted field anywhere: what makes it need `encryptedTable()` and `sealed()`. */
|
|
208
|
+
export declare function hasEncryptedFields(schema: z.ZodType): boolean;
|
|
209
|
+
export {};
|
|
210
|
+
//# sourceMappingURL=encrypted.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encrypted.d.ts","sourceRoot":"","sources":["../../src/privacy/encrypted.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { ValidationError } from "../error/pithyError.js";
|
|
4
|
+
import { EncryptedEnvelope } from "../crypto/envelope.js";
|
|
5
|
+
import { isSqliteJson, sqliteJson } from "../data/codecs.js";
|
|
6
|
+
import { PRIVACY_META_KEY, rankPrivacyClasses } from "./classes.js";
|
|
7
|
+
import { classifyPrivacy } from "./classify.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { CamelCasePlugin } from "kysely";
|
|
10
|
+
//#region src/privacy/encrypted.ts
|
|
11
|
+
/**
|
|
12
|
+
* **A field says how it rests, beside what it is** (#390).
|
|
13
|
+
*
|
|
14
|
+
* #132 gave a field a privacy class: a claim about what the field **is**, which changes no stored column. This is the
|
|
15
|
+
* other half of the sentence, the claim about how it is **stored**. `phi()` says a diagnosis is health data;
|
|
16
|
+
* `encrypted()` says it rests in D1 as an AES-256-GCM envelope rather than as plaintext. Either may be marked alone,
|
|
17
|
+
* and the two compose: the inventory reports both, so *which of your PHI fields are encrypted at rest?* is a
|
|
18
|
+
* derivation, not a second list.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* export const Patient = encryptedTable(
|
|
22
|
+
* z.object({
|
|
23
|
+
* id: z.string().describe("The patient's stable id, assigned by the app before the insert."),
|
|
24
|
+
* diagnosis: encrypted(phi(z.string().describe("The recorded condition."))),
|
|
25
|
+
* email: encrypted(pii(z.string().describe("Where we write.")), { index: "email" }),
|
|
26
|
+
* emailIndex: blindIndex("email"),
|
|
27
|
+
* }),
|
|
28
|
+
* { name: "patient", key: "id" },
|
|
29
|
+
* );
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* ## The codec is synchronous, and the cryptography is one step above it
|
|
33
|
+
*
|
|
34
|
+
* An encrypted field's schema is `sqliteJson(EncryptedEnvelope)`: its stored side is the envelope as a JSON string,
|
|
35
|
+
* its parsed side the envelope object. `Patient.parse(row)` therefore hands back envelopes, and `Patient.encode` takes
|
|
36
|
+
* them, so CLAUDE.md §Data layer's round-trip rule stands unchanged. AES-GCM is asynchronous and needs a key, which a
|
|
37
|
+
* module-level schema must never hold, so it happens in the repository seam `sealed()` builds per request
|
|
38
|
+
* (`./sealed`). **Forgetting the seam cannot write plaintext**: a plain `Patient.encode` handed a string where the
|
|
39
|
+
* codec expects an envelope fails validation.
|
|
40
|
+
*
|
|
41
|
+
* ## It keeps the class it was handed
|
|
42
|
+
*
|
|
43
|
+
* The marker copies every class marked inside the schema it wraps onto itself, in its `meta` and in its output type's
|
|
44
|
+
* brand. So an encrypted `phi` field is still listed as `phi`, still masked by `redact`, and still refused by the log
|
|
45
|
+
* gate — as an envelope after a parse, and as the value itself once `sealed().open` has decrypted it, because the
|
|
46
|
+
* opened value is the wrapped schema's own output, brand and all.
|
|
47
|
+
*
|
|
48
|
+
* ## Presence is outside it
|
|
49
|
+
*
|
|
50
|
+
* `encrypted(x).optional()` and `encrypted(x).nullable()` are the optional field. A null stays SQL `NULL` rather than
|
|
51
|
+
* being sealed, so `IS NULL` still works, and that is a leak the docs name: whether a field holds anything is visible.
|
|
52
|
+
* The schema handed to the marker is the value that is sealed, so a wrapper that admits `null` or `undefined` there is
|
|
53
|
+
* refused, rather than sealing the word `null`.
|
|
54
|
+
*/
|
|
55
|
+
/** The `meta` key an encrypted field carries: `{ index }`, the normalizer its blind index uses, or `null`. */
|
|
56
|
+
const ENCRYPTED_META_KEY = "encrypted";
|
|
57
|
+
/** The `meta` key a blind-index column carries: `{ field }`, the encrypted field it indexes. */
|
|
58
|
+
const BLIND_INDEX_META_KEY = "blindIndex";
|
|
59
|
+
/** The `meta` key `encryptedTable()` writes on the table's schema: `{ name, key }`. */
|
|
60
|
+
const ENCRYPTED_TABLE_META_KEY = "encryptedTable";
|
|
61
|
+
/**
|
|
62
|
+
* **The normalizers a blind index may use, by name, and only these.** A name rather than a function, because the
|
|
63
|
+
* rotator re-indexes inside the manager Worker, which runs no adopter code: it has to be able to compute the same
|
|
64
|
+
* hash from the name alone. A normalizer the kit adds later is one more name here.
|
|
65
|
+
*/
|
|
66
|
+
const BLIND_INDEX_NORMALIZERS = ["exact", "email"];
|
|
67
|
+
/** The normalizer names as a schema, for a stamp or a response that carries one. */
|
|
68
|
+
const BlindIndexNormalizerName = z.enum(BLIND_INDEX_NORMALIZERS).describe("How a blind index normalizes a value before hashing it: `exact` hashes it as written, `email` applies NFC, trims and lowercases.");
|
|
69
|
+
/** Whether a string names one of the kit's normalizers. */
|
|
70
|
+
function isBlindIndexNormalizer(name) {
|
|
71
|
+
return typeof name === "string" && BLIND_INDEX_NORMALIZERS.includes(name);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Normalize a value the way its blind index does. `exact` is the value as written; `email` is Unicode NFC, trimmed,
|
|
75
|
+
* lowercased — so `Ada@Example.com ` and `ada@example.com` find one row.
|
|
76
|
+
*/
|
|
77
|
+
function normalizeForIndex(normalizer, value) {
|
|
78
|
+
switch (normalizer) {
|
|
79
|
+
case "exact": return value;
|
|
80
|
+
case "email": return value.normalize("NFC").trim().toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every encrypted field's spec, by the definition its schema object shares with each clone. `.describe()` and
|
|
85
|
+
* `.meta()` clone a schema onto the same definition, and a privacy marker over the field is a `.meta()`, so a
|
|
86
|
+
* described, marked or re-described encrypted field is found here exactly as the original is.
|
|
87
|
+
*/
|
|
88
|
+
const specs = /* @__PURE__ */ new WeakMap();
|
|
89
|
+
/** A schema's definition object, the key {@link specs} is held under. */
|
|
90
|
+
function defOf(schema) {
|
|
91
|
+
return schema._zod.def;
|
|
92
|
+
}
|
|
93
|
+
/** The wrappers that are the same field as what they wrap: presence, a default, and `readonly`. */
|
|
94
|
+
const PRESENCE = /* @__PURE__ */ new Set([
|
|
95
|
+
"optional",
|
|
96
|
+
"nullable",
|
|
97
|
+
"default",
|
|
98
|
+
"prefault",
|
|
99
|
+
"readonly"
|
|
100
|
+
]);
|
|
101
|
+
/** A schema with its presence wrappers peeled off, and whether any of them admits `null` or `undefined`. */
|
|
102
|
+
function unwrapPresence(schema) {
|
|
103
|
+
let node = schema;
|
|
104
|
+
let absent = false;
|
|
105
|
+
for (let def = defOf(node); PRESENCE.has(def.type) && def.innerType; def = defOf(node)) {
|
|
106
|
+
if (def.type === "optional" || def.type === "nullable") absent = true;
|
|
107
|
+
node = def.innerType;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
node,
|
|
111
|
+
absent
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** The spec of an encrypted field, read through its presence wrappers, or `undefined` for any other schema. */
|
|
115
|
+
function encryptedSpecOf(schema) {
|
|
116
|
+
return specs.get(unwrapPresence(schema).node._zod.def);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Mark a field encrypted at rest. It rests in its own column as an AES-256-GCM envelope sealed by `sealed()`, and
|
|
120
|
+
* `{ index }` gives it a blind index for equality lookups. See the module note for the whole contract.
|
|
121
|
+
*
|
|
122
|
+
* Refused at module load, where the mistake is cheapest: a schema that admits `null` or `undefined` (put presence
|
|
123
|
+
* outside: `encrypted(x).optional()`), a schema that is already encrypted, and a normalizer that is not the kit's.
|
|
124
|
+
*/
|
|
125
|
+
function encrypted(schema, options = {}) {
|
|
126
|
+
const def = defOf(schema);
|
|
127
|
+
if (def.type === "optional" || def.type === "nullable" || def.type === "default" || def.type === "prefault") throw new ValidationError({
|
|
128
|
+
message: "An encrypted field seals a value, and this schema admits none.",
|
|
129
|
+
action: "Put presence outside the marker: encrypted(x).optional() or encrypted(x).nullable(), never encrypted(x.optional())."
|
|
130
|
+
});
|
|
131
|
+
if (encryptedSpecOf(schema)) throw new ValidationError({
|
|
132
|
+
message: "This field is already encrypted.",
|
|
133
|
+
action: "Mark it encrypted() once."
|
|
134
|
+
});
|
|
135
|
+
const index = options.index ?? null;
|
|
136
|
+
if (index !== null && !isBlindIndexNormalizer(index)) throw new ValidationError({
|
|
137
|
+
message: `"${String(index)}" is not one of the kit's blind-index normalizers.`,
|
|
138
|
+
action: `Name one of ${BLIND_INDEX_NORMALIZERS.join(", ")}. A normalizer is a name, so the rotator can re-index without running adopter code.`
|
|
139
|
+
});
|
|
140
|
+
const classes = rankPrivacyClasses(classifyPrivacy(schema).map((field) => field.class));
|
|
141
|
+
const { [PRIVACY_META_KEY]: _ownClasses, [ENCRYPTED_META_KEY]: _ownMarker, ...rest } = schema.meta() ?? {};
|
|
142
|
+
const meta = {
|
|
143
|
+
...rest,
|
|
144
|
+
[ENCRYPTED_META_KEY]: { index }
|
|
145
|
+
};
|
|
146
|
+
if (classes.length === 1) meta[PRIVACY_META_KEY] = classes[0];
|
|
147
|
+
else if (classes.length > 1) meta[PRIVACY_META_KEY] = classes;
|
|
148
|
+
const field = sqliteJson(EncryptedEnvelope).meta(meta);
|
|
149
|
+
specs.set(field._zod.def, {
|
|
150
|
+
inner: schema,
|
|
151
|
+
index
|
|
152
|
+
});
|
|
153
|
+
return field;
|
|
154
|
+
}
|
|
155
|
+
/** The value a blind-index column holds: `<version>:<hmac>`, the HMAC-SHA256 as unpadded base64url. */
|
|
156
|
+
const BLIND_INDEX_VALUE = /^\d+:[A-Za-z0-9_-]{43}$/;
|
|
157
|
+
/**
|
|
158
|
+
* A blind-index column for the encrypted field `field`: a keyed hash of its normalized value, stored as
|
|
159
|
+
* `<version>:<hmac>`, so `WHERE emailIndex IN (…)` finds a row without decrypting anything. `sealed()` writes it on
|
|
160
|
+
* every seal and `sealed().match` computes what to look for. Add `.nullable()` when the field it indexes is optional:
|
|
161
|
+
* an absent value has no index.
|
|
162
|
+
*
|
|
163
|
+
* **It leaks equality, and that is the trade.** Two rows with the same normalized value have the same index. Ordering,
|
|
164
|
+
* ranges, prefixes and `LIKE` do not come back at all.
|
|
165
|
+
*/
|
|
166
|
+
function blindIndex(field) {
|
|
167
|
+
return z.string().regex(BLIND_INDEX_VALUE, "A blind index is `<version>:<hmac>`.").meta({
|
|
168
|
+
description: `The blind index of \`${field}\`: a keyed hash of its normalized value, for equality lookups.`,
|
|
169
|
+
[BLIND_INDEX_META_KEY]: { field }
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** The encrypted field a blind-index column indexes, read through its presence wrappers, or `undefined`. */
|
|
173
|
+
function blindIndexOf(schema) {
|
|
174
|
+
for (let node = schema; node;) {
|
|
175
|
+
const value = node.meta()?.[BLIND_INDEX_META_KEY];
|
|
176
|
+
if (typeof value === "object" && value !== null && typeof value.field === "string") return value.field;
|
|
177
|
+
const def = defOf(node);
|
|
178
|
+
node = PRESENCE.has(def.type) ? def.innerType : void 0;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** A declared name: a lowercase identifier with dashes or underscores, and never a `:`, which separates the AAD. */
|
|
182
|
+
const DECLARED_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
183
|
+
/** The shape of an object schema, or `undefined` for any other schema. */
|
|
184
|
+
function shapeOf(schema) {
|
|
185
|
+
const def = defOf(schema);
|
|
186
|
+
return def.type === "object" ? def.shape ?? {} : void 0;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Declare a table's identity on its schema: the stable `name` its AAD binds, and the primary-key field `key` whose
|
|
190
|
+
* value each row binds. `sealed()` and the deploy stamp both read it from here, so what the app seals with and what
|
|
191
|
+
* the rotator re-seals with cannot disagree.
|
|
192
|
+
*
|
|
193
|
+
* **The key is app-assigned, before the insert**, because a seal binds its value. So it is a text id — the house rule
|
|
194
|
+
* for a security-sensitive entity anyway — and an integer key, which SQLite assigns at the insert, is refused.
|
|
195
|
+
*/
|
|
196
|
+
function encryptedTable(schema, declaration) {
|
|
197
|
+
const shape = shapeOf(schema);
|
|
198
|
+
if (shape === void 0) throw new ValidationError({
|
|
199
|
+
message: "encryptedTable() declares a table, and this schema is not an object.",
|
|
200
|
+
action: "Pass the table's z.object(...) schema."
|
|
201
|
+
});
|
|
202
|
+
if (!DECLARED_NAME.test(declaration.name)) throw new ValidationError({
|
|
203
|
+
message: `"${declaration.name}" is not a table name an encrypted field can bind.`,
|
|
204
|
+
action: "Name it with a lowercase identifier — letters, digits, dashes and underscores, never a colon. It is bound into every ciphertext, so choose one you will keep."
|
|
205
|
+
});
|
|
206
|
+
const keyField = shape[declaration.key];
|
|
207
|
+
if (keyField === void 0) throw new ValidationError({
|
|
208
|
+
message: `The ${declaration.name} table declares "${declaration.key}" as its key, and has no such field.`,
|
|
209
|
+
action: "Name the primary-key field of this schema."
|
|
210
|
+
});
|
|
211
|
+
if (encryptedSpecOf(keyField)) throw new ValidationError({
|
|
212
|
+
message: `The ${declaration.name} table's key, "${declaration.key}", is encrypted.`,
|
|
213
|
+
action: "Leave the primary key in the clear: every sealed field binds its value."
|
|
214
|
+
});
|
|
215
|
+
const { node, absent } = unwrapPresence(keyField);
|
|
216
|
+
const type = defOf(node).type;
|
|
217
|
+
if (type === "number" || type === "int" || type === "bigint") throw new ValidationError({
|
|
218
|
+
message: `The ${declaration.name} table's key, "${declaration.key}", is an integer, which SQLite assigns at the insert.`,
|
|
219
|
+
action: "Use a text id the app assigns before the insert, such as a UUID: every sealed field binds the key's value, so it must exist before the seal."
|
|
220
|
+
});
|
|
221
|
+
if (type !== "string" || absent) throw new ValidationError({
|
|
222
|
+
message: `The ${declaration.name} table's key, "${declaration.key}", is not a required text id.`,
|
|
223
|
+
action: "Use a required z.string() id the app assigns before the insert, such as a UUID."
|
|
224
|
+
});
|
|
225
|
+
return schema.meta({
|
|
226
|
+
...schema.meta() ?? {},
|
|
227
|
+
[ENCRYPTED_TABLE_META_KEY]: {
|
|
228
|
+
name: declaration.name,
|
|
229
|
+
key: declaration.key
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/** What `encryptedTable()` recorded on a schema, or `null` where nothing was declared. */
|
|
234
|
+
function encryptedTableOf(schema) {
|
|
235
|
+
const value = schema.meta()?.[ENCRYPTED_TABLE_META_KEY];
|
|
236
|
+
if (typeof value !== "object" || value === null) return null;
|
|
237
|
+
const { name, key } = value;
|
|
238
|
+
return typeof name === "string" && typeof key === "string" ? {
|
|
239
|
+
name,
|
|
240
|
+
key
|
|
241
|
+
} : null;
|
|
242
|
+
}
|
|
243
|
+
/** `camelCase` → `snake_case`, exactly as `CamelCasePlugin` converts an identifier: it is the plugin's own rule. */
|
|
244
|
+
var Conversions = class extends CamelCasePlugin {
|
|
245
|
+
toSnake(identifier) {
|
|
246
|
+
return this.snakeCase(identifier);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const conversions = new Conversions();
|
|
250
|
+
/** The SQL name a camelCase identifier is written under, as the kit's `createDatabase` writes it. */
|
|
251
|
+
function sqlIdentifier(identifier) {
|
|
252
|
+
return conversions.toSnake(identifier);
|
|
253
|
+
}
|
|
254
|
+
/** A key the AAD can bind and a JSON path can name: an identifier, never a `:` or a `.`. */
|
|
255
|
+
const FIELD_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
256
|
+
/** Whether a definition slot holds a schema. */
|
|
257
|
+
function isSchema(value) {
|
|
258
|
+
return typeof value === "object" && value !== null && "_zod" in value;
|
|
259
|
+
}
|
|
260
|
+
/** Every schema a node holds, without running a getter: where a search for a misplaced field reads through. */
|
|
261
|
+
function childrenOf(schema) {
|
|
262
|
+
const def = defOf(schema);
|
|
263
|
+
const found = [];
|
|
264
|
+
const shape = shapeOf(schema);
|
|
265
|
+
if (shape) found.push(...Object.values(shape));
|
|
266
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(def))) {
|
|
267
|
+
if (key === "checks" || key === "getter" || key === "shape" || !("value" in descriptor)) continue;
|
|
268
|
+
const slot = descriptor.value;
|
|
269
|
+
if (isSchema(slot)) found.push(slot);
|
|
270
|
+
else if (Array.isArray(slot)) {
|
|
271
|
+
for (const each of slot) if (isSchema(each)) found.push(each);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return found;
|
|
275
|
+
}
|
|
276
|
+
/** Whether an encrypted field sits anywhere beneath a schema, on any side of any node. */
|
|
277
|
+
function holdsEncrypted(schema, seen = /* @__PURE__ */ new Set()) {
|
|
278
|
+
if (seen.has(schema)) return false;
|
|
279
|
+
seen.add(schema);
|
|
280
|
+
if (encryptedSpecOf(schema)) return true;
|
|
281
|
+
return childrenOf(schema).some((child) => holdsEncrypted(child, seen));
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Locate every encrypted field in a table schema, and say what keeps it from being sealed or rotated.
|
|
285
|
+
*
|
|
286
|
+
* A field is reached by a path of object keys: a column, or a key inside a `sqliteJson` object column, at any depth of
|
|
287
|
+
* objects. That path is what the AAD binds and what the rotator's `json_extract` names. An encrypted field anywhere
|
|
288
|
+
* else — inside an array, a record, a union, a transform — has no such path, so it is a problem rather than a field.
|
|
289
|
+
* So is a blind index on a field inside a JSON column (an index is a sibling column), an index column naming no
|
|
290
|
+
* indexed field, an indexed field with no index column, and a key no JSON path can name.
|
|
291
|
+
*/
|
|
292
|
+
function encryptedLayout(schema) {
|
|
293
|
+
const declaration = encryptedTableOf(schema);
|
|
294
|
+
const fields = [];
|
|
295
|
+
const problems = [];
|
|
296
|
+
const top = shapeOf(schema) ?? {};
|
|
297
|
+
const indexColumns = /* @__PURE__ */ new Map();
|
|
298
|
+
for (const [key, field] of Object.entries(top)) {
|
|
299
|
+
const indexes = blindIndexOf(field);
|
|
300
|
+
if (indexes !== void 0) indexColumns.set(indexes, key);
|
|
301
|
+
}
|
|
302
|
+
const visit = (node, segments) => {
|
|
303
|
+
const spec = encryptedSpecOf(node);
|
|
304
|
+
const path = segments.join(".");
|
|
305
|
+
if (spec) {
|
|
306
|
+
const bad = segments.find((segment) => !FIELD_KEY.test(segment));
|
|
307
|
+
if (bad !== void 0) {
|
|
308
|
+
problems.push(`The encrypted field at "${path}" has a key, "${bad}", that no JSON path can name.`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
let indexField = null;
|
|
312
|
+
if (spec.index !== null) {
|
|
313
|
+
if (segments.length > 1) problems.push(`The encrypted field "${path}" declares a blind index inside a JSON column. An index is a sibling column, so index a top-level field.`);
|
|
314
|
+
else {
|
|
315
|
+
indexField = indexColumns.get(path) ?? null;
|
|
316
|
+
if (indexField === null) problems.push(`The encrypted field "${path}" declares a blind index and the table has no blindIndex("${path}") column.`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
fields.push({
|
|
320
|
+
path,
|
|
321
|
+
segments: [...segments],
|
|
322
|
+
spec,
|
|
323
|
+
indexField
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const { node: inner } = unwrapPresence(node);
|
|
328
|
+
const nested = isSqliteJson(inner) ? defOf(inner).out : inner;
|
|
329
|
+
const shape = shapeOf(unwrapPresence(nested).node);
|
|
330
|
+
if (shape) {
|
|
331
|
+
for (const [key, child] of Object.entries(shape)) visit(child, [...segments, key]);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (holdsEncrypted(nested)) problems.push(`An encrypted field sits under "${path}" where no path of object keys reaches it, so it can be neither bound nor rotated. Encrypt the whole value, or move the field into an object.`);
|
|
335
|
+
};
|
|
336
|
+
for (const [key, field] of Object.entries(top)) visit(field, [key]);
|
|
337
|
+
const indexed = new Set(fields.filter((field) => field.spec.index !== null).map((field) => field.path));
|
|
338
|
+
for (const [named, column] of indexColumns) if (!indexed.has(named)) problems.push(`The column "${column}" is blindIndex("${named}"), and "${named}" is no indexed encrypted field.`);
|
|
339
|
+
return {
|
|
340
|
+
declaration,
|
|
341
|
+
fields,
|
|
342
|
+
problems
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/** Whether a schema holds an encrypted field anywhere: what makes it need `encryptedTable()` and `sealed()`. */
|
|
346
|
+
function hasEncryptedFields(schema) {
|
|
347
|
+
return holdsEncrypted(schema);
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
350
|
+
export { BLIND_INDEX_META_KEY, BLIND_INDEX_NORMALIZERS, BLIND_INDEX_VALUE, BlindIndexNormalizerName, ENCRYPTED_META_KEY, ENCRYPTED_TABLE_META_KEY, blindIndex, blindIndexOf, encrypted, encryptedLayout, encryptedSpecOf, encryptedTable, encryptedTableOf, hasEncryptedFields, isBlindIndexNormalizer, normalizeForIndex, sqlIdentifier, unwrapPresence };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { Capability } from "../capability/capability";
|
|
5
|
+
/** One encrypted column, as the rotator is stamped with it. */
|
|
6
|
+
export declare const EncryptedColumn: z.ZodObject<{
|
|
7
|
+
name: z.ZodString;
|
|
8
|
+
path: z.ZodString;
|
|
9
|
+
table: z.ZodString;
|
|
10
|
+
column: z.ZodString;
|
|
11
|
+
jsonPath: z.ZodNullable<z.ZodString>;
|
|
12
|
+
primaryKey: z.ZodString;
|
|
13
|
+
binding: z.ZodString;
|
|
14
|
+
index: z.ZodNullable<z.ZodObject<{
|
|
15
|
+
column: z.ZodString;
|
|
16
|
+
normalizer: z.ZodEnum<{
|
|
17
|
+
email: "email";
|
|
18
|
+
exact: "exact";
|
|
19
|
+
}>;
|
|
20
|
+
}, z.core.$strip>>;
|
|
21
|
+
}, z.core.$strip>;
|
|
22
|
+
export type EncryptedColumn = z.output<typeof EncryptedColumn>;
|
|
23
|
+
/** What the rotator is stamped with: the columns, and what the derivation could not cover. */
|
|
24
|
+
export declare const EncryptedColumnStamp: z.ZodObject<{
|
|
25
|
+
columns: z.ZodArray<z.ZodObject<{
|
|
26
|
+
name: z.ZodString;
|
|
27
|
+
path: z.ZodString;
|
|
28
|
+
table: z.ZodString;
|
|
29
|
+
column: z.ZodString;
|
|
30
|
+
jsonPath: z.ZodNullable<z.ZodString>;
|
|
31
|
+
primaryKey: z.ZodString;
|
|
32
|
+
binding: z.ZodString;
|
|
33
|
+
index: z.ZodNullable<z.ZodObject<{
|
|
34
|
+
column: z.ZodString;
|
|
35
|
+
normalizer: z.ZodEnum<{
|
|
36
|
+
email: "email";
|
|
37
|
+
exact: "exact";
|
|
38
|
+
}>;
|
|
39
|
+
}, z.core.$strip>>;
|
|
40
|
+
}, z.core.$strip>>;
|
|
41
|
+
unread: z.ZodArray<z.ZodString>;
|
|
42
|
+
unrotatable: z.ZodArray<z.ZodString>;
|
|
43
|
+
}, z.core.$strip>;
|
|
44
|
+
export type EncryptedColumnStamp = z.output<typeof EncryptedColumnStamp>;
|
|
45
|
+
/** Where an encrypted field sits in one KV store's parts, for the unrotatable list. */
|
|
46
|
+
export declare function kvEncryptedPaths(store: {
|
|
47
|
+
key: z.ZodType;
|
|
48
|
+
value: z.ZodType;
|
|
49
|
+
metadata?: z.ZodType;
|
|
50
|
+
}): string[];
|
|
51
|
+
/**
|
|
52
|
+
* Derive the encrypted columns of a set of composed capabilities. **Refuses** a D1 table with an encrypted field and
|
|
53
|
+
* no `encryptedTable()` declaration, and a table whose layout cannot be sealed or rotated, naming the capability and
|
|
54
|
+
* the table: a column the rotator cannot re-seal is a version that can never be retired.
|
|
55
|
+
*/
|
|
56
|
+
export declare function deriveEncryptedColumns(capabilities: readonly Capability[]): EncryptedColumnStamp;
|
|
57
|
+
/**
|
|
58
|
+
* **Refuse a KV store that holds an encrypted field, at boot** (#390). A KV envelope can never be found by key
|
|
59
|
+
* version, so it could never be rotated, and encryption that cannot rotate reads as the feature and is not. The
|
|
60
|
+
* refusal names the schema, the field and D1, and it reads through `.optional()`, `.nullable()`, `.default()` and
|
|
61
|
+
* nesting, by the wrapper walk the markers use. `@pithy-sh/vector`'s filterable check is the precedent.
|
|
62
|
+
*/
|
|
63
|
+
export declare function refuseEncryptedKvStores(capabilities: readonly Capability[]): void;
|
|
64
|
+
//# sourceMappingURL=encryptedColumns.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encryptedColumns.d.ts","sourceRoot":"","sources":["../../src/privacy/encryptedColumns.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAqC3D,+DAA+D;AAC/D,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;iBAsByE,CAAC;AACtG,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,eAAe,CAAC,CAAC;AAE/D,8FAA8F;AAC9F,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;iBAcmF,CAAC;AACrH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAOzE,uFAAuF;AACvF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE;IAAE,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC;IAAC,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,OAAO,CAAA;CAAE,GAAG,MAAM,EAAE,CAgB5G;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,oBAAoB,CAgDhG;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,IAAI,CAcjF"}
|