@pithy-sh/core 0.13.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 (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 +433 -3
  35. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  36. package/dist/cli/commandCatalog.generated.js +449 -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 +482 -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,287 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { DurableObject } from "cloudflare:workers";
5
+ import { clientError } from "../error/client";
6
+ import {
7
+ fromZodError,
8
+ PithyError,
9
+ PresenceRoomFullError,
10
+ PresenceTicketInvalidError,
11
+ ValidationError,
12
+ } from "../error/pithyError";
13
+ import { negotiateSubprotocol, SUBPROTOCOL_HEADER } from "../http/subprotocol";
14
+ import { PRESENCE_MAX_FRAME_BYTES } from "./declaration";
15
+ import {
16
+ PRESENCE_ROOM_VERSIONS,
17
+ type PresenceParticipant,
18
+ PresenceRoomClientFrame,
19
+ PresenceRoomPing,
20
+ PresenceRoomPong,
21
+ type PresenceRoomServerFrame,
22
+ } from "./frames";
23
+ import {
24
+ admit,
25
+ carrySpent,
26
+ declaresActivity,
27
+ inheritSpent,
28
+ PRESENCE_ADMISSION_HEADER,
29
+ PresenceAdmission,
30
+ PresenceAttachment,
31
+ type SpentTicket,
32
+ } from "./room";
33
+
34
+ /**
35
+ * **One presence room: who has one record open, and what they are doing.** Addressed by `<kind>:<recordId>`,
36
+ * so each record has its own object.
37
+ *
38
+ * The Hibernation API only: `ctx.acceptWebSocket`, never `ws.accept()`, and no registry of sockets in
39
+ * memory — the participants are read back from `getWebSockets()`, each from its own attachment, so a room
40
+ * that hibernates and wakes knows exactly who is in it. Nothing is written to Durable Object storage: the
41
+ * room holds nothing but who is here, and when the last socket closes it is gone.
42
+ *
43
+ * The upgrade is forwarded by the Worker's socket route, which has verified the ticket and states the
44
+ * admission on {@link PRESENCE_ADMISSION_HEADER}. The room trusts that header — a Durable Object is reached
45
+ * only through its binding — and decides the two things only it can: whether the ticket is already spent, and
46
+ * whether the room is full.
47
+ */
48
+ export class PresenceRoom extends DurableObject<Record<string, unknown>> {
49
+ /**
50
+ * Tickets redeemed by sockets that closed while no other socket was left to carry them. Memory, not
51
+ * storage, and only while the room is empty: the next socket to join takes them into its attachment, and
52
+ * until one does, {@link awake} keeps the room from hibernating and losing them.
53
+ */
54
+ private remembered: SpentTicket[] = [];
55
+
56
+ /**
57
+ * The timer that holds an empty room awake until the last remembered ticket would expire. A Durable Object
58
+ * with no socket hibernates after about ten seconds idle and loses its memory, which would let a spent
59
+ * ticket in again; a pending timer is one of the things that stops it. With no timer, an idle object that
60
+ * cannot hibernate is evicted only after 70 seconds or more — past a 60-second ticket's life. A deploy or a
61
+ * restart still clears it, and drops every socket with it.
62
+ */
63
+ private awake: { until: number; timer: ReturnType<typeof setTimeout> } | null = null;
64
+
65
+ override async fetch(request: Request): Promise<Response> {
66
+ if (request.headers.get("upgrade") !== "websocket") {
67
+ return new Response("Expected a WebSocket upgrade.", { status: 426 });
68
+ }
69
+ try {
70
+ const admission = this.admission(request);
71
+ const version = negotiateSubprotocol(request.headers.get(SUBPROTOCOL_HEADER), PRESENCE_ROOM_VERSIONS);
72
+ if (version === null) throw new ValidationError({ message: "Offer pithy.presence.v1." });
73
+
74
+ const now = new Date();
75
+ const nowSeconds = Math.floor(now.getTime() / 1000);
76
+ const present = this.attachments();
77
+ // A socket closing whose close event has not yet run still holds its ticket and what it carries.
78
+ const leaving = this.leavingAttachments().flatMap((attachment) => [attachment.ticket, ...attachment.spent]);
79
+ const refusal = admit(admission, present, [...this.remembered, ...leaving], nowSeconds);
80
+ if (refusal === "spent")
81
+ throw new PresenceTicketInvalidError({ detail: `ticket ${admission.ticket.id} is spent` });
82
+ if (refusal === "full") {
83
+ throw new PresenceRoomFullError({
84
+ detail: `${admission.kind}/${admission.recordId} at ${admission.maxParticipants}`,
85
+ });
86
+ }
87
+
88
+ const participant: PresenceParticipant = {
89
+ id: crypto.randomUUID(),
90
+ ref: admission.ref,
91
+ name: admission.name,
92
+ origin: admission.origin,
93
+ activity: admission.activities[0] as string,
94
+ joinedAt: now.toISOString(),
95
+ };
96
+ const pair = new WebSocketPair();
97
+ const [client, server] = [pair[0], pair[1]];
98
+ this.ctx.acceptWebSocket(server);
99
+ server.serializeAttachment({
100
+ participant,
101
+ version,
102
+ ticket: admission.ticket,
103
+ activities: admission.activities,
104
+ spent: inheritSpent(this.remembered, admission.ticket.id, nowSeconds),
105
+ } satisfies PresenceAttachment);
106
+ // On a socket now, which survives hibernation; the room's memory need not hold them, nor stay awake to.
107
+ this.remembered = [];
108
+ this.stayAwake(nowSeconds);
109
+
110
+ // In the order they joined. The runtime hands sockets back in no promised order.
111
+ const everyone = [...present.map((attachment) => attachment.participant), participant].sort((a, b) =>
112
+ a.joinedAt.localeCompare(b.joinedAt),
113
+ );
114
+ server.send(render({ type: "init", self: participant.id, participants: everyone }));
115
+ this.broadcast({ type: "joined", participant }, server);
116
+
117
+ // A browser drops a socket whose 101 does not name the subprotocol it chose.
118
+ return new Response(null, { status: 101, webSocket: client, headers: { [SUBPROTOCOL_HEADER]: version } });
119
+ } catch (error) {
120
+ if (!(error instanceof PithyError)) throw error;
121
+ return Response.json({ error: clientError(error.payload) }, { status: error.payload.status });
122
+ }
123
+ }
124
+
125
+ /**
126
+ * One frame from a participant: `ping`, or `set_activity` naming an activity the room kind declares. A frame
127
+ * past the size limit, one that does not parse, and an undeclared activity are each answered with an error
128
+ * frame and change nothing.
129
+ */
130
+ override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
131
+ const attachment = this.attachmentOf(ws);
132
+ if (!attachment) return;
133
+ const bytes = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
134
+ try {
135
+ if (bytes > PRESENCE_MAX_FRAME_BYTES) {
136
+ throw new ValidationError({
137
+ message: `A frame is at most ${PRESENCE_MAX_FRAME_BYTES} bytes.`,
138
+ detail: `${bytes} bytes`,
139
+ });
140
+ }
141
+ const text = typeof message === "string" ? message : new TextDecoder().decode(message);
142
+ if (PresenceRoomPing.safeParse(text).success) {
143
+ ws.send(PresenceRoomPong.value);
144
+ return;
145
+ }
146
+ let json: unknown;
147
+ try {
148
+ json = JSON.parse(text);
149
+ } catch {
150
+ throw new ValidationError({ message: "This socket accepts `ping` and JSON frames." });
151
+ }
152
+ const parsed = PresenceRoomClientFrame.safeParse(json);
153
+ if (!parsed.success) throw fromZodError(parsed.error, { message: "This socket accepts `set_activity`." });
154
+ const frame = parsed.data;
155
+ if (!declaresActivity(attachment, frame.activity)) {
156
+ throw new ValidationError({
157
+ message: `This room's activities are ${attachment.activities.join(", ")}.`,
158
+ params: { activity: frame.activity },
159
+ });
160
+ }
161
+ if (frame.activity === attachment.participant.activity) return;
162
+ const participant = { ...attachment.participant, activity: frame.activity };
163
+ ws.serializeAttachment({ ...attachment, participant } satisfies PresenceAttachment);
164
+ this.broadcast({ type: "activity", participantId: participant.id, activity: frame.activity }, ws);
165
+ } catch (error) {
166
+ if (!(error instanceof PithyError)) throw error;
167
+ try {
168
+ ws.send(render({ type: "error", error: clientError(error.payload) }));
169
+ } catch {
170
+ // Closing — nothing to answer.
171
+ }
172
+ }
173
+ }
174
+
175
+ override async webSocketClose(ws: WebSocket): Promise<void> {
176
+ this.leave(ws);
177
+ }
178
+
179
+ override async webSocketError(ws: WebSocket): Promise<void> {
180
+ this.leave(ws);
181
+ }
182
+
183
+ /** A socket leaves: everyone else hears it, and the ticket it spent stays spent. */
184
+ private leave(ws: WebSocket): void {
185
+ const closing = this.attachmentOf(ws);
186
+ if (closing) {
187
+ const nowSeconds = Math.floor(Date.now() / 1000);
188
+ const survivors = this.sockets().filter((other) => other !== ws);
189
+ const [carrier] = survivors;
190
+ const carried = carrier ? this.attachmentOf(carrier) : undefined;
191
+ if (carrier && carried) {
192
+ const spent = carrySpent(carried, closing, nowSeconds);
193
+ if (spent) carrier.serializeAttachment({ ...carried, spent } satisfies PresenceAttachment);
194
+ } else {
195
+ this.remembered = [...this.remembered, closing.ticket, ...closing.spent];
196
+ this.stayAwake(nowSeconds);
197
+ }
198
+ // Cleared before the broadcast, so a second close event for this socket cannot announce it twice.
199
+ ws.serializeAttachment(null);
200
+ this.broadcast({ type: "left", participantId: closing.participant.id }, ws);
201
+ }
202
+ try {
203
+ ws.close();
204
+ } catch {
205
+ // Already closing.
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Hold the room awake until the last remembered ticket would expire, or let it sleep when there is none.
211
+ * The timer wakes once that has passed and lets go.
212
+ */
213
+ private stayAwake(nowSeconds: number): void {
214
+ this.remembered = this.remembered.filter((ticket) => ticket.exp > nowSeconds);
215
+ const until = Math.max(0, ...this.remembered.map((ticket) => ticket.exp));
216
+ if (this.awake && this.remembered.length > 0 && this.awake.until >= until) return;
217
+ if (this.awake) clearTimeout(this.awake.timer);
218
+ this.awake = null;
219
+ if (this.remembered.length === 0) return;
220
+ const timer = setTimeout(
221
+ () => {
222
+ this.awake = null;
223
+ this.stayAwake(Math.floor(Date.now() / 1000));
224
+ },
225
+ (until - nowSeconds + 1) * 1000,
226
+ );
227
+ this.awake = { until, timer };
228
+ }
229
+
230
+ /** Send a frame to every participant but one. One dead socket never blocks the rest. */
231
+ private broadcast(frame: PresenceRoomServerFrame, except: WebSocket): void {
232
+ const text = render(frame);
233
+ for (const ws of this.sockets()) {
234
+ if (ws === except || !this.attachmentOf(ws)) continue;
235
+ try {
236
+ ws.send(text);
237
+ } catch {
238
+ // A dead socket: its close event removes it.
239
+ }
240
+ }
241
+ }
242
+
243
+ /** The open sockets the runtime holds for this room. */
244
+ private sockets(): WebSocket[] {
245
+ return this.ctx.getWebSockets().filter((ws) => ws.readyState === WebSocket.OPEN);
246
+ }
247
+
248
+ /** The attachments of sockets the runtime still holds but that are no longer open. */
249
+ private leavingAttachments(): PresenceAttachment[] {
250
+ return this.ctx.getWebSockets().flatMap((ws) => {
251
+ if (ws.readyState === WebSocket.OPEN) return [];
252
+ const attachment = this.attachmentOf(ws);
253
+ return attachment ? [attachment] : [];
254
+ });
255
+ }
256
+
257
+ /** Every participant's attachment, read back from the runtime. */
258
+ private attachments(): PresenceAttachment[] {
259
+ return this.sockets().flatMap((ws) => {
260
+ const attachment = this.attachmentOf(ws);
261
+ return attachment ? [attachment] : [];
262
+ });
263
+ }
264
+
265
+ private attachmentOf(ws: WebSocket): PresenceAttachment | undefined {
266
+ const parsed = PresenceAttachment.safeParse(ws.deserializeAttachment());
267
+ return parsed.success ? parsed.data : undefined;
268
+ }
269
+
270
+ /** The admission the Worker forwarded. Missing or malformed is a request that did not come through the route. */
271
+ private admission(request: Request): PresenceAdmission {
272
+ const raw = request.headers.get(PRESENCE_ADMISSION_HEADER);
273
+ let json: unknown;
274
+ try {
275
+ json = raw === null ? null : JSON.parse(raw);
276
+ } catch {
277
+ json = null;
278
+ }
279
+ const parsed = PresenceAdmission.safeParse(json);
280
+ if (!parsed.success) throw new PresenceTicketInvalidError({ detail: "no admission on the forwarded upgrade" });
281
+ return parsed.data;
282
+ }
283
+ }
284
+
285
+ function render(frame: PresenceRoomServerFrame): string {
286
+ return JSON.stringify(frame);
287
+ }
@@ -0,0 +1,153 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ErrorBody } from "../error/wire";
6
+ import type { SocketVersions } from "../http/subprotocol";
7
+ import { PresenceActivity } from "./names";
8
+
9
+ /**
10
+ * **Every frame on a presence room socket, as the Zod schema it is sent and parsed with.**
11
+ *
12
+ * Pure: zod and core's leaf modules. A browser imports it to type the socket, and the AsyncAPI document
13
+ * reads the same schemas through the declaring capability's `sockets`, so the document and the Durable
14
+ * Object cannot disagree.
15
+ *
16
+ * **How a client reads a frame, stated once** (`docs/SOCKETS.md`). A field added to a frame later is
17
+ * optional. A client ignores a `type` and a key it does not know. A change no client could ignore is a new
18
+ * subprotocol, and the Worker keeps serving the one before it.
19
+ *
20
+ * **The keepalive is plain text**, the one exception to JSON: a client sends `ping`, the Worker answers
21
+ * `pong`. The only JSON frame a client sends is `set_activity`.
22
+ */
23
+
24
+ /** The subprotocols a room serves. There is no unversioned room: a ticket can only arrive in the offer. */
25
+ export const PRESENCE_ROOM_VERSIONS: SocketVersions = { current: "pithy.presence.v1", previous: null };
26
+
27
+ /** Which door a participant came in by. */
28
+ export const PresenceOrigin = z
29
+ .enum(["control-plane", "session"])
30
+ .describe(
31
+ "Which door the participant came in by: `control-plane`, a management client such as the dashboard, or `session`, a signed-in user of the app.",
32
+ );
33
+ export type PresenceOrigin = z.infer<typeof PresenceOrigin>;
34
+
35
+ /** Who a participant is, as the minting door states it. */
36
+ export const PresenceParticipantRef = z
37
+ .string()
38
+ .min(1)
39
+ .max(128)
40
+ .describe(
41
+ "Who the participant is. A management client's own opaque reference, which it chose; or the signed-in user's id, which the Worker chose.",
42
+ );
43
+
44
+ /** The longest participant name, in UTF-16 code units — what zod's `.max` counts. */
45
+ export const PRESENCE_PARTICIPANT_NAME_MAX = 80;
46
+
47
+ /** How the others see a participant. */
48
+ export const PresenceParticipantName = z
49
+ .string()
50
+ .trim()
51
+ .min(1)
52
+ .max(PRESENCE_PARTICIPANT_NAME_MAX)
53
+ .describe("How the other participants see this one. A label, never an identity — `ref` and `origin` are that.");
54
+
55
+ /** One participant in a room. */
56
+ export const PresenceParticipant = z
57
+ .object({
58
+ id: z
59
+ .string()
60
+ .uuid()
61
+ .describe("This participant's id in the room — one per socket, so one person in two tabs is two participants."),
62
+ ref: PresenceParticipantRef,
63
+ name: PresenceParticipantName,
64
+ origin: PresenceOrigin,
65
+ activity: PresenceActivity.describe("What they are doing now, one of the room kind's declared activities."),
66
+ joinedAt: z.iso.datetime().describe("When this socket joined, ISO-8601."),
67
+ })
68
+ .describe("One participant in a presence room: one open socket.");
69
+ export type PresenceParticipant = z.infer<typeof PresenceParticipant>;
70
+
71
+ /** The first frame on every room socket. */
72
+ export const PresenceRoomInitFrame = z
73
+ .object({
74
+ type: z.literal("init").describe("Always `init`."),
75
+ self: z.string().uuid().describe("This socket's own participant id, so a client can find itself in the list."),
76
+ participants: z
77
+ .array(PresenceParticipant)
78
+ .describe("Everyone in the room now, this socket included, in the order they joined."),
79
+ })
80
+ .describe("Who is in the room, sent once as soon as the socket opens.");
81
+ export type PresenceRoomInitFrame = z.infer<typeof PresenceRoomInitFrame>;
82
+
83
+ /** Someone joined. */
84
+ export const PresenceJoinedFrame = z
85
+ .object({
86
+ type: z.literal("joined").describe("Always `joined`."),
87
+ participant: PresenceParticipant.describe("Who joined."),
88
+ })
89
+ .describe("Another participant joined the room.");
90
+ export type PresenceJoinedFrame = z.infer<typeof PresenceJoinedFrame>;
91
+
92
+ /** Someone left, or their socket dropped. */
93
+ export const PresenceLeftFrame = z
94
+ .object({
95
+ type: z.literal("left").describe("Always `left`."),
96
+ participantId: z.string().uuid().describe("The participant who left."),
97
+ })
98
+ .describe("Another participant left the room, or their socket dropped.");
99
+ export type PresenceLeftFrame = z.infer<typeof PresenceLeftFrame>;
100
+
101
+ /** Someone changed what they are doing. */
102
+ export const PresenceActivityFrame = z
103
+ .object({
104
+ type: z.literal("activity").describe("Always `activity`."),
105
+ participantId: z.string().uuid().describe("The participant whose activity changed."),
106
+ activity: PresenceActivity.describe("What they are doing now."),
107
+ })
108
+ .describe("Another participant announced a different activity.");
109
+ export type PresenceActivityFrame = z.infer<typeof PresenceActivityFrame>;
110
+
111
+ /** A frame the room could not accept. */
112
+ export const PresenceRoomErrorFrame = z
113
+ .object({
114
+ type: z.literal("error").describe("Always `error`."),
115
+ error: ErrorBody.shape.error,
116
+ })
117
+ .describe(
118
+ "The room could not accept a frame — not one it reads, an activity the room kind does not declare, or past the size limit. The error is the one an HTTP response would carry.",
119
+ );
120
+ export type PresenceRoomErrorFrame = z.infer<typeof PresenceRoomErrorFrame>;
121
+
122
+ /** Every JSON frame the Worker sends on a room socket. `pong` is the plain-text exception. */
123
+ export const PresenceRoomServerFrame = z
124
+ .discriminatedUnion("type", [
125
+ PresenceRoomInitFrame,
126
+ PresenceJoinedFrame,
127
+ PresenceLeftFrame,
128
+ PresenceActivityFrame,
129
+ PresenceRoomErrorFrame,
130
+ ])
131
+ .describe("A JSON frame the Worker sends on a presence room socket.");
132
+ export type PresenceRoomServerFrame = z.infer<typeof PresenceRoomServerFrame>;
133
+
134
+ /** Announce a different activity. */
135
+ export const PresenceSetActivityFrame = z
136
+ .object({
137
+ type: z.literal("set_activity").describe("Always `set_activity`."),
138
+ activity: PresenceActivity.describe("What this participant is doing now. One of the room kind's declared values."),
139
+ })
140
+ .describe("Announce what this participant is doing now.");
141
+ export type PresenceSetActivityFrame = z.infer<typeof PresenceSetActivityFrame>;
142
+
143
+ /** Every JSON frame a client may send on a room socket. `ping` is the plain-text exception. */
144
+ export const PresenceRoomClientFrame = z
145
+ .discriminatedUnion("type", [PresenceSetActivityFrame])
146
+ .describe("A JSON frame a client sends on a presence room socket.");
147
+ export type PresenceRoomClientFrame = z.infer<typeof PresenceRoomClientFrame>;
148
+
149
+ /** The keepalive a client sends, as plain text. */
150
+ export const PresenceRoomPing = z.literal("ping").describe("The plain-text keepalive a client sends.");
151
+
152
+ /** The Worker's answer to `ping`, as plain text. */
153
+ export const PresenceRoomPong = z.literal("pong").describe("The plain-text answer to `ping`.");
@@ -0,0 +1,23 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MiddlewareHandler } from "hono";
5
+ import type { PithyHonoEnv } from "../../capability/capability";
6
+ import { UnauthorizedError } from "../../error/pithyError";
7
+
8
+ /**
9
+ * Require a signed-in user of the app, over the `AuthContext` seam. Kept here rather than imported from
10
+ * `@pithy-sh/auth`, which core may not depend on — and without auth composed `c.var.auth` is null, so the
11
+ * session ticket route denies rather than stands open.
12
+ */
13
+ export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
14
+ return async (c, next) => {
15
+ if (!c.var.auth) {
16
+ throw new UnauthorizedError({
17
+ message: "Authentication required.",
18
+ action: "Sign in and retry with a valid session or bearer token.",
19
+ });
20
+ }
21
+ await next();
22
+ };
23
+ }
@@ -0,0 +1,63 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ControlPlaneScope } from "../../controlPlane/scope/scope";
6
+ import { PRESENCE_ROOM_VERSIONS } from "../frames";
7
+ import { PresenceActivity, PresenceRecordId, PresenceRoomKindName } from "../names";
8
+
9
+ /**
10
+ * A minted presence ticket and where to spend it. Both ticket routes answer it.
11
+ *
12
+ * Read across a version boundary like every response schema, so a field added here later is `.optional()`.
13
+ */
14
+ export const PresenceTicket = z
15
+ .object({
16
+ ticket: z
17
+ .string()
18
+ .min(1)
19
+ .describe(
20
+ "The ticket. Offer it in `Sec-WebSocket-Protocol` as `pithy.ticket.<ticket>`, beside `protocol`. Single use.",
21
+ ),
22
+ expiresAt: z.iso.datetime().describe("When the ticket stops being honored, ISO-8601 — 60 seconds after minting."),
23
+ kind: PresenceRoomKindName.describe("The room kind the ticket admits to."),
24
+ recordId: PresenceRecordId.describe("The record the room is about."),
25
+ socketPath: z
26
+ .string()
27
+ .min(1)
28
+ .describe("The path to open the socket at, on this Worker — this room's, already filled in."),
29
+ protocol: z
30
+ .literal(PRESENCE_ROOM_VERSIONS.current)
31
+ .describe("The subprotocol to offer beside the ticket. The Worker answers with it."),
32
+ })
33
+ .describe("A single-use, 60-second ticket into one presence room, and where to open the socket with it.");
34
+ export type PresenceTicket = z.infer<typeof PresenceTicket>;
35
+
36
+ /**
37
+ * One room kind, as `GET /control-plane/manifest` reports it under the capability that declares it. Built
38
+ * from the capability's resolved `basePath`, so a client never assumes where a socket is.
39
+ */
40
+ export const PresenceRoomDescriptor = z
41
+ .object({
42
+ kind: PresenceRoomKindName.describe("The room kind — `support.thread`."),
43
+ scope: ControlPlaneScope.describe(
44
+ "The scope that reads the record, and so joins its room. A management client mints its ticket with exactly this scope.",
45
+ ),
46
+ activities: z
47
+ .array(PresenceActivity)
48
+ .min(1)
49
+ .describe("What a participant may announce. The first is what everyone starts as."),
50
+ maxParticipants: z.number().int().positive().describe("How many participants the room admits at once."),
51
+ sessionTicketPath: z
52
+ .string()
53
+ .nullable()
54
+ .describe(
55
+ "Where a signed-in user of the app mints a ticket for this kind, or null when the kind admits management clients only.",
56
+ ),
57
+ socketPath: z
58
+ .string()
59
+ .min(1)
60
+ .describe("The socket route, with `:kind` and `:recordId` to fill in — the path as the Worker mounted it."),
61
+ })
62
+ .describe("One presence room kind this capability serves, and how to join one of its rooms.");
63
+ export type PresenceRoomDescriptor = z.infer<typeof PresenceRoomDescriptor>;