@byline/host-tanstack-start 5.0.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 (252) 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/integrations/byline-admin-services.js +7 -1
  9. package/dist/integrations/client-ip.d.ts +19 -0
  10. package/dist/integrations/client-ip.js +34 -0
  11. package/dist/integrations/session-coordination.d.ts +24 -0
  12. package/dist/integrations/session-coordination.js +85 -0
  13. package/dist/integrations/session-middleware.d.ts +11 -0
  14. package/dist/integrations/session-middleware.js +94 -0
  15. package/dist/integrations/session-request-kind.d.ts +9 -0
  16. package/dist/integrations/session-request-kind.js +10 -0
  17. package/dist/integrations/session-request-middleware.d.ts +9 -0
  18. package/dist/integrations/session-request-middleware.js +7 -0
  19. package/dist/integrations/sign-in-body.d.ts +12 -0
  20. package/dist/integrations/sign-in-body.js +52 -0
  21. package/dist/integrations/sign-in-middleware.d.ts +9 -0
  22. package/dist/integrations/sign-in-middleware.js +10 -0
  23. package/dist/routes/create-admin-layout-route.js +46 -31
  24. package/dist/routes/create-sign-in-route.js +4 -2
  25. package/dist/server-fns/admin-account/change-password.d.ts +3 -1
  26. package/dist/server-fns/admin-account/change-password.js +9 -3
  27. package/dist/server-fns/admin-account/get.d.ts +3 -1
  28. package/dist/server-fns/admin-account/get.js +4 -1
  29. package/dist/server-fns/admin-account/index.d.ts +5 -5
  30. package/dist/server-fns/admin-account/update.d.ts +3 -1
  31. package/dist/server-fns/admin-account/update.js +4 -1
  32. package/dist/server-fns/admin-activity/get.d.ts +3 -1
  33. package/dist/server-fns/admin-activity/get.js +4 -1
  34. package/dist/server-fns/admin-permissions/get-role-abilities.d.ts +3 -1
  35. package/dist/server-fns/admin-permissions/get-role-abilities.js +4 -1
  36. package/dist/server-fns/admin-permissions/index.d.ts +4 -4
  37. package/dist/server-fns/admin-permissions/list-registered.d.ts +3 -1
  38. package/dist/server-fns/admin-permissions/list-registered.js +4 -1
  39. package/dist/server-fns/admin-permissions/set-role-abilities.d.ts +3 -1
  40. package/dist/server-fns/admin-permissions/set-role-abilities.js +4 -1
  41. package/dist/server-fns/admin-permissions/who-has.d.ts +3 -1
  42. package/dist/server-fns/admin-permissions/who-has.js +4 -1
  43. package/dist/server-fns/admin-roles/create.d.ts +3 -1
  44. package/dist/server-fns/admin-roles/create.js +4 -1
  45. package/dist/server-fns/admin-roles/delete.d.ts +3 -1
  46. package/dist/server-fns/admin-roles/delete.js +4 -1
  47. package/dist/server-fns/admin-roles/get.d.ts +3 -1
  48. package/dist/server-fns/admin-roles/get.js +4 -1
  49. package/dist/server-fns/admin-roles/index.d.ts +6 -6
  50. package/dist/server-fns/admin-roles/list.d.ts +3 -1
  51. package/dist/server-fns/admin-roles/list.js +4 -1
  52. package/dist/server-fns/admin-roles/reorder.d.ts +3 -1
  53. package/dist/server-fns/admin-roles/reorder.js +4 -1
  54. package/dist/server-fns/admin-roles/update.d.ts +3 -1
  55. package/dist/server-fns/admin-roles/update.js +4 -1
  56. package/dist/server-fns/admin-users/create.d.ts +3 -1
  57. package/dist/server-fns/admin-users/create.js +4 -1
  58. package/dist/server-fns/admin-users/delete.d.ts +3 -1
  59. package/dist/server-fns/admin-users/delete.js +4 -1
  60. package/dist/server-fns/admin-users/disable.d.ts +3 -1
  61. package/dist/server-fns/admin-users/disable.js +4 -1
  62. package/dist/server-fns/admin-users/enable.d.ts +3 -1
  63. package/dist/server-fns/admin-users/enable.js +4 -1
  64. package/dist/server-fns/admin-users/get-user-roles.d.ts +3 -1
  65. package/dist/server-fns/admin-users/get-user-roles.js +4 -1
  66. package/dist/server-fns/admin-users/get.d.ts +3 -1
  67. package/dist/server-fns/admin-users/get.js +4 -1
  68. package/dist/server-fns/admin-users/index.d.ts +10 -10
  69. package/dist/server-fns/admin-users/list.d.ts +3 -1
  70. package/dist/server-fns/admin-users/list.js +4 -1
  71. package/dist/server-fns/admin-users/set-password.d.ts +3 -1
  72. package/dist/server-fns/admin-users/set-password.js +4 -1
  73. package/dist/server-fns/admin-users/set-user-roles.d.ts +3 -1
  74. package/dist/server-fns/admin-users/set-user-roles.js +4 -1
  75. package/dist/server-fns/admin-users/update.d.ts +3 -1
  76. package/dist/server-fns/admin-users/update.js +4 -1
  77. package/dist/server-fns/ai/execute.d.ts +3 -1
  78. package/dist/server-fns/ai/execute.js +4 -1
  79. package/dist/server-fns/ai/index.d.ts +1 -1
  80. package/dist/server-fns/analytics/maintenance.d.ts +6 -2
  81. package/dist/server-fns/analytics/maintenance.js +7 -2
  82. package/dist/server-fns/analytics/queries.d.ts +18 -6
  83. package/dist/server-fns/analytics/queries.js +19 -6
  84. package/dist/server-fns/auth/current-user.d.ts +1 -0
  85. package/dist/server-fns/auth/current-user.js +5 -2
  86. package/dist/server-fns/auth/renew.d.ts +13 -0
  87. package/dist/server-fns/auth/renew.js +45 -0
  88. package/dist/server-fns/auth/sign-in.d.ts +2 -1
  89. package/dist/server-fns/auth/sign-in.js +88 -17
  90. package/dist/server-fns/auth/sign-out.d.ts +3 -1
  91. package/dist/server-fns/auth/sign-out.js +38 -6
  92. package/dist/server-fns/collections/audit.d.ts +3 -1
  93. package/dist/server-fns/collections/audit.js +4 -1
  94. package/dist/server-fns/collections/copy-to-locale.d.ts +10 -1
  95. package/dist/server-fns/collections/copy-to-locale.js +5 -2
  96. package/dist/server-fns/collections/create.d.ts +10 -1
  97. package/dist/server-fns/collections/create.js +5 -2
  98. package/dist/server-fns/collections/delete-locale.d.ts +10 -1
  99. package/dist/server-fns/collections/delete-locale.js +5 -2
  100. package/dist/server-fns/collections/delete.d.ts +10 -1
  101. package/dist/server-fns/collections/delete.js +5 -2
  102. package/dist/server-fns/collections/duplicate.d.ts +10 -1
  103. package/dist/server-fns/collections/duplicate.js +5 -2
  104. package/dist/server-fns/collections/get.d.ts +7 -0
  105. package/dist/server-fns/collections/get.js +8 -3
  106. package/dist/server-fns/collections/history.d.ts +3 -1
  107. package/dist/server-fns/collections/history.js +4 -1
  108. package/dist/server-fns/collections/index.d.ts +21 -21
  109. package/dist/server-fns/collections/list.d.ts +10 -1
  110. package/dist/server-fns/collections/list.js +5 -2
  111. package/dist/server-fns/collections/reindex.d.ts +3 -1
  112. package/dist/server-fns/collections/reindex.js +4 -1
  113. package/dist/server-fns/collections/reorder.d.ts +10 -1
  114. package/dist/server-fns/collections/reorder.js +5 -2
  115. package/dist/server-fns/collections/restore-version.d.ts +10 -1
  116. package/dist/server-fns/collections/restore-version.js +5 -2
  117. package/dist/server-fns/collections/scheduled-publication.d.ts +22 -5
  118. package/dist/server-fns/collections/scheduled-publication.js +17 -6
  119. package/dist/server-fns/collections/set-list-view-preference.d.ts +3 -1
  120. package/dist/server-fns/collections/set-list-view-preference.js +4 -1
  121. package/dist/server-fns/collections/stats.js +4 -1
  122. package/dist/server-fns/collections/status.d.ts +13 -2
  123. package/dist/server-fns/collections/status.js +8 -3
  124. package/dist/server-fns/collections/tree.d.ts +22 -5
  125. package/dist/server-fns/collections/tree.js +17 -6
  126. package/dist/server-fns/collections/update.d.ts +13 -2
  127. package/dist/server-fns/collections/update.js +8 -3
  128. package/dist/server-fns/collections/upload.d.ts +10 -1
  129. package/dist/server-fns/collections/upload.js +4 -1
  130. package/dist/server-fns/i18n/set-locale.d.ts +3 -1
  131. package/dist/server-fns/i18n/set-locale.js +18 -3
  132. package/dist/server-fns/preview/disable.d.ts +3 -1
  133. package/dist/server-fns/preview/disable.js +4 -1
  134. package/dist/server-fns/preview/enable.d.ts +3 -1
  135. package/dist/server-fns/preview/enable.js +4 -1
  136. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +10 -1
  137. package/dist/server-fns/singletons/cancel-scheduled-publish.js +5 -2
  138. package/dist/server-fns/singletons/change-status.d.ts +10 -1
  139. package/dist/server-fns/singletons/change-status.js +5 -2
  140. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +10 -1
  141. package/dist/server-fns/singletons/confirm-scheduled-publish.js +5 -2
  142. package/dist/server-fns/singletons/copy-to-locale.d.ts +10 -1
  143. package/dist/server-fns/singletons/copy-to-locale.js +5 -2
  144. package/dist/server-fns/singletons/find-by-version.d.ts +3 -1
  145. package/dist/server-fns/singletons/find-by-version.js +4 -1
  146. package/dist/server-fns/singletons/get-scheduled-publish.d.ts +3 -1
  147. package/dist/server-fns/singletons/get-scheduled-publish.js +4 -1
  148. package/dist/server-fns/singletons/get.d.ts +3 -1
  149. package/dist/server-fns/singletons/get.js +4 -1
  150. package/dist/server-fns/singletons/history.d.ts +3 -1
  151. package/dist/server-fns/singletons/history.js +4 -1
  152. package/dist/server-fns/singletons/restore-version.d.ts +10 -1
  153. package/dist/server-fns/singletons/restore-version.js +5 -2
  154. package/dist/server-fns/singletons/schedule-publish.d.ts +10 -1
  155. package/dist/server-fns/singletons/schedule-publish.js +5 -2
  156. package/dist/server-fns/singletons/unpublish.d.ts +10 -1
  157. package/dist/server-fns/singletons/unpublish.js +5 -2
  158. package/dist/server-fns/singletons/update.d.ts +10 -1
  159. package/dist/server-fns/singletons/update.js +5 -2
  160. package/package.json +19 -19
  161. package/src/admin-shell/chrome/admin-app-bar.tsx +11 -6
  162. package/src/admin-shell/chrome/session-change-boundary.test.tsx +78 -0
  163. package/src/admin-shell/chrome/session-change-boundary.tsx +94 -0
  164. package/src/admin-shell/chrome/sign-in-page.tsx +68 -3
  165. package/src/integrations/byline-admin-services.ts +7 -1
  166. package/src/integrations/client-ip.test.node.ts +45 -0
  167. package/src/integrations/client-ip.ts +54 -0
  168. package/src/integrations/framework-fetch-shape.test.node.ts +56 -0
  169. package/src/integrations/session-client.test.node.ts +121 -0
  170. package/src/integrations/session-coordination.test.node.ts +77 -0
  171. package/src/integrations/session-coordination.ts +96 -0
  172. package/src/integrations/session-middleware.test.node.ts +87 -0
  173. package/src/integrations/session-middleware.ts +107 -0
  174. package/src/integrations/session-request-kind.ts +18 -0
  175. package/src/integrations/session-request-middleware.ts +17 -0
  176. package/src/integrations/sign-in-body.test.node.ts +59 -0
  177. package/src/integrations/sign-in-body.ts +50 -0
  178. package/src/integrations/sign-in-middleware.ts +24 -0
  179. package/src/routes/create-admin-layout-route.tsx +48 -30
  180. package/src/routes/create-sign-in-route.tsx +10 -2
  181. package/src/server-fns/admin-account/change-password.test.node.ts +69 -0
  182. package/src/server-fns/admin-account/change-password.ts +13 -2
  183. package/src/server-fns/admin-account/get.ts +2 -0
  184. package/src/server-fns/admin-account/update.ts +2 -0
  185. package/src/server-fns/admin-activity/get.ts +2 -0
  186. package/src/server-fns/admin-permissions/get-role-abilities.ts +2 -0
  187. package/src/server-fns/admin-permissions/list-registered.ts +5 -4
  188. package/src/server-fns/admin-permissions/set-role-abilities.ts +2 -0
  189. package/src/server-fns/admin-permissions/who-has.ts +2 -0
  190. package/src/server-fns/admin-roles/create.ts +2 -0
  191. package/src/server-fns/admin-roles/delete.ts +2 -0
  192. package/src/server-fns/admin-roles/get.ts +2 -0
  193. package/src/server-fns/admin-roles/list.ts +5 -4
  194. package/src/server-fns/admin-roles/reorder.ts +2 -0
  195. package/src/server-fns/admin-roles/update.ts +2 -0
  196. package/src/server-fns/admin-users/create.ts +2 -0
  197. package/src/server-fns/admin-users/delete.ts +2 -0
  198. package/src/server-fns/admin-users/disable.ts +2 -0
  199. package/src/server-fns/admin-users/enable.ts +2 -0
  200. package/src/server-fns/admin-users/get-user-roles.ts +2 -0
  201. package/src/server-fns/admin-users/get.ts +2 -0
  202. package/src/server-fns/admin-users/list.ts +2 -0
  203. package/src/server-fns/admin-users/set-password.ts +2 -0
  204. package/src/server-fns/admin-users/set-user-roles.ts +2 -0
  205. package/src/server-fns/admin-users/update.ts +2 -0
  206. package/src/server-fns/ai/execute.ts +3 -0
  207. package/src/server-fns/analytics/maintenance.ts +4 -0
  208. package/src/server-fns/analytics/queries.ts +14 -8
  209. package/src/server-fns/auth/current-user.ts +7 -3
  210. package/src/server-fns/auth/renew.test.node.ts +85 -0
  211. package/src/server-fns/auth/renew.ts +54 -0
  212. package/src/server-fns/auth/sign-in.test.node.ts +114 -0
  213. package/src/server-fns/auth/sign-in.ts +80 -27
  214. package/src/server-fns/auth/sign-out.test.node.ts +85 -0
  215. package/src/server-fns/auth/sign-out.ts +44 -19
  216. package/src/server-fns/collections/audit.ts +2 -0
  217. package/src/server-fns/collections/copy-to-locale.ts +3 -1
  218. package/src/server-fns/collections/create.ts +3 -1
  219. package/src/server-fns/collections/delete-locale.ts +3 -1
  220. package/src/server-fns/collections/delete.ts +3 -1
  221. package/src/server-fns/collections/duplicate.ts +3 -1
  222. package/src/server-fns/collections/get.ts +4 -1
  223. package/src/server-fns/collections/history.ts +2 -0
  224. package/src/server-fns/collections/list.ts +3 -1
  225. package/src/server-fns/collections/reindex.ts +2 -0
  226. package/src/server-fns/collections/reorder.ts +3 -1
  227. package/src/server-fns/collections/restore-version.ts +3 -1
  228. package/src/server-fns/collections/revision-input.test.node.ts +14 -0
  229. package/src/server-fns/collections/scheduled-publication.ts +14 -7
  230. package/src/server-fns/collections/set-list-view-preference.ts +2 -0
  231. package/src/server-fns/collections/stats.ts +2 -0
  232. package/src/server-fns/collections/status.ts +4 -1
  233. package/src/server-fns/collections/tree.ts +7 -1
  234. package/src/server-fns/collections/update.ts +4 -1
  235. package/src/server-fns/collections/upload-singleton.test.node.ts +8 -1
  236. package/src/server-fns/collections/upload.ts +10 -0
  237. package/src/server-fns/i18n/set-locale.ts +19 -3
  238. package/src/server-fns/preview/disable.ts +8 -4
  239. package/src/server-fns/preview/enable.ts +9 -5
  240. package/src/server-fns/singletons/cancel-scheduled-publish.ts +3 -1
  241. package/src/server-fns/singletons/change-status.ts +3 -1
  242. package/src/server-fns/singletons/confirm-scheduled-publish.ts +3 -1
  243. package/src/server-fns/singletons/copy-to-locale.ts +3 -1
  244. package/src/server-fns/singletons/find-by-version.ts +2 -0
  245. package/src/server-fns/singletons/get-scheduled-publish.ts +2 -0
  246. package/src/server-fns/singletons/get.ts +2 -0
  247. package/src/server-fns/singletons/history.ts +2 -0
  248. package/src/server-fns/singletons/restore-version.ts +3 -1
  249. package/src/server-fns/singletons/schedule-publish.ts +3 -1
  250. package/src/server-fns/singletons/singletons.test.node.ts +6 -0
  251. package/src/server-fns/singletons/unpublish.ts +3 -1
  252. package/src/server-fns/singletons/update.ts +3 -1
