@open-mercato/documents 0.6.8-develop.7016.1.4ed7b1e49d

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 (602) hide show
  1. package/README.md +88 -0
  2. package/build.mjs +23 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/documents/acl.js +15 -0
  6. package/dist/modules/documents/acl.js.map +7 -0
  7. package/dist/modules/documents/api/[id]/archive/route.js +116 -0
  8. package/dist/modules/documents/api/[id]/archive/route.js.map +7 -0
  9. package/dist/modules/documents/api/[id]/attachments/[attachmentId]/route.js +155 -0
  10. package/dist/modules/documents/api/[id]/attachments/[attachmentId]/route.js.map +7 -0
  11. package/dist/modules/documents/api/[id]/attachments/route.js +153 -0
  12. package/dist/modules/documents/api/[id]/attachments/route.js.map +7 -0
  13. package/dist/modules/documents/api/[id]/collab-token/route.js +180 -0
  14. package/dist/modules/documents/api/[id]/collab-token/route.js.map +7 -0
  15. package/dist/modules/documents/api/[id]/comments/access-check/route.js +86 -0
  16. package/dist/modules/documents/api/[id]/comments/access-check/route.js.map +7 -0
  17. package/dist/modules/documents/api/[id]/comments/route.js +387 -0
  18. package/dist/modules/documents/api/[id]/comments/route.js.map +7 -0
  19. package/dist/modules/documents/api/[id]/content/route.js +143 -0
  20. package/dist/modules/documents/api/[id]/content/route.js.map +7 -0
  21. package/dist/modules/documents/api/[id]/duplicate/route.js +200 -0
  22. package/dist/modules/documents/api/[id]/duplicate/route.js.map +7 -0
  23. package/dist/modules/documents/api/[id]/export/route.js +421 -0
  24. package/dist/modules/documents/api/[id]/export/route.js.map +7 -0
  25. package/dist/modules/documents/api/[id]/favorite/route.js +96 -0
  26. package/dist/modules/documents/api/[id]/favorite/route.js.map +7 -0
  27. package/dist/modules/documents/api/[id]/links/[linkId]/route.js +85 -0
  28. package/dist/modules/documents/api/[id]/links/[linkId]/route.js.map +7 -0
  29. package/dist/modules/documents/api/[id]/links/route.js +257 -0
  30. package/dist/modules/documents/api/[id]/links/route.js.map +7 -0
  31. package/dist/modules/documents/api/[id]/principals/route.js +299 -0
  32. package/dist/modules/documents/api/[id]/principals/route.js.map +7 -0
  33. package/dist/modules/documents/api/[id]/route.js +246 -0
  34. package/dist/modules/documents/api/[id]/route.js.map +7 -0
  35. package/dist/modules/documents/api/[id]/shares/route.js +335 -0
  36. package/dist/modules/documents/api/[id]/shares/route.js.map +7 -0
  37. package/dist/modules/documents/api/[id]/unarchive/route.js +39 -0
  38. package/dist/modules/documents/api/[id]/unarchive/route.js.map +7 -0
  39. package/dist/modules/documents/api/[id]/versions/[versionId]/restore/route.js +133 -0
  40. package/dist/modules/documents/api/[id]/versions/[versionId]/restore/route.js.map +7 -0
  41. package/dist/modules/documents/api/[id]/versions/[versionId]/route.js +93 -0
  42. package/dist/modules/documents/api/[id]/versions/[versionId]/route.js.map +7 -0
  43. package/dist/modules/documents/api/[id]/versions/route.js +193 -0
  44. package/dist/modules/documents/api/[id]/versions/route.js.map +7 -0
  45. package/dist/modules/documents/api/[id]/watch/route.js +97 -0
  46. package/dist/modules/documents/api/[id]/watch/route.js.map +7 -0
  47. package/dist/modules/documents/api/_commands.js +34 -0
  48. package/dist/modules/documents/api/_commands.js.map +7 -0
  49. package/dist/modules/documents/api/_shared.js +490 -0
  50. package/dist/modules/documents/api/_shared.js.map +7 -0
  51. package/dist/modules/documents/api/folders/route.js +256 -0
  52. package/dist/modules/documents/api/folders/route.js.map +7 -0
  53. package/dist/modules/documents/api/instantiate/route.js +108 -0
  54. package/dist/modules/documents/api/instantiate/route.js.map +7 -0
  55. package/dist/modules/documents/api/route.js +322 -0
  56. package/dist/modules/documents/api/route.js.map +7 -0
  57. package/dist/modules/documents/api/templates/[templateId]/preview/route.js +94 -0
  58. package/dist/modules/documents/api/templates/[templateId]/preview/route.js.map +7 -0
  59. package/dist/modules/documents/api/templates/[templateId]/route.js +82 -0
  60. package/dist/modules/documents/api/templates/[templateId]/route.js.map +7 -0
  61. package/dist/modules/documents/api/templates/route.js +344 -0
  62. package/dist/modules/documents/api/templates/route.js.map +7 -0
  63. package/dist/modules/documents/backend/documents/DocumentsPageClient.js +160 -0
  64. package/dist/modules/documents/backend/documents/DocumentsPageClient.js.map +7 -0
  65. package/dist/modules/documents/backend/documents/DocumentsTable.js +143 -0
  66. package/dist/modules/documents/backend/documents/DocumentsTable.js.map +7 -0
  67. package/dist/modules/documents/backend/documents/FolderDialog.js +55 -0
  68. package/dist/modules/documents/backend/documents/FolderDialog.js.map +7 -0
  69. package/dist/modules/documents/backend/documents/FolderTree.js +40 -0
  70. package/dist/modules/documents/backend/documents/FolderTree.js.map +7 -0
  71. package/dist/modules/documents/backend/documents/MoveDocumentDialog.js +64 -0
  72. package/dist/modules/documents/backend/documents/MoveDocumentDialog.js.map +7 -0
  73. package/dist/modules/documents/backend/documents/[id]/CommentAnchorNavigation.js +77 -0
  74. package/dist/modules/documents/backend/documents/[id]/CommentAnchorNavigation.js.map +7 -0
  75. package/dist/modules/documents/backend/documents/[id]/CommentComposer.js +126 -0
  76. package/dist/modules/documents/backend/documents/[id]/CommentComposer.js.map +7 -0
  77. package/dist/modules/documents/backend/documents/[id]/CommentThreadList.js +88 -0
  78. package/dist/modules/documents/backend/documents/[id]/CommentThreadList.js.map +7 -0
  79. package/dist/modules/documents/backend/documents/[id]/CommentsRail.js +123 -0
  80. package/dist/modules/documents/backend/documents/[id]/CommentsRail.js.map +7 -0
  81. package/dist/modules/documents/backend/documents/[id]/DocumentCanvas.js +91 -0
  82. package/dist/modules/documents/backend/documents/[id]/DocumentCanvas.js.map +7 -0
  83. package/dist/modules/documents/backend/documents/[id]/DocumentEditorErrorBoundary.js +27 -0
  84. package/dist/modules/documents/backend/documents/[id]/DocumentEditorErrorBoundary.js.map +7 -0
  85. package/dist/modules/documents/backend/documents/[id]/DocumentEditorIsland.js +50 -0
  86. package/dist/modules/documents/backend/documents/[id]/DocumentEditorIsland.js.map +7 -0
  87. package/dist/modules/documents/backend/documents/[id]/DocumentEditorSurface.js +168 -0
  88. package/dist/modules/documents/backend/documents/[id]/DocumentEditorSurface.js.map +7 -0
  89. package/dist/modules/documents/backend/documents/[id]/DocumentNavigator.js +129 -0
  90. package/dist/modules/documents/backend/documents/[id]/DocumentNavigator.js.map +7 -0
  91. package/dist/modules/documents/backend/documents/[id]/DocumentPageClient.js +429 -0
  92. package/dist/modules/documents/backend/documents/[id]/DocumentPageClient.js.map +7 -0
  93. package/dist/modules/documents/backend/documents/[id]/EditorColorTools.js +68 -0
  94. package/dist/modules/documents/backend/documents/[id]/EditorColorTools.js.map +7 -0
  95. package/dist/modules/documents/backend/documents/[id]/EditorStatusPresence.js +69 -0
  96. package/dist/modules/documents/backend/documents/[id]/EditorStatusPresence.js.map +7 -0
  97. package/dist/modules/documents/backend/documents/[id]/EditorToolbar.js +151 -0
  98. package/dist/modules/documents/backend/documents/[id]/EditorToolbar.js.map +7 -0
  99. package/dist/modules/documents/backend/documents/[id]/ExportMenu.js +121 -0
  100. package/dist/modules/documents/backend/documents/[id]/ExportMenu.js.map +7 -0
  101. package/dist/modules/documents/backend/documents/[id]/MentionPicker.js +114 -0
  102. package/dist/modules/documents/backend/documents/[id]/MentionPicker.js.map +7 -0
  103. package/dist/modules/documents/backend/documents/[id]/OutlinePane.js +70 -0
  104. package/dist/modules/documents/backend/documents/[id]/OutlinePane.js.map +7 -0
  105. package/dist/modules/documents/backend/documents/[id]/RecordFieldsDialog.js +157 -0
  106. package/dist/modules/documents/backend/documents/[id]/RecordFieldsDialog.js.map +7 -0
  107. package/dist/modules/documents/backend/documents/[id]/RelatedRecordsPanel.js +266 -0
  108. package/dist/modules/documents/backend/documents/[id]/RelatedRecordsPanel.js.map +7 -0
  109. package/dist/modules/documents/backend/documents/[id]/VersionHistoryPanel.js +227 -0
  110. package/dist/modules/documents/backend/documents/[id]/VersionHistoryPanel.js.map +7 -0
  111. package/dist/modules/documents/backend/documents/[id]/VersionPreviewDialog.js +157 -0
  112. package/dist/modules/documents/backend/documents/[id]/VersionPreviewDialog.js.map +7 -0
  113. package/dist/modules/documents/backend/documents/[id]/commentTypes.js +150 -0
  114. package/dist/modules/documents/backend/documents/[id]/commentTypes.js.map +7 -0
  115. package/dist/modules/documents/backend/documents/[id]/componentCapabilities.js +13 -0
  116. package/dist/modules/documents/backend/documents/[id]/componentCapabilities.js.map +7 -0
  117. package/dist/modules/documents/backend/documents/[id]/documentPagination.js +295 -0
  118. package/dist/modules/documents/backend/documents/[id]/documentPagination.js.map +7 -0
  119. package/dist/modules/documents/backend/documents/[id]/editorPresenceStyles.js +81 -0
  120. package/dist/modules/documents/backend/documents/[id]/editorPresenceStyles.js.map +7 -0
  121. package/dist/modules/documents/backend/documents/[id]/editorTypes.js +52 -0
  122. package/dist/modules/documents/backend/documents/[id]/editorTypes.js.map +7 -0
  123. package/dist/modules/documents/backend/documents/[id]/page.js +10 -0
  124. package/dist/modules/documents/backend/documents/[id]/page.js.map +7 -0
  125. package/dist/modules/documents/backend/documents/[id]/page.meta.js +16 -0
  126. package/dist/modules/documents/backend/documents/[id]/page.meta.js.map +7 -0
  127. package/dist/modules/documents/backend/documents/[id]/relatedRecordModel.js +38 -0
  128. package/dist/modules/documents/backend/documents/[id]/relatedRecordModel.js.map +7 -0
  129. package/dist/modules/documents/backend/documents/[id]/restoreVersion.js +33 -0
  130. package/dist/modules/documents/backend/documents/[id]/restoreVersion.js.map +7 -0
  131. package/dist/modules/documents/backend/documents/[id]/useDocumentCollaboration.js +392 -0
  132. package/dist/modules/documents/backend/documents/[id]/useDocumentCollaboration.js.map +7 -0
  133. package/dist/modules/documents/backend/documents/[id]/useDocumentComments.js +253 -0
  134. package/dist/modules/documents/backend/documents/[id]/useDocumentComments.js.map +7 -0
  135. package/dist/modules/documents/backend/documents/[id]/useDocumentEditor.js +149 -0
  136. package/dist/modules/documents/backend/documents/[id]/useDocumentEditor.js.map +7 -0
  137. package/dist/modules/documents/backend/documents/[id]/useDocumentTitle.js +82 -0
  138. package/dist/modules/documents/backend/documents/[id]/useDocumentTitle.js.map +7 -0
  139. package/dist/modules/documents/backend/documents/[id]/useEditorInsertions.js +144 -0
  140. package/dist/modules/documents/backend/documents/[id]/useEditorInsertions.js.map +7 -0
  141. package/dist/modules/documents/backend/documents/[id]/useFallbackContentPersistence.js +237 -0
  142. package/dist/modules/documents/backend/documents/[id]/useFallbackContentPersistence.js.map +7 -0
  143. package/dist/modules/documents/backend/documents/[id]/useMentionPicker.js +216 -0
  144. package/dist/modules/documents/backend/documents/[id]/useMentionPicker.js.map +7 -0
  145. package/dist/modules/documents/backend/documents/[id]/versionHistoryModel.js +25 -0
  146. package/dist/modules/documents/backend/documents/[id]/versionHistoryModel.js.map +7 -0
  147. package/dist/modules/documents/backend/documents/components/EntityPicker.js +156 -0
  148. package/dist/modules/documents/backend/documents/components/EntityPicker.js.map +7 -0
  149. package/dist/modules/documents/backend/documents/components/EntityPickerResults.js +46 -0
  150. package/dist/modules/documents/backend/documents/components/EntityPickerResults.js.map +7 -0
  151. package/dist/modules/documents/backend/documents/components/NewFromTemplateDialog.js +127 -0
  152. package/dist/modules/documents/backend/documents/components/NewFromTemplateDialog.js.map +7 -0
  153. package/dist/modules/documents/backend/documents/components/PrincipalPicker.js +84 -0
  154. package/dist/modules/documents/backend/documents/components/PrincipalPicker.js.map +7 -0
  155. package/dist/modules/documents/backend/documents/components/ShareDialog.js +77 -0
  156. package/dist/modules/documents/backend/documents/components/ShareDialog.js.map +7 -0
  157. package/dist/modules/documents/backend/documents/components/ShareDialogAddForm.js +93 -0
  158. package/dist/modules/documents/backend/documents/components/ShareDialogAddForm.js.map +7 -0
  159. package/dist/modules/documents/backend/documents/components/ShareDialogList.js +82 -0
  160. package/dist/modules/documents/backend/documents/components/ShareDialogList.js.map +7 -0
  161. package/dist/modules/documents/backend/documents/components/TemplateBodyEditor.js +96 -0
  162. package/dist/modules/documents/backend/documents/components/TemplateBodyEditor.js.map +7 -0
  163. package/dist/modules/documents/backend/documents/components/TemplateEditorDialog.js +169 -0
  164. package/dist/modules/documents/backend/documents/components/TemplateEditorDialog.js.map +7 -0
  165. package/dist/modules/documents/backend/documents/components/TemplatePreview.js +38 -0
  166. package/dist/modules/documents/backend/documents/components/TemplatePreview.js.map +7 -0
  167. package/dist/modules/documents/backend/documents/components/TemplateSlotFields.js +69 -0
  168. package/dist/modules/documents/backend/documents/components/TemplateSlotFields.js.map +7 -0
  169. package/dist/modules/documents/backend/documents/components/TemplateSlotsEditor.js +71 -0
  170. package/dist/modules/documents/backend/documents/components/TemplateSlotsEditor.js.map +7 -0
  171. package/dist/modules/documents/backend/documents/components/principalPickerModel.js +63 -0
  172. package/dist/modules/documents/backend/documents/components/principalPickerModel.js.map +7 -0
  173. package/dist/modules/documents/backend/documents/components/shareDialogModel.js +68 -0
  174. package/dist/modules/documents/backend/documents/components/shareDialogModel.js.map +7 -0
  175. package/dist/modules/documents/backend/documents/components/templateUi.js +89 -0
  176. package/dist/modules/documents/backend/documents/components/templateUi.js.map +7 -0
  177. package/dist/modules/documents/backend/documents/components/useAvailableEntityRegistry.js +30 -0
  178. package/dist/modules/documents/backend/documents/components/useAvailableEntityRegistry.js.map +7 -0
  179. package/dist/modules/documents/backend/documents/components/useEntitySearch.js +191 -0
  180. package/dist/modules/documents/backend/documents/components/useEntitySearch.js.map +7 -0
  181. package/dist/modules/documents/backend/documents/components/usePrincipalPicker.js +221 -0
  182. package/dist/modules/documents/backend/documents/components/usePrincipalPicker.js.map +7 -0
  183. package/dist/modules/documents/backend/documents/components/useShareDialog.js +236 -0
  184. package/dist/modules/documents/backend/documents/components/useShareDialog.js.map +7 -0
  185. package/dist/modules/documents/backend/documents/components/useTemplateDetail.js +49 -0
  186. package/dist/modules/documents/backend/documents/components/useTemplateDetail.js.map +7 -0
  187. package/dist/modules/documents/backend/documents/components/useTemplateInstantiation.js +234 -0
  188. package/dist/modules/documents/backend/documents/components/useTemplateInstantiation.js.map +7 -0
  189. package/dist/modules/documents/backend/documents/documentUi.js +106 -0
  190. package/dist/modules/documents/backend/documents/documentUi.js.map +7 -0
  191. package/dist/modules/documents/backend/documents/documentsListTypes.js +132 -0
  192. package/dist/modules/documents/backend/documents/documentsListTypes.js.map +7 -0
  193. package/dist/modules/documents/backend/documents/page.js +9 -0
  194. package/dist/modules/documents/backend/documents/page.js.map +7 -0
  195. package/dist/modules/documents/backend/documents/page.meta.js +26 -0
  196. package/dist/modules/documents/backend/documents/page.meta.js.map +7 -0
  197. package/dist/modules/documents/backend/documents/templates/TemplatesPageClient.js +91 -0
  198. package/dist/modules/documents/backend/documents/templates/TemplatesPageClient.js.map +7 -0
  199. package/dist/modules/documents/backend/documents/templates/TemplatesTable.js +58 -0
  200. package/dist/modules/documents/backend/documents/templates/TemplatesTable.js.map +7 -0
  201. package/dist/modules/documents/backend/documents/templates/page.js +9 -0
  202. package/dist/modules/documents/backend/documents/templates/page.js.map +7 -0
  203. package/dist/modules/documents/backend/documents/templates/page.meta.js +34 -0
  204. package/dist/modules/documents/backend/documents/templates/page.meta.js.map +7 -0
  205. package/dist/modules/documents/backend/documents/templates/useTemplatesPage.js +139 -0
  206. package/dist/modules/documents/backend/documents/templates/useTemplatesPage.js.map +7 -0
  207. package/dist/modules/documents/backend/documents/useDocumentsList.js +314 -0
  208. package/dist/modules/documents/backend/documents/useDocumentsList.js.map +7 -0
  209. package/dist/modules/documents/ce.js +8 -0
  210. package/dist/modules/documents/ce.js.map +7 -0
  211. package/dist/modules/documents/commands/aggregate.js +99 -0
  212. package/dist/modules/documents/commands/aggregate.js.map +7 -0
  213. package/dist/modules/documents/commands/attachments.js +252 -0
  214. package/dist/modules/documents/commands/attachments.js.map +7 -0
  215. package/dist/modules/documents/commands/comments.js +782 -0
  216. package/dist/modules/documents/commands/comments.js.map +7 -0
  217. package/dist/modules/documents/commands/content.js +238 -0
  218. package/dist/modules/documents/commands/content.js.map +7 -0
  219. package/dist/modules/documents/commands/document-crud.js +505 -0
  220. package/dist/modules/documents/commands/document-crud.js.map +7 -0
  221. package/dist/modules/documents/commands/documents.js +473 -0
  222. package/dist/modules/documents/commands/documents.js.map +7 -0
  223. package/dist/modules/documents/commands/duplicate.js +640 -0
  224. package/dist/modules/documents/commands/duplicate.js.map +7 -0
  225. package/dist/modules/documents/commands/favorites.js +150 -0
  226. package/dist/modules/documents/commands/favorites.js.map +7 -0
  227. package/dist/modules/documents/commands/folders.js +420 -0
  228. package/dist/modules/documents/commands/folders.js.map +7 -0
  229. package/dist/modules/documents/commands/index.js +64 -0
  230. package/dist/modules/documents/commands/index.js.map +7 -0
  231. package/dist/modules/documents/commands/interceptors.js +300 -0
  232. package/dist/modules/documents/commands/interceptors.js.map +7 -0
  233. package/dist/modules/documents/commands/lifecycle.js +249 -0
  234. package/dist/modules/documents/commands/lifecycle.js.map +7 -0
  235. package/dist/modules/documents/commands/links.js +410 -0
  236. package/dist/modules/documents/commands/links.js.map +7 -0
  237. package/dist/modules/documents/commands/mutation-helpers.js +42 -0
  238. package/dist/modules/documents/commands/mutation-helpers.js.map +7 -0
  239. package/dist/modules/documents/commands/projection-types.js +1 -0
  240. package/dist/modules/documents/commands/projection-types.js.map +7 -0
  241. package/dist/modules/documents/commands/shared.js +143 -0
  242. package/dist/modules/documents/commands/shared.js.map +7 -0
  243. package/dist/modules/documents/commands/shares.js +527 -0
  244. package/dist/modules/documents/commands/shares.js.map +7 -0
  245. package/dist/modules/documents/commands/side-effects.js +103 -0
  246. package/dist/modules/documents/commands/side-effects.js.map +7 -0
  247. package/dist/modules/documents/commands/templates.js +461 -0
  248. package/dist/modules/documents/commands/templates.js.map +7 -0
  249. package/dist/modules/documents/commands/versions.js +432 -0
  250. package/dist/modules/documents/commands/versions.js.map +7 -0
  251. package/dist/modules/documents/commands/watchers.js +164 -0
  252. package/dist/modules/documents/commands/watchers.js.map +7 -0
  253. package/dist/modules/documents/data/entities.js +653 -0
  254. package/dist/modules/documents/data/entities.js.map +7 -0
  255. package/dist/modules/documents/data/extensions.js +44 -0
  256. package/dist/modules/documents/data/extensions.js.map +7 -0
  257. package/dist/modules/documents/data/validators.js +219 -0
  258. package/dist/modules/documents/data/validators.js.map +7 -0
  259. package/dist/modules/documents/di.js +35 -0
  260. package/dist/modules/documents/di.js.map +7 -0
  261. package/dist/modules/documents/encryption.js +12 -0
  262. package/dist/modules/documents/encryption.js.map +7 -0
  263. package/dist/modules/documents/events.js +27 -0
  264. package/dist/modules/documents/events.js.map +7 -0
  265. package/dist/modules/documents/index.js +19 -0
  266. package/dist/modules/documents/index.js.map +7 -0
  267. package/dist/modules/documents/lib/attachmentServicePort.js +30 -0
  268. package/dist/modules/documents/lib/attachmentServicePort.js.map +7 -0
  269. package/dist/modules/documents/lib/capabilities.js +32 -0
  270. package/dist/modules/documents/lib/capabilities.js.map +7 -0
  271. package/dist/modules/documents/lib/collabCloseEvents.js +17 -0
  272. package/dist/modules/documents/lib/collabCloseEvents.js.map +7 -0
  273. package/dist/modules/documents/lib/collabEndpoint.js +22 -0
  274. package/dist/modules/documents/lib/collabEndpoint.js.map +7 -0
  275. package/dist/modules/documents/lib/collabMaterializer.js +107 -0
  276. package/dist/modules/documents/lib/collabMaterializer.js.map +7 -0
  277. package/dist/modules/documents/lib/collabToken.js +120 -0
  278. package/dist/modules/documents/lib/collabToken.js.map +7 -0
  279. package/dist/modules/documents/lib/collaborationAwareness.js +68 -0
  280. package/dist/modules/documents/lib/collaborationAwareness.js.map +7 -0
  281. package/dist/modules/documents/lib/commentAnchors.js +15 -0
  282. package/dist/modules/documents/lib/commentAnchors.js.map +7 -0
  283. package/dist/modules/documents/lib/constants.js +25 -0
  284. package/dist/modules/documents/lib/constants.js.map +7 -0
  285. package/dist/modules/documents/lib/contentService.js +248 -0
  286. package/dist/modules/documents/lib/contentService.js.map +7 -0
  287. package/dist/modules/documents/lib/displayLabels.js +23 -0
  288. package/dist/modules/documents/lib/displayLabels.js.map +7 -0
  289. package/dist/modules/documents/lib/docxRenderer.js +601 -0
  290. package/dist/modules/documents/lib/docxRenderer.js.map +7 -0
  291. package/dist/modules/documents/lib/duplicateContent.js +22 -0
  292. package/dist/modules/documents/lib/duplicateContent.js.map +7 -0
  293. package/dist/modules/documents/lib/editorConfig.js +287 -0
  294. package/dist/modules/documents/lib/editorConfig.js.map +7 -0
  295. package/dist/modules/documents/lib/entityLinks.js +148 -0
  296. package/dist/modules/documents/lib/entityLinks.js.map +7 -0
  297. package/dist/modules/documents/lib/entityRegistry.js +258 -0
  298. package/dist/modules/documents/lib/entityRegistry.js.map +7 -0
  299. package/dist/modules/documents/lib/entityRegistry.server.js +189 -0
  300. package/dist/modules/documents/lib/entityRegistry.server.js.map +7 -0
  301. package/dist/modules/documents/lib/entityRegistryAvailability.server.js +9 -0
  302. package/dist/modules/documents/lib/entityRegistryAvailability.server.js.map +7 -0
  303. package/dist/modules/documents/lib/entitySuggestion.js +50 -0
  304. package/dist/modules/documents/lib/entitySuggestion.js.map +7 -0
  305. package/dist/modules/documents/lib/favorites.js +22 -0
  306. package/dist/modules/documents/lib/favorites.js.map +7 -0
  307. package/dist/modules/documents/lib/folderHierarchySerialization.js +19 -0
  308. package/dist/modules/documents/lib/folderHierarchySerialization.js.map +7 -0
  309. package/dist/modules/documents/lib/historyLimits.js +100 -0
  310. package/dist/modules/documents/lib/historyLimits.js.map +7 -0
  311. package/dist/modules/documents/lib/historyPolicy.js +14 -0
  312. package/dist/modules/documents/lib/historyPolicy.js.map +7 -0
  313. package/dist/modules/documents/lib/organizationAccess.js +17 -0
  314. package/dist/modules/documents/lib/organizationAccess.js.map +7 -0
  315. package/dist/modules/documents/lib/pdfHtml.js +62 -0
  316. package/dist/modules/documents/lib/pdfHtml.js.map +7 -0
  317. package/dist/modules/documents/lib/pdfRenderer.js +284 -0
  318. package/dist/modules/documents/lib/pdfRenderer.js.map +7 -0
  319. package/dist/modules/documents/lib/permissions.js +222 -0
  320. package/dist/modules/documents/lib/permissions.js.map +7 -0
  321. package/dist/modules/documents/lib/platformServices.js +35 -0
  322. package/dist/modules/documents/lib/platformServices.js.map +7 -0
  323. package/dist/modules/documents/lib/recordFieldInsertion.js +58 -0
  324. package/dist/modules/documents/lib/recordFieldInsertion.js.map +7 -0
  325. package/dist/modules/documents/lib/requestBody.js +80 -0
  326. package/dist/modules/documents/lib/requestBody.js.map +7 -0
  327. package/dist/modules/documents/lib/resourceLimits.js +62 -0
  328. package/dist/modules/documents/lib/resourceLimits.js.map +7 -0
  329. package/dist/modules/documents/lib/shareCounts.js +25 -0
  330. package/dist/modules/documents/lib/shareCounts.js.map +7 -0
  331. package/dist/modules/documents/lib/templateFill.js +84 -0
  332. package/dist/modules/documents/lib/templateFill.js.map +7 -0
  333. package/dist/modules/documents/lib/templateInstantiation.js +147 -0
  334. package/dist/modules/documents/lib/templateInstantiation.js.map +7 -0
  335. package/dist/modules/documents/lib/templatePreviewDigest.js +82 -0
  336. package/dist/modules/documents/lib/templatePreviewDigest.js.map +7 -0
  337. package/dist/modules/documents/lib/templateSeeds.js +201 -0
  338. package/dist/modules/documents/lib/templateSeeds.js.map +7 -0
  339. package/dist/modules/documents/lib/userLabels.js +37 -0
  340. package/dist/modules/documents/lib/userLabels.js.map +7 -0
  341. package/dist/modules/documents/lib/versionContent.js +131 -0
  342. package/dist/modules/documents/lib/versionContent.js.map +7 -0
  343. package/dist/modules/documents/lib/versionLabels.js +12 -0
  344. package/dist/modules/documents/lib/versionLabels.js.map +7 -0
  345. package/dist/modules/documents/lib/versioning.js +14 -0
  346. package/dist/modules/documents/lib/versioning.js.map +7 -0
  347. package/dist/modules/documents/lib/visibility.js +422 -0
  348. package/dist/modules/documents/lib/visibility.js.map +7 -0
  349. package/dist/modules/documents/lib/watchers.js +106 -0
  350. package/dist/modules/documents/lib/watchers.js.map +7 -0
  351. package/dist/modules/documents/migrations/Migration20260708163836_documents.js +44 -0
  352. package/dist/modules/documents/migrations/Migration20260708163836_documents.js.map +7 -0
  353. package/dist/modules/documents/migrations/Migration20260709164720_documents.js +17 -0
  354. package/dist/modules/documents/migrations/Migration20260709164720_documents.js.map +7 -0
  355. package/dist/modules/documents/migrations/Migration20260710002318_documents.js +32 -0
  356. package/dist/modules/documents/migrations/Migration20260710002318_documents.js.map +7 -0
  357. package/dist/modules/documents/migrations/Migration20260710071003_documents.js +13 -0
  358. package/dist/modules/documents/migrations/Migration20260710071003_documents.js.map +7 -0
  359. package/dist/modules/documents/migrations/Migration20260712120000_documents.js +13 -0
  360. package/dist/modules/documents/migrations/Migration20260712120000_documents.js.map +7 -0
  361. package/dist/modules/documents/migrations/Migration20260712183000_documents.js +14 -0
  362. package/dist/modules/documents/migrations/Migration20260712183000_documents.js.map +7 -0
  363. package/dist/modules/documents/migrations/Migration20260713092156_documents.js +29 -0
  364. package/dist/modules/documents/migrations/Migration20260713092156_documents.js.map +7 -0
  365. package/dist/modules/documents/migrations/Migration20260717000000_documents.js +37 -0
  366. package/dist/modules/documents/migrations/Migration20260717000000_documents.js.map +7 -0
  367. package/dist/modules/documents/notifications.js +47 -0
  368. package/dist/modules/documents/notifications.js.map +7 -0
  369. package/dist/modules/documents/search.js +101 -0
  370. package/dist/modules/documents/search.js.map +7 -0
  371. package/dist/modules/documents/setup.js +19 -0
  372. package/dist/modules/documents/setup.js.map +7 -0
  373. package/dist/modules/documents/widgets/injection/related-documents/LinkDocumentDialog.js +214 -0
  374. package/dist/modules/documents/widgets/injection/related-documents/LinkDocumentDialog.js.map +7 -0
  375. package/dist/modules/documents/widgets/injection/related-documents/context.js +84 -0
  376. package/dist/modules/documents/widgets/injection/related-documents/context.js.map +7 -0
  377. package/dist/modules/documents/widgets/injection/related-documents/useRelatedDocuments.js +98 -0
  378. package/dist/modules/documents/widgets/injection/related-documents/useRelatedDocuments.js.map +7 -0
  379. package/dist/modules/documents/widgets/injection/related-documents/widget.client.js +83 -0
  380. package/dist/modules/documents/widgets/injection/related-documents/widget.client.js.map +7 -0
  381. package/dist/modules/documents/widgets/injection/related-documents/widget.js +19 -0
  382. package/dist/modules/documents/widgets/injection/related-documents/widget.js.map +7 -0
  383. package/dist/modules/documents/widgets/injection-table.js +21 -0
  384. package/dist/modules/documents/widgets/injection-table.js.map +7 -0
  385. package/dist/server/documents-collab-server.js +2260 -0
  386. package/dist/server/documents-collab-server.js.map +7 -0
  387. package/generated/entities/document/index.ts +12 -0
  388. package/generated/entities/document_attachment/index.ts +9 -0
  389. package/generated/entities/document_comment/index.ts +14 -0
  390. package/generated/entities/document_content/index.ts +11 -0
  391. package/generated/entities/document_entity_link/index.ts +19 -0
  392. package/generated/entities/document_favorite/index.ts +7 -0
  393. package/generated/entities/document_folder/index.ts +9 -0
  394. package/generated/entities/document_share/index.ts +11 -0
  395. package/generated/entities/document_template/index.ts +13 -0
  396. package/generated/entities/document_version/index.ts +9 -0
  397. package/generated/entities/document_watcher/index.ts +7 -0
  398. package/generated/entities.ids.generated.ts +21 -0
  399. package/generated/entity-fields-registry.ts +151 -0
  400. package/jest.config.cjs +34 -0
  401. package/package.json +149 -0
  402. package/server/documents-collab-server.ts +3416 -0
  403. package/src/index.ts +1 -0
  404. package/src/modules/documents/acl.ts +11 -0
  405. package/src/modules/documents/api/[id]/archive/route.ts +132 -0
  406. package/src/modules/documents/api/[id]/attachments/[attachmentId]/route.ts +166 -0
  407. package/src/modules/documents/api/[id]/attachments/route.ts +173 -0
  408. package/src/modules/documents/api/[id]/collab-token/route.ts +203 -0
  409. package/src/modules/documents/api/[id]/comments/access-check/route.ts +96 -0
  410. package/src/modules/documents/api/[id]/comments/route.ts +442 -0
  411. package/src/modules/documents/api/[id]/content/route.ts +156 -0
  412. package/src/modules/documents/api/[id]/duplicate/route.ts +231 -0
  413. package/src/modules/documents/api/[id]/export/route.ts +535 -0
  414. package/src/modules/documents/api/[id]/favorite/route.ts +110 -0
  415. package/src/modules/documents/api/[id]/links/[linkId]/route.ts +92 -0
  416. package/src/modules/documents/api/[id]/links/route.ts +301 -0
  417. package/src/modules/documents/api/[id]/principals/route.ts +375 -0
  418. package/src/modules/documents/api/[id]/route.ts +267 -0
  419. package/src/modules/documents/api/[id]/shares/route.ts +372 -0
  420. package/src/modules/documents/api/[id]/unarchive/route.ts +42 -0
  421. package/src/modules/documents/api/[id]/versions/[versionId]/restore/route.ts +152 -0
  422. package/src/modules/documents/api/[id]/versions/[versionId]/route.ts +100 -0
  423. package/src/modules/documents/api/[id]/versions/route.ts +207 -0
  424. package/src/modules/documents/api/[id]/watch/route.ts +111 -0
  425. package/src/modules/documents/api/_commands.ts +53 -0
  426. package/src/modules/documents/api/_shared.ts +676 -0
  427. package/src/modules/documents/api/folders/route.ts +287 -0
  428. package/src/modules/documents/api/instantiate/route.ts +114 -0
  429. package/src/modules/documents/api/route.ts +362 -0
  430. package/src/modules/documents/api/templates/[templateId]/preview/route.ts +97 -0
  431. package/src/modules/documents/api/templates/[templateId]/route.ts +92 -0
  432. package/src/modules/documents/api/templates/route.ts +370 -0
  433. package/src/modules/documents/backend/documents/DocumentsPageClient.tsx +150 -0
  434. package/src/modules/documents/backend/documents/DocumentsTable.tsx +168 -0
  435. package/src/modules/documents/backend/documents/FolderDialog.tsx +62 -0
  436. package/src/modules/documents/backend/documents/FolderTree.tsx +72 -0
  437. package/src/modules/documents/backend/documents/MoveDocumentDialog.tsx +70 -0
  438. package/src/modules/documents/backend/documents/[id]/CommentAnchorNavigation.ts +88 -0
  439. package/src/modules/documents/backend/documents/[id]/CommentComposer.tsx +136 -0
  440. package/src/modules/documents/backend/documents/[id]/CommentThreadList.tsx +131 -0
  441. package/src/modules/documents/backend/documents/[id]/CommentsRail.tsx +138 -0
  442. package/src/modules/documents/backend/documents/[id]/DocumentCanvas.tsx +111 -0
  443. package/src/modules/documents/backend/documents/[id]/DocumentEditorErrorBoundary.tsx +39 -0
  444. package/src/modules/documents/backend/documents/[id]/DocumentEditorIsland.tsx +50 -0
  445. package/src/modules/documents/backend/documents/[id]/DocumentEditorSurface.tsx +162 -0
  446. package/src/modules/documents/backend/documents/[id]/DocumentNavigator.tsx +129 -0
  447. package/src/modules/documents/backend/documents/[id]/DocumentPageClient.tsx +463 -0
  448. package/src/modules/documents/backend/documents/[id]/EditorColorTools.tsx +71 -0
  449. package/src/modules/documents/backend/documents/[id]/EditorStatusPresence.tsx +78 -0
  450. package/src/modules/documents/backend/documents/[id]/EditorToolbar.tsx +108 -0
  451. package/src/modules/documents/backend/documents/[id]/ExportMenu.tsx +144 -0
  452. package/src/modules/documents/backend/documents/[id]/MentionPicker.tsx +137 -0
  453. package/src/modules/documents/backend/documents/[id]/OutlinePane.tsx +98 -0
  454. package/src/modules/documents/backend/documents/[id]/RecordFieldsDialog.tsx +185 -0
  455. package/src/modules/documents/backend/documents/[id]/RelatedRecordsPanel.tsx +320 -0
  456. package/src/modules/documents/backend/documents/[id]/VersionHistoryPanel.tsx +244 -0
  457. package/src/modules/documents/backend/documents/[id]/VersionPreviewDialog.tsx +197 -0
  458. package/src/modules/documents/backend/documents/[id]/commentTypes.ts +192 -0
  459. package/src/modules/documents/backend/documents/[id]/componentCapabilities.ts +17 -0
  460. package/src/modules/documents/backend/documents/[id]/documentPagination.ts +352 -0
  461. package/src/modules/documents/backend/documents/[id]/editorPresenceStyles.ts +77 -0
  462. package/src/modules/documents/backend/documents/[id]/editorTypes.ts +84 -0
  463. package/src/modules/documents/backend/documents/[id]/page.meta.ts +12 -0
  464. package/src/modules/documents/backend/documents/[id]/page.tsx +6 -0
  465. package/src/modules/documents/backend/documents/[id]/relatedRecordModel.ts +49 -0
  466. package/src/modules/documents/backend/documents/[id]/restoreVersion.ts +49 -0
  467. package/src/modules/documents/backend/documents/[id]/useDocumentCollaboration.ts +458 -0
  468. package/src/modules/documents/backend/documents/[id]/useDocumentComments.ts +286 -0
  469. package/src/modules/documents/backend/documents/[id]/useDocumentEditor.ts +157 -0
  470. package/src/modules/documents/backend/documents/[id]/useDocumentTitle.ts +77 -0
  471. package/src/modules/documents/backend/documents/[id]/useEditorInsertions.ts +147 -0
  472. package/src/modules/documents/backend/documents/[id]/useFallbackContentPersistence.ts +277 -0
  473. package/src/modules/documents/backend/documents/[id]/useMentionPicker.ts +259 -0
  474. package/src/modules/documents/backend/documents/[id]/versionHistoryModel.ts +29 -0
  475. package/src/modules/documents/backend/documents/components/EntityPicker.tsx +171 -0
  476. package/src/modules/documents/backend/documents/components/EntityPickerResults.tsx +64 -0
  477. package/src/modules/documents/backend/documents/components/NewFromTemplateDialog.tsx +136 -0
  478. package/src/modules/documents/backend/documents/components/PrincipalPicker.tsx +90 -0
  479. package/src/modules/documents/backend/documents/components/ShareDialog.tsx +88 -0
  480. package/src/modules/documents/backend/documents/components/ShareDialogAddForm.tsx +113 -0
  481. package/src/modules/documents/backend/documents/components/ShareDialogList.tsx +117 -0
  482. package/src/modules/documents/backend/documents/components/TemplateBodyEditor.tsx +96 -0
  483. package/src/modules/documents/backend/documents/components/TemplateEditorDialog.tsx +171 -0
  484. package/src/modules/documents/backend/documents/components/TemplatePreview.tsx +43 -0
  485. package/src/modules/documents/backend/documents/components/TemplateSlotFields.tsx +72 -0
  486. package/src/modules/documents/backend/documents/components/TemplateSlotsEditor.tsx +72 -0
  487. package/src/modules/documents/backend/documents/components/principalPickerModel.ts +86 -0
  488. package/src/modules/documents/backend/documents/components/shareDialogModel.ts +94 -0
  489. package/src/modules/documents/backend/documents/components/templateUi.ts +128 -0
  490. package/src/modules/documents/backend/documents/components/useAvailableEntityRegistry.ts +43 -0
  491. package/src/modules/documents/backend/documents/components/useEntitySearch.ts +197 -0
  492. package/src/modules/documents/backend/documents/components/usePrincipalPicker.ts +165 -0
  493. package/src/modules/documents/backend/documents/components/useShareDialog.ts +267 -0
  494. package/src/modules/documents/backend/documents/components/useTemplateDetail.ts +54 -0
  495. package/src/modules/documents/backend/documents/components/useTemplateInstantiation.ts +252 -0
  496. package/src/modules/documents/backend/documents/documentUi.ts +134 -0
  497. package/src/modules/documents/backend/documents/documentsListTypes.ts +163 -0
  498. package/src/modules/documents/backend/documents/page.meta.ts +24 -0
  499. package/src/modules/documents/backend/documents/page.tsx +5 -0
  500. package/src/modules/documents/backend/documents/templates/TemplatesPageClient.tsx +93 -0
  501. package/src/modules/documents/backend/documents/templates/TemplatesTable.tsx +71 -0
  502. package/src/modules/documents/backend/documents/templates/page.meta.ts +32 -0
  503. package/src/modules/documents/backend/documents/templates/page.tsx +5 -0
  504. package/src/modules/documents/backend/documents/templates/useTemplatesPage.tsx +148 -0
  505. package/src/modules/documents/backend/documents/useDocumentsList.tsx +305 -0
  506. package/src/modules/documents/ce.ts +6 -0
  507. package/src/modules/documents/commands/aggregate.ts +128 -0
  508. package/src/modules/documents/commands/attachments.ts +309 -0
  509. package/src/modules/documents/commands/comments.ts +966 -0
  510. package/src/modules/documents/commands/content.ts +294 -0
  511. package/src/modules/documents/commands/document-crud.ts +609 -0
  512. package/src/modules/documents/commands/documents.ts +581 -0
  513. package/src/modules/documents/commands/duplicate.ts +772 -0
  514. package/src/modules/documents/commands/favorites.ts +171 -0
  515. package/src/modules/documents/commands/folders.ts +506 -0
  516. package/src/modules/documents/commands/index.ts +33 -0
  517. package/src/modules/documents/commands/interceptors.ts +402 -0
  518. package/src/modules/documents/commands/lifecycle.ts +333 -0
  519. package/src/modules/documents/commands/links.ts +486 -0
  520. package/src/modules/documents/commands/mutation-helpers.ts +48 -0
  521. package/src/modules/documents/commands/projection-types.ts +77 -0
  522. package/src/modules/documents/commands/shared.ts +219 -0
  523. package/src/modules/documents/commands/shares.ts +612 -0
  524. package/src/modules/documents/commands/side-effects.ts +130 -0
  525. package/src/modules/documents/commands/templates.ts +522 -0
  526. package/src/modules/documents/commands/versions.ts +537 -0
  527. package/src/modules/documents/commands/watchers.ts +181 -0
  528. package/src/modules/documents/data/entities.ts +631 -0
  529. package/src/modules/documents/data/extensions.ts +42 -0
  530. package/src/modules/documents/data/validators.ts +248 -0
  531. package/src/modules/documents/di.ts +33 -0
  532. package/src/modules/documents/encryption.ts +14 -0
  533. package/src/modules/documents/events.ts +23 -0
  534. package/src/modules/documents/i18n/.hardcoded-allowlist.json +9 -0
  535. package/src/modules/documents/i18n/de.json +522 -0
  536. package/src/modules/documents/i18n/en.json +522 -0
  537. package/src/modules/documents/i18n/es.json +522 -0
  538. package/src/modules/documents/i18n/ko.json +522 -0
  539. package/src/modules/documents/i18n/pl.json +522 -0
  540. package/src/modules/documents/index.ts +17 -0
  541. package/src/modules/documents/lib/attachmentServicePort.ts +94 -0
  542. package/src/modules/documents/lib/capabilities.ts +57 -0
  543. package/src/modules/documents/lib/collabCloseEvents.ts +26 -0
  544. package/src/modules/documents/lib/collabEndpoint.ts +43 -0
  545. package/src/modules/documents/lib/collabMaterializer.ts +140 -0
  546. package/src/modules/documents/lib/collabToken.ts +170 -0
  547. package/src/modules/documents/lib/collaborationAwareness.ts +102 -0
  548. package/src/modules/documents/lib/commentAnchors.ts +19 -0
  549. package/src/modules/documents/lib/constants.ts +39 -0
  550. package/src/modules/documents/lib/contentService.ts +340 -0
  551. package/src/modules/documents/lib/displayLabels.ts +31 -0
  552. package/src/modules/documents/lib/docxRenderer.ts +685 -0
  553. package/src/modules/documents/lib/duplicateContent.ts +32 -0
  554. package/src/modules/documents/lib/editorConfig.ts +371 -0
  555. package/src/modules/documents/lib/entityLinks.ts +250 -0
  556. package/src/modules/documents/lib/entityRegistry.server.ts +267 -0
  557. package/src/modules/documents/lib/entityRegistry.ts +345 -0
  558. package/src/modules/documents/lib/entityRegistryAvailability.server.ts +19 -0
  559. package/src/modules/documents/lib/entitySuggestion.ts +61 -0
  560. package/src/modules/documents/lib/favorites.ts +35 -0
  561. package/src/modules/documents/lib/folderHierarchySerialization.ts +34 -0
  562. package/src/modules/documents/lib/historyLimits.ts +154 -0
  563. package/src/modules/documents/lib/historyPolicy.ts +7 -0
  564. package/src/modules/documents/lib/organizationAccess.ts +44 -0
  565. package/src/modules/documents/lib/pdfHtml.ts +65 -0
  566. package/src/modules/documents/lib/pdfRenderer.ts +367 -0
  567. package/src/modules/documents/lib/permissions.ts +299 -0
  568. package/src/modules/documents/lib/platformServices.ts +70 -0
  569. package/src/modules/documents/lib/recordFieldInsertion.ts +72 -0
  570. package/src/modules/documents/lib/requestBody.ts +97 -0
  571. package/src/modules/documents/lib/resourceLimits.ts +94 -0
  572. package/src/modules/documents/lib/shareCounts.ts +44 -0
  573. package/src/modules/documents/lib/templateFill.ts +138 -0
  574. package/src/modules/documents/lib/templateInstantiation.ts +206 -0
  575. package/src/modules/documents/lib/templatePreviewDigest.ts +122 -0
  576. package/src/modules/documents/lib/templateSeeds.ts +248 -0
  577. package/src/modules/documents/lib/userLabels.ts +58 -0
  578. package/src/modules/documents/lib/versionContent.ts +163 -0
  579. package/src/modules/documents/lib/versionLabels.ts +9 -0
  580. package/src/modules/documents/lib/versioning.ts +29 -0
  581. package/src/modules/documents/lib/visibility.ts +610 -0
  582. package/src/modules/documents/lib/watchers.ts +129 -0
  583. package/src/modules/documents/migrations/.snapshot-open-mercato.json +2892 -0
  584. package/src/modules/documents/migrations/Migration20260708163836_documents.ts +50 -0
  585. package/src/modules/documents/migrations/Migration20260709164720_documents.ts +19 -0
  586. package/src/modules/documents/migrations/Migration20260710002318_documents.ts +35 -0
  587. package/src/modules/documents/migrations/Migration20260710071003_documents.ts +13 -0
  588. package/src/modules/documents/migrations/Migration20260712120000_documents.ts +13 -0
  589. package/src/modules/documents/migrations/Migration20260712183000_documents.ts +12 -0
  590. package/src/modules/documents/migrations/Migration20260713092156_documents.ts +43 -0
  591. package/src/modules/documents/migrations/Migration20260717000000_documents.ts +40 -0
  592. package/src/modules/documents/notifications.ts +45 -0
  593. package/src/modules/documents/search.ts +118 -0
  594. package/src/modules/documents/setup.ts +19 -0
  595. package/src/modules/documents/widgets/injection/related-documents/LinkDocumentDialog.tsx +224 -0
  596. package/src/modules/documents/widgets/injection/related-documents/context.ts +101 -0
  597. package/src/modules/documents/widgets/injection/related-documents/useRelatedDocuments.ts +112 -0
  598. package/src/modules/documents/widgets/injection/related-documents/widget.client.tsx +76 -0
  599. package/src/modules/documents/widgets/injection/related-documents/widget.ts +19 -0
  600. package/src/modules/documents/widgets/injection-table.ts +20 -0
  601. package/tsconfig.json +9 -0
  602. package/watch.mjs +6 -0
