@pithy-sh/core 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/dist/access/access.d.ts +172 -0
  2. package/dist/access/access.d.ts.map +1 -0
  3. package/dist/access/access.js +147 -0
  4. package/dist/access/acting.d.ts +49 -0
  5. package/dist/access/acting.d.ts.map +1 -0
  6. package/dist/access/acting.js +21 -0
  7. package/dist/access/policyCheck.d.ts +33 -0
  8. package/dist/access/policyCheck.d.ts.map +1 -0
  9. package/dist/access/policyCheck.js +48 -0
  10. package/dist/access/usage.d.ts +83 -0
  11. package/dist/access/usage.d.ts.map +1 -0
  12. package/dist/access/usage.js +104 -0
  13. package/dist/access/visibility.d.ts +66 -0
  14. package/dist/access/visibility.d.ts.map +1 -0
  15. package/dist/access/visibility.js +86 -0
  16. package/dist/audit/auditEvent.d.ts +5 -0
  17. package/dist/audit/auditEvent.d.ts.map +1 -1
  18. package/dist/audit/auditEvent.js +2 -0
  19. package/dist/audit/recorder.d.ts +10 -2
  20. package/dist/audit/recorder.d.ts.map +1 -1
  21. package/dist/capability/bindings.d.ts +47 -2
  22. package/dist/capability/bindings.d.ts.map +1 -1
  23. package/dist/capability/bindings.js +117 -7
  24. package/dist/capability/capability.d.ts +110 -28
  25. package/dist/capability/capability.d.ts.map +1 -1
  26. package/dist/capability/capability.js +2 -0
  27. package/dist/capability/manifest.d.ts +2 -1
  28. package/dist/capability/manifest.d.ts.map +1 -1
  29. package/dist/capability/manifest.js +1 -1
  30. package/dist/capability/purge.d.ts +80 -0
  31. package/dist/capability/purge.d.ts.map +1 -0
  32. package/dist/capability/purge.js +76 -0
  33. package/dist/capability/settings.d.ts +86 -2
  34. package/dist/capability/settings.d.ts.map +1 -1
  35. package/dist/capability/settings.js +80 -1
  36. package/dist/cli/commandCatalog.generated.d.ts +238 -24
  37. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  38. package/dist/cli/commandCatalog.generated.js +251 -22
  39. package/dist/cli/commandFacts.d.ts +2 -1
  40. package/dist/cli/commandFacts.d.ts.map +1 -1
  41. package/dist/cli/commandFacts.js +2 -0
  42. package/dist/controlPlane/capability.d.ts.map +1 -1
  43. package/dist/controlPlane/capability.js +19 -2
  44. package/dist/controlPlane/context.d.ts +5 -0
  45. package/dist/controlPlane/context.d.ts.map +1 -1
  46. package/dist/controlPlane/context.js +3 -1
  47. package/dist/controlPlane/data/connection.d.ts +49 -12
  48. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  49. package/dist/controlPlane/data/connection.js +56 -2
  50. package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
  51. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  52. package/dist/controlPlane/discovery/adminRoute.js +3 -0
  53. package/dist/controlPlane/error/errors.d.ts +4 -3
  54. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  55. package/dist/controlPlane/http/cors.d.ts +4 -4
  56. package/dist/controlPlane/http/cors.js +4 -4
  57. package/dist/controlPlane/http/guard.d.ts +11 -0
  58. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  59. package/dist/controlPlane/http/guard.js +17 -1
  60. package/dist/controlPlane/http/handlers.d.ts +139 -0
  61. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  62. package/dist/controlPlane/http/handlers.js +67 -4
  63. package/dist/controlPlane/http/readiness.d.ts +33 -0
  64. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  65. package/dist/controlPlane/http/readiness.js +39 -0
  66. package/dist/controlPlane/http/responses.d.ts +245 -0
  67. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  68. package/dist/controlPlane/http/responses.js +101 -4
  69. package/dist/controlPlane/http/routes.d.ts +2 -0
  70. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  71. package/dist/controlPlane/http/routes.js +31 -3
  72. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  73. package/dist/controlPlane/http/verify.js +2 -1
  74. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  75. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  76. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  77. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  78. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  79. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  80. package/dist/controlPlane/scope/scope.d.ts +23 -2
  81. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  82. package/dist/controlPlane/scope/scope.js +24 -3
  83. package/dist/createBackend.d.ts.map +1 -1
  84. package/dist/createBackend.js +9 -2
  85. package/dist/createEntrypoint.d.ts.map +1 -1
  86. package/dist/createEntrypoint.js +5 -3
  87. package/dist/crypto/envelope.d.ts +86 -0
  88. package/dist/crypto/envelope.d.ts.map +1 -0
  89. package/dist/crypto/envelope.js +129 -0
  90. package/dist/crypto/fieldKeys.d.ts +41 -0
  91. package/dist/crypto/fieldKeys.d.ts.map +1 -0
  92. package/dist/crypto/fieldKeys.js +56 -0
  93. package/dist/data/codecs.d.ts +23 -10
  94. package/dist/data/codecs.d.ts.map +1 -1
  95. package/dist/data/codecs.js +50 -36
  96. package/dist/entitlement/allowance.d.ts +25 -0
  97. package/dist/entitlement/allowance.d.ts.map +1 -0
  98. package/dist/entitlement/allowance.js +80 -0
  99. package/dist/entitlement/entitlement.d.ts +41 -0
  100. package/dist/entitlement/entitlement.d.ts.map +1 -1
  101. package/dist/entitlement/meter.d.ts +57 -0
  102. package/dist/entitlement/meter.d.ts.map +1 -0
  103. package/dist/entitlement/meter.js +13 -0
  104. package/dist/error/extend.d.ts +6 -5
  105. package/dist/error/extend.d.ts.map +1 -1
  106. package/dist/error/http.d.ts +168 -0
  107. package/dist/error/http.d.ts.map +1 -1
  108. package/dist/error/payload.d.ts +336 -0
  109. package/dist/error/payload.d.ts.map +1 -1
  110. package/dist/error/payload.js +116 -4
  111. package/dist/error/pithyError.d.ts +18 -7
  112. package/dist/error/pithyError.d.ts.map +1 -1
  113. package/dist/error/pithyError.js +6 -0
  114. package/dist/http/authContext.d.ts +7 -0
  115. package/dist/http/authContext.d.ts.map +1 -1
  116. package/dist/http/authContext.js +8 -1
  117. package/dist/http/scopeGates.d.ts +20 -0
  118. package/dist/http/scopeGates.d.ts.map +1 -0
  119. package/dist/http/scopeGates.js +36 -0
  120. package/dist/index.d.ts +1 -1
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +2 -2
  123. package/dist/logger/logger.d.ts +12 -5
  124. package/dist/logger/logger.d.ts.map +1 -1
  125. package/dist/logger/record.d.ts +98 -0
  126. package/dist/logger/record.d.ts.map +1 -1
  127. package/dist/migrations/capabilitySets.d.ts +23 -0
  128. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  129. package/dist/migrations/capabilitySets.js +74 -0
  130. package/dist/migrations/registry.d.ts +26 -0
  131. package/dist/migrations/registry.d.ts.map +1 -1
  132. package/dist/migrations/registry.js +38 -7
  133. package/dist/migrations/state.d.ts +60 -0
  134. package/dist/migrations/state.d.ts.map +1 -0
  135. package/dist/migrations/state.js +46 -0
  136. package/dist/naming/feature.d.ts +27 -5
  137. package/dist/naming/feature.d.ts.map +1 -1
  138. package/dist/naming/feature.js +36 -6
  139. package/dist/naming/limits.d.ts +47 -3
  140. package/dist/naming/limits.d.ts.map +1 -1
  141. package/dist/naming/limits.js +47 -3
  142. package/dist/naming/provisionScope.d.ts +6 -0
  143. package/dist/naming/provisionScope.d.ts.map +1 -1
  144. package/dist/naming/provisionScope.js +8 -3
  145. package/dist/naming/resourceNames.d.ts +21 -2
  146. package/dist/naming/resourceNames.d.ts.map +1 -1
  147. package/dist/naming/resourceNames.js +21 -3
  148. package/dist/privacy/blindIndex.d.ts +27 -0
  149. package/dist/privacy/blindIndex.d.ts.map +1 -0
  150. package/dist/privacy/blindIndex.js +57 -0
  151. package/dist/privacy/classes.d.ts +142 -0
  152. package/dist/privacy/classes.d.ts.map +1 -0
  153. package/dist/privacy/classes.js +209 -0
  154. package/dist/privacy/classify.d.ts +336 -0
  155. package/dist/privacy/classify.d.ts.map +1 -0
  156. package/dist/privacy/classify.js +1024 -0
  157. package/dist/privacy/encrypted.d.ts +210 -0
  158. package/dist/privacy/encrypted.d.ts.map +1 -0
  159. package/dist/privacy/encrypted.js +350 -0
  160. package/dist/privacy/encryptedColumns.d.ts +64 -0
  161. package/dist/privacy/encryptedColumns.d.ts.map +1 -0
  162. package/dist/privacy/encryptedColumns.js +134 -0
  163. package/dist/privacy/fieldRotation.d.ts +34 -0
  164. package/dist/privacy/fieldRotation.d.ts.map +1 -0
  165. package/dist/privacy/fieldRotation.js +186 -0
  166. package/dist/privacy/inventory.d.ts +12 -0
  167. package/dist/privacy/inventory.d.ts.map +1 -0
  168. package/dist/privacy/inventory.js +154 -0
  169. package/dist/privacy/posture.d.ts +114 -0
  170. package/dist/privacy/posture.d.ts.map +1 -0
  171. package/dist/privacy/posture.js +136 -0
  172. package/dist/privacy/redact.d.ts +7 -0
  173. package/dist/privacy/redact.d.ts.map +1 -0
  174. package/dist/privacy/redact.js +404 -0
  175. package/dist/privacy/report.d.ts +58 -0
  176. package/dist/privacy/report.d.ts.map +1 -0
  177. package/dist/privacy/report.js +15 -0
  178. package/dist/privacy/retirement.d.ts +68 -0
  179. package/dist/privacy/retirement.d.ts.map +1 -0
  180. package/dist/privacy/retirement.js +72 -0
  181. package/dist/privacy/sealed.d.ts +87 -0
  182. package/dist/privacy/sealed.d.ts.map +1 -0
  183. package/dist/privacy/sealed.js +221 -0
  184. package/dist/privacy/types.d.ts +559 -0
  185. package/dist/privacy/types.d.ts.map +1 -0
  186. package/dist/privacy/types.js +2 -0
  187. package/dist/seed/writeD1.d.ts +7 -1
  188. package/dist/seed/writeD1.d.ts.map +1 -1
  189. package/dist/seed/writeD1.js +9 -3
  190. package/dist/seed/writeKv.d.ts +2 -0
  191. package/dist/seed/writeKv.d.ts.map +1 -1
  192. package/dist/seed/writeKv.js +6 -2
  193. package/dist/version.generated.d.ts +1 -1
  194. package/dist/version.generated.js +1 -1
  195. package/dist/worker/serviceLoopback.d.ts +36 -0
  196. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  197. package/dist/worker/serviceLoopback.js +74 -0
  198. package/dist/workflow/faults.d.ts +12 -1
  199. package/dist/workflow/faults.d.ts.map +1 -1
  200. package/dist/workflow/faults.js +22 -15
  201. package/package.json +2 -2
  202. package/src/access/access.ts +298 -0
  203. package/src/access/acting.ts +59 -0
  204. package/src/access/policyCheck.ts +77 -0
  205. package/src/access/usage.ts +141 -0
  206. package/src/access/visibility.ts +109 -0
  207. package/src/audit/auditEvent.ts +4 -0
  208. package/src/audit/recorder.ts +10 -2
  209. package/src/capability/bindings.ts +159 -13
  210. package/src/capability/capability.ts +114 -29
  211. package/src/capability/purge.ts +124 -0
  212. package/src/capability/settings.ts +146 -2
  213. package/src/cli/commandCatalog.generated.ts +271 -22
  214. package/src/cli/commandFacts.ts +2 -0
  215. package/src/controlPlane/capability.ts +36 -1
  216. package/src/controlPlane/context.ts +4 -0
  217. package/src/controlPlane/data/connection.ts +90 -0
  218. package/src/controlPlane/discovery/adminRoute.ts +11 -0
  219. package/src/controlPlane/error/errors.ts +4 -3
  220. package/src/controlPlane/http/cors.ts +4 -4
  221. package/src/controlPlane/http/guard.ts +21 -0
  222. package/src/controlPlane/http/handlers.ts +98 -4
  223. package/src/controlPlane/http/readiness.ts +50 -0
  224. package/src/controlPlane/http/responses.ts +240 -2
  225. package/src/controlPlane/http/routes.ts +55 -1
  226. package/src/controlPlane/http/verify.ts +2 -0
  227. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  228. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  229. package/src/controlPlane/scope/scope.ts +25 -2
  230. package/src/controlPlane/token/base64url.ts +1 -1
  231. package/src/createBackend.ts +28 -6
  232. package/src/createEntrypoint.ts +9 -3
  233. package/src/crypto/envelope.ts +196 -0
  234. package/src/crypto/fieldKeys.ts +76 -0
  235. package/src/data/codecs.ts +69 -37
  236. package/src/entitlement/allowance.ts +85 -0
  237. package/src/entitlement/entitlement.ts +38 -0
  238. package/src/entitlement/meter.ts +69 -0
  239. package/src/error/extend.ts +6 -5
  240. package/src/error/payload.ts +253 -4
  241. package/src/error/pithyError.ts +21 -7
  242. package/src/http/authContext.ts +13 -1
  243. package/src/http/scopeGates.ts +34 -0
  244. package/src/index.ts +2 -0
  245. package/src/logger/logger.ts +12 -5
  246. package/src/migrations/capabilitySets.ts +87 -0
  247. package/src/migrations/registry.ts +46 -2
  248. package/src/migrations/state.ts +94 -0
  249. package/src/naming/feature.ts +46 -7
  250. package/src/naming/limits.ts +35 -3
  251. package/src/naming/provisionScope.ts +19 -1
  252. package/src/naming/resourceNames.ts +52 -5
  253. package/src/privacy/blindIndex.ts +81 -0
  254. package/src/privacy/classes.ts +277 -0
  255. package/src/privacy/classify.ts +1447 -0
  256. package/src/privacy/encrypted.ts +513 -0
  257. package/src/privacy/encryptedColumns.ts +185 -0
  258. package/src/privacy/fieldRotation.ts +234 -0
  259. package/src/privacy/inventory.ts +147 -0
  260. package/src/privacy/posture.ts +205 -0
  261. package/src/privacy/redact.ts +423 -0
  262. package/src/privacy/report.ts +64 -0
  263. package/src/privacy/retirement.ts +102 -0
  264. package/src/privacy/sealed.ts +323 -0
  265. package/src/privacy/types.ts +804 -0
  266. package/src/seed/writeD1.ts +13 -4
  267. package/src/seed/writeKv.ts +6 -2
  268. package/src/version.generated.ts +1 -1
  269. package/src/worker/serviceLoopback.ts +105 -0
  270. package/src/workflow/faults.ts +51 -24
