@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,125 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { type Expression, type ExpressionBuilder, type Kysely, type SqlBool, sql } from "kysely";
6
+ import type { z } from "zod";
7
+ import { createDatabase } from "../data/db";
8
+ import { type ErasingPurgeContribution, exportRows, type PurgeContext, purgeBinding } from "./purge";
9
+
10
+ /**
11
+ * The purge contribution most capabilities need, written once (#814): rows in D1 that name a person by a column,
12
+ * exported on an access request and deleted by a purge.
13
+ *
14
+ * `@pithy-sh/rating`'s ratings, `@pithy-sh/leaderboard`'s entries, `@pithy-sh/matchmaking`'s friendships — each is
15
+ * a table whose rows carry the person's id in a column, and nothing else to do with them than read them out and
16
+ * delete them. A capability with more to do — bytes in R2, an address rather than an id, a row that must be
17
+ * anonymized — writes its own contribution with the same types.
18
+ *
19
+ * **One transaction.** The deletes are one `d1.batch()`, in the order the tables are listed, so a failure leaves
20
+ * every row where it was and the retried step erases the lot. **Idempotent**: a second run deletes nothing.
21
+ */
22
+
23
+ /** One table a person's rows are in, and how they are found and exported. */
24
+ export interface PersonTable {
25
+ /** The table's camelCase name, as the capability's tables map keys it. */
26
+ readonly table: string;
27
+ /** Its row schema. Rows are decoded through it, so the export carries the app shape and never SQLite's. */
28
+ readonly schema: z.ZodObject;
29
+ /** The columns that name the person. A row is theirs when any of them is the subject's id. */
30
+ readonly columns: readonly string[];
31
+ /**
32
+ * Columns a row must also hold exactly — `{ subjectType: "user" }`, where one id column names people and
33
+ * organizations both and only a person's rows are theirs.
34
+ */
35
+ readonly match?: Readonly<Record<string, string>>;
36
+ /** The key the rows are exported under — `ratings`, `entries`. */
37
+ readonly as: string;
38
+ /** A column to read them in, so an export reads the same twice. */
39
+ readonly orderBy?: string;
40
+ /** Columns the export leaves out — a credential, or another person's data the row carries beside theirs. */
41
+ readonly omit?: readonly string[];
42
+ /**
43
+ * A per-row redaction, for a credential that sits in a column only some rows hold it in — a Google Play purchase
44
+ * token keys a purchase row on that rail alone. It is handed the decoded row, before `omit` drops any column, and
45
+ * returns the row to export.
46
+ */
47
+ readonly redact?: (row: Record<string, unknown>) => Record<string, unknown>;
48
+ }
49
+
50
+ /** What {@link personRowsPurge} is built from. */
51
+ export interface PersonRowsPurgeInput {
52
+ /** The capability, for the fault a missing binding raises. */
53
+ readonly capability: string;
54
+ /** The one sentence the purge records with this step. */
55
+ readonly summary: string;
56
+ /** The D1 binding the tables live in. */
57
+ readonly database?: string;
58
+ /** The tables, in the order a purge deletes from them — children before the rows they reference. */
59
+ readonly tables: readonly PersonTable[];
60
+ }
61
+
62
+ /** An untyped Kysely: each table is named by the input, not by a typed map. */
63
+ type Untyped = Kysely<Record<string, Record<string, unknown>>>;
64
+
65
+ /** `(column = id OR column = id …) AND match…` over a table's person columns. */
66
+ function theirs(
67
+ { columns, match }: Pick<PersonTable, "columns" | "match">,
68
+ id: string,
69
+ ): (eb: ExpressionBuilder<Record<string, Record<string, unknown>>, string>) => Expression<SqlBool> {
70
+ return (eb) =>
71
+ eb.and([
72
+ eb.or(columns.map((column) => eb(sql.ref(column), "=", id))),
73
+ ...Object.entries(match ?? {}).map(([column, value]) => eb(sql.ref(column), "=", value)),
74
+ ]);
75
+ }
76
+
77
+ /**
78
+ * The export half alone: every row naming the person in `tables`, decoded, under each table's key. For a
79
+ * contribution that **retains** its rows (#814) — `@pithy-sh/payments` keeps a purchase for tax law, and still
80
+ * answers for it.
81
+ */
82
+ export function personRowsExport(
83
+ input: Omit<PersonRowsPurgeInput, "summary">,
84
+ ): (context: PurgeContext) => Promise<Record<string, unknown>> {
85
+ const database = input.database ?? "DB";
86
+ return async ({ subject, env }) => {
87
+ const db = createDatabase(purgeBinding<D1Database>(env, database, input.capability), {}) as unknown as Untyped;
88
+ const read = await Promise.all(
89
+ input.tables.map(async (table) => {
90
+ let query = db.selectFrom(table.table).selectAll().where(theirs(table, subject.id));
91
+ if (table.orderBy) query = query.orderBy(sql.ref(table.orderBy));
92
+ const rows = (await query.execute()).map((row) => {
93
+ const parsed = table.schema.parse(row) as Record<string, unknown>;
94
+ const decoded = table.redact ? table.redact(parsed) : parsed;
95
+ for (const column of table.omit ?? []) delete decoded[column];
96
+ return decoded;
97
+ });
98
+ return [table.as, exportRows(rows)] as const;
99
+ }),
100
+ );
101
+ return Object.fromEntries(read);
102
+ };
103
+ }
104
+
105
+ /** Build a user contribution that exports, then on a purge deletes, every row naming the person in `tables`. */
106
+ export function personRowsPurge(input: PersonRowsPurgeInput): ErasingPurgeContribution {
107
+ const database = input.database ?? "DB";
108
+ const open = (env: Record<string, unknown>) => {
109
+ const d1 = purgeBinding<D1Database>(env, database, input.capability);
110
+ return { d1, db: createDatabase(d1, {}) as unknown as Untyped };
111
+ };
112
+ return {
113
+ subject: "user",
114
+ summary: input.summary,
115
+ erase: async ({ subject, env }) => {
116
+ const { d1, db } = open(env);
117
+ const statements = input.tables.map((table) => {
118
+ const compiled = db.deleteFrom(table.table).where(theirs(table, subject.id)).compile();
119
+ return d1.prepare(compiled.sql).bind(...(compiled.parameters as unknown[]));
120
+ });
121
+ if (statements.length > 0) await d1.batch(statements);
122
+ },
123
+ export: personRowsExport(input),
124
+ };
125
+ }
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
- import { ValidationError } from "../error/pithyError";
5
+ import { InternalError, ValidationError } from "../error/pithyError";
6
6
 
