@pithy-sh/core 0.12.0 → 0.14.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 (362) hide show
  1. package/README.md +54 -0
  2. package/dist/audit/auditEvent.js +1 -1
  3. package/dist/audit/chain.d.ts +289 -0
  4. package/dist/audit/chain.d.ts.map +1 -0
  5. package/dist/audit/chain.js +400 -0
  6. package/dist/audit/citations.d.ts +52 -0
  7. package/dist/audit/citations.d.ts.map +1 -0
  8. package/dist/audit/citations.js +13 -0
  9. package/dist/audit/composed.d.ts +18 -0
  10. package/dist/audit/composed.d.ts.map +1 -0
  11. package/dist/audit/composed.js +28 -0
  12. package/dist/capability/bindings.d.ts +22 -0
  13. package/dist/capability/bindings.d.ts.map +1 -1
  14. package/dist/capability/bindings.js +32 -2
  15. package/dist/capability/capability.d.ts +133 -15
  16. package/dist/capability/capability.d.ts.map +1 -1
  17. package/dist/capability/capability.js +17 -2
  18. package/dist/capability/erasure.d.ts +75 -0
  19. package/dist/capability/erasure.d.ts.map +1 -0
  20. package/dist/capability/erasure.js +2 -0
  21. package/dist/capability/manifest.d.ts +5 -0
  22. package/dist/capability/manifest.d.ts.map +1 -1
  23. package/dist/capability/personColumns.d.ts +28 -0
  24. package/dist/capability/personColumns.d.ts.map +1 -0
  25. package/dist/capability/personColumns.js +66 -0
  26. package/dist/capability/personRows.d.ts +62 -0
  27. package/dist/capability/personRows.d.ts.map +1 -0
  28. package/dist/capability/personRows.js +59 -0
  29. package/dist/capability/purge.d.ts +113 -4
  30. package/dist/capability/purge.d.ts.map +1 -1
  31. package/dist/capability/purge.js +138 -5
  32. package/dist/capability/settings.d.ts +10 -0
  33. package/dist/capability/settings.d.ts.map +1 -1
  34. package/dist/capability/vectorIndex.d.ts +36 -0
  35. package/dist/capability/vectorIndex.d.ts.map +1 -0
  36. package/dist/capability/vectorIndex.js +25 -0
  37. package/dist/cli/commandCatalog.generated.d.ts +434 -4
  38. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  39. package/dist/cli/commandCatalog.generated.js +455 -2
  40. package/dist/cli/commandFacts.d.ts +8 -5
  41. package/dist/cli/commandFacts.d.ts.map +1 -1
  42. package/dist/cli/commandFacts.js +12 -3
  43. package/dist/consent/purpose.d.ts +70 -0
  44. package/dist/consent/purpose.d.ts.map +1 -0
  45. package/dist/consent/purpose.js +97 -0
  46. package/dist/consent/reader.d.ts +99 -0
  47. package/dist/consent/reader.d.ts.map +1 -0
  48. package/dist/consent/reader.js +33 -0
  49. package/dist/controlPlane/audit/actions.d.ts +15 -4
  50. package/dist/controlPlane/audit/actions.d.ts.map +1 -1
  51. package/dist/controlPlane/audit/actions.js +16 -5
  52. package/dist/controlPlane/capability.d.ts +10 -0
  53. package/dist/controlPlane/capability.d.ts.map +1 -1
  54. package/dist/controlPlane/capability.js +56 -8
  55. package/dist/controlPlane/context.d.ts +4 -0
  56. package/dist/controlPlane/context.d.ts.map +1 -1
  57. package/dist/controlPlane/context.js +3 -2
  58. package/dist/controlPlane/data/connection.d.ts +21 -2
  59. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  60. package/dist/controlPlane/data/connection.js +13 -4
  61. package/dist/controlPlane/data/connections.d.ts +94 -0
  62. package/dist/controlPlane/data/connections.d.ts.map +1 -0
  63. package/dist/controlPlane/data/connections.js +151 -0
  64. package/dist/controlPlane/data/hold.d.ts +46 -0
  65. package/dist/controlPlane/data/hold.d.ts.map +1 -0
  66. package/dist/controlPlane/data/hold.js +36 -0
  67. package/dist/controlPlane/data/oidcTrust.d.ts +122 -0
  68. package/dist/controlPlane/data/oidcTrust.d.ts.map +1 -0
  69. package/dist/controlPlane/data/oidcTrust.js +173 -0
  70. package/dist/controlPlane/data/tables.d.ts +4 -0
  71. package/dist/controlPlane/data/tables.d.ts.map +1 -1
  72. package/dist/controlPlane/data/tables.js +9 -4
  73. package/dist/controlPlane/data/trustFirstCall.d.ts +16 -0
  74. package/dist/controlPlane/data/trustFirstCall.d.ts.map +1 -0
  75. package/dist/controlPlane/data/trustFirstCall.js +25 -0
  76. package/dist/controlPlane/discovery/adminRoute.d.ts +165 -2
  77. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  78. package/dist/controlPlane/discovery/adminRoute.js +32 -8
  79. package/dist/controlPlane/discovery/routeInput.d.ts +23 -0
  80. package/dist/controlPlane/discovery/routeInput.d.ts.map +1 -0
  81. package/dist/controlPlane/discovery/routeInput.js +80 -0
  82. package/dist/controlPlane/discovery/toolName.d.ts +21 -0
  83. package/dist/controlPlane/discovery/toolName.d.ts.map +1 -0
  84. package/dist/controlPlane/discovery/toolName.js +25 -0
  85. package/dist/controlPlane/erasure/attest.d.ts +78 -0
  86. package/dist/controlPlane/erasure/attest.d.ts.map +1 -0
  87. package/dist/controlPlane/erasure/attest.js +164 -0
  88. package/dist/controlPlane/erasure/read.d.ts +39 -0
  89. package/dist/controlPlane/erasure/read.d.ts.map +1 -0
  90. package/dist/controlPlane/erasure/read.js +140 -0
  91. package/dist/controlPlane/error/errors.d.ts +61 -2
  92. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  93. package/dist/controlPlane/error/errors.js +13 -0
  94. package/dist/controlPlane/evidence/derive.d.ts +124 -0
  95. package/dist/controlPlane/evidence/derive.d.ts.map +1 -0
  96. package/dist/controlPlane/evidence/derive.js +343 -0
  97. package/dist/controlPlane/evidence/mapping.d.ts +85 -0
  98. package/dist/controlPlane/evidence/mapping.d.ts.map +1 -0
  99. package/dist/controlPlane/evidence/mapping.js +114 -0
  100. package/dist/controlPlane/evidence/pack.d.ts +46 -0
  101. package/dist/controlPlane/evidence/pack.d.ts.map +1 -0
  102. package/dist/controlPlane/evidence/pack.js +124 -0
  103. package/dist/controlPlane/http/connections.d.ts +96 -0
  104. package/dist/controlPlane/http/connections.d.ts.map +1 -0
  105. package/dist/controlPlane/http/connections.js +109 -0
  106. package/dist/controlPlane/http/guard.d.ts +23 -2
  107. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  108. package/dist/controlPlane/http/guard.js +103 -7
  109. package/dist/controlPlane/http/handlers.d.ts +212 -10
  110. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  111. package/dist/controlPlane/http/handlers.js +117 -26
  112. package/dist/controlPlane/http/holds.d.ts +57 -0
  113. package/dist/controlPlane/http/holds.d.ts.map +1 -0
  114. package/dist/controlPlane/http/holds.js +98 -0
  115. package/dist/controlPlane/http/presence.d.ts +27 -0
  116. package/dist/controlPlane/http/presence.d.ts.map +1 -0
  117. package/dist/controlPlane/http/presence.js +67 -0
  118. package/dist/controlPlane/http/processing.d.ts +73 -0
  119. package/dist/controlPlane/http/processing.d.ts.map +1 -0
  120. package/dist/controlPlane/http/processing.js +17 -0
  121. package/dist/controlPlane/http/responses.d.ts +927 -3
  122. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  123. package/dist/controlPlane/http/responses.js +283 -7
  124. package/dist/controlPlane/http/routes.d.ts +12 -2
  125. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  126. package/dist/controlPlane/http/routes.js +182 -5
  127. package/dist/controlPlane/http/schemas.d.ts +63 -0
  128. package/dist/controlPlane/http/schemas.d.ts.map +1 -1
  129. package/dist/controlPlane/http/schemas.js +39 -1
  130. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  131. package/dist/controlPlane/http/verify.js +13 -1
  132. package/dist/controlPlane/http/verifyTrust.d.ts +76 -0
  133. package/dist/controlPlane/http/verifyTrust.d.ts.map +1 -0
  134. package/dist/controlPlane/http/verifyTrust.js +75 -0
  135. package/dist/controlPlane/migrations/0004_connection_trusts.d.ts +15 -0
  136. package/dist/controlPlane/migrations/0004_connection_trusts.d.ts.map +1 -0
  137. package/dist/controlPlane/migrations/0004_connection_trusts.js +23 -0
  138. package/dist/controlPlane/migrations/0005_holds.d.ts +12 -0
  139. package/dist/controlPlane/migrations/0005_holds.d.ts.map +1 -0
  140. package/dist/controlPlane/migrations/0005_holds.js +26 -0
  141. package/dist/controlPlane/processing/derive.d.ts +77 -0
  142. package/dist/controlPlane/processing/derive.d.ts.map +1 -0
  143. package/dist/controlPlane/processing/derive.js +206 -0
  144. package/dist/controlPlane/processing/read.d.ts +10 -0
  145. package/dist/controlPlane/processing/read.d.ts.map +1 -0
  146. package/dist/controlPlane/processing/read.js +89 -0
  147. package/dist/controlPlane/scope/kind.d.ts +46 -0
  148. package/dist/controlPlane/scope/kind.d.ts.map +1 -0
  149. package/dist/controlPlane/scope/kind.js +52 -0
  150. package/dist/controlPlane/scope/scope.d.ts +58 -3
  151. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  152. package/dist/controlPlane/scope/scope.js +59 -4
  153. package/dist/controlPlane/token/jws.js +1 -1
  154. package/dist/controlPlane/token/mint.js +1 -1
  155. package/dist/controlPlane/wire.d.ts +23 -0
  156. package/dist/controlPlane/wire.d.ts.map +1 -1
  157. package/dist/controlPlane/wire.js +24 -1
  158. package/dist/createBackend.d.ts +13 -0
  159. package/dist/createBackend.d.ts.map +1 -1
  160. package/dist/createBackend.js +11 -3
  161. package/dist/createEntrypoint.d.ts +14 -1
  162. package/dist/createEntrypoint.d.ts.map +1 -1
  163. package/dist/createEntrypoint.js +19 -5
  164. package/dist/error/http.d.ts +216 -0
  165. package/dist/error/http.d.ts.map +1 -1
  166. package/dist/error/payload.d.ts +432 -0
  167. package/dist/error/payload.d.ts.map +1 -1
  168. package/dist/error/payload.js +144 -0
  169. package/dist/error/pithyError.d.ts +22 -0
  170. package/dist/error/pithyError.d.ts.map +1 -1
  171. package/dist/error/pithyError.js +31 -1
  172. package/dist/http/routeSpec.d.ts +46 -1
  173. package/dist/http/routeSpec.d.ts.map +1 -1
  174. package/dist/http/routeSpec.js +39 -5
  175. package/dist/http/validation.d.ts +11 -2
  176. package/dist/http/validation.d.ts.map +1 -1
  177. package/dist/http/validation.js +20 -3
  178. package/dist/http/verification.d.ts.map +1 -1
  179. package/dist/http/verification.js +1 -1
  180. package/dist/i18n/translator.d.ts +2 -1
  181. package/dist/i18n/translator.d.ts.map +1 -1
  182. package/dist/index.d.ts +1 -1
  183. package/dist/index.d.ts.map +1 -1
  184. package/dist/index.js +3 -3
  185. package/dist/logger/record.d.ts +126 -0
  186. package/dist/logger/record.d.ts.map +1 -1
  187. package/dist/mcp/annotations.d.ts +20 -0
  188. package/dist/mcp/annotations.d.ts.map +1 -0
  189. package/dist/mcp/annotations.js +23 -0
  190. package/dist/mcp/tools.d.ts +75 -0
  191. package/dist/mcp/tools.d.ts.map +1 -0
  192. package/dist/mcp/tools.js +118 -0
  193. package/dist/openapi/document.d.ts.map +1 -1
  194. package/dist/openapi/document.js +8 -26
  195. package/dist/openapi/operations.d.ts +35 -0
  196. package/dist/openapi/operations.d.ts.map +1 -0
  197. package/dist/openapi/operations.js +37 -0
  198. package/dist/presence/channel.d.ts +11 -0
  199. package/dist/presence/channel.d.ts.map +1 -0
  200. package/dist/presence/channel.js +65 -0
  201. package/dist/presence/declaration.d.ts +154 -0
  202. package/dist/presence/declaration.d.ts.map +1 -0
  203. package/dist/presence/declaration.js +164 -0
  204. package/dist/presence/durableObject.d.ts +61 -0
  205. package/dist/presence/durableObject.d.ts.map +1 -0
  206. package/dist/presence/durableObject.js +257 -0
  207. package/dist/presence/frames.d.ts +166 -0
  208. package/dist/presence/frames.d.ts.map +1 -0
  209. package/dist/presence/frames.js +90 -0
  210. package/dist/presence/http/guard.d.ts +11 -0
  211. package/dist/presence/http/guard.d.ts.map +1 -0
  212. package/dist/presence/http/guard.js +20 -0
  213. package/dist/presence/http/responses.d.ts +31 -0
  214. package/dist/presence/http/responses.d.ts.map +1 -0
  215. package/dist/presence/http/responses.js +34 -0
  216. package/dist/presence/http/routes.d.ts +24 -0
  217. package/dist/presence/http/routes.d.ts.map +1 -0
  218. package/dist/presence/http/routes.js +127 -0
  219. package/dist/presence/http/schemas.d.ts +29 -0
  220. package/dist/presence/http/schemas.d.ts.map +1 -0
  221. package/dist/presence/http/schemas.js +28 -0
  222. package/dist/presence/names.d.ts +23 -0
  223. package/dist/presence/names.d.ts.map +1 -0
  224. package/dist/presence/names.js +22 -0
  225. package/dist/presence/room.d.ts +95 -0
  226. package/dist/presence/room.d.ts.map +1 -0
  227. package/dist/presence/room.js +105 -0
  228. package/dist/presence/ticket.d.ts +55 -0
  229. package/dist/presence/ticket.d.ts.map +1 -0
  230. package/dist/presence/ticket.js +109 -0
  231. package/dist/privacy/classify.js +1 -1
  232. package/dist/privacy/encrypted.js +1 -1
  233. package/dist/processing/activity.d.ts +134 -0
  234. package/dist/processing/activity.d.ts.map +1 -0
  235. package/dist/processing/activity.js +161 -0
  236. package/dist/processing/composed.d.ts +18 -0
  237. package/dist/processing/composed.d.ts.map +1 -0
  238. package/dist/processing/composed.js +28 -0
  239. package/dist/processing/vocabulary.d.ts +26 -0
  240. package/dist/processing/vocabulary.d.ts.map +1 -0
  241. package/dist/processing/vocabulary.js +35 -0
  242. package/dist/queue/consumer.d.ts +59 -0
  243. package/dist/queue/consumer.d.ts.map +1 -0
  244. package/dist/queue/consumer.js +46 -0
  245. package/dist/retention/composed.d.ts +37 -0
  246. package/dist/retention/composed.d.ts.map +1 -0
  247. package/dist/retention/composed.js +66 -0
  248. package/dist/retention/holds.d.ts +61 -0
  249. package/dist/retention/holds.d.ts.map +1 -0
  250. package/dist/retention/holds.js +107 -0
  251. package/dist/retention/holdsPurge.d.ts +22 -0
  252. package/dist/retention/holdsPurge.d.ts.map +1 -0
  253. package/dist/retention/holdsPurge.js +50 -0
  254. package/dist/retention/policy.d.ts +132 -0
  255. package/dist/retention/policy.d.ts.map +1 -0
  256. package/dist/retention/policy.js +197 -0
  257. package/dist/session/rotated.d.ts +34 -0
  258. package/dist/session/rotated.d.ts.map +1 -0
  259. package/dist/session/rotated.js +25 -0
  260. package/dist/test-utils/connectionFixtures.d.ts +13 -0
  261. package/dist/test-utils/connectionFixtures.d.ts.map +1 -0
  262. package/dist/test-utils/connectionFixtures.js +58 -0
  263. package/dist/test-utils/interleave.d.ts +12 -0
  264. package/dist/test-utils/interleave.d.ts.map +1 -0
  265. package/dist/test-utils/interleave.js +26 -0
  266. package/dist/testWorker.d.ts +8 -0
  267. package/dist/testWorker.d.ts.map +1 -0
  268. package/dist/testWorker.js +9 -0
  269. package/dist/version.generated.d.ts +1 -1
  270. package/dist/version.generated.js +1 -1
  271. package/dist/workflow/dispatchRoute.js +1 -1
  272. package/package.json +1 -1
  273. package/src/audit/auditEvent.ts +1 -1
  274. package/src/audit/chain.ts +551 -0
  275. package/src/audit/citations.ts +63 -0
  276. package/src/audit/composed.ts +30 -0
  277. package/src/capability/bindings.ts +61 -0
  278. package/src/capability/capability.ts +166 -18
  279. package/src/capability/erasure.ts +82 -0
  280. package/src/capability/personColumns.ts +83 -0
  281. package/src/capability/personRows.ts +125 -0
  282. package/src/capability/purge.ts +208 -8
  283. package/src/capability/settings.ts +7 -0
  284. package/src/capability/vectorIndex.ts +60 -0
  285. package/src/cli/commandCatalog.generated.ts +488 -2
  286. package/src/cli/commandFacts.ts +12 -3
  287. package/src/cloudflare-test.d.ts +3 -1
  288. package/src/consent/purpose.ts +160 -0
  289. package/src/consent/reader.ts +134 -0
  290. package/src/controlPlane/audit/actions.ts +15 -4
  291. package/src/controlPlane/capability.ts +99 -20
  292. package/src/controlPlane/context.ts +10 -2
  293. package/src/controlPlane/data/connection.ts +16 -2
  294. package/src/controlPlane/data/connections.ts +231 -0
  295. package/src/controlPlane/data/hold.ts +52 -0
  296. package/src/controlPlane/data/oidcTrust.ts +249 -0
  297. package/src/controlPlane/data/tables.ts +7 -0
  298. package/src/controlPlane/data/trustFirstCall.ts +37 -0
  299. package/src/controlPlane/discovery/adminRoute.ts +71 -9
  300. package/src/controlPlane/discovery/routeInput.ts +86 -0
  301. package/src/controlPlane/discovery/toolName.ts +23 -0
  302. package/src/controlPlane/erasure/attest.ts +205 -0
  303. package/src/controlPlane/erasure/read.ts +169 -0
  304. package/src/controlPlane/error/errors.ts +69 -2
  305. package/src/controlPlane/evidence/derive.ts +426 -0
  306. package/src/controlPlane/evidence/mapping.ts +118 -0
  307. package/src/controlPlane/evidence/pack.ts +190 -0
  308. package/src/controlPlane/http/connections.ts +129 -0
  309. package/src/controlPlane/http/guard.ts +178 -10
  310. package/src/controlPlane/http/handlers.ts +172 -40
  311. package/src/controlPlane/http/holds.ts +108 -0
  312. package/src/controlPlane/http/presence.ts +71 -0
  313. package/src/controlPlane/http/processing.ts +19 -0
  314. package/src/controlPlane/http/responses.ts +590 -7
  315. package/src/controlPlane/http/routes.ts +302 -3
  316. package/src/controlPlane/http/schemas.ts +126 -0
  317. package/src/controlPlane/http/verify.ts +19 -2
  318. package/src/controlPlane/http/verifyTrust.ts +176 -0
  319. package/src/controlPlane/migrations/0004_connection_trusts.ts +30 -0
  320. package/src/controlPlane/migrations/0005_holds.ts +40 -0
  321. package/src/controlPlane/processing/derive.ts +359 -0
  322. package/src/controlPlane/processing/read.ts +102 -0
  323. package/src/controlPlane/scope/kind.ts +63 -0
  324. package/src/controlPlane/scope/scope.ts +65 -3
  325. package/src/controlPlane/wire.ts +26 -0
  326. package/src/createBackend.ts +31 -6
  327. package/src/createEntrypoint.ts +34 -2
  328. package/src/error/payload.ts +302 -0
  329. package/src/error/pithyError.ts +42 -0
  330. package/src/http/routeSpec.ts +75 -5
  331. package/src/http/validation.ts +35 -3
  332. package/src/http/verification.ts +3 -1
  333. package/src/i18n/translator.ts +2 -1
  334. package/src/index.ts +2 -0
  335. package/src/mcp/annotations.ts +38 -0
  336. package/src/mcp/tools.ts +220 -0
  337. package/src/openapi/document.ts +8 -28
  338. package/src/openapi/operations.ts +62 -0
  339. package/src/presence/channel.ts +46 -0
  340. package/src/presence/declaration.ts +283 -0
  341. package/src/presence/durableObject.ts +287 -0
  342. package/src/presence/frames.ts +153 -0
  343. package/src/presence/http/guard.ts +23 -0
  344. package/src/presence/http/responses.ts +63 -0
  345. package/src/presence/http/routes.ts +165 -0
  346. package/src/presence/http/schemas.ts +39 -0
  347. package/src/presence/names.ts +45 -0
  348. package/src/presence/room.ts +145 -0
  349. package/src/presence/ticket.ts +145 -0
  350. package/src/processing/activity.ts +289 -0
  351. package/src/processing/composed.ts +33 -0
  352. package/src/processing/vocabulary.ts +43 -0
  353. package/src/queue/consumer.ts +104 -0
  354. package/src/retention/composed.ts +75 -0
  355. package/src/retention/holds.ts +175 -0
  356. package/src/retention/holdsPurge.ts +50 -0
  357. package/src/retention/policy.ts +305 -0
  358. package/src/session/rotated.ts +53 -0
  359. package/src/test-utils/connectionFixtures.ts +58 -0
  360. package/src/test-utils/interleave.ts +31 -0
  361. package/src/testWorker.ts +13 -0
  362. package/src/version.generated.ts +1 -1
