@pithy-sh/core 0.13.0 → 0.15.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 (354) 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/capability/bindings.d.ts +22 -0
  10. package/dist/capability/bindings.d.ts.map +1 -1
  11. package/dist/capability/bindings.js +32 -2
  12. package/dist/capability/capability.d.ts +123 -16
  13. package/dist/capability/capability.d.ts.map +1 -1
  14. package/dist/capability/capability.js +17 -2
  15. package/dist/capability/erasure.d.ts +75 -0
  16. package/dist/capability/erasure.d.ts.map +1 -0
  17. package/dist/capability/erasure.js +2 -0
  18. package/dist/capability/manifest.d.ts +5 -0
  19. package/dist/capability/manifest.d.ts.map +1 -1
  20. package/dist/capability/personColumns.d.ts +28 -0
  21. package/dist/capability/personColumns.d.ts.map +1 -0
  22. package/dist/capability/personColumns.js +66 -0
  23. package/dist/capability/personRows.d.ts +62 -0
  24. package/dist/capability/personRows.d.ts.map +1 -0
  25. package/dist/capability/personRows.js +59 -0
  26. package/dist/capability/purge.d.ts +86 -19
  27. package/dist/capability/purge.d.ts.map +1 -1
  28. package/dist/capability/purge.js +83 -5
  29. package/dist/capability/settings.d.ts +10 -0
  30. package/dist/capability/settings.d.ts.map +1 -1
  31. package/dist/capability/vectorIndex.d.ts +36 -0
  32. package/dist/capability/vectorIndex.d.ts.map +1 -0
  33. package/dist/capability/vectorIndex.js +25 -0
  34. package/dist/cli/commandCatalog.generated.d.ts +438 -3
  35. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  36. package/dist/cli/commandCatalog.generated.js +454 -2
  37. package/dist/cli/commandFacts.d.ts +8 -5
  38. package/dist/cli/commandFacts.d.ts.map +1 -1
  39. package/dist/cli/commandFacts.js +12 -3
  40. package/dist/consent/purpose.d.ts +70 -0
  41. package/dist/consent/purpose.d.ts.map +1 -0
  42. package/dist/consent/purpose.js +97 -0
  43. package/dist/consent/reader.d.ts +99 -0
  44. package/dist/consent/reader.d.ts.map +1 -0
  45. package/dist/consent/reader.js +33 -0
  46. package/dist/controlPlane/audit/actions.d.ts +15 -4
  47. package/dist/controlPlane/audit/actions.d.ts.map +1 -1
  48. package/dist/controlPlane/audit/actions.js +16 -5
  49. package/dist/controlPlane/capability.d.ts +10 -0
  50. package/dist/controlPlane/capability.d.ts.map +1 -1
  51. package/dist/controlPlane/capability.js +56 -8
  52. package/dist/controlPlane/context.d.ts +4 -0
  53. package/dist/controlPlane/context.d.ts.map +1 -1
  54. package/dist/controlPlane/context.js +3 -2
  55. package/dist/controlPlane/data/connection.d.ts +21 -2
  56. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  57. package/dist/controlPlane/data/connection.js +13 -4
  58. package/dist/controlPlane/data/connections.d.ts +94 -0
  59. package/dist/controlPlane/data/connections.d.ts.map +1 -0
  60. package/dist/controlPlane/data/connections.js +151 -0
  61. package/dist/controlPlane/data/hold.d.ts +46 -0
  62. package/dist/controlPlane/data/hold.d.ts.map +1 -0
  63. package/dist/controlPlane/data/hold.js +36 -0
  64. package/dist/controlPlane/data/oidcTrust.d.ts +122 -0
  65. package/dist/controlPlane/data/oidcTrust.d.ts.map +1 -0
  66. package/dist/controlPlane/data/oidcTrust.js +173 -0
  67. package/dist/controlPlane/data/tables.d.ts +4 -0
  68. package/dist/controlPlane/data/tables.d.ts.map +1 -1
  69. package/dist/controlPlane/data/tables.js +9 -4
  70. package/dist/controlPlane/data/trustFirstCall.d.ts +16 -0
  71. package/dist/controlPlane/data/trustFirstCall.d.ts.map +1 -0
  72. package/dist/controlPlane/data/trustFirstCall.js +25 -0
  73. package/dist/controlPlane/discovery/adminRoute.d.ts +165 -2
  74. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  75. package/dist/controlPlane/discovery/adminRoute.js +32 -8
  76. package/dist/controlPlane/discovery/routeInput.d.ts +23 -0
  77. package/dist/controlPlane/discovery/routeInput.d.ts.map +1 -0
  78. package/dist/controlPlane/discovery/routeInput.js +80 -0
  79. package/dist/controlPlane/discovery/toolName.d.ts +21 -0
  80. package/dist/controlPlane/discovery/toolName.d.ts.map +1 -0
  81. package/dist/controlPlane/discovery/toolName.js +25 -0
  82. package/dist/controlPlane/erasure/attest.d.ts +78 -0
  83. package/dist/controlPlane/erasure/attest.d.ts.map +1 -0
  84. package/dist/controlPlane/erasure/attest.js +164 -0
  85. package/dist/controlPlane/erasure/read.d.ts +39 -0
  86. package/dist/controlPlane/erasure/read.d.ts.map +1 -0
  87. package/dist/controlPlane/erasure/read.js +140 -0
  88. package/dist/controlPlane/error/errors.d.ts +61 -2
  89. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  90. package/dist/controlPlane/error/errors.js +13 -0
  91. package/dist/controlPlane/evidence/derive.d.ts +124 -0
  92. package/dist/controlPlane/evidence/derive.d.ts.map +1 -0
  93. package/dist/controlPlane/evidence/derive.js +343 -0
  94. package/dist/controlPlane/evidence/mapping.d.ts +85 -0
  95. package/dist/controlPlane/evidence/mapping.d.ts.map +1 -0
  96. package/dist/controlPlane/evidence/mapping.js +114 -0
  97. package/dist/controlPlane/evidence/pack.d.ts +46 -0
  98. package/dist/controlPlane/evidence/pack.d.ts.map +1 -0
  99. package/dist/controlPlane/evidence/pack.js +124 -0
  100. package/dist/controlPlane/http/connections.d.ts +96 -0
  101. package/dist/controlPlane/http/connections.d.ts.map +1 -0
  102. package/dist/controlPlane/http/connections.js +109 -0
  103. package/dist/controlPlane/http/guard.d.ts +23 -2
  104. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  105. package/dist/controlPlane/http/guard.js +69 -12
  106. package/dist/controlPlane/http/handlers.d.ts +212 -10
  107. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  108. package/dist/controlPlane/http/handlers.js +117 -26
  109. package/dist/controlPlane/http/holds.d.ts +57 -0
  110. package/dist/controlPlane/http/holds.d.ts.map +1 -0
  111. package/dist/controlPlane/http/holds.js +98 -0
  112. package/dist/controlPlane/http/presence.d.ts +27 -0
  113. package/dist/controlPlane/http/presence.d.ts.map +1 -0
  114. package/dist/controlPlane/http/presence.js +67 -0
  115. package/dist/controlPlane/http/processing.d.ts +73 -0
  116. package/dist/controlPlane/http/processing.d.ts.map +1 -0
  117. package/dist/controlPlane/http/processing.js +17 -0
  118. package/dist/controlPlane/http/responses.d.ts +927 -3
  119. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  120. package/dist/controlPlane/http/responses.js +283 -7
  121. package/dist/controlPlane/http/routes.d.ts +12 -2
  122. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  123. package/dist/controlPlane/http/routes.js +182 -5
  124. package/dist/controlPlane/http/schemas.d.ts +63 -0
  125. package/dist/controlPlane/http/schemas.d.ts.map +1 -1
  126. package/dist/controlPlane/http/schemas.js +39 -1
  127. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  128. package/dist/controlPlane/http/verify.js +13 -1
  129. package/dist/controlPlane/http/verifyTrust.d.ts +76 -0
  130. package/dist/controlPlane/http/verifyTrust.d.ts.map +1 -0
  131. package/dist/controlPlane/http/verifyTrust.js +75 -0
  132. package/dist/controlPlane/migrations/0004_connection_trusts.d.ts +15 -0
  133. package/dist/controlPlane/migrations/0004_connection_trusts.d.ts.map +1 -0
  134. package/dist/controlPlane/migrations/0004_connection_trusts.js +23 -0
  135. package/dist/controlPlane/migrations/0005_holds.d.ts +12 -0
  136. package/dist/controlPlane/migrations/0005_holds.d.ts.map +1 -0
  137. package/dist/controlPlane/migrations/0005_holds.js +26 -0
  138. package/dist/controlPlane/processing/derive.d.ts +77 -0
  139. package/dist/controlPlane/processing/derive.d.ts.map +1 -0
  140. package/dist/controlPlane/processing/derive.js +206 -0
  141. package/dist/controlPlane/processing/read.d.ts +10 -0
  142. package/dist/controlPlane/processing/read.d.ts.map +1 -0
  143. package/dist/controlPlane/processing/read.js +89 -0
  144. package/dist/controlPlane/scope/kind.d.ts +46 -0
  145. package/dist/controlPlane/scope/kind.d.ts.map +1 -0
  146. package/dist/controlPlane/scope/kind.js +52 -0
  147. package/dist/controlPlane/scope/scope.d.ts +58 -3
  148. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  149. package/dist/controlPlane/scope/scope.js +59 -4
  150. package/dist/controlPlane/token/jws.js +1 -1
  151. package/dist/controlPlane/token/mint.js +1 -1
  152. package/dist/controlPlane/wire.d.ts +23 -0
  153. package/dist/controlPlane/wire.d.ts.map +1 -1
  154. package/dist/controlPlane/wire.js +24 -1
  155. package/dist/createBackend.d.ts +13 -0
  156. package/dist/createBackend.d.ts.map +1 -1
  157. package/dist/createBackend.js +11 -3
  158. package/dist/createEntrypoint.d.ts +7 -0
  159. package/dist/createEntrypoint.d.ts.map +1 -1
  160. package/dist/createEntrypoint.js +4 -0
  161. package/dist/error/http.d.ts +168 -0
  162. package/dist/error/http.d.ts.map +1 -1
  163. package/dist/error/payload.d.ts +336 -0
  164. package/dist/error/payload.d.ts.map +1 -1
  165. package/dist/error/payload.js +112 -0
  166. package/dist/error/pithyError.d.ts +22 -0
  167. package/dist/error/pithyError.d.ts.map +1 -1
  168. package/dist/error/pithyError.js +31 -1
  169. package/dist/http/routeSpec.d.ts +46 -1
  170. package/dist/http/routeSpec.d.ts.map +1 -1
  171. package/dist/http/routeSpec.js +39 -5
  172. package/dist/http/verification.d.ts.map +1 -1
  173. package/dist/http/verification.js +1 -1
  174. package/dist/i18n/translator.d.ts +2 -1
  175. package/dist/i18n/translator.d.ts.map +1 -1
  176. package/dist/index.d.ts +1 -1
  177. package/dist/index.d.ts.map +1 -1
  178. package/dist/index.js +3 -3
  179. package/dist/logger/record.d.ts +98 -0
  180. package/dist/logger/record.d.ts.map +1 -1
  181. package/dist/mcp/annotations.d.ts +20 -0
  182. package/dist/mcp/annotations.d.ts.map +1 -0
  183. package/dist/mcp/annotations.js +23 -0
  184. package/dist/mcp/tools.d.ts +75 -0
  185. package/dist/mcp/tools.d.ts.map +1 -0
  186. package/dist/mcp/tools.js +118 -0
  187. package/dist/openapi/document.d.ts.map +1 -1
  188. package/dist/openapi/document.js +8 -26
  189. package/dist/openapi/operations.d.ts +35 -0
  190. package/dist/openapi/operations.d.ts.map +1 -0
  191. package/dist/openapi/operations.js +37 -0
  192. package/dist/presence/channel.d.ts +11 -0
  193. package/dist/presence/channel.d.ts.map +1 -0
  194. package/dist/presence/channel.js +65 -0
  195. package/dist/presence/declaration.d.ts +154 -0
  196. package/dist/presence/declaration.d.ts.map +1 -0
  197. package/dist/presence/declaration.js +164 -0
  198. package/dist/presence/durableObject.d.ts +61 -0
  199. package/dist/presence/durableObject.d.ts.map +1 -0
  200. package/dist/presence/durableObject.js +257 -0
  201. package/dist/presence/frames.d.ts +166 -0
  202. package/dist/presence/frames.d.ts.map +1 -0
  203. package/dist/presence/frames.js +90 -0
  204. package/dist/presence/http/guard.d.ts +11 -0
  205. package/dist/presence/http/guard.d.ts.map +1 -0
  206. package/dist/presence/http/guard.js +20 -0
  207. package/dist/presence/http/responses.d.ts +31 -0
  208. package/dist/presence/http/responses.d.ts.map +1 -0
  209. package/dist/presence/http/responses.js +34 -0
  210. package/dist/presence/http/routes.d.ts +24 -0
  211. package/dist/presence/http/routes.d.ts.map +1 -0
  212. package/dist/presence/http/routes.js +127 -0
  213. package/dist/presence/http/schemas.d.ts +29 -0
  214. package/dist/presence/http/schemas.d.ts.map +1 -0
  215. package/dist/presence/http/schemas.js +28 -0
  216. package/dist/presence/names.d.ts +23 -0
  217. package/dist/presence/names.d.ts.map +1 -0
  218. package/dist/presence/names.js +22 -0
  219. package/dist/presence/room.d.ts +95 -0
  220. package/dist/presence/room.d.ts.map +1 -0
  221. package/dist/presence/room.js +105 -0
  222. package/dist/presence/ticket.d.ts +55 -0
  223. package/dist/presence/ticket.d.ts.map +1 -0
  224. package/dist/presence/ticket.js +109 -0
  225. package/dist/privacy/classify.js +1 -1
  226. package/dist/privacy/encrypted.js +1 -1
  227. package/dist/processing/activity.d.ts +134 -0
  228. package/dist/processing/activity.d.ts.map +1 -0
  229. package/dist/processing/activity.js +161 -0
  230. package/dist/processing/composed.d.ts +18 -0
  231. package/dist/processing/composed.d.ts.map +1 -0
  232. package/dist/processing/composed.js +28 -0
  233. package/dist/processing/vocabulary.d.ts +26 -0
  234. package/dist/processing/vocabulary.d.ts.map +1 -0
  235. package/dist/processing/vocabulary.js +35 -0
  236. package/dist/queue/consumer.d.ts +59 -0
  237. package/dist/queue/consumer.d.ts.map +1 -0
  238. package/dist/queue/consumer.js +46 -0
  239. package/dist/retention/composed.d.ts +37 -0
  240. package/dist/retention/composed.d.ts.map +1 -0
  241. package/dist/retention/composed.js +66 -0
  242. package/dist/retention/holds.d.ts +61 -0
  243. package/dist/retention/holds.d.ts.map +1 -0
  244. package/dist/retention/holds.js +107 -0
  245. package/dist/retention/holdsPurge.d.ts +22 -0
  246. package/dist/retention/holdsPurge.d.ts.map +1 -0
  247. package/dist/retention/holdsPurge.js +50 -0
  248. package/dist/retention/policy.d.ts +132 -0
  249. package/dist/retention/policy.d.ts.map +1 -0
  250. package/dist/retention/policy.js +197 -0
  251. package/dist/session/rotated.d.ts +34 -0
  252. package/dist/session/rotated.d.ts.map +1 -0
  253. package/dist/session/rotated.js +25 -0
  254. package/dist/test-utils/connectionFixtures.d.ts +13 -0
  255. package/dist/test-utils/connectionFixtures.d.ts.map +1 -0
  256. package/dist/test-utils/connectionFixtures.js +58 -0
  257. package/dist/test-utils/interleave.d.ts +12 -0
  258. package/dist/test-utils/interleave.d.ts.map +1 -0
  259. package/dist/test-utils/interleave.js +26 -0
  260. package/dist/testWorker.d.ts +8 -0
  261. package/dist/testWorker.d.ts.map +1 -0
  262. package/dist/testWorker.js +9 -0
  263. package/dist/version.generated.d.ts +1 -1
  264. package/dist/version.generated.js +1 -1
  265. package/dist/workflow/dispatchRoute.js +1 -1
  266. package/package.json +1 -1
  267. package/src/audit/auditEvent.ts +1 -1
  268. package/src/audit/chain.ts +551 -0
  269. package/src/audit/citations.ts +63 -0
  270. package/src/capability/bindings.ts +61 -0
  271. package/src/capability/capability.ts +156 -19
  272. package/src/capability/erasure.ts +82 -0
  273. package/src/capability/personColumns.ts +83 -0
  274. package/src/capability/personRows.ts +125 -0
  275. package/src/capability/purge.ts +158 -21
  276. package/src/capability/settings.ts +7 -0
  277. package/src/capability/vectorIndex.ts +60 -0
  278. package/src/cli/commandCatalog.generated.ts +487 -2
  279. package/src/cli/commandFacts.ts +12 -3
  280. package/src/cloudflare-test.d.ts +3 -1
  281. package/src/consent/purpose.ts +160 -0
  282. package/src/consent/reader.ts +134 -0
  283. package/src/controlPlane/audit/actions.ts +15 -4
  284. package/src/controlPlane/capability.ts +99 -20
  285. package/src/controlPlane/context.ts +10 -2
  286. package/src/controlPlane/data/connection.ts +16 -2
  287. package/src/controlPlane/data/connections.ts +231 -0
  288. package/src/controlPlane/data/hold.ts +52 -0
  289. package/src/controlPlane/data/oidcTrust.ts +249 -0
  290. package/src/controlPlane/data/tables.ts +7 -0
  291. package/src/controlPlane/data/trustFirstCall.ts +37 -0
  292. package/src/controlPlane/discovery/adminRoute.ts +71 -9
  293. package/src/controlPlane/discovery/routeInput.ts +86 -0
  294. package/src/controlPlane/discovery/toolName.ts +23 -0
  295. package/src/controlPlane/erasure/attest.ts +205 -0
  296. package/src/controlPlane/erasure/read.ts +169 -0
  297. package/src/controlPlane/error/errors.ts +69 -2
  298. package/src/controlPlane/evidence/derive.ts +426 -0
  299. package/src/controlPlane/evidence/mapping.ts +118 -0
  300. package/src/controlPlane/evidence/pack.ts +190 -0
  301. package/src/controlPlane/http/connections.ts +129 -0
  302. package/src/controlPlane/http/guard.ts +118 -9
  303. package/src/controlPlane/http/handlers.ts +172 -40
  304. package/src/controlPlane/http/holds.ts +108 -0
  305. package/src/controlPlane/http/presence.ts +71 -0
  306. package/src/controlPlane/http/processing.ts +19 -0
  307. package/src/controlPlane/http/responses.ts +590 -7
  308. package/src/controlPlane/http/routes.ts +302 -3
  309. package/src/controlPlane/http/schemas.ts +126 -0
  310. package/src/controlPlane/http/verify.ts +19 -2
  311. package/src/controlPlane/http/verifyTrust.ts +176 -0
  312. package/src/controlPlane/migrations/0004_connection_trusts.ts +30 -0
  313. package/src/controlPlane/migrations/0005_holds.ts +40 -0
  314. package/src/controlPlane/processing/derive.ts +359 -0
  315. package/src/controlPlane/processing/read.ts +102 -0
  316. package/src/controlPlane/scope/kind.ts +63 -0
  317. package/src/controlPlane/scope/scope.ts +65 -3
  318. package/src/controlPlane/wire.ts +26 -0
  319. package/src/createBackend.ts +31 -6
  320. package/src/createEntrypoint.ts +17 -0
  321. package/src/error/payload.ts +234 -0
  322. package/src/error/pithyError.ts +42 -0
  323. package/src/http/routeSpec.ts +75 -5
  324. package/src/http/verification.ts +3 -1
  325. package/src/i18n/translator.ts +2 -1
  326. package/src/index.ts +2 -0
  327. package/src/mcp/annotations.ts +38 -0
  328. package/src/mcp/tools.ts +220 -0
  329. package/src/openapi/document.ts +8 -28
  330. package/src/openapi/operations.ts +62 -0
  331. package/src/presence/channel.ts +46 -0
  332. package/src/presence/declaration.ts +283 -0
  333. package/src/presence/durableObject.ts +287 -0
  334. package/src/presence/frames.ts +153 -0
  335. package/src/presence/http/guard.ts +23 -0
  336. package/src/presence/http/responses.ts +63 -0
  337. package/src/presence/http/routes.ts +165 -0
  338. package/src/presence/http/schemas.ts +39 -0
  339. package/src/presence/names.ts +45 -0
  340. package/src/presence/room.ts +145 -0
  341. package/src/presence/ticket.ts +145 -0
  342. package/src/processing/activity.ts +289 -0
  343. package/src/processing/composed.ts +33 -0
  344. package/src/processing/vocabulary.ts +43 -0
  345. package/src/queue/consumer.ts +104 -0
  346. package/src/retention/composed.ts +75 -0
  347. package/src/retention/holds.ts +175 -0
  348. package/src/retention/holdsPurge.ts +50 -0
  349. package/src/retention/policy.ts +305 -0
  350. package/src/session/rotated.ts +53 -0
  351. package/src/test-utils/connectionFixtures.ts +58 -0
  352. package/src/test-utils/interleave.ts +31 -0
  353. package/src/testWorker.ts +13 -0
  354. package/src/version.generated.ts +1 -1
