@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
|
@@ -85,13 +85,24 @@ export const KEYS_ROTATE_SCOPE: ControlPlaneScope = "keys:rotate";
|
|
|
85
85
|
*/
|
|
86
86
|
export const MIGRATIONS_READ_SCOPE: ControlPlaneScope = "migrations:read";
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Read this Worker's full privacy inventory: every storage schema holding a marked field, each marked field's
|
|
90
|
+
* path, its class, and where it is stored (#132). Read-only — the one route behind it is a `GET`.
|
|
91
|
+
*
|
|
92
|
+
* **Its own scope rather than `manifest:read`**, because field names are the kind of thing an adopter may
|
|
93
|
+
* reasonably withhold from a client that has no business seeing them. The manifest's privacy block carries
|
|
94
|
+
* classes and counts only, so a connection without this one still sees the posture headline.
|
|
95
|
+
*/
|
|
96
|
+
export const PRIVACY_READ_SCOPE: ControlPlaneScope = "privacy:read";
|
|
97
|
+
|
|
88
98
|
/**
|
|
89
99
|
* The seam's base grant — what `pithy dashboard connect` grants whatever the Worker composes. Ping is absent
|
|
90
100
|
* because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
|
|
91
101
|
*
|
|
92
|
-
* **{@link MIGRATIONS_READ_SCOPE}
|
|
93
|
-
* — so `connect` derives it from the seam's `adminRoutes` the way it
|
|
94
|
-
* base exists for `keys:rotate`, which is not a read and would
|
|
102
|
+
* **{@link MIGRATIONS_READ_SCOPE} and {@link PRIVACY_READ_SCOPE} are absent on purpose.** Each is a read —
|
|
103
|
+
* every route requiring it is a `GET` — so `connect` derives it from the seam's `adminRoutes` the way it
|
|
104
|
+
* derives every capability's reads. The base exists for `keys:rotate`, which is not a read and would
|
|
105
|
+
* otherwise drop out.
|
|
95
106
|
*/
|
|
96
107
|
export const SEAM_SCOPES: readonly ControlPlaneScope[] = [MANIFEST_READ_SCOPE, KEYS_ROTATE_SCOPE];
|
|
97
108
|
|
|
@@ -27,7 +27,7 @@ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
|
|
|
27
27
|
export function base64UrlEncode(bytes: Uint8Array): string {
|
|
28
28
|
// Built one character at a time. `String.fromCharCode(...bytes)` overflows the argument stack on
|
|
29
29
|
// inputs this seam genuinely sees — a request body digest, an exported key — so the loop is the
|
|
30
|
-
// implementation, not a style choice. (Same reason as `
|
|
30
|
+
// implementation, not a style choice. (Same reason as `core/src/crypto/envelope.ts`.)
|
|
31
31
|
let binary = "";
|
|
32
32
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
33
33
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
package/src/createBackend.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { Logger } from "./logger/logger";
|
|
|
32
32
|
import { bindRequestContext, createWorkerLogger } from "./logger/worker";
|
|
33
33
|
import type { RouteGroup } from "./openapi/document";
|
|
34
34
|
import { recordRouteGroups } from "./openapi/routeGroups";
|
|
35
|
+
import { refuseEncryptedKvStores } from "./privacy/encryptedColumns";
|
|
35
36
|
import { HEALTH_PATH } from "./worker/health";
|
|
36
37
|
import { workerVersion } from "./worker/identity";
|
|
37
38
|
import { registeredWorkflowBinding } from "./workflow/bindings";
|
|
@@ -178,6 +179,9 @@ export function createBackend<
|
|
|
178
179
|
// `@pithy-sh/i18n`'s middleware replaces it per request; nothing else ever does.
|
|
179
180
|
const defaultTranslator = bakedTranslator(catalogFor(messages, DEFAULT_LOCALE));
|
|
180
181
|
const namespaces = composeKv(all);
|
|
182
|
+
// An encrypted field in a KV store is refused here, at boot (#390): a KV envelope cannot be found by key version,
|
|
183
|
+
// so it could never be rotated. The refusal names the store, the field and D1.
|
|
184
|
+
refuseEncryptedKvStores(all);
|
|
181
185
|
const workflows = composeWorkflows(all);
|
|
182
186
|
|
|
183
187
|
// Each named database implies its D1 binding; each KV namespace implies its KV binding; each
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { InternalError } from "../error/pithyError";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* **The AES-256-GCM envelope: one primitive, two callers** (#390).
|
|
9
|
+
*
|
|
10
|
+
* It was `@pithy-sh/secrets`' own, ported from the CMS `secretsCrypto`, and it sealed that capability's rows and
|
|
11
|
+
* nothing else. Field encryption needs the same primitive inside an adopter's own Workers, and a capability may not
|
|
12
|
+
* depend on another's internals (principle 4), so it lives in core now and both callers import it from here. The
|
|
13
|
+
* format did not change: a row secrets sealed before the move opens after it, byte for byte.
|
|
14
|
+
*
|
|
15
|
+
* ## Two version axes
|
|
16
|
+
*
|
|
17
|
+
* `currentVersion` says which key seals new writes, and the `keyVersion` persisted on every envelope says which key
|
|
18
|
+
* opens it. A rotation adds a version, moves the pointer and keeps the old key until nothing is sealed under it, so
|
|
19
|
+
* a row opens under whichever version sealed it, for as long as that version is in the set. This is the
|
|
20
|
+
* *encryption-key* version. A secret's *value* version is a separate concern (`secrets/src/crypto/versionedValue.ts`).
|
|
21
|
+
*
|
|
22
|
+
* The config is the uniform `{ currentVersion, versions }` shape every versioned value uses, plus `lastRotatedAt`
|
|
23
|
+
* beside it as rotation metadata. Version keys are stringified integers, so reading the keys is a full-set read:
|
|
24
|
+
* every still-valid version, never the current one alone.
|
|
25
|
+
*
|
|
26
|
+
* ## The bound context is the caller's, and it is authenticated
|
|
27
|
+
*
|
|
28
|
+
* Every seal binds a **context** string as AES-GCM additional authenticated data. It is not encrypted; it is bound.
|
|
29
|
+
* Without it a ciphertext is valid under the key alone, so one lifted from one row and written into another opens
|
|
30
|
+
* cleanly, and every decision about which value a caller gets lives in the query above. With it, a moved ciphertext
|
|
31
|
+
* fails inside the primitive, however that query is later rewritten.
|
|
32
|
+
*
|
|
33
|
+
* The primitive takes the context whole, because what to bind is the caller's fact, not this module's:
|
|
34
|
+
*
|
|
35
|
+
* - `@pithy-sh/secrets` binds `pithy.secrets.v1:<stored name>` — exactly what it always bound.
|
|
36
|
+
* - A field marked `encrypted()` binds `pithy/enc/1:<declared name>:<field path>:<primary key>` (`../privacy/sealed`).
|
|
37
|
+
*
|
|
38
|
+
* Each prefix is versioned, so a later change to what is bound is a deliberate, visible break, and the two prefixes
|
|
39
|
+
* are distinct, so a secret's ciphertext never opens as a field and a field's never opens as a secret. **What is bound
|
|
40
|
+
* must be stable for the life of the ciphertext**: each caller states why its own context is.
|
|
41
|
+
*
|
|
42
|
+
* An empty context is refused on both halves. It binds nothing, and it would fail open rather than loudly.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The key set a seal draws from: which version is current, every still-valid key, and when it last rotated. */
|
|
46
|
+
export const EncryptionConfig = z
|
|
47
|
+
.object({
|
|
48
|
+
currentVersion: z
|
|
49
|
+
.string()
|
|
50
|
+
.meta({ multiline: false })
|
|
51
|
+
.describe("The version key (a stringified integer) whose key seals new writes — the active key."),
|
|
52
|
+
versions: z
|
|
53
|
+
// Every string leaf of a `json` secret says whether it fits on a terminal line, and a base64 key
|
|
54
|
+
// does. See `cli/promptPlan.ts`; held repo-wide by `packages/cli/src/ci/secretFieldLines.test.ts`.
|
|
55
|
+
.record(z.string(), z.string().meta({ multiline: false }))
|
|
56
|
+
.describe(
|
|
57
|
+
"Every still-valid key: version key (stringified integer) → base64-encoded AES-256 key. Holds the current key plus any prior versions still needed to open values not yet re-sealed.",
|
|
58
|
+
),
|
|
59
|
+
lastRotatedAt: z.iso
|
|
60
|
+
.datetime()
|
|
61
|
+
.meta({ multiline: false })
|
|
62
|
+
.describe(
|
|
63
|
+
"ISO-8601 timestamp of the last rotation; the rotation cron compares against it to decide when to rotate.",
|
|
64
|
+
),
|
|
65
|
+
})
|
|
66
|
+
.describe(
|
|
67
|
+
"A versioned AES-256 key set: the uniform versioned-value shape (currentVersion + versions) plus rotation metadata. Secrets' master key (`SECRETS_ENCRYPTION_KEYS`) and the field key (`PITHY_FIELD_KEYS`) both take it.",
|
|
68
|
+
);
|
|
69
|
+
export type EncryptionConfig = z.output<typeof EncryptionConfig>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One AES-256-GCM envelope: base64 ciphertext, base64 IV, and the key version that sealed it.
|
|
73
|
+
*
|
|
74
|
+
* The bound context is deliberately not in here. It is supplied by the caller on the way back in, from the row's own
|
|
75
|
+
* identity, so a row that has been moved cannot carry its old context with it.
|
|
76
|
+
*/
|
|
77
|
+
export const EncryptedEnvelope = z
|
|
78
|
+
.object({
|
|
79
|
+
encryptedValue: z.string().min(1).describe("The AES-256-GCM ciphertext and tag, base64."),
|
|
80
|
+
iv: z.string().min(1).describe("The 12-byte initialization vector, base64. Fresh for every seal."),
|
|
81
|
+
keyVersion: z
|
|
82
|
+
.number()
|
|
83
|
+
.int()
|
|
84
|
+
.describe("The version of the key that sealed this value: the key set entry that opens it."),
|
|
85
|
+
})
|
|
86
|
+
.describe("An AES-256-GCM envelope: the ciphertext, its IV, and the key version that sealed it.");
|
|
87
|
+
export type EncryptedEnvelope = z.output<typeof EncryptedEnvelope>;
|
|
88
|
+
|
|
89
|
+
/** What the refusals name the key set by — its binding, so an operator knows which entry to inspect. */
|
|
90
|
+
export interface EnvelopeOptions {
|
|
91
|
+
/** The binding the key set was read from, e.g. `SECRETS_ENCRYPTION_KEYS`. Defaults to `the key set`. */
|
|
92
|
+
keys?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Base64-encode raw bytes without spreading into `fromCharCode` (stack-safe, lint-clean). */
|
|
96
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
97
|
+
let binary = "";
|
|
98
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
99
|
+
return btoa(binary);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Decode base64 to raw bytes. */
|
|
103
|
+
export function fromBase64(b64: string): Uint8Array<ArrayBuffer> {
|
|
104
|
+
return Uint8Array.from(atob(b64), (char) => char.charCodeAt(0));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The authenticated data for one seal. An empty context is refused: it would bind nothing. */
|
|
108
|
+
function additionalData(context: string): Uint8Array<ArrayBuffer> {
|
|
109
|
+
if (context.length === 0) {
|
|
110
|
+
throw new InternalError({ detail: "the envelope needs a context to bind; an empty context binds nothing" });
|
|
111
|
+
}
|
|
112
|
+
// Copied through `from` so the view is backed by an `ArrayBuffer` rather than an `ArrayBufferLike`:
|
|
113
|
+
// workers-types types `encode` as the wider one, which no longer satisfies `BufferSource`.
|
|
114
|
+
return Uint8Array.from(new TextEncoder().encode(context));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The raw key bytes for `version`, or a refusal naming the key set. Never the key itself. */
|
|
118
|
+
export function keyBytes(
|
|
119
|
+
config: EncryptionConfig,
|
|
120
|
+
version: number,
|
|
121
|
+
options: EnvelopeOptions = {},
|
|
122
|
+
): Uint8Array<ArrayBuffer> {
|
|
123
|
+
const b64 = config.versions[String(version)];
|
|
124
|
+
if (!b64) {
|
|
125
|
+
throw new InternalError({
|
|
126
|
+
detail: `encryption key version ${version} not present in ${options.keys ?? "the key set"}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return fromBase64(b64);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Import the AES-GCM key for `version` from the config, or throw if that version is absent. */
|
|
133
|
+
async function importKey(
|
|
134
|
+
config: EncryptionConfig,
|
|
135
|
+
version: number,
|
|
136
|
+
usage: ("encrypt" | "decrypt")[],
|
|
137
|
+
options: EnvelopeOptions,
|
|
138
|
+
): Promise<CryptoKey> {
|
|
139
|
+
return crypto.subtle.importKey("raw", keyBytes(config, version, options), "AES-GCM", false, usage);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Seal `plaintext` under the config's current key version, binding `context`. The IV is 12 fresh random bytes, so
|
|
144
|
+
* two seals of one value never share a ciphertext.
|
|
145
|
+
*/
|
|
146
|
+
export async function encryptValue(
|
|
147
|
+
config: EncryptionConfig,
|
|
148
|
+
context: string,
|
|
149
|
+
plaintext: string,
|
|
150
|
+
options: EnvelopeOptions = {},
|
|
151
|
+
): Promise<EncryptedEnvelope> {
|
|
152
|
+
const aad = additionalData(context);
|
|
153
|
+
const keyVersion = Number(config.currentVersion);
|
|
154
|
+
const key = await importKey(config, keyVersion, ["encrypt"], options);
|
|
155
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
156
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
157
|
+
{ name: "AES-GCM", iv, additionalData: aad },
|
|
158
|
+
key,
|
|
159
|
+
new TextEncoder().encode(plaintext),
|
|
160
|
+
);
|
|
161
|
+
return { encryptedValue: toBase64(new Uint8Array(ciphertext)), iv: toBase64(iv), keyVersion };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Open an envelope sealed under `context`. The key version may differ from `currentVersion` during a rotation window;
|
|
166
|
+
* the matching key must still be in the config.
|
|
167
|
+
*
|
|
168
|
+
* A missing key version, a tampered ciphertext, the wrong key, or a ciphertext sealed under another context all throw
|
|
169
|
+
* — never the plaintext or key material. GCM cannot tell those apart, and separating them for the caller would be an
|
|
170
|
+
* oracle: the `detail` names the context the open was *attempted under*, which is what an operator needs. Each caller
|
|
171
|
+
* binds a context composed from validated identifiers, so it is safe in a log; neither binds a value.
|
|
172
|
+
*/
|
|
173
|
+
export async function decryptValue(
|
|
174
|
+
config: EncryptionConfig,
|
|
175
|
+
context: string,
|
|
176
|
+
envelope: EncryptedEnvelope,
|
|
177
|
+
options: EnvelopeOptions = {},
|
|
178
|
+
): Promise<string> {
|
|
179
|
+
const aad = additionalData(context);
|
|
180
|
+
const key = await importKey(config, envelope.keyVersion, ["decrypt"], options);
|
|
181
|
+
try {
|
|
182
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
183
|
+
{ name: "AES-GCM", iv: fromBase64(envelope.iv), additionalData: aad },
|
|
184
|
+
key,
|
|
185
|
+
fromBase64(envelope.encryptedValue),
|
|
186
|
+
);
|
|
187
|
+
return new TextDecoder().decode(plaintext);
|
|
188
|
+
} catch (cause) {
|
|
189
|
+
throw new InternalError(
|
|
190
|
+
{
|
|
191
|
+
detail: `AES-GCM decrypt failed for '${context}' under key version ${envelope.keyVersion}: wrong key, tampered ciphertext, or a ciphertext sealed under a different context`,
|
|
192
|
+
},
|
|
193
|
+
{ cause },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { remedy } from "../cli/command";
|
|
5
|
+
import { InternalError } from "../error/pithyError";
|
|
6
|
+
import { EncryptionConfig } from "./envelope";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **The field key: what an app Worker seals its encrypted fields with** (#390).
|
|
10
|
+
*
|
|
11
|
+
* `PITHY_FIELD_KEYS` is an {@link EncryptionConfig}, the same versioned shape as secrets' master key, and it is a
|
|
12
|
+
* **different key on purpose**. Field encryption seals and opens inside the adopter's own Workers, so every Worker that
|
|
13
|
+
* holds an encrypted field must hold the key it uses. Were that the master key `SECRETS_ENCRYPTION_KEYS`, every such
|
|
14
|
+
* Worker would hold the key to the whole secrets vault, and one compromised would open it. A key of its own limits
|
|
15
|
+
* that to the fields: a Worker that holds an encrypted field and composes no `secrets` is bound this key and never
|
|
16
|
+
* the master key.
|
|
17
|
+
*
|
|
18
|
+
* It is not a second mechanism: `pithy secrets provision` mints it beside the master key, in the same format, binds
|
|
19
|
+
* it to every Worker that holds an encrypted field, and the manager's at-rest cron rotates it every
|
|
20
|
+
* {@link FIELD_KEY_ROTATION_DAYS} days.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The binding an app Worker reads its field key through. Fixed across environments; only the store entry is scoped. */
|
|
24
|
+
export const FIELD_KEYS_BINDING = "PITHY_FIELD_KEYS";
|
|
25
|
+
|
|
26
|
+
/** How often the manager rotates the field key, in days. */
|
|
27
|
+
export const FIELD_KEY_ROTATION_DAYS = 30;
|
|
28
|
+
|
|
29
|
+
/** A Secrets Store binding: `.get()` resolves the plaintext inside the Worker. */
|
|
30
|
+
export interface FieldKeyBinding {
|
|
31
|
+
get(): Promise<string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where a field key comes from: the Worker's `env.PITHY_FIELD_KEYS` binding (a Secrets Store binding deployed, a JSON
|
|
36
|
+
* string from `.dev.vars` locally), or a config a caller already resolved.
|
|
37
|
+
*/
|
|
38
|
+
export type FieldKeys = FieldKeyBinding | string | EncryptionConfig;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a field key to its config. A missing binding, text that is not JSON, and JSON that is not a key set are each
|
|
42
|
+
* refused, and **nothing read is ever echoed**: what failed to parse is the key set.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveFieldKeys(keys: FieldKeys | undefined): Promise<EncryptionConfig> {
|
|
45
|
+
let raw: unknown = keys;
|
|
46
|
+
if (keys === undefined || keys === null) {
|
|
47
|
+
throw new InternalError({
|
|
48
|
+
message: "This Worker holds an encrypted field and no field key.",
|
|
49
|
+
action: `Bind ${FIELD_KEYS_BINDING}. ${remedy("secrets provision").say((command) => `${command} binds it to every Worker with an encrypted field.`)}`,
|
|
50
|
+
detail: `${FIELD_KEYS_BINDING} binding is not configured`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (typeof keys === "object" && "get" in keys && typeof keys.get === "function") raw = await keys.get();
|
|
54
|
+
if (typeof raw === "string") {
|
|
55
|
+
try {
|
|
56
|
+
raw = JSON.parse(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new InternalError({ detail: `${FIELD_KEYS_BINDING} is not valid JSON` });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const parsed = EncryptionConfig.safeParse(raw);
|
|
62
|
+
if (!parsed.success) throw new InternalError({ detail: `${FIELD_KEYS_BINDING} is not a valid EncryptionConfig` });
|
|
63
|
+
return parsed.data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every live version in a key set, newest first, as numbers. A version key that is not an integer is refused rather
|
|
68
|
+
* than sorted as text, where `"10"` sorts before `"9"`.
|
|
69
|
+
*/
|
|
70
|
+
export function liveVersions(config: EncryptionConfig): number[] {
|
|
71
|
+
const versions = Object.keys(config.versions).map(Number);
|
|
72
|
+
if (!versions.every((version) => Number.isInteger(version))) {
|
|
73
|
+
throw new InternalError({ detail: `${FIELD_KEYS_BINDING}: a version key is not a stringified integer` });
|
|
74
|
+
}
|
|
75
|
+
return versions.sort((left, right) => right - left);
|
|
76
|
+
}
|
package/src/data/codecs.ts
CHANGED
|
@@ -85,46 +85,78 @@ export const JsonDate = z.codec(z.union([z.string(), z.number(), z.date()]), z.d
|
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* sqliteJson:
|
|
89
|
-
*
|
|
90
|
-
* against `schema` before it reaches the app.
|
|
88
|
+
* sqliteJson's decode: a JSON string is parsed, an already-parsed value passes through. One function for every
|
|
89
|
+
* column, so `isSqliteJson` can tell the kit's codec from any other by its decode.
|
|
91
90
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* text an older writer put there, or a payload a customer's Worker returned, can be.
|
|
95
|
-
* `JSON.stringify` throws a `TypeError` on a `BigInt` and on a cycle, either of
|
|
96
|
-
* which a `schema` can legitimately admit. Both become an issue (#358).
|
|
91
|
+
* **`JSON.parse` throws**, and it was reachable: a column holding text an older writer put there, or a payload a
|
|
92
|
+
* customer's Worker returned, need not be a JSON document. That becomes an issue (#358).
|
|
97
93
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
94
|
+
function decodeJson(value: unknown, payload: Payload<unknown>): unknown {
|
|
95
|
+
if (typeof value !== "string") return value;
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(value);
|
|
98
|
+
} catch {
|
|
99
|
+
payload.issues.push({ code: "invalid_format", format: "json", input: value, message: "Not valid JSON." });
|
|
100
|
+
return z.NEVER;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* sqliteJson's encode. **`JSON.stringify` throws** on a `BigInt` and on a cycle, either of which a schema can
|
|
106
|
+
* legitimately admit, so that becomes an issue too (#358).
|
|
107
|
+
*/
|
|
108
|
+
function encodeJson(value: unknown, payload: Payload<unknown>): string {
|
|
109
|
+
// The offending value is deliberately not carried onto the issue: it is the thing JSON could not hold, so a
|
|
110
|
+
// reporter that renders an issue would be the second place to trip over it.
|
|
111
|
+
try {
|
|
112
|
+
const json = JSON.stringify(value);
|
|
113
|
+
if (typeof json === "string") return json;
|
|
114
|
+
} catch {
|
|
115
|
+
// Falls through to the one push below. A `BigInt`, a cycle and an `undefined` result are the same answer —
|
|
116
|
+
// this value does not survive the round trip — and they deserve the same issue.
|
|
117
|
+
}
|
|
118
|
+
payload.issues.push({
|
|
119
|
+
code: "invalid_format",
|
|
120
|
+
format: "json",
|
|
121
|
+
input: undefined,
|
|
122
|
+
message: "Not serializable as JSON.",
|
|
127
123
|
});
|
|
124
|
+
return z.NEVER;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* sqliteJson: T ↔ JSON string, validated by `schema` on both sides. Decode accepts a JSON string or an
|
|
129
|
+
* already-parsed value; the result is validated against `schema` before it reaches the app.
|
|
130
|
+
*/
|
|
131
|
+
export function sqliteJson<T extends z.ZodType>(schema: T): SqliteJson<T> {
|
|
132
|
+
return z.codec(z.union([z.string(), schema]), schema, {
|
|
133
|
+
decode: decodeJson as (value: string | z.output<T>, payload: Payload<string | z.output<T>>) => z.input<T>,
|
|
134
|
+
encode: encodeJson as (value: z.input<T>, payload: Payload<z.input<T>>) => string,
|
|
135
|
+
}) as SqliteJson<T>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Names the type `sqliteJson` returns, and nothing else. A type only: no value carries it. `Redacted<T>` reads a JSON
|
|
140
|
+
* column's fields through it, as `redact` reads them through {@link isSqliteJson}, so an adopter's codec of the same
|
|
141
|
+
* shape is opaque to both.
|
|
142
|
+
*/
|
|
143
|
+
declare const kitJsonColumn: unique symbol;
|
|
144
|
+
|
|
145
|
+
/** What `sqliteJson(schema)` returns: the kit's JSON column over `schema`. Clones keep it, as they return `this`. */
|
|
146
|
+
export type SqliteJson<T extends z.ZodType> = z.ZodCodec<z.ZodUnion<[z.ZodString, T]>, T> & {
|
|
147
|
+
readonly [kitJsonColumn]: T;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** The schema a kit JSON column decodes to, or `never` for any other node. */
|
|
151
|
+
export type SqliteJsonSchema<S> = S extends { readonly [kitJsonColumn]: infer T } ? T : never;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether a schema is a JSON column `sqliteJson` built, or a clone of one (`.describe()`, `.meta()`), read by its
|
|
155
|
+
* decode. `redact` walks such a column's fields one by one, and treats every other codec as opaque.
|
|
156
|
+
*/
|
|
157
|
+
export function isSqliteJson(schema: z.ZodType): boolean {
|
|
158
|
+
const def = schema._zod.def as { type: string; transform?: unknown };
|
|
159
|
+
return def.type === "pipe" && def.transform === decodeJson;
|
|
128
160
|
}
|
|
129
161
|
|
|
130
162
|
export const MAX_TIMEZONE_LENGTH = 64;
|
package/src/error/extend.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import type { MessageParams } from "../i18n/catalog";
|
|
5
|
+
import type { UnmarkedString } from "../privacy/types";
|
|
5
6
|
import type { ErrorPayload, KitErrorDomain } from "./payload";
|
|
6
7
|
import { ExtendedErrorPayload } from "./payload";
|
|
7
8
|
import { InternalError } from "./pithyError";
|
|
@@ -95,11 +96,11 @@ export function defineErrorPayload<const Code extends `${string}/${string}`>(
|
|
|
95
96
|
/** The HTTP status this code maps to. A 4xx or a 5xx — an error is not a success. */
|
|
96
97
|
status: number;
|
|
97
98
|
/** Public, safe-to-expose summary. Sent to clients and shown in the terminal. */
|
|
98
|
-
message:
|
|
99
|
-
/** Operator remediation hint. Becomes the CLI action line. Never sent to a client. */
|
|
100
|
-
action?:
|
|
101
|
-
/** Internal context for logs + audit. NEVER serialized to clients — the HTTP codec strips it. */
|
|
102
|
-
detail?:
|
|
99
|
+
message: UnmarkedString;
|
|
100
|
+
/** Operator remediation hint. Becomes the CLI action line. Never sent to a client. Never a marked value (#132). */
|
|
101
|
+
action?: UnmarkedString;
|
|
102
|
+
/** Internal context for logs + audit. NEVER serialized to clients — the HTTP codec strips it. Never a marked value (#132). */
|
|
103
|
+
detail?: UnmarkedString;
|
|
103
104
|
/**
|
|
104
105
|
* Values a translating client interpolates into its own wording for this code. Client-facing:
|
|
105
106
|
* these cross the boundary with `message`, unlike `action` and `detail`.
|
package/src/error/payload.ts
CHANGED
|
@@ -181,6 +181,38 @@ const AuthSessionNotFreshPublic = z
|
|
|
181
181
|
})
|
|
182
182
|
.describe("The session is authentic but the holder authenticated too long ago to attach a provider (403).");
|
|
183
183
|
|
|
184
|
+
const AuthAccountSuspendedPublic = z
|
|
185
|
+
.object({
|
|
186
|
+
code: z
|
|
187
|
+
.literal("auth/account_suspended")
|
|
188
|
+
.describe(
|
|
189
|
+
"The account this credential belongs to is suspended, so the request is refused whatever it carries. An operator suspended it on purpose, with a reason, and the reason is the `message`. When the suspension has an end, `params.suspendedUntil` names it as ISO-8601; a suspension past its end is no longer one, and nothing has to clear it. A separate code from `auth/forbidden` so a client can render the screen that says what happened rather than a generic refusal.",
|
|
190
|
+
),
|
|
191
|
+
status: z
|
|
192
|
+
.literal(403)
|
|
193
|
+
.describe(
|
|
194
|
+
"Forbidden — the credential is genuine and the account behind it may not act. Not 401: signing in again cannot help, and a client that read it as an expired token would loop.",
|
|
195
|
+
),
|
|
196
|
+
...publicFields,
|
|
197
|
+
})
|
|
198
|
+
.describe("The account is suspended (403).");
|
|
199
|
+
|
|
200
|
+
const AuthAccountDeletedPublic = z
|
|
201
|
+
.object({
|
|
202
|
+
code: z
|
|
203
|
+
.literal("auth/account_deleted")
|
|
204
|
+
.describe(
|
|
205
|
+
"The account this credential belongs to is deleted and inside its purge window: it cannot sign in or act, and it can still be restored until the window closes. After the purge there is no account, and the credential is simply invalid.",
|
|
206
|
+
),
|
|
207
|
+
status: z
|
|
208
|
+
.literal(403)
|
|
209
|
+
.describe(
|
|
210
|
+
"Forbidden — the account exists and may not act. Not 404, because until the purge runs it can come back, and not 401, because signing in again cannot help.",
|
|
211
|
+
),
|
|
212
|
+
...publicFields,
|
|
213
|
+
})
|
|
214
|
+
.describe("The account is deleted and awaiting its purge (403).");
|
|
215
|
+
|
|
184
216
|
const NotFoundPublic = z
|
|
185
217
|
.object({
|
|
186
218
|
code: z.literal("core/not_found").describe("The requested resource does not exist."),
|
|
@@ -1758,6 +1790,8 @@ export const KitPublicErrorPayload = z
|
|
|
1758
1790
|
ForbiddenPublic,
|
|
1759
1791
|
AuthProviderUnavailablePublic,
|
|
1760
1792
|
AuthSessionNotFreshPublic,
|
|
1793
|
+
AuthAccountSuspendedPublic,
|
|
1794
|
+
AuthAccountDeletedPublic,
|
|
1761
1795
|
NotFoundPublic,
|
|
1762
1796
|
ConflictPublic,
|
|
1763
1797
|
RateLimitPublic,
|
|
@@ -1914,6 +1948,12 @@ const AuthProviderUnavailable = AuthProviderUnavailablePublic.extend(internalFie
|
|
|
1914
1948
|
const AuthSessionNotFresh = AuthSessionNotFreshPublic.extend(internalFields).describe(
|
|
1915
1949
|
AuthSessionNotFreshPublic.description ?? "",
|
|
1916
1950
|
);
|
|
1951
|
+
const AuthAccountSuspended = AuthAccountSuspendedPublic.extend(internalFields).describe(
|
|
1952
|
+
AuthAccountSuspendedPublic.description ?? "",
|
|
1953
|
+
);
|
|
1954
|
+
const AuthAccountDeleted = AuthAccountDeletedPublic.extend(internalFields).describe(
|
|
1955
|
+
AuthAccountDeletedPublic.description ?? "",
|
|
1956
|
+
);
|
|
1917
1957
|
const NotFound = NotFoundPublic.extend(internalFields).describe(NotFoundPublic.description ?? "");
|
|
1918
1958
|
const Conflict = ConflictPublic.extend(internalFields).describe(ConflictPublic.description ?? "");
|
|
1919
1959
|
const RateLimit = RateLimitPublic.extend(internalFields).describe(RateLimitPublic.description ?? "");
|
|
@@ -2311,6 +2351,8 @@ export const KitErrorPayload = z
|
|
|
2311
2351
|
Forbidden,
|
|
2312
2352
|
AuthProviderUnavailable,
|
|
2313
2353
|
AuthSessionNotFresh,
|
|
2354
|
+
AuthAccountSuspended,
|
|
2355
|
+
AuthAccountDeleted,
|
|
2314
2356
|
NotFound,
|
|
2315
2357
|
Conflict,
|
|
2316
2358
|
RateLimit,
|
package/src/error/pithyError.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { z } from "zod";
|
|
5
5
|
import type { MessageParams } from "../i18n/catalog";
|
|
6
|
+
import type { UnmarkedString } from "../privacy/types";
|
|
6
7
|
import type { ValidationIssue } from "./payload";
|
|
7
8
|
import { ErrorPayload } from "./payload";
|
|
8
9
|
|
|
@@ -18,7 +19,20 @@ import { ErrorPayload } from "./payload";
|
|
|
18
19
|
export class PithyError extends Error {
|
|
19
20
|
readonly payload: ErrorPayload;
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* `message`, `action` and `detail` are each an {@link UnmarkedString} here and on every subclass's arguments
|
|
24
|
+
* (#132): a value marked with a privacy class is refused at compile time, because all three ride into every log
|
|
25
|
+
* line and audit row that carries this error. A template literal is a plain `string`, so interpolation is not caught — see
|
|
26
|
+
* `privacy/types.ts` for exactly what is.
|
|
27
|
+
*/
|
|
28
|
+
constructor(
|
|
29
|
+
payload: ErrorPayload & {
|
|
30
|
+
message: UnmarkedString;
|
|
31
|
+
action?: UnmarkedString | undefined;
|
|
32
|
+
detail?: UnmarkedString | undefined;
|
|
33
|
+
},
|
|
34
|
+
options?: { cause?: unknown },
|
|
35
|
+
) {
|
|
22
36
|
const parsed = ErrorPayload.parse(payload);
|
|
23
37
|
super(parsed.message, options);
|
|
24
38
|
this.payload = parsed;
|
|
@@ -28,12 +42,12 @@ export class PithyError extends Error {
|
|
|
28
42
|
|
|
29
43
|
/** Variable parts every subclass accepts; the `code`/`status` are fixed by the subclass. */
|
|
30
44
|
interface ErrorArgs {
|
|
31
|
-
/** Override the public, safe-to-expose message. Defaults to a per-code summary. */
|
|
32
|
-
message?:
|
|
33
|
-
/** A remediation hint (CLI action line). */
|
|
34
|
-
action?:
|
|
35
|
-
/** Internal context for logs + audit. Never serialized to clients. */
|
|
36
|
-
detail?:
|
|
45
|
+
/** Override the public, safe-to-expose message. Defaults to a per-code summary. Never a privacy-marked value. */
|
|
46
|
+
message?: UnmarkedString;
|
|
47
|
+
/** A remediation hint (CLI action line). Never a privacy-marked value. */
|
|
48
|
+
action?: UnmarkedString;
|
|
49
|
+
/** Internal context for logs + audit. Never serialized to clients, and never a privacy-marked value (#132). */
|
|
50
|
+
detail?: UnmarkedString;
|
|
37
51
|
/**
|
|
38
52
|
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
39
53
|
* unlike `action` and `detail` — these cross the boundary with `message`. The `code` is the catalog
|
package/src/index.ts
CHANGED
|
@@ -102,6 +102,7 @@ export {
|
|
|
102
102
|
KEYS_ROTATE_SCOPE,
|
|
103
103
|
MANIFEST_READ_SCOPE,
|
|
104
104
|
MIGRATIONS_READ_SCOPE,
|
|
105
|
+
PRIVACY_READ_SCOPE,
|
|
105
106
|
SEAM_SCOPES,
|
|
106
107
|
} from "./controlPlane/scope/scope";
|
|
107
108
|
export { exportPublicJwk, type MintControlPlaneToken, mintControlPlaneToken } from "./controlPlane/token/mint";
|
package/src/logger/logger.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { ErrorPayload } from "../error/payload";
|
|
5
5
|
import { PithyError } from "../error/pithyError";
|
|
6
|
+
import type { Loggable, UnmarkedString } from "../privacy/types";
|
|
6
7
|
import { LOG_LEVEL_ORDER, type LogFields, type LogLevel, type LogRecord } from "./record";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -15,22 +16,28 @@ import { LOG_LEVEL_ORDER, type LogFields, type LogLevel, type LogRecord } from "
|
|
|
15
16
|
* The reserved `error` field on any call may carry a `PithyError` or `ErrorPayload` — the engine lifts
|
|
16
17
|
* it into the record's `error`, `detail` and all. A log is internal; this is the inverse of the HTTP
|
|
17
18
|
* codec, which strips `detail`. The logger must never be wired to a client-facing surface.
|
|
19
|
+
*
|
|
20
|
+
* **A value marked with a privacy class does not compile here** (#132). Every method takes `F & Loggable<F>`,
|
|
21
|
+
* so a field holding a `pii()`/`phi()`/`secret()`/`pci()` value — the value itself, or one inside an object or
|
|
22
|
+
* an array, to the depth `privacy/types.ts` states — is refused at that key, and `redact(Schema, value)` is the
|
|
23
|
+
* way through. The message and a child's name take `UnmarkedString`, so `log.info(user.email)` is refused too. `LogFields` the schema stays permissive: a record that reaches a sink at runtime still parses,
|
|
24
|
+
* because a brand is a type and a parsed log line has none.
|
|
18
25
|
*/
|
|
19
26
|
export interface Logger {
|
|
20
27
|
/** Dev-time diagnostics. Dropped unless the logger's level is `debug`. */
|
|
21
|
-
debug(msg:
|
|
28
|
+
debug<F extends LogFields = LogFields>(msg: UnmarkedString, fields?: F & Loggable<F>): void;
|
|
22
29
|
/** Normal operational events. */
|
|
23
|
-
info(msg:
|
|
30
|
+
info<F extends LogFields = LogFields>(msg: UnmarkedString, fields?: F & Loggable<F>): void;
|
|
24
31
|
/** A recoverable problem worth attention. */
|
|
25
|
-
warn(msg:
|
|
32
|
+
warn<F extends LogFields = LogFields>(msg: UnmarkedString, fields?: F & Loggable<F>): void;
|
|
26
33
|
/** A failure that was observed. Pass `{ error }` (a PithyError) to carry its full payload. */
|
|
27
|
-
error(msg:
|
|
34
|
+
error<F extends LogFields = LogFields>(msg: UnmarkedString, fields?: F & Loggable<F>): void;
|
|
28
35
|
/**
|
|
29
36
|
* A namespaced sub-logger. `name` composes as `parent:child`; bound `fields` merge into every record
|
|
30
37
|
* the child emits. Omit `name` (pass `undefined`) to bind fields only — how request-correlation
|
|
31
38
|
* attaches `request`/`method`/`path` without renaming the logger.
|
|
32
39
|
*/
|
|
33
|
-
child(name?:
|
|
40
|
+
child<F extends LogFields = LogFields>(name?: UnmarkedString, fields?: F & Loggable<F>): Logger;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/** A sink receives a finished {@link LogRecord}. Adapters supply one; the engine builds records and calls it. */
|