@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.
- package/dist/admin-shell/chrome/admin-app-bar.d.ts +7 -0
- package/dist/admin-shell/chrome/admin-app-bar.js +10 -2
- package/dist/admin-shell/chrome/session-change-boundary.d.ts +14 -0
- package/dist/admin-shell/chrome/session-change-boundary.js +87 -0
- package/dist/admin-shell/chrome/session-change-boundary.test.d.ts +8 -0
- package/dist/admin-shell/chrome/sign-in-page.d.ts +2 -1
- package/dist/admin-shell/chrome/sign-in-page.js +60 -3
- package/dist/integrations/byline-admin-services.js +7 -1
- package/dist/integrations/client-ip.d.ts +19 -0
- package/dist/integrations/client-ip.js +34 -0
- package/dist/integrations/session-coordination.d.ts +24 -0
- package/dist/integrations/session-coordination.js +85 -0
- package/dist/integrations/session-middleware.d.ts +11 -0
- package/dist/integrations/session-middleware.js +94 -0
- package/dist/integrations/session-request-kind.d.ts +9 -0
- package/dist/integrations/session-request-kind.js +10 -0
- package/dist/integrations/session-request-middleware.d.ts +9 -0
- package/dist/integrations/session-request-middleware.js +7 -0
- package/dist/integrations/sign-in-body.d.ts +12 -0
- package/dist/integrations/sign-in-body.js +52 -0
- package/dist/integrations/sign-in-middleware.d.ts +9 -0
- package/dist/integrations/sign-in-middleware.js +10 -0
- package/dist/routes/create-admin-layout-route.js +46 -31
- package/dist/routes/create-sign-in-route.js +4 -2
- package/dist/server-fns/admin-account/change-password.d.ts +3 -1
- package/dist/server-fns/admin-account/change-password.js +9 -3
- package/dist/server-fns/admin-account/get.d.ts +3 -1
- package/dist/server-fns/admin-account/get.js +4 -1
- package/dist/server-fns/admin-account/index.d.ts +5 -5
- package/dist/server-fns/admin-account/update.d.ts +3 -1
- package/dist/server-fns/admin-account/update.js +4 -1
- package/dist/server-fns/admin-activity/get.d.ts +3 -1
- package/dist/server-fns/admin-activity/get.js +4 -1
- package/dist/server-fns/admin-permissions/get-role-abilities.d.ts +3 -1
- package/dist/server-fns/admin-permissions/get-role-abilities.js +4 -1
- package/dist/server-fns/admin-permissions/index.d.ts +4 -4
- package/dist/server-fns/admin-permissions/list-registered.d.ts +3 -1
- package/dist/server-fns/admin-permissions/list-registered.js +4 -1
- package/dist/server-fns/admin-permissions/set-role-abilities.d.ts +3 -1
- package/dist/server-fns/admin-permissions/set-role-abilities.js +4 -1
- package/dist/server-fns/admin-permissions/who-has.d.ts +3 -1
- package/dist/server-fns/admin-permissions/who-has.js +4 -1
- package/dist/server-fns/admin-roles/create.d.ts +3 -1
- package/dist/server-fns/admin-roles/create.js +4 -1
- package/dist/server-fns/admin-roles/delete.d.ts +3 -1
- package/dist/server-fns/admin-roles/delete.js +4 -1
- package/dist/server-fns/admin-roles/get.d.ts +3 -1
- package/dist/server-fns/admin-roles/get.js +4 -1
- package/dist/server-fns/admin-roles/index.d.ts +6 -6
- package/dist/server-fns/admin-roles/list.d.ts +3 -1
- package/dist/server-fns/admin-roles/list.js +4 -1
- package/dist/server-fns/admin-roles/reorder.d.ts +3 -1
- package/dist/server-fns/admin-roles/reorder.js +4 -1
- package/dist/server-fns/admin-roles/update.d.ts +3 -1
- package/dist/server-fns/admin-roles/update.js +4 -1
- package/dist/server-fns/admin-users/create.d.ts +3 -1
- package/dist/server-fns/admin-users/create.js +4 -1
- package/dist/server-fns/admin-users/delete.d.ts +3 -1
- package/dist/server-fns/admin-users/delete.js +4 -1
- package/dist/server-fns/admin-users/disable.d.ts +3 -1
- package/dist/server-fns/admin-users/disable.js +4 -1
- package/dist/server-fns/admin-users/enable.d.ts +3 -1
- package/dist/server-fns/admin-users/enable.js +4 -1
- package/dist/server-fns/admin-users/get-user-roles.d.ts +3 -1
- package/dist/server-fns/admin-users/get-user-roles.js +4 -1
- package/dist/server-fns/admin-users/get.d.ts +3 -1
- package/dist/server-fns/admin-users/get.js +4 -1
- package/dist/server-fns/admin-users/index.d.ts +10 -10
- package/dist/server-fns/admin-users/list.d.ts +3 -1
- package/dist/server-fns/admin-users/list.js +4 -1
- package/dist/server-fns/admin-users/set-password.d.ts +3 -1
- package/dist/server-fns/admin-users/set-password.js +4 -1
- package/dist/server-fns/admin-users/set-user-roles.d.ts +3 -1
- package/dist/server-fns/admin-users/set-user-roles.js +4 -1
- package/dist/server-fns/admin-users/update.d.ts +3 -1
- package/dist/server-fns/admin-users/update.js +4 -1
- package/dist/server-fns/ai/execute.d.ts +3 -1
- package/dist/server-fns/ai/execute.js +4 -1
- package/dist/server-fns/ai/index.d.ts +1 -1
- package/dist/server-fns/analytics/maintenance.d.ts +6 -2
- package/dist/server-fns/analytics/maintenance.js +7 -2
- package/dist/server-fns/analytics/queries.d.ts +18 -6
- package/dist/server-fns/analytics/queries.js +19 -6
- package/dist/server-fns/auth/current-user.d.ts +1 -0
- package/dist/server-fns/auth/current-user.js +5 -2
- package/dist/server-fns/auth/renew.d.ts +13 -0
- package/dist/server-fns/auth/renew.js +45 -0
- package/dist/server-fns/auth/sign-in.d.ts +2 -1
- package/dist/server-fns/auth/sign-in.js +88 -17
- package/dist/server-fns/auth/sign-out.d.ts +3 -1
- package/dist/server-fns/auth/sign-out.js +38 -6
- package/dist/server-fns/collections/audit.d.ts +3 -1
- package/dist/server-fns/collections/audit.js +4 -1
- package/dist/server-fns/collections/copy-to-locale.d.ts +10 -1
- package/dist/server-fns/collections/copy-to-locale.js +5 -2
- package/dist/server-fns/collections/create.d.ts +10 -1
- package/dist/server-fns/collections/create.js +5 -2
- package/dist/server-fns/collections/delete-locale.d.ts +10 -1
- package/dist/server-fns/collections/delete-locale.js +5 -2
- package/dist/server-fns/collections/delete.d.ts +10 -1
- package/dist/server-fns/collections/delete.js +5 -2
- package/dist/server-fns/collections/duplicate.d.ts +10 -1
- package/dist/server-fns/collections/duplicate.js +5 -2
- package/dist/server-fns/collections/get.d.ts +7 -0
- package/dist/server-fns/collections/get.js +8 -3
- package/dist/server-fns/collections/history.d.ts +3 -1
- package/dist/server-fns/collections/history.js +4 -1
- package/dist/server-fns/collections/index.d.ts +21 -21
- package/dist/server-fns/collections/list.d.ts +10 -1
- package/dist/server-fns/collections/list.js +5 -2
- package/dist/server-fns/collections/reindex.d.ts +3 -1
- package/dist/server-fns/collections/reindex.js +4 -1
- package/dist/server-fns/collections/reorder.d.ts +10 -1
- package/dist/server-fns/collections/reorder.js +5 -2
- package/dist/server-fns/collections/restore-version.d.ts +10 -1
- package/dist/server-fns/collections/restore-version.js +5 -2
- package/dist/server-fns/collections/scheduled-publication.d.ts +22 -5
- package/dist/server-fns/collections/scheduled-publication.js +17 -6
- package/dist/server-fns/collections/set-list-view-preference.d.ts +3 -1
- package/dist/server-fns/collections/set-list-view-preference.js +4 -1
- package/dist/server-fns/collections/stats.js +4 -1
- package/dist/server-fns/collections/status.d.ts +13 -2
- package/dist/server-fns/collections/status.js +8 -3
- package/dist/server-fns/collections/tree.d.ts +22 -5
- package/dist/server-fns/collections/tree.js +17 -6
- package/dist/server-fns/collections/update.d.ts +13 -2
- package/dist/server-fns/collections/update.js +8 -3
- package/dist/server-fns/collections/upload.d.ts +10 -1
- package/dist/server-fns/collections/upload.js +4 -1
- package/dist/server-fns/i18n/set-locale.d.ts +3 -1
- package/dist/server-fns/i18n/set-locale.js +18 -3
- package/dist/server-fns/preview/disable.d.ts +3 -1
- package/dist/server-fns/preview/disable.js +4 -1
- package/dist/server-fns/preview/enable.d.ts +3 -1
- package/dist/server-fns/preview/enable.js +4 -1
- package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +10 -1
- package/dist/server-fns/singletons/cancel-scheduled-publish.js +5 -2
- package/dist/server-fns/singletons/change-status.d.ts +10 -1
- package/dist/server-fns/singletons/change-status.js +5 -2
- package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +10 -1
- package/dist/server-fns/singletons/confirm-scheduled-publish.js +5 -2
- package/dist/server-fns/singletons/copy-to-locale.d.ts +10 -1
- package/dist/server-fns/singletons/copy-to-locale.js +5 -2
- package/dist/server-fns/singletons/find-by-version.d.ts +3 -1
- package/dist/server-fns/singletons/find-by-version.js +4 -1
- package/dist/server-fns/singletons/get-scheduled-publish.d.ts +3 -1
- package/dist/server-fns/singletons/get-scheduled-publish.js +4 -1
- package/dist/server-fns/singletons/get.d.ts +3 -1
- package/dist/server-fns/singletons/get.js +4 -1
- package/dist/server-fns/singletons/history.d.ts +3 -1
- package/dist/server-fns/singletons/history.js +4 -1
- package/dist/server-fns/singletons/restore-version.d.ts +10 -1
- package/dist/server-fns/singletons/restore-version.js +5 -2
- package/dist/server-fns/singletons/schedule-publish.d.ts +10 -1
- package/dist/server-fns/singletons/schedule-publish.js +5 -2
- package/dist/server-fns/singletons/unpublish.d.ts +10 -1
- package/dist/server-fns/singletons/unpublish.js +5 -2
- package/dist/server-fns/singletons/update.d.ts +10 -1
- package/dist/server-fns/singletons/update.js +5 -2
- package/package.json +19 -19
- package/src/admin-shell/chrome/admin-app-bar.tsx +11 -6
- package/src/admin-shell/chrome/session-change-boundary.test.tsx +78 -0
- package/src/admin-shell/chrome/session-change-boundary.tsx +94 -0
- package/src/admin-shell/chrome/sign-in-page.tsx +68 -3
- package/src/integrations/byline-admin-services.ts +7 -1
- package/src/integrations/client-ip.test.node.ts +45 -0
- package/src/integrations/client-ip.ts +54 -0
- package/src/integrations/framework-fetch-shape.test.node.ts +56 -0
- package/src/integrations/session-client.test.node.ts +121 -0
- package/src/integrations/session-coordination.test.node.ts +77 -0
- package/src/integrations/session-coordination.ts +96 -0
- package/src/integrations/session-middleware.test.node.ts +87 -0
- package/src/integrations/session-middleware.ts +107 -0
- package/src/integrations/session-request-kind.ts +18 -0
- package/src/integrations/session-request-middleware.ts +17 -0
- package/src/integrations/sign-in-body.test.node.ts +59 -0
- package/src/integrations/sign-in-body.ts +50 -0
- package/src/integrations/sign-in-middleware.ts +24 -0
- package/src/routes/create-admin-layout-route.tsx +48 -30
- package/src/routes/create-sign-in-route.tsx +10 -2
- package/src/server-fns/admin-account/change-password.test.node.ts +69 -0
- package/src/server-fns/admin-account/change-password.ts +13 -2
- package/src/server-fns/admin-account/get.ts +2 -0
- package/src/server-fns/admin-account/update.ts +2 -0
- package/src/server-fns/admin-activity/get.ts +2 -0
- package/src/server-fns/admin-permissions/get-role-abilities.ts +2 -0
- package/src/server-fns/admin-permissions/list-registered.ts +5 -4
- package/src/server-fns/admin-permissions/set-role-abilities.ts +2 -0
- package/src/server-fns/admin-permissions/who-has.ts +2 -0
- package/src/server-fns/admin-roles/create.ts +2 -0
- package/src/server-fns/admin-roles/delete.ts +2 -0
- package/src/server-fns/admin-roles/get.ts +2 -0
- package/src/server-fns/admin-roles/list.ts +5 -4
- package/src/server-fns/admin-roles/reorder.ts +2 -0
- package/src/server-fns/admin-roles/update.ts +2 -0
- package/src/server-fns/admin-users/create.ts +2 -0
- package/src/server-fns/admin-users/delete.ts +2 -0
- package/src/server-fns/admin-users/disable.ts +2 -0
- package/src/server-fns/admin-users/enable.ts +2 -0
- package/src/server-fns/admin-users/get-user-roles.ts +2 -0
- package/src/server-fns/admin-users/get.ts +2 -0
- package/src/server-fns/admin-users/list.ts +2 -0
- package/src/server-fns/admin-users/set-password.ts +2 -0
- package/src/server-fns/admin-users/set-user-roles.ts +2 -0
- package/src/server-fns/admin-users/update.ts +2 -0
- package/src/server-fns/ai/execute.ts +3 -0
- package/src/server-fns/analytics/maintenance.ts +4 -0
- package/src/server-fns/analytics/queries.ts +14 -8
- package/src/server-fns/auth/current-user.ts +7 -3
- package/src/server-fns/auth/renew.test.node.ts +85 -0
- package/src/server-fns/auth/renew.ts +54 -0
- package/src/server-fns/auth/sign-in.test.node.ts +114 -0
- package/src/server-fns/auth/sign-in.ts +80 -27
- package/src/server-fns/auth/sign-out.test.node.ts +85 -0
- package/src/server-fns/auth/sign-out.ts +44 -19
- package/src/server-fns/collections/audit.ts +2 -0
- package/src/server-fns/collections/copy-to-locale.ts +3 -1
- package/src/server-fns/collections/create.ts +3 -1
- package/src/server-fns/collections/delete-locale.ts +3 -1
- package/src/server-fns/collections/delete.ts +3 -1
- package/src/server-fns/collections/duplicate.ts +3 -1
- package/src/server-fns/collections/get.ts +4 -1
- package/src/server-fns/collections/history.ts +2 -0
- package/src/server-fns/collections/list.ts +3 -1
- package/src/server-fns/collections/reindex.ts +2 -0
- package/src/server-fns/collections/reorder.ts +3 -1
- package/src/server-fns/collections/restore-version.ts +3 -1
- package/src/server-fns/collections/revision-input.test.node.ts +14 -0
- package/src/server-fns/collections/scheduled-publication.ts +14 -7
- package/src/server-fns/collections/set-list-view-preference.ts +2 -0
- package/src/server-fns/collections/stats.ts +2 -0
- package/src/server-fns/collections/status.ts +4 -1
- package/src/server-fns/collections/tree.ts +7 -1
- package/src/server-fns/collections/update.ts +4 -1
- package/src/server-fns/collections/upload-singleton.test.node.ts +8 -1
- package/src/server-fns/collections/upload.ts +10 -0
- package/src/server-fns/i18n/set-locale.ts +19 -3
- package/src/server-fns/preview/disable.ts +8 -4
- package/src/server-fns/preview/enable.ts +9 -5
- package/src/server-fns/singletons/cancel-scheduled-publish.ts +3 -1
- package/src/server-fns/singletons/change-status.ts +3 -1
- package/src/server-fns/singletons/confirm-scheduled-publish.ts +3 -1
- package/src/server-fns/singletons/copy-to-locale.ts +3 -1
- package/src/server-fns/singletons/find-by-version.ts +2 -0
- package/src/server-fns/singletons/get-scheduled-publish.ts +2 -0
- package/src/server-fns/singletons/get.ts +2 -0
- package/src/server-fns/singletons/history.ts +2 -0
- package/src/server-fns/singletons/restore-version.ts +3 -1
- package/src/server-fns/singletons/schedule-publish.ts +3 -1
- package/src/server-fns/singletons/singletons.test.node.ts +6 -0
- package/src/server-fns/singletons/unpublish.ts +3 -1
- 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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|