@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
@@ -2,6 +2,8 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { PrivacyPosture } from "../../privacy/posture";
6
+ import { PRIVACY_SOURCES } from "../../privacy/report";
5
7
  import { ConnectionPrincipal, ConnectionPurpose, ControlPlaneActorKind, Ed25519PublicJwk } from "../data/connection";
6
8
 
7
9
  /**
@@ -228,3 +230,101 @@ export const ControlPlaneMigrationsResponse = z
228
230
  })
229
231
  .describe("What is applied, pending and undeclared in each bound database, and which run put it there.");
230
232
  export type ControlPlaneMigrationsResponse = z.output<typeof ControlPlaneMigrationsResponse>;
233
+
234
+ /**
235
+ * One marked field, as `GET {basePath}/privacy` reports it (#132).
236
+ *
237
+ * **The per-field record.** Since #390 it says whether the field rests encrypted beside `class`, so "which of
238
+ * your PHI fields are encrypted" is a derivation over this list rather than a second inventory to keep.
239
+ */
240
+ export const PrivacyFieldView = z
241
+ .object({
242
+ path: z
243
+ .string()
244
+ .describe(
245
+ "Where the field sits. A D1 column path (`email`, `address.line1`, `profile.phone` inside a JSON column), or a KV entry's part first (`key.email`, `value.token`, `metadata.ip`, or `value` for a whole marked value). An array element is `[]`, a record's value `*`.",
246
+ ),
247
+ class: z
248
+ .string()
249
+ .describe("The privacy class it is marked with: `secret`, `pii`, `phi`, `pci`, or an adopter's own."),
250
+ truncated: z
251
+ .literal(true)
252
+ .optional()
253
+ .describe(
254
+ "Set where the class is assumed rather than found: a read ran out of its budget there, or the schema could not be read at all, so any class may be marked below it. Absent where the schema was read to the end.",
255
+ ),
256
+ sameAs: z
257
+ .string()
258
+ .optional()
259
+ .describe(
260
+ "Set on a reference: the path this field's position was first listed under, once the list reached its cap on paths. The class is marked somewhere beneath both, and the paths beneath this one are those beneath that one.",
261
+ ),
262
+ encryptedAtRest: z
263
+ .boolean()
264
+ .optional()
265
+ .describe(
266
+ "Whether the field rests encrypted (#390). Absent where the core cannot say — an assumed class, a reference, or a core older than encrypted fields — and never defaulted to false.",
267
+ ),
268
+ unrotatable: z
269
+ .literal(true)
270
+ .optional()
271
+ .describe(
272
+ "Set on an encrypted field whose storage is not D1: it can never be rotated, and it blocks retirement.",
273
+ ),
274
+ })
275
+ .describe("One marked field and its class.");
276
+ export type PrivacyFieldView = z.output<typeof PrivacyFieldView>;
277
+
278
+ /** One storage schema holding at least one marked field. */
279
+ export const PrivacySchemaView = z
280
+ .object({
281
+ capability: z
282
+ .string()
283
+ .describe("The capability that declared the schema, as the manifest's `capabilities[]` names it."),
284
+ storage: z.enum(PRIVACY_SOURCES).describe("Where it is stored: a `d1` table or a `kv` store."),
285
+ container: z.string().describe("The D1 database name or the KV namespace name it lives in."),
286
+ name: z.string().describe("The table name, or the KV store name."),
287
+ fields: z.array(PrivacyFieldView).describe("Every marked field in it, in schema order."),
288
+ encrypted: z
289
+ .array(
290
+ z
291
+ .object({
292
+ path: z.string().describe("Where the encrypted field sits, on the same paths as `fields[].path`."),
293
+ unrotatable: z
294
+ .literal(true)
295
+ .optional()
296
+ .describe("Set where the storage is not D1, so the field can never be rotated."),
297
+ })
298
+ .describe("One encrypted field."),
299
+ )
300
+ .optional()
301
+ .describe(
302
+ "Every field marked encrypted at rest, classed or not (#390). Absent on a Worker whose core predates encrypted fields.",
303
+ ),
304
+ })
305
+ .describe("One storage schema and the marked fields it holds.");
306
+ export type PrivacySchemaView = z.output<typeof PrivacySchemaView>;
307
+
308
+ /**
309
+ * `GET /control-plane/privacy` — the full inventory, behind `privacy:read` (#132).
310
+ *
311
+ * Derived at runtime from the schemas this Worker composed, walked once per isolate. The manifest's `privacy`
312
+ * block is the headline; this is the detail, and it names fields, which is why it has a scope of its own.
313
+ */
314
+ export const ControlPlanePrivacyResponse = z
315
+ .object({
316
+ environment: z.string().describe("The environment this Worker answers for."),
317
+ posture: PrivacyPosture.describe("The declared and observed classes, the delta, and the console gate."),
318
+ schemasWalked: z
319
+ .number()
320
+ .int()
321
+ .nonnegative()
322
+ .describe("How many storage schemas were walked, marked or not, across the sources `posture.sources` names."),
323
+ schemas: z
324
+ .array(PrivacySchemaView)
325
+ .describe("Every schema holding a marked field, in composition order. A schema with none is not listed."),
326
+ })
327
+ .describe(
328
+ "Every schema this Worker stores that holds a marked field, each field's path and class, and where it lives.",
329
+ );
330
+ export type ControlPlanePrivacyResponse = z.output<typeof ControlPlanePrivacyResponse>;
@@ -6,7 +6,13 @@ import type { PithyHonoEnv } from "../../capability/capability";
6
6
  import { describeRoute } from "../../http/routeSpec";
