@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.
- 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/admin-shell/collections/edit.d.ts +1 -1
- package/dist/admin-shell/collections/edit.js +151 -31
- package/dist/admin-shell/collections/history.js +4 -2
- package/dist/admin-shell/collections/list.js +30 -6
- package/dist/admin-shell/collections/restore-version-modal.d.ts +3 -1
- package/dist/admin-shell/collections/restore-version-modal.js +9 -4
- package/dist/admin-shell/collections/tree-list.js +16 -6
- package/dist/admin-shell/collections/version-history.d.ts +2 -1
- package/dist/admin-shell/collections/version-history.js +11 -2
- package/dist/admin-shell/document-mutation-notice.d.ts +6 -0
- package/dist/admin-shell/document-mutation-notice.js +53 -0
- package/dist/admin-shell/document-mutation-state.d.ts +22 -0
- package/dist/admin-shell/document-mutation-state.js +71 -0
- package/dist/admin-shell/document-mutation-state.test.d.ts +1 -0
- package/dist/admin-shell/scheduled-publications/list.js +15 -5
- package/dist/admin-shell/singletons/history.js +4 -2
- package/dist/admin-shell/singletons/view.d.ts +1 -1
- package/dist/admin-shell/singletons/view.js +98 -19
- package/dist/integrations/byline-admin-services.js +7 -1
- package/dist/integrations/byline-field-services.js +2 -9
- 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-collection-edit-route.js +1 -1
- package/dist/routes/create-collection-list-route.d.ts +0 -7
- package/dist/routes/create-collection-list-route.js +7 -9
- package/dist/routes/create-sign-in-route.js +4 -2
- package/dist/routes/create-singleton-route.d.ts +2 -0
- package/dist/routes/create-singleton-route.js +4 -3
- 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 +5 -1
- package/dist/server-fns/collections/copy-to-locale.js +7 -2
- package/dist/server-fns/collections/create.d.ts +4 -1
- package/dist/server-fns/collections/create.js +8 -3
- package/dist/server-fns/collections/delete-locale.d.ts +5 -1
- package/dist/server-fns/collections/delete-locale.js +7 -2
- package/dist/server-fns/collections/delete-outcome.d.ts +7 -3
- package/dist/server-fns/collections/delete-outcome.js +4 -0
- package/dist/server-fns/collections/delete.d.ts +5 -1
- package/dist/server-fns/collections/delete.js +8 -3
- package/dist/server-fns/collections/duplicate.d.ts +5 -1
- package/dist/server-fns/collections/duplicate.js +8 -3
- package/dist/server-fns/collections/get.d.ts +1 -0
- package/dist/server-fns/collections/get.js +12 -6
- 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 +3 -1
- package/dist/server-fns/collections/list.js +15 -4
- 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 -2
- package/dist/server-fns/collections/reorder.js +23 -76
- package/dist/server-fns/collections/restore-version.d.ts +6 -1
- package/dist/server-fns/collections/restore-version.js +8 -2
- package/dist/server-fns/collections/save-outcome.d.ts +1 -0
- package/dist/server-fns/collections/save-outcome.js +1 -0
- package/dist/server-fns/collections/scheduled-publication.d.ts +64 -7
- package/dist/server-fns/collections/scheduled-publication.js +42 -21
- 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 +14 -2
- package/dist/server-fns/collections/status.js +18 -5
- package/dist/server-fns/collections/tree-mutation.d.ts +7 -6
- package/dist/server-fns/collections/tree-mutation.js +17 -5
- package/dist/server-fns/collections/tree.d.ts +29 -6
- package/dist/server-fns/collections/tree.js +32 -17
- package/dist/server-fns/collections/update.d.ts +18 -6
- package/dist/server-fns/collections/update.js +39 -20
- package/dist/server-fns/collections/upload.d.ts +11 -1
- package/dist/server-fns/collections/upload.js +6 -2
- package/dist/server-fns/document-mutation-errors.d.ts +5 -0
- package/dist/server-fns/document-mutation-errors.js +50 -0
- package/dist/server-fns/editable-revision.d.ts +9 -0
- package/dist/server-fns/editable-revision.js +11 -0
- 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/singleton-document-read.d.ts +1 -1
- package/dist/server-fns/singleton-document-read.js +7 -3
- package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +9 -2
- package/dist/server-fns/singletons/cancel-scheduled-publish.js +7 -1
- package/dist/server-fns/singletons/change-status.d.ts +5 -1
- package/dist/server-fns/singletons/change-status.js +7 -1
- package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +8 -2
- package/dist/server-fns/singletons/confirm-scheduled-publish.js +7 -2
- package/dist/server-fns/singletons/copy-to-locale.d.ts +5 -1
- package/dist/server-fns/singletons/copy-to-locale.js +7 -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 +6 -4
- 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 +5 -1
- package/dist/server-fns/singletons/restore-version.js +9 -3
- package/dist/server-fns/singletons/schedule-publish.d.ts +8 -2
- package/dist/server-fns/singletons/schedule-publish.js +7 -2
- package/dist/server-fns/singletons/unpublish.d.ts +5 -1
- package/dist/server-fns/singletons/unpublish.js +7 -1
- package/dist/server-fns/singletons/update.d.ts +6 -4
- package/dist/server-fns/singletons/update.js +8 -4
- package/dist/server-fns/structural-receipt.d.ts +4 -0
- package/dist/server-fns/structural-receipt.js +31 -0
- package/package.json +20 -20
- 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/admin-shell/collections/edit.tsx +173 -54
- package/src/admin-shell/collections/history.tsx +11 -2
- package/src/admin-shell/collections/list.tsx +22 -6
- package/src/admin-shell/collections/restore-version-modal.tsx +12 -3
- package/src/admin-shell/collections/save-navigation.test.tsx +156 -8
- package/src/admin-shell/collections/tree-list.tsx +16 -6
- package/src/admin-shell/collections/version-history.tsx +13 -3
- package/src/admin-shell/document-mutation-notice.tsx +45 -0
- package/src/admin-shell/document-mutation-state.test.tsx +146 -0
- package/src/admin-shell/document-mutation-state.ts +95 -0
- package/src/admin-shell/scheduled-publications/list.tsx +16 -6
- package/src/admin-shell/singletons/history.test.tsx +3 -1
- package/src/admin-shell/singletons/history.tsx +10 -2
- package/src/admin-shell/singletons/view.test.tsx +59 -18
- package/src/admin-shell/singletons/view.tsx +110 -29
- package/src/integrations/byline-admin-services.ts +7 -1
- package/src/integrations/byline-field-services.ts +2 -3
- 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-collection-edit-route.tsx +1 -0
- package/src/routes/create-collection-list-route.tsx +13 -2
- package/src/routes/create-sign-in-route.tsx +10 -2
- package/src/routes/create-singleton-route.test.node.ts +10 -2
- package/src/routes/create-singleton-route.tsx +6 -4
- 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 +43 -36
- package/src/server-fns/collections/create.ts +56 -50
- package/src/server-fns/collections/delete-locale.ts +43 -32
- package/src/server-fns/collections/delete-outcome.test.node.ts +16 -0
- package/src/server-fns/collections/delete-outcome.ts +27 -16
- package/src/server-fns/collections/delete.ts +46 -38
- package/src/server-fns/collections/duplicate.ts +40 -32
- package/src/server-fns/collections/get.ts +11 -4
- package/src/server-fns/collections/history.ts +2 -0
- package/src/server-fns/collections/list.ts +10 -3
- package/src/server-fns/collections/reindex.ts +2 -0
- package/src/server-fns/collections/reorder.ts +34 -124
- package/src/server-fns/collections/restore-version.ts +49 -39
- package/src/server-fns/collections/revision-input.test.node.ts +122 -0
- package/src/server-fns/collections/save-outcome.test.node.ts +3 -0
- package/src/server-fns/collections/save-outcome.ts +2 -0
- package/src/server-fns/collections/scheduled-publication.ts +149 -115
- 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 +89 -64
- package/src/server-fns/collections/tree-mutation.test.node.ts +87 -29
- package/src/server-fns/collections/tree-mutation.ts +28 -11
- package/src/server-fns/collections/tree.ts +156 -122
- package/src/server-fns/collections/update.ts +113 -86
- package/src/server-fns/collections/upload-singleton.test.node.ts +10 -7
- package/src/server-fns/collections/upload.ts +91 -77
- package/src/server-fns/document-mutation-errors.ts +67 -0
- package/src/server-fns/document-mutation-transport.test.node.ts +211 -0
- package/src/server-fns/editable-revision.test.node.ts +23 -0
- package/src/server-fns/editable-revision.ts +18 -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/singleton-document-read.ts +7 -3
- package/src/server-fns/singletons/cancel-scheduled-publish.ts +12 -3
- package/src/server-fns/singletons/change-status.ts +12 -3
- package/src/server-fns/singletons/confirm-scheduled-publish.ts +14 -6
- package/src/server-fns/singletons/copy-to-locale.ts +22 -15
- 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 +5 -3
- package/src/server-fns/singletons/history.ts +2 -0
- package/src/server-fns/singletons/restore-version.ts +19 -12
- package/src/server-fns/singletons/schedule-publish.ts +20 -7
- package/src/server-fns/singletons/singletons.test.node.ts +69 -25
- package/src/server-fns/singletons/unpublish.ts +12 -3
- package/src/server-fns/singletons/update.ts +23 -16
- package/src/server-fns/structural-receipt.ts +41 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
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 { createMiddleware } from '@tanstack/react-start'
|
|
10
|
+
import { getRequest, setResponseHeader } from '@tanstack/react-start/server'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
AuthError,
|
|
14
|
+
ERR_ACCESS_EXPIRED,
|
|
15
|
+
ERR_SESSION_CHANGED,
|
|
16
|
+
ERR_UNAUTHENTICATED,
|
|
17
|
+
} from '@byline/auth'
|
|
18
|
+
import { getAdminRequestContext } from '@byline/client/server'
|
|
19
|
+
|
|
20
|
+
import { renewAdminSession } from '../server-fns/auth/renew.js'
|
|
21
|
+
import {
|
|
22
|
+
coordinateAuthAction,
|
|
23
|
+
flagSessionChanged,
|
|
24
|
+
observeSession,
|
|
25
|
+
renewSingleFlight,
|
|
26
|
+
requireUnchanged,
|
|
27
|
+
sessionSnapshot,
|
|
28
|
+
} from './session-coordination.js'
|
|
29
|
+
import { sessionRequestKind } from './session-request-kind.js'
|
|
30
|
+
|
|
31
|
+
const outcomeHeader = 'x-byline-auth-boundary'
|
|
32
|
+
/** The boundary owns the retry signal; handler errors cannot turn into retryable outcomes. */
|
|
33
|
+
export const adminSessionMiddleware = createMiddleware({ type: 'function' })
|
|
34
|
+
.client(async ({ next }) => {
|
|
35
|
+
if (typeof window === 'undefined')
|
|
36
|
+
return next({ sendContext: { bylineExpectedSessionId: null as string | null } })
|
|
37
|
+
const snapshot = sessionSnapshot()
|
|
38
|
+
requireUnchanged(snapshot)
|
|
39
|
+
return next({
|
|
40
|
+
sendContext: { bylineExpectedSessionId: snapshot.sessionId },
|
|
41
|
+
fetch: async (input, init) => {
|
|
42
|
+
// Re-serialize reusable BodyInit values (including FormData/Blob) on retry.
|
|
43
|
+
// Never clone/tee a Request: an unread branch can buffer an entire upload.
|
|
44
|
+
const reusable = !(input instanceof Request) && !(init?.body instanceof ReadableStream)
|
|
45
|
+
let response = await fetch(input, init)
|
|
46
|
+
if (response.headers.get(outcomeHeader) === 'changed') {
|
|
47
|
+
flagSessionChanged()
|
|
48
|
+
throw ERR_SESSION_CHANGED({ message: 'session changed' })
|
|
49
|
+
}
|
|
50
|
+
if (response.headers.get(outcomeHeader) === 'renew') {
|
|
51
|
+
requireUnchanged(snapshot)
|
|
52
|
+
await renewSingleFlight(() =>
|
|
53
|
+
coordinateAuthAction(async () => {
|
|
54
|
+
const result = await renewAdminSession({
|
|
55
|
+
data: { expectedSessionId: snapshot.sessionId ?? undefined },
|
|
56
|
+
}).catch((error) => {
|
|
57
|
+
if (error?.code === 'ERR_SESSION_CHANGED') flagSessionChanged()
|
|
58
|
+
throw error
|
|
59
|
+
})
|
|
60
|
+
if (snapshot.sessionId && result.sessionId !== snapshot.sessionId) {
|
|
61
|
+
flagSessionChanged()
|
|
62
|
+
throw ERR_SESSION_CHANGED({ message: 'session changed during renewal' })
|
|
63
|
+
}
|
|
64
|
+
observeSession(result.sessionId)
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
requireUnchanged(snapshot)
|
|
68
|
+
if (!reusable)
|
|
69
|
+
throw ERR_ACCESS_EXPIRED({ message: 'Session renewed. Please retry the operation.' })
|
|
70
|
+
response = await fetch(input, init)
|
|
71
|
+
if (response.headers.get(outcomeHeader) === 'changed') flagSessionChanged()
|
|
72
|
+
}
|
|
73
|
+
requireUnchanged(snapshot)
|
|
74
|
+
return response
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
.server(async ({ next, context, method }) => {
|
|
79
|
+
const kind = sessionRequestKind(getRequest())
|
|
80
|
+
if (!kind) throw new Error('sessionRequestMiddleware is not configured')
|
|
81
|
+
let auth: Awaited<ReturnType<typeof getAdminRequestContext>>
|
|
82
|
+
try {
|
|
83
|
+
auth = await getAdminRequestContext()
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof AuthError && error.code === 'ERR_ACCESS_EXPIRED')
|
|
86
|
+
setResponseHeader(outcomeHeader, 'renew')
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
const expected = context.bylineExpectedSessionId
|
|
90
|
+
if ((kind === 'serverFn' || method !== 'GET') && (!expected || expected !== auth.sessionId)) {
|
|
91
|
+
setResponseHeader(outcomeHeader, 'changed')
|
|
92
|
+
throw ERR_SESSION_CHANGED({ message: 'session changed before operation' })
|
|
93
|
+
}
|
|
94
|
+
if (!auth.sessionId) throw ERR_UNAUTHENTICATED({ message: 'provider omitted login identity' })
|
|
95
|
+
// Strip the reserved signal after any handler outcome, including exceptions.
|
|
96
|
+
try {
|
|
97
|
+
const result = await next()
|
|
98
|
+
const response = (result as { result?: unknown }).result
|
|
99
|
+
if (response instanceof Response) response.headers.delete(outcomeHeader)
|
|
100
|
+
return result
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof Response) error.headers.delete(outcomeHeader)
|
|
103
|
+
throw error
|
|
104
|
+
} finally {
|
|
105
|
+
setResponseHeader(outcomeHeader, 'handled')
|
|
106
|
+
}
|
|
107
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
/** Server-owned classification. A browser header cannot opt a request into SSR privileges. */
|
|
10
|
+
const key = Symbol.for('byline.sessionRequestKinds')
|
|
11
|
+
const kinds: WeakMap<object, string> = (globalThis as any)[key] ?? new WeakMap()
|
|
12
|
+
;(globalThis as any)[key] = kinds
|
|
13
|
+
export function recordSessionRequestKind(request: object, kind: string) {
|
|
14
|
+
kinds.set(request, kind)
|
|
15
|
+
}
|
|
16
|
+
export function sessionRequestKind(request: object) {
|
|
17
|
+
return kinds.get(request)
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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 { createMiddleware } from '@tanstack/react-start'
|
|
10
|
+
|
|
11
|
+
import { recordSessionRequestKind } from './session-request-kind.js'
|
|
12
|
+
|
|
13
|
+
/** Register after CSRF. Classifies SSR separately from remotely invoked business functions. */
|
|
14
|
+
export const sessionRequestMiddleware = createMiddleware().server(async (ctx) => {
|
|
15
|
+
recordSessionRequestKind(ctx.request, ctx.handlerType)
|
|
16
|
+
return ctx.next()
|
|
17
|
+
})
|