@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,283 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Context } from "hono";
5
+ import type { BindingSpecInput } from "../capability/bindings";
6
+ import type { PithyHonoEnv } from "../capability/capability";
7
+ import { remedy } from "../cli/command";
8
+ import { ControlPlaneScope } from "../controlPlane/scope/scope";
9
+ import { InternalError } from "../error/pithyError";
10
+ import { PRESENCE_PARTICIPANT_NAME_MAX } from "./frames";
11
+ import { PresenceActivity, PresenceRoomKindName } from "./names";
12
+
13
+ /**
14
+ * **Presence rooms: who else has a record open, live, from the adopter's own Worker.**
15
+ *
16
+ * A capability declares the room kinds it serves beside its admin routes, on {@link Capability.presence}: a
17
+ * name (`support.thread`), the control-plane scope that reads the record (`support:threads:read`), and the
18
+ * activities a participant may announce (`viewing`, `replying`). A room is one kind plus one record id, and
19
+ * it lives in one hibernating Durable Object, `PresenceRoom`, that holds nothing but who is in it.
20
+ *
21
+ * Joining takes a **ticket**, because a browser's `WebSocket` sets no headers and so cannot carry either the
22
+ * control-plane token or, cross-origin, a session cookie. A ticket is minted by one of two routes — the
23
+ * control-plane one behind the kind's scope, or the capability's own session one behind its own read
24
+ * permission — and presented once, in the subprotocol offer, within 60 seconds.
25
+ *
26
+ * **Presence adds no scope of its own.** Anyone who may read a record may see who else has it open, so the
27
+ * room kind names the scope that already reads the record and the session gate the capability already has.
28
+ *
29
+ * This module is pure: zod, types and constants. The routes are in `./http/routes.ts`, the Durable Object in
30
+ * `./durableObject.ts`, and nothing here imports either.
31
+ */
32
+
33
+ /** The Durable Object binding every room lives behind. One namespace for every room kind in a Worker. */
34
+ export const PRESENCE_ROOMS_BINDING = "PRESENCE_ROOMS";
35
+
36
+ /** The class that backs {@link PRESENCE_ROOMS_BINDING}, and the module the Worker's entry re-exports it from. */
37
+ export const PRESENCE_ROOM_CLASS = "PresenceRoom";
38
+
39
+ /** Its own module, never a package entry point, so `pithy.config.ts` can load the capability in Node. */
40
+ export const PRESENCE_ROOM_MODULE = "@pithy-sh/core/src/presence/durableObject";
41
+
42
+ /**
43
+ * The binding a capability that declares room kinds requires. `defineCapability` adds it, so a capability
44
+ * never spells it; a kit capability's `pithy.manifest.json` names it exactly as written here, which is what
45
+ * `pithy add` and `pithy upgrade` wire through the Durable Object path (#428).
46
+ */
47
+ export const PRESENCE_ROOMS_BINDING_SPEC: BindingSpecInput = {
48
+ type: "durable_object",
49
+ name: PRESENCE_ROOMS_BINDING,
50
+ className: PRESENCE_ROOM_CLASS,
51
+ classModule: PRESENCE_ROOM_MODULE,
52
+ };
53
+
54
+ /**
55
+ * The secret tickets are signed with: one per Worker environment, generated, never typed. A capability that
56
+ * declares room kinds declares this name in its `@pithy-sh/secrets` registry with {@link
57
+ * PRESENCE_TICKET_KEY_ENTRY} and reads it through `secretsStore`, and its manifest lists it under
58
+ * `devSecrets` so `pithy add` mints the local value. The name is the join key, so every capability that
59
+ * declares rooms in one Worker signs and verifies with the one key.
60
+ */
61
+ export const PRESENCE_TICKET_KEY_SECRET = "presence-ticket-key";
62
+
63
+ /**
64
+ * The registry entry for {@link PRESENCE_TICKET_KEY_SECRET}, in the shape `defineSecretRegistry` takes. Its
65
+ * value is arbitrary — nothing outside the Worker has to agree with it — so it is `devValue: "random"`, and
66
+ * rotatable because {@link PresenceTicketKey} accepts every version still honored.
67
+ */
68
+ export const PRESENCE_TICKET_KEY_ENTRY = {
69
+ backend: "d1",
70
+ scope: "environment",
71
+ rotatable: true,
72
+ valueType: "text",
73
+ devValue: "random",
74
+ } as const;
75
+
76
+ /** How long a minted ticket is honored, in seconds. Long enough to open a socket, short enough to be useless later. */
77
+ export const PRESENCE_TICKET_TTL_SECONDS = 60;
78
+
79
+ /** The participant cap a room kind gets when it declares none. */
80
+ export const DEFAULT_PRESENCE_PARTICIPANTS = 25;
81
+
82
+ /** The most participants any room kind may declare. A room is one Durable Object, and every frame fans out to all of them. */
83
+ export const MAX_PRESENCE_PARTICIPANTS = 100;
84
+
85
+ /** The largest frame a room accepts from a client, in bytes. The one frame a client sends is an activity name. */
86
+ export const PRESENCE_MAX_FRAME_BYTES = 1024;
87
+
88
+ /** The most activities one room kind may declare. */
89
+ export const MAX_PRESENCE_ACTIVITIES = 16;
90
+
91
+ /** What the session gate grants: the participant's display name, or the user id when it names none. */
92
+ export interface PresenceSessionGrant {
93
+ /**
94
+ * How other participants see this user. Trimmed and cut to 80 characters; the user id when it is missing
95
+ * or blank, so a gate can pass a stored display name through as it is.
96
+ */
97
+ readonly name?: string;
98
+ }
99
+
100
+ /**
101
+ * The name a session participant joins under: the gate's, trimmed and cut to fit, or the user id when the
102
+ * gate names none or a blank one. Always a name a ticket accepts — a display name is the user's own data, and
103
+ * one too long or empty must not lock them out of every room.
104
+ */
105
+ export function sessionParticipantName(name: string | undefined, userId: string): string {
106
+ return fitName(name ?? "") || fitName(userId) || "Participant";
107
+ }
108
+
109
+ /** Trim, then cut to {@link PRESENCE_PARTICIPANT_NAME_MAX} code units without splitting a character. */
110
+ function fitName(raw: string): string {
111
+ let fitted = "";
112
+ for (const char of raw.trim()) {
113
+ if (fitted.length + char.length > PRESENCE_PARTICIPANT_NAME_MAX) break;
114
+ fitted += char;
115
+ }
116
+ return fitted.trim();
117
+ }
118
+
119
+ /**
120
+ * The capability's own permission for reading one record, for a signed-in user of the app. Answer a grant
121
+ * to admit, `null` to refuse. `c.var.auth` is already set when it runs; it is the capability's job to say
122
+ * whether that user may read `recordId`, exactly as its own read route would.
123
+ */
124
+ export type PresenceSessionGate = (
125
+ c: Context<PithyHonoEnv>,
126
+ recordId: string,
127
+ ) => PresenceSessionGrant | null | Promise<PresenceSessionGrant | null>;
128
+
129
+ /** One room kind a capability serves. */
130
+ export interface PresenceRoomKind {
131
+ /** The kind's name — `support.thread`. Its first segment must be the declaring capability's name. */
132
+ readonly kind: string;
133
+ /** The control-plane scope that reads the record. A management client joins with exactly this scope. */
134
+ readonly scope: string;
135
+ /** The activities a participant may announce. The first is what a participant starts as. */
136
+ readonly activities: readonly [string, ...string[]];
137
+ /** The participant cap. Defaults to {@link DEFAULT_PRESENCE_PARTICIPANTS}, at most {@link MAX_PRESENCE_PARTICIPANTS}. */
138
+ readonly maxParticipants?: number;
139
+ /**
140
+ * Who of the app's own signed-in users may join: the capability's read permission for the record. Absent,
141
+ * the kind admits management clients only and the session ticket route refuses it.
142
+ */
143
+ readonly session?: PresenceSessionGate;
144
+ }
145
+
146
+ /**
147
+ * The secret tickets are signed with: the value, every value still honored during a rotation (the first
148
+ * signs), or a resolver reading it from the Worker env per request — which is the only form a secret read
149
+ * through `@pithy-sh/secrets` can take, since `env` exists only per request.
150
+ */
151
+ export type PresenceTicketKey =
152
+ | string
153
+ | readonly string[]
154
+ | ((env: Record<string, unknown>) => string | readonly string[] | Promise<string | readonly string[]>);
155
+
156
+ /** A capability's presence declaration — see {@link Capability.presence}. */
157
+ export interface PresenceDeclaration {
158
+ /**
159
+ * Where this capability mounts its presence routes: `{basePath}/presence/tickets` and
160
+ * `{basePath}/presence/rooms/:kind/:recordId`. The capability's own resolved `basePath`, never its default.
161
+ */
162
+ readonly basePath: string;
163
+ /** The room kinds it serves. */
164
+ readonly rooms: readonly PresenceRoomKind[];
165
+ /** The ticket signing key — {@link PRESENCE_TICKET_KEY_SECRET}, read through the capability's own secrets reader. */
166
+ readonly ticketKey: PresenceTicketKey;
167
+ }
168
+
169
+ /** The session ticket route, under a capability's base path. */
170
+ export function presenceTicketPath(basePath: string): string {
171
+ return `${basePath}/presence/tickets`;
172
+ }
173
+
174
+ /** The socket route, under a capability's base path, as Hono registers it. */
175
+ export function presenceSocketPath(basePath: string): string {
176
+ return `${basePath}/presence/rooms/:kind/:recordId`;
177
+ }
178
+
179
+ /** The socket path for one room, ready to open. */
180
+ export function presenceRoomPath(basePath: string, kind: string, recordId: string): string {
181
+ return `${basePath}/presence/rooms/${encodeURIComponent(kind)}/${encodeURIComponent(recordId)}`;
182
+ }
183
+
184
+ /** The participant cap a room kind runs with. */
185
+ export function participantCap(room: PresenceRoomKind): number {
186
+ return room.maxParticipants ?? DEFAULT_PRESENCE_PARTICIPANTS;
187
+ }
188
+
189
+ /** One of the capability's own admin routes, as far as the scope rule reads it: its method and the scope it requires. */
190
+ export interface PresenceGatingRoute {
191
+ method: string;
192
+ scope: string | null;
193
+ }
194
+
195
+ /**
196
+ * Refuse a declaration no room could be served from, where it is written and naming the capability: a kind
197
+ * outside the capability's own name, a duplicate, a scope that is not one, an activity that is not one, a
198
+ * cap out of range. A declaration that reached a request would be refused there, by a caller, in a way only
199
+ * the operator can fix.
200
+ *
201
+ * **A room kind's scope is one the capability's own admin routes already require, and only `GET`s require
202
+ * it** — compared against `adminRoutes`, the same artifact `capabilityHealthSources` polices health keys
203
+ * with, and for the same reason. `pithy dashboard connect` offers the scopes it reads off `adminRoutes`, so
204
+ * a scope no route requires — one of presence's own, a near-miss, another capability's — is never offered
205
+ * and never in the default grant, and the room is unjoinable from every management client with no error to
206
+ * say why. And a scope that also gates a write would make joining a room need a mutation grant, which
207
+ * inverts the read rule `grant.ts` applies.
208
+ */
209
+ export function assertPresenceDeclaration(
210
+ capability: string,
211
+ presence: PresenceDeclaration,
212
+ adminRoutes: readonly PresenceGatingRoute[],
213
+ ): void {
214
+ const refuse = (detail: string): never => {
215
+ throw new InternalError({
216
+ message: `${capability} declares presence rooms this Worker cannot serve.`,
217
+ action: `Fix the presence declaration on the ${capability} capability.`,
218
+ detail,
219
+ });
220
+ };
221
+ if (!presence.basePath.startsWith("/") || presence.basePath.endsWith("/")) {
222
+ refuse(`presence basePath "${presence.basePath}" must start with "/" and not end with one`);
223
+ }
224
+ const seen = new Set<string>();
225
+ for (const room of presence.rooms) {
226
+ if (!PresenceRoomKindName.safeParse(room.kind).success) refuse(`room kind "${room.kind}" is not a kind name`);
227
+ if (!room.kind.startsWith(`${capability}.`)) {
228
+ refuse(
229
+ `room kind "${room.kind}" must start with "${capability}." — a capability declares kinds under its own name`,
230
+ );
231
+ }
232
+ if (seen.has(room.kind)) refuse(`room kind "${room.kind}" is declared twice`);
233
+ seen.add(room.kind);
234
+ if (!ControlPlaneScope.safeParse(room.scope).success)
235
+ refuse(`room kind "${room.kind}" scope "${room.scope}" is not a scope`);
236
+ // Inherited, never invented: the scope a room joins with is one this capability already gates a read with.
237
+ const gating = adminRoutes.filter((route) => route.scope === room.scope);
238
+ if (gating.length === 0) {
239
+ refuse(
240
+ `room kind "${room.kind}" scope "${room.scope}": no admin route of ${capability} requires it, so no grant would ever offer it`,
241
+ );
242
+ }
243
+ const write = gating.find((route) => route.method !== "GET");
244
+ if (write) {
245
+ refuse(
246
+ `room kind "${room.kind}" scope "${room.scope}" gates a ${write.method}; a room joins with a read scope, one only GET routes require`,
247
+ );
248
+ }
249
+ if (room.activities.length === 0 || room.activities.length > MAX_PRESENCE_ACTIVITIES) {
250
+ refuse(
251
+ `room kind "${room.kind}" declares ${room.activities.length} activities; it needs 1 to ${MAX_PRESENCE_ACTIVITIES}`,
252
+ );
253
+ }
254
+ if (new Set(room.activities).size !== room.activities.length) {
255
+ refuse(`room kind "${room.kind}" declares an activity twice`);
256
+ }
257
+ for (const activity of room.activities) {
258
+ if (!PresenceActivity.safeParse(activity).success)
259
+ refuse(`room kind "${room.kind}" activity "${activity}" is not one word`);
260
+ }
261
+ const cap = participantCap(room);
262
+ if (!Number.isInteger(cap) || cap < 1 || cap > MAX_PRESENCE_PARTICIPANTS) {
263
+ refuse(`room kind "${room.kind}" maxParticipants ${cap} is outside 1 to ${MAX_PRESENCE_PARTICIPANTS}`);
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * The ticket signing keys, resolved and non-empty — the first signs, every one verifies. A blank entry is
270
+ * dropped rather than tried, since WebCrypto refuses a zero-length key with an error that names nothing.
271
+ */
272
+ export async function resolveTicketKeys(key: PresenceTicketKey, env: Record<string, unknown>): Promise<string[]> {
273
+ const resolved = typeof key === "function" ? await key(env) : key;
274
+ const keys = (typeof resolved === "string" ? [resolved] : [...resolved]).filter((value) => value.length > 0);
275
+ if (keys.length === 0) {
276
+ throw new InternalError({
277
+ message: "Presence is not available right now.",
278
+ action: `Set ${PRESENCE_TICKET_KEY_SECRET}. ${remedy("secrets provision", {}, { runtime: true }).say((command) => `${command} mints it for each environment.`)}`,
279
+ detail: `${PRESENCE_TICKET_KEY_SECRET} resolved to no usable value`,
280
+ });
281
+ }
282
+ return keys;
283
+ }
@@ -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
+ }