@@ -0,0 +1,142 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { InternalError } from "../../error/pithyError.js";
4
+ import { MIGRATIONS_READ_SCOPE } from "../scope/scope.js";
5
+ import { composeDatabases } from "../../data/databases.js";
6
+ import { composedMigrationKey, createMigrationRegistry, decomposeMigrationKey } from "../../migrations/registry.js";
7
+ import { collectMigrationSets } from "../../migrations/capabilitySets.js";
8
+ import { readMigrationLedger } from "../../migrations/runner.js";
9
+ import { readMigrationState } from "../../migrations/state.js";
10
+ //#region src/controlPlane/migrationState/migrationState.ts
11
+ /** A provider declaring nothing, for a database bound with no migrations: everything applied is undeclared. */
12
+ const NOTHING_DECLARED = { getMigrations: async () => ({}) };
13
+ /**
14
+ * Every database this Worker binds, with the provider this build composes for it, sorted by name.
15
+ *
16
+ * One entry per database `composeDatabases` maps to a binding — the same map the request registry is built
17
+ * from — so a database bound with no migrations is still reported. Leaving it out would read as not bound.
18
+ */
19
+ function composeMigrationDatabases(capabilities) {
20
+ const sets = collectMigrationSets([...capabilities]);
21
+ const registry = createMigrationRegistry(sets);
22
+ const bound = composeDatabases([...capabilities]);
23
+ return Object.entries(bound).sort(([left], [right]) => left.localeCompare(right)).map(([database, group]) => {
24
+ const namespaces = /* @__PURE__ */ new Map();
25
+ for (const set of sets) {
26
+ if (set.database !== database) continue;
27
+ for (const localKey of Object.keys(set.migrations)) namespaces.set(composedMigrationKey(set, localKey), set.namespace);
28
+ }
29
+ return {
30
+ database,
31
+ binding: group.binding,
32
+ provider: registry[database] ?? NOTHING_DECLARED,
33
+ namespaces
34
+ };
35
+ });
36
+ }
37
+ /**
38
+ * One migration as the route reports it.
39
+ *
40
+ * A declared migration's namespace comes from the set it was declared in. An undeclared one has only its
41
+ * key, so it is decomposed — and a key no registry composed answers null rather than a guess.
42
+ */
43
+ function migrationView(name, namespaces) {
44
+ return {
45
+ name,
46
+ namespace: namespaces.get(name) ?? decomposeMigrationKey(name)?.namespace ?? null
47
+ };
48
+ }
49
+ /** The D1 binding for `entry`, or a refusal naming it — a Worker missing the binding cannot read it. */
50
+ function bindingOf(env, entry) {
51
+ const database = env[entry.binding];
52
+ if (database == null) throw new InternalError({
53
+ message: `The ${entry.binding} binding is missing, so database "${entry.database}" cannot be read.`,
54
+ action: `Declare ${entry.binding} in this Worker's wrangler.jsonc, then deploy again.`
55
+ });
56
+ return database;
57
+ }
58
+ function groupView(group, namespaces) {
59
+ return {
60
+ group: group.group,
61
+ appliedAt: group.appliedAt.toISOString(),
62
+ migrations: group.migrations.map((name) => migrationView(name, namespaces)),
63
+ position: group.position.state === "top" ? { state: "top" } : {
64
+ state: "buried",
65
+ above: group.position.above,
66
+ ungrouped: group.position.ungrouped.map((name) => migrationView(name, namespaces))
67
+ }
68
+ };
69
+ }
70
+ /**
71
+ * Read one database's migration state for the route.
72
+ *
73
+ * **A database that cannot be read is `unreadable`, and costs only itself.** The cause goes to
74
+ * `onUnreadable` — the route logs it, where the operator's context stays the operator's — and never into
75
+ * the view: a D1 error can name a table or a query, and the view crosses to a management client.
76
+ */
77
+ async function readDatabaseMigrations(env, entry, onUnreadable) {
78
+ try {
79
+ const state = await readMigrationState(bindingOf(env, entry), entry.provider);
80
+ const view = (name) => migrationView(name, entry.namespaces);
81
+ return {
82
+ state: "readable",
83
+ database: entry.database,
84
+ binding: entry.binding,
85
+ applied: state.applied.map(view),
86
+ pending: state.pending.map(view),
87
+ undeclared: state.undeclared.map(view),
88
+ lastGroup: state.lastGroup ? groupView(state.lastGroup, entry.namespaces) : null
89
+ };
90
+ } catch (cause) {
91
+ onUnreadable?.(entry, cause);
92
+ return {
93
+ state: "unreadable",
94
+ database: entry.database,
95
+ binding: entry.binding
96
+ };
97
+ }
98
+ }
99
+ /**
100
+ * The two health keys the seam contributes to its own manifest entry.
101
+ *
102
+ * Both `indexed`: one small select per bound database, bounded by the declared migration count rather
103
+ * than by the adopter's data. Both behind {@link MIGRATIONS_READ_SCOPE}, which the seam's own route
104
+ * requires, so a connection without it gets no value rather than a zero.
105
+ *
106
+ * **`undeclaredMigrations` declares no nominal, on purpose.** From inside one Worker, a database shared
107
+ * with another lists the other's migrations as undeclared, so `atMost: 0` would report a corrupted chain
108
+ * on a database that is entirely fine. Null is what a client reads as unknowable, never as healthy (#471).
109
+ */
110
+ const MIGRATION_HEALTH_KEYS = [{
111
+ key: "pendingMigrations",
112
+ kind: "count",
113
+ states: null,
114
+ scope: MIGRATIONS_READ_SCOPE,
115
+ cost: "indexed",
116
+ summary: "Migrations this build declares that a bound database has not applied.",
117
+ nominal: { atMost: 0 }
118
+ }, {
119
+ key: "undeclaredMigrations",
120
+ kind: "count",
121
+ states: null,
122
+ scope: MIGRATIONS_READ_SCOPE,
123
+ cost: "indexed",
124
+ summary: "Migrations a bound database holds that this Worker does not declare. Relative to this Worker: a database shared with another lists that Worker's migrations here.",
125
+ nominal: null
126
+ }];
127
+ /**
128
+ * Total pending and undeclared across every bound database, for the manifest.
129
+ *
130
+ * **Throws rather than total a database it could not read.** A partial total is a zero for the database
131
+ * nobody read. The seam catches the throw and reports the summary `unavailable`, which a client reads as
132
+ * unknown — null is never zero.
133
+ */
134
+ async function readMigrationHealth(env, databases) {
135
+ const ledgers = await Promise.all(databases.map((entry) => readMigrationLedger(bindingOf(env, entry), entry.provider)));
136
+ return {
137
+ pendingMigrations: ledgers.reduce((total, ledger) => total + ledger.pending.length, 0),
138
+ undeclaredMigrations: ledgers.reduce((total, ledger) => total + ledger.undeclared.length, 0)
139
+ };
140
+ }
141
+ //#endregion
142
+ export { MIGRATION_HEALTH_KEYS, composeMigrationDatabases, migrationView, readDatabaseMigrations, readMigrationHealth };
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Migration } from "kysely/migration";
4
+ /**
5
+ * Who sanctioned a connection, what kind of client it is, and what it is for — `#721`.
6
+ *
7
+ * **Appended, not folded into `0001`.** The seam has shipped and adopters' databases hold rows written by
8
+ * it, so the three columns arrive in a migration of their own, as `0002`'s origin did.
9
+ *
10
+ * **Nullable, with no default, and nothing backfilled.** A row registered before this carries no
11
+ * principal, and none can be recovered: most of the trail that might be mined for one names a token
12
+ * rather than a person, and a principal read off a token name is the self-assertion this issue exists to
13
+ * end. So null means *registered before principals were recorded*, the connection keeps working exactly
14
+ * as it did, its purpose may be set with `pithy dashboard connect --update --purpose`, and its principal
15
+ * is set only by registering again, under a signed-in session.
16
+ *
17
+ * **The Worker reads only `actorKind`**, to stamp it onto the audit rows a connection's calls write. The
18
+ * other two exist for the adopter's own review, on their own side.
19
+ */
20
+ export declare const controlplane_0003_connection_principal: Migration;
21
+ //# sourceMappingURL=0003_connection_principal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"0003_connection_principal.d.ts","sourceRoot":"","sources":["../../../src/controlPlane/migrations/0003_connection_principal.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,sCAAsC,EAAE,SAcpD,CAAC"}
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/controlPlane/migrations/0003_connection_principal.ts
4
+ /**
5
+ * Who sanctioned a connection, what kind of client it is, and what it is for — `#721`.
6
+ *
7
+ * **Appended, not folded into `0001`.** The seam has shipped and adopters' databases hold rows written by
8
+ * it, so the three columns arrive in a migration of their own, as `0002`'s origin did.
9
+ *
10
+ * **Nullable, with no default, and nothing backfilled.** A row registered before this carries no
11
+ * principal, and none can be recovered: most of the trail that might be mined for one names a token
12
+ * rather than a person, and a principal read off a token name is the self-assertion this issue exists to
13
+ * end. So null means *registered before principals were recorded*, the connection keeps working exactly
14
+ * as it did, its purpose may be set with `pithy dashboard connect --update --purpose`, and its principal
15
+ * is set only by registering again, under a signed-in session.
16
+ *
17
+ * **The Worker reads only `actorKind`**, to stamp it onto the audit rows a connection's calls write. The
18
+ * other two exist for the adopter's own review, on their own side.
19
+ */
20
+ const controlplane_0003_connection_principal = {
21
+ up: async (db) => {
22
+ await db.schema.alterTable("pithyControlplaneConnections").addColumn("authorizedBy", "text").execute();
23
+ await db.schema.alterTable("pithyControlplaneConnections").addColumn("actorKind", "text").execute();
24
+ await db.schema.alterTable("pithyControlplaneConnections").addColumn("purpose", "text").execute();
25
+ },
26
+ down: async (db) => {
27
+ await db.schema.alterTable("pithyControlplaneConnections").dropColumn("purpose").execute();
28
+ await db.schema.alterTable("pithyControlplaneConnections").dropColumn("actorKind").execute();
29
+ await db.schema.alterTable("pithyControlplaneConnections").dropColumn("authorizedBy").execute();
30
+ }
31
+ };
32
+ //#endregion
33
+ export { controlplane_0003_connection_principal };
@@ -50,8 +50,29 @@ export declare const MANIFEST_READ_SCOPE: ControlPlaneScope;
50
50
  */
