@pithy-sh/core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/audit/composed.d.ts +18 -0
  2. package/dist/audit/composed.d.ts.map +1 -0
  3. package/dist/audit/composed.js +28 -0
  4. package/dist/audit/recorder.d.ts +10 -2
  5. package/dist/audit/recorder.d.ts.map +1 -1
  6. package/dist/capability/capability.d.ts +23 -0
  7. package/dist/capability/capability.d.ts.map +1 -1
  8. package/dist/capability/capability.js +2 -0
  9. package/dist/capability/purge.d.ts +122 -0
  10. package/dist/capability/purge.d.ts.map +1 -0
  11. package/dist/capability/purge.js +131 -0
  12. package/dist/cli/commandCatalog.generated.d.ts +54 -2
  13. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  14. package/dist/cli/commandCatalog.generated.js +61 -0
  15. package/dist/cli/commandFacts.d.ts +1 -0
  16. package/dist/cli/commandFacts.d.ts.map +1 -1
  17. package/dist/cli/commandFacts.js +1 -0
  18. package/dist/controlPlane/capability.d.ts.map +1 -1
  19. package/dist/controlPlane/capability.js +7 -0
  20. package/dist/controlPlane/data/connection.d.ts +2 -12
  21. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  22. package/dist/controlPlane/data/connection.js +1 -1
  23. package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
  24. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  25. package/dist/controlPlane/discovery/adminRoute.js +3 -0
  26. package/dist/controlPlane/error/errors.d.ts +4 -3
  27. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  28. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  29. package/dist/controlPlane/http/guard.js +40 -1
  30. package/dist/controlPlane/http/handlers.d.ts +79 -0
  31. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  32. package/dist/controlPlane/http/handlers.js +37 -3
  33. package/dist/controlPlane/http/responses.d.ts +98 -0
  34. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  35. package/dist/controlPlane/http/responses.js +41 -1
  36. package/dist/controlPlane/http/routes.d.ts +1 -0
  37. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  38. package/dist/controlPlane/http/routes.js +17 -3
  39. package/dist/controlPlane/scope/scope.d.ts +13 -3
  40. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  41. package/dist/controlPlane/scope/scope.js +14 -4
  42. package/dist/createBackend.d.ts.map +1 -1
  43. package/dist/createBackend.js +2 -0
  44. package/dist/createEntrypoint.d.ts +7 -1
  45. package/dist/createEntrypoint.d.ts.map +1 -1
  46. package/dist/createEntrypoint.js +15 -5
  47. package/dist/crypto/envelope.d.ts +86 -0
  48. package/dist/crypto/envelope.d.ts.map +1 -0
  49. package/dist/crypto/envelope.js +129 -0
  50. package/dist/crypto/fieldKeys.d.ts +41 -0
  51. package/dist/crypto/fieldKeys.d.ts.map +1 -0
  52. package/dist/crypto/fieldKeys.js +56 -0
  53. package/dist/data/codecs.d.ts +23 -10
  54. package/dist/data/codecs.d.ts.map +1 -1
  55. package/dist/data/codecs.js +50 -36
  56. package/dist/error/extend.d.ts +6 -5
  57. package/dist/error/extend.d.ts.map +1 -1
  58. package/dist/error/http.d.ts +72 -0
  59. package/dist/error/http.d.ts.map +1 -1
  60. package/dist/error/payload.d.ts +144 -0
  61. package/dist/error/payload.d.ts.map +1 -1
  62. package/dist/error/payload.js +48 -0
  63. package/dist/error/pithyError.d.ts +18 -7
  64. package/dist/error/pithyError.d.ts.map +1 -1
  65. package/dist/error/pithyError.js +6 -0
  66. package/dist/http/validation.d.ts +11 -2
  67. package/dist/http/validation.d.ts.map +1 -1
  68. package/dist/http/validation.js +20 -3
  69. package/dist/index.d.ts +1 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +2 -2
  72. package/dist/logger/logger.d.ts +12 -5
  73. package/dist/logger/logger.d.ts.map +1 -1
  74. package/dist/logger/record.d.ts +42 -0
  75. package/dist/logger/record.d.ts.map +1 -1
  76. package/dist/privacy/blindIndex.d.ts +27 -0
  77. package/dist/privacy/blindIndex.d.ts.map +1 -0
  78. package/dist/privacy/blindIndex.js +57 -0
  79. package/dist/privacy/classes.d.ts +142 -0
  80. package/dist/privacy/classes.d.ts.map +1 -0
  81. package/dist/privacy/classes.js +209 -0
  82. package/dist/privacy/classify.d.ts +336 -0
  83. package/dist/privacy/classify.d.ts.map +1 -0
  84. package/dist/privacy/classify.js +1024 -0
  85. package/dist/privacy/encrypted.d.ts +210 -0
  86. package/dist/privacy/encrypted.d.ts.map +1 -0
  87. package/dist/privacy/encrypted.js +350 -0
  88. package/dist/privacy/encryptedColumns.d.ts +64 -0
  89. package/dist/privacy/encryptedColumns.d.ts.map +1 -0
  90. package/dist/privacy/encryptedColumns.js +134 -0
  91. package/dist/privacy/fieldRotation.d.ts +34 -0
  92. package/dist/privacy/fieldRotation.d.ts.map +1 -0
  93. package/dist/privacy/fieldRotation.js +186 -0
  94. package/dist/privacy/inventory.d.ts +12 -0
  95. package/dist/privacy/inventory.d.ts.map +1 -0
  96. package/dist/privacy/inventory.js +154 -0
  97. package/dist/privacy/posture.d.ts +114 -0
  98. package/dist/privacy/posture.d.ts.map +1 -0
  99. package/dist/privacy/posture.js +136 -0
  100. package/dist/privacy/redact.d.ts +7 -0
  101. package/dist/privacy/redact.d.ts.map +1 -0
  102. package/dist/privacy/redact.js +404 -0
  103. package/dist/privacy/report.d.ts +58 -0
  104. package/dist/privacy/report.d.ts.map +1 -0
  105. package/dist/privacy/report.js +15 -0
  106. package/dist/privacy/retirement.d.ts +68 -0
  107. package/dist/privacy/retirement.d.ts.map +1 -0
  108. package/dist/privacy/retirement.js +72 -0
  109. package/dist/privacy/sealed.d.ts +87 -0
  110. package/dist/privacy/sealed.d.ts.map +1 -0
  111. package/dist/privacy/sealed.js +221 -0
  112. package/dist/privacy/types.d.ts +559 -0
  113. package/dist/privacy/types.d.ts.map +1 -0
  114. package/dist/privacy/types.js +2 -0
  115. package/dist/version.generated.d.ts +1 -1
  116. package/dist/version.generated.js +1 -1
  117. package/package.json +1 -1
  118. package/src/audit/composed.ts +30 -0
  119. package/src/audit/recorder.ts +10 -2
  120. package/src/capability/capability.ts +26 -0
  121. package/src/capability/purge.ts +187 -0
  122. package/src/cli/commandCatalog.generated.ts +65 -0
  123. package/src/cli/commandFacts.ts +1 -0
  124. package/src/controlPlane/capability.ts +11 -0
  125. package/src/controlPlane/discovery/adminRoute.ts +11 -0
  126. package/src/controlPlane/error/errors.ts +4 -3
  127. package/src/controlPlane/http/guard.ts +60 -1
  128. package/src/controlPlane/http/handlers.ts +57 -4
  129. package/src/controlPlane/http/responses.ts +100 -0
  130. package/src/controlPlane/http/routes.ts +31 -1
  131. package/src/controlPlane/scope/scope.ts +14 -3
  132. package/src/controlPlane/token/base64url.ts +1 -1
  133. package/src/createBackend.ts +4 -0
  134. package/src/createEntrypoint.ts +17 -2
  135. package/src/crypto/envelope.ts +196 -0
  136. package/src/crypto/fieldKeys.ts +76 -0
  137. package/src/data/codecs.ts +69 -37
  138. package/src/error/extend.ts +6 -5
  139. package/src/error/payload.ts +110 -0
  140. package/src/error/pithyError.ts +21 -7
  141. package/src/http/validation.ts +35 -3
  142. package/src/index.ts +1 -0
  143. package/src/logger/logger.ts +12 -5
  144. package/src/privacy/blindIndex.ts +81 -0
  145. package/src/privacy/classes.ts +277 -0
  146. package/src/privacy/classify.ts +1447 -0
  147. package/src/privacy/encrypted.ts +513 -0
  148. package/src/privacy/encryptedColumns.ts +185 -0
  149. package/src/privacy/fieldRotation.ts +234 -0
  150. package/src/privacy/inventory.ts +147 -0
  151. package/src/privacy/posture.ts +205 -0
  152. package/src/privacy/redact.ts +423 -0
  153. package/src/privacy/report.ts +64 -0
  154. package/src/privacy/retirement.ts +102 -0
  155. package/src/privacy/sealed.ts +323 -0
  156. package/src/privacy/types.ts +804 -0
  157. package/src/version.generated.ts +1 -1