7
7
  /**
8
8
  * How a capability says what erasing one subject means for its own rows — the purge contribution.
@@ -23,6 +23,21 @@ import { ValidationError } from "../error/pithyError";
23
23
  * a step is retried after a failure that may have come after its rows were already gone, and erasing
24
24
  * nothing must then succeed.
25
25
  *
26
+ * ## The same contribution answers an export (#622)
27
+ *
28
+ * An access request arrives before an erasure, not after. So a contribution may also say what this capability
29
+ * holds about the subject, on the read side: `export` answers one JSON object, and the control plane's export
30
+ * route assembles one document from every composed contribution, in the same order the purge runs them. A
31
+ * contribution with no `export` is listed as holding rows it did not say how to export, which is the honest
32
+ * answer and the one an operator can act on.
33
+ *
34
+ * ## Or it keeps them, and says why (#814)
35
+ *
36
+ * Not every row about a person should go. A payment record is kept for tax law, a suppression has to outlive the
37
+ * person or the unsubscribe stops holding, and the audit trail is the record of who acted. So a contribution has a
38
+ * third disposition beside erase and anonymize: `retain`, with a one-sentence basis written for the person. The
39
+ * purge records that step as retained and erases nothing of it; the export carries the rows and the basis.
40
+ *
26
41
  * ## An adopter declares one too
27
42
  *
28
43
  * On their own `app` capability, for their own tables, with the same types. The contract is exported for
@@ -31,12 +46,13 @@ import { ValidationError } from "../error/pithyError";
31
46
 
32
47
  /**
33
48
  * The kinds of subject a purge erases. Closed, so a typo is refused where it is written rather than becoming
34
- * a contribution no purge ever asks for. `organization` joins when organization state does (#623).
49
+ * a contribution no purge ever asks for. `organization` joined with organization state (#623): a capability
50
+ * holding rows scoped to a tenant declares one for it, and the organization purge runs it.
35
51
  */
