@byline/host-tanstack-start 3.21.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/admin-shell/admin-activity/list.js +15 -5
  2. package/dist/admin-shell/admin-roles/delete.js +2 -1
  3. package/dist/admin-shell/admin-roles/list.js +2 -1
  4. package/dist/admin-shell/admin-users/delete.js +2 -1
  5. package/dist/admin-shell/admin-users/list.js +4 -7
  6. package/dist/admin-shell/chrome/admin-app-bar.js +2 -1
  7. package/dist/admin-shell/chrome/admin-menu-drawer.js +9 -8
  8. package/dist/admin-shell/chrome/admin-menu-provider.js +2 -1
  9. package/dist/admin-shell/chrome/dashboard.js +4 -3
  10. package/dist/admin-shell/chrome/sign-in-page.d.ts +2 -2
  11. package/dist/admin-shell/chrome/sign-in-page.js +2 -2
  12. package/dist/admin-shell/collections/create.js +4 -3
  13. package/dist/admin-shell/collections/document-history.js +6 -1
  14. package/dist/admin-shell/collections/edit.js +18 -14
  15. package/dist/admin-shell/collections/history.js +5 -4
  16. package/dist/admin-shell/collections/list.js +8 -7
  17. package/dist/admin-shell/collections/restore-version-modal.js +2 -1
  18. package/dist/admin-shell/collections/tree-list.js +53 -17
  19. package/dist/admin-shell/collections/view-menu.js +9 -8
  20. package/dist/auth/auth-context.d.ts +13 -0
  21. package/dist/auth/auth-context.js +4 -0
  22. package/dist/auth/request-scope.d.ts +16 -0
  23. package/dist/auth/request-scope.js +24 -0
  24. package/dist/integrations/abilities.js +8 -7
  25. package/dist/integrations/byline-public-client.d.ts +38 -0
  26. package/dist/integrations/byline-public-client.js +8 -4
  27. package/dist/integrations/byline-viewer-client.d.ts +6 -0
  28. package/dist/integrations/byline-viewer-client.js +22 -17
  29. package/dist/integrations/start-errors.js +2 -1
  30. package/dist/routes/admin-path.d.ts +22 -0
  31. package/dist/routes/admin-path.js +35 -0
  32. package/dist/routes/create-admin-account-route.js +3 -2
  33. package/dist/routes/create-admin-activity-route.js +3 -2
  34. package/dist/routes/create-admin-dashboard-route.js +2 -1
  35. package/dist/routes/create-admin-layout-route.d.ts +1 -1
  36. package/dist/routes/create-admin-layout-route.js +3 -1
  37. package/dist/routes/create-admin-permissions-route.js +3 -2
  38. package/dist/routes/create-admin-role-edit-route.js +4 -3
  39. package/dist/routes/create-admin-roles-list-route.js +3 -2
  40. package/dist/routes/create-admin-user-edit-route.js +4 -3
  41. package/dist/routes/create-admin-users-list-route.js +3 -2
  42. package/dist/routes/create-collection-api-route.js +5 -4
  43. package/dist/routes/create-collection-create-route.js +4 -3
  44. package/dist/routes/create-collection-edit-route.js +4 -3
  45. package/dist/routes/create-collection-history-route.js +5 -4
  46. package/dist/routes/create-collection-list-route.js +3 -2
  47. package/dist/routes/create-sign-in-route.js +3 -2
  48. package/dist/routes/index.d.ts +3 -0
  49. package/dist/routes/index.js +2 -0
  50. package/dist/routes/sign-in-path.d.ts +13 -0
  51. package/dist/routes/sign-in-path.js +19 -0
  52. package/dist/server-fns/collections/delete-outcome.d.ts +30 -0
  53. package/dist/server-fns/collections/delete-outcome.js +34 -0
  54. package/dist/server-fns/collections/delete.d.ts +1 -4
  55. package/dist/server-fns/collections/delete.js +2 -4
  56. package/dist/server-fns/collections/index.d.ts +1 -0
  57. package/dist/server-fns/collections/index.js +1 -0
  58. package/dist/server-fns/collections/tree-mutation.d.ts +46 -0
  59. package/dist/server-fns/collections/tree-mutation.js +109 -0
  60. package/dist/server-fns/collections/tree-read.d.ts +6 -0
  61. package/dist/server-fns/collections/tree-read.js +15 -0
  62. package/dist/server-fns/collections/tree.d.ts +5 -4
  63. package/dist/server-fns/collections/tree.js +15 -12
  64. package/dist/server-fns/collections/update.js +2 -1
  65. package/dist/vite/client-hook-boundary.d.ts +19 -0
  66. package/dist/vite/client-hook-boundary.js +20 -0
  67. package/dist/vite/index.d.ts +8 -0
  68. package/dist/vite/index.js +1 -0
  69. package/package.json +15 -9
  70. package/src/admin-shell/admin-activity/list.tsx +13 -3
  71. package/src/admin-shell/admin-roles/delete.tsx +2 -1
  72. package/src/admin-shell/admin-roles/list.tsx +2 -1
  73. package/src/admin-shell/admin-users/delete.tsx +3 -2
  74. package/src/admin-shell/admin-users/list.tsx +9 -8
  75. package/src/admin-shell/chrome/admin-app-bar.tsx +2 -1
  76. package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -8
  77. package/src/admin-shell/chrome/admin-menu-provider.tsx +2 -1
  78. package/src/admin-shell/chrome/dashboard.tsx +4 -3
  79. package/src/admin-shell/chrome/sign-in-page.tsx +3 -3
  80. package/src/admin-shell/collections/create.tsx +4 -3
  81. package/src/admin-shell/collections/document-history.tsx +5 -0
  82. package/src/admin-shell/collections/edit.tsx +20 -13
  83. package/src/admin-shell/collections/history.tsx +6 -5
  84. package/src/admin-shell/collections/list.tsx +8 -7
  85. package/src/admin-shell/collections/restore-version-modal.tsx +2 -1
  86. package/src/admin-shell/collections/tree-list.tsx +78 -22
  87. package/src/admin-shell/collections/view-menu.tsx +9 -8
  88. package/src/auth/auth-context.test.node.ts +87 -0
  89. package/src/auth/auth-context.ts +18 -0
  90. package/src/auth/request-scope.test.node.ts +93 -0
  91. package/src/auth/request-scope.ts +62 -0
  92. package/src/integrations/abilities.tsx +14 -4
  93. package/src/integrations/byline-client.ts +6 -4
  94. package/src/integrations/byline-public-client.ts +18 -4
  95. package/src/integrations/byline-viewer-client.ts +30 -18
  96. package/src/integrations/request-context-contract.test.node.ts +131 -0
  97. package/src/integrations/request-context-factories.test.node.ts +140 -0
  98. package/src/integrations/start-errors.test.node.ts +22 -0
  99. package/src/integrations/start-errors.ts +1 -0
  100. package/src/routes/admin-path.test.node.ts +129 -0
  101. package/src/routes/admin-path.ts +66 -0
  102. package/src/routes/auth-routes.test.node.ts +39 -0
  103. package/src/routes/create-admin-account-route.tsx +3 -2
  104. package/src/routes/create-admin-activity-route.tsx +3 -2
  105. package/src/routes/create-admin-dashboard-route.tsx +2 -1
  106. package/src/routes/create-admin-layout-route.tsx +4 -4
  107. package/src/routes/create-admin-permissions-route.tsx +3 -2
  108. package/src/routes/create-admin-role-edit-route.tsx +7 -3
  109. package/src/routes/create-admin-roles-list-route.tsx +3 -2
  110. package/src/routes/create-admin-user-edit-route.tsx +7 -3
  111. package/src/routes/create-admin-users-list-route.tsx +3 -2
  112. package/src/routes/create-collection-api-route.tsx +8 -4
  113. package/src/routes/create-collection-create-route.tsx +7 -3
  114. package/src/routes/create-collection-edit-route.tsx +7 -3
  115. package/src/routes/create-collection-history-route.tsx +8 -4
  116. package/src/routes/create-collection-list-route.tsx +3 -2
  117. package/src/routes/create-sign-in-route.tsx +9 -3
  118. package/src/routes/index.ts +11 -0
  119. package/src/routes/sign-in-path.test.node.ts +79 -0
  120. package/src/routes/sign-in-path.ts +36 -0
  121. package/src/server-fns/collections/delete-outcome.test.node.ts +72 -0
  122. package/src/server-fns/collections/delete-outcome.ts +80 -0
  123. package/src/server-fns/collections/delete.ts +2 -1
  124. package/src/server-fns/collections/get.ts +2 -0
  125. package/src/server-fns/collections/index.ts +1 -0
  126. package/src/server-fns/collections/tree-mutation.test.node.ts +225 -0
  127. package/src/server-fns/collections/tree-mutation.ts +165 -0
  128. package/src/server-fns/collections/tree-read.test.node.ts +46 -0
  129. package/src/server-fns/collections/tree-read.ts +26 -0
  130. package/src/server-fns/collections/tree.ts +26 -10
  131. package/src/server-fns/collections/update.ts +3 -0
  132. package/src/vite/client-hook-boundary.test.node.ts +50 -0
  133. package/src/vite/client-hook-boundary.ts +45 -0
  134. package/src/vite/index.ts +9 -0