@@ -0,0 +1,107 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { ConflictError, NotFoundError } from "../error/pithyError.js";
4
+ import { SQLiteDate } from "../data/codecs.js";
5
+ import { LegalHold } from "../controlPlane/data/hold.js";
6
+ import { CONTROL_PLANE_HOLDS_TABLE, controlPlaneDatabase } from "../controlPlane/data/tables.js";
7
+ //#region src/retention/holds.ts
8
+ /** Place a hold. Holds stack: a second hold on one subject is its own row, released on its own. */
9
+ async function placeHold(db, input) {
10
+ const hold = {
11
+ id: crypto.randomUUID(),
12
+ subjectType: input.subjectType,
13
+ subjectId: input.subjectId,
14
+ reason: input.reason,
15
+ placedBy: input.actor.subject,
16
+ placedAt: input.at,
17
+ state: "active",
18
+ releasedAt: null,
19
+ releasedBy: null,
20
+ releaseReason: null
21
+ };
22
+ await db.insertInto(CONTROL_PLANE_HOLDS_TABLE).values(LegalHold.encode(hold)).execute();
23
+ return hold;
24
+ }
25
+ /**
26
+ * Release a hold, with a reason. A hold already released is a conflict, never a second release: the row keeps the
27
+ * first one's who and why. An unknown id is a 404.
28
+ */
29
+ async function releaseHold(db, input) {
30
+ const existing = await readHold(db, input.id);
31
+ if (!existing) throw new NotFoundError({
32
+ message: "No hold has that id.",
33
+ detail: `hold ${input.id} not found`
34
+ });
35
+ const result = await db.updateTable(CONTROL_PLANE_HOLDS_TABLE).set({
36
+ state: "released",
37
+ releasedAt: SQLiteDate.encode(input.at),
38
+ releasedBy: input.actor.subject,
39
+ releaseReason: input.reason
40
+ }).where("id", "=", input.id).where("state", "=", "active").executeTakeFirst();
41
+ if (Number(result.numUpdatedRows) === 0) throw new ConflictError({
42
+ message: "That hold is already released.",
43
+ detail: `hold ${input.id} was released before this call`
44
+ });
45
+ return {
46
+ ...existing,
47
+ state: "released",
48
+ releasedAt: input.at,
49
+ releasedBy: input.actor.subject,
50
+ releaseReason: input.reason
51
+ };
52
+ }
53
+ /** One hold by id, or null. */
54
+ async function readHold(db, id) {
55
+ const row = await db.selectFrom(CONTROL_PLANE_HOLDS_TABLE).selectAll().where("id", "=", id).executeTakeFirst();
56
+ return row ? LegalHold.parse(row) : null;
57
+ }
58
+ /** Every hold, active first, newest first within each, at most `limit`. */
59
+ async function listHolds(db, options) {
60
+ let query = db.selectFrom(CONTROL_PLANE_HOLDS_TABLE).selectAll().orderBy("state", "asc").orderBy("placedAt", "desc").orderBy("id", "asc").limit(options.limit);
61
+ if (options.state) query = query.where("state", "=", options.state);
62
+ return (await query.execute()).map((row) => LegalHold.parse(row));
63
+ }
64
+ /** How many holds stand. What the manifest carries. */
65
+ async function countActiveHolds(db) {
66
+ const row = await db.selectFrom(CONTROL_PLANE_HOLDS_TABLE).select((eb) => eb.fn.countAll().as("n")).where("state", "=", "active").executeTakeFirst();
67
+ return Number(row?.n ?? 0);
68
+ }
69
+ /** The app database the control plane keeps its holds in. Fixed, as the seam's own binding is. */
70
+ const HOLDS_BINDING = "DB";
71
+ /** Whether a D1 failure is the hold table not existing — a database no control plane ever migrated. */
72
+ function noHoldTable(error) {
73
+ const text = error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : String(error);
74
+ return /no such table/i.test(text) && /pithy_controlplane_holds/i.test(text);
75
+ }
76
+ /** The holds over one env, read from {@link HOLDS_BINDING}. See the file header for what an unreadable read does. */
77
+ function legalHolds(env) {
78
+ const binding = env["DB"];
79
+ if (typeof binding !== "object" || binding === null) return { held: async () => false };
80
+ const db = controlPlaneDatabase(binding);
81
+ return { held: async (subjectType, subjectId) => {
82
+ try {
83
+ let query = db.selectFrom(CONTROL_PLANE_HOLDS_TABLE).select("id").where("state", "=", "active").where("subjectType", "=", subjectType).limit(1);
84
+ query = subjectId === null ? query.where("subjectId", "is", null) : query.where("subjectId", "=", subjectId);
85
+ return (await query.execute()).length > 0;
86
+ } catch (error) {
87
+ if (noHoldTable(error)) return false;
88
+ throw error;
89
+ }
90
+ } };
91
+ }
92
+ /**
93
+ * How many holds stand in this env's app database — zero where no control plane ever migrated it, since nothing
94
+ * could have placed one there. Any other failure throws, and the manifest reports the summary unavailable.
95
+ */
96
+ async function standingHolds(env) {
97
+ const binding = env["DB"];
98
+ if (typeof binding !== "object" || binding === null) return 0;
99
+ try {
100
+ return await countActiveHolds(controlPlaneDatabase(binding));
101
+ } catch (error) {
102
+ if (noHoldTable(error)) return 0;
103
+ throw error;
104
+ }
105
+ }
106
+ //#endregion
107
+ export { HOLDS_BINDING, countActiveHolds, legalHolds, listHolds, placeHold, readHold, releaseHold, standingHolds };
@@ -0,0 +1,22 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { RetainingPurgeContribution } from "../capability/purge";
4
+ /**
5
+ * What a person's purge means for the legal holds: **nothing is erased, and the export says so and why.**
6
+ *
7
+ * A hold names the user it preserves. The row is the record that a hold stood, from when to when and why it ended,
8
+ * and it is kept after release for exactly that reason; a purge the hold allowed to run does not take the record of
9
+ * the hold with it. The export answers for every hold placed on the person.
10
+ *
11
+ * **Only `user` holds are theirs.** One `subjectId` column names users, organizations and audit tenants, and a
12
+ * tenant may be named by a user's id. **And the export says that a hold stood, never who placed it or why**: the
13
+ * operator who placed or lifted it is another person, and the reasons are counsel's words about a matter the person
14
+ * may be a party to.
15
+ */
16
+ /** The one sentence the purge records with this step. */
17
+ export declare const HOLDS_PURGE_SUMMARY = "Keeps every legal hold placed on the user.";
18
+ /** Why, in the sentence the person is told. */
19
+ export declare const HOLDS_RETENTION_BASIS = "Kept as the record that a legal hold stood, when it was placed and why it ended.";
20
+ /** The user contribution the control plane declares, over the app database its holds live in. */
21
+ export declare function holdsUserPurge(database?: string): RetainingPurgeContribution;
22
+ //# sourceMappingURL=holdsPurge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"holdsPurge.d.ts","sourceRoot":"","sources":["../../src/retention/holdsPurge.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAItE;;;;;;;;;;;GAWG;AAEH,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,+CAA+C,CAAC;AAEhF,+CAA+C;AAC/C,eAAO,MAAM,qBAAqB,qFAAqF,CAAC;AAExH,iGAAiG;AACjG,wBAAgB,cAAc,CAAC,QAAQ,SAAO,GAAG,0BAA0B,CAqB1E"}
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { LegalHold } from "../controlPlane/data/hold.js";
4
+ import { CONTROL_PLANE_HOLDS_TABLE } from "../controlPlane/data/tables.js";
5
+ import { personRowsExport } from "../capability/personRows.js";
6
+ //#region src/retention/holdsPurge.ts
7
+ /**
8
+ * What a person's purge means for the legal holds: **nothing is erased, and the export says so and why.**
9
+ *
10
+ * A hold names the user it preserves. The row is the record that a hold stood, from when to when and why it ended,
11
+ * and it is kept after release for exactly that reason; a purge the hold allowed to run does not take the record of
12
+ * the hold with it. The export answers for every hold placed on the person.
13
+ *
14
+ * **Only `user` holds are theirs.** One `subjectId` column names users, organizations and audit tenants, and a
15
+ * tenant may be named by a user's id. **And the export says that a hold stood, never who placed it or why**: the
16
+ * operator who placed or lifted it is another person, and the reasons are counsel's words about a matter the person
17
+ * may be a party to.
18
+ */
19
+ /** The one sentence the purge records with this step. */
20
+ const HOLDS_PURGE_SUMMARY = "Keeps every legal hold placed on the user.";
21
+ /** Why, in the sentence the person is told. */
22
+ const HOLDS_RETENTION_BASIS = "Kept as the record that a legal hold stood, when it was placed and why it ended.";
23
+ /** The user contribution the control plane declares, over the app database its holds live in. */
24
+ function holdsUserPurge(database = "DB") {
25
+ return {
26
+ subject: "user",
27
+ summary: HOLDS_PURGE_SUMMARY,
28
+ retain: { basis: HOLDS_RETENTION_BASIS },
29
+ export: personRowsExport({
30
+ capability: "controlplane",
31
+ database,
32
+ tables: [{
33
+ table: CONTROL_PLANE_HOLDS_TABLE,
34
+ schema: LegalHold,
35
+ columns: ["subjectId"],
36
+ match: { subjectType: "user" },
37
+ as: "holds",
38
+ omit: [
39
+ "placedBy",
40
+ "releasedBy",
41
+ "reason",
42
+ "releaseReason"
43
+ ],
44
+ orderBy: "placedAt"
45
+ }]
46
+ })
47
+ };
48
+ }
49
+ //#endregion
50
+ export { HOLDS_PURGE_SUMMARY, HOLDS_RETENTION_BASIS, holdsUserPurge };
@@ -0,0 +1,132 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ /**
5
+ * **How long everything is kept, declared in one place.**
6
+ *
7
+ * A Worker's `pithy.config.ts` carries one `retention` block beside its `capabilities`, so the policy is bundled
8
+ * with the code that enforces it and deployed like it. Three things are governed:
9
+ *
10
+ * - **Purge windows.** How long a deleted user or organization stays restorable before its purge. Each capability
11
+ * that owns a deletion keeps its own `retention.purgeAfterDays` as the default; a window stated here replaces it.
12
+ * - **Audit retention.** How long audit events are kept, by action prefix, with a default. Absent means forever.
13
+ * The prune Workflow in `@pithy-sh/audit` removes events past their window and leaves a checkpointed tombstone.
14
+ * - **Class ages.** The longest an account may sit inactive while it holds a privacy class. An account untouched
15
+ * for longer than the strictest class it holds is soft-deleted through the lifecycle that already exists.
16
+ *
17
+ * **The kit holds a floor under the audit trail.** No rule may keep `controlplane/*` or a lifecycle action for
18
+ * less than {@link AUDIT_RETENTION_FLOOR_DAYS} days, and a rule that would is refused when the config loads. The
19
+ * default is not a rule: a short default leaves the floored actions at the floor, and the manifest says so.
20
+ *
21
+ * Nothing here shortens anything at runtime. The policy is code, and no route changes it.
22
+ */
23
+ /** A day, in ms. */
24
+ export declare const MS_PER_DAY = 86400000;
25
+ /** The least the kit keeps a floored audit action: a year. */
26
+ export declare const AUDIT_RETENTION_FLOOR_DAYS = 365;
27
+ /**
28
+ * The action prefixes the floor always covers, whatever is composed. The control plane's own trail — every call,
29
+ * every key, every hold — is the record of who managed the Worker, and it outlives any short default.
30
+ */
31
+ export declare const FLOORED_AUDIT_PREFIXES: readonly ["controlplane/"];
32
+ /**
33
+ * A length of time: whole days as a number, or a count and a unit — `90d`, `12w`, `18m`, `3y`. A month is thirty
34
+ * days and a year is 365.
35
+ */
36
+ export declare const RetentionDuration: z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>;
37
+ export type RetentionDuration = z.infer<typeof RetentionDuration>;
38
+ /** A duration in whole days. */
39
+ export declare function durationDays(value: RetentionDuration): number;
40
+ /** The `retention` block, as a Worker's `pithy.config.ts` writes it. */
41
+ export declare const RetentionPolicy: z.ZodObject<{
42
+ purgeWindows: z.ZodOptional<z.ZodObject<{
43
+ user: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
44
+ organization: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
45
+ }, z.core.$strict>>;
46
+ audit: z.ZodOptional<z.ZodObject<{
47
+ default: z.ZodOptional<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodLiteral<"forever">]>>;
48
+ prefixes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>>;
49
+ }, z.core.$strict>>;
50
+ classAges: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>>;
51
+ }, z.core.$strict>;
52
+ export type RetentionPolicy = z.infer<typeof RetentionPolicy>;
53
+ export type RetentionPolicyInput = z.input<typeof RetentionPolicy>;
54
+ /** Where an effective value came from: this block, a capability's own config, or the kit's floor. */
55
+ export declare const RetentionSource: z.ZodEnum<{
56
+ capability: "capability";
57
+ floor: "floor";
58
+ policy: "policy";
59
+ }>;
60
+ export type RetentionSource = z.infer<typeof RetentionSource>;
61
+ /** The policy as it takes effect: every value resolved to days, and where each came from. */
62
+ export declare const EffectiveRetention: z.ZodObject<{
63
+ purgeWindows: z.ZodArray<z.ZodObject<{
64
+ subject: z.ZodEnum<{
65
+ organization: "organization";
66
+ user: "user";
67
+ }>;
68
+ days: z.ZodNumber;
69
+ source: z.ZodEnum<{
70
+ capability: "capability";
71
+ floor: "floor";
72
+ policy: "policy";
73
+ }>;
74
+ }, z.core.$strip>>;
75
+ audit: z.ZodObject<{
76
+ defaultDays: z.ZodNullable<z.ZodNumber>;
77
+ floorDays: z.ZodNumber;
78
+ rules: z.ZodArray<z.ZodObject<{
79
+ prefix: z.ZodString;
80
+ days: z.ZodNumber;
81
+ source: z.ZodEnum<{
82
+ capability: "capability";
83
+ floor: "floor";
84
+ policy: "policy";
85
+ }>;
86
+ }, z.core.$strip>>;
87
+ floored: z.ZodArray<z.ZodString>;
88
+ }, z.core.$strip>;
89
+ classAges: z.ZodArray<z.ZodObject<{
90
+ class: z.ZodString;
91
+ days: z.ZodNumber;
92
+ }, z.core.$strip>>;
93
+ }, z.core.$strip>;
94
+ export type EffectiveRetention = z.infer<typeof EffectiveRetention>;
95
+ /** A capability's own soft-delete window, the default a policy may replace. */
96
+ export interface PurgeWindowDefault {
97
+ readonly subject: "user" | "organization";
98
+ readonly days: number;
99
+ }
100
+ /** What resolving needs from the composition: each capability's window, and the lifecycle actions it audits. */
101
+ export interface RetentionComposition {
102
+ /** Each composed capability's own soft-delete window, in composition order. */
103
+ readonly purgeWindows: readonly PurgeWindowDefault[];
104
+ /** Every lifecycle audit action the composed capabilities record — the floor covers each. */
105
+ readonly lifecycleActions: readonly string[];
106
+ }
107
+ /**
108
+ * Parse a `retention` block, or refuse it naming what is wrong. `undefined` is the empty policy: every capability's
109
+ * own window, an audit trail kept forever, no class ages.
110
+ */
111
+ export declare function parseRetentionPolicy(input: unknown): RetentionPolicy;
112
+ /**
113
+ * The policy as it takes effect over a composition — or refused, when a rule would keep a floored action for less
114
+ * than the floor. Called when a Worker assembles, so a policy that would prune the control plane's trail early never
115
+ * deploys.
116
+ */
117
+ export declare function resolveRetention(policy: RetentionPolicy, composition: RetentionComposition): EffectiveRetention;
118
+ /**
119
+ * How many days an audit event with this action is kept, or `null` for forever. The longest matching rule decides;
120
+ * with none, the default. A floored action is never kept under the floor, whichever decided.
121
+ */
122
+ export declare function auditWindowDays(retention: EffectiveRetention, action: string): number | null;
123
+ /** Whether the policy prunes any audit event at all. */
124
+ export declare function prunesAudit(retention: EffectiveRetention): boolean;
125
+ /** The soft-delete window for a subject, in days, or `undefined` when no composed capability runs one. */
126
+ export declare function purgeWindowDays(retention: EffectiveRetention, subject: "user" | "organization"): number | undefined;
127
+ /**
128
+ * The longest an account may sit inactive given the classes it holds, in days — the strictest class age among
129
+ * them — or `null` when no class it holds has an age.
130
+ */
131
+ export declare function inactivityDays(retention: EffectiveRetention, held: readonly string[]): number | null;
132
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/retention/policy.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;;;;;;;;;;GAkBG;AAEH,oBAAoB;AACpB,eAAO,MAAM,UAAU,WAAa,CAAC;AAErC,8DAA8D;AAC9D,eAAO,MAAM,0BAA0B,MAAM,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,sBAAsB,YAAI,eAAe,CAAU,CAAC;AAQjE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,iDAO3B,CAAC;AACJ,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAElE,gCAAgC;AAChC,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,MAAM,CAI7D;AAaD,wEAAwE;AACxE,eAAO,MAAM,eAAe;;;;;;;;;;kBA0CgF,CAAC;AAC7G,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAC9D,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAEnE,qGAAqG;AACrG,eAAO,MAAM,eAAe;;;;EAIzB,CAAC;AACJ,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,6FAA6F;AAC7F,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAuD4E,CAAC;AAC5G,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,+EAA+E;AAC/E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,CAAC;IAC1C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,gHAAgH;AAChH,MAAM,WAAW,oBAAoB;IACnC,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACrD,6FAA6F;IAC7F,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9C;AAOD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,eAAe,CAUpE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,eAAe,EAAE,WAAW,EAAE,oBAAoB,GAAG,kBAAkB,CA0C/G;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,kBAAkB,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM5F;AAED,wDAAwD;AACxD,wBAAgB,WAAW,CAAC,SAAS,EAAE,kBAAkB,GAAG,OAAO,CAElE;AAED,0GAA0G;AAC1G,wBAAgB,eAAe,CAAC,SAAS,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,GAAG,cAAc,GAAG,MAAM,GAAG,SAAS,CAEnH;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,kBAAkB,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CAGpG"}
@@ -0,0 +1,197 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { ValidationError, fromZodError } from "../error/pithyError.js";
4
+ import { PrivacyClassName } from "../privacy/classes.js";
5
+ import { z } from "zod";
6
+ //#region src/retention/policy.ts
7
+ /**
8
+ * **How long everything is kept, declared in one place.**
9
+ *
10
+ * A Worker's `pithy.config.ts` carries one `retention` block beside its `capabilities`, so the policy is bundled
11
+ * with the code that enforces it and deployed like it. Three things are governed:
12
+ *
13
+ * - **Purge windows.** How long a deleted user or organization stays restorable before its purge. Each capability
14
+ * that owns a deletion keeps its own `retention.purgeAfterDays` as the default; a window stated here replaces it.
15
+ * - **Audit retention.** How long audit events are kept, by action prefix, with a default. Absent means forever.
16
+ * The prune Workflow in `@pithy-sh/audit` removes events past their window and leaves a checkpointed tombstone.
17
+ * - **Class ages.** The longest an account may sit inactive while it holds a privacy class. An account untouched
18
+ * for longer than the strictest class it holds is soft-deleted through the lifecycle that already exists.
19
+ *
20
+ * **The kit holds a floor under the audit trail.** No rule may keep `controlplane/*` or a lifecycle action for
21
+ * less than {@link AUDIT_RETENTION_FLOOR_DAYS} days, and a rule that would is refused when the config loads. The
22
+ * default is not a rule: a short default leaves the floored actions at the floor, and the manifest says so.
23
+ *
24
+ * Nothing here shortens anything at runtime. The policy is code, and no route changes it.
25
+ */
26
+ /** A day, in ms. */
27
+ const MS_PER_DAY = 864e5;
28
+ /** The least the kit keeps a floored audit action: a year. */
29
+ const AUDIT_RETENTION_FLOOR_DAYS = 365;
30
+ /**
31
+ * The action prefixes the floor always covers, whatever is composed. The control plane's own trail — every call,
32
+ * every key, every hold — is the record of who managed the Worker, and it outlives any short default.
33
+ */
34
+ const FLOORED_AUDIT_PREFIXES = ["controlplane/"];
35
+ /** Days per unit of a duration string. A month is thirty days and a year is 365: a policy, not a calendar. */
36
+ const UNIT_DAYS = {
37
+ d: 1,
38
+ w: 7,
39
+ m: 30,
40
+ y: 365
41
+ };
42
+ /**
43
+ * A length of time: whole days as a number, or a count and a unit — `90d`, `12w`, `18m`, `3y`. A month is thirty
44
+ * days and a year is 365.
45
+ */
46
+ const RetentionDuration = z.union([z.number().int().min(1).max(36500), z.string().regex(/^[1-9]\d{0,4}[dwmy]$/, "A duration is a whole number and a unit: `90d`, `12w`, `18m` or `3y`.")]).describe("A length of time: whole days as a number, or a count and a unit — `90d`, `12w`, `18m`, `3y`. A month is thirty days and a year is 365.");
47
+ /** A duration in whole days. */
48
+ function durationDays(value) {
49
+ if (typeof value === "number") return value;
50
+ const unit = value.slice(-1);
51
+ return Number(value.slice(0, -1)) * UNIT_DAYS[unit];
52
+ }
53
+ /** An audit action prefix: a domain, a slash, and as much of the reason as the rule means. */
54
+ const AuditActionPrefix = z.string().regex(/^[a-z][a-z0-9_]*\/[a-z0-9_.]*$/, "An audit prefix is a domain and a slash, optionally followed by the start of a reason: `auth/`, `support/thread_`.").describe("An audit action prefix: a domain and a slash, optionally the start of a reason — `auth/`, `support/thread_`. The longest prefix matching an action decides its window.");
55
+ /** The `retention` block, as a Worker's `pithy.config.ts` writes it. */
56
+ const RetentionPolicy = z.object({
57
+ purgeWindows: z.object({
58
+ user: RetentionDuration.optional().describe("How long a deleted account stays restorable before its purge. Replaces `auth({ retention: { purgeAfterDays } })`."),
59
+ organization: RetentionDuration.optional().describe("How long a deleted organization stays restorable before its purge. Replaces `organization({ retention: { purgeAfterDays } })`.")
60
+ }).strict().optional().describe("Every soft-delete window, in one place. A subject left out keeps the window its own capability configures."),
61
+ audit: z.object({
62
+ default: z.union([RetentionDuration, z.literal("forever")]).optional().describe("How long an audit event no prefix names is kept. Absent, or `forever`, keeps it forever. Never applied under the floor to a floored action."),
63
+ prefixes: z.record(AuditActionPrefix, RetentionDuration).optional().describe("How long events are kept by action prefix. The longest matching prefix wins. A rule under 365 days that covers `controlplane/*` or a lifecycle action is refused.")
64
+ }).strict().optional().describe("How long the audit trail is kept. Absent keeps everything forever."),
65
+ classAges: z.record(PrivacyClassName, RetentionDuration).optional().describe("The longest an account may sit inactive — no sign-in, no session — while it holds a privacy class: `{ pii: \"3y\" }`. Past the strictest class it holds, it is soft-deleted through the lifecycle, with its notice mail and its purge window.")
66
+ }).strict().describe("How long this Worker keeps deleted accounts, the audit trail and each class of personal data.");
67
+ /** Where an effective value came from: this block, a capability's own config, or the kit's floor. */
68
+ const RetentionSource = z.enum([
69
+ "policy",
70
+ "capability",
71
+ "floor"
72
+ ]).describe("Where the value came from: `policy`, the `retention` block; `capability`, the capability's own config, because the block names none; `floor`, the kit's minimum for the audit trail.");
73
+ /** The policy as it takes effect: every value resolved to days, and where each came from. */
74
+ const EffectiveRetention = z.object({
75
+ purgeWindows: z.array(z.object({
76
+ subject: z.enum(["user", "organization"]).describe("Whose deletion the window governs."),
77
+ days: z.number().int().min(0).describe("How many days a deletion stays restorable before its purge."),
78
+ source: RetentionSource
79
+ }).describe("One soft-delete window as it takes effect.")).describe("Every soft-delete window a composed capability runs, in composition order."),
80
+ audit: z.object({
81
+ defaultDays: z.number().int().min(1).nullable().describe("How long an event no rule names is kept, in days. Null keeps it forever."),
82
+ floorDays: z.number().int().min(1).describe("The least the kit keeps `controlplane/*` and every lifecycle action, in days."),
83
+ rules: z.array(z.object({
84
+ prefix: z.string().describe("The action prefix, or a floored action in full."),
85
+ days: z.number().int().min(1).describe("How long matching events are kept, in days."),
86
+ source: RetentionSource
87
+ }).describe("One audit rule as it takes effect.")).describe("Every rule, longest prefix first. A `floor` rule is the kit holding a floored action at the floor under a shorter default."),
88
+ floored: z.array(z.string()).describe("Every prefix and action the floor covers: `controlplane/` and each composed lifecycle action.")
89
+ }).describe("How long the audit trail is kept."),
90
+ classAges: z.array(z.object({
91
+ class: PrivacyClassName.describe("The privacy class."),
92
+ days: z.number().int().min(1).describe("The longest an account holding it may sit inactive, in days.")
93
+ }).describe("One class age.")).describe("Every class age the block declares, strictest first.")
94
+ }).describe("This Worker's retention, as it takes effect: every window in days, and where each came from.");
95
+ /** Whether a rule's prefix would decide the window of a floored prefix or action. */
96
+ function covers(prefix, floored) {
97
+ return floored.startsWith(prefix) || prefix.startsWith(floored);
98
+ }
99
+ /**
100
+ * Parse a `retention` block, or refuse it naming what is wrong. `undefined` is the empty policy: every capability's
101
+ * own window, an audit trail kept forever, no class ages.
102
+ */
103
+ function parseRetentionPolicy(input) {
104
+ if (input === void 0) return {};
105
+ const parsed = RetentionPolicy.safeParse(input);
106
+ if (!parsed.success) throw fromZodError(parsed.error, {
107
+ message: "The retention block in pithy.config.ts does not parse.",
108
+ action: "Fix the field named, then deploy again."
109
+ });
110
+ return parsed.data;
111
+ }
112
+ /**
113
+ * The policy as it takes effect over a composition — or refused, when a rule would keep a floored action for less
114
+ * than the floor. Called when a Worker assembles, so a policy that would prune the control plane's trail early never
115
+ * deploys.
116
+ */
117
+ function resolveRetention(policy, composition) {
118
+ const floored = [...FLOORED_AUDIT_PREFIXES, ...new Set(composition.lifecycleActions)];
119
+ const rules = Object.entries(policy.audit?.prefixes ?? {}).map(([prefix, duration]) => ({
120
+ prefix,
121
+ days: durationDays(duration),
122
+ source: "policy"
123
+ }));
124
+ for (const rule of rules) {
125
+ if (rule.days >= 365) continue;
126
+ const hit = floored.find((each) => covers(rule.prefix, each));
127
+ if (hit !== void 0) throw new ValidationError({
128
+ message: `The retention block keeps ${rule.prefix} for ${rule.days} days, under the kit's floor of 365 for ${hit}.`,
129
+ action: `Keep it at least 365 days, or name a narrower prefix that leaves ${hit} out.`,
130
+ detail: `audit retention rule ${rule.prefix}=${rule.days}d covers floored ${hit}`
131
+ });
132
+ }
133
+ const fallback = policy.audit?.default;
134
+ const defaultDays = fallback === void 0 || fallback === "forever" ? null : durationDays(fallback);
135
+ if (defaultDays !== null && defaultDays < 365) {
136
+ for (const each of floored) if (!rules.some((rule) => each.startsWith(rule.prefix))) rules.push({
137
+ prefix: each,
138
+ days: 365,
139
+ source: "floor"
140
+ });
141
+ }
142
+ rules.sort((a, b) => b.prefix.length - a.prefix.length || a.prefix.localeCompare(b.prefix));
143
+ const windows = policy.purgeWindows ?? {};
144
+ const purgeWindows = composition.purgeWindows.map(({ subject, days }) => {
145
+ const stated = windows[subject];
146
+ return stated === void 0 ? {
147
+ subject,
148
+ days,
149
+ source: "capability"
150
+ } : {
151
+ subject,
152
+ days: durationDays(stated),
153
+ source: "policy"
154
+ };
155
+ });
156
+ const classAges = Object.entries(policy.classAges ?? {}).map(([name, duration]) => ({
157
+ class: name,
158
+ days: durationDays(duration)
159
+ })).sort((a, b) => a.days - b.days || a.class.localeCompare(b.class));
160
+ return {
161
+ purgeWindows,
162
+ audit: {
163
+ defaultDays,
164
+ floorDays: 365,
165
+ rules,
166
+ floored
167
+ },
168
+ classAges
169
+ };
170
+ }
171
+ /**
172
+ * How many days an audit event with this action is kept, or `null` for forever. The longest matching rule decides;
173
+ * with none, the default. A floored action is never kept under the floor, whichever decided.
174
+ */
175
+ function auditWindowDays(retention, action) {
176
+ const days = retention.audit.rules.find((each) => action.startsWith(each.prefix))?.days ?? retention.audit.defaultDays;
177
+ if (days === null) return null;
178
+ return retention.audit.floored.some((each) => action.startsWith(each)) ? Math.max(days, retention.audit.floorDays) : days;
179
+ }
180
+ /** Whether the policy prunes any audit event at all. */
181
+ function prunesAudit(retention) {
182
+ return retention.audit.defaultDays !== null || retention.audit.rules.some((rule) => rule.source === "policy");
183
+ }
184
+ /** The soft-delete window for a subject, in days, or `undefined` when no composed capability runs one. */
185
+ function purgeWindowDays(retention, subject) {
186
+ return retention.purgeWindows.find((each) => each.subject === subject)?.days;
187
+ }
188
+ /**
189
+ * The longest an account may sit inactive given the classes it holds, in days — the strictest class age among
190
+ * them — or `null` when no class it holds has an age.
191
+ */
192
+ function inactivityDays(retention, held) {
193
+ const ages = retention.classAges.filter((age) => held.includes(age.class)).map((age) => age.days);
194
+ return ages.length === 0 ? null : Math.min(...ages);
195
+ }
196
+ //#endregion
197
+ export { AUDIT_RETENTION_FLOOR_DAYS, EffectiveRetention, FLOORED_AUDIT_PREFIXES, MS_PER_DAY, RetentionDuration, RetentionPolicy, RetentionSource, auditWindowDays, durationDays, inactivityDays, parseRetentionPolicy, prunesAudit, purgeWindowDays, resolveRetention };
@@ -0,0 +1,34 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Capability } from "../capability/capability";
4
+ /**
5
+ * One session replaced by its successor: a refresh-credential rotation. The capability that owns sessions
6
+ * announces it once the presented session is spent and the successor stands, so a capability that keyed
7
+ * state on the old id can carry it to the new one.
8
+ */
9
+ export interface SessionRotation {
10
+ /** The session that was presented and is now gone. */
11
+ readonly previousSessionId: string;
12
+ /** The session that replaces it. */
13
+ readonly sessionId: string;
14
+ /** Whose sessions both are. A listener matches on it as well as the id. */
15
+ readonly userId: string;
16
+ /** The Worker's bindings for this request. */
17
+ readonly env: Record<string, unknown>;
18
+ }
19
+ /**
20
+ * Hear of a rotation. Called after the successor exists and the presented session is gone; a throw is the
21
+ * listener's own failure, and the rotation it reports has already happened.
22
+ */
23
+ export type SessionRotatedListener = (rotation: SessionRotation) => Promise<void>;
24
+ /** A listener that threw, named by its capability. */
25
+ export interface SessionRotatedFailure {
26
+ readonly capability: string;
27
+ readonly error: unknown;
28
+ }
29
+ /**
30
+ * Tell every composed capability that declares {@link Capability.sessionRotated}, in composition order. Each
31
+ * listener runs whatever the one before it did; the failures come back for the caller to log.
32
+ */
33
+ export declare function announceSessionRotated(capabilities: readonly Capability[], rotation: SessionRotation): Promise<SessionRotatedFailure[]>;
34
+ //# sourceMappingURL=rotated.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rotated.d.ts","sourceRoot":"","sources":["../../src/session/rotated.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,oCAAoC;IACpC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED;;;GAGG;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,QAAQ,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAElF,sDAAsD;AACtD,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,YAAY,EAAE,SAAS,UAAU,EAAE,EACnC,QAAQ,EAAE,eAAe,GACxB,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAYlC"}
@@ -0,0 +1,25 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/session/rotated.ts
4
+ /**
5
+ * Tell every composed capability that declares {@link Capability.sessionRotated}, in composition order. Each
6
+ * listener runs whatever the one before it did; the failures come back for the caller to log.
7
+ */
8
+ async function announceSessionRotated(capabilities, rotation) {
9
+ const failures = [];
10
+ for (const capability of capabilities) {
11
+ const listener = capability.sessionRotated;
12
+ if (!listener) continue;
13
+ try {
14
+ await listener(rotation);
15
+ } catch (error) {
16
+ failures.push({
17
+ capability: capability.name,
18
+ error
19
+ });
20
+ }
21
+ }
22
+ return failures;
23
+ }
24
+ //#endregion
25
+ export { announceSessionRotated };
@@ -0,0 +1,13 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { ControlPlaneConnection, RegisteredKey } from "../controlPlane/data/connection";
4
+ import type { OidcTrust } from "../controlPlane/data/oidcTrust";
5
+ /** The instant the connection fixtures are read at. */
6
+ export declare const FIXTURE_NOW: Date;
7
+ /** A registered key, live at {@link FIXTURE_NOW} unless overridden. */
8
+ export declare function fixtureKey(keyId: string, overrides?: Partial<RegisteredKey>): RegisteredKey;
9
+ /** An OIDC trust, live at {@link FIXTURE_NOW} unless overridden. */
10
+ export declare function fixtureTrust(trustId: string, overrides?: Partial<OidcTrust>): OidcTrust;
11
+ /** A connection authorized by ada@example.com, holding one live key, unless overridden. */
12
+ export declare function fixtureConnection(overrides?: Partial<ControlPlaneConnection>): ControlPlaneConnection;
13
+ //# sourceMappingURL=connectionFixtures.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connectionFixtures.d.ts","sourceRoot":"","sources":["../../src/test-utils/connectionFixtures.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAC7F,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAEhE,uDAAuD;AACvD,eAAO,MAAM,WAAW,MAAuC,CAAC;AAGhE,uEAAuE;AACvE,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,GAAE,OAAO,CAAC,aAAa,CAAM,GAAG,aAAa,CAS/F;AAED,oEAAoE;AACpE,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,GAAE,OAAO,CAAC,SAAS,CAAM,GAAG,SAAS,CAa3F;AAED,2FAA2F;AAC3F,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,OAAO,CAAC,sBAAsB,CAAM,GAAG,sBAAsB,CAkBzG"}