@byline/host-tanstack-start 3.20.4 → 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 (140) 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.d.ts +1 -7
  43. package/dist/routes/create-collection-api-route.js +12 -8
  44. package/dist/routes/create-collection-create-route.js +4 -3
  45. package/dist/routes/create-collection-edit-route.d.ts +1 -7
  46. package/dist/routes/create-collection-edit-route.js +9 -6
  47. package/dist/routes/create-collection-history-route.d.ts +1 -7
  48. package/dist/routes/create-collection-history-route.js +10 -7
  49. package/dist/routes/create-collection-list-route.js +3 -2
  50. package/dist/routes/create-sign-in-route.js +3 -2
  51. package/dist/routes/get-content-locale-route-config.d.ts +12 -0
  52. package/dist/routes/get-content-locale-route-config.js +16 -0
  53. package/dist/routes/index.d.ts +3 -0
  54. package/dist/routes/index.js +2 -0
  55. package/dist/routes/sign-in-path.d.ts +13 -0
  56. package/dist/routes/sign-in-path.js +19 -0
  57. package/dist/server-fns/collections/delete-outcome.d.ts +30 -0
  58. package/dist/server-fns/collections/delete-outcome.js +34 -0
  59. package/dist/server-fns/collections/delete.d.ts +1 -4
  60. package/dist/server-fns/collections/delete.js +2 -4
  61. package/dist/server-fns/collections/index.d.ts +1 -0
  62. package/dist/server-fns/collections/index.js +1 -0
  63. package/dist/server-fns/collections/tree-mutation.d.ts +46 -0
  64. package/dist/server-fns/collections/tree-mutation.js +109 -0
  65. package/dist/server-fns/collections/tree-read.d.ts +6 -0
  66. package/dist/server-fns/collections/tree-read.js +15 -0
  67. package/dist/server-fns/collections/tree.d.ts +5 -4
  68. package/dist/server-fns/collections/tree.js +15 -12
  69. package/dist/server-fns/collections/update.js +2 -1
  70. package/dist/vite/client-hook-boundary.d.ts +19 -0
  71. package/dist/vite/client-hook-boundary.js +20 -0
  72. package/dist/vite/index.d.ts +8 -0
  73. package/dist/vite/index.js +1 -0
  74. package/package.json +15 -9
  75. package/src/admin-shell/admin-activity/list.tsx +13 -3
  76. package/src/admin-shell/admin-roles/delete.tsx +2 -1
  77. package/src/admin-shell/admin-roles/list.tsx +2 -1
  78. package/src/admin-shell/admin-users/delete.tsx +3 -2
  79. package/src/admin-shell/admin-users/list.tsx +9 -8
  80. package/src/admin-shell/chrome/admin-app-bar.tsx +2 -1
  81. package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -8
  82. package/src/admin-shell/chrome/admin-menu-provider.tsx +2 -1
  83. package/src/admin-shell/chrome/dashboard.tsx +4 -3
  84. package/src/admin-shell/chrome/sign-in-page.tsx +3 -3
  85. package/src/admin-shell/collections/create.tsx +4 -3
  86. package/src/admin-shell/collections/document-history.tsx +5 -0
  87. package/src/admin-shell/collections/edit.tsx +20 -13
  88. package/src/admin-shell/collections/history.tsx +6 -5
  89. package/src/admin-shell/collections/list.tsx +8 -7
  90. package/src/admin-shell/collections/restore-version-modal.tsx +2 -1
  91. package/src/admin-shell/collections/tree-list.tsx +78 -22
  92. package/src/admin-shell/collections/view-menu.tsx +9 -8
  93. package/src/auth/auth-context.test.node.ts +87 -0
  94. package/src/auth/auth-context.ts +18 -0
  95. package/src/auth/request-scope.test.node.ts +93 -0
  96. package/src/auth/request-scope.ts +62 -0
  97. package/src/integrations/abilities.tsx +14 -4
  98. package/src/integrations/byline-client.ts +6 -4
  99. package/src/integrations/byline-public-client.ts +18 -4
  100. package/src/integrations/byline-viewer-client.ts +30 -18
  101. package/src/integrations/request-context-contract.test.node.ts +131 -0
  102. package/src/integrations/request-context-factories.test.node.ts +140 -0
  103. package/src/integrations/start-errors.test.node.ts +22 -0
  104. package/src/integrations/start-errors.ts +1 -0
  105. package/src/routes/admin-path.test.node.ts +129 -0
  106. package/src/routes/admin-path.ts +66 -0
  107. package/src/routes/auth-routes.test.node.ts +39 -0
  108. package/src/routes/create-admin-account-route.tsx +3 -2
  109. package/src/routes/create-admin-activity-route.tsx +3 -2
  110. package/src/routes/create-admin-dashboard-route.tsx +2 -1
  111. package/src/routes/create-admin-layout-route.tsx +4 -4
  112. package/src/routes/create-admin-permissions-route.tsx +3 -2
  113. package/src/routes/create-admin-role-edit-route.tsx +7 -3
  114. package/src/routes/create-admin-roles-list-route.tsx +3 -2
  115. package/src/routes/create-admin-user-edit-route.tsx +7 -3
  116. package/src/routes/create-admin-users-list-route.tsx +3 -2
  117. package/src/routes/create-collection-api-route.tsx +15 -14
  118. package/src/routes/create-collection-create-route.tsx +7 -3
  119. package/src/routes/create-collection-edit-route.tsx +12 -12
  120. package/src/routes/create-collection-history-route.tsx +13 -13
  121. package/src/routes/create-collection-list-route.tsx +3 -2
  122. package/src/routes/create-sign-in-route.tsx +9 -3
  123. package/src/routes/get-content-locale-route-config.ts +26 -0
  124. package/src/routes/index.ts +11 -0
  125. package/src/routes/sign-in-path.test.node.ts +79 -0
  126. package/src/routes/sign-in-path.ts +36 -0
  127. package/src/server-fns/collections/delete-outcome.test.node.ts +72 -0
  128. package/src/server-fns/collections/delete-outcome.ts +80 -0
  129. package/src/server-fns/collections/delete.ts +2 -1
  130. package/src/server-fns/collections/get.ts +2 -0
  131. package/src/server-fns/collections/index.ts +1 -0
  132. package/src/server-fns/collections/tree-mutation.test.node.ts +225 -0
  133. package/src/server-fns/collections/tree-mutation.ts +165 -0
  134. package/src/server-fns/collections/tree-read.test.node.ts +46 -0
  135. package/src/server-fns/collections/tree-read.ts +26 -0
  136. package/src/server-fns/collections/tree.ts +26 -10
  137. package/src/server-fns/collections/update.ts +3 -0
  138. package/src/vite/client-hook-boundary.test.node.ts +50 -0
  139. package/src/vite/client-hook-boundary.ts +45 -0
  140. package/src/vite/index.ts +9 -0
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type React from 'react'
10
- import { useEffect, useMemo, useState } from 'react'
10
+ import { useEffect, useMemo, useRef, useState } from 'react'
11
11
  import { useRouter } from '@tanstack/react-router'
