@byline/host-tanstack-start 4.19.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/dist/admin-shell/chrome/admin-app-bar.d.ts +7 -0
  2. package/dist/admin-shell/chrome/admin-app-bar.js +10 -2
  3. package/dist/admin-shell/chrome/session-change-boundary.d.ts +14 -0
  4. package/dist/admin-shell/chrome/session-change-boundary.js +87 -0
  5. package/dist/admin-shell/chrome/session-change-boundary.test.d.ts +8 -0
  6. package/dist/admin-shell/chrome/sign-in-page.d.ts +2 -1
  7. package/dist/admin-shell/chrome/sign-in-page.js +60 -3
  8. package/dist/admin-shell/collections/edit.d.ts +1 -1
  9. package/dist/admin-shell/collections/edit.js +151 -31
  10. package/dist/admin-shell/collections/history.js +4 -2
  11. package/dist/admin-shell/collections/list.js +30 -6
  12. package/dist/admin-shell/collections/restore-version-modal.d.ts +3 -1
  13. package/dist/admin-shell/collections/restore-version-modal.js +9 -4
  14. package/dist/admin-shell/collections/tree-list.js +16 -6
  15. package/dist/admin-shell/collections/version-history.d.ts +2 -1
  16. package/dist/admin-shell/collections/version-history.js +11 -2
  17. package/dist/admin-shell/document-mutation-notice.d.ts +6 -0
  18. package/dist/admin-shell/document-mutation-notice.js +53 -0
  19. package/dist/admin-shell/document-mutation-state.d.ts +22 -0
  20. package/dist/admin-shell/document-mutation-state.js +71 -0
  21. package/dist/admin-shell/document-mutation-state.test.d.ts +1 -0
  22. package/dist/admin-shell/scheduled-publications/list.js +15 -5
  23. package/dist/admin-shell/singletons/history.js +4 -2
  24. package/dist/admin-shell/singletons/view.d.ts +1 -1
  25. package/dist/admin-shell/singletons/view.js +98 -19
  26. package/dist/integrations/byline-admin-services.js +7 -1
  27. package/dist/integrations/byline-field-services.js +2 -9
  28. package/dist/integrations/client-ip.d.ts +19 -0
  29. package/dist/integrations/client-ip.js +34 -0
  30. package/dist/integrations/session-coordination.d.ts +24 -0
  31. package/dist/integrations/session-coordination.js +85 -0
  32. package/dist/integrations/session-middleware.d.ts +11 -0
  33. package/dist/integrations/session-middleware.js +94 -0
  34. package/dist/integrations/session-request-kind.d.ts +9 -0
  35. package/dist/integrations/session-request-kind.js +10 -0
  36. package/dist/integrations/session-request-middleware.d.ts +9 -0
  37. package/dist/integrations/session-request-middleware.js +7 -0
  38. package/dist/integrations/sign-in-body.d.ts +12 -0
  39. package/dist/integrations/sign-in-body.js +52 -0
  40. package/dist/integrations/sign-in-middleware.d.ts +9 -0
  41. package/dist/integrations/sign-in-middleware.js +10 -0
  42. package/dist/routes/create-admin-layout-route.js +46 -31
  43. package/dist/routes/create-collection-edit-route.js +1 -1
  44. package/dist/routes/create-collection-list-route.d.ts +0 -7
  45. package/dist/routes/create-collection-list-route.js +7 -9
  46. package/dist/routes/create-sign-in-route.js +4 -2
  47. package/dist/routes/create-singleton-route.d.ts +2 -0
  48. package/dist/routes/create-singleton-route.js +4 -3
  49. package/dist/server-fns/admin-account/change-password.d.ts +3 -1
  50. package/dist/server-fns/admin-account/change-password.js +9 -3
  51. package/dist/server-fns/admin-account/get.d.ts +3 -1
  52. package/dist/server-fns/admin-account/get.js +4 -1
  53. package/dist/server-fns/admin-account/index.d.ts +5 -5
  54. package/dist/server-fns/admin-account/update.d.ts +3 -1
  55. package/dist/server-fns/admin-account/update.js +4 -1
  56. package/dist/server-fns/admin-activity/get.d.ts +3 -1
  57. package/dist/server-fns/admin-activity/get.js +4 -1
  58. package/dist/server-fns/admin-permissions/get-role-abilities.d.ts +3 -1
  59. package/dist/server-fns/admin-permissions/get-role-abilities.js +4 -1
  60. package/dist/server-fns/admin-permissions/index.d.ts +4 -4
  61. package/dist/server-fns/admin-permissions/list-registered.d.ts +3 -1
  62. package/dist/server-fns/admin-permissions/list-registered.js +4 -1
  63. package/dist/server-fns/admin-permissions/set-role-abilities.d.ts +3 -1
  64. package/dist/server-fns/admin-permissions/set-role-abilities.js +4 -1
  65. package/dist/server-fns/admin-permissions/who-has.d.ts +3 -1
  66. package/dist/server-fns/admin-permissions/who-has.js +4 -1
  67. package/dist/server-fns/admin-roles/create.d.ts +3 -1
  68. package/dist/server-fns/admin-roles/create.js +4 -1
  69. package/dist/server-fns/admin-roles/delete.d.ts +3 -1
  70. package/dist/server-fns/admin-roles/delete.js +4 -1
  71. package/dist/server-fns/admin-roles/get.d.ts +3 -1
  72. package/dist/server-fns/admin-roles/get.js +4 -1
  73. package/dist/server-fns/admin-roles/index.d.ts +6 -6
  74. package/dist/server-fns/admin-roles/list.d.ts +3 -1
  75. package/dist/server-fns/admin-roles/list.js +4 -1
  76. package/dist/server-fns/admin-roles/reorder.d.ts +3 -1
  77. package/dist/server-fns/admin-roles/reorder.js +4 -1
  78. package/dist/server-fns/admin-roles/update.d.ts +3 -1
  79. package/dist/server-fns/admin-roles/update.js +4 -1
  80. package/dist/server-fns/admin-users/create.d.ts +3 -1
  81. package/dist/server-fns/admin-users/create.js +4 -1
  82. package/dist/server-fns/admin-users/delete.d.ts +3 -1
  83. package/dist/server-fns/admin-users/delete.js +4 -1
  84. package/dist/server-fns/admin-users/disable.d.ts +3 -1
  85. package/dist/server-fns/admin-users/disable.js +4 -1
  86. package/dist/server-fns/admin-users/enable.d.ts +3 -1
  87. package/dist/server-fns/admin-users/enable.js +4 -1
  88. package/dist/server-fns/admin-users/get-user-roles.d.ts +3 -1
  89. package/dist/server-fns/admin-users/get-user-roles.js +4 -1
  90. package/dist/server-fns/admin-users/get.d.ts +3 -1
  91. package/dist/server-fns/admin-users/get.js +4 -1
  92. package/dist/server-fns/admin-users/index.d.ts +10 -10
  93. package/dist/server-fns/admin-users/list.d.ts +3 -1
  94. package/dist/server-fns/admin-users/list.js +4 -1
  95. package/dist/server-fns/admin-users/set-password.d.ts +3 -1
  96. package/dist/server-fns/admin-users/set-password.js +4 -1
  97. package/dist/server-fns/admin-users/set-user-roles.d.ts +3 -1
  98. package/dist/server-fns/admin-users/set-user-roles.js +4 -1
  99. package/dist/server-fns/admin-users/update.d.ts +3 -1
  100. package/dist/server-fns/admin-users/update.js +4 -1
  101. package/dist/server-fns/ai/execute.d.ts +3 -1
  102. package/dist/server-fns/ai/execute.js +4 -1
  103. package/dist/server-fns/ai/index.d.ts +1 -1
  104. package/dist/server-fns/analytics/maintenance.d.ts +6 -2
  105. package/dist/server-fns/analytics/maintenance.js +7 -2
  106. package/dist/server-fns/analytics/queries.d.ts +18 -6
  107. package/dist/server-fns/analytics/queries.js +19 -6
  108. package/dist/server-fns/auth/current-user.d.ts +1 -0
  109. package/dist/server-fns/auth/current-user.js +5 -2
  110. package/dist/server-fns/auth/renew.d.ts +13 -0
  111. package/dist/server-fns/auth/renew.js +45 -0
  112. package/dist/server-fns/auth/sign-in.d.ts +2 -1
  113. package/dist/server-fns/auth/sign-in.js +88 -17
  114. package/dist/server-fns/auth/sign-out.d.ts +3 -1
  115. package/dist/server-fns/auth/sign-out.js +38 -6
  116. package/dist/server-fns/collections/audit.d.ts +3 -1
  117. package/dist/server-fns/collections/audit.js +4 -1
  118. package/dist/server-fns/collections/copy-to-locale.d.ts +5 -1
  119. package/dist/server-fns/collections/copy-to-locale.js +7 -2
  120. package/dist/server-fns/collections/create.d.ts +4 -1
  121. package/dist/server-fns/collections/create.js +8 -3
  122. package/dist/server-fns/collections/delete-locale.d.ts +5 -1
  123. package/dist/server-fns/collections/delete-locale.js +7 -2
  124. package/dist/server-fns/collections/delete-outcome.d.ts +7 -3
  125. package/dist/server-fns/collections/delete-outcome.js +4 -0
  126. package/dist/server-fns/collections/delete.d.ts +5 -1
  127. package/dist/server-fns/collections/delete.js +8 -3
  128. package/dist/server-fns/collections/duplicate.d.ts +5 -1
  129. package/dist/server-fns/collections/duplicate.js +8 -3
  130. package/dist/server-fns/collections/get.d.ts +1 -0
  131. package/dist/server-fns/collections/get.js +12 -6
  132. package/dist/server-fns/collections/history.d.ts +3 -1
  133. package/dist/server-fns/collections/history.js +4 -1
  134. package/dist/server-fns/collections/index.d.ts +21 -21
  135. package/dist/server-fns/collections/list.d.ts +3 -1
  136. package/dist/server-fns/collections/list.js +15 -4
  137. package/dist/server-fns/collections/reindex.d.ts +3 -1
  138. package/dist/server-fns/collections/reindex.js +4 -1
  139. package/dist/server-fns/collections/reorder.d.ts +10 -2
  140. package/dist/server-fns/collections/reorder.js +23 -76
  141. package/dist/server-fns/collections/restore-version.d.ts +6 -1
  142. package/dist/server-fns/collections/restore-version.js +8 -2
  143. package/dist/server-fns/collections/save-outcome.d.ts +1 -0
  144. package/dist/server-fns/collections/save-outcome.js +1 -0
  145. package/dist/server-fns/collections/scheduled-publication.d.ts +64 -7
  146. package/dist/server-fns/collections/scheduled-publication.js +42 -21
  147. package/dist/server-fns/collections/set-list-view-preference.d.ts +3 -1
  148. package/dist/server-fns/collections/set-list-view-preference.js +4 -1
  149. package/dist/server-fns/collections/stats.js +4 -1
  150. package/dist/server-fns/collections/status.d.ts +14 -2
  151. package/dist/server-fns/collections/status.js +18 -5
  152. package/dist/server-fns/collections/tree-mutation.d.ts +7 -6
  153. package/dist/server-fns/collections/tree-mutation.js +17 -5
  154. package/dist/server-fns/collections/tree.d.ts +29 -6
  155. package/dist/server-fns/collections/tree.js +32 -17
  156. package/dist/server-fns/collections/update.d.ts +18 -6
  157. package/dist/server-fns/collections/update.js +39 -20
  158. package/dist/server-fns/collections/upload.d.ts +11 -1
  159. package/dist/server-fns/collections/upload.js +6 -2
  160. package/dist/server-fns/document-mutation-errors.d.ts +5 -0
  161. package/dist/server-fns/document-mutation-errors.js +50 -0
  162. package/dist/server-fns/editable-revision.d.ts +9 -0
  163. package/dist/server-fns/editable-revision.js +11 -0
  164. package/dist/server-fns/i18n/set-locale.d.ts +3 -1
  165. package/dist/server-fns/i18n/set-locale.js +18 -3
  166. package/dist/server-fns/preview/disable.d.ts +3 -1
  167. package/dist/server-fns/preview/disable.js +4 -1
  168. package/dist/server-fns/preview/enable.d.ts +3 -1
  169. package/dist/server-fns/preview/enable.js +4 -1
  170. package/dist/server-fns/singleton-document-read.d.ts +1 -1
  171. package/dist/server-fns/singleton-document-read.js +7 -3
  172. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +9 -2
  173. package/dist/server-fns/singletons/cancel-scheduled-publish.js +7 -1
  174. package/dist/server-fns/singletons/change-status.d.ts +5 -1
  175. package/dist/server-fns/singletons/change-status.js +7 -1
  176. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +8 -2
  177. package/dist/server-fns/singletons/confirm-scheduled-publish.js +7 -2
  178. package/dist/server-fns/singletons/copy-to-locale.d.ts +5 -1
  179. package/dist/server-fns/singletons/copy-to-locale.js +7 -2
  180. package/dist/server-fns/singletons/find-by-version.d.ts +3 -1
  181. package/dist/server-fns/singletons/find-by-version.js +4 -1
  182. package/dist/server-fns/singletons/get-scheduled-publish.d.ts +3 -1
  183. package/dist/server-fns/singletons/get-scheduled-publish.js +4 -1
  184. package/dist/server-fns/singletons/get.d.ts +6 -4
  185. package/dist/server-fns/singletons/get.js +4 -1
  186. package/dist/server-fns/singletons/history.d.ts +3 -1
  187. package/dist/server-fns/singletons/history.js +4 -1
  188. package/dist/server-fns/singletons/restore-version.d.ts +5 -1
  189. package/dist/server-fns/singletons/restore-version.js +9 -3
  190. package/dist/server-fns/singletons/schedule-publish.d.ts +8 -2
  191. package/dist/server-fns/singletons/schedule-publish.js +7 -2
  192. package/dist/server-fns/singletons/unpublish.d.ts +5 -1
  193. package/dist/server-fns/singletons/unpublish.js +7 -1
  194. package/dist/server-fns/singletons/update.d.ts +6 -4
  195. package/dist/server-fns/singletons/update.js +8 -4
  196. package/dist/server-fns/structural-receipt.d.ts +4 -0
  197. package/dist/server-fns/structural-receipt.js +31 -0
  198. package/package.json +20 -20
  199. package/src/admin-shell/chrome/admin-app-bar.tsx +11 -6
  200. package/src/admin-shell/chrome/session-change-boundary.test.tsx +78 -0
  201. package/src/admin-shell/chrome/session-change-boundary.tsx +94 -0
  202. package/src/admin-shell/chrome/sign-in-page.tsx +68 -3
  203. package/src/admin-shell/collections/edit.tsx +173 -54
  204. package/src/admin-shell/collections/history.tsx +11 -2
  205. package/src/admin-shell/collections/list.tsx +22 -6
  206. package/src/admin-shell/collections/restore-version-modal.tsx +12 -3
  207. package/src/admin-shell/collections/save-navigation.test.tsx +156 -8
  208. package/src/admin-shell/collections/tree-list.tsx +16 -6
  209. package/src/admin-shell/collections/version-history.tsx +13 -3
  210. package/src/admin-shell/document-mutation-notice.tsx +45 -0
  211. package/src/admin-shell/document-mutation-state.test.tsx +146 -0
  212. package/src/admin-shell/document-mutation-state.ts +95 -0
  213. package/src/admin-shell/scheduled-publications/list.tsx +16 -6
  214. package/src/admin-shell/singletons/history.test.tsx +3 -1
  215. package/src/admin-shell/singletons/history.tsx +10 -2
  216. package/src/admin-shell/singletons/view.test.tsx +59 -18
  217. package/src/admin-shell/singletons/view.tsx +110 -29
  218. package/src/integrations/byline-admin-services.ts +7 -1
  219. package/src/integrations/byline-field-services.ts +2 -3
  220. package/src/integrations/client-ip.test.node.ts +45 -0
  221. package/src/integrations/client-ip.ts +54 -0
  222. package/src/integrations/framework-fetch-shape.test.node.ts +56 -0
  223. package/src/integrations/session-client.test.node.ts +121 -0
  224. package/src/integrations/session-coordination.test.node.ts +77 -0
  225. package/src/integrations/session-coordination.ts +96 -0
  226. package/src/integrations/session-middleware.test.node.ts +87 -0
  227. package/src/integrations/session-middleware.ts +107 -0
  228. package/src/integrations/session-request-kind.ts +18 -0
  229. package/src/integrations/session-request-middleware.ts +17 -0
  230. package/src/integrations/sign-in-body.test.node.ts +59 -0
  231. package/src/integrations/sign-in-body.ts +50 -0
  232. package/src/integrations/sign-in-middleware.ts +24 -0
  233. package/src/routes/create-admin-layout-route.tsx +48 -30
  234. package/src/routes/create-collection-edit-route.tsx +1 -0
  235. package/src/routes/create-collection-list-route.tsx +13 -2
  236. package/src/routes/create-sign-in-route.tsx +10 -2
  237. package/src/routes/create-singleton-route.test.node.ts +10 -2
  238. package/src/routes/create-singleton-route.tsx +6 -4
  239. package/src/server-fns/admin-account/change-password.test.node.ts +69 -0
  240. package/src/server-fns/admin-account/change-password.ts +13 -2
  241. package/src/server-fns/admin-account/get.ts +2 -0
  242. package/src/server-fns/admin-account/update.ts +2 -0
  243. package/src/server-fns/admin-activity/get.ts +2 -0
  244. package/src/server-fns/admin-permissions/get-role-abilities.ts +2 -0
  245. package/src/server-fns/admin-permissions/list-registered.ts +5 -4
  246. package/src/server-fns/admin-permissions/set-role-abilities.ts +2 -0
  247. package/src/server-fns/admin-permissions/who-has.ts +2 -0
  248. package/src/server-fns/admin-roles/create.ts +2 -0
  249. package/src/server-fns/admin-roles/delete.ts +2 -0
  250. package/src/server-fns/admin-roles/get.ts +2 -0
  251. package/src/server-fns/admin-roles/list.ts +5 -4
  252. package/src/server-fns/admin-roles/reorder.ts +2 -0
  253. package/src/server-fns/admin-roles/update.ts +2 -0
  254. package/src/server-fns/admin-users/create.ts +2 -0
  255. package/src/server-fns/admin-users/delete.ts +2 -0
  256. package/src/server-fns/admin-users/disable.ts +2 -0
  257. package/src/server-fns/admin-users/enable.ts +2 -0
  258. package/src/server-fns/admin-users/get-user-roles.ts +2 -0
  259. package/src/server-fns/admin-users/get.ts +2 -0
  260. package/src/server-fns/admin-users/list.ts +2 -0
  261. package/src/server-fns/admin-users/set-password.ts +2 -0
  262. package/src/server-fns/admin-users/set-user-roles.ts +2 -0
  263. package/src/server-fns/admin-users/update.ts +2 -0
  264. package/src/server-fns/ai/execute.ts +3 -0
  265. package/src/server-fns/analytics/maintenance.ts +4 -0
  266. package/src/server-fns/analytics/queries.ts +14 -8
  267. package/src/server-fns/auth/current-user.ts +7 -3
  268. package/src/server-fns/auth/renew.test.node.ts +85 -0
  269. package/src/server-fns/auth/renew.ts +54 -0
  270. package/src/server-fns/auth/sign-in.test.node.ts +114 -0
  271. package/src/server-fns/auth/sign-in.ts +80 -27
  272. package/src/server-fns/auth/sign-out.test.node.ts +85 -0
  273. package/src/server-fns/auth/sign-out.ts +44 -19
  274. package/src/server-fns/collections/audit.ts +2 -0
  275. package/src/server-fns/collections/copy-to-locale.ts +43 -36
  276. package/src/server-fns/collections/create.ts +56 -50
  277. package/src/server-fns/collections/delete-locale.ts +43 -32
  278. package/src/server-fns/collections/delete-outcome.test.node.ts +16 -0
  279. package/src/server-fns/collections/delete-outcome.ts +27 -16
  280. package/src/server-fns/collections/delete.ts +46 -38
  281. package/src/server-fns/collections/duplicate.ts +40 -32
  282. package/src/server-fns/collections/get.ts +11 -4
  283. package/src/server-fns/collections/history.ts +2 -0
  284. package/src/server-fns/collections/list.ts +10 -3
  285. package/src/server-fns/collections/reindex.ts +2 -0
  286. package/src/server-fns/collections/reorder.ts +34 -124
  287. package/src/server-fns/collections/restore-version.ts +49 -39
  288. package/src/server-fns/collections/revision-input.test.node.ts +122 -0
  289. package/src/server-fns/collections/save-outcome.test.node.ts +3 -0
  290. package/src/server-fns/collections/save-outcome.ts +2 -0
  291. package/src/server-fns/collections/scheduled-publication.ts +149 -115
  292. package/src/server-fns/collections/set-list-view-preference.ts +2 -0
  293. package/src/server-fns/collections/stats.ts +2 -0
  294. package/src/server-fns/collections/status.ts +89 -64
  295. package/src/server-fns/collections/tree-mutation.test.node.ts +87 -29
  296. package/src/server-fns/collections/tree-mutation.ts +28 -11
  297. package/src/server-fns/collections/tree.ts +156 -122
  298. package/src/server-fns/collections/update.ts +113 -86
  299. package/src/server-fns/collections/upload-singleton.test.node.ts +10 -7
  300. package/src/server-fns/collections/upload.ts +91 -77
  301. package/src/server-fns/document-mutation-errors.ts +67 -0
  302. package/src/server-fns/document-mutation-transport.test.node.ts +211 -0
  303. package/src/server-fns/editable-revision.test.node.ts +23 -0
  304. package/src/server-fns/editable-revision.ts +18 -0
  305. package/src/server-fns/i18n/set-locale.ts +19 -3
  306. package/src/server-fns/preview/disable.ts +8 -4
  307. package/src/server-fns/preview/enable.ts +9 -5
  308. package/src/server-fns/singleton-document-read.ts +7 -3
  309. package/src/server-fns/singletons/cancel-scheduled-publish.ts +12 -3
  310. package/src/server-fns/singletons/change-status.ts +12 -3
  311. package/src/server-fns/singletons/confirm-scheduled-publish.ts +14 -6
  312. package/src/server-fns/singletons/copy-to-locale.ts +22 -15
  313. package/src/server-fns/singletons/find-by-version.ts +2 -0
  314. package/src/server-fns/singletons/get-scheduled-publish.ts +2 -0
  315. package/src/server-fns/singletons/get.ts +5 -3
  316. package/src/server-fns/singletons/history.ts +2 -0
  317. package/src/server-fns/singletons/restore-version.ts +19 -12
  318. package/src/server-fns/singletons/schedule-publish.ts +20 -7
  319. package/src/server-fns/singletons/singletons.test.node.ts +69 -25
  320. package/src/server-fns/singletons/unpublish.ts +12 -3
  321. package/src/server-fns/singletons/update.ts +23 -16
  322. package/src/server-fns/structural-receipt.ts +41 -0