7
7
  import { validate } from "../../http/validation";
8
8
  import { type AdminRoute, ControlPlaneManifest } from "../discovery/adminRoute";
9
- import { ANY_VERIFIED_CALLER, KEYS_ROTATE_SCOPE, MANIFEST_READ_SCOPE, MIGRATIONS_READ_SCOPE } from "../scope/scope";
9
+ import {
10
+ ANY_VERIFIED_CALLER,
11
+ KEYS_ROTATE_SCOPE,
12
+ MANIFEST_READ_SCOPE,
13
+ MIGRATIONS_READ_SCOPE,
14
+ PRIVACY_READ_SCOPE,
15
+ } from "../scope/scope";
10
16
  import { requireControlPlane } from "./guard";
11
17
  import {
12
18
  type ControlPlaneHandlerDeps,
@@ -15,12 +21,14 @@ import {
15
21
  manifestHandler,
16
22
  migrationsHandler,
17
23
  pingHandler,
24
+ privacyHandler,
18
25
  registerKeyHandler,
19
26
  } from "./handlers";
20
27
  import {
21
28
  ControlPlaneKeysResponse,
22
29
  ControlPlaneMigrationsResponse,
23
30
  ControlPlanePingResponse,
31
+ ControlPlanePrivacyResponse,
24
32
  ExpireKeyResponse,
25
33
  RegisterKeyResponse,
26
34
  } from "./responses";
@@ -35,6 +43,7 @@ import { ExpireKeyParams, ExpireKeyRequest, RegisterKeyRequest } from "./schemas
35
43
  * POST /control-plane/keys → register a key (keys:rotate)
36
44
  * POST /control-plane/keys/:keyId/expire → retire a superseded key (keys:rotate)
37
45
  * GET /control-plane/migrations → where each database stands (migrations:read)
46
+ * GET /control-plane/privacy → what each schema holds (privacy:read)
38
47
  *
39
48
  * Every one is `control-plane` and default-denied — with no connection registered they all answer 403,
40
49
  * and that is the shipped state of a Worker nobody has connected.
@@ -100,6 +109,13 @@ export function controlPlaneRouteDescriptors(basePath: string): AdminRoute[] {
100
109
  summary:
101
110
  "What each bound database has applied, what is pending, what is undeclared, and which run put it there. Undeclared is relative to this Worker.",
102
111
  },
112
+ {
113
+ method: "GET",
114
+ path: `${basePath}/privacy`,
115
+ scope: PRIVACY_READ_SCOPE,
116
+ summary:
117
+ "Every stored schema holding a privacy-marked field, each field's path and class, and where it lives. Derived from the running code.",
118
+ },
103
119
  ];
104
120
  }
105
121
 