36
52
  export const PurgeSubject = z
37
- .enum(["user"])
53
+ .enum(["user", "organization"])
38
54
  .describe(
39
- "The kind of subject a purge erases. `user` is one person's account in `@pithy-sh/auth`, identified by its user id.",
55
+ "The kind of subject a purge erases. `user` is one person's account in `@pithy-sh/auth`, identified by its user id. `organization` is one tenant in `@pithy-sh/organization`, identified by its organization id.",
40
56
  );
41
57
  export type PurgeSubject = z.output<typeof PurgeSubject>;
42
58
 
@@ -46,23 +62,73 @@ export interface PurgeContext {
46
62
  readonly subject: { readonly type: PurgeSubject; readonly id: string };
47
63
  /** The Worker's bindings for this run, from which the contribution opens its own database. */
48
64
  readonly env: Record<string, unknown>;
65
+ /**
66
+ * The subject's email address, read on demand from the capability that owns the subject — `null` when it has
67
+ * none, or no longer has one (#814). For a capability that holds rows by address rather than by id: a tester's
68
+ * membership, a suppression, an invitation.
69
+ *
70
+ * **A function, never a value**, so the address is read inside the step that needs it and is never carried in
71
+ * a Workflow's journal, where a purge would otherwise leave it behind. Absent where nobody can say, and a
72
+ * contribution that needs it then finds nothing by address.
73
+ */
74
+ readonly address?: () => Promise<string | null>;
49
75
  }
50
76
 
51
- /** One capability's answer to "how are this subject's rows erased". */
52
- export interface PurgeContribution {
77
+ /** What every contribution says, whichever its disposition. */
78
+ interface PurgeContributionBase {
53
79
  /** The kind of subject this erases. */
54
80
  readonly subject: PurgeSubject;
55
81
  /**
56
- * One sentence on what is erased or anonymized — `Deletes the user's uploads and their stored bytes.`
82
+ * One sentence on what is erased, anonymized or kept — `Deletes the user's uploads and their stored bytes.`
57
83
  * Recorded with the step that ran it, so the trail says what a purge did and not only that it ran.
58
84
  */
59
85
  readonly summary: string;
86
+ /**
87
+ * What this capability holds about the subject, as one JSON object — the read side of `erase` (#622). It
88
+ * answers an access request, so it carries the subject's own data and never a credential: no session token,
89
+ * no provider token, no push token. Optional on an erasing contribution: one without it is listed in the
90
+ * export as rows it erases and did not say how to export. A throw costs this capability's entry, never the
91
+ * document.
92
+ */
93
+ readonly export?: (context: PurgeContext) => Promise<Record<string, unknown>>;
94
+ }
95
+
96
+ /** A contribution that erases or anonymizes the subject's rows. */
97
+ export interface ErasingPurgeContribution extends PurgeContributionBase {
60
98
  /**
61
99
  * Erase or anonymize this subject's rows. **Idempotent**: run again after it succeeded, or after a failure
62
100
  * that came once the rows were gone, it must succeed having found nothing to do. Throwing fails this step
63
101
  * and only this step.
64
102
  */
65
103
  readonly erase: (context: PurgeContext) => Promise<void>;
104
+ /** Never set on an erasing contribution. */
105
+ readonly retain?: undefined;
106
+ }
107
+
108
+ /**
109
+ * A contribution that **keeps** the subject's rows through a purge, and says why (#814). A payment record kept
110
+ * for tax law, a suppression that must outlive the person or the unsubscribe stops holding, the audit trail of
111
+ * who acted. The purge records the step as retained and never erases a row of it; the export carries the rows
112
+ * and the basis, because rows that are kept are rows that are answered for.
113
+ */
114
+ export interface RetainingPurgeContribution extends PurgeContributionBase {
115
+ /** Why the rows are kept, in one sentence written for the person — `Kept for tax records.` */
116
+ readonly retain: { readonly basis: string };
117
+ /** Required: what is kept is exported. */
118
+ readonly export: (context: PurgeContext) => Promise<Record<string, unknown>>;
119
+ /** Never set on a retaining contribution. */
120
+ readonly erase?: undefined;
121
+ }
122
+
123
+ /** One capability's answer to "what happens to this subject's rows": erased, anonymized, or kept and why. */
124
+ export type PurgeContribution = ErasingPurgeContribution | RetainingPurgeContribution;
125
+
126
+ /** What a purge does with a contribution's rows: `erase` (or anonymize), or `retain`. */
127
+ export type PurgeDisposition = "erase" | "retain";
128
+
129
+ /** Whether a contribution keeps the subject's rows rather than erasing them. */
130
+ export function isRetained(contribution: PurgeContribution): contribution is RetainingPurgeContribution {
131
+ return contribution.retain !== undefined;
66
132
  }
67
133
 
68
134
  /** One step of a purge: which capability it runs, and the contribution that capability declared. */
@@ -101,15 +167,49 @@ export function assertPurgeContributions(capability: string, contributions: read
101
167
  action: "Say in one sentence what it erases. The purge records it with the step.",
102
168
  });