@@ -0,0 +1,3416 @@
1
+ import { resolve } from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { randomUUID } from 'node:crypto'
4
+ import type { IncomingMessage, ServerResponse } from 'node:http'
5
+ import type { EntityManager } from '@mikro-orm/postgresql'
6
+ import { LockMode } from '@mikro-orm/core'
7
+ import {
8
+ Connection as HocuspocusConnection,
9
+ IncomingMessage as HocuspocusIncomingMessage,
10
+ MessageReceiver as HocuspocusMessageReceiver,
11
+ MessageType,
12
+ OutgoingMessage as HocuspocusOutgoingMessage,
13
+ } from '@hocuspocus/server'
14
+ import { Redis as HocuspocusRedis } from '@hocuspocus/extension-redis'
15
+ import type {
16
+ afterLoadDocumentPayload,
17
+ afterStoreDocumentPayload,
18
+ afterUnloadDocumentPayload,
19
+ beforeHandleAwarenessPayload,
20
+ beforeHandleMessagePayload,
21
+ beforeSyncPayload,
22
+ connectedPayload,
23
+ onAuthenticatePayload,
24
+ onDisconnectPayload,
25
+ onLoadDocumentPayload,
26
+ onChangePayload,
27
+ onRequestPayload,
28
+ onStoreDocumentPayload,
29
+ Server as HocuspocusServer,
30
+ } from '@hocuspocus/server'
31
+ import * as Y from 'yjs'
32
+ import { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
33
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
34
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
35
+ import { createLogger } from '@open-mercato/shared/lib/logger'
36
+ import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
37
+ import {
38
+ COLLAB_TOKEN_CLOCK_SKEW_SECONDS,
39
+ COLLAB_TOKEN_TTL_SECONDS,
40
+ isCollabTokenV2Ready,
41
+ resolveLegacyCollabTokenVerifier,
42
+ verifyCollabTokenV2,
43
+ type CollabTokenClaims,
44
+ type VerifiedCollabTokenV2Claims,
45
+ } from '@open-mercato/documents/modules/documents/lib/collabToken'
46
+ import {
47
+ advanceDocumentCollaborationGeneration,
48
+ loadDocumentCollaborationGeneration,
49
+ loadDocumentContentForCollaboration,
50
+ normalizeDocumentCollaborationGeneration,
51
+ persistDocumentContent,
52
+ type PersistDocumentContentDeps,
53
+ } from '@open-mercato/documents/modules/documents/lib/contentService'
54
+ import {
55
+ htmlToYDoc,
56
+ yDocToContent,
57
+ } from '@open-mercato/documents/modules/documents/lib/collabMaterializer'
58
+ import { Document, DocumentContent } from '@open-mercato/documents/modules/documents/data/entities'
59
+ import { deriveDocumentCapabilities } from '@open-mercato/documents/modules/documents/lib/capabilities'
60
+ import { resolveUserAccess } from '@open-mercato/documents/modules/documents/lib/permissions'
61
+ import { resolveUserLabels } from '@open-mercato/documents/modules/documents/lib/userLabels'
62
+ import { resolveOrganizationScopeService } from '@open-mercato/documents/modules/documents/lib/platformServices'
63
+ import {
64
+ hasResolvedDocumentsOrganizationAccess,
65
+ type ResolvedDocumentsOrganizationScope,
66
+ } from '@open-mercato/documents/modules/documents/lib/organizationAccess'
67
+ import {
68
+ createCanonicalCollaborationAwarenessUser,
69
+ type CanonicalCollaborationAwarenessUser,
70
+ } from '@open-mercato/documents/modules/documents/lib/collaborationAwareness'
71
+ import {
72
+ assertDocumentContentResourceLimits,
73
+ assertDocumentYjsStateByteLength,
74
+ DOCUMENTS_COLLAB_MAX_PAYLOAD_BYTES,
75
+ } from '@open-mercato/documents/modules/documents/lib/resourceLimits'
76
+ import { COLLAB_CONTENT_RESET_CLOSE_EVENT } from '@open-mercato/documents/modules/documents/lib/collabCloseEvents'
77
+
78
+ export { htmlToYDoc, yDocToContent }
79
+
80
+ const logger = createLogger('documents-collab')
81
+
82
+ const MAX_COLLAB_STORE_ATTEMPTS = 4
83
+ export const DOCUMENTS_COLLAB_MAX_AWARENESS_STATE_BYTES = 8 * 1024
84
+ export const DOCUMENTS_COLLAB_MAX_AWARENESS_CLIENT_IDS_PER_CONNECTION = 1
85
+ export const DOCUMENTS_COLLAB_MAX_AWARENESS_CLIENT_IDS_PER_ROOM = 256
86
+ const MAX_YJS_CLIENT_ID = 0xffff_ffff
87
+ export const COLLAB_SERVER_TRANSPORT_OPTIONS = {
88
+ maxPayload: DOCUMENTS_COLLAB_MAX_PAYLOAD_BYTES,
89
+ } as const
90
+ export const DOCUMENTS_COLLAB_MAX_PENDING_MESSAGES = 128
91
+ export const DOCUMENTS_COLLAB_MAX_PENDING_BYTES = DOCUMENTS_COLLAB_MAX_PAYLOAD_BYTES * 2
92
+ export const DOCUMENTS_COLLAB_MAX_PENDING_DOCUMENTS = 32
93
+ export const DOCUMENTS_COLLAB_MAX_AWARENESS_FRAME_BYTES = 32 * 1024
94
+ export const DOCUMENTS_COLLAB_MAX_CONTROL_FRAME_BYTES = 64 * 1024
95
+ export const DOCUMENTS_COLLAB_MAX_READ_ONLY_SYNC_FRAME_BYTES = 256 * 1024
96
+ export const DOCUMENTS_COLLAB_AUTHORIZATION_TICKET_TIMEOUT_MS = 60_000
97
+ export const DOCUMENTS_COLLAB_ACTIVE_REAUTHORIZATION_INTERVAL_MS = 15_000
98
+ export const COLLAB_SERVER_WEBSOCKET_CONFIGURATION = {
99
+ websocketOptions: COLLAB_SERVER_TRANSPORT_OPTIONS,
100
+ } as const
101
+ export const COLLAB_SERVER_UNAUTHENTICATED_CONFIGURATION = {
102
+ maxUnauthenticatedQueueSize: DOCUMENTS_COLLAB_MAX_PENDING_BYTES,
103
+ maxUnauthenticatedQueueMessages: DOCUMENTS_COLLAB_MAX_PENDING_MESSAGES,
104
+ maxPendingDocuments: DOCUMENTS_COLLAB_MAX_PENDING_DOCUMENTS,
105
+ } as const
106
+ export const COLLAB_SERVER_RUNTIME_CONFIGURATION = {
107
+ ...COLLAB_SERVER_WEBSOCKET_CONFIGURATION,
108
+ ...COLLAB_SERVER_UNAUTHENTICATED_CONFIGURATION,
109
+ } as const
110
+
111
+ export type DocumentsCollabRedisConfiguration = {
112
+ host: string
113
+ port: number
114
+ prefix: string
115
+ options: {
116
+ username?: string
117
+ password?: string
118
+ db?: number
119
+ tls?: Record<string, never>
120
+ maxRetriesPerRequest: null
121
+ }
122
+ }
123
+
124
+ const DEFAULT_DOCUMENTS_COLLAB_REDIS_PREFIX = 'open-mercato:documents:collab:development'
125
+ const LEGACY_DOCUMENTS_COLLAB_REDIS_PREFIX = 'open-mercato:documents:collab'
126
+ const DOCUMENTS_COLLAB_REDIS_PREFIX_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,158}[A-Za-z0-9])?$/
127
+
128
+ function resolveDocumentsCollabRedisPrefix(environment: NodeJS.ProcessEnv): string {
129
+ const configured = environment.DOCUMENTS_COLLAB_REDIS_PREFIX?.trim() ?? ''
130
+ if (!configured && environment.NODE_ENV === 'production') {
131
+ throw new Error(
132
+ '[internal] DOCUMENTS_COLLAB_REDIS_PREFIX is required in production when collaboration Redis is configured',
133
+ )
134
+ }
135
+ const prefix = configured || DEFAULT_DOCUMENTS_COLLAB_REDIS_PREFIX
136
+ if (
137
+ prefix === LEGACY_DOCUMENTS_COLLAB_REDIS_PREFIX
138
+ || !DOCUMENTS_COLLAB_REDIS_PREFIX_PATTERN.test(prefix)
139
+ ) {
140
+ throw new Error(
141
+ '[internal] DOCUMENTS_COLLAB_REDIS_PREFIX must be a deployment-scoped Redis key prefix using letters, numbers, dot, underscore, colon, or dash',
142
+ )
143
+ }
144
+ return prefix
145
+ }
146
+
147
+ export function resolveDocumentsCollabRedisConfiguration(
148
+ environment: NodeJS.ProcessEnv = process.env,
149
+ ): DocumentsCollabRedisConfiguration | null {
150
+ const configured = environment.DOCUMENTS_COLLAB_REDIS_URL?.trim()
151
+ || environment.REDIS_URL?.trim()
152
+ || ''
153
+ if (!configured) return null
154
+
155
+ let parsed: URL
156
+ try {
157
+ parsed = new URL(configured)
158
+ } catch {
159
+ throw new Error('[internal] Documents collaboration Redis URL is invalid')
160
+ }
161
+ if ((parsed.protocol !== 'redis:' && parsed.protocol !== 'rediss:') || !parsed.hostname) {
162
+ throw new Error('[internal] Documents collaboration Redis URL must use redis:// or rediss://')
163
+ }
164
+ const port = parsed.port ? Number(parsed.port) : 6379
165
+ const databasePath = parsed.pathname.replace(/^\//, '')
166
+ const db = databasePath ? Number(databasePath) : undefined
167
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
168
+ throw new Error('[internal] Documents collaboration Redis port is invalid')
169
+ }
170
+ if (db !== undefined && (!Number.isInteger(db) || db < 0)) {
171
+ throw new Error('[internal] Documents collaboration Redis database is invalid')
172
+ }
173
+
174
+ return {
175
+ host: parsed.hostname,
176
+ port,
177
+ prefix: resolveDocumentsCollabRedisPrefix(environment),
178
+ options: {
179
+ ...(parsed.username ? { username: decodeURIComponent(parsed.username) } : {}),
180
+ ...(parsed.password ? { password: decodeURIComponent(parsed.password) } : {}),
181
+ ...(db !== undefined ? { db } : {}),
182
+ ...(parsed.protocol === 'rediss:' ? { tls: {} } : {}),
183
+ maxRetriesPerRequest: null,
184
+ },
185
+ }
186
+ }
187
+
188
+ type DocumentsCollabRedisStorePayload = Pick<
189
+ onStoreDocumentPayload,
190
+ 'lastTransactionOrigin'
191
+ >
192
+
193
+ type DocumentsCollabRedisStoreExtension = {
194
+ onStoreDocument: (payload: onStoreDocumentPayload) => unknown
195
+ }
196
+
197
+ type DocumentsCollabRedisReplicationOptions = {
198
+ onRejectedAggregate?: (documentName: string, document: Y.Doc) => void
199
+ onAcceptedAggregate?: (document: Y.Doc, byteLength: number) => void
200
+ resolveCollaborationGeneration?: (document: Y.Doc) => number | undefined
201
+ }
202
+
203
+ type DocumentsCollabRedisFanoutPublisher = {
204
+ publish: (channel: string, message: Buffer) => Promise<unknown>
205
+ disconnect: (reconnect?: boolean) => void
206
+ }
207
+
208
+ type DocumentsCollabPendingFanout = {
209
+ document: Y.Doc
210
+ collaborationGeneration: number
211
+ revision: number
212
+ message: Uint8Array
213
+ }
214
+
215
+ type DocumentsCollabBufferedFanout = {
216
+ collaborationGeneration: number
217
+ update: Uint8Array
218
+ }
219
+
220
+ const DOCUMENTS_COLLAB_REDIS_FANOUT_COMMAND_TIMEOUT_MS = 1_000
221
+ const DOCUMENTS_COLLAB_REDIS_FANOUT_RETRY_MIN_MS = 250
222
+ const DOCUMENTS_COLLAB_REDIS_FANOUT_RETRY_MAX_MS = 5_000
223
+ const DOCUMENTS_COLLAB_REDIS_FANOUT_SHUTDOWN_TIMEOUT_MS = 1_500
224
+ const DOCUMENTS_COLLAB_REDIS_LOCK_RELEASE_TIMEOUT_MS = 1_250
225
+ // The collaboration clients run with maxRetriesPerRequest: null, which disables
226
+ // ioredis' offline-queue flush. Every awaited Redis command therefore needs its
227
+ // own deadline or a prolonged outage parks the caller forever.
228
+ export const DOCUMENTS_COLLAB_REDIS_SUBSCRIBE_TIMEOUT_MS = 5_000
229
+ export const DOCUMENTS_COLLAB_REDIS_FANOUT_MAX_PENDING_ROOMS = 256
230
+ export const DOCUMENTS_COLLAB_REDIS_FANOUT_MAX_PENDING_BYTES = DOCUMENTS_COLLAB_MAX_PAYLOAD_BYTES * 8
231
+ const DOCUMENTS_COLLAB_REDIS_FANOUT_MAGIC = Buffer.from('OMDF1', 'ascii')
232
+ const DOCUMENTS_COLLAB_REDIS_FANOUT_GENERATION_BYTES = 8
233
+
234
+ function isHocuspocusStoreLockContention(error: unknown): error is Error {
235
+ // @hocuspocus/extension-redis throws this cross-package error when another
236
+ // replica owns the room's store lock. Match by the stable error name rather
237
+ // than instanceof so duplicated Hocuspocus packages do not hide contention.
238
+ return error instanceof Error && error.name === 'SkipFurtherHooksError'
239
+ }
240
+
241
+ /**
242
+ * Redis replicas receive an authenticated source replica's Yjs update, but
243
+ * they do not receive its browser authorization context. Only a store caused
244
+ * by a local authenticated connection may compete for the distributed store
245
+ * lock. A receiving replica therefore mirrors the update in memory while the
246
+ * source replica remains responsible for making it durable. When two source
247
+ * replicas race, Hocuspocus otherwise swallows the Redis lock loser's
248
+ * SkipFurtherHooksError and may unload it even though a later Redis merge does
249
+ * not schedule store hooks. Queue a complete Hocuspocus store retry before
250
+ * propagating the sentinel so the room stays mapped and the retry still runs
251
+ * the Redis lock, live authorization, and optimistic merge hooks in order.
252
+ */
253
+ export function isDocumentsCollabSourceStore(
254
+ payload: DocumentsCollabRedisStorePayload,
255
+ ): boolean {
256
+ const origin = payload.lastTransactionOrigin
257
+ return !(
258
+ origin
259
+ && typeof origin === 'object'
260
+ && 'source' in origin
261
+ && origin.source === 'redis'
262
+ )
263
+ }
264
+
265
+ export function enforceDocumentsCollabSourceStoreOwnership<
266
+ RedisExtension extends DocumentsCollabRedisStoreExtension,
267
+ >(extension: RedisExtension): RedisExtension {
268
+ const onStoreDocument = extension.onStoreDocument.bind(extension)
269
+ extension.onStoreDocument = async (payload: onStoreDocumentPayload) => {
270
+ if (!isDocumentsCollabSourceStore(payload)) return
271
+ try {
272
+ return await onStoreDocument(payload)
273
+ } catch (error) {
274
+ if (isHocuspocusStoreLockContention(error)) {
275
+ // Register the retry synchronously. shouldUnloadDocument() observes
276
+ // the debounced work when Hocuspocus catches the sentinel on the
277
+ // current attempt, including when the last client just disconnected.
278
+ void payload.instance.storeDocumentHooks(payload.document, payload)
279
+ }
280
+ throw error
281
+ }
282
+ }
283
+ return extension
284
+ }
285
+
286
+ /**
287
+ * Verify a durable update against the receiver's complete in-memory state
288
+ * before Redis is allowed to apply or broadcast it. Each source replica
289
+ * enforces the same limits before persistence, but two individually valid
290
+ * replicas can still exceed the aggregate limit when their states merge.
291
+ */
292
+ export function assertDocumentsCollabRedisAggregateUpdate(
293
+ document: Y.Doc,
294
+ update: Uint8Array,
295
+ ): number {
296
+ const candidate = new Y.Doc()
297
+ try {
298
+ Y.applyUpdate(candidate, Y.encodeStateAsUpdate(document))
299
+ Y.applyUpdate(candidate, update)
300
+ return assertDocumentsCollabRedisAggregateState(candidate)
301
+ } finally {
302
+ candidate.destroy()
303
+ }
304
+ }
305
+
306
+ function assertDocumentsCollabRedisAggregateState(document: Y.Doc): number {
307
+ const materialized = yDocToContent(document)
308
+ const yjsState = Y.encodeStateAsUpdate(document)
309
+ assertDocumentContentResourceLimits({
310
+ yjsState,
311
+ contentHtml: materialized?.html,
312
+ contentText: materialized?.text,
313
+ })
314
+ return yjsState.byteLength
315
+ }
316
+
317
+ /**
318
+ * Documents collaboration deliberately does not use Hocuspocus Redis' Yjs
319
+ * state-vector handshake. That handshake reads the live room and can fan out
320
+ * an edit before its source replica has re-authorized and durably persisted
321
+ * it. Instead, this extension keeps Redis awareness behaviour and the
322
+ * distributed store lock, but publishes one complete Yjs update only after
323
+ * the Documents store hook confirms the exact state became durable.
324
+ */
325
+ export class DocumentsCollabRedisExtension extends HocuspocusRedis {
326
+ private readonly persistedStates = new WeakMap<Y.Doc, {
327
+ collaborationGeneration: number
328
+ state: Uint8Array
329
+ }>()
330
+
331
+ /**
332
+ * Durable fanout uses a dedicated fail-fast Redis connection. The stock
333
+ * publisher is also the Redlock client and intentionally waits for Redis to
334
+ * recover before allowing another replica to persist; post-store delivery
335
+ * must not keep that distributed lock or Hocuspocus' save mutex occupied.
336
+ */
337
+ private readonly fanoutPublisher: DocumentsCollabRedisFanoutPublisher
338
+
339
+ private readonly pendingFanouts = new Map<string, DocumentsCollabPendingFanout>()
340
+
341
+ private readonly activeFanouts = new Set<string>()
342
+
343
+ private readonly fanoutRetryAttempts = new Map<string, number>()
344
+
345
+ private readonly fanoutRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
346
+
347
+ private readonly pendingLocalAfterStoreDelays = new Map<string, {
348
+ timeout: ReturnType<typeof setTimeout>
349
+ resolve: () => void
350
+ }>()
351
+
352
+ private readonly invalidatedFanoutDocuments = new WeakSet<Y.Doc>()
353
+
354
+ private readonly bufferedFanouts = new WeakMap<
355
+ Y.Doc,
356
+ Map<string, DocumentsCollabBufferedFanout>
357
+ >()
358
+
359
+ private readonly bufferedFanoutBytes = new WeakMap<Y.Doc, number>()
360
+
361
+ private nextFanoutRevision = 0
362
+
363
+ private pendingFanoutBytes = 0
364
+
365
+ private fanoutDestroyed = false
366
+
367
+ /**
368
+ * Hocuspocus does not publish a loading document in instance.documents until
369
+ * after afterLoadDocument completes. Keep the hook payload reachable so a
370
+ * Redis update received while the scoped database snapshot is loading can be
371
+ * applied to that same Y.Doc instead of being silently dropped.
372
+ */
373
+ private readonly knownDocuments = new Map<string, onLoadDocumentPayload['document']>()
374
+
375
+ private readonly onRejectedAggregate?: (
376
+ documentName: string,
377
+ document: Y.Doc,
378
+ ) => void
379
+
380
+ private readonly onAcceptedAggregate?: (
381
+ document: Y.Doc,
382
+ byteLength: number,
383
+ ) => void
384
+
385
+ private readonly resolveCollaborationGeneration?: (
386
+ document: Y.Doc,
387
+ ) => number | undefined
388
+
389
+ constructor(
390
+ configuration: DocumentsCollabRedisConfiguration,
391
+ options: DocumentsCollabRedisReplicationOptions = {},
392
+ ) {
393
+ super(configuration)
394
+ this.onRejectedAggregate = options.onRejectedAggregate
395
+ this.onAcceptedAggregate = options.onAcceptedAggregate
396
+ this.resolveCollaborationGeneration = options.resolveCollaborationGeneration
397
+ this.fanoutPublisher = (
398
+ this.pub as unknown as {
399
+ duplicate: (options: {
400
+ autoResendUnfulfilledCommands: boolean
401
+ commandTimeout: number
402
+ enableOfflineQueue: boolean
403
+ maxRetriesPerRequest: number
404
+ }) => DocumentsCollabRedisFanoutPublisher
405
+ }
406
+ ).duplicate({
407
+ autoResendUnfulfilledCommands: false,
408
+ commandTimeout: DOCUMENTS_COLLAB_REDIS_FANOUT_COMMAND_TIMEOUT_MS,
409
+ enableOfflineQueue: false,
410
+ maxRetriesPerRequest: 1,
411
+ })
412
+
413
+ // Redis registers its stock state-vector receiver in super(). Replace only
414
+ // that freshly-created client's messageBuffer listener with the durable
415
+ // update receiver below; connection/error listeners remain untouched.
416
+ for (const listener of this.sub.listeners('messageBuffer')) {
417
+ this.sub.off('messageBuffer', listener)
418
+ }
419
+ this.sub.on('messageBuffer', this.receiveDocumentsRedisMessage)
420
+ }
421
+
422
+ /**
423
+ * EventEmitter never observes the promise an async listener returns, so a
424
+ * truncated frame, a receiver failure, or a failed reply publish would become
425
+ * an unhandled rejection and terminate the sidecar. Keep the registered
426
+ * listener synchronous and terminate every rejection here instead: one
427
+ * malformed frame must cost at most that frame.
428
+ */
429
+ private readonly receiveDocumentsRedisMessage = (channel: Buffer, data: Buffer): void => {
430
+ void this.handleDocumentsRedisMessage(channel, data).catch((error: unknown) => {
431
+ logger.warn('rejected malformed Redis collaboration frame', {
432
+ error: error instanceof Error ? error.message : String(error),
433
+ })
434
+ })
435
+ }
436
+
437
+ markPersisted(
438
+ document: Y.Doc,
439
+ yjsState: Uint8Array,
440
+ collaborationGeneration: number,
441
+ ): void {
442
+ if (this.invalidatedFanoutDocuments.has(document)) return
443
+ this.persistedStates.set(document, {
444
+ collaborationGeneration,
445
+ state: Uint8Array.from(yjsState),
446
+ })
447
+ }
448
+
449
+ async onLoadDocument(data: onLoadDocumentPayload): Promise<void> {
450
+ const { documentName, document } = data
451
+ this.knownDocuments.set(documentName, document)
452
+ const loadingDocument = data.instance.loadingDocuments.get(documentName)
453
+ if (loadingDocument) {
454
+ void loadingDocument.catch(() => {
455
+ if (
456
+ this.knownDocuments.get(documentName) === document
457
+ && !data.instance.documents.has(documentName)
458
+ ) {
459
+ this.knownDocuments.delete(documentName)
460
+ void this.sub.unsubscribe(this.redisKey(documentName)).catch(() => undefined)
461
+ }
462
+ })
463
+ }
464
+ try {
465
+ await this.subscribeDocument(documentName)
466
+ } catch (error) {
467
+ if (this.knownDocuments.get(documentName) === document) {
468
+ this.knownDocuments.delete(documentName)
469
+ }
470
+ throw error
471
+ }
472
+ }
473
+
474
+ override async afterLoadDocument({
475
+ documentName,
476
+ document,
477
+ }: afterLoadDocumentPayload): Promise<void> {
478
+ let aggregateByteLength: number
479
+ try {
480
+ this.applyBufferedFanouts(documentName, document)
481
+ // A durable Redis update can arrive after the database snapshot was read
482
+ // but before it is applied. Validate and re-budget their final union.
483
+ aggregateByteLength = assertDocumentsCollabRedisAggregateState(document)
484
+ } catch (error) {
485
+ this.onRejectedAggregate?.(documentName, document)
486
+ throw error
487
+ }
488
+ this.onAcceptedAggregate?.(document, aggregateByteLength)
489
+
490
+ const awarenessQuery = new HocuspocusOutgoingMessage(documentName)
491
+ .writeQueryAwareness()
492
+ .toUint8Array()
493
+ await this.publishRedisMessage(documentName, awarenessQuery)
494
+ }
495
+
496
+ override async afterUnloadDocument(data: afterUnloadDocumentPayload): Promise<void> {
497
+ // The old room can finish unloading after a reconnect has already begun.
498
+ // Keep the replacement's early subscription intact until its load either
499
+ // succeeds or the failed-load cleanup above releases it.
500
+ if (
501
+ data.instance.documents.has(data.documentName)
502
+ || data.instance.loadingDocuments.has(data.documentName)
503
+ ) return
504
+
505
+ this.knownDocuments.delete(data.documentName)
506
+ await super.afterUnloadDocument(data)
507
+ }
508
+
509
+ /**
510
+ * The subscriber client keeps ioredis' offline queue enabled, so a room load
511
+ * started during a Redis outage would otherwise wait for recovery with no
512
+ * deadline while its socket stays open. Fail the load instead: onLoadDocument
513
+ * releases the early subscription and Hocuspocus rejects the connection, so
514
+ * the client can retry rather than hanging against a green health check.
515
+ */
516
+ private async subscribeDocument(documentName: string): Promise<void> {
517
+ await new Promise<void>((resolve, reject) => {
518
+ const deadline = setTimeout(() => {
519
+ reject(new Error('[internal] documents collab: Redis room subscription timed out'))
520
+ }, DOCUMENTS_COLLAB_REDIS_SUBSCRIBE_TIMEOUT_MS)
521
+ deadline.unref?.()
522
+ this.sub.subscribe(this.redisKey(documentName), (error: Error | null | undefined) => {
523
+ clearTimeout(deadline)
524
+ if (error) reject(error)
525
+ else resolve()
526
+ })
527
+ })
528
+ }
529
+
530
+ override async onChange(_data: onChangePayload): Promise<void> {
531
+ // Document updates are published from afterStoreDocument only. Awareness
532
+ // and stateless hooks continue to use the parent extension unchanged.
533
+ }
534
+
535
+ override async afterStoreDocument(data: afterStoreDocumentPayload): Promise<void> {
536
+ const persistedState = this.persistedStates.get(data.document)
537
+ this.persistedStates.delete(data.document)
538
+ try {
539
+ await this.releaseStoreLock(data)
540
+ } finally {
541
+ if (
542
+ persistedState
543
+ && isDocumentsCollabSourceStore(data)
544
+ && !this.invalidatedFanoutDocuments.has(data.document)
545
+ ) {
546
+ this.queuePersistedFanout(
547
+ data.documentName,
548
+ data.document,
549
+ persistedState.state,
550
+ persistedState.collaborationGeneration,
551
+ )
552
+ }
553
+ }
554
+ }
555
+
556
+ private async releaseStoreLock(data: afterStoreDocumentPayload): Promise<void> {
557
+ const lockKey = `${this.redisKey(data.documentName)}:lock`
558
+ const lockState = this.locks.get(lockKey)
559
+ if (lockState) {
560
+ let timeout: ReturnType<typeof setTimeout> | null = null
561
+ try {
562
+ const releasing = lockState.lock.release()
563
+ lockState.release = releasing
564
+ const deadline = new Promise<false>((resolve) => {
565
+ timeout = setTimeout(
566
+ () => resolve(false),
567
+ DOCUMENTS_COLLAB_REDIS_LOCK_RELEASE_TIMEOUT_MS,
568
+ )
569
+ timeout.unref?.()
570
+ })
571
+ const released = await Promise.race([
572
+ releasing.then(
573
+ () => true as const,
574
+ () => true as const,
575
+ ),
576
+ deadline,
577
+ ])
578
+ if (!released) {
579
+ // The Redlock token expires after one second. Do not retain the
580
+ // room's save mutex forever when ioredis keeps the release command
581
+ // queued during an outage. The old promise is deliberately detached:
582
+ // unlike the parent hook, its late settlement cannot delete a newer
583
+ // lock entry installed for the same room.
584
+ void releasing.catch(() => undefined)
585
+ logger.warn('Redis collaboration store lock release timed out; continuing after TTL', {
586
+ room: data.documentName,
587
+ })
588
+ }
589
+ } catch {
590
+ // Match the parent extension: Redlock expiry is the release fallback.
591
+ } finally {
592
+ if (timeout) clearTimeout(timeout)
593
+ if (this.locks.get(lockKey) === lockState) this.locks.delete(lockKey)
594
+ }
595
+ }
596
+
597
+ await this.delayLocalAfterStore(data)
598
+ }
599
+
600
+ private async delayLocalAfterStore(data: afterStoreDocumentPayload): Promise<void> {
601
+ const origin = data.lastTransactionOrigin
602
+ if (
603
+ !origin
604
+ || typeof origin !== 'object'
605
+ || !('source' in origin)
606
+ || origin.source !== 'local'
607
+ ) return
608
+
609
+ // Mirror the parent extension's direct-connection debounce without calling
610
+ // its lock cleanup after our bounded release. Keeping this map subclass-
611
+ // owned also makes every cleanup identity-safe.
612
+ const previous = this.pendingLocalAfterStoreDelays.get(data.documentName)
613
+ if (previous) {
614
+ clearTimeout(previous.timeout)
615
+ this.pendingLocalAfterStoreDelays.delete(data.documentName)
616
+ previous.resolve()
617
+ }
618
+
619
+ await new Promise<void>((resolve) => {
620
+ const finish = (): void => {
621
+ const current = this.pendingLocalAfterStoreDelays.get(data.documentName)
622
+ if (current?.resolve === finish) {
623
+ this.pendingLocalAfterStoreDelays.delete(data.documentName)
624
+ }
625
+ resolve()
626
+ }
627
+ const timeout = setTimeout(finish, this.configuration.disconnectDelay)
628
+ this.pendingLocalAfterStoreDelays.set(data.documentName, { timeout, resolve: finish })
629
+ })
630
+ }
631
+
632
+ discardPendingFanout(documentName: string, document?: Y.Doc): void {
633
+ if (document) this.invalidatedFanoutDocuments.add(document)
634
+ const pending = this.pendingFanouts.get(documentName)
635
+ if (document && pending && pending.document !== document) return
636
+ this.forgetPendingFanout(documentName)
637
+ }
638
+
639
+ private removePendingFanoutEntry(documentName: string): void {
640
+ const pending = this.pendingFanouts.get(documentName)
641
+ if (!pending) return
642
+ this.pendingFanoutBytes -= pending.message.byteLength
643
+ this.pendingFanouts.delete(documentName)
644
+ }
645
+
646
+ private forgetPendingFanout(documentName: string): void {
647
+ this.removePendingFanoutEntry(documentName)
648
+ this.fanoutRetryAttempts.delete(documentName)
649
+ const retryTimer = this.fanoutRetryTimers.get(documentName)
650
+ if (retryTimer) clearTimeout(retryTimer)
651
+ this.fanoutRetryTimers.delete(documentName)
652
+ }
653
+
654
+ /**
655
+ * A pending fanout carries the room's complete post-store state and pins its
656
+ * Y.Doc, so an unreachable Redis would otherwise let one entry per edited
657
+ * document accumulate for the whole outage — long after those rooms unload.
658
+ * Bound the backlog by dropping the least recently refreshed rooms: their
659
+ * state is already durable, every later fanout for a room supersedes the one
660
+ * dropped here, and a peer that never sees the frame reloads authoritative
661
+ * content on its next load anyway.
662
+ */
663
+ private evictOverflowingFanouts(retainedDocumentName: string): void {
664
+ for (const documentName of this.pendingFanouts.keys()) {
665
+ if (
666
+ this.pendingFanouts.size <= DOCUMENTS_COLLAB_REDIS_FANOUT_MAX_PENDING_ROOMS
667
+ && this.pendingFanoutBytes <= DOCUMENTS_COLLAB_REDIS_FANOUT_MAX_PENDING_BYTES
668
+ ) return
669
+ if (documentName === retainedDocumentName) continue
670
+ this.forgetPendingFanout(documentName)
671
+ logger.warn('dropped a stale pending Redis collaboration fanout to bound the outage backlog', {
672
+ room: documentName,
673
+ pendingRooms: this.pendingFanouts.size,
674
+ pendingBytes: this.pendingFanoutBytes,
675
+ })
676
+ }
677
+ }
678
+
679
+ private queuePersistedFanout(
680
+ documentName: string,
681
+ document: Y.Doc,
682
+ persistedState: Uint8Array,
683
+ collaborationGeneration: number,
684
+ ): void {
685
+ if (this.fanoutDestroyed) return
686
+ const message = new HocuspocusOutgoingMessage(documentName)
687
+ .createSyncMessage()
688
+ .writeUpdate(persistedState)
689
+ .toUint8Array()
690
+ // Re-insert rather than overwrite so the map stays ordered by how recently
691
+ // each room was refreshed; eviction below relies on that ordering. The
692
+ // room's retry attempts and backoff timer deliberately survive: a coalesced
693
+ // update must not reset an outage's exponential backoff.
694
+ this.removePendingFanoutEntry(documentName)
695
+ this.pendingFanouts.set(documentName, {
696
+ document,
697
+ collaborationGeneration,
698
+ revision: ++this.nextFanoutRevision,
699
+ message,
700
+ })
701
+ this.pendingFanoutBytes += message.byteLength
702
+ this.evictOverflowingFanouts(documentName)
703
+ this.startPersistedFanout(documentName)
704
+ }
705
+
706
+ private startPersistedFanout(documentName: string): void {
707
+ if (
708
+ this.fanoutDestroyed
709
+ || this.activeFanouts.has(documentName)
710
+ || this.fanoutRetryTimers.has(documentName)
711
+ || !this.pendingFanouts.has(documentName)
712
+ ) return
713
+
714
+ this.activeFanouts.add(documentName)
715
+ void this.drainPersistedFanout(documentName).finally(() => {
716
+ this.activeFanouts.delete(documentName)
717
+ if (
718
+ !this.fanoutDestroyed
719
+ && this.pendingFanouts.has(documentName)
720
+ && !this.fanoutRetryTimers.has(documentName)
721
+ ) {
722
+ this.startPersistedFanout(documentName)
723
+ }
724
+ })
725
+ }
726
+
727
+ private async drainPersistedFanout(documentName: string): Promise<void> {
728
+ const pending = this.pendingFanouts.get(documentName)
729
+ if (!pending || this.fanoutDestroyed) return
730
+
731
+ try {
732
+ await this.publishRedisMessage(
733
+ documentName,
734
+ pending.message,
735
+ this.fanoutPublisher,
736
+ pending.collaborationGeneration,
737
+ )
738
+ } catch (error) {
739
+ if (this.fanoutDestroyed || !this.pendingFanouts.has(documentName)) return
740
+ const attempt = (this.fanoutRetryAttempts.get(documentName) ?? 0) + 1
741
+ this.fanoutRetryAttempts.set(documentName, attempt)
742
+ const delay = Math.min(
743
+ DOCUMENTS_COLLAB_REDIS_FANOUT_RETRY_MAX_MS,
744
+ DOCUMENTS_COLLAB_REDIS_FANOUT_RETRY_MIN_MS * 2 ** Math.min(attempt - 1, 8),
745
+ )
746
+ logger.warn('durable Redis collaboration fanout failed; retrying latest state', {
747
+ room: documentName,
748
+ attempt,
749
+ error: error instanceof Error ? error.message : String(error),
750
+ })
751
+ const retryTimer = setTimeout(() => {
752
+ this.fanoutRetryTimers.delete(documentName)
753
+ this.startPersistedFanout(documentName)
754
+ }, delay)
755
+ retryTimer.unref?.()
756
+ this.fanoutRetryTimers.set(documentName, retryTimer)
757
+ return
758
+ }
759
+
760
+ this.fanoutRetryAttempts.delete(documentName)
761
+ if (this.pendingFanouts.get(documentName)?.revision === pending.revision) {
762
+ this.removePendingFanoutEntry(documentName)
763
+ }
764
+ }
765
+
766
+ private redisKey(documentName: string): string {
767
+ return `${this.configuration.prefix}:${documentName}`
768
+ }
769
+
770
+ private encodeRedisMessage(message: Uint8Array): Buffer {
771
+ return Buffer.concat([this.messagePrefix, Buffer.from(message)])
772
+ }
773
+
774
+ private encodeDurableFanoutMessage(
775
+ message: Uint8Array,
776
+ collaborationGeneration: number,
777
+ ): Buffer {
778
+ if (
779
+ !Number.isSafeInteger(collaborationGeneration)
780
+ || collaborationGeneration < 1
781
+ ) {
782
+ throw new Error('[internal] documents collab: invalid Redis fanout generation')
783
+ }
784
+ const generation = Buffer.allocUnsafe(DOCUMENTS_COLLAB_REDIS_FANOUT_GENERATION_BYTES)
785
+ generation.writeBigUInt64BE(BigInt(collaborationGeneration))
786
+ return Buffer.concat([
787
+ this.messagePrefix,
788
+ DOCUMENTS_COLLAB_REDIS_FANOUT_MAGIC,
789
+ generation,
790
+ Buffer.from(message),
791
+ ])
792
+ }
793
+
794
+ private async publishRedisMessage(
795
+ documentName: string,
796
+ message: Uint8Array,
797
+ publisher: DocumentsCollabRedisFanoutPublisher | Pick<DocumentsCollabRedisFanoutPublisher, 'publish'> = this.pub,
798
+ collaborationGeneration?: number,
799
+ ): Promise<unknown> {
800
+ return await publisher.publish(
801
+ this.redisKey(documentName),
802
+ collaborationGeneration === undefined
803
+ ? this.encodeRedisMessage(message)
804
+ : this.encodeDurableFanoutMessage(message, collaborationGeneration),
805
+ )
806
+ }
807
+
808
+ private bufferDurableFanout(
809
+ sender: string,
810
+ documentName: string,
811
+ document: Y.Doc,
812
+ collaborationGeneration: number,
813
+ update: Uint8Array,
814
+ ): void {
815
+ const buffered = this.bufferedFanouts.get(document) ?? new Map()
816
+ const previous = buffered.get(sender)
817
+ const bufferedBytes = (this.bufferedFanoutBytes.get(document) ?? 0)
818
+ - (previous?.update.byteLength ?? 0)
819
+ + update.byteLength
820
+ if (
821
+ (!previous && buffered.size >= DOCUMENTS_COLLAB_MAX_PENDING_MESSAGES)
822
+ || bufferedBytes > DOCUMENTS_COLLAB_MAX_PENDING_BYTES
823
+ ) {
824
+ this.bufferedFanouts.delete(document)
825
+ this.bufferedFanoutBytes.delete(document)
826
+ this.onRejectedAggregate?.(documentName, document)
827
+ logger.warn('rejected buffered Redis collaboration updates that exceeded load limits', {
828
+ room: documentName,
829
+ })
830
+ return
831
+ }
832
+ buffered.set(sender, {
833
+ collaborationGeneration,
834
+ update: Uint8Array.from(update),
835
+ })
836
+ this.bufferedFanouts.set(document, buffered)
837
+ this.bufferedFanoutBytes.set(document, bufferedBytes)
838
+ }
839
+
840
+ private applyDurableFanout(
841
+ sender: string,
842
+ documentName: string,
843
+ document: Y.Doc,
844
+ collaborationGeneration: number,
845
+ update: Uint8Array,
846
+ ): void {
847
+ const loadedGeneration = this.resolveCollaborationGeneration?.(document)
848
+ if (loadedGeneration === undefined) {
849
+ this.bufferDurableFanout(
850
+ sender,
851
+ documentName,
852
+ document,
853
+ collaborationGeneration,
854
+ update,
855
+ )
856
+ return
857
+ }
858
+ if (loadedGeneration !== collaborationGeneration) {
859
+ logger.warn('rejected stale Redis collaboration update from another generation', {
860
+ room: documentName,
861
+ expectedGeneration: loadedGeneration,
862
+ receivedGeneration: collaborationGeneration,
863
+ })
864
+ return
865
+ }
866
+
867
+ let aggregateByteLength: number
868
+ try {
869
+ aggregateByteLength = assertDocumentsCollabRedisAggregateUpdate(document, update)
870
+ } catch (error) {
871
+ if (isCrudHttpError(error) && error.status === 413) {
872
+ this.onRejectedAggregate?.(documentName, document)
873
+ logger.warn('rejected Redis collaboration update that exceeded room limits', {
874
+ room: documentName,
875
+ })
876
+ return
877
+ }
878
+ logger.warn('rejected malformed Redis collaboration update', {
879
+ room: documentName,
880
+ error: error instanceof Error ? error.message : String(error),
881
+ })
882
+ return
883
+ }
884
+ // Refresh the receiver's aggregate budget before Yjs forwards the accepted
885
+ // update to local connections.
886
+ this.onAcceptedAggregate?.(document, aggregateByteLength)
887
+ Y.applyUpdate(document, update, this.redisTransactionOrigin)
888
+ }
889
+
890
+ private applyBufferedFanouts(documentName: string, document: Y.Doc): void {
891
+ const buffered = this.bufferedFanouts.get(document)
892
+ this.bufferedFanouts.delete(document)
893
+ this.bufferedFanoutBytes.delete(document)
894
+ if (!buffered) return
895
+ const loadedGeneration = this.resolveCollaborationGeneration?.(document)
896
+ if (loadedGeneration === undefined) {
897
+ this.onRejectedAggregate?.(documentName, document)
898
+ throw new Error('[internal] documents collab: loaded room has no collaboration generation')
899
+ }
900
+ for (const [sender, fanout] of buffered) {
901
+ this.applyDurableFanout(
902
+ sender,
903
+ documentName,
904
+ document,
905
+ fanout.collaborationGeneration,
906
+ fanout.update,
907
+ )
908
+ }
909
+ }
910
+
911
+ private readonly handleDocumentsRedisMessage = async (
912
+ _channel: Buffer,
913
+ data: Buffer,
914
+ ): Promise<void> => {
915
+ const identifierLength = data[0]
916
+ if (identifierLength === undefined || data.byteLength <= identifierLength + 1) return
917
+ const identifier = data.toString('utf8', 1, identifierLength + 1)
918
+ if (identifier === this.configuration.identifier) return
919
+
920
+ const redisPayload = data.subarray(identifierLength + 1)
921
+ let collaborationGeneration: number | undefined
922
+ let messageBuffer = redisPayload
923
+ if (
924
+ redisPayload.byteLength
925
+ >= DOCUMENTS_COLLAB_REDIS_FANOUT_MAGIC.byteLength
926
+ + DOCUMENTS_COLLAB_REDIS_FANOUT_GENERATION_BYTES
927
+ && redisPayload.subarray(0, DOCUMENTS_COLLAB_REDIS_FANOUT_MAGIC.byteLength)
928
+ .equals(DOCUMENTS_COLLAB_REDIS_FANOUT_MAGIC)
929
+ ) {
930
+ const generationOffset = DOCUMENTS_COLLAB_REDIS_FANOUT_MAGIC.byteLength
931
+ const encodedGeneration = redisPayload.readBigUInt64BE(generationOffset)
932
+ if (encodedGeneration > BigInt(Number.MAX_SAFE_INTEGER) || encodedGeneration < 1n) return
933
+ collaborationGeneration = Number(encodedGeneration)
934
+ messageBuffer = redisPayload.subarray(
935
+ generationOffset + DOCUMENTS_COLLAB_REDIS_FANOUT_GENERATION_BYTES,
936
+ )
937
+ }
938
+ const header = new HocuspocusIncomingMessage(messageBuffer)
939
+ const documentName = header.readVarString()
940
+ const document = this.instance.documents.get(documentName)
941
+ ?? this.knownDocuments.get(documentName)
942
+ if (!document) return
943
+
944
+ const messageType = header.readVarUint()
945
+ if (messageType === MessageType.Sync || messageType === MessageType.SyncReply) {
946
+ // The Documents protocol accepts only complete, post-persistence update
947
+ // frames. Ignore state vectors/step-two replies so no peer can ask this
948
+ // replica to reveal transient browser-authored room state.
949
+ const syncType = header.readVarUint()
950
+ if (messageType !== MessageType.Sync || syncType !== 2) return
951
+ if (collaborationGeneration === undefined) {
952
+ logger.warn('rejected unversioned Redis collaboration update', { room: documentName })
953
+ return
954
+ }
955
+ const update = header.readVarUint8Array()
956
+ this.applyDurableFanout(
957
+ identifier,
958
+ documentName,
959
+ document,
960
+ collaborationGeneration,
961
+ update,
962
+ )
963
+ return
964
+ }
965
+
966
+ const receiverMessage = new HocuspocusIncomingMessage(messageBuffer)
967
+ receiverMessage.readVarString()
968
+ receiverMessage.writeVarString(documentName)
969
+ const receiver = new HocuspocusMessageReceiver(
970
+ receiverMessage,
971
+ this.redisTransactionOrigin,
972
+ )
973
+ await receiver.apply(document, undefined, (reply) => {
974
+ void this.publishRedisMessage(documentName, reply).catch((error: unknown) => {
975
+ logger.warn('failed to publish Redis collaboration reply', {
976
+ room: documentName,
977
+ error: error instanceof Error ? error.message : String(error),
978
+ })
979
+ })
980
+ })
981
+ }
982
+
983
+ private async flushPendingFanoutsBeforeDestroy(): Promise<void> {
984
+ const pending = [...this.pendingFanouts.entries()]
985
+ if (pending.length === 0) return
986
+
987
+ const attempts = Promise.allSettled(pending.map(async ([documentName, fanout]) => {
988
+ await this.publishRedisMessage(
989
+ documentName,
990
+ fanout.message,
991
+ this.fanoutPublisher,
992
+ fanout.collaborationGeneration,
993
+ )
994
+ }))
995
+ let timeout: ReturnType<typeof setTimeout> | null = null
996
+ const deadline = new Promise<null>((resolve) => {
997
+ timeout = setTimeout(() => resolve(null), DOCUMENTS_COLLAB_REDIS_FANOUT_SHUTDOWN_TIMEOUT_MS)
998
+ timeout.unref?.()
999
+ })
1000
+ const results = await Promise.race([attempts, deadline])
1001
+ if (timeout) clearTimeout(timeout)
1002
+
1003
+ if (results === null) {
1004
+ logger.warn('durable Redis collaboration fanout did not drain before shutdown', {
1005
+ pending: pending.length,
1006
+ })
1007
+ return
1008
+ }
1009
+ const failed = results.filter((result) => result.status === 'rejected').length
1010
+ if (failed > 0) {
1011
+ logger.warn('durable Redis collaboration fanout failed during shutdown', {
1012
+ pending: pending.length,
1013
+ failed,
1014
+ })
1015
+ }
1016
+ }
1017
+
1018
+ override async onDestroy(): Promise<void> {
1019
+ this.fanoutDestroyed = true
1020
+ for (const retryTimer of this.fanoutRetryTimers.values()) clearTimeout(retryTimer)
1021
+ this.fanoutRetryTimers.clear()
1022
+ const pendingLocalDelays = [...this.pendingLocalAfterStoreDelays.values()]
1023
+ this.pendingLocalAfterStoreDelays.clear()
1024
+ for (const pending of pendingLocalDelays) {
1025
+ clearTimeout(pending.timeout)
1026
+ pending.resolve()
1027
+ }
1028
+ try {
1029
+ await this.flushPendingFanoutsBeforeDestroy()
1030
+ } finally {
1031
+ this.pendingFanouts.clear()
1032
+ this.pendingFanoutBytes = 0
1033
+ this.fanoutRetryAttempts.clear()
1034
+ this.fanoutPublisher.disconnect(false)
1035
+ // The stock extension calls Redlock.quit(), which can wait forever when
1036
+ // its shared client uses maxRetriesPerRequest=null during a Redis outage.
1037
+ // Shutdown has already made every room inert and attempted a bounded
1038
+ // durable drain, so force-close the transport clients instead.
1039
+ this.pub.disconnect(false)
1040
+ this.sub.disconnect(false)
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * The Redis extension replicates updates and awareness across sidecar
1047
+ * replicas. It is activated only when a Redis URL is explicitly configured:
1048
+ * defaulting to a localhost instance could silently attach the sidecar to an
1049
+ * unrelated Redis, and failing hard would block valid single-node
1050
+ * deployments. Without Redis the sidecar runs in single-node mode and logs a
1051
+ * prominent startup warning, because multi-instance deployments require
1052
+ * Redis for cross-instance document sync.
1053
+ */
1054
+ export function resolveDocumentsCollabRedisExtensions<RedisExtension>(
1055
+ environment: NodeJS.ProcessEnv,
1056
+ createRedisExtension: (configuration: DocumentsCollabRedisConfiguration) => RedisExtension,
1057
+ ): RedisExtension[] {
1058
+ const configuration = resolveDocumentsCollabRedisConfiguration(environment)
1059
+ if (!configuration) {
1060
+ logger.warn(
1061
+ 'DOCUMENTS_COLLAB_REDIS_URL and REDIS_URL are unset; '
1062
+ + 'running in single-node mode. Multi-instance deployments require Redis '
1063
+ + 'for cross-instance document sync.',
1064
+ )
1065
+ return []
1066
+ }
1067
+ return [createRedisExtension(configuration)]
1068
+ }
1069
+
1070
+ export type CollabFinalDrainConsumeResult =
1071
+ | 'unmarked'
1072
+ | 'busy'
1073
+ | 'connected'
1074
+ | 'failed'
1075
+ | 'consumed'
1076
+
1077
+ export type CollabAuthorizationTicket = {
1078
+ readonly documentName: string
1079
+ readonly epoch: number
1080
+ readonly state: object
1081
+ }
1082
+
1083
+ export type CollabFinalDrainRegistry = {
1084
+ /**
1085
+ * Mark the exact trusted in-memory room before its sockets are closed, but
1086
+ * only when it currently has at least one live logical connection.
1087
+ */
1088
+ mark: (document: Y.Doc, readiness: Promise<void>) => boolean
1089
+ /** Read-only identity check used to keep reconnects out until the drain starts. */
1090
+ isMarked: (document: Y.Doc) => boolean
1091
+ /**
1092
+ * Consume the mark once, but only after every captured connection queue has
1093
+ * drained and the room has no live connections.
1094
+ */
1095
+ consume: (document: Y.Doc) => Promise<CollabFinalDrainConsumeResult>
1096
+ /** Seal the old room identity after durable success; unmapping releases it via WeakMap. */
1097
+ complete: (document: Y.Doc) => void
1098
+ /** Permanently withdraw a pending exception when another guard wins. */
1099
+ discard: (document: Y.Doc) => void
1100
+ /** Begin a bounded auth ticket for one currently authenticating scoped document. */
1101
+ beginAuthorization: (
1102
+ documentName: string,
1103
+ scope?: { tenantId: string; organizationId: string },
1104
+ ) => CollabAuthorizationTicket
1105
+ /** Advance only in-flight tickets for this exact document and scope. */
1106
+ bumpAuthorization: (
1107
+ documentName: string,
1108
+ scope?: { tenantId: string; organizationId: string },
1109
+ ) => void
1110
+ /** Verify no trusted access event crossed any authentication await. */
1111
+ isAuthorizationCurrent: (ticket: CollabAuthorizationTicket) => boolean
1112
+ /** Release the ticket and delete its document state when the last auth ends. */
1113
+ endAuthorization: (ticket: CollabAuthorizationTicket) => void
1114
+ }
1115
+
1116
+ type ConnectionCountedYDoc = Y.Doc & {
1117
+ getConnectionsCount?: () => unknown
1118
+ getConnections?: () => unknown
1119
+ }
1120
+
1121
+ type PendingMessagesConnection = {
1122
+ waitForPendingMessages?: () => unknown
1123
+ }
1124
+
1125
+ type CollabFinalDrainState = {
1126
+ /** Resolves false when any captured queue cannot be proven drained. */
1127
+ readiness: Promise<boolean>
1128
+ /** Only the caller that installs this promise may receive the drain grant. */
1129
+ consuming?: Promise<CollabFinalDrainConsumeResult>
1130
+ /** The one store invocation allowed to finish the final durable write. */
1131
+ granted?: boolean
1132
+ /** Durable success seals this old room identity until Hocuspocus unmaps it. */
1133
+ completed?: boolean
1134
+ }
1135
+
1136
+ type CollabAuthorizationState = {
1137
+ active: number
1138
+ epoch: number
1139
+ }
1140
+
1141
+ type InternalCollabAuthorizationTicket = CollabAuthorizationTicket & {
1142
+ expiry?: ReturnType<typeof setTimeout>
1143
+ released: boolean
1144
+ stateKey: string
1145
+ }
1146
+
1147
+ function collabAuthorizationStateKey(
1148
+ documentName: string,
1149
+ scope?: { tenantId: string; organizationId: string },
1150
+ ): string {
1151
+ return scope
1152
+ ? `${documentName}\u0000${scope.tenantId}\u0000${scope.organizationId}`
1153
+ : documentName
1154
+ }
1155
+
1156
+ function liveCollabConnectionCount(document: Y.Doc): number | null {
1157
+ const getConnectionsCount = (document as ConnectionCountedYDoc).getConnectionsCount
1158
+ if (typeof getConnectionsCount !== 'function') return null
1159
+ try {
1160
+ const count = getConnectionsCount.call(document)
1161
+ return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0
1162
+ ? count
1163
+ : null
1164
+ } catch {
1165
+ return null
1166
+ }
1167
+ }
1168
+
1169
+ function captureCollabPendingMessageReadiness(document: Y.Doc): Promise<void> | null {
1170
+ const getConnections = (document as ConnectionCountedYDoc).getConnections
1171
+ if (typeof getConnections !== 'function') return null
1172
+
1173
+ let connections: unknown
1174
+ try {
1175
+ connections = getConnections.call(document)
1176
+ } catch {
1177
+ return null
1178
+ }
1179
+ if (!Array.isArray(connections) || connections.length === 0) return null
1180
+
1181
+ // Capture the public queue promises synchronously, while the exact
1182
+ // connections are still registered and before closeConnections removes
1183
+ // them from the room. A missing/throwing/non-Promise readiness contract is
1184
+ // represented by a rejection so the registry can fail the drain closed.
1185
+ const pending = connections.map((candidate) => {
1186
+ const connection = candidate as PendingMessagesConnection | null
1187
+ if (typeof connection?.waitForPendingMessages !== 'function') {
1188
+ return Promise.reject(new Error(
1189
+ '[internal] documents collab: connection queue readiness is unavailable',
1190
+ ))
1191
+ }
1192
+ try {
1193
+ const readiness = connection.waitForPendingMessages()
1194
+ if (
1195
+ !readiness
1196
+ || (typeof readiness !== 'object' && typeof readiness !== 'function')
1197
+ || typeof (readiness as PromiseLike<unknown>).then !== 'function'
1198
+ ) {
1199
+ return Promise.reject(new Error(
1200
+ '[internal] documents collab: connection queue readiness is invalid',
1201
+ ))
1202
+ }
1203
+ return Promise.resolve(readiness)
1204
+ } catch (error) {
1205
+ return Promise.reject(error)
1206
+ }
1207
+ })
1208
+
1209
+ return Promise.all(pending).then(() => undefined)
1210
+ }
1211
+
1212
+ /**
1213
+ * One-shot, room-identity-bound exception for draining edits accepted before
1214
+ * a trusted share-change event closed the room. Unknown connection state is
1215
+ * deliberately treated as connected. No timer can make a marked room become
1216
+ * eligible: every exact pre-close connection queue must drain and the live
1217
+ * connection count must then be zero.
1218
+ */
1219
+ export function createCollabFinalDrainRegistry(
1220
+ resolveLiveConnections: (document: Y.Doc) => number | null = liveCollabConnectionCount,
1221
+ ): CollabFinalDrainRegistry {
1222
+ const markedDocuments = new WeakMap<Y.Doc, CollabFinalDrainState>()
1223
+ const authorizationStates = new Map<string, CollabAuthorizationState>()
1224
+ const releaseAuthorization = (ticket: InternalCollabAuthorizationTicket): void => {
1225
+ if (ticket.released) return
1226
+ ticket.released = true
1227
+ if (ticket.expiry) clearTimeout(ticket.expiry)
1228
+ const state = authorizationStates.get(ticket.stateKey)
1229
+ if (state !== ticket.state) return
1230
+ state.active = Math.max(0, state.active - 1)
1231
+ if (state.active === 0) authorizationStates.delete(ticket.stateKey)
1232
+ }
1233
+ return {
1234
+ mark(document, readiness) {
1235
+ const guardedReadiness = readiness.then(
1236
+ () => true,
1237
+ () => false,
1238
+ )
1239
+ if ((resolveLiveConnections(document) ?? 0) <= 0) return false
1240
+ if (markedDocuments.has(document)) return true
1241
+ markedDocuments.set(document, {
1242
+ readiness: guardedReadiness,
1243
+ })
1244
+ return true
1245
+ },
1246
+ isMarked(document) {
1247
+ return markedDocuments.has(document)
1248
+ },
1249
+ async consume(document) {
1250
+ const state = markedDocuments.get(document)
1251
+ if (!state) return 'unmarked'
1252
+ // A non-owner store must not invalidate or release the room while the
1253
+ // owner is waiting for captured queues or completing its durable write.
1254
+ if (state.consuming || state.granted) return 'busy'
1255
+
1256
+ const consuming = (async (): Promise<CollabFinalDrainConsumeResult> => {
1257
+ const ready = await state.readiness
1258
+ if (markedDocuments.get(document) !== state) return 'unmarked'
1259
+
1260
+ // Every terminal outcome stays marked until its caller synchronously
1261
+ // installs invalidation or Hocuspocus unmaps the successfully drained
1262
+ // old Y.Doc. Deleting here would create a microtask gap in which an
1263
+ // in-flight authentication could enter before invalidation is visible.
1264
+ if (!ready) return 'failed'
1265
+ if (resolveLiveConnections(document) !== 0) return 'connected'
1266
+ state.granted = true
1267
+ return 'consumed'
1268
+ })()
1269
+ state.consuming = consuming
1270
+ return consuming
1271
+ },
1272
+ complete(document) {
1273
+ const state = markedDocuments.get(document)
1274
+ if (state?.granted) state.completed = true
1275
+ },
1276
+ discard(document) {
1277
+ markedDocuments.delete(document)
1278
+ },
1279
+ beginAuthorization(documentName, scope) {
1280
+ const stateKey = collabAuthorizationStateKey(documentName, scope)
1281
+ let state = authorizationStates.get(stateKey)
1282
+ if (!state) {
1283
+ state = { active: 0, epoch: 0 }
1284
+ authorizationStates.set(stateKey, state)
1285
+ }
1286
+ state.active += 1
1287
+ const ticket: InternalCollabAuthorizationTicket = {
1288
+ documentName,
1289
+ epoch: state.epoch,
1290
+ released: false,
1291
+ stateKey,
1292
+ state,
1293
+ }
1294
+ ticket.expiry = setTimeout(() => {
1295
+ releaseAuthorization(ticket)
1296
+ }, DOCUMENTS_COLLAB_AUTHORIZATION_TICKET_TIMEOUT_MS)
1297
+ ticket.expiry.unref?.()
1298
+ return ticket
1299
+ },
1300
+ bumpAuthorization(documentName, scope) {
1301
+ const state = authorizationStates.get(collabAuthorizationStateKey(documentName, scope))
1302
+ if (state) state.epoch += 1
1303
+ },
1304
+ isAuthorizationCurrent(ticket) {
1305
+ const internalTicket = ticket as InternalCollabAuthorizationTicket
1306
+ const state = authorizationStates.get(internalTicket.stateKey)
1307
+ return !internalTicket.released
1308
+ && state === ticket.state
1309
+ && state.epoch === ticket.epoch
1310
+ },
1311
+ endAuthorization(ticket) {
1312
+ releaseAuthorization(ticket as InternalCollabAuthorizationTicket)
1313
+ },
1314
+ }
1315
+ }
1316
+
1317
+ /**
1318
+ * Prepare the current room for a trusted share-event close. Direct-only or
1319
+ * already-disconnected rooms are deliberately left unmarked because
1320
+ * closeConnections cannot produce a last-websocket store for them.
1321
+ */
1322
+ export function markCollabFinalDrainForReauth(
1323
+ document: Y.Doc,
1324
+ registry: CollabFinalDrainRegistry,
1325
+ ): boolean {
1326
+ const readiness = captureCollabPendingMessageReadiness(document)
1327
+ if (!readiness) return false
1328
+ return registry.mark(document, readiness)
1329
+ }
1330
+
1331
+ type CollabIngressLimits = {
1332
+ maxPendingBytes: number
1333
+ maxPendingMessages: number
1334
+ }
1335
+
1336
+ type CollabIngressState = {
1337
+ blocked: boolean
1338
+ pendingBytes: number
1339
+ pendingMessages: number
1340
+ }
1341
+
1342
+ type BoundedCollabConnection = Pick<
1343
+ HocuspocusConnection<CollabContext>,
1344
+ 'close' | 'handleMessage' | 'waitForPendingMessages' | 'webSocket'
1345
+ >
1346
+
1347
+ const collabIngressStates = new WeakMap<object, CollabIngressState>()
1348
+ const boundedCollabConnections = new WeakSet<object>()
1349
+ const guardedCollabConnectionPrototypes = new WeakSet<object>()
1350
+
1351
+ function closeCollabIngress(
1352
+ connection: BoundedCollabConnection,
1353
+ state: CollabIngressState,
1354
+ ): void {
1355
+ if (state.blocked) return
1356
+ state.blocked = true
1357
+ try {
1358
+ connection.close()
1359
+ } catch {
1360
+ // The transport close below is authoritative even if the logical room was
1361
+ // concurrently removed.
1362
+ }
1363
+ try {
1364
+ connection.webSocket.close(1009, 'Collaboration ingress limit exceeded')
1365
+ } catch {
1366
+ // A concurrently closed socket is already in the required terminal state.
1367
+ }
1368
+ }
1369
+
1370
+ function handleBoundedCollabIngress(
1371
+ connection: BoundedCollabConnection,
1372
+ data: Uint8Array,
1373
+ handleMessage: (input: Uint8Array) => void,
1374
+ limits: CollabIngressLimits,
1375
+ ): void {
1376
+ const socketKey = connection.webSocket as object
1377
+ let state = collabIngressStates.get(socketKey)
1378
+ if (!state) {
1379
+ state = { blocked: false, pendingBytes: 0, pendingMessages: 0 }
1380
+ collabIngressStates.set(socketKey, state)
1381
+ }
1382
+ if (state.blocked) return
1383
+
1384
+ const pendingBytes = state.pendingBytes + data.byteLength
1385
+ const pendingMessages = state.pendingMessages + 1
1386
+ if (
1387
+ pendingBytes > limits.maxPendingBytes
1388
+ || pendingMessages > limits.maxPendingMessages
1389
+ ) {
1390
+ closeCollabIngress(connection, state)
1391
+ return
1392
+ }
1393
+
1394
+ state.pendingBytes = pendingBytes
1395
+ state.pendingMessages = pendingMessages
1396
+ let released = false
1397
+ const release = () => {
1398
+ if (released) return
1399
+ released = true
1400
+ state.pendingBytes = Math.max(0, state.pendingBytes - data.byteLength)
1401
+ state.pendingMessages = Math.max(0, state.pendingMessages - 1)
1402
+ }
1403
+
1404
+ try {
1405
+ handleMessage(data)
1406
+ void connection.waitForPendingMessages().then(release, release)
1407
+ } catch (error) {
1408
+ release()
1409
+ throw error
1410
+ }
1411
+ }
1412
+
1413
+ /**
1414
+ * Bound Hocuspocus' otherwise-unbounded authenticated message queue.
1415
+ *
1416
+ * The state is keyed by the physical socket, not the logical document
1417
+ * connection, because one provider can multiplex several documents. Every
1418
+ * accepted frame stays charged until Hocuspocus reports that its serial queue
1419
+ * drained, including frames rejected by a later hook.
1420
+ */
1421
+ export function installBoundedCollabIngress(
1422
+ connection: BoundedCollabConnection,
1423
+ limits: CollabIngressLimits = {
1424
+ maxPendingBytes: DOCUMENTS_COLLAB_MAX_PENDING_BYTES,
1425
+ maxPendingMessages: DOCUMENTS_COLLAB_MAX_PENDING_MESSAGES,
1426
+ },
1427
+ ): void {
1428
+ const connectionKey = connection as object
1429
+ if (boundedCollabConnections.has(connectionKey)) return
1430
+ boundedCollabConnections.add(connectionKey)
1431
+
1432
+ const handleMessage = connection.handleMessage.bind(connection)
1433
+ connection.handleMessage = (data: Uint8Array): void => {
1434
+ handleBoundedCollabIngress(connection, data, handleMessage, limits)
1435
+ }
1436
+ }
1437
+
1438
+ type HocuspocusConnectionClass = {
1439
+ prototype: BoundedCollabConnection
1440
+ }
1441
+
1442
+ /**
1443
+ * Guard every logical Hocuspocus connection before the server can drain its
1444
+ * pre-authentication queue. Hocuspocus invokes `connected` only after that
1445
+ * synchronous drain, so installing the guard from the hook would miss the
1446
+ * oldest (and usually largest) retained batch.
1447
+ */
1448
+ export function installHocuspocusCollabIngressGuard(
1449
+ ConnectionClass: HocuspocusConnectionClass = HocuspocusConnection,
1450
+ ): void {
1451
+ const prototype = ConnectionClass.prototype
1452
+ if (guardedCollabConnectionPrototypes.has(prototype as object)) return
1453
+ guardedCollabConnectionPrototypes.add(prototype as object)
1454
+
1455
+ const handleMessage = prototype.handleMessage
1456
+ prototype.handleMessage = function guardedHandleMessage(
1457
+ this: BoundedCollabConnection,
1458
+ data: Uint8Array,
1459
+ ): void {
1460
+ if (boundedCollabConnections.has(this as object)) {
1461
+ handleMessage.call(this, data)
1462
+ return
1463
+ }
1464
+ handleBoundedCollabIngress(
1465
+ this,
1466
+ data,
1467
+ (input) => handleMessage.call(this, input),
1468
+ {
1469
+ maxPendingBytes: DOCUMENTS_COLLAB_MAX_PENDING_BYTES,
1470
+ maxPendingMessages: DOCUMENTS_COLLAB_MAX_PENDING_MESSAGES,
1471
+ },
1472
+ )
1473
+ }
1474
+ }
1475
+
1476
+ /** Reject expensive control/read-only frames before Hocuspocus decodes Yjs/JSON payloads. */
1477
+ export function assertCollabInboundFramePolicy(
1478
+ update: Uint8Array,
1479
+ options: { readOnly: boolean },
1480
+ ): void {
1481
+ if (update.byteLength > DOCUMENTS_COLLAB_MAX_PAYLOAD_BYTES) {
1482
+ throw new Error('[internal] documents collab: frame exceeds transport limit')
1483
+ }
1484
+
1485
+ let messageType: number
1486
+ try {
1487
+ const message = new HocuspocusIncomingMessage(update)
1488
+ message.readVarString()
1489
+ messageType = message.readVarUint()
1490
+ } catch {
1491
+ throw new Error('[internal] documents collab: malformed frame envelope')
1492
+ }
1493
+
1494
+ const isSync = messageType === MessageType.Sync || messageType === MessageType.SyncReply
1495
+ if (
1496
+ messageType === MessageType.Awareness
1497
+ && update.byteLength > DOCUMENTS_COLLAB_MAX_AWARENESS_FRAME_BYTES
1498
+ ) {
1499
+ throw new Error('[internal] documents collab: awareness frame exceeds limit')
1500
+ }
1501
+ if (
1502
+ !isSync
1503
+ && update.byteLength > DOCUMENTS_COLLAB_MAX_CONTROL_FRAME_BYTES
1504
+ ) {
1505
+ throw new Error('[internal] documents collab: control frame exceeds limit')
1506
+ }
1507
+ if (
1508
+ options.readOnly
1509
+ && isSync
1510
+ && update.byteLength > DOCUMENTS_COLLAB_MAX_READ_ONLY_SYNC_FRAME_BYTES
1511
+ ) {
1512
+ throw new Error('[internal] documents collab: read-only sync frame exceeds limit')
1513
+ }
1514
+ }
1515
+
1516
+ export type CollabConnection = { readOnly: boolean }
1517
+ type CollabTier = CollabTokenClaims['tier']
1518
+ export type CollabContext = {
1519
+ userId: string
1520
+ tenantId: string
1521
+ organizationId: string
1522
+ documentId: string
1523
+ tier: CollabTier
1524
+ readOnly: boolean
1525
+ exp: number | null
1526
+ awarenessUser?: CanonicalCollaborationAwarenessUser
1527
+ }
1528
+ const COLLAB_AUTHORIZATION_LIFECYCLE = Symbol('documents.collab.authorization-lifecycle')
1529
+ type CollabAuthorizationLifecycle = {
1530
+ established: boolean
1531
+ ticket?: CollabAuthorizationTicket
1532
+ }
1533
+ type TicketedCollabContext = CollabContext & {
1534
+ [COLLAB_AUTHORIZATION_LIFECYCLE]?: CollabAuthorizationLifecycle
1535
+ }
1536
+ type CollabScope = { tenantId: string; organizationId: string }
1537
+ type CollabContainer = { resolve: (name: string) => unknown }
1538
+ type CollabAcl = {
1539
+ isSuperAdmin: boolean
1540
+ features: string[]
1541
+ organizations: string[] | null
1542
+ }
1543
+ type CollabRbacService = {
1544
+ invalidateUserCache: (userId: string) => Promise<void>
1545
+ loadAcl: (
1546
+ userId: string,
1547
+ scope: { tenantId: string | null; organizationId: string | null },
1548
+ ) => Promise<CollabAcl>
1549
+ }
1550
+
1551
+ async function loadFreshCollabAcl(
1552
+ rbacService: CollabRbacService,
1553
+ userId: string,
1554
+ scope: { tenantId: string | null; organizationId: string | null },
1555
+ ): Promise<CollabAcl | null> {
1556
+ if (
1557
+ typeof rbacService?.invalidateUserCache !== 'function'
1558
+ || typeof rbacService.loadAcl !== 'function'
1559
+ ) {
1560
+ return null
1561
+ }
1562
+ await rbacService.invalidateUserCache(userId)
1563
+ return rbacService.loadAcl(userId, scope)
1564
+ }
1565
+ export type CollabHooksDeps = {
1566
+ verifyToken?: (token: string) => CollabTokenClaims | null
1567
+ verifyTokenV2?: (token: string) => VerifiedCollabTokenV2Claims | null
1568
+ authorizeContext: (context: CollabContext) => Promise<boolean>
1569
+ resolveAwarenessName?: (context: CollabContext) => Promise<unknown>
1570
+ resolveContainer: () => Promise<CollabContainer>
1571
+ loadContent: (
1572
+ em: unknown,
1573
+ documentId: string,
1574
+ scope: CollabScope,
1575
+ ) => Promise<{
1576
+ yjsState: Buffer | null
1577
+ contentHtml: string | null
1578
+ updatedAt: string | Date
1579
+ collaborationGeneration: number
1580
+ } | null>
1581
+ loadCollaborationGeneration?: (
1582
+ em: unknown,
1583
+ documentId: string,
1584
+ scope: CollabScope,
1585
+ ) => Promise<number | null>
1586
+ initializeYjsState: (
1587
+ em: unknown,
1588
+ documentId: string,
1589
+ scope: CollabScope,
1590
+ ) => Promise<Buffer | null>
1591
+ persistContent: (
1592
+ em: unknown,
1593
+ documentId: string,
1594
+ scope: CollabScope,
1595
+ input: { yjsState: Buffer; contentHtml?: string | null; contentText?: string | null },
1596
+ deps: {
1597
+ searchIndexer: unknown
1598
+ expectedUpdatedAt: string
1599
+ expectedCollaborationGeneration: number
1600
+ requireExpectedVersion: true
1601
+ },
1602
+ ) => Promise<{ updatedAt: string | Date; collaborationGeneration: number }>
1603
+ /** Notify the replication layer only after this exact generation/state is durable. */
1604
+ onPersisted?: (
1605
+ document: Y.Doc,
1606
+ yjsState: Uint8Array,
1607
+ collaborationGeneration: number,
1608
+ ) => void
1609
+ allowedOrigins?: string[] | null
1610
+ /** Require both an Origin header and a configured exact-match trusted origin. */
1611
+ requireOrigin?: boolean
1612
+ isRoomInvalidated?: (documentName: string, document?: Y.Doc) => boolean
1613
+ invalidateRoom?: (documentName: string, document: Y.Doc) => void
1614
+ finalDrainRegistry?: CollabFinalDrainRegistry
1615
+ /** Resolve only the room identity currently mapped by Hocuspocus. */
1616
+ resolveRoomDocument?: (documentName: string) => Y.Doc | undefined
1617
+ /** @deprecated Use isRoomInvalidated. Kept for extension compatibility. */
1618
+ isRoomClosing?: (documentName: string, document: Y.Doc) => boolean
1619
+ }
1620
+
1621
+ export type CollabExpiryConnection = {
1622
+ close: () => void
1623
+ onClose: (callback: () => void) => unknown
1624
+ }
1625
+
1626
+ export type CollabHealthRequest = Pick<IncomingMessage, 'method' | 'url'>
1627
+ export type CollabHealthResponse = Pick<ServerResponse, 'statusCode' | 'setHeader' | 'end'>
1628
+
1629
+ type CollabAwarenessOwnership = {
1630
+ ownedClientIds: ReadonlySet<number>
1631
+ occupiedClientIds: ReadonlySet<number>
1632
+ /** All client ids admitted during this websocket connection's lifetime. */
1633
+ claimedClientIds?: Set<number>
1634
+ /** Stable room-lifetime binding that prevents a different actor recycling an id. */
1635
+ roomClientOwners?: Map<number, string>
1636
+ }
1637
+
1638
+ type RequestHeaders = Record<string, string | string[] | undefined>
1639
+
1640
+ function readHeader(headers: RequestHeaders | undefined, name: string): string | undefined {
1641
+ const value = headers?.[name] ?? headers?.[name.toLowerCase()]
1642
+ if (Array.isArray(value)) return value[0]
1643
+ return value
1644
+ }
1645
+
1646
+ function normalizeTrustedOrigin(value: string): string | null {
1647
+ try {
1648
+ const url = new URL(value)
1649
+ return (url.protocol === 'http:' || url.protocol === 'https:') ? url.origin : null
1650
+ } catch {
1651
+ return null
1652
+ }
1653
+ }
1654
+
1655
+ export function resolveCollabAllowedOrigins(
1656
+ env: { DOCUMENTS_COLLAB_ALLOWED_ORIGINS?: string; APP_URL?: string; NEXT_PUBLIC_APP_URL?: string },
1657
+ ): string[] {
1658
+ const candidates = [
1659
+ ...(env.DOCUMENTS_COLLAB_ALLOWED_ORIGINS ?? '').split(','),
1660
+ env.APP_URL ?? '',
1661
+ env.NEXT_PUBLIC_APP_URL ?? '',
1662
+ ]
1663
+ return Array.from(new Set(candidates
1664
+ .map((candidate) => candidate.trim())
1665
+ .filter(Boolean)
1666
+ .map(normalizeTrustedOrigin)
1667
+ .filter((origin): origin is string => Boolean(origin))))
1668
+ }
1669
+
1670
+ export function isCollabRequestOriginAllowed(input: {
1671
+ origin?: string
1672
+ allowedOrigins?: string[] | null
1673
+ requireOrigin: boolean
1674
+ }): boolean {
1675
+ if (!input.origin) return !input.requireOrigin
1676
+ const origin = normalizeTrustedOrigin(input.origin)
1677
+ if (!origin) return false
1678
+ const allowedOrigins = input.allowedOrigins ?? []
1679
+ if (allowedOrigins.length === 0) return !input.requireOrigin
1680
+ return allowedOrigins.includes(origin)
1681
+ }
1682
+
1683
+ function assertScopedContext(context: CollabContext | null | undefined): asserts context is CollabContext {
1684
+ if (!context?.tenantId || !context.organizationId) {
1685
+ throw new Error('[internal] documents collab: missing tenant scope')
1686
+ }
1687
+ }
1688
+
1689
+ function normalizeContentVersion(value: string | Date | null | undefined): string | null {
1690
+ if (value instanceof Date) {
1691
+ return Number.isFinite(value.getTime()) ? value.toISOString() : null
1692
+ }
1693
+ if (typeof value !== 'string' || !value.trim()) return null
1694
+ const parsed = new Date(value)
1695
+ return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null
1696
+ }
1697
+
1698
+ function isReadOnlyTier(tier: CollabTier): boolean {
1699
+ return tier === 'viewer' || tier === 'commenter'
1700
+ }
1701
+
1702
+ function toContext(
1703
+ claims: CollabTokenClaims,
1704
+ readOnly: boolean,
1705
+ exp: number | null,
1706
+ ): CollabContext {
1707
+ return {
1708
+ userId: claims.userId,
1709
+ tenantId: claims.tenantId,
1710
+ organizationId: claims.organizationId,
1711
+ documentId: claims.documentId,
1712
+ tier: claims.tier,
1713
+ readOnly,
1714
+ exp,
1715
+ }
1716
+ }
1717
+
1718
+ function resolveCollabClaims(deps: CollabHooksDeps, token: string): CollabContext | null {
1719
+ const v2Claims = deps.verifyTokenV2?.(token) ?? null
1720
+ if (v2Claims) return toContext(v2Claims, v2Claims.readOnly, v2Claims.exp)
1721
+
1722
+ const legacyClaims = deps.verifyToken?.(token) ?? null
1723
+ if (!legacyClaims) return null
1724
+ const exp = readLegacyTokenExpiration(token)
1725
+ return exp === null
1726
+ ? null
1727
+ : toContext(legacyClaims, isReadOnlyTier(legacyClaims.tier), exp)
1728
+ }
1729
+
1730
+ export function bindCollabAwarenessStates(
1731
+ context: CollabContext | null | undefined,
1732
+ states: Map<number, Record<string, unknown>>,
1733
+ ownership?: CollabAwarenessOwnership,
1734
+ ): void {
1735
+ if (!context?.awarenessUser) {
1736
+ throw new Error('[internal] documents collab: awareness identity is not authenticated')
1737
+ }
1738
+
1739
+ const admittedThisUpdate = new Set<number>()
1740
+ for (const [clientId, state] of states) {
1741
+ // Hocuspocus decodes the inbound update through a scratch Awareness whose
1742
+ // constructor creates one empty local state. Never turn that decoder-only
1743
+ // entry into a broadcast collaborator or retain it in the room.
1744
+ if (
1745
+ !state
1746
+ || typeof state !== 'object'
1747
+ || Array.isArray(state)
1748
+ || Object.keys(state).length === 0
1749
+ ) {
1750
+ states.delete(clientId)
1751
+ continue
1752
+ }
1753
+ if (
1754
+ !Number.isSafeInteger(clientId)
1755
+ || clientId < 0
1756
+ || clientId > MAX_YJS_CLIENT_ID
1757
+ || !isBoundedAwarenessState(state)
1758
+ ) {
1759
+ states.delete(clientId)
1760
+ continue
1761
+ }
1762
+ if (
1763
+ ownership?.occupiedClientIds.has(clientId)
1764
+ && !ownership.ownedClientIds.has(clientId)
1765
+ ) {
1766
+ // Hocuspocus providers echo awareness updates received from peers. An
1767
+ // occupied id owned by another socket is therefore either a harmless
1768
+ // echo or an attempted overwrite; in both cases it must be discarded,
1769
+ // never rebound to the sender or treated as a reason to disconnect it.
1770
+ states.delete(clientId)
1771
+ continue
1772
+ }
1773
+
1774
+ const roomOwner = ownership?.roomClientOwners?.get(clientId)
1775
+ if (roomOwner !== undefined && roomOwner !== context.awarenessUser.id) {
1776
+ states.delete(clientId)
1777
+ continue
1778
+ }
1779
+
1780
+ const knownToConnection = Boolean(
1781
+ ownership?.ownedClientIds.has(clientId)
1782
+ || ownership?.claimedClientIds?.has(clientId),
1783
+ )
1784
+ const connectionClientCount = new Set([
1785
+ ...(ownership?.ownedClientIds ?? []),
1786
+ ...(ownership?.claimedClientIds ?? []),
1787
+ ...admittedThisUpdate,
1788
+ ]).size
1789
+ if (
1790
+ !knownToConnection
1791
+ && connectionClientCount >= DOCUMENTS_COLLAB_MAX_AWARENESS_CLIENT_IDS_PER_CONNECTION
1792
+ ) {
1793
+ states.delete(clientId)
1794
+ continue
1795
+ }
1796
+ if (
1797
+ roomOwner === undefined
1798
+ && (ownership?.roomClientOwners?.size ?? 0)
1799
+ >= DOCUMENTS_COLLAB_MAX_AWARENESS_CLIENT_IDS_PER_ROOM
1800
+ ) {
1801
+ states.delete(clientId)
1802
+ continue
1803
+ }
1804
+
1805
+ const cursor = sanitizeAwarenessCursor(state.cursor)
1806
+ const canonicalState: Record<string, unknown> = {
1807
+ user: { ...context.awarenessUser },
1808
+ }
1809
+ if (cursor) canonicalState.cursor = cursor
1810
+ states.set(clientId, canonicalState)
1811
+ admittedThisUpdate.add(clientId)
1812
+ ownership?.claimedClientIds?.add(clientId)
1813
+ if (roomOwner === undefined) {
1814
+ ownership?.roomClientOwners?.set(clientId, context.awarenessUser.id)
1815
+ }
1816
+ }
1817
+ }
1818
+
1819
+ function isBoundedAwarenessState(state: Record<string, unknown>): boolean {
1820
+ try {
1821
+ const serialized = JSON.stringify(state)
1822
+ return typeof serialized === 'string'
1823
+ && Buffer.byteLength(serialized, 'utf8') <= DOCUMENTS_COLLAB_MAX_AWARENESS_STATE_BYTES
1824
+ } catch {
1825
+ return false
1826
+ }
1827
+ }
1828
+
1829
+ type SanitizedAwarenessPosition = {
1830
+ assoc: number
1831
+ item?: { client: number; clock: number }
1832
+ tname?: string
1833
+ type?: { client: number; clock: number }
1834
+ }
1835
+
1836
+ function sanitizeAwarenessId(value: unknown): { client: number; clock: number } | null {
1837
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
1838
+ const record = value as Record<string, unknown>
1839
+ if (
1840
+ !Number.isSafeInteger(record.client)
1841
+ || (record.client as number) < 0
1842
+ || (record.client as number) > MAX_YJS_CLIENT_ID
1843
+ || !Number.isSafeInteger(record.clock)
1844
+ || (record.clock as number) < 0
1845
+ ) {
1846
+ return null
1847
+ }
1848
+ return { client: record.client as number, clock: record.clock as number }
1849
+ }
1850
+
1851
+ function sanitizeAwarenessPosition(value: unknown): SanitizedAwarenessPosition | null {
1852
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
1853
+ const record = value as Record<string, unknown>
1854
+ const assoc = record.assoc === undefined ? 0 : record.assoc
1855
+ if (!Number.isSafeInteger(assoc) || (assoc as number) < -1 || (assoc as number) > 1) {
1856
+ return null
1857
+ }
1858
+
1859
+ const item = sanitizeAwarenessId(record.item)
1860
+ if (item) return { item, assoc: assoc as number }
1861
+ if (typeof record.tname === 'string' && record.tname.length > 0 && record.tname.length <= 120) {
1862
+ return { tname: record.tname, assoc: assoc as number }
1863
+ }
1864
+ const type = sanitizeAwarenessId(record.type)
1865
+ return type ? { type, assoc: assoc as number } : null
1866
+ }
1867
+
1868
+ function sanitizeAwarenessCursor(value: unknown): {
1869
+ anchor: SanitizedAwarenessPosition
1870
+ head: SanitizedAwarenessPosition
1871
+ } | null {
1872
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
1873
+ const record = value as Record<string, unknown>
1874
+ const anchor = sanitizeAwarenessPosition(record.anchor)
1875
+ const head = sanitizeAwarenessPosition(record.head)
1876
+ return anchor && head ? { anchor, head } : null
1877
+ }
1878
+
1879
+ function readLegacyTokenExpiration(token: string): number | null {
1880
+ const encodedPayload = token.split('.')[1]
1881
+ if (!encodedPayload) return null
1882
+ try {
1883
+ const payload: unknown = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'))
1884
+ if (!payload || typeof payload !== 'object') return null
1885
+ const { exp, iat } = payload as Record<string, unknown>
1886
+ const now = Math.floor(Date.now() / 1000)
1887
+ if (
1888
+ typeof exp !== 'number'
1889
+ || !Number.isInteger(exp)
1890
+ || typeof iat !== 'number'
1891
+ || !Number.isInteger(iat)
1892
+ || exp <= now
1893
+ || exp <= iat
1894
+ || exp - iat > COLLAB_TOKEN_TTL_SECONDS
1895
+ || iat > now + COLLAB_TOKEN_CLOCK_SKEW_SECONDS
1896
+ || exp > now + COLLAB_TOKEN_TTL_SECONDS + COLLAB_TOKEN_CLOCK_SKEW_SECONDS
1897
+ ) {
1898
+ return null
1899
+ }
1900
+ return exp
1901
+ } catch {
1902
+ return null
1903
+ }
1904
+ }
1905
+
1906
+ export function scheduleCollabConnectionExpiry(
1907
+ connection: CollabExpiryConnection,
1908
+ exp: number,
1909
+ ): () => void {
1910
+ let timer: ReturnType<typeof setTimeout> | null = null
1911
+ const clearExpiry = () => {
1912
+ if (timer === null) return
1913
+ clearTimeout(timer)
1914
+ timer = null
1915
+ }
1916
+ timer = setTimeout(() => {
1917
+ timer = null
1918
+ connection.close()
1919
+ }, Math.max(0, exp * 1000 - Date.now()))
1920
+ connection.onClose(clearExpiry)
1921
+ return clearExpiry
1922
+ }
1923
+
1924
+ /**
1925
+ * Revalidate one active logical connection without retaining or scanning a
1926
+ * process-wide connection registry. Refreshes never overlap, and the timer is
1927
+ * released with the connection even when authorization infrastructure fails.
1928
+ */
1929
+ export function scheduleCollabConnectionReauthorization(
1930
+ connection: CollabExpiryConnection,
1931
+ authorize: () => Promise<boolean>,
1932
+ intervalMs = DOCUMENTS_COLLAB_ACTIVE_REAUTHORIZATION_INTERVAL_MS,
1933
+ ): () => void {
1934
+ let stopped = false
1935
+ let timer: ReturnType<typeof setTimeout> | null = null
1936
+
1937
+ const stop = (): void => {
1938
+ stopped = true
1939
+ if (timer === null) return
1940
+ clearTimeout(timer)
1941
+ timer = null
1942
+ }
1943
+ const schedule = (): void => {
1944
+ if (stopped) return
1945
+ timer = setTimeout(() => {
1946
+ timer = null
1947
+ void (async () => {
1948
+ let authorized = false
1949
+ try {
1950
+ authorized = await authorize()
1951
+ } catch {
1952
+ authorized = false
1953
+ }
1954
+ if (stopped) return
1955
+ if (!authorized) {
1956
+ stop()
1957
+ connection.close()
1958
+ return
1959
+ }
1960
+ schedule()
1961
+ })()
1962
+ }, Math.max(1, intervalMs))
1963
+ timer.unref?.()
1964
+ }
1965
+
1966
+ connection.onClose(stop)
1967
+ schedule()
1968
+ return stop
1969
+ }
1970
+
1971
+ export function handleCollabHealthRequest(
1972
+ request: CollabHealthRequest,
1973
+ response: CollabHealthResponse,
1974
+ ): boolean {
1975
+ const pathname = new URL(request.url ?? '/', 'http://documents-collab.local').pathname
1976
+ if (pathname !== '/healthz') return false
1977
+
1978
+ response.setHeader('Cache-Control', 'no-store')
1979
+ if (request.method !== 'GET') {
1980
+ response.statusCode = 405
1981
+ response.setHeader('Allow', 'GET')
1982
+ response.end()
1983
+ return true
1984
+ }
1985
+
1986
+ const ready = isCollabTokenV2Ready()
1987
+ response.statusCode = ready ? 200 : 503
1988
+ response.setHeader('Content-Type', 'application/json')
1989
+ response.end(JSON.stringify({
1990
+ status: ready ? 'ok' : 'unavailable',
1991
+ service: 'documents-collab',
1992
+ capabilityTokenVersion: 2,
1993
+ }))
1994
+ return true
1995
+ }
1996
+
1997
+ export async function handleCollabServerRequest(input: {
1998
+ request: CollabHealthRequest
1999
+ response: CollabHealthResponse
2000
+ }): Promise<void> {
2001
+ if (handleCollabHealthRequest(input.request, input.response)) {
2002
+ return Promise.reject()
2003
+ }
2004
+ }
2005
+
2006
+ /**
2007
+ * HTML-only legacy rows need one durable Yjs identity before they are sent to
2008
+ * a client. Hocuspocus installs its change listener after onLoadDocument, so a
2009
+ * conversion performed only in that hook is never scheduled for persistence.
2010
+ * A reconnect would then convert the same HTML with a new Yjs client ID and
2011
+ * merge duplicate content into the provider's retained local Y.Doc.
2012
+ *
2013
+ * The bootstrap is internal representation work, not a user edit: nativeUpdate
2014
+ * intentionally leaves updated_at unchanged and avoids a redundant reindex.
2015
+ */
2016
+ export async function initializeDocumentYjsState(
2017
+ em: EntityManager,
2018
+ documentId: string,
2019
+ scope: CollabScope,
2020
+ ): Promise<Buffer | null> {
2021
+ return em.transactional(async (transactionalEm) => {
2022
+ // Serialize legacy-content bootstrap behind the aggregate root. This also
2023
+ // lets two first-time sockets safely repair a pre-M6 document that has no
2024
+ // DocumentContent row without racing the document_id unique constraint.
2025
+ const document = await findOneWithDecryption(
2026
+ transactionalEm,
2027
+ Document,
2028
+ {
2029
+ id: documentId,
2030
+ tenantId: scope.tenantId,
2031
+ organizationId: scope.organizationId,
2032
+ deletedAt: null,
2033
+ },
2034
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
2035
+ scope,
2036
+ )
2037
+ if (!document) return null
2038
+
2039
+ let content = await findOneWithDecryption(
2040
+ transactionalEm,
2041
+ DocumentContent,
2042
+ {
2043
+ documentId,
2044
+ tenantId: scope.tenantId,
2045
+ organizationId: scope.organizationId,
2046
+ },
2047
+ { filters: false, lockMode: LockMode.PESSIMISTIC_WRITE },
2048
+ scope,
2049
+ )
2050
+ if (!content) {
2051
+ content = transactionalEm.create(DocumentContent, {
2052
+ id: randomUUID(),
2053
+ documentId,
2054
+ tenantId: scope.tenantId,
2055
+ organizationId: scope.organizationId,
2056
+ contentHtml: '',
2057
+ contentText: '',
2058
+ yjsState: null,
2059
+ collaborationGeneration: 1,
2060
+ deletedAt: null,
2061
+ updatedAt: new Date(),
2062
+ })
2063
+ transactionalEm.persist(content)
2064
+ await transactionalEm.flush()
2065
+ return null
2066
+ }
2067
+ if (content.deletedAt) {
2068
+ // A live document with a tombstoned content row represents a logically
2069
+ // blank body (for example an undo of its first legacy content write).
2070
+ // Repair the one-row invariant without reviving the tombstoned body.
2071
+ const now = new Date()
2072
+ content.yjsState = null
2073
+ content.contentHtml = ''
2074
+ content.contentText = ''
2075
+ content.deletedAt = null
2076
+ advanceDocumentCollaborationGeneration(content)
2077
+ content.updatedAt = now.getTime() > content.updatedAt.getTime()
2078
+ ? now
2079
+ : new Date(content.updatedAt.getTime() + 1)
2080
+ await transactionalEm.flush()
2081
+ }
2082
+ if (content.yjsState && content.yjsState.length > 0) {
2083
+ assertDocumentContentResourceLimits({
2084
+ yjsState: content.yjsState,
2085
+ contentHtml: content.contentHtml,
2086
+ contentText: content.contentText,
2087
+ })
2088
+ return Buffer.from(content.yjsState)
2089
+ }
2090
+ if (!content.contentHtml) return null
2091
+
2092
+ assertDocumentContentResourceLimits({
2093
+ contentHtml: content.contentHtml,
2094
+ contentText: content.contentText,
2095
+ })
2096
+ const materialized = htmlToYDoc(content.contentHtml)
2097
+ const yjsState = Buffer.from(Y.encodeStateAsUpdate(materialized))
2098
+ assertDocumentContentResourceLimits({ yjsState, contentHtml: content.contentHtml })
2099
+ await transactionalEm.nativeUpdate(
2100
+ DocumentContent,
2101
+ {
2102
+ id: content.id,
2103
+ documentId,
2104
+ tenantId: scope.tenantId,
2105
+ organizationId: scope.organizationId,
2106
+ deletedAt: null,
2107
+ },
2108
+ { yjsState },
2109
+ )
2110
+ return yjsState
2111
+ })
2112
+ }
2113
+
2114
+ export type CollabAuthorizationSnapshot = {
2115
+ relationshipTier: CollabTier | null
2116
+ isSuperAdmin: boolean
2117
+ features: string[]
2118
+ organizations: string[] | null
2119
+ organizationScope?: ResolvedDocumentsOrganizationScope | null
2120
+ archived?: boolean
2121
+ }
2122
+
2123
+ export function isCollabAuthorizationCurrent(
2124
+ context: CollabContext,
2125
+ snapshot: CollabAuthorizationSnapshot,
2126
+ ): boolean {
2127
+ if (!hasResolvedDocumentsOrganizationAccess(
2128
+ snapshot,
2129
+ context.organizationId,
2130
+ snapshot.organizationScope,
2131
+ )) {
2132
+ return false
2133
+ }
2134
+
2135
+ const features = snapshot.isSuperAdmin ? ['*'] : snapshot.features
2136
+ const managerOverride = hasAllFeatures(['documents.manage'], features)
2137
+ const relationshipTier = managerOverride ? 'owner' : snapshot.relationshipTier
2138
+ const capabilities = deriveDocumentCapabilities({
2139
+ relationshipTier,
2140
+ managerOverride,
2141
+ archived: snapshot.archived === true,
2142
+ userFeatures: features,
2143
+ })
2144
+
2145
+ return capabilities.canView
2146
+ && relationshipTier === context.tier
2147
+ && context.readOnly === !capabilities.canEdit
2148
+ }
2149
+
2150
+ /**
2151
+ * Re-resolve the actor's current ACL and document relationship whenever a
2152
+ * socket authenticates. Share events close existing sockets; this check makes
2153
+ * replaying their still-signed short-lived token fail even after a sidecar
2154
+ * restart, and also rejects capability downgrades or role-share removal.
2155
+ */
2156
+ export async function authorizeCollabContext(
2157
+ container: CollabContainer,
2158
+ context: CollabContext,
2159
+ ): Promise<boolean> {
2160
+ try {
2161
+ const scope = {
2162
+ tenantId: context.tenantId,
2163
+ organizationId: context.organizationId,
2164
+ }
2165
+ const em = container.resolve('em') as EntityManager
2166
+ const rbacService = container.resolve('rbacService') as CollabRbacService
2167
+ let acl = await loadFreshCollabAcl(rbacService, context.userId, scope)
2168
+ if (!acl) return false
2169
+
2170
+ let organizationScope: ResolvedDocumentsOrganizationScope | null = null
2171
+ if (!hasResolvedDocumentsOrganizationAccess(acl, context.organizationId)) {
2172
+ // Organization hierarchy resolution performs its own ACL read. Route it
2173
+ // through the same fail-closed fresh-load primitive so no secondary
2174
+ // lookup can revive a warm sidecar cache after a role/ACL revocation.
2175
+ const organizationScopeService = resolveOrganizationScopeService(container)
2176
+ if (!organizationScopeService) return false
2177
+ const freshAuthorization = await organizationScopeService.resolveFresh({
2178
+ auth: {
2179
+ sub: context.userId,
2180
+ userId: context.userId,
2181
+ tenantId: context.tenantId,
2182
+ orgId: context.organizationId,
2183
+ isSuperAdmin: false,
2184
+ },
2185
+ selectedId: context.organizationId,
2186
+ tenantId: context.tenantId,
2187
+ })
2188
+ organizationScope = freshAuthorization.scope
2189
+ acl = freshAuthorization.acl
2190
+ if (!hasResolvedDocumentsOrganizationAccess(
2191
+ acl,
2192
+ context.organizationId,
2193
+ organizationScope,
2194
+ )) {
2195
+ return false
2196
+ }
2197
+ }
2198
+ const features = acl.isSuperAdmin ? ['*'] : acl.features
2199
+ const managerOverride = hasAllFeatures(['documents.manage'], features)
2200
+ const relationshipTier = managerOverride
2201
+ ? 'owner'
2202
+ : await resolveUserAccess(
2203
+ em,
2204
+ context.documentId,
2205
+ scope,
2206
+ context.userId,
2207
+ container,
2208
+ )
2209
+ const documentRow = await findOneWithDecryption(
2210
+ em,
2211
+ Document,
2212
+ { id: context.documentId, tenantId: scope.tenantId, organizationId: scope.organizationId },
2213
+ { fields: ['id', 'archivedAt', 'deletedAt'], filters: false },
2214
+ scope,
2215
+ )
2216
+ if (!documentRow || documentRow.deletedAt) return false
2217
+ return isCollabAuthorizationCurrent(context, {
2218
+ relationshipTier,
2219
+ isSuperAdmin: acl.isSuperAdmin,
2220
+ features: acl.features,
2221
+ organizations: acl.organizations,
2222
+ organizationScope,
2223
+ archived: documentRow?.archivedAt != null,
2224
+ })
2225
+ } catch (error) {
2226
+ // Fail closed, but leave a trace: an RBAC/DB outage otherwise surfaces
2227
+ // only as an unexplained mass disconnect.
2228
+ logger.warn('collab authorization check failed; failing closed', {
2229
+ error: error instanceof Error ? error.message : String(error),
2230
+ })
2231
+ return false
2232
+ }
2233
+ }
2234
+
2235
+ export function createCollabHooks(deps: CollabHooksDeps) {
2236
+ const materializationWarningRooms = new Set<string>()
2237
+ const loadedContentVersions = new WeakMap<Y.Doc, string>()
2238
+ const loadedCollaborationGenerations = new WeakMap<Y.Doc, number>()
2239
+ const roomResourceBudgets = new WeakMap<Y.Doc, { bytes: number; revision: number }>()
2240
+ const roomScopes = new WeakMap<Y.Doc, CollabScope>()
2241
+ const awarenessConnectionClientIds = new WeakMap<object, Set<number>>()
2242
+ const awarenessRoomClientOwners = new WeakMap<Y.Doc, Map<number, string>>()
2243
+ // Hocuspocus hands onStoreDocument only the room's lastContext, which can be
2244
+ // a read-only viewer even when the retained edits were authored by an
2245
+ // editor. Remember the most recent writable authorization per room identity
2246
+ // so a debounced store can still persist under a freshly re-validated
2247
+ // writable context. The WeakMap releases the entry with the Y.Doc when
2248
+ // Hocuspocus unloads or destroys the room.
2249
+ const roomWritableContexts = new WeakMap<Y.Doc, CollabContext>()
2250
+
2251
+ const rememberWritableContext = (
2252
+ document: Y.Doc | undefined,
2253
+ context: CollabContext | undefined,
2254
+ ): void => {
2255
+ if (!document || !context || context.readOnly) return
2256
+ roomWritableContexts.set(document, context)
2257
+ }
2258
+
2259
+ const isInvalidated = (documentName: string, document?: Y.Doc): boolean => (
2260
+ deps.isRoomInvalidated?.(documentName, document)
2261
+ ?? (document ? deps.isRoomClosing?.(documentName, document) : false)
2262
+ ?? false
2263
+ )
2264
+
2265
+ const isFinalDrainPending = (documentName: string): boolean => {
2266
+ const room = deps.resolveRoomDocument?.(documentName)
2267
+ return Boolean(room && deps.finalDrainRegistry?.isMarked(room))
2268
+ }
2269
+
2270
+ const assertRoomAcceptsAuthentication = (documentName: string): void => {
2271
+ if (isInvalidated(documentName)) {
2272
+ throw new Error('[internal] documents collab: room is reloading authoritative content')
2273
+ }
2274
+ if (isFinalDrainPending(documentName)) {
2275
+ throw new Error('[internal] documents collab: room is draining accepted edits')
2276
+ }
2277
+ }
2278
+
2279
+ type StoreAuthorization = 'normal' | 'final-drain' | 'denied'
2280
+
2281
+ const invalidateStoreRoom = (data: {
2282
+ documentName: string
2283
+ document: Y.Doc
2284
+ }): void => {
2285
+ deps.finalDrainRegistry?.discard(data.document)
2286
+ deps.invalidateRoom?.(data.documentName, data.document)
2287
+ }
2288
+
2289
+ const retireFailedStore = (data: {
2290
+ documentName: string
2291
+ document: Y.Doc
2292
+ }, ownsGrant = false): boolean => {
2293
+ const invalidated = isInvalidated(data.documentName, data.document)
2294
+ if (
2295
+ !ownsGrant
2296
+ && !invalidated
2297
+ && !deps.finalDrainRegistry?.isMarked(data.document)
2298
+ ) return false
2299
+ if (invalidated) {
2300
+ deps.finalDrainRegistry?.discard(data.document)
2301
+ } else {
2302
+ invalidateStoreRoom(data)
2303
+ }
2304
+ // Keep this diagnostic bounded: persistence errors can contain driver
2305
+ // details or document data. Returning normally is intentional so
2306
+ // Hocuspocus reaches its zero-connection unload path instead of retaining
2307
+ // an invalidated, permanently authentication-blocked room.
2308
+ logger.error(invalidated
2309
+ ? 'invalidated store failed; retiring in-memory room'
2310
+ : 'final drain failed; retiring in-memory room')
2311
+ return true
2312
+ }
2313
+
2314
+ const assertConnectionAuthorization = (context: CollabContext): void => {
2315
+ if (!deps.finalDrainRegistry) return
2316
+ const lifecycle = (context as TicketedCollabContext)[COLLAB_AUTHORIZATION_LIFECYCLE]
2317
+ if (lifecycle?.established) return
2318
+ if (
2319
+ !lifecycle?.ticket
2320
+ || !deps.finalDrainRegistry.isAuthorizationCurrent(lifecycle.ticket)
2321
+ ) {
2322
+ throw new Error('[internal] documents collab: access changed during connection setup')
2323
+ }
2324
+ assertRoomAcceptsAuthentication(context.documentId)
2325
+ }
2326
+
2327
+ const establishConnectionAuthorization = (context: CollabContext): void => {
2328
+ if (!deps.finalDrainRegistry) return
2329
+ assertConnectionAuthorization(context)
2330
+ const lifecycle = (context as TicketedCollabContext)[COLLAB_AUTHORIZATION_LIFECYCLE]
2331
+ if (!lifecycle) return
2332
+ if (lifecycle.ticket) deps.finalDrainRegistry.endAuthorization(lifecycle.ticket)
2333
+ lifecycle.ticket = undefined
2334
+ lifecycle.established = true
2335
+ }
2336
+
2337
+ const releaseConnectionAuthorization = (context: CollabContext): void => {
2338
+ if (!deps.finalDrainRegistry) return
2339
+ const ticketedContext = context as TicketedCollabContext
2340
+ const lifecycle = ticketedContext[COLLAB_AUTHORIZATION_LIFECYCLE]
2341
+ if (lifecycle?.ticket) deps.finalDrainRegistry.endAuthorization(lifecycle.ticket)
2342
+ delete ticketedContext[COLLAB_AUTHORIZATION_LIFECYCLE]
2343
+ }
2344
+
2345
+ const authorizeStoreAttempt = async (data: {
2346
+ documentName: string
2347
+ context: CollabContext
2348
+ document: Y.Doc
2349
+ }): Promise<StoreAuthorization> => {
2350
+ let finalDrain: CollabFinalDrainConsumeResult = 'unmarked'
2351
+ if (deps.finalDrainRegistry?.isMarked(data.document)) {
2352
+ finalDrain = await deps.finalDrainRegistry.consume(data.document)
2353
+ if (finalDrain === 'busy') {
2354
+ // Another store owns the one-shot grant. It alone keeps the room
2355
+ // marked through persistence; this duplicate must be inert.
2356
+ return 'denied'
2357
+ }
2358
+ if (finalDrain === 'connected' || finalDrain === 'failed') {
2359
+ invalidateStoreRoom(data)
2360
+ return 'denied'
2361
+ }
2362
+ }
2363
+
2364
+ let authorized: boolean
2365
+ try {
2366
+ authorized = await deps.authorizeContext(data.context)
2367
+ } catch {
2368
+ // Authorization infrastructure failures are security failures here. A
2369
+ // room containing edits accepted under an unverified capability must
2370
+ // never remain eligible for a later debounced/retried store. Complete
2371
+ // normally after invalidation so Hocuspocus can retire the mapped room.
2372
+ invalidateStoreRoom(data)
2373
+ logger.error('store authorization failed; retiring in-memory room')
2374
+ return 'denied'
2375
+ }
2376
+
2377
+ // Content replacement/deletion invalidation always wins over a share
2378
+ // final drain. It is never safe to merge the old collaboration epoch.
2379
+ if (isInvalidated(data.documentName, data.document)) {
2380
+ deps.finalDrainRegistry?.discard(data.document)
2381
+ return 'denied'
2382
+ }
2383
+
2384
+ // A store can start its live ACL check immediately before the trusted
2385
+ // event marks the room. Re-read and claim the drain after that async
2386
+ // boundary so the store still waits for every pre-close queue.
2387
+ if (finalDrain === 'unmarked') {
2388
+ finalDrain = await (deps.finalDrainRegistry?.consume(data.document)
2389
+ ?? Promise.resolve('unmarked' as const))
2390
+ }
2391
+ if (finalDrain === 'busy') return 'denied'
2392
+ if (finalDrain === 'connected' || finalDrain === 'failed') {
2393
+ // Seeing a live connection after every captured queue drained means the
2394
+ // room was recycled, reconnected, or has a direct connection. Missing,
2395
+ // malformed, or rejected queue readiness is equally untrusted.
2396
+ invalidateStoreRoom(data)
2397
+ return 'denied'
2398
+ }
2399
+ if (finalDrain === 'consumed') return 'final-drain'
2400
+
2401
+ if (authorized) return 'normal'
2402
+
2403
+ // Revoke the whole room identity, not only this store invocation. This
2404
+ // closes its sockets in production and prevents its unauthorized Y.Doc
2405
+ // from being persisted by a later debounce after access is restored.
2406
+ invalidateStoreRoom(data)
2407
+ return 'denied'
2408
+ }
2409
+
2410
+ const reauthorizeActiveConnection = async (context: CollabContext): Promise<boolean> => {
2411
+ if (isInvalidated(context.documentId) || isFinalDrainPending(context.documentId)) {
2412
+ return false
2413
+ }
2414
+
2415
+ let authorized = false
2416
+ try {
2417
+ authorized = await deps.authorizeContext(context)
2418
+ } catch {
2419
+ authorized = false
2420
+ }
2421
+
2422
+ // A trusted document event that crossed the ACL refresh already owns the
2423
+ // room shutdown/final-drain decision. Do not replace it with invalidation.
2424
+ if (isInvalidated(context.documentId) || isFinalDrainPending(context.documentId)) {
2425
+ return false
2426
+ }
2427
+ if (authorized) {
2428
+ const resolveRoomDocument = deps.resolveRoomDocument
2429
+ // Production active connections always belong to an exact mapped room.
2430
+ // Keep the hook backwards-compatible for embedders that do not expose
2431
+ // room resolution, but close a connection whose production mapping has
2432
+ // already disappeared instead of authorizing a stale logical socket.
2433
+ if (!resolveRoomDocument) return true
2434
+ const room = resolveRoomDocument(context.documentId)
2435
+ if (!room) return false
2436
+
2437
+ const loadedGeneration = loadedCollaborationGenerations.get(room)
2438
+ let durableGeneration: number | null = null
2439
+ try {
2440
+ const container = await deps.resolveContainer()
2441
+ const em = container.resolve('em')
2442
+ const scope = {
2443
+ tenantId: context.tenantId,
2444
+ organizationId: context.organizationId,
2445
+ }
2446
+ if (deps.loadCollaborationGeneration) {
2447
+ durableGeneration = await deps.loadCollaborationGeneration(
2448
+ em,
2449
+ context.documentId,
2450
+ scope,
2451
+ )
2452
+ } else {
2453
+ const content = await deps.loadContent(em, context.documentId, scope)
2454
+ durableGeneration = normalizeDocumentCollaborationGeneration(
2455
+ content?.collaborationGeneration,
2456
+ )
2457
+ }
2458
+ } catch {
2459
+ durableGeneration = null
2460
+ }
2461
+
2462
+ // A trusted event may have reached this process while the durable read
2463
+ // was in flight. Its invalidation/final-drain path owns room retirement.
2464
+ if (isInvalidated(context.documentId) || isFinalDrainPending(context.documentId)) {
2465
+ return false
2466
+ }
2467
+ // Never apply a stale connection's refresh result to a replacement room
2468
+ // that loaded after an unload/reconnect race.
2469
+ if (resolveRoomDocument(context.documentId) !== room) return false
2470
+
2471
+ if (
2472
+ loadedGeneration !== undefined
2473
+ && durableGeneration !== null
2474
+ && durableGeneration === loadedGeneration
2475
+ ) {
2476
+ rememberWritableContext(room, context)
2477
+ return true
2478
+ }
2479
+
2480
+ // Reconcile a missed cross-process content-reset/deletion event from the
2481
+ // durable, scoped DocumentContent generation. Retiring the old Y.Doc is
2482
+ // required so no pre-reset update can later pass the store CAS and merge
2483
+ // back into the authoritative epoch.
2484
+ invalidateStoreRoom({
2485
+ documentName: context.documentId,
2486
+ document: room,
2487
+ })
2488
+ return false
2489
+ }
2490
+
2491
+ // A failed active refresh can follow an RBAC/role change that did not name
2492
+ // a document. Retire only the exact mapped room; the per-connection timer
2493
+ // avoids any global room or connection scan.
2494
+ const room = deps.resolveRoomDocument?.(context.documentId)
2495
+ if (room) {
2496
+ invalidateStoreRoom({
2497
+ documentName: context.documentId,
2498
+ document: room,
2499
+ })
2500
+ }
2501
+ return false
2502
+ }
2503
+
2504
+ return {
2505
+ assertConnectionAuthorization,
2506
+ establishConnectionAuthorization,
2507
+ releaseConnectionAuthorization,
2508
+ reauthorizeActiveConnection,
2509
+ recordRedisAggregate(document: Y.Doc, byteLength: number): void {
2510
+ const previous = roomResourceBudgets.get(document)
2511
+ roomResourceBudgets.set(document, {
2512
+ bytes: byteLength,
2513
+ revision: (previous?.revision ?? 0) + 1,
2514
+ })
2515
+ },
2516
+ resolveCollaborationGeneration(document: Y.Doc): number | undefined {
2517
+ return loadedCollaborationGenerations.get(document)
2518
+ },
2519
+ resolveRoomScope(document: Y.Doc): CollabScope | null {
2520
+ return roomScopes.get(document) ?? null
2521
+ },
2522
+ async onAuthenticate(data: {
2523
+ token?: string
2524
+ documentName: string
2525
+ connection: CollabConnection
2526
+ requestHeaders?: RequestHeaders
2527
+ }): Promise<CollabContext> {
2528
+ if (!isCollabRequestOriginAllowed({
2529
+ origin: readHeader(data.requestHeaders, 'origin'),
2530
+ allowedOrigins: deps.allowedOrigins,
2531
+ requireOrigin: deps.requireOrigin ?? process.env.NODE_ENV === 'production',
2532
+ })) {
2533
+ throw new Error('[internal] documents collab: origin not allowed')
2534
+ }
2535
+
2536
+ const context = resolveCollabClaims(deps, data.token ?? '')
2537
+ if (!context) {
2538
+ throw new Error('[internal] documents collab: invalid token')
2539
+ }
2540
+ if (context.documentId !== data.documentName) {
2541
+ throw new Error('[internal] documents collab: room mismatch')
2542
+ }
2543
+ const authorizationTicket = deps.finalDrainRegistry
2544
+ ?.beginAuthorization(data.documentName, {
2545
+ tenantId: context.tenantId,
2546
+ organizationId: context.organizationId,
2547
+ })
2548
+ let retainAuthorizationTicket = false
2549
+ try {
2550
+ assertRoomAcceptsAuthentication(data.documentName)
2551
+ if (!(await deps.authorizeContext(context))) {
2552
+ throw new Error('[internal] documents collab: stale or revoked token')
2553
+ }
2554
+
2555
+ const awarenessName = await deps.resolveAwarenessName?.(context)
2556
+ // Label resolution is another async boundary and the old room can even
2557
+ // unmap while it is in flight. Re-resolve live authorization after it,
2558
+ // then synchronously verify the bounded event ticket and exact mapped
2559
+ // room tombstone before admitting the connection.
2560
+ if (!(await deps.authorizeContext(context))) {
2561
+ throw new Error('[internal] documents collab: stale or revoked token')
2562
+ }
2563
+ if (
2564
+ authorizationTicket
2565
+ && !deps.finalDrainRegistry?.isAuthorizationCurrent(authorizationTicket)
2566
+ ) {
2567
+ throw new Error('[internal] documents collab: access changed during authentication')
2568
+ }
2569
+ assertRoomAcceptsAuthentication(data.documentName)
2570
+ context.awarenessUser = createCanonicalCollaborationAwarenessUser(
2571
+ context.userId,
2572
+ awarenessName,
2573
+ )
2574
+ data.connection.readOnly = context.readOnly
2575
+ if (authorizationTicket) {
2576
+ (context as TicketedCollabContext)[COLLAB_AUTHORIZATION_LIFECYCLE] = {
2577
+ established: false,
2578
+ ticket: authorizationTicket,
2579
+ }
2580
+ retainAuthorizationTicket = true
2581
+ }
2582
+ return context
2583
+ } finally {
2584
+ if (authorizationTicket && !retainAuthorizationTicket) {
2585
+ deps.finalDrainRegistry?.endAuthorization(authorizationTicket)
2586
+ }
2587
+ }
2588
+ },
2589
+
2590
+ async beforeHandleAwareness(data: {
2591
+ context?: CollabContext
2592
+ states: Map<number, Record<string, unknown>>
2593
+ ownedClientIds?: ReadonlySet<number>
2594
+ occupiedClientIds?: ReadonlySet<number>
2595
+ connection?: object
2596
+ document?: Y.Doc
2597
+ }): Promise<void> {
2598
+ let claimedClientIds: Set<number> | undefined
2599
+ if (data.connection) {
2600
+ claimedClientIds = awarenessConnectionClientIds.get(data.connection)
2601
+ if (!claimedClientIds) {
2602
+ claimedClientIds = new Set()
2603
+ awarenessConnectionClientIds.set(data.connection, claimedClientIds)
2604
+ }
2605
+ }
2606
+ let roomClientOwners: Map<number, string> | undefined
2607
+ if (data.document) {
2608
+ roomClientOwners = awarenessRoomClientOwners.get(data.document)
2609
+ if (!roomClientOwners) {
2610
+ roomClientOwners = new Map()
2611
+ awarenessRoomClientOwners.set(data.document, roomClientOwners)
2612
+ }
2613
+ }
2614
+ bindCollabAwarenessStates(
2615
+ data.context,
2616
+ data.states,
2617
+ data.ownedClientIds && data.occupiedClientIds
2618
+ ? {
2619
+ ownedClientIds: data.ownedClientIds,
2620
+ occupiedClientIds: data.occupiedClientIds,
2621
+ claimedClientIds,
2622
+ roomClientOwners,
2623
+ }
2624
+ : undefined,
2625
+ )
2626
+ },
2627
+
2628
+ async onLoadDocument(data: {
2629
+ documentName: string
2630
+ context: CollabContext
2631
+ document: Y.Doc
2632
+ }): Promise<Y.Doc> {
2633
+ assertScopedContext(data.context)
2634
+ roomScopes.set(data.document, {
2635
+ tenantId: data.context.tenantId,
2636
+ organizationId: data.context.organizationId,
2637
+ })
2638
+ rememberWritableContext(data.document, data.context)
2639
+
2640
+ const container = await deps.resolveContainer()
2641
+ const em = container.resolve('em')
2642
+ const scope = {
2643
+ tenantId: data.context.tenantId,
2644
+ organizationId: data.context.organizationId,
2645
+ }
2646
+ let content = await deps.loadContent(em, data.documentName, scope)
2647
+ if (!content) {
2648
+ // Pre-M6 document creation was a two-request flow, so durable legacy
2649
+ // rows can legitimately exist without DocumentContent. Repair that
2650
+ // representation under the aggregate lock, then load its CAS version.
2651
+ await deps.initializeYjsState(em, data.documentName, scope)
2652
+ content = await deps.loadContent(em, data.documentName, scope)
2653
+ }
2654
+ const loadedVersion = normalizeContentVersion(content?.updatedAt)
2655
+ if (!loadedVersion) {
2656
+ throw new Error('[internal] documents collab: content row has no durable version')
2657
+ }
2658
+ if (content?.yjsState && content.yjsState.length > 0) {
2659
+ assertDocumentContentResourceLimits({
2660
+ yjsState: content.yjsState,
2661
+ contentHtml: content.contentHtml,
2662
+ })
2663
+ Y.applyUpdate(data.document, new Uint8Array(content.yjsState))
2664
+ roomResourceBudgets.set(data.document, {
2665
+ bytes: content.yjsState.byteLength,
2666
+ revision: 0,
2667
+ })
2668
+ } else if (content?.contentHtml) {
2669
+ assertDocumentContentResourceLimits({ contentHtml: content.contentHtml })
2670
+ const initializedState = await deps.initializeYjsState(
2671
+ em,
2672
+ data.documentName,
2673
+ scope,
2674
+ )
2675
+ if (initializedState && initializedState.length > 0) {
2676
+ Y.applyUpdate(data.document, new Uint8Array(initializedState))
2677
+ roomResourceBudgets.set(data.document, {
2678
+ bytes: initializedState.byteLength,
2679
+ revision: 0,
2680
+ })
2681
+ }
2682
+ }
2683
+ if (!roomResourceBudgets.has(data.document)) {
2684
+ roomResourceBudgets.set(data.document, { bytes: 0, revision: 0 })
2685
+ }
2686
+
2687
+ const collaborationGeneration = normalizeDocumentCollaborationGeneration(
2688
+ content?.collaborationGeneration,
2689
+ )
2690
+ if (collaborationGeneration === null) {
2691
+ throw new Error('[internal] documents collab: content row has no durable collaboration generation')
2692
+ }
2693
+ loadedContentVersions.set(data.document, loadedVersion)
2694
+ loadedCollaborationGenerations.set(data.document, collaborationGeneration)
2695
+ return data.document
2696
+ },
2697
+
2698
+ async beforeSync(data: {
2699
+ type: number
2700
+ payload: Uint8Array
2701
+ document: Y.Doc
2702
+ connection: { readOnly: boolean; close?: () => void }
2703
+ context?: CollabContext
2704
+ }): Promise<void> {
2705
+ // SyncStep1 contains only a state vector. Read-only SyncStep2/updates are
2706
+ // dropped by Hocuspocus and must not consume the writable room budget.
2707
+ if (data.connection.readOnly || (data.type !== 1 && data.type !== 2)) return
2708
+ // Revalidate the exact writer before Hocuspocus applies the frame. The
2709
+ // periodic connection refresh protects idle sockets, but it leaves a
2710
+ // bounded revocation window in which an update could otherwise enter the
2711
+ // shared Y.Doc and later be persisted under another writer's context.
2712
+ // MessageReceiver awaits beforeSync before readSyncStep2/readUpdate, so a
2713
+ // rejected frame never reaches local peers or Redis.
2714
+ let authorized = false
2715
+ if (data.context && !isInvalidated(data.context.documentId, data.document)) {
2716
+ try {
2717
+ authorized = await deps.authorizeContext(data.context)
2718
+ } catch {
2719
+ authorized = false
2720
+ }
2721
+ }
2722
+ if (
2723
+ !authorized
2724
+ || !data.context
2725
+ || isInvalidated(data.context.documentId, data.document)
2726
+ || isFinalDrainPending(data.context.documentId)
2727
+ ) {
2728
+ data.connection.close?.()
2729
+ if (data.context) {
2730
+ invalidateStoreRoom({
2731
+ documentName: data.context.documentId,
2732
+ document: data.document,
2733
+ })
2734
+ }
2735
+ throw new Error('[internal] documents collab: write authorization is no longer current')
2736
+ }
2737
+ rememberWritableContext(data.document, data.context)
2738
+ const budget = roomResourceBudgets.get(data.document) ?? { bytes: 0, revision: 0 }
2739
+ const nextBytes = budget.bytes + data.payload.byteLength
2740
+ assertDocumentYjsStateByteLength(nextBytes)
2741
+ budget.bytes = nextBytes
2742
+ budget.revision += 1
2743
+ roomResourceBudgets.set(data.document, budget)
2744
+ },
2745
+
2746
+ async onStoreDocument(data: {
2747
+ documentName: string
2748
+ context: CollabContext
2749
+ document: Y.Doc
2750
+ }): Promise<void> {
2751
+ try {
2752
+ assertScopedContext(data.context)
2753
+ } catch (error) {
2754
+ if (retireFailedStore(data)) return
2755
+ throw error
2756
+ }
2757
+ if (isInvalidated(data.documentName, data.document)) return
2758
+ if (data.context.readOnly) {
2759
+ // Edits retained by this room were necessarily authored by a writable
2760
+ // connection: read-only sync frames are dropped before they reach the
2761
+ // Y.Doc. Fall back to the room's last writable authorization so a
2762
+ // viewer being the last-seen context cannot silently drop the
2763
+ // debounced store; authorizeStoreAttempt below re-validates that
2764
+ // context's live access before anything is persisted. A room with no
2765
+ // recorded writable context has nothing a viewer could have authored.
2766
+ const writableContext = roomWritableContexts.get(data.document)
2767
+ if (!writableContext) {
2768
+ retireFailedStore(data)
2769
+ return
2770
+ }
2771
+ data = { ...data, context: writableContext }
2772
+ } else {
2773
+ rememberWritableContext(data.document, data.context)
2774
+ }
2775
+
2776
+ let expectedUpdatedAt = loadedContentVersions.get(data.document)
2777
+ let collaborationGeneration = loadedCollaborationGenerations.get(data.document)
2778
+ if (!expectedUpdatedAt || collaborationGeneration === undefined) {
2779
+ const error = new Error('[internal] documents collab: room store has no loaded content version')
2780
+ if (retireFailedStore(data)) return
2781
+ throw error
2782
+ }
2783
+ let em: unknown
2784
+ let searchIndexer: unknown = null
2785
+ try {
2786
+ const container = await deps.resolveContainer()
2787
+ em = container.resolve('em')
2788
+ try {
2789
+ searchIndexer = container.resolve('searchIndexer')
2790
+ } catch {
2791
+ searchIndexer = null
2792
+ }
2793
+ } catch (error) {
2794
+ if (retireFailedStore(data)) return
2795
+ throw error
2796
+ }
2797
+ const scope = {
2798
+ tenantId: data.context.tenantId,
2799
+ organizationId: data.context.organizationId,
2800
+ }
2801
+ let finalDrainAuthorized = false
2802
+
2803
+ try {
2804
+ for (let attempt = 0; attempt < MAX_COLLAB_STORE_ATTEMPTS; attempt += 1) {
2805
+ if (isInvalidated(data.documentName, data.document)) {
2806
+ if (finalDrainAuthorized) deps.finalDrainRegistry?.discard(data.document)
2807
+ return
2808
+ }
2809
+ if (!finalDrainAuthorized) {
2810
+ // Re-resolve ACL, organization scope, relationship tier/share, and
2811
+ // edit capability immediately before every normal persistence
2812
+ // attempt, including every multi-replica CAS retry. The sole
2813
+ // exception is a one-shot trusted share-event drain whose sockets
2814
+ // are already gone; that consumed grant remains local to this one
2815
+ // store invocation so its bounded CAS merge can finish.
2816
+ const authorization = await authorizeStoreAttempt(data)
2817
+ if (authorization === 'denied') return
2818
+ finalDrainAuthorized = authorization === 'final-drain'
2819
+ }
2820
+
2821
+ // Authorization and a final-drain queue wait both cross async
2822
+ // boundaries. Snapshot only afterwards so every frame accepted before
2823
+ // closeConnections (and every update merged for a CAS retry) is part
2824
+ // of the exact state that becomes durable.
2825
+ if (isInvalidated(data.documentName, data.document)) {
2826
+ if (finalDrainAuthorized) deps.finalDrainRegistry?.discard(data.document)
2827
+ return
2828
+ }
2829
+ const materialized = yDocToContent(data.document)
2830
+ if (materialized) materializationWarningRooms.delete(data.documentName)
2831
+ if (!materialized && !materializationWarningRooms.has(data.documentName)) {
2832
+ materializationWarningRooms.add(data.documentName)
2833
+ logger.warn('materialization failed; preserving previous html/text', { room: data.documentName })
2834
+ }
2835
+ const yjsState = Buffer.from(Y.encodeStateAsUpdate(data.document))
2836
+ const resourceBudgetRevision = roomResourceBudgets.get(data.document)?.revision ?? 0
2837
+ assertDocumentContentResourceLimits({
2838
+ yjsState,
2839
+ contentHtml: materialized?.html,
2840
+ contentText: materialized?.text,
2841
+ })
2842
+
2843
+ let persisted: { updatedAt: string | Date; collaborationGeneration: number }
2844
+ try {
2845
+ persisted = await deps.persistContent(
2846
+ em,
2847
+ data.documentName,
2848
+ scope,
2849
+ materialized
2850
+ ? {
2851
+ yjsState,
2852
+ contentHtml: materialized.html,
2853
+ contentText: materialized.text,
2854
+ }
2855
+ : { yjsState },
2856
+ {
2857
+ searchIndexer,
2858
+ expectedUpdatedAt,
2859
+ expectedCollaborationGeneration: collaborationGeneration,
2860
+ requireExpectedVersion: true,
2861
+ },
2862
+ )
2863
+ } catch (error) {
2864
+ // An explicit replace/restore event can arrive while the CAS waits.
2865
+ // Its room marker always wins and stale content is discarded.
2866
+ if (isInvalidated(data.documentName, data.document)) {
2867
+ if (finalDrainAuthorized) deps.finalDrainRegistry?.discard(data.document)
2868
+ return
2869
+ }
2870
+ const isVersionConflict = isCrudHttpError(error)
2871
+ && error.status === 409
2872
+ && error.body.code === OPTIMISTIC_LOCK_CONFLICT_CODE
2873
+ if (!isVersionConflict) throw error
2874
+
2875
+ // A normal competing store from another sidecar replica shares the
2876
+ // same server-owned collaboration generation. Merge its
2877
+ // authoritative Yjs update into this room and retry against its new
2878
+ // version so neither replica's edit depends on a client reconnect to
2879
+ // become durable.
2880
+ const authoritative = await deps.loadContent(em, data.documentName, scope)
2881
+ const authoritativeVersion = normalizeContentVersion(authoritative?.updatedAt)
2882
+ if (!authoritativeVersion) {
2883
+ invalidateStoreRoom(data)
2884
+ return
2885
+ }
2886
+ const authoritativeGeneration = normalizeDocumentCollaborationGeneration(
2887
+ authoritative?.collaborationGeneration,
2888
+ )
2889
+ if (authoritativeGeneration === null) {
2890
+ invalidateStoreRoom(data)
2891
+ return
2892
+ }
2893
+ const authoritativeDocument = authoritative?.yjsState?.length
2894
+ ? new Y.Doc()
2895
+ : htmlToYDoc(authoritative?.contentHtml ?? '')
2896
+ if (authoritative?.yjsState?.length) {
2897
+ assertDocumentContentResourceLimits({
2898
+ yjsState: authoritative.yjsState,
2899
+ contentHtml: authoritative.contentHtml,
2900
+ })
2901
+ Y.applyUpdate(authoritativeDocument, new Uint8Array(authoritative.yjsState))
2902
+ }
2903
+ if (
2904
+ authoritativeGeneration !== collaborationGeneration
2905
+ || isInvalidated(data.documentName, data.document)
2906
+ ) {
2907
+ // A changed generation is an intentional content replacement/restore,
2908
+ // even if its event has not reached this replica yet. Never merge
2909
+ // pre-reset edits back into the new authoritative document.
2910
+ invalidateStoreRoom(data)
2911
+ return
2912
+ }
2913
+
2914
+ Y.applyUpdate(
2915
+ data.document,
2916
+ Y.encodeStateAsUpdate(authoritativeDocument),
2917
+ {
2918
+ source: 'local',
2919
+ skipStoreHooks: true,
2920
+ context: data.context,
2921
+ },
2922
+ )
2923
+ expectedUpdatedAt = authoritativeVersion
2924
+ collaborationGeneration = authoritativeGeneration
2925
+ loadedContentVersions.set(data.document, authoritativeVersion)
2926
+ loadedCollaborationGenerations.set(data.document, authoritativeGeneration)
2927
+ if (attempt + 1 >= MAX_COLLAB_STORE_ATTEMPTS) throw error
2928
+ continue
2929
+ }
2930
+
2931
+ if (isInvalidated(data.documentName, data.document)) {
2932
+ if (finalDrainAuthorized) deps.finalDrainRegistry?.discard(data.document)
2933
+ return
2934
+ }
2935
+ const persistedVersion = normalizeContentVersion(persisted.updatedAt)
2936
+ if (!persistedVersion) {
2937
+ throw new Error('[internal] documents collab: content store returned no durable version')
2938
+ }
2939
+ if (persisted.collaborationGeneration !== collaborationGeneration) {
2940
+ invalidateStoreRoom(data)
2941
+ return
2942
+ }
2943
+ loadedContentVersions.set(data.document, persistedVersion)
2944
+ loadedCollaborationGenerations.set(data.document, collaborationGeneration)
2945
+ const currentBudget = roomResourceBudgets.get(data.document)
2946
+ if (!currentBudget || currentBudget.revision === resourceBudgetRevision) {
2947
+ roomResourceBudgets.set(data.document, {
2948
+ bytes: yjsState.byteLength,
2949
+ revision: resourceBudgetRevision,
2950
+ })
2951
+ }
2952
+ deps.onPersisted?.(data.document, yjsState, collaborationGeneration)
2953
+ if (finalDrainAuthorized) deps.finalDrainRegistry?.complete(data.document)
2954
+ return
2955
+ }
2956
+ } catch (error) {
2957
+ if (isCrudHttpError(error) && error.status === 413) {
2958
+ // A Redis/CAS merge can make the complete room exceed the aggregate
2959
+ // resource limit even though each individual inbound frame passed.
2960
+ // Retire the room so the oversized state cannot be retried, fanned
2961
+ // out, or later persisted under another connection's context.
2962
+ invalidateStoreRoom(data)
2963
+ logger.warn('retiring collaboration room that exceeded content limits', {
2964
+ room: data.documentName,
2965
+ })
2966
+ return
2967
+ }
2968
+ // Ordinary store failures retain Hocuspocus' normal retry/data-loss
2969
+ // protection. A final drain cannot: with every socket already closed,
2970
+ // rethrowing makes Hocuspocus retain an invalidated mapped Y.Doc and
2971
+ // block every future authentication forever. Retire it fail-closed
2972
+ // and complete the hook normally so Hocuspocus schedules unload.
2973
+ if (retireFailedStore(data, finalDrainAuthorized)) return
2974
+ throw error
2975
+ }
2976
+ },
2977
+ }
2978
+ }
2979
+
2980
+ function headersToRecord(headers: Headers): RequestHeaders {
2981
+ return { origin: headers.get('origin') ?? undefined }
2982
+ }
2983
+
2984
+ function eventDocumentId(payload: unknown): string | null {
2985
+ if (!payload || typeof payload !== 'object') return null
2986
+ const record = payload as Record<string, unknown>
2987
+ const id = record.documentId ?? record.id
2988
+ return typeof id === 'string' && id.length > 0 ? id : null
2989
+ }
2990
+
2991
+ type CollabRoomConnectionRegistry = {
2992
+ documents: Map<string, { connections: Map<{ close: (event?: { code: number; reason: string }) => void }, unknown> }>
2993
+ }
2994
+
2995
+ /**
2996
+ * A content-replaced room must not be rejoined by the same browser Y.Doc:
2997
+ * Hocuspocus' plain `closeConnections` reset makes the provider reconnect with
2998
+ * its local document, whose pre-replacement state would sync straight back
2999
+ * into the freshly loaded room and undo the restore. Close these connections
3000
+ * with the dedicated reason the browser resets its document on instead.
3001
+ */
3002
+ export function closeCollabRoomConnectionsForContentReset(
3003
+ registry: CollabRoomConnectionRegistry,
3004
+ documentName: string,
3005
+ ): number {
3006
+ const room = registry.documents.get(documentName)
3007
+ if (!room) return 0
3008
+ let closed = 0
3009
+ room.connections.forEach((_clients, connection) => {
3010
+ connection.close(COLLAB_CONTENT_RESET_CLOSE_EVENT)
3011
+ closed += 1
3012
+ })
3013
+ return closed
3014
+ }
3015
+
3016
+ export type CollabRoomEventAction = 'ignore' | 'reauth' | 'invalidate'
3017
+
3018
+ /**
3019
+ * Access changes only require sockets to re-authenticate. Suppressing a store
3020
+ * for those events can drop edits made shortly after a share is created or
3021
+ * changed. Content-invalidating events must additionally prevent the closing
3022
+ * room's stale Y.Doc from overwriting the authoritative database state.
3023
+ */
3024
+ export function resolveCollabRoomEventAction(event: string, payload?: unknown): CollabRoomEventAction {
3025
+ if (
3026
+ event === 'documents.document.shared'
3027
+ || event === 'documents.document.unshared'
3028
+ || event === 'documents.document.archived'
3029
+ || event === 'documents.document.unarchived'
3030
+ ) {
3031
+ return 'reauth'
3032
+ }
3033
+ const record = payload && typeof payload === 'object' ? payload as Record<string, unknown> : null
3034
+ if (
3035
+ event === 'documents.document.deleted'
3036
+ || event === 'documents.version.restored'
3037
+ || (event === 'documents.document.updated' && record?.contentEpochReset === true)
3038
+ ) {
3039
+ return 'invalidate'
3040
+ }
3041
+ return 'ignore'
3042
+ }
3043
+
3044
+ type DocumentsCrossProcessEventEnvelope = {
3045
+ event: string
3046
+ payload: unknown
3047
+ options?: {
3048
+ tenantId?: unknown
3049
+ organizationId?: unknown
3050
+ emitterModuleId?: unknown
3051
+ }
3052
+ originPid?: unknown
3053
+ originInstanceId?: unknown
3054
+ }
3055
+
3056
+ export type TrustedDocumentsCrossProcessEvent = {
3057
+ action: Exclude<CollabRoomEventAction, 'ignore'>
3058
+ documentId: string
3059
+ scope: CollabScope
3060
+ }
3061
+
3062
+ function nonEmptyString(value: unknown): string | null {
3063
+ return typeof value === 'string' && value.trim().length > 0 ? value : null
3064
+ }
3065
+
3066
+ /**
3067
+ * Resolve only private Documents events stamped by the Documents module and
3068
+ * carrying trusted envelope scope. Payload scope is intentionally ignored: it
3069
+ * is application data and can be authored by a workflow.
3070
+ */
3071
+ export function resolveTrustedDocumentsCrossProcessEvent(
3072
+ envelope: DocumentsCrossProcessEventEnvelope,
3073
+ ): TrustedDocumentsCrossProcessEvent | null {
3074
+ if (envelope.options?.emitterModuleId !== 'documents') return null
3075
+ const tenantId = nonEmptyString(envelope.options?.tenantId)
3076
+ const organizationId = nonEmptyString(envelope.options?.organizationId)
3077
+ if (!tenantId || !organizationId) return null
3078
+ const action = resolveCollabRoomEventAction(envelope.event, envelope.payload)
3079
+ if (action === 'ignore') return null
3080
+ const documentId = eventDocumentId(envelope.payload)
3081
+ if (!documentId) return null
3082
+ return {
3083
+ action,
3084
+ documentId,
3085
+ scope: { tenantId, organizationId },
3086
+ }
3087
+ }
3088
+
3089
+ export function isTrustedDocumentsCollabRoomScope(
3090
+ event: TrustedDocumentsCrossProcessEvent,
3091
+ roomScope: CollabScope | null,
3092
+ ): boolean {
3093
+ return roomScope?.tenantId === event.scope.tenantId
3094
+ && roomScope.organizationId === event.scope.organizationId
3095
+ }
3096
+
3097
+ /**
3098
+ * The Events bridge stamps every envelope this sidecar publishes with its
3099
+ * random per-process instance id, so an envelope without one is provably
3100
+ * foreign. Falling back to originPid would discard it whenever another
3101
+ * container happens to share our pid — commonly pid 1 — silently dropping
3102
+ * reauth and invalidation across a rolling deploy.
3103
+ */
3104
+ export function isOwnDocumentsCrossProcessEvent(
3105
+ envelope: DocumentsCrossProcessEventEnvelope,
3106
+ ownInstanceId: string,
3107
+ ): boolean {
3108
+ return envelope.originInstanceId === ownInstanceId
3109
+ }
3110
+
3111
+ function isMainModule(): boolean {
3112
+ const entry = process.argv[1]
3113
+ return Boolean(entry && import.meta.url === pathToFileURL(resolve(entry)).href)
3114
+ }
3115
+
3116
+ export async function main(): Promise<void> {
3117
+ const [
3118
+ { Connection, Server },
3119
+ { bootstrapFromAppRoot },
3120
+ { createRequestContainer },
3121
+ { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID },
3122
+ ] = await Promise.all([
3123
+ import('@hocuspocus/server'),
3124
+ import('@open-mercato/shared/lib/bootstrap/dynamicLoader'),
3125
+ import('@open-mercato/shared/lib/di/container'),
3126
+ import('@open-mercato/events'),
3127
+ ])
3128
+
3129
+ installHocuspocusCollabIngressGuard(Connection)
3130
+
3131
+ const appRoot = process.env.DOCUMENTS_COLLAB_APP_ROOT || undefined
3132
+ await bootstrapFromAppRoot(appRoot)
3133
+
3134
+ const port = Number(process.env.DOCUMENTS_COLLAB_PORT || 4101)
3135
+ const allowedOrigins = resolveCollabAllowedOrigins(process.env)
3136
+ // Invalidated room identities stay marked for their whole lifetime. The
3137
+ // WeakSet releases them only after Hocuspocus unloads/destroys the Y.Doc;
3138
+ // unlike a timer, a slow debounced/in-flight store can never become writable
3139
+ // again merely because cleanup took longer than expected.
3140
+ const invalidatedRoomDocuments = new WeakSet<Y.Doc>()
3141
+ const finalDrainRegistry = createCollabFinalDrainRegistry()
3142
+ let server: HocuspocusServer<CollabContext> | null = null
3143
+ let redisStoreExtension: DocumentsCollabRedisExtension | null = null
3144
+ const closeRoomConnectionsForContentReset = (documentName: string): void => {
3145
+ if (server) closeCollabRoomConnectionsForContentReset(server.hocuspocus, documentName)
3146
+ }
3147
+ const invalidateRoom = (documentName: string, document: Y.Doc): void => {
3148
+ redisStoreExtension?.discardPendingFanout(documentName, document)
3149
+ finalDrainRegistry.discard(document)
3150
+ invalidatedRoomDocuments.add(document)
3151
+ closeRoomConnectionsForContentReset(documentName)
3152
+ }
3153
+ const legacyTokenVerifier = resolveLegacyCollabTokenVerifier(process.env)
3154
+ const hooks = createCollabHooks({
3155
+ ...(legacyTokenVerifier ? { verifyToken: legacyTokenVerifier } : {}),
3156
+ verifyTokenV2: verifyCollabTokenV2,
3157
+ authorizeContext: async (context) => {
3158
+ const container = await createRequestContainer()
3159
+ return authorizeCollabContext(container, context)
3160
+ },
3161
+ resolveAwarenessName: async (context) => {
3162
+ const container = await createRequestContainer()
3163
+ const labels = await resolveUserLabels(
3164
+ container,
3165
+ {
3166
+ tenantId: context.tenantId,
3167
+ organizationId: context.organizationId,
3168
+ },
3169
+ [context.userId],
3170
+ )
3171
+ return labels.get(context.userId)?.label ?? null
3172
+ },
3173
+ resolveContainer: () => createRequestContainer(),
3174
+ loadContent: async (em, id, scope) => {
3175
+ const content = await loadDocumentContentForCollaboration(
3176
+ em as EntityManager,
3177
+ id,
3178
+ scope,
3179
+ )
3180
+ if (!content) return null
3181
+ return {
3182
+ yjsState: content.yjsState ?? null,
3183
+ contentHtml: content.contentHtml ?? null,
3184
+ updatedAt: content.updatedAt,
3185
+ collaborationGeneration: content.collaborationGeneration,
3186
+ }
3187
+ },
3188
+ loadCollaborationGeneration: (em, id, scope) => loadDocumentCollaborationGeneration(
3189
+ em as EntityManager,
3190
+ id,
3191
+ scope,
3192
+ ),
3193
+ initializeYjsState: (em, id, scope) => initializeDocumentYjsState(
3194
+ em as EntityManager,
3195
+ id,
3196
+ scope,
3197
+ ),
3198
+ persistContent: (em, id, scope, input, serviceDeps) => persistDocumentContent(
3199
+ em as EntityManager,
3200
+ id,
3201
+ scope,
3202
+ input,
3203
+ serviceDeps as PersistDocumentContentDeps,
3204
+ ),
3205
+ allowedOrigins,
3206
+ requireOrigin: process.env.NODE_ENV === 'production',
3207
+ onPersisted: (document, yjsState, collaborationGeneration) => {
3208
+ redisStoreExtension?.markPersisted(document, yjsState, collaborationGeneration)
3209
+ },
3210
+ finalDrainRegistry,
3211
+ resolveRoomDocument: (documentName) => server?.hocuspocus.documents.get(documentName),
3212
+ isRoomInvalidated: (documentName, document) => {
3213
+ const room = document ?? server?.hocuspocus.documents.get(documentName)
3214
+ return Boolean(room && invalidatedRoomDocuments.has(room))
3215
+ },
3216
+ invalidateRoom,
3217
+ })
3218
+
3219
+ const runningServer: HocuspocusServer<CollabContext> = new Server<CollabContext>({
3220
+ port,
3221
+ ...COLLAB_SERVER_RUNTIME_CONFIGURATION,
3222
+ extensions: resolveDocumentsCollabRedisExtensions(
3223
+ process.env,
3224
+ (configuration) => {
3225
+ redisStoreExtension = new DocumentsCollabRedisExtension(configuration, {
3226
+ resolveCollaborationGeneration: hooks.resolveCollaborationGeneration,
3227
+ onRejectedAggregate: invalidateRoom,
3228
+ onAcceptedAggregate: (document, byteLength) => {
3229
+ hooks.recordRedisAggregate(document, byteLength)
3230
+ },
3231
+ })
3232
+ return enforceDocumentsCollabSourceStoreOwnership(redisStoreExtension)
3233
+ },
3234
+ ),
3235
+ async onAuthenticate(data: onAuthenticatePayload<CollabContext>) {
3236
+ return await hooks.onAuthenticate({
3237
+ token: data.token,
3238
+ documentName: data.documentName,
3239
+ connection: data.connectionConfig,
3240
+ requestHeaders: headersToRecord(data.requestHeaders),
3241
+ })
3242
+ },
3243
+ async connected(data: connectedPayload<CollabContext>) {
3244
+ try {
3245
+ hooks.establishConnectionAuthorization(data.context)
3246
+ } catch (error) {
3247
+ data.connection.close()
3248
+ hooks.releaseConnectionAuthorization(data.context)
3249
+ throw error
3250
+ }
3251
+ if (data.context.exp !== null) {
3252
+ scheduleCollabConnectionExpiry(data.connection, data.context.exp)
3253
+ }
3254
+ scheduleCollabConnectionReauthorization(
3255
+ data.connection,
3256
+ () => hooks.reauthorizeActiveConnection(data.context),
3257
+ )
3258
+ },
3259
+ async beforeHandleMessage(data: beforeHandleMessagePayload<CollabContext>) {
3260
+ try {
3261
+ hooks.assertConnectionAuthorization(data.context)
3262
+ } catch (error) {
3263
+ data.connection.close()
3264
+ hooks.releaseConnectionAuthorization(data.context)
3265
+ throw error
3266
+ }
3267
+ assertCollabInboundFramePolicy(data.update, {
3268
+ readOnly: data.connection.readOnly,
3269
+ })
3270
+ },
3271
+ async beforeHandleAwareness(data: beforeHandleAwarenessPayload<CollabContext>) {
3272
+ // Redis replication has no browser connection/context. Its awareness
3273
+ // payload was already authenticated, size-bounded, and canonicalized at
3274
+ // the source replica, so only client-originated frames enter the
3275
+ // per-connection identity guard below.
3276
+ if (!data.connection) return
3277
+ return hooks.beforeHandleAwareness({
3278
+ context: data.context,
3279
+ states: data.states,
3280
+ ownedClientIds: new Set(data.document.getClients(data.connection)),
3281
+ occupiedClientIds: new Set(data.awareness.getStates().keys()),
3282
+ connection: data.connection,
3283
+ document: data.document,
3284
+ })
3285
+ },
3286
+ async beforeSync(data: beforeSyncPayload<CollabContext>) {
3287
+ return hooks.beforeSync({
3288
+ type: data.type,
3289
+ payload: data.payload,
3290
+ document: data.document,
3291
+ connection: data.connection,
3292
+ context: data.context,
3293
+ })
3294
+ },
3295
+ async onLoadDocument(data: onLoadDocumentPayload<CollabContext>) {
3296
+ try {
3297
+ hooks.assertConnectionAuthorization(data.context)
3298
+ const document = await hooks.onLoadDocument({
3299
+ documentName: data.documentName,
3300
+ context: data.context,
3301
+ document: data.document,
3302
+ })
3303
+ hooks.assertConnectionAuthorization(data.context)
3304
+ return document
3305
+ } catch (error) {
3306
+ hooks.releaseConnectionAuthorization(data.context)
3307
+ // Hocuspocus has not mapped this freshly created Document yet, so its
3308
+ // unload helper cannot destroy the Y.Doc on a load-hook failure.
3309
+ data.document.destroy()
3310
+ throw error
3311
+ }
3312
+ },
3313
+ async afterLoadDocument(data: afterLoadDocumentPayload<CollabContext>) {
3314
+ try {
3315
+ hooks.assertConnectionAuthorization(data.context)
3316
+ } catch (error) {
3317
+ hooks.releaseConnectionAuthorization(data.context)
3318
+ data.document.destroy()
3319
+ throw error
3320
+ }
3321
+ },
3322
+ async onStoreDocument(data: onStoreDocumentPayload<CollabContext>) {
3323
+ if (!isDocumentsCollabSourceStore(data)) return
3324
+ return await hooks.onStoreDocument({
3325
+ documentName: data.documentName,
3326
+ context: data.lastContext,
3327
+ document: data.document,
3328
+ })
3329
+ },
3330
+ async onRequest(data: onRequestPayload) {
3331
+ return handleCollabServerRequest(data)
3332
+ },
3333
+ async onDisconnect(data: onDisconnectPayload<CollabContext>) {
3334
+ hooks.releaseConnectionAuthorization(data.context)
3335
+ },
3336
+ })
3337
+ server = runningServer
3338
+
3339
+ await runningServer.listen()
3340
+
3341
+ registerCrossProcessEventListener((envelope: DocumentsCrossProcessEventEnvelope) => {
3342
+ if (isOwnDocumentsCrossProcessEvent(envelope, CROSS_PROCESS_EVENT_INSTANCE_ID)) return
3343
+ const trustedEvent = resolveTrustedDocumentsCrossProcessEvent(envelope)
3344
+ if (!trustedEvent) return
3345
+ const { action, documentId, scope } = trustedEvent
3346
+
3347
+ // Bump the exact scoped handshake epoch before consulting live room
3348
+ // metadata. A room can be mapped during the narrow load transition before
3349
+ // its scope WeakMap is observable here; scoped tickets still let the event
3350
+ // reject that in-flight authentication without touching another tenant.
3351
+ finalDrainRegistry.bumpAuthorization(documentId, scope)
3352
+ const roomDocument = runningServer.hocuspocus.documents.get(documentId)
3353
+ if (
3354
+ roomDocument
3355
+ && !isTrustedDocumentsCollabRoomScope(trustedEvent, hooks.resolveRoomScope(roomDocument))
3356
+ ) {
3357
+ return
3358
+ }
3359
+ if (roomDocument && action === 'reauth') {
3360
+ // Capture every exact connection queue before closeConnections removes
3361
+ // those logical connections synchronously. The one-shot store cannot
3362
+ // snapshot or unblock reconnects until all captured queues have drained.
3363
+ markCollabFinalDrainForReauth(roomDocument, finalDrainRegistry)
3364
+ }
3365
+ if (action === 'invalidate') {
3366
+ // A durable fanout intentionally survives ordinary unload, so discard by
3367
+ // room name even when no Y.Doc is currently mapped. When one is mapped,
3368
+ // also tombstone that exact identity so a store already releasing its
3369
+ // lock cannot enqueue the pre-invalidation generation afterwards.
3370
+ redisStoreExtension?.discardPendingFanout(documentId)
3371
+ if (roomDocument) {
3372
+ redisStoreExtension?.discardPendingFanout(documentId, roomDocument)
3373
+ finalDrainRegistry.discard(roomDocument)
3374
+ invalidatedRoomDocuments.add(roomDocument)
3375
+ }
3376
+ closeRoomConnectionsForContentReset(documentId)
3377
+ return
3378
+ }
3379
+ runningServer.hocuspocus.closeConnections(documentId)
3380
+ })
3381
+
3382
+ // Hocuspocus debounces onStoreDocument, so an unhandled SIGTERM/SIGINT
3383
+ // (docker stop, redeploy) would drop the last seconds of edits. destroy()
3384
+ // unloads every document, which drains pending stores before the process
3385
+ // exits.
3386
+ let shuttingDown = false
3387
+ const shutdown = (signal: NodeJS.Signals): void => {
3388
+ if (shuttingDown) return
3389
+ shuttingDown = true
3390
+ logger.info('shutting down; draining pending document stores', { signal })
3391
+ void runningServer
3392
+ .destroy()
3393
+ .catch((error: unknown) => {
3394
+ logger.error('graceful shutdown failed', {
3395
+ error: error instanceof Error ? error.message : String(error),
3396
+ })
3397
+ process.exitCode = 1
3398
+ })
3399
+ .finally(() => {
3400
+ process.exit(process.exitCode ?? 0)
3401
+ })
3402
+ }
3403
+ process.once('SIGTERM', shutdown)
3404
+ process.once('SIGINT', shutdown)
3405
+
3406
+ logger.info(`listening on :${port}`)
3407
+ }
3408
+
3409
+ if (process.env.DOCUMENTS_COLLAB_START !== 'off' && isMainModule()) {
3410
+ void main().catch((error: unknown) => {
3411
+ logger.error('failed to start', {
3412
+ error: error instanceof Error ? (error.stack ?? error.message) : String(error),
3413
+ })
3414
+ process.exitCode = 1
3415
+ })
3416
+ }