@@ -6,6 +6,8 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import { useEffect, useState } from 'react'
10
+
9
11
  import { SignInForm } from '@byline/admin/auth/components/sign-in-form'
10
12
  import { BylineAdminServicesProvider } from '@byline/admin/services'
11
13
  import { getAdminConfig } from '@byline/core'
@@ -15,10 +17,20 @@ import cx from 'clsx'
15
17
 
16
18
  import { buildLocaleDefinitions } from '../../i18n/locale-definitions.js'
17
19
  import { bylineAdminServices } from '../../integrations/byline-admin-services.js'
20
+ import {
21
+ coordinateAuthAction,
22
+ expectedSession,
23
+ flagSessionChanged,
24
+ observeSession,
25
+ renewSingleFlight,
26
+ } from '../../integrations/session-coordination.js'
27
+ import { renewAdminSession } from '../../server-fns/auth/renew.js'
18
28
  import { setAdminLocaleFn } from '../../server-fns/i18n/index.js'
29
+ import { SessionChangeBoundary } from './session-change-boundary.js'
19
30
  import styles from './sign-in-page.module.css'
20
31
 
21
32
  interface SignInPageProps {
33
+ reauthenticate?: boolean
22
34
  redirectTo: string
23
35
  activeLocale: LocaleCode
24
36
  homeUrl?: string
@@ -44,14 +56,54 @@ interface SignInPageProps {
44
56
  * Threads an optional host-owned `homeUrl` into `SignInForm` so the form's
45
57
  * action row can render a plain "Home" link beside the submit button.
46
58
  */
47
- export function SignInPage({ redirectTo, activeLocale, homeUrl }: SignInPageProps) {
59
+ export function SignInPage({
60
+ redirectTo,
61
+ activeLocale,
62
+ homeUrl,
63
+ reauthenticate = false,
64
+ }: SignInPageProps) {
65
+ const [checking, setChecking] = useState(!reauthenticate)
66
+ const [bootstrapError, setBootstrapError] = useState(false)
67
+ useEffect(() => {
68
+ if (reauthenticate) return
69
+ let active = true
70
+ void renewSingleFlight(() =>
71
+ coordinateAuthAction(async () => {
72
+ const result = await renewAdminSession({
73
+ data: { expectedSessionId: expectedSession() ?? undefined },
74
+ })
75
+ observeSession(result.sessionId)
76
+ })
77
+ )
78
+ .then(() => {
79
+ if (active) window.location.replace(redirectTo)
80
+ })
81
+ .catch((error) => {
82
+ if (error?.code === 'ERR_SESSION_CHANGED') flagSessionChanged()
83
+ else if (
84
+ ![
85
+ 'ERR_UNAUTHENTICATED',
86
+ 'ERR_INVALID_TOKEN',
87
+ 'ERR_REVOKED_TOKEN',
88
+ 'ERR_ACCOUNT_DISABLED',
89
+ ].includes(error?.code)
90
+ )
91
+ setBootstrapError(true)
92
+ })
93
+ .finally(() => {
94
+ if (active) setChecking(false)
95
+ })
96
+ return () => {
97
+ active = false
98
+ }
99
+ }, [redirectTo, reauthenticate])
48
100
  const { i18n } = getAdminConfig()
49
101
  const localeDefinitions = buildLocaleDefinitions(i18n.admin.locales, i18n.admin.localeDefinitions)
50
102
  const handleSetLocale = async (next: LocaleCode) => {
51
103
  await setAdminLocaleFn({ data: { locale: next } })
52
104
  window.location.reload()
53
105
  }
54
- return (
106
+ const content = (
55
107
  <I18nProvider
56
108
  bundle={i18n.translations ?? {}}
57
109
  activeLocale={activeLocale}
@@ -61,14 +113,27 @@ export function SignInPage({ redirectTo, activeLocale, homeUrl }: SignInPageProp
61
113
  >
62
114
  <BylineAdminServicesProvider services={bylineAdminServices}>
63
115
  <main className={cx('byline-sign-in-page', styles.main)}>
116
+ <noscript>JavaScript is required to sign in or renew your admin session.</noscript>
64
117
  <div className={cx('byline-sign-in-page-bar', styles.bar)}>
65
118
  <LanguageMenu />
66
119
  </div>
67
120
  <div className={cx('byline-sign-in-page-inner', styles.inner)}>
68
- <SignInForm redirectTo={redirectTo} homeUrl={homeUrl} />
121
+ {bootstrapError ? (
122
+ <div role="alert">
123
+ <p>Unable to check your session. Please try again.</p>
124
+ <button type="button" onClick={() => window.location.reload()}>
125
+ Try again
126
+ </button>
127
+ </div>
128
+ ) : checking ? (
129
+ <p role="status">Checking your session…</p>
130
+ ) : (
131
+ <SignInForm redirectTo={redirectTo} homeUrl={homeUrl} />
132
+ )}
69
133
  </div>
70
134
  </main>
71
135
  </BylineAdminServicesProvider>
72
136
  </I18nProvider>
73
137
  )
138
+ return reauthenticate ? content : <SessionChangeBoundary>{content}</SessionChangeBoundary>
74
139
  }
@@ -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,
@@ -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
+ })
@@ -0,0 +1,54 @@
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 { isIP } from 'node:net'
10
+
11
+ import type { ClientIpResolver } from '@byline/auth'
12
+
13
+ /** Normalize equivalent IPv6 spellings and IPv4-mapped IPv6 before choosing a rate-limit key. */
14
+ export function normalizeClientIp(value: string | null | undefined): string | null {
15
+ if (!value || value.length > 64) return null
16
+ const ip = value.trim()
17
+ if (isIP(ip) === 4) return ip
18
+ if (isIP(ip) !== 6 || ip.includes('%')) return null
19
+ const normalized = new URL(`http://[${ip}]/`).hostname.slice(1, -1)
20
+ const mapped = /^::ffff:([0-9a-f]+):([0-9a-f]+)$/.exec(normalized)
21
+ if (mapped) {
22
+ const high = Number.parseInt(mapped[1]!, 16)
23
+ const low = Number.parseInt(mapped[2]!, 16)
24
+ return [high >>> 8, high & 255, low >>> 8, low & 255].join('.')
25
+ }
26
+ return normalized
27
+ }
28
+
29
+ /**
30
+ * Configure either a direct peer resolver or an explicitly trusted proxy header.
31
+ * Header mode requires the deployment to overwrite that header and block proxy bypass.
32
+ * Never falls back from a missing/invalid trusted header to another client-supplied header.
33
+ */
34
+ export function createClientIpResolver(options: {
35
+ trustedProxyHeader?: string
36
+ resolvePeerIp: ClientIpResolver
37
+ }): ClientIpResolver {
38
+ const header = options.trustedProxyHeader
39
+ if (header !== undefined && !/^[a-z0-9-]+$/i.test(header))
40
+ throw new Error('Invalid trusted IP header')
41
+ let lastDiagnostic = -Infinity
42
+ return async (request) => {
43
+ const ip = normalizeClientIp(
44
+ header === undefined ? await options.resolvePeerIp(request) : request.headers.get(header)
45
+ )
46
+ if (!ip && performance.now() - lastDiagnostic >= 60_000) {
47
+ lastDiagnostic = performance.now()
48
+ console.warn(
49
+ '[byline:client-ip] No valid single client IP; check the configured peer resolver or trusted proxy header.'
50
+ )
51
+ }
52
+ return ip
53
+ }
54
+ }
@@ -0,0 +1,56 @@
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 { createRequire } from 'node:module'
10
+ import { pathToFileURL } from 'node:url'
11
+
12
+ import { describe, expect, it, vi } from 'vitest'
13
+
14
+ // Exercise the installed framework serializer, not a copy of its fetch logic.
15
+ // Its internal path is deliberately pinned here so an upgrade requires review.
16
+ const frameworkRequire = createRequire(import.meta.resolve('@tanstack/react-start/client-rpc'))
17
+ const coreManifest = frameworkRequire.resolve('@tanstack/start-client-core/package.json')
18
+ const fetcherUrl = new URL('./dist/esm/client-rpc/serverFnFetcher.js', pathToFileURL(coreManifest))
19
+ const { serverFnFetcher } = await import(/* @vite-ignore */ fetcherUrl.href)
20
+ const storageUrl = new URL(
21
+ './dist/esm/index.js',
22
+ pathToFileURL(createRequire(coreManifest).resolve('@tanstack/start-storage-context/package.json'))
23
+ )
24
+ const { runWithStartContext } = await import(/* @vite-ignore */ storageUrl.href)
25
+
26
+ describe('framework server-function fetch shape', () => {
27
+ it.each(['GET', 'POST', 'FormData'])(
28
+ 'keeps %s requests reusable without stream cloning',
29
+ async (kind) => {
30
+ const form = new FormData()
31
+ form.set('upload', new Blob(['content']), 'fixture.txt')
32
+ const fetch = vi.fn(async (url: unknown, init: RequestInit) => {
33
+ expect(
34
+ typeof url === 'string' &&
35
+ (init.body === undefined ||
36
+ typeof init.body === 'string' ||
37
+ init.body instanceof FormData)
38
+ ).toBe(true)
39
+ return new Response('ok', { headers: { 'x-tss-raw': 'true' } })
40
+ })
41
+ await runWithStartContext({ startOptions: {} }, () =>
42
+ serverFnFetcher(
43
+ 'http://localhost/server-fn/fixture',
44
+ [
45
+ {
46
+ method: kind === 'GET' ? 'GET' : 'POST',
47
+ data: kind === 'FormData' ? form : { title: 'example' },
48
+ },
49
+ ],
50
+ fetch
51
+ )
52
+ )
53
+ expect(fetch).toHaveBeenCalledOnce()
54
+ }
55
+ )
56
+ })
@@ -0,0 +1,121 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
+
11
+ const mocks = vi.hoisted(() => ({ renew: vi.fn() }))
12
+ vi.mock('../server-fns/auth/renew.js', () => ({ renewAdminSession: mocks.renew }))
13
+ vi.mock('@byline/client/server', () => ({ getAdminRequestContext: vi.fn() }))
14
+
15
+ let middleware: typeof import('./session-middleware.js')
16
+ let coordination: typeof import('./session-coordination.js')
17
+ const boundary = (outcome: string) =>
18
+ new Response('result', { headers: { 'x-byline-auth-boundary': outcome } })
19
+ const request = async (body: BodyInit = 'edit') => {
20
+ return middleware.adminSessionMiddleware.options.client?.({
21
+ next: (options: any) => options.fetch('http://localhost/business', { method: 'POST', body }),
22
+ } as any)
23
+ }
24
+ beforeEach(async () => {
25
+ vi.resetModules()
26
+ vi.resetAllMocks()
27
+ const storage = new Map<string, string>()
28
+ vi.stubGlobal('window', {
29
+ sessionStorage: {
30
+ getItem: (key: string) => storage.get(key) ?? null,
31
+ setItem: (key: string, value: string) => storage.set(key, value),
32
+ },
33
+ })
34
+ vi.stubGlobal('navigator', {})
35
+ vi.stubGlobal('BroadcastChannel', undefined)
36
+ coordination = await import('./session-coordination.js')
37
+ middleware = await import('./session-middleware.js')
38
+ coordination.acceptSession('A')
39
+ mocks.renew.mockResolvedValue({ sessionId: 'A' })
40
+ })
41
+ afterEach(() => vi.unstubAllGlobals())
42
+
43
+ describe('coordinated business resend', () => {
44
+ it('coalesces six expiry responses into one renewal and resends each operation once', async () => {
45
+ const fetch = vi.fn().mockImplementationOnce(() => boundary('renew'))
46
+ for (let i = 1; i < 6; i++) fetch.mockImplementationOnce(() => boundary('renew'))
47
+ fetch.mockImplementation(() => boundary('handled'))
48
+ vi.stubGlobal('fetch', fetch)
49
+ await Promise.all(Array.from({ length: 6 }, () => request()))
50
+ expect(mocks.renew).toHaveBeenCalledTimes(1)
51
+ expect(mocks.renew).toHaveBeenCalledWith({ data: { expectedSessionId: 'A' } })
52
+ expect(fetch).toHaveBeenCalledTimes(12)
53
+ })
54
+ it('re-serializes multipart uploads without cloning or teeing the request', async () => {
55
+ const bodies: Array<{ title: string; file: string }> = []
56
+ const clone = vi.spyOn(Request.prototype, 'clone')
57
+ vi.stubGlobal(
58
+ 'fetch',
59
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
60
+ const form = await new Request(input, init).formData()
61
+ bodies.push({
62
+ title: String(form.get('title')),
63
+ file: await (form.get('upload') as Blob).text(),
64
+ })
65
+ return boundary(bodies.length === 1 ? 'renew' : 'handled')
66
+ })
67
+ )
68
+ const body = new FormData()
69
+ body.set('title', 'An editorial change')
70
+ body.set('upload', new Blob(['file-content']), 'example.txt')
71
+ await request(body)
72
+ expect(bodies).toHaveLength(2)
73
+ expect(bodies[0]).toEqual({ title: 'An editorial change', file: 'file-content' })
74
+ expect(bodies[1]).toEqual(bodies[0])
75
+ expect(clone).not.toHaveBeenCalled()
76
+ clone.mockRestore()
77
+ })
78
+ it('renews but requires an explicit retry for a one-shot stream body', async () => {
79
+ const fetch = vi.fn().mockResolvedValue(boundary('renew'))
80
+ vi.stubGlobal('fetch', fetch)
81
+ const stream = new ReadableStream({
82
+ start(controller) {
83
+ controller.close()
84
+ },
85
+ })
86
+ await expect(request(stream)).rejects.toThrow('Please retry the operation')
87
+ expect(fetch).toHaveBeenCalledOnce()
88
+ expect(mocks.renew).toHaveBeenCalledOnce()
89
+ })
90
+ it('never retries a post-handler error or an ambiguous network failure', async () => {
91
+ const fetch = vi
92
+ .fn()
93
+ .mockResolvedValue(
94
+ new Response('failed', { status: 500, headers: { 'x-byline-auth-boundary': 'handled' } })
95
+ )
96
+ vi.stubGlobal('fetch', fetch)
97
+ await request()
98
+ expect(fetch).toHaveBeenCalledOnce()
99
+ fetch.mockReset().mockRejectedValue(new Error('connection lost'))
100
+ await expect(request()).rejects.toThrow('connection lost')
101
+ expect(fetch).toHaveBeenCalledOnce()
102
+ expect(mocks.renew).not.toHaveBeenCalled()
103
+ })
104
+ it('does not replay work when the session changes during renewal', async () => {
105
+ const fetch = vi.fn().mockResolvedValue(boundary('renew'))
106
+ vi.stubGlobal('fetch', fetch)
107
+ mocks.renew.mockImplementation(async () => {
108
+ coordination.flagSessionChanged()
109
+ return { sessionId: 'A' }
110
+ })
111
+ await expect(request()).rejects.toThrow('Session changed')
112
+ expect(fetch).toHaveBeenCalledOnce()
113
+ })
114
+ it('stops after one resend even if access expires again', async () => {
115
+ const fetch = vi.fn().mockImplementation(() => boundary('renew'))
116
+ vi.stubGlobal('fetch', fetch)
117
+ await request()
118
+ expect(fetch).toHaveBeenCalledTimes(2)
119
+ expect(mocks.renew).toHaveBeenCalledOnce()
120
+ })
121
+ })
@@ -0,0 +1,77 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
10
+
11
+ beforeEach(() => {
12
+ vi.resetModules()
13
+ vi.unstubAllGlobals()
14
+ })
15
+ function browserStorage() {
16
+ const values = new Map<string, string>()
17
+ vi.stubGlobal('window', {
18
+ sessionStorage: {
19
+ getItem: (k: string) => values.get(k) ?? null,
20
+ setItem: (k: string, v: string) => values.set(k, v),
21
+ },
22
+ })
23
+ return values
24
+ }
25
+ describe('browser session coordination', () => {
26
+ it('six concurrent renewals share one operation and a later request can renew again', async () => {
27
+ const { renewSingleFlight } = await import('./session-coordination.js')
28
+ let release!: () => void
29
+ const pending = new Promise<void>((resolve) => {
30
+ release = resolve
31
+ })
32
+ const work = vi.fn(() => pending)
33
+ const calls = Array.from({ length: 6 }, () => renewSingleFlight(work))
34
+ expect(work).toHaveBeenCalledOnce()
35
+ release()
36
+ await Promise.all(calls)
37
+ await renewSingleFlight(work)
38
+ expect(work).toHaveBeenCalledTimes(2)
39
+ })
40
+ it('rejects work captured before session change even after acknowledgement', async () => {
41
+ browserStorage()
42
+ const state = await import('./session-coordination.js')
43
+ state.acceptSession('A')
44
+ const old = state.sessionSnapshot()
45
+ expect(() => state.observeSession('B')).toThrow('Session changed')
46
+ expect(() => state.requireUnchanged(old)).toThrow()
47
+ state.acceptSession('B')
48
+ expect(() => state.requireUnchanged(old)).toThrow()
49
+ expect(() => state.requireUnchanged(state.sessionSnapshot())).not.toThrow()
50
+ })
51
+ it('retains successful sign-in intent across navigation and detects a different server login', async () => {
52
+ const values = browserStorage()
53
+ const before = await import('./session-coordination.js')
54
+ before.acceptSession('B')
55
+ vi.resetModules()
56
+ const after = await import('./session-coordination.js')
57
+ expect(after.expectedSession()).toBe('B')
58
+ expect(() => after.observeSession('A')).toThrow()
59
+ expect(values.get('byline.expected-login')).toBe('B')
60
+ })
61
+ it('serializes auth writes and releases the queue after failure without Web Locks', async () => {
62
+ vi.stubGlobal('navigator', {})
63
+ const { coordinateAuthAction } = await import('./session-coordination.js')
64
+ const order: number[] = []
65
+ const a = coordinateAuthAction(async () => {
66
+ order.push(1)
67
+ throw new Error('failed')
68
+ })
69
+ const b = coordinateAuthAction(async () => {
70
+ order.push(2)
71
+ return 'ok'
72
+ })
73
+ await expect(a).rejects.toThrow()
74
+ await expect(b).resolves.toBe('ok')
75
+ expect(order).toEqual([1, 2])
76
+ })
77
+ })
@@ -0,0 +1,96 @@
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
+ /** Browser coordination metadata only. No access or refresh credentials are exposed to JavaScript. */
10
+ const storageKey = 'byline.expected-login'
11
+ let expected: string | null = null
12
+ let epoch = 0
13
+ let changed = false
14
+ let renewal: Promise<void> | undefined
15
+ const listeners = new Set<() => void>()
16
+ export const sessionChangedError = () =>
17
+ new Error('Session changed. Acknowledge the active account before continuing.')
18
+ export function expectedSession() {
19
+ if (typeof window === 'undefined') return null
20
+ if (!expected) expected = window.sessionStorage.getItem(storageKey)
21
+ return expected
22
+ }
23
+ export function sessionSnapshot() {
24
+ return { sessionId: expectedSession(), epoch }
25
+ }
26
+ export function requireUnchanged(snapshot: ReturnType<typeof sessionSnapshot>) {
27
+ if (changed || snapshot.epoch !== epoch || snapshot.sessionId !== expectedSession())
28
+ throw sessionChangedError()
29
+ }
30
+ export function flagSessionChanged() {
31
+ changed = true
32
+ epoch++
33
+ for (const listener of listeners) listener()
34
+ }
35
+ export function acceptSession(sessionId: string) {
36
+ if (typeof window === 'undefined') return
37
+ if (typeof sessionId !== 'string' || !sessionId)
38
+ throw new Error('Provider omitted login identity')
39
+ window.sessionStorage.setItem(storageKey, sessionId)
40
+ expected = sessionId
41
+ changed = false
42
+ epoch++
43
+ for (const listener of listeners) listener()
44
+ }
45
+ export function observeSession(sessionId: string) {
46
+ const old = expectedSession()
47
+ if (old && old !== sessionId) {
48
+ flagSessionChanged()
49
+ throw sessionChangedError()
50
+ }
51
+ if (!old) acceptSession(sessionId)
52
+ }
53
+ export function sessionEpoch() {
54
+ return epoch
55
+ }
56
+ export function sessionIsChanged() {
57
+ return changed
58
+ }
59
+ export function subscribeSessionChange(listener: () => void) {
60
+ listeners.add(listener)
61
+ return () => {
62
+ listeners.delete(listener)
63
+ }
64
+ }
65
+ export function renewSingleFlight(work: () => Promise<void>) {
66
+ if (!renewal)
67
+ renewal = work().finally(() => {
68
+ renewal = undefined
69
+ })
70
+ return renewal
71
+ }
72
+
73
+ let authQueue: Promise<unknown> = Promise.resolve()
74
+ export function coordinateAuthAction<T>(work: () => Promise<T>): Promise<T> {
75
+ const run = authQueue
76
+ .catch(() => {})
77
+ .then(async () => {
78
+ if (typeof navigator !== 'undefined' && navigator.locks)
79
+ return navigator.locks.request('byline.auth', work)
80
+ return work()
81
+ })
82
+ authQueue = run
83
+ return run
84
+ }
85
+ let channel: BroadcastChannel | undefined
86
+ export function startSessionNotifications() {
87
+ if (typeof BroadcastChannel === 'undefined' || channel) return
88
+ channel = new BroadcastChannel('byline.session')
89
+ channel.onmessage = () => {
90
+ flagSessionChanged()
91
+ }
92
+ }
93
+ export function notifySessionAction() {
94
+ startSessionNotifications()
95
+ channel?.postMessage({ type: 'session-changed' })
96
+ }
@@ -0,0 +1,87 @@
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 { ERR_ACCESS_EXPIRED, ERR_REVOKED_TOKEN } from '@byline/auth'
10
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
11
+
12
+ const mocks = vi.hoisted(() => ({
13
+ request: {},
14
+ headers: new Map<string, string>(),
15
+ auth: vi.fn(),
16
+ renew: vi.fn(),
17
+ }))
18
+ vi.mock('@tanstack/react-start/server', () => ({
19
+ getRequest: () => mocks.request,
20
+ setResponseHeader: (k: string, v: string) => mocks.headers.set(k, v),
21
+ }))
22
+ vi.mock('@byline/client/server', () => ({ getAdminRequestContext: mocks.auth }))
23
+ vi.mock('../server-fns/auth/renew.js', () => ({ renewAdminSession: mocks.renew }))
24
+
25
+ import { adminSessionMiddleware } from './session-middleware.js'
26
+ import { recordSessionRequestKind } from './session-request-kind.js'
27
+
28
+ const server = adminSessionMiddleware.options.server!
29
+ const invoke = (next: any, expected: unknown = 'login-A', method = 'POST') =>
30
+ server({ next, context: { bylineExpectedSessionId: expected }, method } as any)
31
+ beforeEach(() => {
32
+ vi.clearAllMocks()
33
+ mocks.headers.clear()
34
+ recordSessionRequestKind(mocks.request, 'serverFn')
35
+ mocks.auth.mockResolvedValue({ sessionId: 'login-A', actor: { id: 'account-X' } })
36
+ })
37
+ describe('pre-handler session boundary', () => {
38
+ it.each([null, undefined, '', 'login-B'])(
39
+ 'rejects missing or changed expected sid %s before any side effect',
40
+ async (expected) => {
41
+ const write = vi.fn()
42
+ // Explicit undefined must not invoke the helper default.
43
+ await expect(
44
+ server({
45
+ next: write,
46
+ context: { bylineExpectedSessionId: expected },
47
+ method: 'POST',
48
+ } as any)
49
+ ).rejects.toMatchObject({ code: 'ERR_SESSION_CHANGED' })
50
+ expect(write).not.toHaveBeenCalled()
51
+ expect(mocks.headers.get('x-byline-auth-boundary')).toBe('changed')
52
+ }
53
+ )
54
+ it('signals renewal only for expiry before the handler starts', async () => {
55
+ const write = vi.fn()
56
+ mocks.auth.mockRejectedValue(ERR_ACCESS_EXPIRED({ message: 'expired' }))
57
+ await expect(invoke(write)).rejects.toThrow()
58
+ expect(write).not.toHaveBeenCalled()
59
+ expect(mocks.headers.get('x-byline-auth-boundary')).toBe('renew')
60
+ })
61
+ it('does not renew revoked credentials or database errors', async () => {
62
+ for (const error of [ERR_REVOKED_TOKEN({ message: 'revoked' }), new Error('db unavailable')]) {
63
+ const write = vi.fn()
64
+ mocks.auth.mockRejectedValue(error)
65
+ await expect(invoke(write)).rejects.toThrow()
66
+ expect(write).not.toHaveBeenCalled()
67
+ expect(mocks.headers.has('x-byline-auth-boundary')).toBe(false)
68
+ }
69
+ })
70
+ it('never turns an expiry error thrown after a side effect into a retry signal', async () => {
71
+ const write = vi.fn(async () => {
72
+ mocks.headers.set('x-byline-auth-boundary', 'renew')
73
+ throw ERR_ACCESS_EXPIRED({ message: 'handler error' })
74
+ })
75
+ await expect(invoke(write)).rejects.toThrow()
76
+ expect(write).toHaveBeenCalledOnce()
77
+ expect(mocks.headers.get('x-byline-auth-boundary')).toBe('handled')
78
+ })
79
+ it('allows server-classified SSR reads, but not SSR mutations without expected sid', async () => {
80
+ recordSessionRequestKind(mocks.request, 'router')
81
+ const read = vi.fn(async () => ({}))
82
+ await invoke(read, null, 'GET')
83
+ expect(read).toHaveBeenCalledOnce()
84
+ await expect(invoke(read, null)).rejects.toMatchObject({ code: 'ERR_SESSION_CHANGED' })
85
+ expect(read).toHaveBeenCalledOnce()
86
+ })
87
+ })