103
169
  }
170
+ if (contribution.export !== undefined && typeof contribution.export !== "function") {
171
+ throw new ValidationError({
172
+ message: `The ${capability} capability's ${subject.data} purge has an export that is not a function.`,
173
+ action: "Give it an `export` that answers what this capability holds about the subject, or leave it out.",
174
+ });
175
+ }
176
+ if (contribution.retain !== undefined) {
177
+ assertRetention(capability, subject.data, contribution);
178
+ continue;
179
+ }
104
180
  if (typeof contribution.erase !== "function") {
105
181
  throw new ValidationError({
106
182
  message: `The ${capability} capability's ${subject.data} purge has no erase function.`,
107
- action: "Give it an `erase` that removes or anonymizes the subject's rows.",
183
+ action:
184
+ "Give it an `erase` that removes or anonymizes the subject's rows, or a `retain` that says why it keeps them.",
108
185
  });
109
186
  }
110
187
  }
111
188
  }
112
189
 
190
+ /** Refuse a retaining contribution the person could not be answered with: no basis, no export, or an erase too. */
191
+ function assertRetention(capability: string, subject: PurgeSubject, contribution: PurgeContribution): void {
192
+ const basis = (contribution.retain as { basis?: unknown } | undefined)?.basis;
193
+ if (typeof basis !== "string" || basis.trim() === "") {
194
+ throw new ValidationError({
195
+ message: `The ${capability} capability's ${subject} purge retains the rows with no basis.`,
196
+ action: "Say in one sentence, written for the person, why they are kept — `Kept for tax records.`",
197
+ });
198
+ }
199
+ if (typeof contribution.export !== "function") {
200
+ throw new ValidationError({
201
+ message: `The ${capability} capability's ${subject} purge retains the rows and has no export.`,
202
+ action: "Give it an `export`: rows that are kept are rows an access request is answered with.",
203
+ });
204
+ }
205
+ if (contribution.erase !== undefined) {
206
+ throw new ValidationError({
207
+ message: `The ${capability} capability's ${subject} purge both retains and erases the rows.`,
208
+ action: "Declare one disposition: `retain` with its basis, or `erase`.",
209
+ });
210
+ }
211
+ }
212
+
113
213
  /** The steps a purge of `subject` runs, in composition order — libraries first, the adopter's `app` last. */
114
214
  export function purgeSteps(
115
215
  capabilities: readonly { readonly name: string; readonly purge?: readonly PurgeContribution[] }[],
@@ -122,3 +222,103 @@ export function purgeSteps(
122
222
  }
123
223
  return steps;
124
224
  }