@@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({
28
28
  getCookie: vi.fn(),
29
29
  setCookie: vi.fn(),
30
30
  getRequestHeader: vi.fn(),
31
+ getRequest: vi.fn(),
31
32
  }))
32
33
 
33
34
  vi.mock('@byline/core', async () => {
@@ -47,6 +48,7 @@ vi.mock('@tanstack/react-start/server', () => ({
47
48
  getCookie: mocks.getCookie,
48
49
  setCookie: mocks.setCookie,
49
50
  getRequestHeader: mocks.getRequestHeader,
51
+ getRequest: mocks.getRequest,
50
52
  }))
51
53
 
52
54
  const { verifyAccessToken, refreshSession, getCookie, setCookie } = mocks
@@ -70,6 +72,12 @@ function stubActor() {
70
72
  describe('getAdminRequestContext', () => {
71
73
  beforeEach(() => {
72
74
  vi.clearAllMocks()
75
+ // Default: no Start request in scope, so every call resolves fresh.
76
+ // The per-request memoization tests below override this with a
77
+ // stable request object.
78
+ mocks.getRequest.mockImplementation(() => {
79
+ throw new Error('No StartEvent found in AsyncLocalStorage')
80
+ })
73
81
  })
74
82
 
75
83
  afterEach(() => {
@@ -212,4 +220,83 @@ describe('getAdminRequestContext', () => {
212
220
  const clears = setCookie.mock.calls.filter((c) => c[2]?.maxAge === 0)
213
221
  expect(clears.length).toBe(2)
214
222
  })
223
+
224
+ describe('per-request memoization', () => {
225
+ it('returns the same context instance — and requestId — for every call in one request', async () => {
226
+ mocks.getRequest.mockReturnValue({ id: 'request-a' })
227
+ cookiesReturn({
228
+ byline_access_token: 'valid-access',
229
+ byline_refresh_token: 'some-refresh',
230
+ })
231
+ verifyAccessToken.mockResolvedValue({ actor: stubActor() })
232
+
233
+ const first = await getAdminRequestContext()
234
+ const second = await getAdminRequestContext()
235
+
236
+ // Reads sharing a ReadContext bind one request authority; the token
237
+ // includes requestId, so it must be stable within a request.
238
+ expect(second).toBe(first)
239
+ expect(second.requestId).toBe(first.requestId)
240
+ expect(verifyAccessToken).toHaveBeenCalledTimes(1)
241
+ })
242
+
243
+ it('resolves independently across different requests', async () => {
244
+ cookiesReturn({
245
+ byline_access_token: 'valid-access',
246
+ byline_refresh_token: 'some-refresh',
247
+ })
248
+ verifyAccessToken.mockResolvedValue({ actor: stubActor() })
249
+
250
+ mocks.getRequest.mockReturnValue({ id: 'request-a' })
251
+ const first = await getAdminRequestContext()
252
+ mocks.getRequest.mockReturnValue({ id: 'request-b' })
253
+ const second = await getAdminRequestContext()
254
+
255
+ expect(second).not.toBe(first)
256
+ expect(second.requestId).not.toBe(first.requestId)
257
+ expect(verifyAccessToken).toHaveBeenCalledTimes(2)
258
+ })
259
+
260
+ it('burns at most one refresh rotation per request', async () => {
261
+ mocks.getRequest.mockReturnValue({ id: 'request-a' })
262
+ cookiesReturn({
263
+ byline_access_token: 'stale-access',
264
+ byline_refresh_token: 'valid-refresh',
265
+ })
266
+ const actor = stubActor()
267
+ verifyAccessToken.mockRejectedValueOnce(new Error('expired')).mockResolvedValueOnce({ actor })
268
+ refreshSession.mockResolvedValueOnce({
269
+ accessToken: 'fresh-access',
270
+ refreshToken: 'fresh-refresh',
271
+ accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
272
+ refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
273
+ })
274
+
275
+ const first = await getAdminRequestContext()
276
+ // A second unmemoized call would re-run the refresh dance against the
277
+ // request's stale cookie — now already rotated — and sign the admin out.
278
+ const second = await getAdminRequestContext()
279
+
280
+ expect(second).toBe(first)
281
+ expect(refreshSession).toHaveBeenCalledTimes(1)
282
+ })
283
+
284
+ it('memoizes the unauthenticated rejection within one request', async () => {
285
+ mocks.getRequest.mockReturnValue({ id: 'request-a' })
286
+ cookiesReturn({
287
+ byline_access_token: 'stale',
288
+ byline_refresh_token: 'bad-refresh',
289
+ })
290
+ verifyAccessToken.mockRejectedValue(new Error('expired'))
291
+ refreshSession.mockRejectedValue(new Error('revoked'))
292
+
293
+ await expect(getAdminRequestContext()).rejects.toMatchObject({
294
+ code: AuthErrorCodes.UNAUTHENTICATED,
295
+ })
296
+ await expect(getAdminRequestContext()).rejects.toMatchObject({
297
+ code: AuthErrorCodes.UNAUTHENTICATED,
298
+ })
299
+ expect(refreshSession).toHaveBeenCalledTimes(1)
300
+ })
301
+ })
215
302
  })
@@ -33,6 +33,19 @@
33
33
  * Burns a refresh-token rotation only when the access token actually
34
34
  * fails verification — not on every request. The "one session" UX is
35
35
  * the consequence of this helper working invisibly behind each call.
36
+ *
37
+ * Resolution is memoized per request (`oncePerRequest`): every call within
38
+ * one logical request returns the same `RequestContext` instance — same
39
+ * actor snapshot, same `requestId`. Two invariants depend on this:
40
+ *
41
+ * - The read-authorization layer binds each `ReadContext` to one
42
+ * immutable authority token that includes `requestId`; a fresh id per
43
+ * call makes any two reads sharing a `ReadContext` (the admin tree
44
+ * view, hook-threaded nested reads) throw
45
+ * 'ReadContext cannot be reused across request authorities'.
46
+ * - The refresh path rotates the refresh token; a second unmemoized call
47
+ * in the same request would re-run the dance against the
48
+ * already-rotated cookie and sign the admin out.
36
49
  */
37
50
 
38
51
  import { ERR_UNAUTHENTICATED, type RequestContext } from '@byline/auth'
@@ -45,6 +58,7 @@ import {
45
58
  readRefreshTokenCookie,
46
59
  setSessionCookies,
47
60
  } from './auth-cookies.js'
61
+ import { oncePerRequest } from './request-scope.js'
48
62
 
49
63
  function requireSessionProvider() {
50
64
  const provider = getServerConfig().sessionProvider
@@ -58,6 +72,10 @@ function requireSessionProvider() {
58
72
  }
59
73
 
60
74
  export async function getAdminRequestContext(): Promise<RequestContext> {
75
+ return oncePerRequest('byline:admin-request-context', resolveAdminRequestContext)
76
+ }
77
+
78
+ async function resolveAdminRequestContext(): Promise<RequestContext> {
61
79
  const provider = requireSessionProvider()
62
80
 
63
81
  const accessToken = readAccessTokenCookie()
@@ -0,0 +1,93 @@
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
+ /**
10
+ * Unit coverage for per-request memoization. Mocks the Start runtime's
11
+ * `getRequest` so "which request am I in" can be simulated: a stable
12
+ * object per request, or a throw when running outside a request.
13
+ */
14
+
15
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
16
+
17
+ const mocks = vi.hoisted(() => ({
18
+ getRequest: vi.fn(),
19
+ }))
20
+
21
+ vi.mock('@tanstack/react-start/server', () => ({
22
+ getRequest: mocks.getRequest,
23
+ }))
24
+
25
+ import { oncePerRequest } from './request-scope.js'
26
+
27
+ describe('oncePerRequest', () => {
28
+ beforeEach(() => {
29
+ vi.clearAllMocks()
30
+ mocks.getRequest.mockImplementation(() => {
31
+ throw new Error('No StartEvent found in AsyncLocalStorage')
32
+ })
33
+ })
34
+
35
+ it('resolves the factory once per request per key', async () => {
36
+ mocks.getRequest.mockReturnValue({ id: 'request-a' })
37
+ const factory = vi.fn(async () => ({ token: Math.random() }))
38
+
39
+ const first = await oncePerRequest('ctx', factory)
40
+ const second = await oncePerRequest('ctx', factory)
41
+
42
+ expect(second).toBe(first)
43
+ expect(factory).toHaveBeenCalledTimes(1)
44
+ })
45
+
46
+ it('keys memoization by request identity', async () => {
47
+ const requestA = { id: 'a' }
48
+ const requestB = { id: 'b' }
49
+ const factory = vi.fn(async () => ({ token: Math.random() }))
50
+
51
+ mocks.getRequest.mockReturnValue(requestA)
52
+ const first = await oncePerRequest('ctx', factory)
53
+ mocks.getRequest.mockReturnValue(requestB)
54
+ const second = await oncePerRequest('ctx', factory)
55
+
56
+ expect(second).not.toBe(first)
57
+ expect(factory).toHaveBeenCalledTimes(2)
58
+ })
59
+
60
+ it('isolates entries by key within one request', async () => {
61
+ mocks.getRequest.mockReturnValue({ id: 'request-a' })
62
+ const admin = vi.fn(async () => 'admin-context')
63
+ const publicCtx = vi.fn(async () => 'public-context')
64
+
65
+ await expect(oncePerRequest('admin', admin)).resolves.toBe('admin-context')
66
+ await expect(oncePerRequest('public', publicCtx)).resolves.toBe('public-context')
67
+ await expect(oncePerRequest('admin', admin)).resolves.toBe('admin-context')
68
+
69
+ expect(admin).toHaveBeenCalledTimes(1)
70
+ expect(publicCtx).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('memoizes rejections for the remainder of the request', async () => {
74
+ mocks.getRequest.mockReturnValue({ id: 'request-a' })
75
+ const factory = vi.fn(async () => {
76
+ throw new Error('no admin session')
77
+ })
78
+
79
+ await expect(oncePerRequest('ctx', factory)).rejects.toThrow('no admin session')
80
+ await expect(oncePerRequest('ctx', factory)).rejects.toThrow('no admin session')
81
+ expect(factory).toHaveBeenCalledTimes(1)
82
+ })
83
+
84
+ it('runs unmemoized outside the Start runtime', async () => {
85
+ const factory = vi.fn(async () => ({ token: Math.random() }))
86
+
87
+ const first = await oncePerRequest('ctx', factory)
88
+ const second = await oncePerRequest('ctx', factory)
89
+
90
+ expect(second).not.toBe(first)
91
+ expect(factory).toHaveBeenCalledTimes(2)
92
+ })
93
+ })
@@ -0,0 +1,62 @@
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
+ /**
10
+ * Per-request memoization for `RequestContext` factories.
11
+ *
12
+ * `RequestContext.requestId` is documented as "per logical request", and
13
+ * the read-authorization layer binds every `ReadContext` to one immutable
14
+ * request authority whose token includes that id. A factory that mints a
15
+ * fresh context per call breaks both contracts as soon as two reads share
16
+ * a `ReadContext` (the admin tree view, hook-threaded nested reads):
17
+ * the second bind sees a different authority token and throws
18
+ * 'ReadContext cannot be reused across request authorities'.
19
+ *
20
+ * Memoizing on the current Start request makes every context factory
21
+ * request-stable. It also means at most one session verification — and at
22
+ * most one refresh-token rotation — per request, instead of one per call.
23
+ */
24
+
25
+ import { getRequest } from '@tanstack/react-start/server'
26
+
27
+ const requestScopes = new WeakMap<object, Map<string, Promise<unknown>>>()
28
+
29
+ /**
30
+ * Resolve `factory` at most once per HTTP request for a given `key`. The
31
+ * returned promise is memoized on the current Start request — rejections
32
+ * included, since a request's cookies cannot change mid-flight and retrying
33
+ * a failed refresh would burn a second rotation on an already-rotated
34
+ * token. Outside the Start runtime (seed scripts, unit tests, background
35
+ * jobs) there is no request to key on and the factory runs unmemoized.
36
+ */
37
+ export function oncePerRequest<T>(key: string, factory: () => Promise<T>): Promise<T> {
38
+ const request = currentRequest()
39
+ if (!request) return factory()
40
+
41
+ let scope = requestScopes.get(request)
42
+ if (!scope) {
43
+ scope = new Map()
44
+ requestScopes.set(request, scope)
45
+ }
46
+
47
+ const existing = scope.get(key)
48
+ if (existing) return existing as Promise<T>
49
+
50
+ const pending = factory()
51
+ scope.set(key, pending)
52
+ return pending
53
+ }
54
+
55
+ function currentRequest(): object | undefined {
56
+ try {
57
+ return getRequest()
58
+ } catch {
59
+ // No StartEvent in AsyncLocalStorage — running outside a request.
60
+ return undefined
61
+ }
62
+ }
@@ -38,15 +38,25 @@
38
38
  import type React from 'react'
39
39
  import { useRouteContext } from '@tanstack/react-router'
40
40
 
41
- /** Route id of the admin shell — the layout route that owns `user` on context. */
42
- const ADMIN_ROUTE_ID = '/_byline/admin' as const
41
+ import { getAdminRouteId } from '../routes/admin-path.js'
42
+
43
+ interface AbilityRouteContext {
44
+ user: {
45
+ is_super_admin: boolean
46
+ abilities: string[]
47
+ }
48
+ }
49
+
50
+ function useAbilityRouteContext(): AbilityRouteContext {
51
+ return useRouteContext({ from: getAdminRouteId() as never }) as AbilityRouteContext
52
+ }
43
53
 
44
54
  /**
45
55
  * `true` when the current admin holds the given ability (or is a
46
56
  * super-admin). Cosmetic — see file-level docstring.
47
57
  */
48
58
  export function useAbility(ability: string): boolean {
49
- const { user } = useRouteContext({ from: ADMIN_ROUTE_ID })
59
+ const { user } = useAbilityRouteContext()
50
60
  if (user.is_super_admin) return true
51
61
  return user.abilities.includes(ability)
52
62
  }
@@ -62,7 +72,7 @@ export function useAbilities(): {
62
72
  hasAny: (abilities: readonly string[]) => boolean
63
73
  isSuperAdmin: boolean
64
74
  } {
65
- const { user } = useRouteContext({ from: ADMIN_ROUTE_ID })
75
+ const { user } = useAbilityRouteContext()
66
76
  const has = (ability: string): boolean => user.is_super_admin || user.abilities.includes(ability)
67
77
  const hasAny = (abilities: readonly string[]): boolean =>
68
78
  user.is_super_admin || abilities.some((key) => user.abilities.includes(key))
@@ -31,10 +31,12 @@ export function getAdminBylineClient(): BylineClient {
31
31
  if (cachedClient) return cachedClient
32
32
  cachedClient = createBylineClient({
33
33
  config: getServerConfig(),
34
- // Resolved per-call so each server fn picks up the actor from the
35
- // current request's session cookies. `getAdminRequestContext` runs the
36
- // refresh dance on its own and throws `ERR_UNAUTHENTICATED` when no
37
- // session is present — the client surfaces the throw verbatim.
34
+ // Resolved from the current request's session cookies, memoized per
35
+ // request: every read in one request binds the same context instance
36
+ // (and requestId), which the ReadContext authority check requires.
37
+ // `getAdminRequestContext` runs the refresh dance on its own and throws
38
+ // `ERR_UNAUTHENTICATED` when no session is present — the client
39
+ // surfaces the throw verbatim.
38
40
  requestContext: getAdminRequestContext,
39
41
  })
40
42
  return cachedClient
@@ -33,21 +33,35 @@
33
33
  * process lifetime.
34
34
  *
35
35
  * Companion: `getAdminBylineClient` in `./byline-client.ts` for the
36
- * admin webapp's authenticated reads (resolves a fresh `RequestContext`
37
- * from session cookies on every call).
36
+ * admin webapp's authenticated reads (resolves the `RequestContext`
37
+ * from session cookies, memoized per request).
38
38
  */
39
39
 
40
- import { createRequestContext } from '@byline/auth'
40
+ import { createRequestContext, type RequestContext } from '@byline/auth'
41
41
  import { type BylineClient, createBylineClient } from '@byline/client'
42
42
  import { getServerConfig } from '@byline/core'
43
43
 
44
+ import { oncePerRequest } from '../auth/request-scope.js'
45
+
46
+ /**
47
+ * Anonymous, published-only context — memoized per request so the
48
+ * context (and its requestId) is stable across every read in one
49
+ * request: reads sharing a ReadContext must bind a single request
50
+ * authority.
51
+ */
52
+ export function resolvePublicRequestContext(): Promise<RequestContext> {
53
+ return oncePerRequest('byline:public-request-context', async () =>
54
+ createRequestContext({ readMode: 'published' })
55
+ )
56
+ }
57
+
44
58
  let cachedClient: BylineClient | undefined
45
59
 
46
60
  export function getPublicBylineClient(): BylineClient {
47
61
  if (cachedClient) return cachedClient
48
62
  cachedClient = createBylineClient({
49
63
  config: getServerConfig(),
50
- requestContext: () => createRequestContext({ readMode: 'published' }),
64
+ requestContext: resolvePublicRequestContext,
51
65
  })
52
66
  return cachedClient
53
67
  }
@@ -49,6 +49,7 @@ import { getServerConfig } from '@byline/core'
49
49
 
50
50
  import { getAdminRequestContext } from '../auth/auth-context.js'
51
51
  import { readPreviewCookie } from '../auth/preview-cookies.js'
52
+ import { oncePerRequest } from '../auth/request-scope.js'
52
53
 
53
54
  let cachedClient: BylineClient | undefined
54
55
 
@@ -56,28 +57,39 @@ export function getViewerBylineClient(): BylineClient {
56
57
  if (cachedClient) return cachedClient
57
58
  cachedClient = createBylineClient({
58
59
  config: getServerConfig(),
59
- requestContext: async () => {
60
- // No preview cookie → behave exactly like the public client.
61
- // Cheap path; no JWT verification, no DB lookup.
62
- if (!readPreviewCookie()) {
63
- return createRequestContext({ readMode: 'published' })
64
- }
65
-
66
- // Preview cookie present → try the admin context. A failure means
67
- // the cookie is stale (admin signed out, session expired, refresh
68
- // rejected). We swallow the error and fall back to the public
69
- // context so the page renders instead of erroring.
70
- try {
71
- const ctx = await getAdminRequestContext()
72
- return { ...ctx, readMode: 'any' }
73
- } catch {
74
- return createRequestContext({ readMode: 'published' })
75
- }
76
- },
60
+ requestContext: resolveViewerRequestContext,
77
61
  })
78
62
  return cachedClient
79
63
  }
80
64
 
65
+ /**
66
+ * Preview-aware viewer context — memoized per request so every read in
67
+ * one request binds the same context instance (and requestId): reads
68
+ * sharing a ReadContext must resolve a single request authority.
69
+ */
70
+ export function resolveViewerRequestContext() {
71
+ return oncePerRequest('byline:viewer-request-context', resolveViewerContext)
72
+ }
73
+
74
+ async function resolveViewerContext() {
75
+ // No preview cookie → behave exactly like the public client.
76
+ // Cheap path; no JWT verification, no DB lookup.
77
+ if (!readPreviewCookie()) {
78
+ return createRequestContext({ readMode: 'published' })
79
+ }
80
+
81
+ // Preview cookie present → try the admin context. A failure means
82
+ // the cookie is stale (admin signed out, session expired, refresh
83
+ // rejected). We swallow the error and fall back to the public
84
+ // context so the page renders instead of erroring.
85
+ try {
86
+ const ctx = await getAdminRequestContext()
87
+ return { ...ctx, readMode: 'any' as const }
88
+ } catch {
89
+ return createRequestContext({ readMode: 'published' })
90
+ }
91
+ }
92
+
81
93
  /**
82
94
  * Resolve whether the current request should surface non-published
83
95
  * versions. True iff the preview cookie is set **and** a valid admin
@@ -0,0 +1,131 @@
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
+ /**
10
+ * Cross-layer contract test: a real `BylineClient` wired to the real
11
+ * cookie-derived host factory (`getAdminRequestContext`) under a mocked
12
+ * Start request, running two operations that share one `ReadContext`.
13
+ *
14
+ * This is the seam every per-layer suite missed when the admin tree view
15
+ * broke with 'ReadContext cannot be reused across request authorities':
16
+ * client tests used stable test contexts, host auth tests called the
17
+ * factory once, and tree tests mocked the handle. Here the factory's
18
+ * request-stability contract (see `BylineClientConfig.requestContext`)
19
+ * is exercised end-to-end through the SDK's authority binding.
20
+ */
21
+
22
+ import { AdminAuth } from '@byline/auth'
23
+ import { createBylineClient } from '@byline/client'
24
+ import type { CollectionDefinition, IDbAdapter } from '@byline/core'
25
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
26
+
27
+ const mocks = vi.hoisted(() => ({
28
+ verifyAccessToken: vi.fn(),
29
+ refreshSession: vi.fn(),
30
+ getCookie: vi.fn(),
31
+ setCookie: vi.fn(),
32
+ getRequestHeader: vi.fn(),
33
+ getRequest: vi.fn(),
34
+ }))
35
+
36
+ vi.mock('@byline/core', async () => {
37
+ const actual = await vi.importActual<typeof import('@byline/core')>('@byline/core')
38
+ return {
39
+ ...actual,
40
+ getServerConfig: () => ({
41
+ sessionProvider: {
42
+ verifyAccessToken: mocks.verifyAccessToken,
43
+ refreshSession: mocks.refreshSession,
44
+ },
45
+ }),
46
+ }
47
+ })
48
+
49
+ vi.mock('@tanstack/react-start/server', () => ({
50
+ getCookie: mocks.getCookie,
51
+ setCookie: mocks.setCookie,
52
+ getRequestHeader: mocks.getRequestHeader,
53
+ getRequest: mocks.getRequest,
54
+ }))
55
+
56
+ import { createReadContext } from '@byline/core'
57
+
58
+ import { getAdminRequestContext } from '../auth/auth-context.js'
59
+
60
+ const postsCollection: CollectionDefinition = {
61
+ path: 'posts',
62
+ labels: { singular: 'Post', plural: 'Posts' },
63
+ fields: [{ name: 'title', type: 'text', label: 'Title' }],
64
+ }
65
+
66
+ function mockDb(): IDbAdapter {
67
+ return {
68
+ commands: { collections: {}, documents: {}, counters: {} },
69
+ queries: {
70
+ collections: {
71
+ getCollectionByPath: vi.fn().mockResolvedValue({ id: 'col-1', version: 1 }),
72
+ },
73
+ documents: {
74
+ findDocuments: vi.fn().mockResolvedValue({ documents: [], total: 0 }),
75
+ getDocumentsByDocumentIds: vi.fn().mockResolvedValue([]),
76
+ },
77
+ },
78
+ } as unknown as IDbAdapter
79
+ }
80
+
81
+ describe('host factory ↔ client authority contract', () => {
82
+ beforeEach(() => {
83
+ vi.clearAllMocks()
84
+ mocks.getCookie.mockImplementation((name: string) =>
85
+ name === 'byline_access_token' ? 'valid-access' : undefined
86
+ )
87
+ mocks.verifyAccessToken.mockResolvedValue({
88
+ actor: new AdminAuth({ id: 'admin-1', abilities: ['collections.posts.read'] }),
89
+ })
90
+ })
91
+
92
+ it('two reads sharing a ReadContext bind one authority within a request', async () => {
93
+ mocks.getRequest.mockReturnValue({ id: 'request-a' })
94
+ const db = mockDb()
95
+ const beforeRead = vi.fn(() => ({ title: 'scoped' }))
96
+ const client = createBylineClient({
97
+ db,
98
+ collections: [{ ...postsCollection, hooks: { beforeRead } }],
99
+ requestContext: getAdminRequestContext,
100
+ })
101
+ const readContext = createReadContext()
102
+
103
+ // The admin tree view shape: two top-level reads, one shared ReadContext.
104
+ await client.collection('posts').find({ status: 'any', _readContext: readContext })
105
+ await client.collection('posts').find({ status: 'any', _readContext: readContext })
106
+
107
+ expect(db.queries.documents.findDocuments).toHaveBeenCalledTimes(2)
108
+ // One session verification and one beforeRead compile for the whole
109
+ // request — the factory resolved a single request-stable context.
110
+ expect(mocks.verifyAccessToken).toHaveBeenCalledTimes(1)
111
+ expect(beforeRead).toHaveBeenCalledTimes(1)
112
+ })
113
+
114
+ it('still rejects a ReadContext carried across two different requests', async () => {
115
+ const db = mockDb()
116
+ const client = createBylineClient({
117
+ db,
118
+ collections: [{ ...postsCollection, hooks: { beforeRead: () => ({ title: 'scoped' }) } }],
119
+ requestContext: getAdminRequestContext,
120
+ })
121
+ const readContext = createReadContext()
122
+
123
+ mocks.getRequest.mockReturnValue({ id: 'request-a' })
124
+ await client.collection('posts').find({ status: 'any', _readContext: readContext })
125
+
126
+ mocks.getRequest.mockReturnValue({ id: 'request-b' })
127
+ await expect(
128
+ client.collection('posts').find({ status: 'any', _readContext: readContext })
129
+ ).rejects.toThrow('cannot be reused across request authorities')
130
+ })
131
+ })