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