@@ -184,4 +200,18 @@ export function registerControlPlaneRoutes(
184
200
  requireControlPlane(MIGRATIONS_READ_SCOPE),
185
201
  migrationsHandler(deps),
186
202
  );
203
+
204
+ app.get(
205
+ `${basePath}/privacy`,
206
+ describeRoute({
207
+ summary: "Report every stored field marked with a privacy class.",
208
+ description:
209
+ "Every D1 table and KV store this Worker composed that holds a privacy-marked field, with each field's path, its class and where it is stored, beside the declared-and-observed posture. Derived at runtime from the deployed schemas. Read-only.",
210
+ verification: "control-plane",
211
+ scopes: [PRIVACY_READ_SCOPE],
212
+ responses: { 200: ControlPlanePrivacyResponse },
213
+ }),
214
+ requireControlPlane(PRIVACY_READ_SCOPE),
215
+ privacyHandler(deps),
216
+ );
187
217
  }
@@ -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} is absent on purpose.** It is a read — every route requiring it is a `GET`
93
- * — so `connect` derives it from the seam's `adminRoutes` the way it derives every capability's reads. The
94
- * base exists for `keys:rotate`, which is not a read and would otherwise drop out.
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 `secrets/src/crypto/envelope.ts`.)
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(/=+$/, "");
@@ -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
@@ -28,7 +28,13 @@ export interface PithyEntrypoint {
28
28
  */
29
29
  email?: (message: ForwardableEmailMessage, env: Record<string, unknown>, ctx: ExecutionContext) => Promise<void>;
30
30
  /**
31
- * The Worker `scheduled` handler — starts every registered job that declares a `schedule`.
31
+ * The Worker `scheduled` handler — starts every registered job whose `schedule` is the cron that fired.
32
+ *
33
+ * A Worker carries one `scheduled` entry for all of its `triggers.crons`, and the runtime names the one
34
+ * that fired on `controller.cron`, verbatim as `wrangler.jsonc` declares it. Only the jobs whose
35
+ * `schedule` is that string start: a daily job composed beside an hourly one must not run every hour,
36
+ * and the hourly one must not run an extra time at the daily job's minute (#622). An event that names
37
+ * no cron, or one no job declares, starts nothing.
32
38
  *
33
39
  * **Present only when at least one job carries a cron.** The reason differs from `email`'s: an
34
40
  * inert `email()` silently *drops* mail, which is a correctness bug, whereas an inert `scheduled()`
@@ -50,6 +56,13 @@ export interface PithyEntrypoint {
50
56
  scheduled?: (controller: unknown, env: Record<string, unknown>, ctx: ExecutionContext) => Promise<void>;
51
57
  }
52
58
 
59
+ /** The cron expression a `scheduled` event fired for, or `undefined` when the event names none. */
60
+ function firedCron(controller: unknown): string | undefined {
61
+ if (typeof controller !== "object" || controller === null) return undefined;
62
+ const cron = (controller as { cron?: unknown }).cron;
63
+ return typeof cron === "string" ? cron : undefined;
64
+ }
65
+
53
66
  /**
54
67
  * The raw MIME stream of a `ForwardableEmailMessage` is **single-use** — once one handler reads it,
55
68
  * the next sees an empty stream. When more than one capability handles inbound mail we buffer the
@@ -99,9 +112,11 @@ export function createEntrypoint<
99
112
  // job whose binding is missing cannot stop the rest of the schedule from running; the failure is
100
113
  // rethrown after the pass so the invocation is still recorded as failed.
101
114
  if (scheduled.length > 0) {
102
- entrypoint.scheduled = async (_controller, env) => {
115
+ entrypoint.scheduled = async (controller, env) => {
116
+ const cron = firedCron(controller);
103
117
  const failures: unknown[] = [];
104
118
  for (const entry of scheduled) {
119
+ if (entry.spec.schedule !== cron) continue;
105
120
  // A cron supplies no caller input, which is an *empty* parameter object, not an absent one —
106
121
  // `z.object({})` accepts `{}` and rejects `undefined`. A job whose schema requires fields it
107
122
  // can never receive on a schedule therefore fails loudly here, which is the author's bug and
@@ -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
+ }