225
+
226
+ /** What every export entry says: the capability, its sentence, and what a purge does with its rows. */
227
+ interface PurgeExportEntryBase {
228
+ readonly capability: string;
229
+ readonly summary: string;
230
+ /** `erase` — a purge erases or anonymizes these rows — or `retain`, a purge keeps them (#814). */
231
+ readonly disposition: PurgeDisposition;
232
+ /** For `retain`, why the rows are kept, in the sentence the contribution wrote for the person. */
233
+ readonly basis?: string;
234
+ }
235
+
236
+ /** What one step's export answered: its data, that it declared none, or that it could not be read. */
237
+ export type PurgeExportEntry =
238
+ | (PurgeExportEntryBase & { readonly state: "exported"; readonly data: Record<string, unknown> })
239
+ | (PurgeExportEntryBase & { readonly state: "not_exported" })
240
+ | (PurgeExportEntryBase & { readonly state: "unavailable" });
241
+
242
+ /** Whether an export answered what the contract promises: one plain object. */
243
+ function isRecord(value: unknown): value is Record<string, unknown> {
244
+ return typeof value === "object" && value !== null && !Array.isArray(value);
245
+ }
246
+
247
+ /**
248
+ * Read every step's export for one subject — the export document's body, in step order (#622).
249
+ *
250
+ * **Each read is guarded on its own and they run concurrently**, the shape the user pane's sub-reads have
251
+ * (#380): one capability whose table will not read costs its own entry as `unavailable`, and the rest of the
252
+ * document still answers the request. What the read threw is not carried — it names a query and a table, and
253
+ * the document crosses a trust boundary. An answer that is not one object is `unavailable` too, because the
254
+ * document promises an object per capability and a client validates it.
255
+ */
256
+ export async function purgeExports(steps: readonly PurgeStep[], context: PurgeContext): Promise<PurgeExportEntry[]> {
257
+ return Promise.all(
258
+ steps.map(async ({ step, contribution }): Promise<PurgeExportEntry> => {
259
+ const base: PurgeExportEntryBase = isRetained(contribution)
260
+ ? { capability: step, summary: contribution.summary, disposition: "retain", basis: contribution.retain.basis }
261
+ : { capability: step, summary: contribution.summary, disposition: "erase" };
262
+ const read = contribution.export;
263
+ if (read === undefined) return { ...base, state: "not_exported" };
264
+ try {
265
+ const data = await read(context);
266
+ return isRecord(data) ? { ...base, state: "exported", data } : { ...base, state: "unavailable" };
267
+ } catch {
268
+ return { ...base, state: "unavailable" };
269
+ }
270
+ }),
271
+ );
272
+ }
273
+
274
+ /**
275
+ * One binding a contribution opens off a run's env — or a wiring fault naming it and the capability (#814). The
276
+ * purge and the export both run in the adopter's Worker, so a missing binding is that Worker's wiring, and the
277
+ * fault says which binding to add rather than failing deep inside a query.
278
+ */
279
+ export function purgeBinding<T>(
280
+ env: Record<string, unknown>,
281
+ name: string,
282
+ capability: string,
283
+ kind = "D1 database",
284
+ ): T {
285
+ const binding = env[name];
286
+ if (typeof binding !== "object" || binding === null) {
287
+ throw new InternalError({
288
+ message: `The ${capability} capability's ${kind} is not bound, so its purge step could not run.`,
289
+ action: `Bind the ${kind} named ${name} to the Worker that runs the purge.`,
290
+ detail: `${capability} purge contribution found no ${name} binding on the env`,
291
+ });
292
+ }
293
+ return binding as T;
294
+ }
295
+
296
+ /**
297
+ * Rows as an export carries them (#814): plain JSON, a `Date` as its ISO-8601 string — the shape the export
298
+ * document crosses the wire in, so what a contribution answers is what a reader parses, and a test compares
299
+ * exactly that.
300
+ */
301
+ export function exportRows<T>(rows: readonly T[]): Record<string, unknown>[] {
302
+ return JSON.parse(JSON.stringify(rows)) as Record<string, unknown>[];
303
+ }
304
+
305
+ /**
306
+ * A field name that holds a credential — a token, a secret, a password, a digest of one, a sealed value. What an
307
+ * export never carries (#814): an access request is answered with what is held, never with the means to act as
308
+ * the person or to reach what they shared.
309
+ */
310
+ const CREDENTIAL_FIELD = /secret|token|password|credential|digest|private_?key|encrypted/i;
311
+
312
+ /**
313
+ * Every path in an export's data whose field name holds a credential — empty for an export that carries none.
314
+ * Each capability's export test asserts it empty, so a column added later with a token in it fails the build
315
+ * rather than reaching an access request (#814).
316
+ */
317
+ export function credentialFields(value: unknown, path = ""): string[] {
318
+ if (Array.isArray(value)) return value.flatMap((item, index) => credentialFields(item, `${path}[${index}]`));
319
+ if (typeof value !== "object" || value === null) return [];
320
+ return Object.entries(value).flatMap(([key, inner]) => {
321
+ const here = path ? `${path}.${key}` : key;
322
+ return [...(CREDENTIAL_FIELD.test(key) ? [here] : []), ...credentialFields(inner, here)];
323
+ });
324
+ }
@@ -169,6 +169,13 @@ export interface SettingsAccountReader {
169
169
  * Asked by secrets of the manager token the runtime token superseded, which nothing revokes for you.
170
170
  */
171
171
  accountToken(name: string): Promise<boolean>;
172
+ /**
173
+ * **Whether an R2 bucket's objects under `prefix` are under an indefinite lock (#822)** — `locked`, `unlocked`, or
174
+ * `missing` when no bucket of that name exists on the account. Asked by audit, whose checkpoints are evidence only
175
+ * while nothing can replace them. Optional, so a reader built before the question existed still compiles; a
176
+ * check asking a reader without it reports the environment unanswered rather than guessing.
177
+ */
178
+ r2BucketLock?(request: { bucket: string; prefix: string }): Promise<"locked" | "unlocked" | "missing">;
172
179
  }