51
51
  export declare const KEYS_ROTATE_SCOPE: ControlPlaneScope;
52
52
  /**
53
- * Every grantable scope the seam's own routes use — what `pithy dashboard connect` offers by default.
54
- * Ping is absent because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
53
+ * Read where each database this Worker binds stands: what is applied, what is pending, what is undeclared,
54
+ * and which run put it there (#729). Read-only by construction — every route behind it is a `GET`, and
55
+ * nothing over the seam runs, reverses or resets a migration. The two migration health keys on the seam's
56
+ * manifest entry sit behind it too, so a connection without it gets no number rather than a zero.
57
+ */
58
+ export declare const MIGRATIONS_READ_SCOPE: ControlPlaneScope;
59
+ /**
60
+ * Read this Worker's full privacy inventory: every storage schema holding a marked field, each marked field's
61
+ * path, its class, and where it is stored (#132). Read-only — the one route behind it is a `GET`.
62
+ *
63
+ * **Its own scope rather than `manifest:read`**, because field names are the kind of thing an adopter may
64
+ * reasonably withhold from a client that has no business seeing them. The manifest's privacy block carries
65
+ * classes and counts only, so a connection without this one still sees the posture headline.
66
+ */
67
+ export declare const PRIVACY_READ_SCOPE: ControlPlaneScope;
68
+ /**
69
+ * The seam's base grant — what `pithy dashboard connect` grants whatever the Worker composes. Ping is absent
70
+ * because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
71
+ *
72
+ * **{@link MIGRATIONS_READ_SCOPE} and {@link PRIVACY_READ_SCOPE} are absent on purpose.** Each is a read —
73
+ * every route requiring it is a `GET` — so `connect` derives it from the seam's `adminRoutes` the way it
74
+ * derives every capability's reads. The base exists for `keys:rotate`, which is not a read and would
75
+ * otherwise drop out.
55
76
  */