@@ -0,0 +1,160 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ValidationError } from "../error/pithyError";
6
+ import { PrivacyClassName } from "../privacy/classes";
7
+
8
+ /**
9
+ * Consent purposes: what a person is asked to agree to, declared where the processing is.
10
+ *
11
+ * ## Declared, never discovered
12
+ *
13
+ * A capability that processes under consent says so, the way it states its migrations: `@pithy-sh/email` declares
14
+ * `email.marketing` and `email.tracking`. The adopter adds their own to `consent({ purposes })`. The consent
15
+ * capability composes every declaration into one catalog, and `hasConsent` answers only for a purpose in it.
16
+ *
17
+ * ## A purpose says what it covers and what it demands
18
+ *
19
+ * Beside its notice version, a purpose names the data classes it covers, whether it is **explicit** (GDPR
20
+ * Article 9), the **minimum method** a grant needs, and whether a withdrawal must be made at least at the grade
21
+ * of the grant it withdraws, the way a HIPAA revocation must be in writing.
22
+ *
23
+ * ## The id is the capability's own
24
+ *
25
+ * `<domain>.<name>`, and a capability may declare purposes only under its own `name`, the rule `pithy_<name>_*`
26
+ * tables and `<name>/…` error codes follow, for the same reason: the domain is what keeps two declarations from
27
+ * colliding. `defineCapability` refuses anything else, naming the capability.
28
+ */
29
+
30
+ /**
31
+ * How a grant was given, weakest first. The order is the grade: a purpose's `minimumMethod` refuses a grant below
32
+ * it, and a purpose with `writtenWithdrawal` refuses a withdrawal below the grant it withdraws.
33
+ */
34
+ export const CONSENT_METHODS = ["click", "typed", "drawn", "provider"] as const;
35
+
36
+ /** How a consent was given or withdrawn. */
37
+ export const ConsentMethod = z
38
+ .enum(CONSENT_METHODS)
39
+ .describe(
40
+ "How a consent was given or withdrawn, weakest first: `click` (a checkbox or a button), `typed` (the person typed their name), `drawn` (a drawn signature), or `provider` (an e-signature provider's completed envelope). The order is the grade a purpose's minimum is held against.",
41
+ );
42
+ export type ConsentMethod = z.output<typeof ConsentMethod>;
43
+
44
+ /** A method's grade: its place in {@link CONSENT_METHODS}, so `click` is 0 and `provider` is 3. */
45
+ export function methodRank(method: ConsentMethod): number {
46
+ return CONSENT_METHODS.indexOf(method);
47
+ }
48
+
49
+ /** Whether `method` meets `minimum`: at its grade or above. */
50
+ export function meetsMethod(method: ConsentMethod, minimum: ConsentMethod): boolean {
51
+ return methodRank(method) >= methodRank(minimum);
52
+ }
53
+
54
+ /** A purpose id: `<domain>.<name>`, lowercase, the domain being the declaring capability's name. */
55
+ export const ConsentPurposeId = z
56
+ .string()
57
+ .regex(
58
+ /^[a-z][a-z0-9-]*\.[a-z][a-z0-9_.-]*$/,
59
+ "A consent purpose id is `<domain>.<name>`: lowercase, the domain being the declaring capability's name.",
60
+ )
61
+ .max(128)
62
+ .describe(
63
+ "A consent purpose id, `<domain>.<name>` — `email.marketing`. The domain is the declaring capability's name, or the adopter's own domain for a purpose declared in `consent({ purposes })`.",
64
+ );
65
+ export type ConsentPurposeId = z.output<typeof ConsentPurposeId>;
66
+
67
+ /** One purpose a capability or the adopter processes under consent. */
68
+ export const ConsentPurpose = z
69
+ .object({
70
+ id: ConsentPurposeId,
71
+ summary: z
72
+ .string()
73
+ .min(1)
74
+ .max(500)
75
+ .describe(
76
+ "One sentence, written for the person, on what agreeing allows — `Send me news and offers by email.` Shown on the consent settings screen.",
77
+ ),
78
+ noticeVersion: z
79
+ .number()
80
+ .int()
81
+ .positive()
82
+ .describe(
83
+ "The version of the notice a person agrees to. Bump it when the notice changes: every grant under an older version then needs renewing, and `hasConsent` answers no for it until it is.",
84
+ ),
85
+ classes: z
86
+ .array(PrivacyClassName)
87
+ .default([])
88
+ .describe(
89
+ "The data classes this purpose covers — `pii`, `phi`, or an adopter's own. The processing record reads it to name a classed field with no consent behind it.",
90
+ ),
91
+ explicit: z
92
+ .boolean()
93
+ .default(false)
94
+ .describe(
95
+ "Whether this purpose needs explicit consent (GDPR Article 9), as processing health data does. Recorded so an auditor can see which purposes claim it.",
96
+ ),
97
+ minimumMethod: ConsentMethod.default("click").describe(
98
+ "The weakest method a grant may be given by. A grant below it is refused, and `hasConsent` answers no for one recorded below it before the minimum was raised.",
99
+ ),
100
+ writtenWithdrawal: z
101
+ .boolean()
102
+ .default(false)
103
+ .describe(
104
+ "Whether a withdrawal must be made at least at the grade of the grant it withdraws, as a HIPAA revocation must be in writing. Off, a click withdraws any grant: withdrawing is then never harder than granting.",
105
+ ),
106
+ })
107
+ .describe("One purpose processed under consent: what it allows, its notice version, and what a grant demands.");
108
+ export type ConsentPurpose = z.output<typeof ConsentPurpose>;
109
+ export type ConsentPurposeInput = z.input<typeof ConsentPurpose>;
110
+
111
+ /** The domain half of a purpose id: `email` for `email.marketing`. */
112
+ export function purposeDomain(id: string): string {
113
+ const dot = id.indexOf(".");
114
+ return dot === -1 ? id : id.slice(0, dot);
115
+ }
116
+
117
+ /**
118
+ * Parse one declarer's purposes and refuse what no consent capability could answer for: an id that is not a
119
+ * purpose id, one under a domain that is not the declarer's, or two with one id. `domain` is the declaring
120
+ * capability's name, or `null` for the adopter's own, which may use any domain but a reserved one.
121
+ */
122
+ export function parseConsentPurposes(
123
+ declarer: string,
124
+ purposes: readonly ConsentPurposeInput[],
125
+ domain: string | null,
126
+ reserved: ReadonlySet<string> = new Set(),
127
+ ): ConsentPurpose[] {
128
+ const seen = new Set<string>();
129
+ return purposes.map((input) => {
130
+ const parsed = ConsentPurpose.safeParse(input);
131
+ if (!parsed.success) {
132
+ throw new ValidationError({
133
+ message: `${declarer} declares a consent purpose that is not valid: ${parsed.error.issues[0]?.message ?? "invalid"}.`,
134
+ action: "Give it an id of the form `<domain>.<name>`, a summary, and a positive notice version.",
135
+ });
136
+ }
137
+ const purpose = parsed.data;
138
+ const own = purposeDomain(purpose.id);
139
+ if (domain !== null && own !== domain) {
140
+ throw new ValidationError({
141
+ message: `The ${declarer} capability declares the consent purpose "${purpose.id}" outside its own domain.`,
142
+ action: `Name it under ${domain}. — a capability declares purposes only under its own name.`,
143
+ });
144
+ }
145
+ if (domain === null && reserved.has(own)) {
146
+ throw new ValidationError({
147
+ message: `The consent purpose "${purpose.id}" is under ${own}., which a composed capability owns.`,
148
+ action: "Name your own purposes under your own domain, such as your app's name.",
149
+ });
150
+ }
151
+ if (seen.has(purpose.id)) {
152
+ throw new ValidationError({
153
+ message: `${declarer} declares the consent purpose "${purpose.id}" twice.`,
154
+ action: "Declare each purpose once.",
155
+ });
156
+ }
157
+ seen.add(purpose.id);
158
+ return purpose;
159
+ });
160
+ }
@@ -0,0 +1,134 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "../capability/capability";
5
+ import type { ConsentPurpose } from "./purpose";
6
+
7
+ /**
8
+ * The consent seam: the one question a capability asks before consent-based processing.
9
+ *
10
+ * `@pithy-sh/consent` answers it, and nothing imports that package to ask: a capability finds the composed reader
11
+ * through {@link composedConsent}, the shape `composedAuditEmit` gives the audit recorder, so `@pithy-sh/email`
12
+ * gates its tracking without depending on a sibling (principle 4).
13
+ *
14
+ * **Absent is not "no".** With no consent capability composed there is no record to consult, so
15
+ * {@link composedConsent} answers `null`, and a capability gates nothing on it. `pithy doctor`
16
+ * names that state, because processing that a law may require consent for is running with no record behind it.
17
+ */
18
+
19
+ /** The subject a consent is recorded against: an account's user id, or a visitor's anonymous device id. */
20
+ export interface ConsentSubject {
21
+ /** `account` — a user id from `@pithy-sh/auth`; `visitor` — an anonymous device id held in a first-party cookie. */
22
+ readonly kind: "account" | "visitor";
23
+ /** The id in that kind. */
24
+ readonly id: string;
25
+ }
26
+
27
+ /** What the consent capability answers. */
28
+ export interface ConsentReader {
29
+ /**
30
+ * Whether `subject` holds consent to `purpose` now: a grant under the purpose's current notice version, at or
31
+ * above its minimum method, not withdrawn since, and not expired. An account's answer includes what the visitors
32
+ * merged into it granted. A purpose the catalog does not know answers no.
33
+ */
34
+ hasConsent(subject: ConsentSubject, purpose: string): Promise<boolean>;
35
+ /**
36
+ * Every purpose in the composed catalog, the adopter's included, with how many subjects stand where on it now. What
37
+ * the processing record cites for a consent-based activity. Optional on the seam: a reader without it leaves the
38
+ * counts unknown, never zero.
39
+ */
40
+ standings?(): Promise<readonly ConsentPurposeStanding[]>;
41
+ /**
42
+ * Every purpose in the composed catalog, the adopter's included, read from memory and never from the record. What
43
+ * the processing record checks a consent basis against when `standings` cannot answer. Optional on the seam: a
44
+ * reader without it leaves the catalog unknown, and the record then reports no gap that rests on the catalog.
45
+ */
46
+ purposes?(): readonly ConsentPurpose[];
47
+ }
48
+
49
+ /** One purpose in the catalog, and where its subjects stand on it now. */
50
+ export interface ConsentPurposeStanding {
51
+ /** The purpose as the catalog composed it. */
52
+ readonly purpose: ConsentPurpose;
53
+ /** Subjects whose latest choice is a grant, under any notice version, expired or not. */
54
+ readonly grants: number;
55
+ /** Subjects whose latest choice is a withdrawal. */
56
+ readonly withdrawals: number;
57
+ /**
58
+ * Subjects `hasConsent` answers yes for now: a grant under the current notice version, at or above the purpose's
59
+ * minimum method, unexpired, and for a visitor not merged into an account, inside the visitor window.
60
+ */
61
+ readonly standing: number;
62
+ }
63
+
64
+ /** One withdrawal, as a capability that keeps its own state in step with consent is told of it. */
65
+ export interface ConsentWithdrawal {
66
+ /** Who withdrew. */
67
+ readonly subject: ConsentSubject;
68
+ /** What they withdrew from. */
69
+ readonly purpose: string;
70
+ /** The Worker's bindings for this request. */
71
+ readonly env: Record<string, unknown>;
72
+ /**
73
+ * The subject's email address, read on demand from the capability that owns accounts, or `null` for a visitor
74
+ * or an account with none. A function, so a listener that needs no address reads none.
75
+ */
76
+ readonly address: () => Promise<string | null>;
77
+ }
78
+
79
+ /**
80
+ * Hear of a withdrawal from one of this capability's own purposes. `@pithy-sh/email` suppresses the address for
81
+ * marketing when `email.marketing` is withdrawn, so the suppression list and the consent record never disagree.
82
+ * Called after the withdrawal is recorded; a throw is the listener's own failure and never undoes the record.
83
+ */
84
+ export type ConsentWithdrawnListener = (withdrawal: ConsentWithdrawal) => Promise<void>;
85
+
86
+ /** One grant that now stands, as a capability that keeps its own state in step with consent is told of it. */
87
+ export type ConsentGrant = ConsentWithdrawal;
88
+
89
+ /**
90
+ * Hear of a grant to one of this capability's own purposes, once it stands. The other half of
91
+ * {@link ConsentWithdrawnListener}: `@pithy-sh/email` lifts the marketing suppression a withdrawal wrote when
92
+ * `email.marketing` is granted again, and nothing else, so a person who changes their mind back is heard. Called
93
+ * after the grant is recorded and only when the subject's standing is `granted`; a throw never undoes the record.
94
+ */
95
+ export type ConsentGrantedListener = (grant: ConsentGrant) => Promise<void>;
96
+
97
+ /** Every purpose the composed capabilities declare, in composition order. */
98
+ export function composedConsentPurposes(capabilities: readonly Capability[]): ConsentPurpose[] {
99
+ return capabilities.flatMap((capability) => [...(capability.consentPurposes ?? [])]);
100
+ }
101
+
102
+ /** The composed consent reader for this env, or `null` when no capability provides one. */
103
+ export function composedConsent(
104
+ capabilities: readonly Capability[],
105
+ env: Record<string, unknown>,
106
+ ): ConsentReader | null {
107
+ const provider = capabilities.find((capability) => capability.consentReader !== undefined);
108
+ return provider?.consentReader ? provider.consentReader(env) : null;
109
+ }
110
+
111
+ /** Whether any composed capability provides the consent seam. */
112
+ export function providesConsent(capabilities: readonly Capability[]): boolean {
113
+ return capabilities.some((capability) => capability.consentReader !== undefined);
114
+ }
115
+
116
+ /**
117
+ * The account's address, read through the capability that owns accounts (`@pithy-sh/auth`'s `addressOf`), or
118
+ * `null` where nothing composed can say. Never throws: a listener that cannot learn the address does less, and the
119
+ * withdrawal it heard of stands.
120
+ */
121
+ export async function composedAddress(
122
+ capabilities: readonly Capability[],
123
+ env: Record<string, unknown>,
124
+ subject: ConsentSubject,
125
+ ): Promise<string | null> {
126
+ if (subject.kind !== "account") return null;
127
+ const provider = capabilities.find((capability) => capability.addressOf !== undefined);
128
+ if (!provider?.addressOf) return null;
129
+ try {
130
+ return await provider.addressOf(env, subject.id);
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
@@ -24,8 +24,8 @@ import type { Logger } from "../../logger/logger";
24
24
  * Emission goes through core's `emit` seam (`c.var.emit`), a no-op when the audit capability is
25
25
  * absent, so the seam never imports audit at runtime either (principle 4).
26
26
  *
27
- * **Four of these are emitted by a route; three are emitted by the CLI** (#294). The connection
28
- * lifecycle — registered, updated, removed — is written by `pithy dashboard` opening the adopter's D1
27
+ * **Most of these are emitted by a route; four are emitted by the CLI, one of them by a route as well.** The
28
+ * connection lifecycle — registered, updated, removed — is written by `pithy dashboard` opening the adopter's D1
29
29
  * directly, because those operations never reach their Worker: a first connect has no key to sign with
30
30
  * and the Worker may not be deployed, an update re-points an address the Worker never reads, and a
31
31
  * disconnect withdraws trust, which must not depend on a working connection (docs/CONTROL-PLANE.md §15).
@@ -34,8 +34,12 @@ import type { Logger } from "../../logger/logger";
34
34
  * `control-plane`, meaning the management client called in and proved it, while a CLI-side event carries
35
35
  * the adopter's own operator and no session at all.
36
36
  *
37
- * A declared code that nothing emits is a surface the trail promises and never delivers, which is what
38
- * these three were until #294. `packages/cli/src/ci/auditActions.test.ts` fails the build on one.
37
+ * `connectionRevoked` is the one written from both sides: by `POST {basePath}/connections/:connectionId/revoke`,
38
+ * with `actorType` `control-plane` and the caller's connection, and by `pithy dashboard connections --revoke`, with
39
+ * the operator as actor and no session.
40
+ *
41
+ * A declared code that nothing emits is a surface the trail promises and never delivers.
42
+ * `packages/cli/src/ci/auditActions.test.ts` fails the build on one.
39
43
  */
40
44
  export const ControlPlaneAuditActions = {
41
45
  /** A verified, in-scope management call was admitted. The trail of what the dashboard actually did. */
@@ -52,6 +56,13 @@ export const ControlPlaneAuditActions = {
52
56
  connectionUpdated: "controlplane/connection_updated",
53
57
  /** A connection was removed. The seam returns to its shipped state: connected to nothing, denying everything. */
54
58
  connectionRemoved: "controlplane/connection_removed",
59
+ /**
60
+ * Another connection's keys and trusts were revoked, with a reason: by a management client over the seam, or by the
61
+ * operator at the CLI.
62
+ */
63
+ connectionRevoked: "controlplane/connection_revoked",
64
+ holdPlaced: "controlplane/hold_placed",
65
+ holdReleased: "controlplane/hold_released",
55
66
  } as const;
56
67
 
57
68
  /** One of the control-plane audit action codes. */
@@ -6,14 +6,22 @@ import type { Migration } from "kysely/migration";
6
6
  import type { BindingSpecInput } from "../capability/bindings";
7
7
  import { type Capability, defineCapability, type PithyHonoEnv } from "../capability/capability";
8
8
  import type { DatabaseSpecMap } from "../data/databases";
9
+ import { memoryJwksCache } from "../http/oidcWebhook";
9
10
  import type { KvNamespaceSpecMap, KvRegistry } from "../kv/namespaces";
11
+ import { routeGroupsOf } from "../openapi/routeGroups";
12
+ import type { PresenceDeclaration } from "../presence/declaration";
13
+ import { describePresenceRooms } from "../presence/http/routes";
10
14
  import { readRetirement } from "../privacy/fieldRotation";
11
15
  import { lazyPrivacyInventory } from "../privacy/inventory";
16
+ import { composedRetention } from "../retention/composed";
17
+ import { standingHolds } from "../retention/holds";
18
+ import { holdsUserPurge } from "../retention/holdsPurge";
12
19
  import { PACKAGE_NAME, PACKAGE_VERSION } from "../version.generated";
13
20
  import { ENVIRONMENT_VAR } from "../worker/identity";
14
21
  import { ControlPlaneConfig, type ControlPlaneConfigInput } from "./config/config";
15
22
  import { ControlPlaneConnection } from "./data/connection";
16
23
  import { CONTROL_PLANE_CONNECTIONS_TABLE, type ControlPlaneDatabase, controlPlaneTables } from "./data/tables";
24
+ import { recordTrustFirstCall } from "./data/trustFirstCall";
17
25
  import type { CapabilityDeclaration } from "./discovery/adminRoute";
18
26
  import { type CapabilityHealthSource, capabilityHealthSources, defineCapabilityHealth } from "./discovery/health";
19
27
  import { createControlPlaneVerifier } from "./http/guard";
@@ -27,6 +35,8 @@ import {
27
35
  import { controlplane_0001_init } from "./migrations/0001_init";
28
36
  import { controlplane_0002_management_origin } from "./migrations/0002_management_origin";
29
37
  import { controlplane_0003_connection_principal } from "./migrations/0003_connection_principal";
38
+ import { controlplane_0004_connection_trusts } from "./migrations/0004_connection_trusts";
39
+ import { controlplane_0005_holds } from "./migrations/0005_holds";
30
40
  import { d1ReplayGuard } from "./replay/d1Guard";
31
41
  import type { ReplayGuard } from "./replay/guard";
32
42
  import {
@@ -35,6 +45,7 @@ import {
35
45
  controlPlaneKvNamespaces,
36
46
  kvReplayGuard,
37
47
  } from "./replay/kvGuard";
48
+ import { RETENTION_READ_SCOPE } from "./scope/scope";
38
49
 
39
50
  /**
40
51
  * Where the control-plane registration sorts in the app database. Composed key:
@@ -50,6 +61,17 @@ export const CONTROLPLANE_MIGRATION_ORDER = 1100;
50
61
 
51
62
  export type ControlPlaneOptions = ControlPlaneConfigInput;
52
63
 
64
+ /** The standing legal holds, on the seam's own manifest entry. Behind the scope the holds listing requires. */
65
+ export const ACTIVE_HOLDS_HEALTH_KEY = {
66
+ key: "activeHolds",
67
+ kind: "count",
68
+ states: null,
69
+ scope: RETENTION_READ_SCOPE,
70
+ cost: "indexed",
71
+ summary: "Legal holds standing. While one stands, no purge, prune or inactivity deletion touches its subject.",
72
+ nominal: null,
73
+ } as const;
74
+
53
75
  /** The control-plane capability, with its resolved config attached. */
54
76
  export interface ControlPlaneCapability extends Capability<DatabaseSpecMap, KvNamespaceSpecMap, "controlplane"> {
55
77
  /** The resolved seam config. */
@@ -89,6 +111,8 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
89
111
  "0001_init": controlplane_0001_init,
90
112
  "0002_management_origin": controlplane_0002_management_origin,
91
113
  "0003_connection_principal": controlplane_0003_connection_principal,
114
+ "0004_connection_trusts": controlplane_0004_connection_trusts,
115
+ "0005_holds": controlplane_0005_holds,
92
116
  };
93
117
  const requiredBindings: BindingSpecInput[] = [
94
118
  // The app database — the connections table lives here, beside auth's and audit's. Not the secrets
@@ -119,6 +143,12 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
119
143
 
120
144
  const now = () => new Date();
121
145
 
146
+ // The issuers' key sets for every OIDC trust this Worker honors (#722), held for the life of the isolate and
147
+ // shared by every call. Built once here, never per request: a call naming a real trust and an invented `kid`
148
+ // is anonymous traffic, and without a held set and its refresh window each one would buy a round trip to the
149
+ // issuer — `memoryJwksCache` is what bounds that, and only if it outlives the request.
150
+ const jwksCache = memoryJwksCache({ now });
151
+
122
152
  /**
123
153
  * The replay guard for this request, chosen once by config.
124
154
  *
@@ -152,6 +182,10 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
152
182
  // (#132) — lazily, on the first control-plane read, and once for the isolate. Capturing the list costs
153
183
  // nothing; walking it is deferred, so a Worker whose control plane is never called never walks.
154
184
  let composedCapabilities: readonly Capability[] = [];
185
+
186
+ // Every presence room kind this Worker composed, with the declaration serving it, filled by the same hook.
187
+ // The seam's ticket route reaches every capability's rooms through it.
188
+ let presence: ReadonlyMap<string, PresenceDeclaration> = new Map();
155
189
  const privacy = lazyPrivacyInventory(() => composedCapabilities);
156
190
 
157
191
  const capability = defineCapability({
@@ -186,13 +220,23 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
186
220
  // The seam describes itself too. Built from the same resolved `basePath` the routes mount on, so a
187
221
  // moved mount point is reported rather than becoming a lie a client believes.
188
222
  adminRoutes: controlPlaneRouteDescriptors(config.basePath),
223
+ // A legal hold names the user it preserves, and outlives the purge it allowed: kept, and exported on request.
224
+ purge: [holdsUserPurge()],
189
225
  // Two counts on the seam's own manifest entry, so a client sees *this environment is behind* on the read
190
226
  // it already makes and calls the route only to see what. Behind `migrations:read`, which the route above
191
227
  // requires — a connection without it gets no number rather than a zero. A database that cannot be read
192
228
  // fails the producer, and the seam reports the summary unavailable rather than a partial total.
229
+ // And the count of standing legal holds, behind `retention:read`, which the holds listing requires.
193
230
  health: defineCapabilityHealth({
194
- keys: MIGRATION_HEALTH_KEYS,
195
- read: (c) => readMigrationHealth(c.env as Record<string, unknown>, migrationDatabases),
231
+ keys: [...MIGRATION_HEALTH_KEYS, ACTIVE_HOLDS_HEALTH_KEY],
232
+ read: async (c) => {
233
+ const env = c.env as Record<string, unknown>;
234
+ const [migrations, activeHolds] = await Promise.all([
235
+ readMigrationHealth(env, migrationDatabases),
236
+ standingHolds(env),
237
+ ]);
238
+ return { ...migrations, activeHolds };
239
+ },
196
240
  }),
197
241
  compose: ({ capabilities }) => {
198
242
  // Every capability, including those exposing nothing. A client rendering a capability it cannot
@@ -214,7 +258,16 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
214
258
  // per-request cost: a fact is resolved config, so there is nothing to ask and nothing to fail.
215
259
  configKeys: [...(cap.manifestConfig?.keys ?? [])],
216
260
  config: { ...(cap.manifestConfig?.values ?? {}) },
261
+ // Every room kind, with the paths built from the capability's own resolved base path.
262
+ presenceRooms: cap.presence ? describePresenceRooms(cap.presence) : [],
217
263
  }));
264
+ presence = new Map(
265
+ capabilities.flatMap((cap) =>
266
+ cap.presence
267
+ ? cap.presence.rooms.map((room) => [room.kind, cap.presence as PresenceDeclaration] as const)
268
+ : [],
269
+ ),
270
+ );
218
271
  health = capabilityHealthSources(capabilities);
219
272
  migrationDatabases = composeMigrationDatabases(capabilities);
220
273
  composedCapabilities = [...capabilities];
@@ -227,28 +280,47 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
227
280
  // on a route that has nothing to do with the seam.
228
281
  app.use("*", async (c, next) => {
229
282
  const db = database(c);
283
+ const environment = String((c.env as Record<string, unknown>)[ENVIRONMENT_VAR] ?? "");
230
284
  c.set(
231
285
  "controlPlaneVerifier",
232
- createControlPlaneVerifier({
233
- loadConnection: async (connectionId) => {
234
- const row = await db
235
- .selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE)
236
- .selectAll()
237
- .where("id", "=", connectionId)
238
- .executeTakeFirst();
239
- return row ? ControlPlaneConnection.parse(row) : null;
286
+ createControlPlaneVerifier(
287
+ {
288
+ loadConnection: async (connectionId) => {
289
+ const row = await db
290
+ .selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE)
291
+ .selectAll()
292
+ .where("id", "=", connectionId)
293
+ .executeTakeFirst();
294
+ return row ? ControlPlaneConnection.parse(row) : null;
295
+ },
296
+ countConnections: async () => {
297
+ const rows = await db.selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE).select("id").limit(1).execute();
298
+ return rows.length;
299
+ },
300
+ replay: replay(c),
301
+ // The environment a credential is bound to. Absent off-platform, where the empty string
302
+ // matches no stored connection and every call therefore denies — the right failure.
303
+ environment,
304
+ config,
305
+ now,
240
306
  },
241
- countConnections: async () => {
242
- const rows = await db.selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE).select("id").limit(1).execute();
243
- return rows.length;
307
+ {
308
+ // One connection per environment (docs/CONTROL-PLANE.md §15), so a call under a trust names the
309
+ // trust and the environment names the connection.
310
+ loadEnvironmentConnection: async () => {
311
+ const row = await db
312
+ .selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE)
313
+ .selectAll()
314
+ .where("environment", "=", environment)
315
+ .executeTakeFirst();
316
+ return row ? ControlPlaneConnection.parse(row) : null;
317
+ },
318
+ environment,
319
+ now,
320
+ jwksCache,
321
+ recordFirstCall: (connectionId, trustId, at) => recordTrustFirstCall(db, connectionId, trustId, at),
244
322
  },
245
- replay: replay(c),
246
- // The environment a credential is bound to. Absent off-platform, where the empty string
247
- // matches no stored connection and every call therefore denies — the right failure.
248
- environment: String((c.env as Record<string, unknown>)[ENVIRONMENT_VAR] ?? ""),
249
- config,
250
- now,
251
- }),
323
+ ),
252
324
  );
253
325
  await next();
254
326
  });
@@ -263,6 +335,13 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
263
335
  composedMigrations: () => migrationDatabases,
264
336
  privacyInventory: privacy,
265
337
  retirement: (env) => readRetirement(env, composedCapabilities),
338
+ composedPresence: () => presence,
339
+ composition: () => composedCapabilities,
340
+ // The app these routes mount on is the one `createBackend` records its route table against, once every
341
+ // capability has mounted — so the evidence pack, reading it per call, sees every route (#821).
342
+ routeGroups: () => routeGroupsOf(app),
343
+ // The retention block as this Worker resolved it at assembly, for the manifest.
344
+ retention: composedRetention,
266
345
  now,
267
346
  });
268
347
  },
@@ -52,11 +52,19 @@ export const ControlPlaneContext = z
52
52
  keyId: z
53
53
  .string()
54
54
  .describe(
55
- "Which registered key verified this call. Recorded so a rotation can be traced through the trail, and so a key still in use is visible before it is expired.",
55
+ "Which registered key verified this call — or, for a call under an OIDC trust, which trust. Recorded so a rotation can be traced through the trail, and so a credential still in use is visible before it is retired.",
56
56
  ),
57
57
  tokenId: z
58
58
  .string()
59
- .describe("The token's `jti`, already claimed in the replay set. Recorded to tie one audit event to one call."),
59
+ .describe(
60
+ "The token's `jti`, already claimed in the replay set. Recorded to tie one audit event to one call. For a call under an OIDC trust, the provider token's own `jti` when it carries one, else empty: such a token is verified per call and never spent.",
61
+ ),
62
+ credentialKind: z
63
+ .enum(["key", "trust"])
64
+ .optional()
65
+ .describe(
66
+ "Which kind of credential verified this call: a registered `key`, or an OIDC `trust` (#722), in which case `keyId` names the trust. Absent reads as a key, which is every call made before trusts existed.",
67
+ ),
60
68
  actorKind: ControlPlaneActorKind.nullable().describe(
61
69
  "What kind of client the connection is for — `agent`, `dashboard` or `script` — read from the adopter's own row, never from the token. The gate stamps it onto every control-plane audit row this call writes. Null for a connection registered before the kind was recorded (#721).",
62
70
  ),
@@ -4,6 +4,7 @@
4
4
  import { z } from "zod";
5
5
  import { JsonDate, SQLiteDate, sqliteJson } from "../../data/codecs";
6
6
  import { ControlPlaneScope } from "../scope/scope";
7
+ import { OidcTrust } from "./oidcTrust";
7
8
 
8
9
  /**
9
10
  * The registration record: who may call this Worker's control-plane routes, from where, with what
@@ -67,7 +68,7 @@ export const RegisteredKey = z
67
68
  export type RegisteredKey = z.output<typeof RegisteredKey>;
68
69
 
69
70
  /**
70
- * What kind of client a connection is for (#721): `agent`, `dashboard` or `script`.
71
+ * What kind of client a connection is for: `agent`, `dashboard` or `script`.
71
72
  *
72
73
  * The audit trail's `actorType: "control-plane"` names all three identically, and the questions asked
73
74
  * after an incident are exactly the ones that tell them apart — was it a person at the dashboard, a
@@ -121,7 +122,7 @@ export const MAX_CONNECTION_PURPOSE = 200;
121
122
  const UNRENDERABLE = /[\p{Cc}\p{Bidi_Control}\u2028\u2029\u200B\u2060\uFEFF]/u;
122
123
 
123
124
  /**
124
- * What a connection is for, in the words of whoever registered it (#721).
125
+ * What a connection is for, in the words of whoever registered it.
125
126
  *
126
127
  * **Required at registration, and refused when absent**, because a review listing credentials by key id
127
128
  * cannot support the decision it exists for. **Untrusted input**: a customer writes it and something else
@@ -141,6 +142,14 @@ export const ConnectionPurpose = z
141
142
  );
142
143
  export type ConnectionPurpose = z.output<typeof ConnectionPurpose>;
143
144
 
145
+ /** Which credential kinds a connection holds, live or not. */
146
+ export const ConnectionCredentialKind = z
147
+ .enum(["key", "trust", "both", "none"])
148
+ .describe(
149
+ "Which credential kinds the connection holds, live or not: `key` (registered Ed25519 keys), `trust` (OIDC trusts), `both`, or `none`.",
150
+ );
151
+ export type ConnectionCredentialKind = z.infer<typeof ConnectionCredentialKind>;
152
+
144
153
  /**
145
154
  * One management-client connection — the row in `pithy_controlplane_connections`.
146
155
  *
@@ -208,6 +217,11 @@ export const ControlPlaneConnection = z
208
217
  keys: sqliteJson(z.array(RegisteredKey)).describe(
209
218
  "Every key this connection may sign with, current and superseded. One JSON column rather than a child table, so a rotation is one atomic UPDATE and verification is one read.",
210
219
  ),
220
+ trusts: sqliteJson(z.array(OidcTrust))
221
+ .optional()
222
+ .describe(
223
+ "Every OIDC trust this connection honors, live and revoked (#722) — the second credential kind beside `keys`, so a connection may hold a trust, a key, or both. Optional on the type, and absent reads as none: a connection value built before trusts existed still describes a connection, and the column itself defaults to an empty list.",
224
+ ),
211
225
  createdAt: SQLiteDate.describe("When the connection was registered. Ms-epoch in SQLite, a Date in TypeScript."),
212
226
  updatedAt: SQLiteDate.describe(
213
227
  "When the row last changed — a key registered or expired, or the Worker URL re-pointed.",