@@ -1,4 +1,7 @@
1
- 'use client'
1
+ import { DocumentMutationNotice } from '../document-mutation-notice.js'
2
+ import { useDocumentMutationState } from '../document-mutation-state.js'
3
+
4
+ ;('use client')
2
5
 
3
6
  /**
4
7
  * This Source Code is subject to the terms of the Mozilla Public
@@ -60,6 +63,7 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
60
63
  state?: 'armed' | 'needs_reconfirm'
61
64
  page?: number
62
65
  }
66
+ const mutation = useDocumentMutationState(undefined)
63
67
  const [cancelling, setCancelling] = useState<string | null>(null)
64
68
 
65
69
  const applySearch = (patch: Record<string, unknown>) => {
@@ -69,11 +73,13 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
69
73
  })
70
74
  }
71
75
 
72
- const cancel = async (collection: string, documentId: string) => {
76
+ const cancel = async (collection: string, documentId: string, expectedRevision: number) => {
77
+ if (mutation.blocked || cancelling) return
73
78
  setCancelling(documentId)
74
79
  try {
80
+ mutation.assertWritable()
75
81
  const result = await cancelCollectionDocumentScheduledPublish({
76
- data: { collection, id: documentId },
82
+ data: { collection, id: documentId, expectedRevision },
77
83
  })
78
84
  const cancelled = result.status === 'cancelled'
79
85
  toastManager.add({
@@ -95,9 +101,10 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
95
101
  search: location.search as Record<string, unknown>,
96
102
  })
97
103
  } catch (error) {
104
+ if (mutation.report(error)) return
98
105
  toastManager.add({
99
106
  title: t('scheduledPublication.toast.failedTitle'),
100
- description: (error as Error).message,
107
+ description: t('documentConcurrency.failed'),
101
108
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
102
109
  })
103
110
  } finally {
@@ -110,6 +117,7 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
110
117
  return (
111
118
  <Section>
112
119
  <Container>
120
+ <DocumentMutationNotice issue={mutation.issue} />
113
121
  <div className={cx('byline-scheduled-list-head', styles.head)}>
114
122
  <h1 className={styles.title}>{t('scheduledPublication.list.title')}</h1>
115
123
  <p className={styles.intro}>{t('scheduledPublication.list.intro')}</p>
@@ -257,13 +265,15 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
257
265
  size="xs"
258
266
  type="button"
259
267
  intent="danger"
260
- disabled={cancelling === schedule.documentId}
268
+ disabled={mutation.blocked || cancelling !== null}
261
269
  // The column header already says Actions and the row
262
270
  // is a schedule, so the short label carries here —
263
271
  // unlike the editor's menu, where "Cancel schedule"
264
272
  // has to distinguish itself from cancelling an edit.
265
273
  aria-label={t('scheduledPublication.actions.cancel')}
266
- onClick={() => cancel(schedule.collectionPath, schedule.documentId)}
274
+ onClick={() =>
275
+ cancel(schedule.collectionPath, schedule.documentId, schedule.revision)
276
+ }
267
277
  >
268
278
  {t('common.actions.cancel')}
269
279
  </Button>
@@ -194,6 +194,7 @@ const data: VersionHistoryData = {
194
194
  }
195
195
 
196
196
  const currentDocument = {
197
+ revision: 7,
197
198
  id: 'document-settings',
198
199
  versionId: 'version-current',
199
200
  status: 'published',
@@ -266,7 +267,7 @@ describe('singleton version history', () => {
266
267
  await act(async () => confirm?.click())
267
268
 
268
269
  expect(mocks.restoreSingleton).toHaveBeenCalledWith({
269
- data: { singleton: 'site-settings', versionId: 'version-old' },
270
+ data: { singleton: 'site-settings', versionId: 'version-old', expectedRevision: 7 },
270
271
  })
271
272
  expect(mocks.invalidate).toHaveBeenCalledTimes(1)
272
273
  expect(mocks.navigate).toHaveBeenLastCalledWith({
@@ -395,6 +396,7 @@ describe('singleton version history', () => {
395
396
 
396
397
  expect(mocks.restoreCollection).toHaveBeenCalledWith({
397
398
  data: {
399
+ expectedRevision: 7,
398
400
  collection: 'articles',
399
401
  id: 'document-article',
400
402
  versionId: 'version-old',
@@ -89,6 +89,7 @@ export function SingletonHistoryView({
89
89
  currentVersionId,
90
90
  actorLabel,
91
91
  restoreStatusLabel,
92
+ mutationsBlocked,
92
93
  openRestore,
93
94
  }) => (
94
95
  <>
@@ -106,6 +107,7 @@ export function SingletonHistoryView({
106
107
  variant="outlined"
107
108
  size="xs"
108
109
  intent="noeffect"
110
+ disabled={mutationsBlocked}
109
111
  onClick={openRestore}
110
112
  className={cx('byline-coll-history-restore-button', historyStyles.restoreButton)}
111
113
  title={t('collections.history.restoreButtonTitle', {
@@ -166,8 +168,14 @@ export function SingletonHistoryView({
166
168
  return historical as unknown as Record<string, unknown>
167
169
  }}
168
170
  onPageSizeChange={handlePageSizeChange}
169
- restoreVersion={(versionId) =>
170
- restoreSingletonVersion({ data: { singleton, versionId } })
171
+ restoreVersion={(versionId, expectedRevision) =>
172
+ restoreSingletonVersion({
173
+ data: {
174
+ expectedRevision,
175
+ singleton,
176
+ versionId,
177
+ },
178
+ })
171
179
  }
172
180
  onRestoreComplete={async () => {
173
181
  await router.invalidate()
@@ -9,7 +9,12 @@
9
9
  import { act } from 'react'
10
10
 
11
11
  import { BylineFieldServicesProvider } from '@byline/admin/react'
12
- import { defineAdminConfig, defineSingleton, type SingletonDefinition } from '@byline/core'
12
+ import {
13
+ defineAdminConfig,
14
+ defineSingleton,
15
+ ERR_DOCUMENT_STALE,
16
+ type SingletonDefinition,
17
+ } from '@byline/core'
13
18
  import { adminTranslations } from '@byline/i18n/admin'
14
19
  import { I18nProvider } from '@byline/i18n/react'
15
20
  import { createRoot, type Root } from 'react-dom/client'
@@ -149,6 +154,7 @@ const locales = [
149
154
  const loadedDocument = {
150
155
  id: 'doc-settings',
151
156
  versionId: 'version-1',
157
+ revision: 7,
152
158
  path: '__singleton__/site-settings',
153
159
  status: 'draft',
154
160
  fields: { title: 'Loaded title', attachment: null },
@@ -233,13 +239,29 @@ function latestToastDescription(): string {
233
239
  beforeEach(() => {
234
240
  vi.clearAllMocks()
235
241
  mocks.guardStates.length = 0
236
- mocks.update.mockResolvedValue({ versionId: 'version-next' })
237
- mocks.changeStatus.mockResolvedValue({ newStatus: 'published' })
238
- mocks.unpublish.mockResolvedValue({ archivedCount: 1 })
239
- mocks.schedule.mockResolvedValue({ state: 'armed' })
240
- mocks.confirmSchedule.mockResolvedValue({ state: 'armed' })
241
- mocks.cancelSchedule.mockResolvedValue(null)
242
- mocks.copyToLocale.mockResolvedValue({ versionId: 'version-next' })
242
+ mocks.update.mockResolvedValue({
243
+ documentId: 'doc-settings',
244
+ revision: 8,
245
+ versionId: 'version-next',
246
+ })
247
+ mocks.changeStatus.mockResolvedValue({
248
+ documentId: 'doc-settings',
249
+ revision: 8,
250
+ newStatus: 'published',
251
+ })
252
+ mocks.unpublish.mockResolvedValue({ documentId: 'doc-settings', revision: 8, archivedCount: 1 })
253
+ mocks.schedule.mockResolvedValue({ documentId: 'doc-settings', revision: 8, state: 'armed' })
254
+ mocks.confirmSchedule.mockResolvedValue({
255
+ documentId: 'doc-settings',
256
+ revision: 8,
257
+ state: 'armed',
258
+ })
259
+ mocks.cancelSchedule.mockResolvedValue({ schedule: null, revision: 8 })
260
+ mocks.copyToLocale.mockResolvedValue({
261
+ documentId: 'doc-settings',
262
+ revision: 8,
263
+ versionId: 'version-next',
264
+ })
243
265
 
244
266
  defineAdminConfig({
245
267
  routes: { admin: '/internal/cms' },
@@ -316,7 +338,7 @@ describe('SingletonView', () => {
316
338
  expect(container.querySelector('.byline-preview-link')).toBeNull()
317
339
  })
318
340
 
319
- it('saves full data with the active locale and the correct optimistic version', async () => {
341
+ it('saves full data with the active locale and the observed revision or explicit empty slot', async () => {
320
342
  render(null, 'en')
321
343
  typeIntoTitle('First save')
322
344
  await submit()
@@ -326,7 +348,7 @@ describe('SingletonView', () => {
326
348
  singleton: 'site-settings',
327
349
  data: { title: 'First save', attachment: undefined },
328
350
  locale: 'en',
329
- expectedVersionId: undefined,
351
+ expectedState: 'empty',
330
352
  },
331
353
  })
332
354
  expect(mocks.invalidate).toHaveBeenCalledTimes(1)
@@ -342,19 +364,28 @@ describe('SingletonView', () => {
342
364
  singleton: 'site-settings',
343
365
  data: { title: 'Second save', attachment: null },
344
366
  locale: 'fr',
345
- expectedVersionId: 'version-1',
367
+ expectedRevision: 7,
346
368
  },
347
369
  })
348
370
  expect(mocks.invalidate).toHaveBeenCalledTimes(2)
349
371
  })
350
372
 
351
373
  it('distinguishes stale saves from a singleton missing on the server', async () => {
352
- mocks.update.mockRejectedValueOnce({ code: 'ERR_CONFLICT' })
374
+ mocks.update.mockRejectedValueOnce(
375
+ ERR_DOCUMENT_STALE({
376
+ message: 'Test failure',
377
+ details: {
378
+ reason: 'revision_mismatch',
379
+ documentId: 'doc-settings',
380
+ expectedRevision: 7,
381
+ currentRevision: 8,
382
+ },
383
+ })
384
+ )
353
385
  render(loadedDocument)
354
386
  typeIntoTitle('Stale edit')
355
387
  await submit()
356
- const conflict = latestToastDescription()
357
- expect(conflict).toContain('Someone else saved this singleton first')
388
+ expect(container.textContent).toContain('This document has changed since you opened it.')
358
389
 
359
390
  act(() => root.unmount())
360
391
  root = createRoot(container)
@@ -362,13 +393,22 @@ describe('SingletonView', () => {
362
393
  render(loadedDocument)
363
394
  typeIntoTitle('Missing edit')
364
395
  await submit()
365
- const notConfigured = latestToastDescription()
366
- expect(notConfigured).toContain('not configured on the server')
367
- expect(notConfigured).not.toBe(conflict)
396
+ expect(container.textContent).toContain('This document is no longer available.')
397
+ expect(container.textContent).not.toContain('This document has changed since you opened it.')
368
398
  })
369
399
 
370
400
  it('keeps a failed save dirty with the navigation guard active', async () => {
371
- mocks.update.mockRejectedValueOnce({ code: 'ERR_CONFLICT' })
401
+ mocks.update.mockRejectedValueOnce(
402
+ ERR_DOCUMENT_STALE({
403
+ message: 'Test failure',
404
+ details: {
405
+ reason: 'revision_mismatch',
406
+ documentId: 'doc-settings',
407
+ expectedRevision: 7,
408
+ currentRevision: 8,
409
+ },
410
+ })
411
+ )
372
412
  render(loadedDocument)
373
413
  typeIntoTitle('Unsaved change')
374
414
  await submit()
@@ -383,6 +423,7 @@ describe('SingletonView', () => {
383
423
  status: 'committed-hook-failed',
384
424
  documentId: 'doc-settings',
385
425
  documentVersionId: 'version-next',
426
+ revision: 8,
386
427
  sideEffectFailure: { phase: 'afterSave', code: 'ERR_UNHANDLED' },
387
428
  })
388
429
  render(loadedDocument)
@@ -13,7 +13,6 @@ import { useRouter } from '@tanstack/react-router'
13
13
  import { FormRenderer } from '@byline/admin/react'
14
14
  import type { SingletonAdminConfig, SingletonDefinition } from '@byline/core'
15
15
  import {
16
- ErrorCodes,
17
16
  getDefaultStatus,
18
17
  getWorkflow,
19
18
  getWorkflowStatuses,
@@ -38,6 +37,7 @@ import {
38
37
  } from '../../server-fns/singletons/index.js'
39
38
  import { useNavigate } from '../chrome/loose-router.js'
40
39
  import { useTanStackNavigationGuard } from '../collections/tanstack-navigation-guard.js'
40
+ import { useDocumentMutationState } from '../document-mutation-state.js'
41
41
  import { SingletonViewMenu } from './view-menu.js'
42
42
  import type { ContentLocaleOption } from '../collections/view-menu.js'
43
43
 
@@ -47,16 +47,10 @@ type SingletonDocument = Record<string, any> & {
47
47
  fields: Record<string, any>
48
48
  }
49
49
 
50
- function errorCode(error: unknown): string | null {
51
- return typeof (error as { code?: unknown })?.code === 'string'
52
- ? (error as { code: string }).code
53
- : null
54
- }
55
-
56
50
  export function SingletonView({
57
51
  singletonDefinition,
58
52
  adminConfig,
59
- document,
53
+ document: loadedDocument,
60
54
  initialData,
61
55
  locale,
62
56
  contentLocales,
@@ -71,6 +65,8 @@ export function SingletonView({
71
65
  contentLocales: ReadonlyArray<ContentLocaleOption>
72
66
  defaultContentLocale: string
73
67
  }) {
68
+ const mutation = useDocumentMutationState(loadedDocument)
69
+ const document = mutation.document ?? null
74
70
  const router = useRouter()
75
71
  const navigate = useNavigate()
76
72
  const toastManager = useToastManager()
@@ -132,14 +128,18 @@ export function SingletonView({
132
128
 
133
129
  const handleSubmit = async ({ data }: { data: Record<string, any> }) => {
134
130
  try {
131
+ mutation.assertWritable()
135
132
  const result = await updateSingleton({
136
133
  data: {
137
134
  singleton: path,
138
135
  data,
139
136
  locale: locale ?? defaultContentLocale,
140
- expectedVersionId: document?.versionId,
137
+ ...(document == null
138
+ ? { expectedState: 'empty' as const }
139
+ : { expectedRevision: mutation.expectedRevision() }),
141
140
  },
142
141
  })
142
+ mutation.adopt(result)
143
143
  if (document != null) notifyScheduleSuspended()
144
144
  if (hasCommittedDocumentHookFailure(result)) {
145
145
  notifyCommittedHookFailure()
@@ -152,13 +152,16 @@ export function SingletonView({
152
152
  }
153
153
  await reload()
154
154
  } catch (err) {
155
- const code = errorCode(err)
156
- const description =
157
- code === ErrorCodes.CONFLICT
158
- ? t('singletons.edit.conflictDescription')
159
- : code === ErrorCodes.NOT_FOUND
160
- ? t('singletons.edit.notConfiguredDescription')
161
- : t('singletons.edit.updateFailedDescription', { label: label.toLowerCase() })
155
+ const handled = mutation.report(err)
156
+ if (handled === 'blocked') throw err
157
+ if (handled === 'committed') {
158
+ notifyCommittedHookFailure()
159
+ await reload()
160
+ return
161
+ }
162
+ const description = t('singletons.edit.updateFailedDescription', {
163
+ label: label.toLowerCase(),
164
+ })
162
165
  toast(t('singletons.edit.updateTitle', { label }), description, 'danger')
163
166
 
164
167
  // FormRenderer commits its clean baseline only when this promise
@@ -170,7 +173,11 @@ export function SingletonView({
170
173
 
171
174
  const handleStatusChange = async (status: string) => {
172
175
  try {
173
- await changeSingletonStatus({ data: { singleton: path, status } })
176
+ mutation.assertWritable()
177
+ const result = await changeSingletonStatus({
178
+ data: { expectedRevision: mutation.expectedRevision(), singleton: path, status },
179
+ })
180
+ mutation.adopt(result)
174
181
  toast(
175
182
  t('collections.edit.statusUpdateTitle', { label }),
176
183
  t('collections.edit.statusChangedDescription', { status }),
@@ -178,9 +185,18 @@ export function SingletonView({
178
185
  )
179
186
  await reload()
180
187
  } catch (err) {
188
+ const handled = mutation.report(err)
189
+ if (handled === 'blocked') throw err
190
+ if (handled === 'committed') {
191
+ notifyCommittedHookFailure()
192
+ await reload()
193
+ return
194
+ }
181
195
  toast(
182
196
  t('collections.edit.statusUpdateTitle', { label }),
183
- t('collections.edit.statusChangeFailedDescription', { message: (err as Error).message }),
197
+ t('collections.edit.statusChangeFailedDescription', {
198
+ message: t('documentConcurrency.failed'),
199
+ }),
184
200
  'danger'
185
201
  )
186
202
  throw err
@@ -189,7 +205,11 @@ export function SingletonView({
189
205
 
190
206
  const handleUnpublish = async () => {
191
207
  try {
192
- await unpublishSingleton({ data: { singleton: path } })
208
+ mutation.assertWritable()
209
+ const result = await unpublishSingleton({
210
+ data: { expectedRevision: mutation.expectedRevision(), singleton: path },
211
+ })
212
+ mutation.adopt(result)
193
213
  toast(
194
214
  t('collections.edit.unpublishTitle', { label }),
195
215
  t('collections.edit.unpublishedDescription'),
@@ -197,9 +217,18 @@ export function SingletonView({
197
217
  )
198
218
  await reload()
199
219
  } catch (err) {
220
+ const handled = mutation.report(err)
221
+ if (handled === 'blocked') throw err
222
+ if (handled === 'committed') {
223
+ notifyCommittedHookFailure()
224
+ await reload()
225
+ return
226
+ }
200
227
  toast(
201
228
  t('collections.edit.unpublishTitle', { label }),
202
- t('collections.edit.unpublishFailedDescription', { message: (err as Error).message }),
229
+ t('collections.edit.unpublishFailedDescription', {
230
+ message: t('documentConcurrency.failed'),
231
+ }),
203
232
  'danger'
204
233
  )
205
234
  throw err
@@ -214,14 +243,17 @@ export function SingletonView({
214
243
  overwrite: boolean
215
244
  }) => {
216
245
  try {
246
+ mutation.assertWritable()
217
247
  const result = await copySingletonToLocale({
218
248
  data: {
249
+ expectedRevision: mutation.expectedRevision(),
219
250
  singleton: path,
220
251
  sourceLocale: locale ?? defaultContentLocale,
221
252
  targetLocale,
222
253
  overwrite,
223
254
  },
224
255
  })
256
+ mutation.adopt(result)
225
257
  const sourceLocale = locale ?? defaultContentLocale
226
258
  const sourceLabel =
227
259
  contentLocales.find((entry) => entry.code === sourceLocale)?.label ?? sourceLocale
@@ -246,9 +278,16 @@ export function SingletonView({
246
278
  search: { locale: targetLocale },
247
279
  })
248
280
  } catch (err) {
281
+ const handled = mutation.report(err)
282
+ if (handled === 'blocked') throw err
283
+ if (handled === 'committed') {
284
+ notifyCommittedHookFailure()
285
+ await reload()
286
+ return
287
+ }
249
288
  toast(
250
289
  t('collections.edit.copyToLocaleTitle', { label }),
251
- t('collections.edit.copyFailedDescription', { message: (err as Error).message }),
290
+ t('collections.edit.copyFailedDescription', { message: t('documentConcurrency.failed') }),
252
291
  'danger'
253
292
  )
254
293
  throw err
@@ -257,9 +296,16 @@ export function SingletonView({
257
296
 
258
297
  const handleSchedulePublication = async ({ publishAt }: { publishAt: string }) => {
259
298
  try {
260
- await scheduleSingletonPublish({
261
- data: { singleton: path, publishAt, expectedVersionId: String(document?.versionId) },
299
+ mutation.assertWritable()
300
+ const result = await scheduleSingletonPublish({
301
+ data: {
302
+ expectedRevision: mutation.expectedRevision(),
303
+ singleton: path,
304
+ publishAt,
305
+ expectedVersionId: String(document?.versionId),
306
+ },
262
307
  })
308
+ mutation.adopt(result)
263
309
  toast(
264
310
  t('scheduledPublication.toast.scheduledTitle'),
265
311
  t('scheduledPublication.toast.scheduledDescription'),
@@ -267,16 +313,29 @@ export function SingletonView({
267
313
  )
268
314
  await reload()
269
315
  } catch (err) {
270
- toast(t('scheduledPublication.toast.failedTitle'), (err as Error).message, 'danger')
316
+ const handled = mutation.report(err)
317
+ if (handled === 'blocked') throw err
318
+ if (handled === 'committed') {
319
+ notifyCommittedHookFailure()
320
+ await reload()
321
+ return
322
+ }
323
+ toast(t('scheduledPublication.toast.failedTitle'), t('documentConcurrency.failed'), 'danger')
271
324
  throw err
272
325
  }
273
326
  }
274
327
 
275
328
  const handleConfirmScheduledPublication = async () => {
276
329
  try {
277
- await confirmSingletonScheduledPublish({
278
- data: { singleton: path, expectedVersionId: String(document?.versionId) },
330
+ mutation.assertWritable()
331
+ const result = await confirmSingletonScheduledPublish({
332
+ data: {
333
+ expectedRevision: mutation.expectedRevision(),
334
+ singleton: path,
335
+ expectedVersionId: String(document?.versionId),
336
+ },
279
337
  })
338
+ mutation.adopt(result)
280
339
  toast(
281
340
  t('scheduledPublication.toast.confirmedTitle'),
282
341
  t('scheduledPublication.toast.confirmedDescription'),
@@ -284,14 +343,25 @@ export function SingletonView({
284
343
  )
285
344
  await reload()
286
345
  } catch (err) {
287
- toast(t('scheduledPublication.toast.failedTitle'), (err as Error).message, 'danger')
346
+ const handled = mutation.report(err)
347
+ if (handled === 'blocked') throw err
348
+ if (handled === 'committed') {
349
+ notifyCommittedHookFailure()
350
+ await reload()
351
+ return
352
+ }
353
+ toast(t('scheduledPublication.toast.failedTitle'), t('documentConcurrency.failed'), 'danger')
288
354
  throw err
289
355
  }
290
356
  }
291
357
 
292
358
  const handleCancelScheduledPublication = async () => {
293
359
  try {
294
- await cancelSingletonScheduledPublish({ data: { singleton: path } })
360
+ mutation.assertWritable()
361
+ const result = await cancelSingletonScheduledPublish({
362
+ data: { expectedRevision: mutation.expectedRevision(), singleton: path },
363
+ })
364
+ mutation.adopt({ ...result, documentId: String(document?.id) })
295
365
  toast(
296
366
  t('scheduledPublication.toast.cancelledTitle'),
297
367
  t('scheduledPublication.toast.cancelledDescription'),
@@ -299,7 +369,14 @@ export function SingletonView({
299
369
  )
300
370
  await reload()
301
371
  } catch (err) {
302
- toast(t('scheduledPublication.toast.failedTitle'), (err as Error).message, 'danger')
372
+ const handled = mutation.report(err)
373
+ if (handled === 'blocked') throw err
374
+ if (handled === 'committed') {
375
+ notifyCommittedHookFailure()
376
+ await reload()
377
+ return
378
+ }
379
+ toast(t('scheduledPublication.toast.failedTitle'), t('documentConcurrency.failed'), 'danger')
303
380
  throw err
304
381
  }
305
382
  }
@@ -308,6 +385,10 @@ export function SingletonView({
308
385
  <Section>
309
386
  <Container>
310
387
  <FormRenderer
388
+ mutationIssue={mutation.issue}
389
+ mutationsBlocked={mutation.blocked}
390
+ observedRevision={mutation.revision}
391
+ onMutationError={mutation.report}
311
392
  mode={document == null ? 'create' : 'edit'}
312
393
  fields={fields}
313
394
  onSubmit={handleSubmit}
@@ -36,6 +36,7 @@ import {
36
36
  import { adminSignIn } from '../server-fns/auth/index.js'
37
37
  import { getCollectionDocumentVersion as serverGetCollectionDocumentVersion } from '../server-fns/collections/get.js'
38
38
  import { setAdminLocaleFn } from '../server-fns/i18n/index.js'
39
+ import { acceptSession, coordinateAuthAction, notifySessionAction } from './session-coordination.js'
39
40
 
40
41
  /**
41
42
  * Diff helper adapter — the contract uses positional args; the underlying
@@ -62,7 +63,12 @@ const getCollectionDocumentVersion: BylineAdminServices['getCollectionDocumentVe
62
63
 
63
64
  export const bylineAdminServices: BylineAdminServices = {
64
65
  // Auth
65
- adminSignIn,
66
+ adminSignIn: async (args) => {
67
+ const result = await coordinateAuthAction(() => adminSignIn(args))
68
+ acceptSession(result.sessionId)
69
+ notifySessionAction()
70
+ return result
71
+ },
66
72
 
67
73
  // Account self-service
68
74
  updateAccount,
@@ -44,12 +44,11 @@ const uploadField: UploadFieldFn = (collection, formData, createDocument) =>
44
44
  serverUploadField(collection, formData, createDocument)
45
45
 
46
46
  const placeTreeNode: PlaceTreeNodeFn = async (input) => {
47
- const { orderKey } = await serverPlaceTreeNode({ data: input })
48
- return { orderKey }
47
+ return serverPlaceTreeNode({ data: input })
49
48
  }
50
49
 
51
50
  const removeFromTree: RemoveFromTreeFn = async (input) => {
52
- await serverRemoveFromTree({ data: input })
51
+ return serverRemoveFromTree({ data: input })
53
52
  }
54
53
 
55
54
  const getTreeAncestors: GetTreeAncestorsFn = (input) =>
@@ -0,0 +1,45 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { createClientIpResolver, normalizeClientIp } from './client-ip.js'
12
+
13
+ describe('client IP trust boundary', () => {
14
+ it('ignores spoofed forwarding headers in direct mode', async () => {
15
+ const resolve = createClientIpResolver({ resolvePeerIp: () => '192.0.2.1' })
16
+ expect(
17
+ await resolve(
18
+ new Request('https://app.test', {
19
+ headers: { 'x-forwarded-for': '198.51.100.1', 'x-real-ip': '198.51.100.2' },
20
+ })
21
+ )
22
+ ).toBe('192.0.2.1')
23
+ })
24
+ it('uses only the configured proxy header and fails closed on absent/invalid values', async () => {
25
+ const resolve = createClientIpResolver({
26
+ trustedProxyHeader: 'x-byline-client-ip',
27
+ resolvePeerIp: () => '127.0.0.1',
28
+ })
29
+ for (const value of ['', '192.0.2.1, 192.0.2.2', 'garbage']) {
30
+ expect(
31
+ await resolve(new Request('https://app.test', { headers: { 'x-byline-client-ip': value } }))
32
+ ).toBeNull()
33
+ }
34
+ expect(
35
+ await resolve(
36
+ new Request('https://app.test', { headers: { 'x-byline-client-ip': '2001:0db8::1' } })
37
+ )
38
+ ).toBe('2001:db8::1')
39
+ })
40
+ it('normalizes equivalent IP representations', () => {
41
+ expect(normalizeClientIp('::ffff:192.0.2.1')).toBe('192.0.2.1')
42
+ expect(normalizeClientIp('::ffff:c000:201')).toBe('192.0.2.1')
43
+ expect(normalizeClientIp('fe80::1%en0')).toBeNull()
44
+ })
45
+ })