56
77
  export declare const SEAM_SCOPES: readonly ControlPlaneScope[];
57
78
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../../src/controlPlane/scope/scope.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAexB;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,aAQ3B,CAAC;AACJ,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,EAAE,OAAO,MAA2D,CAAC;AAErG;;;;;;;GAOG;AACH,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,GAAG,OAAO,mBAAmB,CAAC;AAErF;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,EAAE,iBAAmC,CAAC;AAEtE;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,iBAAiC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,WAAW,EAAE,SAAS,iBAAiB,EAA6C,CAAC;AAElG;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,uBAAuB,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAIrH"}
1
+ {"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../../src/controlPlane/scope/scope.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAexB;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,aAQ3B,CAAC;AACJ,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,EAAE,OAAO,MAA2D,CAAC;AAErG;;;;;;;GAOG;AACH,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,GAAG,OAAO,mBAAmB,CAAC;AAErF;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,EAAE,iBAAmC,CAAC;AAEtE;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,iBAAiC,CAAC;AAElE;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,iBAAqC,CAAC;AAE1E;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,EAAE,iBAAkC,CAAC;AAEpE;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,EAAE,SAAS,iBAAiB,EAA6C,CAAC;AAElG;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,uBAAuB,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAIrH"}
@@ -40,8 +40,29 @@ const MANIFEST_READ_SCOPE = "manifest:read";
40
40
  */