12
12
 
13
13
  import { renderFormatted, StatusBadge } from '@byline/admin/react'
@@ -41,6 +41,12 @@ import {
41
41
  } from '@dnd-kit/sortable'
42
42
  import cx from 'classnames'
43
43
 
44
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
45
+ import {
46
+ createAdminTreeMoveController,
47
+ getAdminTreeMoveUiEffects,
48
+ shouldSyncAdminTreeRows,
49
+ } from '../../server-fns/collections/tree-mutation.js'
44
50
  import { Link } from '../chrome/loose-router.js'
45
51
  import { TableHeadingCellSortable } from '../chrome/th-sortable.js'
46
52
  import { formatNumber } from '../chrome/utils.js'
@@ -59,7 +65,6 @@ export type TreeMoveFn = (params: {
59
65
  afterDocumentId: string | null
60
66
  }) => Promise<unknown>
61
67
 
62
- // biome-ignore lint/suspicious/noExplicitAny: tree rows carry heterogeneous fields
63
68
  function getColumnValue(row: CollectionTreeRow, fieldName: string): any {
64
69
  if (row.fields && fieldName in row.fields) return row.fields[fieldName]
65
70
  return (row as Record<string, any>)[fieldName]
@@ -73,19 +78,20 @@ function getColumnValue(row: CollectionTreeRow, fieldName: string): any {
73
78
  */
74
79
  function SortableTreeRow({
75
80
  id,
76
- depth,
77
81
  dragging,
82
+ disabled,
78
83
  dragHandleLabel,
79
84
  children,
80
85
  }: {
81
86
  id: string
82
- depth: number
83
87
  dragging: boolean
88
+ disabled: boolean
84
89
  dragHandleLabel: string
85
90
  children: React.ReactNode
86
91
  }) {
87
92
  const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
88
93
  id,
94
+ disabled,
89
95
  })
90
96
  const style: React.CSSProperties = {
91
97
  transform: transform ? `translate3d(0, ${transform.y}px, 0)` : undefined,
@@ -103,6 +109,7 @@ function SortableTreeRow({
103
109
  <td className={cx('byline-tree-list-drag-cell', styles.dragCell)}>
104
110
  <button
105
111
  type="button"
112
+ disabled={disabled}
106
113
  className={cx('byline-tree-list-drag-handle', styles.dragHandle)}
107
114
  aria-label={dragHandleLabel}
108
115
  {...attributes}
@@ -149,8 +156,12 @@ export const TreeListView = ({
149
156
  // back). Mirrors the flat-list reorder pattern.
150
157
  const [localPlaced, setLocalPlaced] = useState(() => rows.filter((r) => !r.unplaced))
151
158
  const [isMoving, setIsMoving] = useState(false)
159
+ const [moveController] = useState(() => createAdminTreeMoveController())
160
+ const preserveOptimisticForRows = useRef<CollectionTreeRow[] | null>(null)
152
161
  useEffect(() => {
153
- if (!isMoving) setLocalPlaced(rows.filter((r) => !r.unplaced))
162
+ if (!shouldSyncAdminTreeRows(rows, preserveOptimisticForRows.current, isMoving)) return
163
+ preserveOptimisticForRows.current = null
164
+ setLocalPlaced(rows.filter((r) => !r.unplaced))
154
165
  }, [rows, isMoving])
155
166
 
156
167
  const [activeId, setActiveId] = useState<string | null>(null)
@@ -174,11 +185,17 @@ export const TreeListView = ({
174
185
  setOffsetLeft(0)
175
186
  }
176
187
 
177
- const handleDragStart = ({ active }: DragStartEvent) => setActiveId(String(active.id))
188
+ const handleDragStart = ({ active }: DragStartEvent) => {
189
+ if (!moveController.isMoving()) setActiveId(String(active.id))
190
+ }
178
191
  const handleDragMove = ({ delta }: DragMoveEvent) => setOffsetLeft(delta.x)
179
192
  const handleDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null)
180
193
 
181
194
  const handleDragEnd = async ({ active, over }: DragEndEvent) => {
195
+ if (moveController.isMoving()) {
196
+ resetDnd()
197
+ return
198
+ }
182
199
  const proj =
183
200
  over != null
184
201
  ? getTreeProjection(localPlaced, String(active.id), String(over.id), offsetLeft, INDENT)
@@ -197,24 +214,60 @@ export const TreeListView = ({
197
214
 
198
215
  setLocalPlaced(next)
199
216
  setIsMoving(true)
200
- try {
201
- await onMove({
202
- documentId,
203
- parentDocumentId: proj.parentId,
204
- beforeDocumentId: proj.beforeId,
205
- afterDocumentId: proj.afterId,
206
- })
207
- await router.invalidate()
208
- } catch {
217
+ const outcome = await moveController.execute(
218
+ () =>
219
+ onMove({
220
+ documentId,
221
+ parentDocumentId: proj.parentId,
222
+ beforeDocumentId: proj.beforeId,
223
+ afterDocumentId: proj.afterId,
224
+ }),
225
+ () => router.invalidate()
226
+ )
227
+
228
+ if (outcome.status === 'suppressed') {
209
229
  setLocalPlaced(current)
230
+ setIsMoving(moveController.isMoving())
231
+ return
232
+ }
233
+
234
+ const effects = getAdminTreeMoveUiEffects(outcome)
235
+ if (effects.rollback) setLocalPlaced(current)
236
+
237
+ if (effects.structuralFailure != null) {
238
+ const conflict = effects.structuralFailure === 'conflict'
210
239
  toastManager.add({
211
- title: t('collections.list.reorderFailedToast'),
212
- description: t('collections.list.reorderFailedDescription'),
240
+ title: t(
241
+ conflict ? 'collections.list.treeConflictToast' : 'collections.list.reorderFailedToast'
242
+ ),
243
+ description: t(
244
+ conflict
245
+ ? 'collections.list.treeConflictDescription'
246
+ : 'collections.list.reorderFailedDescription'
247
+ ),
213
248
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
214
249
  })
215
- } finally {
216
- setIsMoving(false)
217
250
  }
251
+
252
+ if (effects.committedHookWarning) {
253
+ toastManager.add({
254
+ title: t('collections.list.treeHookFailedToast'),
255
+ description: t('collections.list.treeHookFailedDescription'),
256
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
257
+ })
258
+ }
259
+
260
+ if (effects.refreshWarning) {
261
+ toastManager.add({
262
+ title: t('collections.list.treeRefreshFailedToast'),
263
+ description: t('collections.list.treeRefreshFailedDescription'),
264
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
265
+ })
266
+ }
267
+
268
+ if (effects.preserveOptimistic) preserveOptimisticForRows.current = rows
269
+
270
+ setIsMoving(false)
218
271
  }
219
272
 
220
273
  const renderCells = (row: CollectionTreeRow, depth: number) =>
@@ -240,7 +293,7 @@ export const TreeListView = ({
240
293
  </span>
241
294
  )}
242
295
  <Link
243
- to={'/admin/collections/$collection/$id' as never}
296
+ to={getAdminRoutePath('collections', '$collection', '$id')}
244
297
  params={{ collection, id: row.id }}
245
298
  >
246
299
  {column.formatter
@@ -277,7 +330,10 @@ export const TreeListView = ({
277
330
  <IconButton
278
331
  aria-label={t('collections.list.createAriaLabel')}
279
332
  render={
280
- <Link to={'/admin/collections/$collection/create' as never} params={{ collection }} />
333
+ <Link
334
+ to={getAdminRoutePath('collections', '$collection', 'create')}
335
+ params={{ collection }}
336
+ />
281
337
  }
282
338
  >
283
339
  <PlusIcon height="18px" width="18px" svgClassName="stroke-white" />
@@ -323,8 +379,8 @@ export const TreeListView = ({
323
379
  <SortableTreeRow
324
380
  key={row.id}
325
381
  id={row.id}
326
- depth={depth}
327
382
  dragging={row.id === activeId}
383
+ disabled={isMoving}
328
384
  dragHandleLabel={t('collections.list.dragHandleAriaLabel')}
329
385
  >
330
386
  {renderCells(row, depth)}
@@ -13,6 +13,7 @@ import { useTranslation } from '@byline/i18n/react'
13
13
  import { Button, HistoryIcon, IconButton, Label, Select } from '@byline/ui/react'
14
14
  import cx from 'classnames'
15
15
 
16
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
16
17
  import { useNavigate } from '../chrome/loose-router.js'
17
18
  import { PreviewLink } from './preview-link.js'
18
19
  import styles from './view-menu.module.css'
@@ -86,7 +87,7 @@ export const ViewMenu = ({
86
87
  useEffect(() => {
87
88
  if (activeView === 'edit' && locale === 'all') {
88
89
  navigate({
89
- to: '/admin/collections/$collection/$id' as never,
90
+ to: getAdminRoutePath('collections', '$collection', '$id'),
90
91
  params: { collection, id: documentId },
91
92
  search: (prev: Record<string, unknown>) => ({
92
93
  ...prev,
@@ -103,19 +104,19 @@ export const ViewMenu = ({
103
104
  const search = (prev: Record<string, unknown>) => ({ ...prev, locale: value })
104
105
  if (activeView === 'api') {
105
106
  navigate({
106
- to: '/admin/collections/$collection/$id/api' as never,
107
+ to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
107
108
  params: { collection, id: documentId },
108
109
  search: search as never,
109
110
  })
110
111
  } else if (activeView === 'history') {
111
112
  navigate({
112
- to: '/admin/collections/$collection/$id/history' as never,
113
+ to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
113
114
  params: { collection, id: documentId },
114
115
  search: search as never,
115
116
  })
116
117
  } else {
117
118
  navigate({
118
- to: '/admin/collections/$collection/$id' as never,
119
+ to: getAdminRoutePath('collections', '$collection', '$id'),
119
120
  params: { collection, id: documentId },
120
121
  search: search as never,
121
122
  })
@@ -127,7 +128,7 @@ export const ViewMenu = ({
127
128
  const n = Number.parseInt(value, 10)
128
129
  const nextDepth = Number.isFinite(n) ? Math.max(0, Math.min(3, n)) : 0
129
130
  navigate({
130
- to: '/admin/collections/$collection/$id/api' as never,
131
+ to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
131
132
  params: { collection, id: documentId },
132
133
  // Store 0 as undefined so the URL stays clean when the user picks
133
134
  // "no populate" — avoids `?depth=0` in bookmarks / share links.
@@ -201,7 +202,7 @@ export const ViewMenu = ({
201
202
  variant={activeView === 'history' ? 'filled' : 'text'}
202
203
  onClick={() =>
203
204
  navigate({
204
- to: '/admin/collections/$collection/$id/history' as never,
205
+ to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
205
206
  params: { collection, id: documentId },
206
207
  search: locale ? { locale } : {},
207
208
  })
@@ -215,7 +216,7 @@ export const ViewMenu = ({
215
216
  className={cx('byline-view-menu-button', styles.button)}
216
217
  onClick={() =>
217
218
  navigate({
218
- to: '/admin/collections/$collection/$id' as never,
219
+ to: getAdminRoutePath('collections', '$collection', '$id'),
219
220
  params: { collection, id: documentId },
220
221
  search: locale ? { locale } : {},
221
222
  })
@@ -229,7 +230,7 @@ export const ViewMenu = ({
229
230
  className={cx('byline-view-menu-button', styles.button)}
230
231
  onClick={() =>
231
232
  navigate({
232
- to: '/admin/collections/$collection/$id/api' as never,
233
+ to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
233
234
  params: { collection, id: documentId },
234
235
  search: locale ? { locale } : {},
235
236
  })
@@ -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
  }