173
180
 
174
181
  /** What {@link SettingsAccountReader.appRows} is asked. */
@@ -0,0 +1,60 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { InternalError } from "../error/pithyError";
5
+
6
+ /**
7
+ * A Vectorize index a capability owns and re-embeds itself — the contribution `pithy vector reprocess` reads.
8
+ *
9
+ * ## Why a contribution
10
+ *
11
+ * `@pithy-sh/vector` re-embeds the indexes an adopter declares in `vector({ indexes })`. A capability that
12
+ * builds an index of its own — `@pithy-sh/support`'s semantic message index — uses vector's public modules to
13
+ * embed and to re-embed, and runs vector's reprocess orchestration in a Workflow it hosts, over its own corpus.
14
+ * What it cannot do is teach vector's CLI that the index exists: capabilities depend on core seams, never on
15
+ * each other. So it says so here, on its own `Capability`, the way it states its migrations and its purge, and
16
+ * `pithy vector reprocess` reads every composed declaration — in a project that never composes `vector()` too.
17
+ *
18
+ * ## What the CLI does with one
19
+ *
20
+ * It starts `reprocessJob`, one of this capability's own `workflows`, under the deployed name every Workflow
21
+ * gets — `<project>-<env>-<capability>-<job>` — with the parameters every reprocess takes: `{ index, all }`.
22
+ * A model change, a repair and a full rebuild (`--all`) are then the same three cases they are for any index.
23
+ */
24
+ export interface VectorIndexContribution {
25
+ /**
26
+ * The index's name as `pithy vector reprocess --index` takes it — `support-messages`. Unique across the
27
+ * project, and never one of the adopter's own `vector()` index keys.
28
+ */
29
+ readonly name: string;
30
+ /** The job, in this capability's `workflows`, that re-embeds the index. */
31
+ readonly reprocessJob: string;
32
+ }
33
+
34
+ /**
35
+ * Refuse a declaration no reprocess could run: a duplicate name, or a job the capability does not register.
36
+ * Called by `defineCapability`, so the mistake is named where it is written rather than on the day somebody
37
+ * changes a model.
38
+ */
39
+ export function assertVectorIndexContributions(
40
+ capability: string,
41
+ indexes: readonly VectorIndexContribution[],
42
+ jobs: readonly string[],
43
+ ): void {
44
+ const seen = new Set<string>();
45
+ for (const index of indexes) {
46
+ if (seen.has(index.name)) {
47
+ throw new InternalError({
48
+ message: `The ${capability} capability declares the vector index \`${index.name}\` twice.`,
49
+ detail: `vectorIndexes on ${capability} repeats ${index.name}`,
50
+ });
51
+ }
52
+ seen.add(index.name);
53
+ if (!jobs.includes(index.reprocessJob)) {
54
+ throw new InternalError({
55
+ message: `The ${capability} capability's vector index \`${index.name}\` names a reprocess job it does not register.`,
56
+ detail: `vectorIndexes on ${capability}: no workflow job ${index.reprocessJob}; registered: ${jobs.join(", ") || "none"}`,
57
+ });
58
+ }
59
+ }
60
+ }