41
41
  const KEYS_ROTATE_SCOPE = "keys:rotate";
42
42
  /**
43
- * Every grantable scope the seam's own routes use — what `pithy dashboard connect` offers by default.
44
- * Ping is absent because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
43
+ * Read where each database this Worker binds stands: what is applied, what is pending, what is undeclared,
44
+ * and which run put it there (#729). Read-only by construction — every route behind it is a `GET`, and
45
+ * nothing over the seam runs, reverses or resets a migration. The two migration health keys on the seam's
46
+ * manifest entry sit behind it too, so a connection without it gets no number rather than a zero.
47
+ */
48
+ const MIGRATIONS_READ_SCOPE = "migrations:read";
49
+ /**
50
+ * Read this Worker's full privacy inventory: every storage schema holding a marked field, each marked field's
51
+ * path, its class, and where it is stored (#132). Read-only — the one route behind it is a `GET`.
52
+ *
53
+ * **Its own scope rather than `manifest:read`**, because field names are the kind of thing an adopter may
54
+ * reasonably withhold from a client that has no business seeing them. The manifest's privacy block carries
55
+ * classes and counts only, so a connection without this one still sees the posture headline.
56
+ */
57
+ const PRIVACY_READ_SCOPE = "privacy:read";
58
+ /**
59
+ * The seam's base grant — what `pithy dashboard connect` grants whatever the Worker composes. Ping is absent
60
+ * because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
61
+ *
62
+ * **{@link MIGRATIONS_READ_SCOPE} and {@link PRIVACY_READ_SCOPE} are absent on purpose.** Each is a read —
63
+ * every route requiring it is a `GET` — so `connect` derives it from the seam's `adminRoutes` the way it
64
+ * derives every capability's reads. The base exists for `keys:rotate`, which is not a read and would
65
+ * otherwise drop out.
45
66
  */
46
67
  const SEAM_SCOPES = [MANIFEST_READ_SCOPE, KEYS_ROTATE_SCOPE];