@@ -0,0 +1,185 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import type { Capability } from "../capability/capability";
6
+ import { ValidationError } from "../error/pithyError";
7
+ import {
8
+ BlindIndexNormalizerName,
9
+ encryptedLayout,
10
+ encryptedSpecOf,
11
+ hasEncryptedFields,
12
+ sqlIdentifier,
13
+ } from "./encrypted";
14
+
15
+ /**
16
+ * **Every encrypted column a project holds, derived from its schemas** (#390).
17
+ *
18
+ * The rotator runs in the secrets manager, which never reads `pithy.config.ts`. So it learns the encrypted columns
19
+ * the way it learns `rotateEveryDays`: `pithy secrets provision` and `pithy deploy` stamp it with them, and this is
20
+ * the derivation they stamp. It reads the same markers #132's inventory walks, so a field marked `encrypted()`
21
+ * tomorrow joins rotation on the next deploy, with no registration step.
22
+ *
23
+ * It is also how an app Worker reports its own retirement verdict: the same walk over its own capabilities, so what
24
+ * the dashboard reads and what the rotator acts on are one derivation.
25
+ *
26
+ * ## D1 only, and what that excludes
27
+ *
28
+ * A KV `list()` returns keys, never values, so a KV envelope cannot be found by key version: no stale query, no
29
+ * compare-and-swap and no count. An encrypted field in a KV store is refused at boot (`refuseEncryptedKvStores`), and
30
+ * any this derivation still meets is reported as **unrotatable** rather than stamped, because nothing can ever prove
31
+ * those values re-sealed. An unrotatable field blocks retirement.
32
+ */
33
+
34
+ /** A SQL identifier as `CamelCasePlugin` writes one: what a stamp may name, so nothing else reaches the rotator's SQL. */
35
+ const SqlName = z.string().regex(/^[a-z_][a-z0-9_]*$/, "A SQL identifier: lowercase letters, digits and underscores.");
36
+
37
+ /** A JSON path of object keys, the only kind an encrypted field is reached by. */
38
+ const JsonPath = z
39
+ .string()
40
+ .regex(/^\$(\.[A-Za-z_$][A-Za-z0-9_$]*)+$/, "A JSON path of object keys, such as `$.phone`.");
41
+
42
+ /** One encrypted column, as the rotator is stamped with it. */
43
+ export const EncryptedColumn = z
44
+ .object({
45
+ name: z.string().describe("The table's declared name: what the AAD binds, never the SQL table name."),
46
+ path: z.string().describe("The field path the AAD binds: `diagnosis`, or `profile.phone` inside a JSON column."),
47
+ table: SqlName.describe("The SQL table, as `CamelCasePlugin` writes it."),
48
+ column: SqlName.describe("The SQL column holding the envelope, or the JSON column the field sits inside."),
49
+ jsonPath: JsonPath.nullable().describe(
50
+ "The JSON path of a field inside a JSON column, such as `$.phone`. Null for a field in its own column.",
51
+ ),
52
+ primaryKey: SqlName.describe("The SQL column of the table's app-assigned primary key."),
53
+ binding: z
54
+ .string()
55
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "A binding name.")
56
+ .describe("The D1 binding the table lives in. Workers that share a binding share the database."),
57
+ index: z
58
+ .object({
59
+ column: SqlName.describe("The SQL column of the field's blind index."),
60
+ normalizer: BlindIndexNormalizerName.describe("The normalizer the index hashes with."),
61
+ })
62
+ .nullable()
63
+ .describe("The field's blind index, or null for a field with none."),
64
+ })
65
+ .describe("One encrypted column the rotator re-seals: where it is, and what its ciphertexts bind.");
66
+ export type EncryptedColumn = z.output<typeof EncryptedColumn>;
67
+
68
+ /** What the rotator is stamped with: the columns, and what the derivation could not cover. */
69
+ export const EncryptedColumnStamp = z
70
+ .object({
71
+ columns: z.array(EncryptedColumn).describe("Every encrypted D1 column the project's schemas declare."),
72
+ unread: z
73
+ .array(z.string())
74
+ .describe(
75
+ "Every schema source the derivation could not read, such as a Worker whose `pithy.config.ts` would not load. Non-empty means coverage is incomplete, and no version is retired.",
76
+ ),
77
+ unrotatable: z
78
+ .array(z.string())
79
+ .describe(
80
+ "Every encrypted field whose storage is not D1, as `<capability>:<container>.<schema>:<path>`. Each blocks retirement.",
81
+ ),
82
+ })
83
+ .describe("The encrypted columns a project holds, derived from its schemas, as the rotator is stamped with them.");
84
+ export type EncryptedColumnStamp = z.output<typeof EncryptedColumnStamp>;
85
+
86
+ /** A SQL JSON path from object keys. Each key is an identifier, which `encryptedLayout` has already required. */
87
+ function jsonPathOf(segments: readonly string[]): string {
88
+ return `$.${segments.join(".")}`;
89
+ }
90
+
91
+ /** Where an encrypted field sits in one KV store's parts, for the unrotatable list. */
92
+ export function kvEncryptedPaths(store: { key: z.ZodType; value: z.ZodType; metadata?: z.ZodType }): string[] {
93
+ const paths: string[] = [];
94
+ for (const [part, schema] of [
95
+ ["key", store.key],
96
+ ["value", store.value],
97
+ ["metadata", store.metadata],
98
+ ] as const) {
99
+ if (!schema || !hasEncryptedFields(schema)) continue;
100
+ if (encryptedSpecOf(schema)) {
101
+ paths.push(part);
102
+ continue;
103
+ }
104
+ const located = encryptedLayout(schema).fields.map((field) => `${part}.${field.path}`);
105
+ paths.push(...(located.length > 0 ? located : [part]));
106
+ }
107
+ return paths;
108
+ }
109
+
110
+ /**
111
+ * Derive the encrypted columns of a set of composed capabilities. **Refuses** a D1 table with an encrypted field and
112
+ * no `encryptedTable()` declaration, and a table whose layout cannot be sealed or rotated, naming the capability and
113
+ * the table: a column the rotator cannot re-seal is a version that can never be retired.
114
+ */
115
+ export function deriveEncryptedColumns(capabilities: readonly Capability[]): EncryptedColumnStamp {
116
+ const columns: EncryptedColumn[] = [];
117
+ const unrotatable: string[] = [];
118
+ for (const capability of capabilities) {
119
+ for (const [container, database] of Object.entries(capability.databases ?? {})) {
120
+ for (const [table, schema] of Object.entries(database.tables)) {
121
+ const layout = encryptedLayout(schema as z.ZodType);
122
+ if (layout.fields.length === 0 && layout.problems.length === 0 && !hasEncryptedFields(schema as z.ZodType))
123
+ continue;
124
+ const where = `${capability.name}'s ${container}.${table} table`;
125
+ if (layout.declaration === null) {
126
+ throw new ValidationError({
127
+ message: `${where} holds an encrypted field and declares no table.`,
128
+ action:
129
+ "Wrap its schema in encryptedTable(schema, { name, key }): the rotator re-seals by that name and key.",
130
+ });
131
+ }
132
+ if (layout.problems.length > 0) {
133
+ throw new ValidationError({
134
+ message: `${where} cannot be rotated as declared. ${layout.problems.join(" ")}`,
135
+ action: "Fix the schema's encrypted fields and their blindIndex columns.",
136
+ });
137
+ }
138
+ for (const field of layout.fields) {
139
+ const [head, ...rest] = field.segments as [string, ...string[]];
140
+ columns.push({
141
+ name: layout.declaration.name,
142
+ path: field.path,
143
+ table: sqlIdentifier(table),
144
+ column: sqlIdentifier(head),
145
+ jsonPath: rest.length > 0 ? jsonPathOf(rest) : null,
146
+ primaryKey: sqlIdentifier(layout.declaration.key),
147
+ binding: database.binding,
148
+ index:
149
+ field.indexField && field.spec.index
150
+ ? { column: sqlIdentifier(field.indexField), normalizer: field.spec.index }
151
+ : null,
152
+ });
153
+ }
154
+ }
155
+ }
156
+ for (const [container, namespace] of Object.entries(capability.kvNamespaces ?? {})) {
157
+ for (const [name, store] of Object.entries(namespace.stores)) {
158
+ for (const path of kvEncryptedPaths(store)) unrotatable.push(`${capability.name}:${container}.${name}:${path}`);
159
+ }
160
+ }
161
+ }
162
+ return { columns, unread: [], unrotatable };
163
+ }
164
+
165
+ /**
166
+ * **Refuse a KV store that holds an encrypted field, at boot** (#390). A KV envelope can never be found by key
167
+ * version, so it could never be rotated, and encryption that cannot rotate reads as the feature and is not. The
168
+ * refusal names the schema, the field and D1, and it reads through `.optional()`, `.nullable()`, `.default()` and
169
+ * nesting, by the wrapper walk the markers use. `@pithy-sh/vector`'s filterable check is the precedent.
170
+ */
171
+ export function refuseEncryptedKvStores(capabilities: readonly Capability[]): void {
172
+ for (const capability of capabilities) {
173
+ for (const [container, namespace] of Object.entries(capability.kvNamespaces ?? {})) {
174
+ for (const [name, store] of Object.entries(namespace.stores)) {
175
+ const [path] = kvEncryptedPaths(store);
176
+ if (path === undefined) continue;
177
+ throw new ValidationError({
178
+ message: `${capability.name}'s KV store ${container}.${name} holds an encrypted field at "${path}". Encrypted fields rest in D1.`,
179
+ action:
180
+ "Move the field to a D1 table declared with encryptedTable(). A KV entry cannot be found by key version, so it could never be rotated.",
181
+ });
182
+ }
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,234 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { Capability } from "../capability/capability";
6
+ import { decryptValue, EncryptedEnvelope, type EncryptionConfig, encryptValue } from "../crypto/envelope";
7
+ import { FIELD_KEYS_BINDING, type FieldKeys, liveVersions, resolveFieldKeys } from "../crypto/fieldKeys";
8
+ import { InternalError } from "../error/pithyError";
9
+ import { blindIndexValue } from "./blindIndex";
10
+ import { deriveEncryptedColumns, type EncryptedColumn, type EncryptedColumnStamp } from "./encryptedColumns";
11
+ import { type RetirementVerdict, retirementVerdict, versionsDue } from "./retirement";
12
+ import { fieldContext } from "./sealed";
13
+
14
+ /**
15
+ * **Re-sealing an encrypted column in place, and the guard on retiring a version** (#390).
16
+ *
17
+ * Rotation is lazy: every write seals under the current version, so a row moves off an old key the next time it is
18
+ * written, and nothing re-seals between rotations. At each rotation the manager's rotator adds a version, re-seals the
19
+ * rows still under the version **leaving the window** — and only those — and retires it once the guard reads clean.
20
+ * The SQL that finds those rows, re-seals one, and counts what is left lives here, beside the derivation it reads, so
21
+ * the rotator and the app Worker's own retirement report count one way.
22
+ *
23
+ * - **A stale row is found by `json_extract`, not by a scan of plaintext.** An envelope carries its `keyVersion` in
24
+ * the clear, and a blind index its version prefix, so the count and the batch are one indexed-or-not `WHERE`.
25
+ * - **Each re-seal is a compare-and-swap** on the version it read: `UPDATE … WHERE <pk> = ? AND json_extract(col,
26
+ * '$.keyVersion') = <version read>`. A row the application re-sealed in between is skipped, never overwritten.
27
+ * - **A field inside a JSON column** is an envelope JSON string at its JSON path, so its version is
28
+ * `json_extract(json_extract(col, '$.path'), '$.keyVersion')` and its re-seal is a `json_set` of that path.
29
+ */
30
+
31
+ /** Quote a SQL identifier. Every identifier here comes from a validated stamp; this keeps it an identifier. */
32
+ function quote(identifier: string): string {
33
+ return `"${identifier.replace(/"/g, '""')}"`;
34
+ }
35
+
36
+ /** The SQL that reads the key version of a column's envelope. */
37
+ function envelopeVersion(column: EncryptedColumn): string {
38
+ const col = quote(column.column);
39
+ return column.jsonPath === null
40
+ ? `json_extract(${col}, '$.keyVersion')`
41
+ : `json_extract(json_extract(${col}, '${column.jsonPath}'), '$.keyVersion')`;
42
+ }
43
+
44
+ /** The SQL that reads the version prefix of a blind-index value. */
45
+ function indexVersion(indexColumn: string): string {
46
+ const col = quote(indexColumn);
47
+ return `CAST(substr(${col}, 1, instr(${col}, ':') - 1) AS INTEGER)`;
48
+ }
49
+
50
+ /** The SQL predicate a row is stale under: its envelope, or its index, carries one of `count` versions. */
51
+ function stalePredicate(column: EncryptedColumn, count: number): string {
52
+ const marks = Array.from({ length: count }, () => "?").join(", ");
53
+ const envelope = `${envelopeVersion(column)} IN (${marks})`;
54
+ return column.index ? `(${envelope} OR ${indexVersion(column.index.column)} IN (${marks}))` : envelope;
55
+ }
56
+
57
+ /** The bindings for {@link stalePredicate}. */
58
+ function staleBindings(column: EncryptedColumn, versions: readonly number[]): number[] {
59
+ return column.index ? [...versions, ...versions] : [...versions];
60
+ }
61
+
62
+ /** How many rows of one column still hold an envelope or an index under any of `versions`. */
63
+ export async function countStale(
64
+ d1: D1Database,
65
+ column: EncryptedColumn,
66
+ versions: readonly number[],
67
+ ): Promise<number> {
68
+ if (versions.length === 0) return 0;
69
+ const row = await d1
70
+ .prepare(`SELECT COUNT(*) AS count FROM ${quote(column.table)} WHERE ${stalePredicate(column, versions.length)}`)
71
+ .bind(...staleBindings(column, versions))
72
+ .first<{ count: number }>();
73
+ return Number(row?.count ?? 0);
74
+ }
75
+
76
+ /** One stale row, as a batch reads it. */
77
+ interface StaleRow {
78
+ pk: string;
79
+ stored: string | null;
80
+ index: string | null;
81
+ }
82
+
83
+ /** One batch's outcome. */
84
+ export interface ResealResult {
85
+ /** Rows re-sealed and re-indexed under the current version. */
86
+ resealed: number;
87
+ /** Rows the application re-sealed between the read and the write: left alone. */
88
+ skipped: number;
89
+ /** Rows that would not open. Never described: what failed to open is ciphertext. */
90
+ failed: number;
91
+ }
92
+
93
+ /** The envelope a stored column holds at the field's path, or `null` where it holds none. */
94
+ function envelopeAt(column: EncryptedColumn, stored: string | null): EncryptedEnvelope | null {
95
+ if (stored === null) return null;
96
+ let value: unknown = JSON.parse(stored);
97
+ if (column.jsonPath !== null) {
98
+ for (const segment of column.jsonPath.slice(2).split(".")) {
99
+ if (typeof value !== "object" || value === null) return null;
100
+ value = (value as Record<string, unknown>)[segment];
101
+ }
102
+ if (value === undefined || value === null) return null;
103
+ value = typeof value === "string" ? JSON.parse(value) : value;
104
+ }
105
+ if (value === null) return null;
106
+ return EncryptedEnvelope.parse(value);
107
+ }
108
+
109
+ /**
110
+ * Re-seal one batch of a column's rows still under `version`, onto `config`'s current version, re-indexing each in
111
+ * the same write. Every write is a compare-and-swap on the version read, so a row the application re-sealed in
112
+ * between is skipped. A row that will not open is counted and left, never described.
113
+ */
114
+ export async function resealBatch(
115
+ d1: D1Database,
116
+ column: EncryptedColumn,
117
+ config: EncryptionConfig,
118
+ version: number,
119
+ limit: number,
120
+ ): Promise<ResealResult> {
121
+ const result: ResealResult = { resealed: 0, skipped: 0, failed: 0 };
122
+ const current = Number(config.currentVersion);
123
+ const index = column.index ? `, ${quote(column.index.column)} AS "index"` : `, NULL AS "index"`;
124
+ const { results } = await d1
125
+ .prepare(
126
+ `SELECT ${quote(column.primaryKey)} AS pk, ${quote(column.column)} AS stored${index} FROM ${quote(column.table)} WHERE ${stalePredicate(column, 1)} LIMIT ?`,
127
+ )
128
+ .bind(...staleBindings(column, [version]), limit)
129
+ .all<StaleRow>();
130
+ for (const row of results) {
131
+ let envelope: EncryptedEnvelope | null;
132
+ let plaintext: string | null = null;
133
+ let sealed: EncryptedEnvelope | null = null;
134
+ let indexValue: string | null = row.index;
135
+ try {
136
+ envelope = envelopeAt(column, row.stored);
137
+ const context = fieldContext(column.name, column.path, String(row.pk));
138
+ if (envelope !== null) {
139
+ plaintext = await decryptValue(config, context, envelope, { keys: FIELD_KEYS_BINDING });
140
+ sealed = await encryptValue(config, context, plaintext, { keys: FIELD_KEYS_BINDING });
141
+ }
142
+ if (column.index && plaintext !== null) {
143
+ const value: unknown = JSON.parse(plaintext);
144
+ if (typeof value !== "string") throw new InternalError({ detail: "an indexed field opened to a non-string" });
145
+ indexValue = await blindIndexValue(config, current, column.index.normalizer, value);
146
+ }
147
+ } catch {
148
+ // No binding: what failed to open is ciphertext, and its text belongs nowhere.
149
+ result.failed += 1;
150
+ continue;
151
+ }
152
+ const readVersion = envelope?.keyVersion ?? null;
153
+ const set: string[] = [];
154
+ const values: unknown[] = [];
155
+ if (sealed !== null) {
156
+ if (column.jsonPath === null) {
157
+ set.push(`${quote(column.column)} = ?`);
158
+ } else {
159
+ set.push(`${quote(column.column)} = json_set(${quote(column.column)}, '${column.jsonPath}', ?)`);
160
+ }
161
+ values.push(JSON.stringify(sealed));
162
+ }
163
+ if (column.index) {
164
+ set.push(`${quote(column.index.column)} = ?`);
165
+ values.push(indexValue);
166
+ }
167
+ if (set.length === 0) {
168
+ result.failed += 1;
169
+ continue;
170
+ }
171
+ const swapped = await d1
172
+ .prepare(
173
+ `UPDATE ${quote(column.table)} SET ${set.join(", ")} WHERE ${quote(column.primaryKey)} = ? AND ${envelopeVersion(column)} IS ?${column.index ? ` AND ${quote(column.index.column)} IS ?` : ""}`,
174
+ )
175
+ .bind(...values, row.pk, readVersion, ...(column.index ? [row.index] : []))
176
+ .run();
177
+ if ((swapped.meta.changes ?? 0) > 0) result.resealed += 1;
178
+ else result.skipped += 1;
179
+ }
180
+ return result;
181
+ }
182
+
183
+ /** Whether an `env` slot is a D1 binding. */
184
+ function isD1(value: unknown): value is D1Database {
185
+ return typeof value === "object" && value !== null && typeof (value as { prepare?: unknown }).prepare === "function";
186
+ }
187
+
188
+ /**
189
+ * **This Worker's own retirement verdict**, for the manifest's posture block: the guard over its own capabilities'
190
+ * encrypted columns, read through its own D1 bindings and its own `PITHY_FIELD_KEYS`. The same derivation and the
191
+ * same counts the rotator acts on, so what the dashboard shows is what the rotator would decide.
192
+ *
193
+ * **It never throws.** A derivation that refuses, a key set that will not resolve, a binding the Worker lacks and a
194
+ * count that fails are each incomplete coverage: the guard could not read something, so it is not clean.
195
+ */
196
+ export async function readRetirement(
197
+ env: Record<string, unknown>,
198
+ capabilities: readonly Capability[],
199
+ ): Promise<RetirementVerdict> {
200
+ let stamp: EncryptedColumnStamp;
201
+ try {
202
+ stamp = deriveEncryptedColumns(capabilities);
203
+ } catch {
204
+ return retirementVerdict({ versions: [], stale: 0, coverageComplete: false, unrotatable: 0 });
205
+ }
206
+ const keys = env[FIELD_KEYS_BINDING];
207
+ let coverageComplete = true;
208
+ let versions: number[] = [];
209
+ let current: number | undefined;
210
+ if (stamp.columns.length > 0 || keys !== undefined) {
211
+ try {
212
+ const config = await resolveFieldKeys(keys as FieldKeys | undefined);
213
+ versions = liveVersions(config);
214
+ current = Number(config.currentVersion);
215
+ } catch {
216
+ coverageComplete = false;
217
+ }
218
+ }
219
+ const due = current === undefined ? [] : versionsDue(versions, current);
220
+ let stale = 0;
221
+ for (const column of stamp.columns) {
222
+ const d1 = env[column.binding];
223
+ if (!isD1(d1)) {
224
+ coverageComplete = false;
225
+ continue;
226
+ }
227
+ try {
228
+ stale += await countStale(d1, column, due);
229
+ } catch {
230
+ coverageComplete = false;
231
+ }
232
+ }
233
+ return retirementVerdict({ versions, current, stale, coverageComplete, unrotatable: stamp.unrotatable.length });
234
+ }
@@ -0,0 +1,147 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import type { Capability } from "../capability/capability";
6
+ import { BUILT_IN_PRIVACY_CLASSES } from "./classes";
7
+ import { classifyPrivacy, type PrivacyField } from "./classify";
8
+ import { encryptedLayout } from "./encrypted";
9
+ import { kvEncryptedPaths } from "./encryptedColumns";
10
+ import {
11
+ type EncryptedFieldRecord,
12
+ PRIVACY_SOURCES,
13
+ type PrivacyInventory,
14
+ type PrivacyInventorySchema,
15
+ } from "./report";
16
+
17
+ /**
18
+ * **What this Worker holds, derived from the code that is running** (#132).
19
+ *
20
+ * The inventory is computed at runtime from the composed capabilities' storage schemas — the Zod objects that
21
+ * *are* the table definitions, already in the deployed bundle — not stamped at build. A stamped artifact is
22
+ * skippable (a raw `wrangler deploy` ships the last stamp) and a drifted one is worse than none, because a
23
+ * dashboard acts on it. A derivation cannot be skipped, cannot be edited out of the adopter's repository, and
24
+ * cannot disagree with the code: it is the code. Unmark a `phi()` field and that field leaves the report on the
25
+ * next deploy, with no build step, generated file or person in between.
26
+ *
27
+ * ## What it walks
28
+ *
29
+ * {@link PRIVACY_SOURCES}, and nothing else: every composed capability's **D1 tables**, and its **KV stores** —
30
+ * the key segments, the value and the metadata, since a KV entry stores all three. That is where persisted data
31
+ * lives and what an auditor asks about. R2 objects, Vectorize metadata, Durable Object storage, email templates
32
+ * and request bodies are not walked, and the report names its sources so that is a stated fact rather than an
33
+ * implied guarantee.
34
+ *
35
+ * ## What a path looks like
36
+ *
37
+ * A D1 field is its column path: `email`, `address.line1`, or `profile.phone` for a marked field inside a JSON column,
38
+ * which `redact` reads field by field. A KV field leads with the part of the entry it is in: `key.email`, `value.token`,
39
+ * `metadata.ip`, `value[].email` for an array value — or `value` alone when the whole value is marked. It is the path
40
+ * `pithy privacy scan` writes for the same field.
41
+ *
42
+ * ## The per-field record
43
+ *
44
+ * `{ path, class }`, from `classifyPrivacy`, with `truncated: true` where a read ran out of its budget, or was cut at
45
+ * the depth limit on a chain of schemas not yet read, and it assumed every class, and `sameAs` on a reference: a
46
+ * position written out under another path once the list reached its cap on paths, listed once per class marked
47
+ * beneath it. The counts count each record, a reference included. Since #390 it also carries `encryptedAtRest`
48
+ * beside `class`, so "which of your PHI fields are encrypted" is one more derivation rather than a second thing to
49
+ * maintain, and it is absent where the core cannot say.
50
+ *
51
+ * ## What it costs
52
+ *
53
+ * Nothing until it is read. {@link lazyPrivacyInventory} walks on first read and caches for the isolate, so a
54
+ * Worker whose control plane is never called never walks.
55
+ */
56
+
57
+ /** The marked fields of a KV store: its key segments, value and metadata, each under its own prefix. */
58
+ function kvFields(store: { key: z.ZodType; value: z.ZodType; metadata?: z.ZodType }): PrivacyField[] {
59
+ const parts: [string, z.ZodType | undefined][] = [
60
+ ["key", store.key],
61
+ ["value", store.value],
62
+ ["metadata", store.metadata],
63
+ ];
64
+ return parts.flatMap(([part, schema]) => {
65
+ if (!schema) return [];
66
+ const under = (path: string) => (path === "" || path.startsWith("[") ? `${part}${path}` : `${part}.${path}`);
67
+ // A reference names a path in the same list, so it takes the same prefix.
68
+ return classifyPrivacy(schema).map((field) =>
69
+ field.sameAs === undefined
70
+ ? { ...field, path: under(field.path) }
71
+ : { ...field, path: under(field.path), sameAs: under(field.sameAs) },
72
+ );
73
+ });
74
+ }
75
+
76
+ /**
77
+ * A field record with whether it rests encrypted (#390). **Left out where the core cannot say**: a class assumed past a
78
+ * budget or a cut, and a reference, which stands for paths it never wrote out. Never defaulted to `false`.
79
+ */
80
+ function withEncryption(field: PrivacyField, encrypted: ReadonlySet<string>, unrotatable: boolean): PrivacyField {
81
+ if (field.truncated || field.sameAs !== undefined) return field;
82
+ const atRest = encrypted.has(field.path);
83
+ return atRest && unrotatable
84
+ ? { ...field, encryptedAtRest: true, unrotatable: true }
85
+ : { ...field, encryptedAtRest: atRest };
86
+ }
87
+
88
+ /** Classes in the order every report lists them: the built-ins first, then each other class by name. */
89
+ function ordered(classes: Iterable<string>): string[] {
90
+ const builtIn: readonly string[] = BUILT_IN_PRIVACY_CLASSES;
91
+ const all = [...new Set(classes)];
92
+ return [...builtIn.filter((name) => all.includes(name)), ...all.filter((name) => !builtIn.includes(name)).sort()];
93
+ }
94
+
95
+ /** Walk every composed capability's D1 tables and KV stores. See the module note for exactly what is covered. */
96
+ export function privacyInventory(capabilities: readonly Capability[]): PrivacyInventory {
97
+ const schemas: PrivacyInventorySchema[] = [];
98
+ let schemasWalked = 0;
99
+ for (const capability of capabilities) {
100
+ for (const [container, database] of Object.entries(capability.databases ?? {})) {
101
+ for (const [name, table] of Object.entries(database.tables)) {
102
+ schemasWalked += 1;
103
+ const paths = new Set(encryptedLayout(table as z.ZodType).fields.map((field) => field.path));
104
+ const fields = classifyPrivacy(table as z.ZodType).map((field) => withEncryption(field, paths, false));
105
+ const encrypted: EncryptedFieldRecord[] = [...paths].map((path) => ({ path }));
106
+ if (fields.length > 0 || encrypted.length > 0) {
107
+ schemas.push({ capability: capability.name, storage: "d1", container, name, fields, encrypted });
108
+ }
109
+ }
110
+ }
111
+ for (const [container, namespace] of Object.entries(capability.kvNamespaces ?? {})) {
112
+ for (const [name, store] of Object.entries(namespace.stores)) {
113
+ schemasWalked += 1;
114
+ // An encrypted field in KV is refused at boot; one this walk still meets is reported, and it is unrotatable.
115
+ const paths = new Set(kvEncryptedPaths(store));
116
+ const fields = kvFields(store).map((field) => withEncryption(field, paths, true));
117
+ const encrypted: EncryptedFieldRecord[] = [...paths].map((path) => ({ path, unrotatable: true }));
118
+ if (fields.length > 0 || encrypted.length > 0) {
119
+ schemas.push({ capability: capability.name, storage: "kv", container, name, fields, encrypted });
120
+ }
121
+ }
122
+ }
123
+ }
124
+ const tally = new Map<string, number>();
125
+ for (const schema of schemas)
126
+ for (const field of schema.fields) tally.set(field.class, (tally.get(field.class) ?? 0) + 1);
127
+ const observed = ordered(tally.keys());
128
+ const counts = Object.fromEntries(observed.map((name) => [name, tally.get(name) ?? 0]));
129
+ const encryptedFields = schemas.reduce((sum, schema) => sum + schema.encrypted.length, 0);
130
+ const unrotatable = schemas.reduce(
131
+ (sum, schema) => sum + schema.encrypted.filter((field) => field.unrotatable).length,
132
+ 0,
133
+ );
134
+ return { sources: [...PRIVACY_SOURCES], schemasWalked, schemas, counts, observed, encryptedFields, unrotatable };
135
+ }
136
+
137
+ /**
138
+ * The inventory, walked on first read and cached for the isolate. `capabilities` is a thunk because the
139
+ * composed set is only known once assembly has run, and reading it is itself deferred to the first call.
140
+ */
141
+ export function lazyPrivacyInventory(capabilities: () => readonly Capability[]): () => PrivacyInventory {
142
+ let cached: PrivacyInventory | undefined;
143
+ return () => {
144
+ cached ??= privacyInventory(capabilities());
145
+ return cached;
146
+ };
147
+ }