@pithy-sh/core 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/capability/capability.d.ts +12 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- package/dist/capability/purge.d.ts +80 -0
- package/dist/capability/purge.d.ts.map +1 -0
- package/dist/capability/purge.js +76 -0
- package/dist/cli/commandCatalog.generated.d.ts +54 -2
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +55 -0
- package/dist/cli/commandFacts.d.ts +1 -0
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +1 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +7 -0
- package/dist/controlPlane/data/connection.d.ts +2 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +1 -1
- package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +3 -0
- package/dist/controlPlane/error/errors.d.ts +4 -3
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.d.ts +79 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +37 -3
- package/dist/controlPlane/http/responses.d.ts +98 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +41 -1
- package/dist/controlPlane/http/routes.d.ts +1 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +17 -3
- package/dist/controlPlane/scope/scope.d.ts +13 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +14 -4
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +2 -0
- package/dist/crypto/envelope.d.ts +86 -0
- package/dist/crypto/envelope.d.ts.map +1 -0
- package/dist/crypto/envelope.js +129 -0
- package/dist/crypto/fieldKeys.d.ts +41 -0
- package/dist/crypto/fieldKeys.d.ts.map +1 -0
- package/dist/crypto/fieldKeys.js +56 -0
- package/dist/data/codecs.d.ts +23 -10
- package/dist/data/codecs.d.ts.map +1 -1
- package/dist/data/codecs.js +50 -36
- package/dist/error/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +24 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +48 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +16 -0
- package/dist/error/pithyError.d.ts +18 -7
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/logger.d.ts +12 -5
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/record.d.ts +14 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/privacy/blindIndex.d.ts +27 -0
- package/dist/privacy/blindIndex.d.ts.map +1 -0
- package/dist/privacy/blindIndex.js +57 -0
- package/dist/privacy/classes.d.ts +142 -0
- package/dist/privacy/classes.d.ts.map +1 -0
- package/dist/privacy/classes.js +209 -0
- package/dist/privacy/classify.d.ts +336 -0
- package/dist/privacy/classify.d.ts.map +1 -0
- package/dist/privacy/classify.js +1024 -0
- package/dist/privacy/encrypted.d.ts +210 -0
- package/dist/privacy/encrypted.d.ts.map +1 -0
- package/dist/privacy/encrypted.js +350 -0
- package/dist/privacy/encryptedColumns.d.ts +64 -0
- package/dist/privacy/encryptedColumns.d.ts.map +1 -0
- package/dist/privacy/encryptedColumns.js +134 -0
- package/dist/privacy/fieldRotation.d.ts +34 -0
- package/dist/privacy/fieldRotation.d.ts.map +1 -0
- package/dist/privacy/fieldRotation.js +186 -0
- package/dist/privacy/inventory.d.ts +12 -0
- package/dist/privacy/inventory.d.ts.map +1 -0
- package/dist/privacy/inventory.js +154 -0
- package/dist/privacy/posture.d.ts +114 -0
- package/dist/privacy/posture.d.ts.map +1 -0
- package/dist/privacy/posture.js +136 -0
- package/dist/privacy/redact.d.ts +7 -0
- package/dist/privacy/redact.d.ts.map +1 -0
- package/dist/privacy/redact.js +404 -0
- package/dist/privacy/report.d.ts +58 -0
- package/dist/privacy/report.d.ts.map +1 -0
- package/dist/privacy/report.js +15 -0
- package/dist/privacy/retirement.d.ts +68 -0
- package/dist/privacy/retirement.d.ts.map +1 -0
- package/dist/privacy/retirement.js +72 -0
- package/dist/privacy/sealed.d.ts +87 -0
- package/dist/privacy/sealed.d.ts.map +1 -0
- package/dist/privacy/sealed.js +221 -0
- package/dist/privacy/types.d.ts +559 -0
- package/dist/privacy/types.d.ts.map +1 -0
- package/dist/privacy/types.js +2 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +1 -1
- package/src/audit/recorder.ts +10 -2
- package/src/capability/capability.ts +15 -0
- package/src/capability/purge.ts +124 -0
- package/src/cli/commandCatalog.generated.ts +59 -0
- package/src/cli/commandFacts.ts +1 -0
- package/src/controlPlane/capability.ts +11 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/handlers.ts +57 -4
- package/src/controlPlane/http/responses.ts +100 -0
- package/src/controlPlane/http/routes.ts +31 -1
- package/src/controlPlane/scope/scope.ts +14 -3
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +4 -0
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +42 -0
- package/src/error/pithyError.ts +21 -7
- package/src/index.ts +1 -0
- package/src/logger/logger.ts +12 -5
- package/src/privacy/blindIndex.ts +81 -0
- package/src/privacy/classes.ts +277 -0
- package/src/privacy/classify.ts +1447 -0
- package/src/privacy/encrypted.ts +513 -0
- package/src/privacy/encryptedColumns.ts +185 -0
- package/src/privacy/fieldRotation.ts +234 -0
- package/src/privacy/inventory.ts +147 -0
- package/src/privacy/posture.ts +205 -0
- package/src/privacy/redact.ts +423 -0
- package/src/privacy/report.ts +64 -0
- package/src/privacy/retirement.ts +102 -0
- package/src/privacy/sealed.ts +323 -0
- package/src/privacy/types.ts +804 -0
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { ValidationError } from "../error/pithyError.js";
|
|
4
|
+
import { BlindIndexNormalizerName, encryptedLayout, encryptedSpecOf, hasEncryptedFields, sqlIdentifier } from "./encrypted.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region src/privacy/encryptedColumns.ts
|
|
7
|
+
/**
|
|
8
|
+
* **Every encrypted column a project holds, derived from its schemas** (#390).
|
|
9
|
+
*
|
|
10
|
+
* The rotator runs in the secrets manager, which never reads `pithy.config.ts`. So it learns the encrypted columns
|
|
11
|
+
* the way it learns `rotateEveryDays`: `pithy secrets provision` and `pithy deploy` stamp it with them, and this is
|
|
12
|
+
* the derivation they stamp. It reads the same markers #132's inventory walks, so a field marked `encrypted()`
|
|
13
|
+
* tomorrow joins rotation on the next deploy, with no registration step.
|
|
14
|
+
*
|
|
15
|
+
* It is also how an app Worker reports its own retirement verdict: the same walk over its own capabilities, so what
|
|
16
|
+
* the dashboard reads and what the rotator acts on are one derivation.
|
|
17
|
+
*
|
|
18
|
+
* ## D1 only, and what that excludes
|
|
19
|
+
*
|
|
20
|
+
* A KV `list()` returns keys, never values, so a KV envelope cannot be found by key version: no stale query, no
|
|
21
|
+
* compare-and-swap and no count. An encrypted field in a KV store is refused at boot (`refuseEncryptedKvStores`), and
|
|
22
|
+
* any this derivation still meets is reported as **unrotatable** rather than stamped, because nothing can ever prove
|
|
23
|
+
* those values re-sealed. An unrotatable field blocks retirement.
|
|
24
|
+
*/
|
|
25
|
+
/** A SQL identifier as `CamelCasePlugin` writes one: what a stamp may name, so nothing else reaches the rotator's SQL. */
|
|
26
|
+
const SqlName = z.string().regex(/^[a-z_][a-z0-9_]*$/, "A SQL identifier: lowercase letters, digits and underscores.");
|
|
27
|
+
/** A JSON path of object keys, the only kind an encrypted field is reached by. */
|
|
28
|
+
const JsonPath = z.string().regex(/^\$(\.[A-Za-z_$][A-Za-z0-9_$]*)+$/, "A JSON path of object keys, such as `$.phone`.");
|
|
29
|
+
/** One encrypted column, as the rotator is stamped with it. */
|
|
30
|
+
const EncryptedColumn = z.object({
|
|
31
|
+
name: z.string().describe("The table's declared name: what the AAD binds, never the SQL table name."),
|
|
32
|
+
path: z.string().describe("The field path the AAD binds: `diagnosis`, or `profile.phone` inside a JSON column."),
|
|
33
|
+
table: SqlName.describe("The SQL table, as `CamelCasePlugin` writes it."),
|
|
34
|
+
column: SqlName.describe("The SQL column holding the envelope, or the JSON column the field sits inside."),
|
|
35
|
+
jsonPath: JsonPath.nullable().describe("The JSON path of a field inside a JSON column, such as `$.phone`. Null for a field in its own column."),
|
|
36
|
+
primaryKey: SqlName.describe("The SQL column of the table's app-assigned primary key."),
|
|
37
|
+
binding: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "A binding name.").describe("The D1 binding the table lives in. Workers that share a binding share the database."),
|
|
38
|
+
index: z.object({
|
|
39
|
+
column: SqlName.describe("The SQL column of the field's blind index."),
|
|
40
|
+
normalizer: BlindIndexNormalizerName.describe("The normalizer the index hashes with.")
|
|
41
|
+
}).nullable().describe("The field's blind index, or null for a field with none.")
|
|
42
|
+
}).describe("One encrypted column the rotator re-seals: where it is, and what its ciphertexts bind.");
|
|
43
|
+
/** What the rotator is stamped with: the columns, and what the derivation could not cover. */
|
|
44
|
+
const EncryptedColumnStamp = z.object({
|
|
45
|
+
columns: z.array(EncryptedColumn).describe("Every encrypted D1 column the project's schemas declare."),
|
|
46
|
+
unread: z.array(z.string()).describe("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."),
|
|
47
|
+
unrotatable: z.array(z.string()).describe("Every encrypted field whose storage is not D1, as `<capability>:<container>.<schema>:<path>`. Each blocks retirement.")
|
|
48
|
+
}).describe("The encrypted columns a project holds, derived from its schemas, as the rotator is stamped with them.");
|
|
49
|
+
/** A SQL JSON path from object keys. Each key is an identifier, which `encryptedLayout` has already required. */
|
|
50
|
+
function jsonPathOf(segments) {
|
|
51
|
+
return `$.${segments.join(".")}`;
|
|
52
|
+
}
|
|
53
|
+
/** Where an encrypted field sits in one KV store's parts, for the unrotatable list. */
|
|
54
|
+
function kvEncryptedPaths(store) {
|
|
55
|
+
const paths = [];
|
|
56
|
+
for (const [part, schema] of [
|
|
57
|
+
["key", store.key],
|
|
58
|
+
["value", store.value],
|
|
59
|
+
["metadata", store.metadata]
|
|
60
|
+
]) {
|
|
61
|
+
if (!schema || !hasEncryptedFields(schema)) continue;
|
|
62
|
+
if (encryptedSpecOf(schema)) {
|
|
63
|
+
paths.push(part);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const located = encryptedLayout(schema).fields.map((field) => `${part}.${field.path}`);
|
|
67
|
+
paths.push(...located.length > 0 ? located : [part]);
|
|
68
|
+
}
|
|
69
|
+
return paths;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Derive the encrypted columns of a set of composed capabilities. **Refuses** a D1 table with an encrypted field and
|
|
73
|
+
* no `encryptedTable()` declaration, and a table whose layout cannot be sealed or rotated, naming the capability and
|
|
74
|
+
* the table: a column the rotator cannot re-seal is a version that can never be retired.
|
|
75
|
+
*/
|
|
76
|
+
function deriveEncryptedColumns(capabilities) {
|
|
77
|
+
const columns = [];
|
|
78
|
+
const unrotatable = [];
|
|
79
|
+
for (const capability of capabilities) {
|
|
80
|
+
for (const [container, database] of Object.entries(capability.databases ?? {})) for (const [table, schema] of Object.entries(database.tables)) {
|
|
81
|
+
const layout = encryptedLayout(schema);
|
|
82
|
+
if (layout.fields.length === 0 && layout.problems.length === 0 && !hasEncryptedFields(schema)) continue;
|
|
83
|
+
const where = `${capability.name}'s ${container}.${table} table`;
|
|
84
|
+
if (layout.declaration === null) throw new ValidationError({
|
|
85
|
+
message: `${where} holds an encrypted field and declares no table.`,
|
|
86
|
+
action: "Wrap its schema in encryptedTable(schema, { name, key }): the rotator re-seals by that name and key."
|
|
87
|
+
});
|
|
88
|
+
if (layout.problems.length > 0) throw new ValidationError({
|
|
89
|
+
message: `${where} cannot be rotated as declared. ${layout.problems.join(" ")}`,
|
|
90
|
+
action: "Fix the schema's encrypted fields and their blindIndex columns."
|
|
91
|
+
});
|
|
92
|
+
for (const field of layout.fields) {
|
|
93
|
+
const [head, ...rest] = field.segments;
|
|
94
|
+
columns.push({
|
|
95
|
+
name: layout.declaration.name,
|
|
96
|
+
path: field.path,
|
|
97
|
+
table: sqlIdentifier(table),
|
|
98
|
+
column: sqlIdentifier(head),
|
|
99
|
+
jsonPath: rest.length > 0 ? jsonPathOf(rest) : null,
|
|
100
|
+
primaryKey: sqlIdentifier(layout.declaration.key),
|
|
101
|
+
binding: database.binding,
|
|
102
|
+
index: field.indexField && field.spec.index ? {
|
|
103
|
+
column: sqlIdentifier(field.indexField),
|
|
104
|
+
normalizer: field.spec.index
|
|
105
|
+
} : null
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const [container, namespace] of Object.entries(capability.kvNamespaces ?? {})) for (const [name, store] of Object.entries(namespace.stores)) for (const path of kvEncryptedPaths(store)) unrotatable.push(`${capability.name}:${container}.${name}:${path}`);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
columns,
|
|
113
|
+
unread: [],
|
|
114
|
+
unrotatable
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* **Refuse a KV store that holds an encrypted field, at boot** (#390). A KV envelope can never be found by key
|
|
119
|
+
* version, so it could never be rotated, and encryption that cannot rotate reads as the feature and is not. The
|
|
120
|
+
* refusal names the schema, the field and D1, and it reads through `.optional()`, `.nullable()`, `.default()` and
|
|
121
|
+
* nesting, by the wrapper walk the markers use. `@pithy-sh/vector`'s filterable check is the precedent.
|
|
122
|
+
*/
|
|
123
|
+
function refuseEncryptedKvStores(capabilities) {
|
|
124
|
+
for (const capability of capabilities) for (const [container, namespace] of Object.entries(capability.kvNamespaces ?? {})) for (const [name, store] of Object.entries(namespace.stores)) {
|
|
125
|
+
const [path] = kvEncryptedPaths(store);
|
|
126
|
+
if (path === void 0) continue;
|
|
127
|
+
throw new ValidationError({
|
|
128
|
+
message: `${capability.name}'s KV store ${container}.${name} holds an encrypted field at "${path}". Encrypted fields rest in D1.`,
|
|
129
|
+
action: "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."
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
export { EncryptedColumn, EncryptedColumnStamp, deriveEncryptedColumns, kvEncryptedPaths, refuseEncryptedKvStores };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
4
|
+
import type { Capability } from "../capability/capability";
|
|
5
|
+
import { type EncryptionConfig } from "../crypto/envelope";
|
|
6
|
+
import { type EncryptedColumn } from "./encryptedColumns";
|
|
7
|
+
import { type RetirementVerdict } from "./retirement";
|
|
8
|
+
/** How many rows of one column still hold an envelope or an index under any of `versions`. */
|
|
9
|
+
export declare function countStale(d1: D1Database, column: EncryptedColumn, versions: readonly number[]): Promise<number>;
|
|
10
|
+
/** One batch's outcome. */
|
|
11
|
+
export interface ResealResult {
|
|
12
|
+
/** Rows re-sealed and re-indexed under the current version. */
|
|
13
|
+
resealed: number;
|
|
14
|
+
/** Rows the application re-sealed between the read and the write: left alone. */
|
|
15
|
+
skipped: number;
|
|
16
|
+
/** Rows that would not open. Never described: what failed to open is ciphertext. */
|
|
17
|
+
failed: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Re-seal one batch of a column's rows still under `version`, onto `config`'s current version, re-indexing each in
|
|
21
|
+
* the same write. Every write is a compare-and-swap on the version read, so a row the application re-sealed in
|
|
22
|
+
* between is skipped. A row that will not open is counted and left, never described.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resealBatch(d1: D1Database, column: EncryptedColumn, config: EncryptionConfig, version: number, limit: number): Promise<ResealResult>;
|
|
25
|
+
/**
|
|
26
|
+
* **This Worker's own retirement verdict**, for the manifest's posture block: the guard over its own capabilities'
|
|
27
|
+
* encrypted columns, read through its own D1 bindings and its own `PITHY_FIELD_KEYS`. The same derivation and the
|
|
28
|
+
* same counts the rotator acts on, so what the dashboard shows is what the rotator would decide.
|
|
29
|
+
*
|
|
30
|
+
* **It never throws.** A derivation that refuses, a key set that will not resolve, a binding the Worker lacks and a
|
|
31
|
+
* count that fails are each incomplete coverage: the guard could not read something, so it is not clean.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readRetirement(env: Record<string, unknown>, capabilities: readonly Capability[]): Promise<RetirementVerdict>;
|
|
34
|
+
//# sourceMappingURL=fieldRotation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fieldRotation.d.ts","sourceRoot":"","sources":["../../src/privacy/fieldRotation.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAmC,KAAK,gBAAgB,EAAgB,MAAM,oBAAoB,CAAC;AAI1G,OAAO,EAA0B,KAAK,eAAe,EAA6B,MAAM,oBAAoB,CAAC;AAC7G,OAAO,EAAE,KAAK,iBAAiB,EAAkC,MAAM,cAAc,CAAC;AAmDtF,8FAA8F;AAC9F,wBAAsB,UAAU,CAC9B,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,eAAe,EACvB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,OAAO,CAAC,MAAM,CAAC,CAOjB;AASD,2BAA2B;AAC3B,MAAM,WAAW,YAAY;IAC3B,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,OAAO,EAAE,MAAM,CAAC;IAChB,oFAAoF;IACpF,MAAM,EAAE,MAAM,CAAC;CAChB;AAkBD;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,eAAe,EACvB,MAAM,EAAE,gBAAgB,EACxB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,YAAY,CAAC,CA6DvB;AAOD;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,YAAY,EAAE,SAAS,UAAU,EAAE,GAClC,OAAO,CAAC,iBAAiB,CAAC,CAmC5B"}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { InternalError } from "../error/pithyError.js";
|
|
4
|
+
import { EncryptedEnvelope, decryptValue, encryptValue } from "../crypto/envelope.js";
|
|
5
|
+
import { FIELD_KEYS_BINDING, liveVersions, resolveFieldKeys } from "../crypto/fieldKeys.js";
|
|
6
|
+
import { blindIndexValue } from "./blindIndex.js";
|
|
7
|
+
import { deriveEncryptedColumns } from "./encryptedColumns.js";
|
|
8
|
+
import { retirementVerdict, versionsDue } from "./retirement.js";
|
|
9
|
+
import { fieldContext } from "./sealed.js";
|
|
10
|
+
//#region src/privacy/fieldRotation.ts
|
|
11
|
+
/**
|
|
12
|
+
* **Re-sealing an encrypted column in place, and the guard on retiring a version** (#390).
|
|
13
|
+
*
|
|
14
|
+
* Rotation is lazy: every write seals under the current version, so a row moves off an old key the next time it is
|
|
15
|
+
* written, and nothing re-seals between rotations. At each rotation the manager's rotator adds a version, re-seals the
|
|
16
|
+
* rows still under the version **leaving the window** — and only those — and retires it once the guard reads clean.
|
|
17
|
+
* The SQL that finds those rows, re-seals one, and counts what is left lives here, beside the derivation it reads, so
|
|
18
|
+
* the rotator and the app Worker's own retirement report count one way.
|
|
19
|
+
*
|
|
20
|
+
* - **A stale row is found by `json_extract`, not by a scan of plaintext.** An envelope carries its `keyVersion` in
|
|
21
|
+
* the clear, and a blind index its version prefix, so the count and the batch are one indexed-or-not `WHERE`.
|
|
22
|
+
* - **Each re-seal is a compare-and-swap** on the version it read: `UPDATE … WHERE <pk> = ? AND json_extract(col,
|
|
23
|
+
* '$.keyVersion') = <version read>`. A row the application re-sealed in between is skipped, never overwritten.
|
|
24
|
+
* - **A field inside a JSON column** is an envelope JSON string at its JSON path, so its version is
|
|
25
|
+
* `json_extract(json_extract(col, '$.path'), '$.keyVersion')` and its re-seal is a `json_set` of that path.
|
|
26
|
+
*/
|
|
27
|
+
/** Quote a SQL identifier. Every identifier here comes from a validated stamp; this keeps it an identifier. */
|
|
28
|
+
function quote(identifier) {
|
|
29
|
+
return `"${identifier.replace(/"/g, "\"\"")}"`;
|
|
30
|
+
}
|
|
31
|
+
/** The SQL that reads the key version of a column's envelope. */
|
|
32
|
+
function envelopeVersion(column) {
|
|
33
|
+
const col = quote(column.column);
|
|
34
|
+
return column.jsonPath === null ? `json_extract(${col}, '$.keyVersion')` : `json_extract(json_extract(${col}, '${column.jsonPath}'), '$.keyVersion')`;
|
|
35
|
+
}
|
|
36
|
+
/** The SQL that reads the version prefix of a blind-index value. */
|
|
37
|
+
function indexVersion(indexColumn) {
|
|
38
|
+
const col = quote(indexColumn);
|
|
39
|
+
return `CAST(substr(${col}, 1, instr(${col}, ':') - 1) AS INTEGER)`;
|
|
40
|
+
}
|
|
41
|
+
/** The SQL predicate a row is stale under: its envelope, or its index, carries one of `count` versions. */
|
|
42
|
+
function stalePredicate(column, count) {
|
|
43
|
+
const marks = Array.from({ length: count }, () => "?").join(", ");
|
|
44
|
+
const envelope = `${envelopeVersion(column)} IN (${marks})`;
|
|
45
|
+
return column.index ? `(${envelope} OR ${indexVersion(column.index.column)} IN (${marks}))` : envelope;
|
|
46
|
+
}
|
|
47
|
+
/** The bindings for {@link stalePredicate}. */
|
|
48
|
+
function staleBindings(column, versions) {
|
|
49
|
+
return column.index ? [...versions, ...versions] : [...versions];
|
|
50
|
+
}
|
|
51
|
+
/** How many rows of one column still hold an envelope or an index under any of `versions`. */
|
|
52
|
+
async function countStale(d1, column, versions) {
|
|
53
|
+
if (versions.length === 0) return 0;
|
|
54
|
+
const row = await d1.prepare(`SELECT COUNT(*) AS count FROM ${quote(column.table)} WHERE ${stalePredicate(column, versions.length)}`).bind(...staleBindings(column, versions)).first();
|
|
55
|
+
return Number(row?.count ?? 0);
|
|
56
|
+
}
|
|
57
|
+
/** The envelope a stored column holds at the field's path, or `null` where it holds none. */
|
|
58
|
+
function envelopeAt(column, stored) {
|
|
59
|
+
if (stored === null) return null;
|
|
60
|
+
let value = JSON.parse(stored);
|
|
61
|
+
if (column.jsonPath !== null) {
|
|
62
|
+
for (const segment of column.jsonPath.slice(2).split(".")) {
|
|
63
|
+
if (typeof value !== "object" || value === null) return null;
|
|
64
|
+
value = value[segment];
|
|
65
|
+
}
|
|
66
|
+
if (value === void 0 || value === null) return null;
|
|
67
|
+
value = typeof value === "string" ? JSON.parse(value) : value;
|
|
68
|
+
}
|
|
69
|
+
if (value === null) return null;
|
|
70
|
+
return EncryptedEnvelope.parse(value);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Re-seal one batch of a column's rows still under `version`, onto `config`'s current version, re-indexing each in
|
|
74
|
+
* the same write. Every write is a compare-and-swap on the version read, so a row the application re-sealed in
|
|
75
|
+
* between is skipped. A row that will not open is counted and left, never described.
|
|
76
|
+
*/
|
|
77
|
+
async function resealBatch(d1, column, config, version, limit) {
|
|
78
|
+
const result = {
|
|
79
|
+
resealed: 0,
|
|
80
|
+
skipped: 0,
|
|
81
|
+
failed: 0
|
|
82
|
+
};
|
|
83
|
+
const current = Number(config.currentVersion);
|
|
84
|
+
const index = column.index ? `, ${quote(column.index.column)} AS "index"` : `, NULL AS "index"`;
|
|
85
|
+
const { results } = await d1.prepare(`SELECT ${quote(column.primaryKey)} AS pk, ${quote(column.column)} AS stored${index} FROM ${quote(column.table)} WHERE ${stalePredicate(column, 1)} LIMIT ?`).bind(...staleBindings(column, [version]), limit).all();
|
|
86
|
+
for (const row of results) {
|
|
87
|
+
let envelope;
|
|
88
|
+
let plaintext = null;
|
|
89
|
+
let sealed = null;
|
|
90
|
+
let indexValue = row.index;
|
|
91
|
+
try {
|
|
92
|
+
envelope = envelopeAt(column, row.stored);
|
|
93
|
+
const context = fieldContext(column.name, column.path, String(row.pk));
|
|
94
|
+
if (envelope !== null) {
|
|
95
|
+
plaintext = await decryptValue(config, context, envelope, { keys: FIELD_KEYS_BINDING });
|
|
96
|
+
sealed = await encryptValue(config, context, plaintext, { keys: FIELD_KEYS_BINDING });
|
|
97
|
+
}
|
|
98
|
+
if (column.index && plaintext !== null) {
|
|
99
|
+
const value = JSON.parse(plaintext);
|
|
100
|
+
if (typeof value !== "string") throw new InternalError({ detail: "an indexed field opened to a non-string" });
|
|
101
|
+
indexValue = await blindIndexValue(config, current, column.index.normalizer, value);
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
result.failed += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const readVersion = envelope?.keyVersion ?? null;
|
|
108
|
+
const set = [];
|
|
109
|
+
const values = [];
|
|
110
|
+
if (sealed !== null) {
|
|
111
|
+
if (column.jsonPath === null) set.push(`${quote(column.column)} = ?`);
|
|
112
|
+
else set.push(`${quote(column.column)} = json_set(${quote(column.column)}, '${column.jsonPath}', ?)`);
|
|
113
|
+
values.push(JSON.stringify(sealed));
|
|
114
|
+
}
|
|
115
|
+
if (column.index) {
|
|
116
|
+
set.push(`${quote(column.index.column)} = ?`);
|
|
117
|
+
values.push(indexValue);
|
|
118
|
+
}
|
|
119
|
+
if (set.length === 0) {
|
|
120
|
+
result.failed += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (((await d1.prepare(`UPDATE ${quote(column.table)} SET ${set.join(", ")} WHERE ${quote(column.primaryKey)} = ? AND ${envelopeVersion(column)} IS ?${column.index ? ` AND ${quote(column.index.column)} IS ?` : ""}`).bind(...values, row.pk, readVersion, ...column.index ? [row.index] : []).run()).meta.changes ?? 0) > 0) result.resealed += 1;
|
|
124
|
+
else result.skipped += 1;
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
/** Whether an `env` slot is a D1 binding. */
|
|
129
|
+
function isD1(value) {
|
|
130
|
+
return typeof value === "object" && value !== null && typeof value.prepare === "function";
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* **This Worker's own retirement verdict**, for the manifest's posture block: the guard over its own capabilities'
|
|
134
|
+
* encrypted columns, read through its own D1 bindings and its own `PITHY_FIELD_KEYS`. The same derivation and the
|
|
135
|
+
* same counts the rotator acts on, so what the dashboard shows is what the rotator would decide.
|
|
136
|
+
*
|
|
137
|
+
* **It never throws.** A derivation that refuses, a key set that will not resolve, a binding the Worker lacks and a
|
|
138
|
+
* count that fails are each incomplete coverage: the guard could not read something, so it is not clean.
|
|
139
|
+
*/
|
|
140
|
+
async function readRetirement(env, capabilities) {
|
|
141
|
+
let stamp;
|
|
142
|
+
try {
|
|
143
|
+
stamp = deriveEncryptedColumns(capabilities);
|
|
144
|
+
} catch {
|
|
145
|
+
return retirementVerdict({
|
|
146
|
+
versions: [],
|
|
147
|
+
stale: 0,
|
|
148
|
+
coverageComplete: false,
|
|
149
|
+
unrotatable: 0
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const keys = env[FIELD_KEYS_BINDING];
|
|
153
|
+
let coverageComplete = true;
|
|
154
|
+
let versions = [];
|
|
155
|
+
let current;
|
|
156
|
+
if (stamp.columns.length > 0 || keys !== void 0) try {
|
|
157
|
+
const config = await resolveFieldKeys(keys);
|
|
158
|
+
versions = liveVersions(config);
|
|
159
|
+
current = Number(config.currentVersion);
|
|
160
|
+
} catch {
|
|
161
|
+
coverageComplete = false;
|
|
162
|
+
}
|
|
163
|
+
const due = current === void 0 ? [] : versionsDue(versions, current);
|
|
164
|
+
let stale = 0;
|
|
165
|
+
for (const column of stamp.columns) {
|
|
166
|
+
const d1 = env[column.binding];
|
|
167
|
+
if (!isD1(d1)) {
|
|
168
|
+
coverageComplete = false;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
stale += await countStale(d1, column, due);
|
|
173
|
+
} catch {
|
|
174
|
+
coverageComplete = false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return retirementVerdict({
|
|
178
|
+
versions,
|
|
179
|
+
current,
|
|
180
|
+
stale,
|
|
181
|
+
coverageComplete,
|
|
182
|
+
unrotatable: stamp.unrotatable.length
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
export { countStale, readRetirement, resealBatch };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { Capability } from "../capability/capability";
|
|
4
|
+
import { type PrivacyInventory } from "./report";
|
|
5
|
+
/** Walk every composed capability's D1 tables and KV stores. See the module note for exactly what is covered. */
|
|
6
|
+
export declare function privacyInventory(capabilities: readonly Capability[]): PrivacyInventory;
|
|
7
|
+
/**
|
|
8
|
+
* The inventory, walked on first read and cached for the isolate. `capabilities` is a thunk because the
|
|
9
|
+
* composed set is only known once assembly has run, and reading it is itself deferred to the first call.
|
|
10
|
+
*/
|
|
11
|
+
export declare function lazyPrivacyInventory(capabilities: () => readonly Capability[]): () => PrivacyInventory;
|
|
12
|
+
//# sourceMappingURL=inventory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inventory.d.ts","sourceRoot":"","sources":["../../src/privacy/inventory.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAK3D,OAAO,EAGL,KAAK,gBAAgB,EAEtB,MAAM,UAAU,CAAC;AAgFlB,iHAAiH;AACjH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,gBAAgB,CAuCtF;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,SAAS,UAAU,EAAE,GAAG,MAAM,gBAAgB,CAMtG"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { BUILT_IN_PRIVACY_CLASSES } from "./classes.js";
|
|
4
|
+
import { classifyPrivacy } from "./classify.js";
|
|
5
|
+
import { encryptedLayout } from "./encrypted.js";
|
|
6
|
+
import { kvEncryptedPaths } from "./encryptedColumns.js";
|
|
7
|
+
import { PRIVACY_SOURCES } from "./report.js";
|
|
8
|
+
//#region src/privacy/inventory.ts
|
|
9
|
+
/**
|
|
10
|
+
* **What this Worker holds, derived from the code that is running** (#132).
|
|
11
|
+
*
|
|
12
|
+
* The inventory is computed at runtime from the composed capabilities' storage schemas — the Zod objects that
|
|
13
|
+
* *are* the table definitions, already in the deployed bundle — not stamped at build. A stamped artifact is
|
|
14
|
+
* skippable (a raw `wrangler deploy` ships the last stamp) and a drifted one is worse than none, because a
|
|
15
|
+
* dashboard acts on it. A derivation cannot be skipped, cannot be edited out of the adopter's repository, and
|
|
16
|
+
* cannot disagree with the code: it is the code. Unmark a `phi()` field and that field leaves the report on the
|
|
17
|
+
* next deploy, with no build step, generated file or person in between.
|
|
18
|
+
*
|
|
19
|
+
* ## What it walks
|
|
20
|
+
*
|
|
21
|
+
* {@link PRIVACY_SOURCES}, and nothing else: every composed capability's **D1 tables**, and its **KV stores** —
|
|
22
|
+
* the key segments, the value and the metadata, since a KV entry stores all three. That is where persisted data
|
|
23
|
+
* lives and what an auditor asks about. R2 objects, Vectorize metadata, Durable Object storage, email templates
|
|
24
|
+
* and request bodies are not walked, and the report names its sources so that is a stated fact rather than an
|
|
25
|
+
* implied guarantee.
|
|
26
|
+
*
|
|
27
|
+
* ## What a path looks like
|
|
28
|
+
*
|
|
29
|
+
* A D1 field is its column path: `email`, `address.line1`, or `profile.phone` for a marked field inside a JSON column,
|
|
30
|
+
* which `redact` reads field by field. A KV field leads with the part of the entry it is in: `key.email`, `value.token`,
|
|
31
|
+
* `metadata.ip`, `value[].email` for an array value — or `value` alone when the whole value is marked. It is the path
|
|
32
|
+
* `pithy privacy scan` writes for the same field.
|
|
33
|
+
*
|
|
34
|
+
* ## The per-field record
|
|
35
|
+
*
|
|
36
|
+
* `{ path, class }`, from `classifyPrivacy`, with `truncated: true` where a read ran out of its budget, or was cut at
|
|
37
|
+
* the depth limit on a chain of schemas not yet read, and it assumed every class, and `sameAs` on a reference: a
|
|
38
|
+
* position written out under another path once the list reached its cap on paths, listed once per class marked
|
|
39
|
+
* beneath it. The counts count each record, a reference included. Since #390 it also carries `encryptedAtRest`
|
|
40
|
+
* beside `class`, so "which of your PHI fields are encrypted" is one more derivation rather than a second thing to
|
|
41
|
+
* maintain, and it is absent where the core cannot say.
|
|
42
|
+
*
|
|
43
|
+
* ## What it costs
|
|
44
|
+
*
|
|
45
|
+
* Nothing until it is read. {@link lazyPrivacyInventory} walks on first read and caches for the isolate, so a
|
|
46
|
+
* Worker whose control plane is never called never walks.
|
|
47
|
+
*/
|
|
48
|
+
/** The marked fields of a KV store: its key segments, value and metadata, each under its own prefix. */
|
|
49
|
+
function kvFields(store) {
|
|
50
|
+
return [
|
|
51
|
+
["key", store.key],
|
|
52
|
+
["value", store.value],
|
|
53
|
+
["metadata", store.metadata]
|
|
54
|
+
].flatMap(([part, schema]) => {
|
|
55
|
+
if (!schema) return [];
|
|
56
|
+
const under = (path) => path === "" || path.startsWith("[") ? `${part}${path}` : `${part}.${path}`;
|
|
57
|
+
return classifyPrivacy(schema).map((field) => field.sameAs === void 0 ? {
|
|
58
|
+
...field,
|
|
59
|
+
path: under(field.path)
|
|
60
|
+
} : {
|
|
61
|
+
...field,
|
|
62
|
+
path: under(field.path),
|
|
63
|
+
sameAs: under(field.sameAs)
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A field record with whether it rests encrypted (#390). **Left out where the core cannot say**: a class assumed past a
|
|
69
|
+
* budget or a cut, and a reference, which stands for paths it never wrote out. Never defaulted to `false`.
|
|
70
|
+
*/
|
|
71
|
+
function withEncryption(field, encrypted, unrotatable) {
|
|
72
|
+
if (field.truncated || field.sameAs !== void 0) return field;
|
|
73
|
+
const atRest = encrypted.has(field.path);
|
|
74
|
+
return atRest && unrotatable ? {
|
|
75
|
+
...field,
|
|
76
|
+
encryptedAtRest: true,
|
|
77
|
+
unrotatable: true
|
|
78
|
+
} : {
|
|
79
|
+
...field,
|
|
80
|
+
encryptedAtRest: atRest
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Classes in the order every report lists them: the built-ins first, then each other class by name. */
|
|
84
|
+
function ordered(classes) {
|
|
85
|
+
const builtIn = BUILT_IN_PRIVACY_CLASSES;
|
|
86
|
+
const all = [...new Set(classes)];
|
|
87
|
+
return [...builtIn.filter((name) => all.includes(name)), ...all.filter((name) => !builtIn.includes(name)).sort()];
|
|
88
|
+
}
|
|
89
|
+
/** Walk every composed capability's D1 tables and KV stores. See the module note for exactly what is covered. */
|
|
90
|
+
function privacyInventory(capabilities) {
|
|
91
|
+
const schemas = [];
|
|
92
|
+
let schemasWalked = 0;
|
|
93
|
+
for (const capability of capabilities) {
|
|
94
|
+
for (const [container, database] of Object.entries(capability.databases ?? {})) for (const [name, table] of Object.entries(database.tables)) {
|
|
95
|
+
schemasWalked += 1;
|
|
96
|
+
const paths = new Set(encryptedLayout(table).fields.map((field) => field.path));
|
|
97
|
+
const fields = classifyPrivacy(table).map((field) => withEncryption(field, paths, false));
|
|
98
|
+
const encrypted = [...paths].map((path) => ({ path }));
|
|
99
|
+
if (fields.length > 0 || encrypted.length > 0) schemas.push({
|
|
100
|
+
capability: capability.name,
|
|
101
|
+
storage: "d1",
|
|
102
|
+
container,
|
|
103
|
+
name,
|
|
104
|
+
fields,
|
|
105
|
+
encrypted
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
for (const [container, namespace] of Object.entries(capability.kvNamespaces ?? {})) for (const [name, store] of Object.entries(namespace.stores)) {
|
|
109
|
+
schemasWalked += 1;
|
|
110
|
+
const paths = new Set(kvEncryptedPaths(store));
|
|
111
|
+
const fields = kvFields(store).map((field) => withEncryption(field, paths, true));
|
|
112
|
+
const encrypted = [...paths].map((path) => ({
|
|
113
|
+
path,
|
|
114
|
+
unrotatable: true
|
|
115
|
+
}));
|
|
116
|
+
if (fields.length > 0 || encrypted.length > 0) schemas.push({
|
|
117
|
+
capability: capability.name,
|
|
118
|
+
storage: "kv",
|
|
119
|
+
container,
|
|
120
|
+
name,
|
|
121
|
+
fields,
|
|
122
|
+
encrypted
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const tally = /* @__PURE__ */ new Map();
|
|
127
|
+
for (const schema of schemas) for (const field of schema.fields) tally.set(field.class, (tally.get(field.class) ?? 0) + 1);
|
|
128
|
+
const observed = ordered(tally.keys());
|
|
129
|
+
const counts = Object.fromEntries(observed.map((name) => [name, tally.get(name) ?? 0]));
|
|
130
|
+
const encryptedFields = schemas.reduce((sum, schema) => sum + schema.encrypted.length, 0);
|
|
131
|
+
const unrotatable = schemas.reduce((sum, schema) => sum + schema.encrypted.filter((field) => field.unrotatable).length, 0);
|
|
132
|
+
return {
|
|
133
|
+
sources: [...PRIVACY_SOURCES],
|
|
134
|
+
schemasWalked,
|
|
135
|
+
schemas,
|
|
136
|
+
counts,
|
|
137
|
+
observed,
|
|
138
|
+
encryptedFields,
|
|
139
|
+
unrotatable
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The inventory, walked on first read and cached for the isolate. `capabilities` is a thunk because the
|
|
144
|
+
* composed set is only known once assembly has run, and reading it is itself deferred to the first call.
|
|
145
|
+
*/
|
|
146
|
+
function lazyPrivacyInventory(capabilities) {
|
|
147
|
+
let cached;
|
|
148
|
+
return () => {
|
|
149
|
+
cached ??= privacyInventory(capabilities());
|
|
150
|
+
return cached;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
export { lazyPrivacyInventory, privacyInventory };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { type PrivacyInventory } from "./report";
|
|
5
|
+
/**
|
|
6
|
+
* **What a project declares about privacy, and what its running code holds — side by side** (#132).
|
|
7
|
+
*
|
|
8
|
+
* Two facts with two provenances, and the asymmetry between them is the useful part.
|
|
9
|
+
*
|
|
10
|
+
* - **Declared** is an **attestation**. The root `pithy.config.ts` names the classes a project handles
|
|
11
|
+
* (`privacy: { classes: ["pii", "phi"] }`), and `pithy deploy` writes them, with the console gate's verdict, into
|
|
12
|
+
* {@link PRIVACY_ATTESTATION_VAR} on every Worker it ships — the mechanism a Worker already learns its own name
|
|
13
|
+
* and project by. It is dated, and it describes the last deploy Pithy performed.
|
|
14
|
+
* - **Observed** is a **derivation** — the inventory, walked from the composed schemas inside the Worker. It
|
|
15
|
+
* cannot be edited and cannot be stale.
|
|
16
|
+
*
|
|
17
|
+
* An attestation that disagrees with what the running code holds is itself the finding, which is why both are
|
|
18
|
+
* reported and neither is an error Pithy raises. A project declaring `phi` with no PHI field marked is an
|
|
19
|
+
* unfinished migration; one marking `phi` it never declared is a posture change nobody escalated.
|
|
20
|
+
*
|
|
21
|
+
* ## How stale an attestation can be
|
|
22
|
+
*
|
|
23
|
+
* **Usually it is absent rather than stale.** `pithy deploy` passes the var on wrangler's argv (`--var`), and a
|
|
24
|
+
* plain `wrangler deploy` uploads only the vars its config names — wrangler sends `keep_bindings` for plain-text
|
|
25
|
+
* vars only under `keep_vars: true` (read in wrangler 4.151.0's deploy path) — so a deploy Pithy did not perform
|
|
26
|
+
* drops the attestation, and the posture reads `unattested`. Under `keep_vars: true` the var survives a raw
|
|
27
|
+
* deploy and describes an older build. The Cloudflare version id cannot pair the two, because it is assigned at
|
|
28
|
+
* upload, after the var is written; the manifest carries {@link PrivacyAttestation.attestedAt} beside the
|
|
29
|
+
* version's own creation time instead, and a version created long after its attestation is that case.
|
|
30
|
+
*/
|
|
31
|
+
/** The var `pithy deploy` writes the attestation into. JSON, parsed by {@link privacyAttestation}. */
|
|
32
|
+
export declare const PRIVACY_ATTESTATION_VAR = "PITHY_PRIVACY";
|
|
33
|
+
/**
|
|
34
|
+
* The console gate's verdict at the deploy that wrote the attestation. `off` never appears: `pithy deploy`
|
|
35
|
+
* refuses a project whose rule is off, so no attestation is ever written for one.
|
|
36
|
+
*/
|
|
37
|
+
export declare const ConsoleGateVerdict: z.ZodEnum<{
|
|
38
|
+
enforced: "enforced";
|
|
39
|
+
not_required: "not_required";
|
|
40
|
+
unverifiable: "unverifiable";
|
|
41
|
+
}>;
|
|
42
|
+
export type ConsoleGateVerdict = z.output<typeof ConsoleGateVerdict>;
|
|
43
|
+
/** What `pithy deploy` attests on a Worker it ships. */
|
|
44
|
+
export declare const PrivacyAttestation: z.ZodObject<{
|
|
45
|
+
classes: z.ZodArray<z.ZodString>;
|
|
46
|
+
consoleGate: z.ZodEnum<{
|
|
47
|
+
enforced: "enforced";
|
|
48
|
+
not_required: "not_required";
|
|
49
|
+
unverifiable: "unverifiable";
|
|
50
|
+
}>;
|
|
51
|
+
reason: z.ZodNullable<z.ZodString>;
|
|
52
|
+
attestedAt: z.ZodISODateTime;
|
|
53
|
+
}, z.core.$strip>;
|
|
54
|
+
export type PrivacyAttestation = z.output<typeof PrivacyAttestation>;
|
|
55
|
+
/**
|
|
56
|
+
* Read the attestation off a Worker's `env`. **Never throws**: an absent var, a value that is not JSON, and one
|
|
57
|
+
* that does not parse are all `null`, which the posture reports as `unattested`. A string is parsed; an object
|
|
58
|
+
* — what wrangler hands a JSON-typed var — is taken as it is.
|
|
59
|
+
*/
|
|
60
|
+
export declare function privacyAttestation(env: unknown): PrivacyAttestation | null;
|
|
61
|
+
/** The console gate as the manifest reports it: the attested verdict, or `unattested` when there is none. */
|
|
62
|
+
export declare const ConsoleGateState: z.ZodEnum<{
|
|
63
|
+
enforced: "enforced";
|
|
64
|
+
not_required: "not_required";
|
|
65
|
+
unattested: "unattested";
|
|
66
|
+
unverifiable: "unverifiable";
|
|
67
|
+
}>;
|
|
68
|
+
export type ConsoleGateState = z.output<typeof ConsoleGateState>;
|
|
69
|
+
/**
|
|
70
|
+
* The privacy block on `GET /control-plane/manifest`. Counts and classes only — no field names.
|
|
71
|
+
*
|
|
72
|
+
* **A project that declared nothing costs nothing.** When the attestation says no class is declared, the block
|
|
73
|
+
* says exactly that and the walk does not run: `observed`, `counts` and both deltas are null, and `sources` is
|
|
74
|
+
* empty, because nothing was looked at. That is the "behaves exactly as it does today" a project omitting the
|
|
75
|
+
* key is promised. Every other case walks — a declared project to compare against, and an **unattested** one
|
|
76
|
+
* because a Worker shipped around `pithy deploy` is precisely the one whose holdings someone needs to see.
|
|
77
|
+
*/
|
|
78
|
+
export declare const PrivacyPosture: z.ZodObject<{
|
|
79
|
+
declared: z.ZodNullable<z.ZodArray<z.ZodString>>;
|
|
80
|
+
observed: z.ZodNullable<z.ZodArray<z.ZodString>>;
|
|
81
|
+
counts: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
82
|
+
observedNotDeclared: z.ZodNullable<z.ZodArray<z.ZodString>>;
|
|
83
|
+
declaredNotObserved: z.ZodNullable<z.ZodArray<z.ZodString>>;
|
|
84
|
+
consoleGate: z.ZodEnum<{
|
|
85
|
+
enforced: "enforced";
|
|
86
|
+
not_required: "not_required";
|
|
87
|
+
unattested: "unattested";
|
|
88
|
+
unverifiable: "unverifiable";
|
|
89
|
+
}>;
|
|
90
|
+
consoleGateReason: z.ZodNullable<z.ZodString>;
|
|
91
|
+
attestedAt: z.ZodNullable<z.ZodISODateTime>;
|
|
92
|
+
sources: z.ZodArray<z.ZodEnum<{
|
|
93
|
+
d1: "d1";
|
|
94
|
+
kv: "kv";
|
|
95
|
+
}>>;
|
|
96
|
+
retirement: z.ZodOptional<z.ZodObject<{
|
|
97
|
+
blocked: z.ZodBoolean;
|
|
98
|
+
reasons: z.ZodArray<z.ZodEnum<{
|
|
99
|
+
"incomplete-coverage": "incomplete-coverage";
|
|
100
|
+
stale: "stale";
|
|
101
|
+
unrotatable: "unrotatable";
|
|
102
|
+
}>>;
|
|
103
|
+
liveVersions: z.ZodNumber;
|
|
104
|
+
}, z.core.$strip>>;
|
|
105
|
+
}, z.core.$strip>;
|
|
106
|
+
export type PrivacyPosture = z.output<typeof PrivacyPosture>;
|
|
107
|
+
/** Whether an attestation says, explicitly, that the project declares no class — the one case that walks nothing. */
|
|
108
|
+
export declare function declaresNothing(attestation: PrivacyAttestation | null): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* The posture: the attestation beside the inventory, and the delta both ways. `inventory` is a thunk, read only
|
|
111
|
+
* when something is declared or nothing is attested — see {@link PrivacyPosture}.
|
|
112
|
+
*/
|
|
113
|
+
export declare function privacyPosture(attestation: PrivacyAttestation | null, inventory: () => PrivacyInventory): PrivacyPosture;
|
|
114
|
+
//# sourceMappingURL=posture.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"posture.d.ts","sourceRoot":"","sources":["../../src/privacy/posture.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAmB,KAAK,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAGlE;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,sGAAsG;AACtG,eAAO,MAAM,uBAAuB,kBAAkB,CAAC;AAEvD;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;EAI5B,CAAC;AACJ,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAErE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB;;;;;;;;;iBAc5B,CAAC;AACJ,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAErE;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,kBAAkB,GAAG,IAAI,CAc1E;AAED,6GAA6G;AAC7G,eAAO,MAAM,gBAAgB;;;;;EAI1B,CAAC;AACJ,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEjE;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAsDxB,CAAC;AACJ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE7D,qHAAqH;AACrH,wBAAgB,eAAe,CAAC,WAAW,EAAE,kBAAkB,GAAG,IAAI,GAAG,OAAO,CAE/E;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,kBAAkB,GAAG,IAAI,EACtC,SAAS,EAAE,MAAM,gBAAgB,GAChC,cAAc,CA8BhB"}
|