47
68
  /**
@@ -62,4 +83,4 @@ function scopeCovers(required, presented, granted) {
62
83
  return granted.includes(required);
63
84
  }
64
85
  //#endregion
65
- export { ANY_VERIFIED_CALLER, ControlPlaneScope, KEYS_ROTATE_SCOPE, MANIFEST_READ_SCOPE, SEAM_SCOPES, scopeCovers };
86
+ export { ANY_VERIFIED_CALLER, ControlPlaneScope, KEYS_ROTATE_SCOPE, MANIFEST_READ_SCOPE, MIGRATIONS_READ_SCOPE, PRIVACY_READ_SCOPE, SEAM_SCOPES, scopeCovers };
@@ -1 +1 @@
1
- {"version":3,"file":"createBackend.d.ts","sourceRoot":"","sources":["../src/createBackend.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,KAAK,EACV,UAAU,EACV,eAAe,EACf,kBAAkB,EAClB,oBAAoB,EAEpB,SAAS,EACV,MAAM,yBAAyB,CAAC;AAMjC,OAAO,EAAqC,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMtF,OAAO,EAA8B,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE9E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAO9C,OAAO,EAA2B,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAGvF,oGAAoG;AACpG,MAAM,WAAW,oBAAoB,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EAAE,GAAG,SAAS,UAAU;IAC9F,2EAA2E;IAC3E,YAAY,EAAE,IAAI,CAAC;IACnB;;;OAGG;IACH,GAAG,CAAC,EAAE,GAAG,CAAC;IACV;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,iHAAiH;AACjH,KAAK,UAAU,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,IAAI;IACpD,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,GAAG,IAAI,GAAG,WAAW,CAAC,GAAG;QACtD,EAAE,EAAE,UAAU,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;QACtC,EAAE,EAAE,UAAU,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC;QACzC,SAAS,EAAE,kBAAkB,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC;KAC3D,CAAC;CACH,CAAC;AAuCF,kCAAkC;AAClC,eAAO,MAAM,UAAU;;;iBAQ+B,CAAC;AACvD,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAC3B,KAAK,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EACxC,KAAK,CAAC,GAAG,SAAS,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,EACrF,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,CA0LrF"}
1
+ {"version":3,"file":"createBackend.d.ts","sourceRoot":"","sources":["../src/createBackend.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,KAAK,EACV,UAAU,EACV,eAAe,EACf,kBAAkB,EAClB,oBAAoB,EAEpB,SAAS,EACV,MAAM,yBAAyB,CAAC;AAOjC,OAAO,EAAqC,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMtF,OAAO,EAA8B,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE9E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAQ9C,OAAO,EAA2B,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAGvF,oGAAoG;AACpG,MAAM,WAAW,oBAAoB,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EAAE,GAAG,SAAS,UAAU;IAC9F,2EAA2E;IAC3E,YAAY,EAAE,IAAI,CAAC;IACnB;;;OAGG;IACH,GAAG,CAAC,EAAE,GAAG,CAAC;IACV;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,iHAAiH;AACjH,KAAK,UAAU,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,IAAI;IACpD,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,GAAG,IAAI,GAAG,WAAW,CAAC,GAAG;QACtD,EAAE,EAAE,UAAU,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;QACtC,EAAE,EAAE,UAAU,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC;QACzC,SAAS,EAAE,kBAAkB,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC;KAC3D,CAAC;CACH,CAAC;AAuCF,kCAAkC;AAClC,eAAO,MAAM,UAAU;;;iBAQ+B,CAAC;AACvD,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAC3B,KAAK,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EACxC,KAAK,CAAC,GAAG,SAAS,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,EACrF,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,CA6MrF"}
@@ -1,16 +1,19 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
+ import { noActingOrganization } from "./access/acting.js";
3
4
  import { noopEmit } from "./audit/recorder.js";
4
5
  import { ValidationError } from "./error/pithyError.js";
5
6
  import { pithyCommandOf } from "./cli/command.js";
6
7
  import { BindingSpec } from "./capability/bindings.js";
7
8
  import { recordComposition } from "./capability/composition.js";
8
9
  import { validateBindings } from "./capability/validateBindings.js";
10
+ import { refuseEncryptedKvStores } from "./privacy/encryptedColumns.js";
9
11
  import { workerVersion } from "./worker/identity.js";
10
12
  import { describeRoute } from "./http/routeSpec.js";
13
+ import { buildDbRegistry, composeDatabases } from "./data/databases.js";
11
14
  import { isControlPlaneCapability } from "./controlPlane/capability.js";
12
15
  import { allowedOriginSet, corsSurface, registerControlPlaneCors } from "./controlPlane/http/cors.js";
13
- import { buildDbRegistry, composeDatabases } from "./data/databases.js";
16
+ import { registerReadinessGuards } from "./controlPlane/http/readiness.js";
14
17
  import { noEntitlementProvider } from "./entitlement/entitlement.js";
15
18
  import { bakedTranslator } from "./i18n/translator.js";
16
19
  import { catalogFor, composeMessages } from "./i18n/registry.js";
@@ -97,6 +100,7 @@ function createBackend(options) {
97
100
  const messages = composeMessages(all);
98
101
  const defaultTranslator = bakedTranslator(catalogFor(messages, "en"));
99
102
  const namespaces = composeKv(all);
103
+ refuseEncryptedKvStores(all);
100
104
  const workflows = composeWorkflows(all);
101
105
  const required = dedupeBindings([
102
106
  ...all.flatMap((cap) => cap.requiredBindings),
@@ -113,10 +117,11 @@ function createBackend(options) {
113
117
  const app = new Hono();
114
118
  app.onError(loggingErrorHandler);
115
119
  const baseLogger = options.logger ?? createWorkerLogger();
120
+ const adminSurface = corsSurface(all);
116
121
  const controlPlane = all.find(isControlPlaneCapability);
117
122
  if (controlPlane) {
118
123
  const cors = controlPlane.controlPlaneConfig;
119
- registerControlPlaneCors(app, corsSurface(all), allowedOriginSet(cors), cors.corsMaxAgeSeconds);
124
+ registerControlPlaneCors(app, adminSurface, allowedOriginSet(cors), cors.corsMaxAgeSeconds);
120
125
  }
121
126
  let validated = false;
122
127
  app.use("*", async (c, next) => {
@@ -126,6 +131,7 @@ function createBackend(options) {
126
131
  validated = true;
127
132
  }
128
133
  if (c.get("auth") === void 0) c.set("auth", null);
134
+ if (c.get("actingOrganization") === void 0) c.set("actingOrganization", noActingOrganization);
129
135
  if (c.get("locale") === void 0) c.set("locale", null);
130
136
  if (c.get("t") === void 0) c.set("t", defaultTranslator);
131
137
  if (c.get("controlPlane") === void 0) c.set("controlPlane", null);
@@ -188,6 +194,7 @@ function createBackend(options) {
188
194
  routes: app.routes.slice(healthStart)
189
195
  });
190
196
  for (const cap of all) for (const middleware of cap.middleware ?? []) middleware(app);
197
+ registerReadinessGuards(app, adminSurface, all.flatMap((cap) => cap.readiness ?? []));
191
198
  for (const cap of all) {
192
199
  const start = app.routes.length;
193
200
  cap.routes?.(app);
@@ -1 +1 @@
1
- {"version":3,"file":"createEntrypoint.d.ts","sourceRoot":"","sources":["../src/createEntrypoint.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAC3F,OAAO,KAAK,EAAE,UAAU,EAA0B,MAAM,yBAAyB,CAAC;AAClF,OAAO,EAAE,KAAK,oBAAoB,EAAiB,MAAM,iBAAiB,CAAC;AAI3E;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC9B,0DAA0D;IAC1D,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC/G;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjH;;;;;;;;;;;;;;;;;;;OAmBG;IACH,SAAS,CAAC,EAAE,CAAC,UAAU,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzG;AAkBD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EACxC,KAAK,CAAC,GAAG,SAAS,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,EACrF,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,eAAe,CAoD3D"}
1
+ {"version":3,"file":"createEntrypoint.d.ts","sourceRoot":"","sources":["../src/createEntrypoint.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAC3F,OAAO,KAAK,EAAE,UAAU,EAA0B,MAAM,yBAAyB,CAAC;AAClF,OAAO,EAAE,KAAK,oBAAoB,EAAiB,MAAM,iBAAiB,CAAC;AAK3E;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC9B,0DAA0D;IAC1D,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC/G;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjH;;;;;;;;;;;;;;;;;;;OAmBG;IACH,SAAS,CAAC,EAAE,CAAC,UAAU,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzG;AAkBD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EACxC,KAAK,CAAC,GAAG,SAAS,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,EACrF,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,eAAe,CAyD3D"}
@@ -3,6 +3,7 @@
3
3
  import { triggerWorkflow } from "./workflow/dispatch.js";
4
4
  import { composeWorkflows, scheduledWorkflows } from "./workflow/register.js";
5
5
  import { createBackend } from "./createBackend.js";
6
+ import { withLocalServices } from "./worker/serviceLoopback.js";
6
7
  //#region src/createEntrypoint.ts
7
8
  /**
8
9
  * The raw MIME stream of a `ForwardableEmailMessage` is **single-use** — once one handler reads it,
@@ -31,17 +32,18 @@ function createEntrypoint(options) {
31
32
  for (const cap of all) cap.boot?.({ capabilities: all });
32
33
  const registry = composeWorkflows(all);
33
34
  const scheduled = scheduledWorkflows(registry);
34
- const entrypoint = { fetch: (request, env, ctx) => app.fetch(request, env, ctx) };
35
+ const entrypoint = { fetch: (request, env, ctx) => app.fetch(request, withLocalServices(env), ctx) };
35
36
  if (scheduled.length > 0) entrypoint.scheduled = async (_controller, env) => {
36
37
  const failures = [];
37
38
  for (const entry of scheduled) try {
38
- await triggerWorkflow(env, registry, entry.key, {});
39
+ await triggerWorkflow(withLocalServices(env), registry, entry.key, {});
39
40
  } catch (error) {
40
41
  failures.push(error);
41
42
  }
42
43
  if (failures[0] !== void 0) throw failures[0];
43
44
  };
44
- if (handlers.length > 0) entrypoint.email = async (message, env, ctx) => {
45
+ if (handlers.length > 0) entrypoint.email = async (message, given, ctx) => {
46
+ const env = withLocalServices(given);
45
47
  if (handlers.length === 1) {
46
48
  await handlers[0]?.(message, env, ctx);
47
49
  return;
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ /**
5
+ * **The AES-256-GCM envelope: one primitive, two callers** (#390).
6
+ *
7
+ * It was `@pithy-sh/secrets`' own, ported from the CMS `secretsCrypto`, and it sealed that capability's rows and
8
+ * nothing else. Field encryption needs the same primitive inside an adopter's own Workers, and a capability may not
9
+ * depend on another's internals (principle 4), so it lives in core now and both callers import it from here. The
10
+ * format did not change: a row secrets sealed before the move opens after it, byte for byte.
11
+ *
12
+ * ## Two version axes
13
+ *
14
+ * `currentVersion` says which key seals new writes, and the `keyVersion` persisted on every envelope says which key
15
+ * opens it. A rotation adds a version, moves the pointer and keeps the old key until nothing is sealed under it, so
16
+ * a row opens under whichever version sealed it, for as long as that version is in the set. This is the
17
+ * *encryption-key* version. A secret's *value* version is a separate concern (`secrets/src/crypto/versionedValue.ts`).
18
+ *
19
+ * The config is the uniform `{ currentVersion, versions }` shape every versioned value uses, plus `lastRotatedAt`
20
+ * beside it as rotation metadata. Version keys are stringified integers, so reading the keys is a full-set read:
21
+ * every still-valid version, never the current one alone.
22
+ *
23
+ * ## The bound context is the caller's, and it is authenticated
24
+ *
25
+ * Every seal binds a **context** string as AES-GCM additional authenticated data. It is not encrypted; it is bound.
26
+ * Without it a ciphertext is valid under the key alone, so one lifted from one row and written into another opens
27
+ * cleanly, and every decision about which value a caller gets lives in the query above. With it, a moved ciphertext
28
+ * fails inside the primitive, however that query is later rewritten.
29
+ *
30
+ * The primitive takes the context whole, because what to bind is the caller's fact, not this module's:
31
+ *
32
+ * - `@pithy-sh/secrets` binds `pithy.secrets.v1:<stored name>` — exactly what it always bound.
33
+ * - A field marked `encrypted()` binds `pithy/enc/1:<declared name>:<field path>:<primary key>` (`../privacy/sealed`).
34
+ *
35
+ * Each prefix is versioned, so a later change to what is bound is a deliberate, visible break, and the two prefixes
36
+ * are distinct, so a secret's ciphertext never opens as a field and a field's never opens as a secret. **What is bound
37
+ * must be stable for the life of the ciphertext**: each caller states why its own context is.
38
+ *
39
+ * An empty context is refused on both halves. It binds nothing, and it would fail open rather than loudly.
40
+ */
41
+ /** The key set a seal draws from: which version is current, every still-valid key, and when it last rotated. */
42
+ export declare const EncryptionConfig: z.ZodObject<{
43
+ currentVersion: z.ZodString;
44
+ versions: z.ZodRecord<z.ZodString, z.ZodString>;
45
+ lastRotatedAt: z.ZodISODateTime;
46
+ }, z.core.$strip>;
47
+ export type EncryptionConfig = z.output<typeof EncryptionConfig>;
48
+ /**
49
+ * One AES-256-GCM envelope: base64 ciphertext, base64 IV, and the key version that sealed it.
50
+ *
51
+ * The bound context is deliberately not in here. It is supplied by the caller on the way back in, from the row's own
52
+ * identity, so a row that has been moved cannot carry its old context with it.
53
+ */
54
+ export declare const EncryptedEnvelope: z.ZodObject<{
55
+ encryptedValue: z.ZodString;
56
+ iv: z.ZodString;
57
+ keyVersion: z.ZodNumber;
58
+ }, z.core.$strip>;
59
+ export type EncryptedEnvelope = z.output<typeof EncryptedEnvelope>;
60
+ /** What the refusals name the key set by — its binding, so an operator knows which entry to inspect. */
61
+ export interface EnvelopeOptions {
62
+ /** The binding the key set was read from, e.g. `SECRETS_ENCRYPTION_KEYS`. Defaults to `the key set`. */
63
+ keys?: string;
64
+ }
65
+ /** Base64-encode raw bytes without spreading into `fromCharCode` (stack-safe, lint-clean). */
66
+ export declare function toBase64(bytes: Uint8Array): string;
67
+ /** Decode base64 to raw bytes. */
68
+ export declare function fromBase64(b64: string): Uint8Array<ArrayBuffer>;
69
+ /** The raw key bytes for `version`, or a refusal naming the key set. Never the key itself. */
70
+ export declare function keyBytes(config: EncryptionConfig, version: number, options?: EnvelopeOptions): Uint8Array<ArrayBuffer>;
71
+ /**
72
+ * Seal `plaintext` under the config's current key version, binding `context`. The IV is 12 fresh random bytes, so
73
+ * two seals of one value never share a ciphertext.
74
+ */
75
+ export declare function encryptValue(config: EncryptionConfig, context: string, plaintext: string, options?: EnvelopeOptions): Promise<EncryptedEnvelope>;
76
+ /**
77
+ * Open an envelope sealed under `context`. The key version may differ from `currentVersion` during a rotation window;
78
+ * the matching key must still be in the config.
79
+ *
80
+ * A missing key version, a tampered ciphertext, the wrong key, or a ciphertext sealed under another context all throw
81
+ * — never the plaintext or key material. GCM cannot tell those apart, and separating them for the caller would be an
82
+ * oracle: the `detail` names the context the open was *attempted under*, which is what an operator needs. Each caller
83
+ * binds a context composed from validated identifiers, so it is safe in a log; neither binds a value.
84
+ */
85
+ export declare function decryptValue(config: EncryptionConfig, context: string, envelope: EncryptedEnvelope, options?: EnvelopeOptions): Promise<string>;
86
+ //# sourceMappingURL=envelope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"envelope.d.ts","sourceRoot":"","sources":["../../src/crypto/envelope.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,gHAAgH;AAChH,eAAO,MAAM,gBAAgB;;;;iBAsB1B,CAAC;AACJ,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEjE;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB;;;;iBASqE,CAAC;AACpG,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEnE,wGAAwG;AACxG,MAAM,WAAW,eAAe;IAC9B,wGAAwG;IACxG,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,8FAA8F;AAC9F,wBAAgB,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAIlD;AAED,kCAAkC;AAClC,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAAC,WAAW,CAAC,CAE/D;AAYD,8FAA8F;AAC9F,wBAAgB,QAAQ,CACtB,MAAM,EAAE,gBAAgB,EACxB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,eAAoB,GAC5B,UAAU,CAAC,WAAW,CAAC,CAQzB;AAYD;;;GAGG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,gBAAgB,EACxB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CAW5B;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,gBAAgB,EACxB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,iBAAiB,EAC3B,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,MAAM,CAAC,CAkBjB"}
@@ -0,0 +1,129 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { InternalError } from "../error/pithyError.js";
4
+ import { z } from "zod";
5
+ //#region src/crypto/envelope.ts
6
+ /**
7
+ * **The AES-256-GCM envelope: one primitive, two callers** (#390).
8
+ *
9
+ * It was `@pithy-sh/secrets`' own, ported from the CMS `secretsCrypto`, and it sealed that capability's rows and
10
+ * nothing else. Field encryption needs the same primitive inside an adopter's own Workers, and a capability may not
11
+ * depend on another's internals (principle 4), so it lives in core now and both callers import it from here. The
12
+ * format did not change: a row secrets sealed before the move opens after it, byte for byte.
13
+ *
14
+ * ## Two version axes
15
+ *
16
+ * `currentVersion` says which key seals new writes, and the `keyVersion` persisted on every envelope says which key
17
+ * opens it. A rotation adds a version, moves the pointer and keeps the old key until nothing is sealed under it, so
18
+ * a row opens under whichever version sealed it, for as long as that version is in the set. This is the
19
+ * *encryption-key* version. A secret's *value* version is a separate concern (`secrets/src/crypto/versionedValue.ts`).
20
+ *
21
+ * The config is the uniform `{ currentVersion, versions }` shape every versioned value uses, plus `lastRotatedAt`
22
+ * beside it as rotation metadata. Version keys are stringified integers, so reading the keys is a full-set read:
23
+ * every still-valid version, never the current one alone.
24
+ *
25
+ * ## The bound context is the caller's, and it is authenticated
26
+ *
27
+ * Every seal binds a **context** string as AES-GCM additional authenticated data. It is not encrypted; it is bound.
28
+ * Without it a ciphertext is valid under the key alone, so one lifted from one row and written into another opens
29
+ * cleanly, and every decision about which value a caller gets lives in the query above. With it, a moved ciphertext
30
+ * fails inside the primitive, however that query is later rewritten.
31
+ *
32
+ * The primitive takes the context whole, because what to bind is the caller's fact, not this module's:
33
+ *
34
+ * - `@pithy-sh/secrets` binds `pithy.secrets.v1:<stored name>` — exactly what it always bound.
35
+ * - A field marked `encrypted()` binds `pithy/enc/1:<declared name>:<field path>:<primary key>` (`../privacy/sealed`).
36
+ *
37
+ * Each prefix is versioned, so a later change to what is bound is a deliberate, visible break, and the two prefixes
38
+ * are distinct, so a secret's ciphertext never opens as a field and a field's never opens as a secret. **What is bound
39
+ * must be stable for the life of the ciphertext**: each caller states why its own context is.
40
+ *
41
+ * An empty context is refused on both halves. It binds nothing, and it would fail open rather than loudly.
42
+ */
43
+ /** The key set a seal draws from: which version is current, every still-valid key, and when it last rotated. */
44
+ const EncryptionConfig = z.object({
45
+ currentVersion: z.string().meta({ multiline: false }).describe("The version key (a stringified integer) whose key seals new writes — the active key."),
46
+ versions: z.record(z.string(), z.string().meta({ multiline: false })).describe("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."),
47
+ lastRotatedAt: z.iso.datetime().meta({ multiline: false }).describe("ISO-8601 timestamp of the last rotation; the rotation cron compares against it to decide when to rotate.")
48
+ }).describe("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.");
49
+ /**
50
+ * One AES-256-GCM envelope: base64 ciphertext, base64 IV, and the key version that sealed it.
51
+ *
52
+ * The bound context is deliberately not in here. It is supplied by the caller on the way back in, from the row's own
53
+ * identity, so a row that has been moved cannot carry its old context with it.
54
+ */
55
+ const EncryptedEnvelope = z.object({
56
+ encryptedValue: z.string().min(1).describe("The AES-256-GCM ciphertext and tag, base64."),
57
+ iv: z.string().min(1).describe("The 12-byte initialization vector, base64. Fresh for every seal."),
58
+ keyVersion: z.number().int().describe("The version of the key that sealed this value: the key set entry that opens it.")
59
+ }).describe("An AES-256-GCM envelope: the ciphertext, its IV, and the key version that sealed it.");
60
+ /** Base64-encode raw bytes without spreading into `fromCharCode` (stack-safe, lint-clean). */
61
+ function toBase64(bytes) {
62
+ let binary = "";
63
+ for (const byte of bytes) binary += String.fromCharCode(byte);
64
+ return btoa(binary);
65
+ }
66
+ /** Decode base64 to raw bytes. */
67
+ function fromBase64(b64) {
68
+ return Uint8Array.from(atob(b64), (char) => char.charCodeAt(0));
69
+ }
70
+ /** The authenticated data for one seal. An empty context is refused: it would bind nothing. */
71
+ function additionalData(context) {
72
+ if (context.length === 0) throw new InternalError({ detail: "the envelope needs a context to bind; an empty context binds nothing" });
73
+ return Uint8Array.from(new TextEncoder().encode(context));
74
+ }
75
+ /** The raw key bytes for `version`, or a refusal naming the key set. Never the key itself. */
76
+ function keyBytes(config, version, options = {}) {
77
+ const b64 = config.versions[String(version)];
78
+ if (!b64) throw new InternalError({ detail: `encryption key version ${version} not present in ${options.keys ?? "the key set"}` });
79
+ return fromBase64(b64);
80
+ }
81
+ /** Import the AES-GCM key for `version` from the config, or throw if that version is absent. */
82
+ async function importKey(config, version, usage, options) {
83
+ return crypto.subtle.importKey("raw", keyBytes(config, version, options), "AES-GCM", false, usage);
84
+ }
85
+ /**
86
+ * Seal `plaintext` under the config's current key version, binding `context`. The IV is 12 fresh random bytes, so
87
+ * two seals of one value never share a ciphertext.
88
+ */
89
+ async function encryptValue(config, context, plaintext, options = {}) {
90
+ const aad = additionalData(context);
91
+ const keyVersion = Number(config.currentVersion);
92
+ const key = await importKey(config, keyVersion, ["encrypt"], options);
93
+ const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
94
+ const ciphertext = await crypto.subtle.encrypt({
95
+ name: "AES-GCM",
96
+ iv,
97
+ additionalData: aad
98
+ }, key, new TextEncoder().encode(plaintext));
99
+ return {
100
+ encryptedValue: toBase64(new Uint8Array(ciphertext)),
101
+ iv: toBase64(iv),
102
+ keyVersion
103
+ };
104
+ }
105
+ /**
106
+ * Open an envelope sealed under `context`. The key version may differ from `currentVersion` during a rotation window;
107
+ * the matching key must still be in the config.
108
+ *
109
+ * A missing key version, a tampered ciphertext, the wrong key, or a ciphertext sealed under another context all throw
110
+ * — never the plaintext or key material. GCM cannot tell those apart, and separating them for the caller would be an
111
+ * oracle: the `detail` names the context the open was *attempted under*, which is what an operator needs. Each caller
112
+ * binds a context composed from validated identifiers, so it is safe in a log; neither binds a value.
113
+ */
114
+ async function decryptValue(config, context, envelope, options = {}) {
115
+ const aad = additionalData(context);
116
+ const key = await importKey(config, envelope.keyVersion, ["decrypt"], options);
117
+ try {
118
+ const plaintext = await crypto.subtle.decrypt({
119
+ name: "AES-GCM",
120
+ iv: fromBase64(envelope.iv),
121
+ additionalData: aad
122
+ }, key, fromBase64(envelope.encryptedValue));
123
+ return new TextDecoder().decode(plaintext);
124
+ } catch (cause) {
125
+ throw new InternalError({ detail: `AES-GCM decrypt failed for '${context}' under key version ${envelope.keyVersion}: wrong key, tampered ciphertext, or a ciphertext sealed under a different context` }, { cause });
126
+ }
127
+ }
128
+ //#endregion
129
+ export { EncryptedEnvelope, EncryptionConfig, decryptValue, encryptValue, fromBase64, keyBytes, toBase64 };