@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,6 +7,7 @@ import { Container, GripperVerticalIcon, IconButton, LoaderRing, PlusIcon, Searc
7
7
  import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
8
8
  import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
9
9
  import classnames from "classnames";
10
+ import { getAdminRoutePath } from "../../routes/admin-path.js";
10
11
  import { Link, useNavigate } from "../chrome/loose-router.js";
11
12
  import { RouterPager } from "../chrome/router-pager.js";
12
13
  import { SortAscendingIcon } from "../chrome/sort-icons.js";
@@ -157,7 +158,7 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
157
158
  delete params.page;
158
159
  params.query = query;
159
160
  navigate({
160
- to: '/admin/collections/$collection',
161
+ to: getAdminRoutePath('collections', '$collection'),
161
162
  params: {
162
163
  collection: data.included.collection.path
163
164
  },
@@ -170,7 +171,7 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
170
171
  delete params.page;
171
172
  delete params.query;
172
173
  navigate({
173
- to: '/admin/collections/$collection',
174
+ to: getAdminRoutePath('collections', '$collection'),
174
175
  params: {
175
176
  collection: data.included.collection.path
176
177
  },
@@ -184,7 +185,7 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
184
185
  if ('_all' === value) delete params.status;
185
186
  else params.status = value;
186
187
  navigate({
187
- to: '/admin/collections/$collection',
188
+ to: getAdminRoutePath('collections', '$collection'),
188
189
  params: {
189
190
  collection: data.included.collection.path
190
191
  },
@@ -197,7 +198,7 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
197
198
  delete params.page;
198
199
  params.page_size = Number.parseInt(value, 10);
199
200
  navigate({
200
- to: '/admin/collections/$collection',
201
+ to: getAdminRoutePath('collections', '$collection'),
201
202
  params: {
202
203
  collection: data.included.collection.path
203
204
  },
@@ -223,7 +224,7 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
223
224
  /*#__PURE__*/ jsx(IconButton, {
224
225
  "aria-label": t('collections.list.createAriaLabel'),
225
226
  render: /*#__PURE__*/ jsx(Link, {
226
- to: '/admin/collections/$collection/create',
227
+ to: getAdminRoutePath('collections', '$collection', 'create'),
227
228
  params: {
228
229
  collection: data.included.collection.path
229
230
  }
@@ -328,7 +329,7 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
328
329
  [list_module.cellCenter]: 'center' === column.align
329
330
  }),
330
331
  children: useAsTitle && column.fieldName === useAsTitle ? /*#__PURE__*/ jsx(Link, {
331
- to: '/admin/collections/$collection/$id',
332
+ to: getAdminRoutePath('collections', '$collection', '$id'),
332
333
  params: {
333
334
  collection: data.included.collection.path,
334
335
  id: document.id
@@ -371,7 +372,7 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
371
372
  [list_module.cellCenter]: 'center' === column.align
372
373
  }),
373
374
  children: useAsTitle && column.fieldName === useAsTitle ? /*#__PURE__*/ jsx(Link, {
374
- to: '/admin/collections/$collection/$id',
375
+ to: getAdminRoutePath('collections', '$collection', '$id'),
375
376
  params: {
376
377
  collection: data.included.collection.path,
377
378
  id: document.id
@@ -5,6 +5,7 @@ import { useRouter } from "@tanstack/react-router";
5
5
  import { useTranslation } from "@byline/i18n/react";
6
6
  import { Alert, Button, LoaderEllipsis, Modal } from "@byline/ui/react";
7
7
  import classnames from "classnames";
8
+ import { getAdminRoutePath } from "../../routes/admin-path.js";
8
9
  import { restoreDocumentVersion } from "../../server-fns/collections/index.js";
9
10
  import { useNavigate } from "../chrome/loose-router.js";
10
11
  import restore_version_modal_module from "./restore-version-modal.module.js";
@@ -29,7 +30,7 @@ function RestoreVersionModal({ collection, documentId, versionId, versionLabel,
29
30
  onClose();
30
31
  await router.invalidate();
31
32
  navigate({
32
- to: '/admin/collections/$collection/$id',
33
+ to: getAdminRoutePath('collections', '$collection', '$id'),
33
34
  params: {
34
35
  collection,
35
36
  id: documentId
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useState } from "react";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { useRouter } from "@tanstack/react-router";
4
4
  import { StatusBadge, renderFormatted } from "@byline/admin/react";
5
5
  import { useTranslation } from "@byline/i18n/react";
@@ -7,6 +7,8 @@ import { Container, GripperVerticalIcon, IconButton, PlusIcon, Section, Table, u
7
7
  import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
8
8
  import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
9
9
  import classnames from "classnames";
10
+ import { getAdminRoutePath } from "../../routes/admin-path.js";
11
+ import { createAdminTreeMoveController, getAdminTreeMoveUiEffects, shouldSyncAdminTreeRows } from "../../server-fns/collections/tree-mutation.js";
10
12
  import { Link } from "../chrome/loose-router.js";
11
13
  import { TableHeadingCellSortable } from "../chrome/th-sortable.js";
12
14
  import { formatNumber } from "../chrome/utils.js";
@@ -17,9 +19,10 @@ function getColumnValue(row, fieldName) {
17
19
  if (row.fields && fieldName in row.fields) return row.fields[fieldName];
18
20
  return row[fieldName];
19
21
  }
20
- function SortableTreeRow({ id, depth, dragging, dragHandleLabel, children }) {
22
+ function SortableTreeRow({ id, dragging, disabled, dragHandleLabel, children }) {
21
23
  const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
22
- id
24
+ id,
25
+ disabled
23
26
  });
24
27
  const style = {
25
28
  transform: transform ? `translate3d(0, ${transform.y}px, 0)` : void 0,
@@ -39,6 +42,7 @@ function SortableTreeRow({ id, depth, dragging, dragHandleLabel, children }) {
39
42
  className: classnames('byline-tree-list-drag-cell', tree_list_module.dragCell),
40
43
  children: /*#__PURE__*/ jsx("button", {
41
44
  type: "button",
45
+ disabled: disabled,
42
46
  className: classnames('byline-tree-list-drag-handle', tree_list_module.dragHandle),
43
47
  "aria-label": dragHandleLabel,
44
48
  ...attributes,
@@ -59,8 +63,12 @@ const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection,
59
63
  ]);
60
64
  const [localPlaced, setLocalPlaced] = useState(()=>rows.filter((r)=>!r.unplaced));
61
65
  const [isMoving, setIsMoving] = useState(false);
66
+ const [moveController] = useState(()=>createAdminTreeMoveController());
67
+ const preserveOptimisticForRows = useRef(null);
62
68
  useEffect(()=>{
63
- if (!isMoving) setLocalPlaced(rows.filter((r)=>!r.unplaced));
69
+ if (!shouldSyncAdminTreeRows(rows, preserveOptimisticForRows.current, isMoving)) return;
70
+ preserveOptimisticForRows.current = null;
71
+ setLocalPlaced(rows.filter((r)=>!r.unplaced));
64
72
  }, [
65
73
  rows,
66
74
  isMoving
@@ -89,10 +97,13 @@ const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection,
89
97
  setOverId(null);
90
98
  setOffsetLeft(0);
91
99
  };
92
- const handleDragStart = ({ active })=>setActiveId(String(active.id));
100
+ const handleDragStart = ({ active })=>{
101
+ if (!moveController.isMoving()) setActiveId(String(active.id));
102
+ };
93
103
  const handleDragMove = ({ delta })=>setOffsetLeft(delta.x);
94
104
  const handleDragOver = ({ over })=>setOverId(over ? String(over.id) : null);
95
105
  const handleDragEnd = async ({ active, over })=>{
106
+ if (moveController.isMoving()) return void resetDnd();
96
107
  const proj = null != over ? getTreeProjection(localPlaced, String(active.id), String(over.id), offsetLeft, INDENT) : null;
97
108
  resetDnd();
98
109
  if (!onMove || null == proj) return;
@@ -103,19 +114,24 @@ const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection,
103
114
  if (unchanged) return;
104
115
  setLocalPlaced(next);
105
116
  setIsMoving(true);
106
- try {
107
- await onMove({
117
+ const outcome = await moveController.execute(()=>onMove({
108
118
  documentId,
109
119
  parentDocumentId: proj.parentId,
110
120
  beforeDocumentId: proj.beforeId,
111
121
  afterDocumentId: proj.afterId
112
- });
113
- await router.invalidate();
114
- } catch {
122
+ }), ()=>router.invalidate());
123
+ if ('suppressed' === outcome.status) {
115
124
  setLocalPlaced(current);
125
+ setIsMoving(moveController.isMoving());
126
+ return;
127
+ }
128
+ const effects = getAdminTreeMoveUiEffects(outcome);
129
+ if (effects.rollback) setLocalPlaced(current);
130
+ if (null != effects.structuralFailure) {
131
+ const conflict = 'conflict' === effects.structuralFailure;
116
132
  toastManager.add({
117
- title: t('collections.list.reorderFailedToast'),
118
- description: t("collections.list.reorderFailedDescription"),
133
+ title: t(conflict ? 'collections.list.treeConflictToast' : 'collections.list.reorderFailedToast'),
134
+ description: t(conflict ? "collections.list.treeConflictDescription" : "collections.list.reorderFailedDescription"),
119
135
  data: {
120
136
  intent: 'danger',
121
137
  iconType: 'danger',
@@ -123,9 +139,29 @@ const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection,
123
139
  close: true
124
140
  }
125
141
  });
126
- } finally{
127
- setIsMoving(false);
128
142
  }
143
+ if (effects.committedHookWarning) toastManager.add({
144
+ title: t('collections.list.treeHookFailedToast'),
145
+ description: t("collections.list.treeHookFailedDescription"),
146
+ data: {
147
+ intent: 'warning',
148
+ iconType: 'warning',
149
+ icon: true,
150
+ close: true
151
+ }
152
+ });
153
+ if (effects.refreshWarning) toastManager.add({
154
+ title: t('collections.list.treeRefreshFailedToast'),
155
+ description: t("collections.list.treeRefreshFailedDescription"),
156
+ data: {
157
+ intent: 'warning',
158
+ iconType: 'warning',
159
+ icon: true,
160
+ close: true
161
+ }
162
+ });
163
+ if (effects.preserveOptimistic) preserveOptimisticForRows.current = rows;
164
+ setIsMoving(false);
129
165
  };
130
166
  const renderCells = (row, depth)=>columns.map((column)=>{
131
167
  const fieldName = column.fieldName;
@@ -147,7 +183,7 @@ const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection,
147
183
  children: "└─"
148
184
  }),
149
185
  /*#__PURE__*/ jsx(Link, {
150
- to: '/admin/collections/$collection/$id',
186
+ to: getAdminRoutePath('collections', '$collection', '$id'),
151
187
  params: {
152
188
  collection,
153
189
  id: row.id
@@ -183,7 +219,7 @@ const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection,
183
219
  /*#__PURE__*/ jsx(IconButton, {
184
220
  "aria-label": t('collections.list.createAriaLabel'),
185
221
  render: /*#__PURE__*/ jsx(Link, {
186
- to: '/admin/collections/$collection/create',
222
+ to: getAdminRoutePath('collections', '$collection', 'create'),
187
223
  params: {
188
224
  collection
189
225
  }
@@ -234,8 +270,8 @@ const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection,
234
270
  const depth = row.id === activeId && projection ? projection.depth : row.depth;
235
271
  return dndEnabled ? /*#__PURE__*/ jsx(SortableTreeRow, {
236
272
  id: row.id,
237
- depth: depth,
238
273
  dragging: row.id === activeId,
274
+ disabled: isMoving,
239
275
  dragHandleLabel: t('collections.list.dragHandleAriaLabel'),
240
276
  children: renderCells(row, depth)
241
277
  }, row.id) : /*#__PURE__*/ jsx(Table.Row, {
@@ -3,6 +3,7 @@ import { useEffect } from "react";
3
3
  import { useTranslation } from "@byline/i18n/react";
4
4
  import { Button, HistoryIcon, IconButton, Label, Select } from "@byline/ui/react";
5
5
  import classnames from "classnames";
6
+ import { getAdminRoutePath } from "../../routes/admin-path.js";
6
7
  import { useNavigate } from "../chrome/loose-router.js";
7
8
  import { PreviewLink } from "./preview-link.js";
8
9
  import view_menu_module from "./view-menu.module.js";
@@ -11,7 +12,7 @@ const ViewMenu = ({ collection, documentId, activeView, locale, depth, contentLo
11
12
  const { t } = useTranslation('byline-admin');
12
13
  useEffect(()=>{
13
14
  if ('edit' === activeView && 'all' === locale) navigate({
14
- to: '/admin/collections/$collection/$id',
15
+ to: getAdminRoutePath('collections', '$collection', '$id'),
15
16
  params: {
16
17
  collection,
17
18
  id: documentId
@@ -36,7 +37,7 @@ const ViewMenu = ({ collection, documentId, activeView, locale, depth, contentLo
36
37
  locale: value
37
38
  });
38
39
  if ('api' === activeView) navigate({
39
- to: '/admin/collections/$collection/$id/api',
40
+ to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
40
41
  params: {
41
42
  collection,
42
43
  id: documentId
@@ -44,14 +45,14 @@ const ViewMenu = ({ collection, documentId, activeView, locale, depth, contentLo
44
45
  search: search
45
46
  });
46
47
  else 'history' === activeView ? navigate({
47
- to: '/admin/collections/$collection/$id/history',
48
+ to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
48
49
  params: {
49
50
  collection,
50
51
  id: documentId
51
52
  },
52
53
  search: search
53
54
  }) : navigate({
54
- to: '/admin/collections/$collection/$id',
55
+ to: getAdminRoutePath('collections', '$collection', '$id'),
55
56
  params: {
56
57
  collection,
57
58
  id: documentId
@@ -64,7 +65,7 @@ const ViewMenu = ({ collection, documentId, activeView, locale, depth, contentLo
64
65
  const n = Number.parseInt(value, 10);
65
66
  const nextDepth = Number.isFinite(n) ? Math.max(0, Math.min(3, n)) : 0;
66
67
  navigate({
67
- to: '/admin/collections/$collection/$id/api',
68
+ to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
68
69
  params: {
69
70
  collection,
70
71
  id: documentId
@@ -154,7 +155,7 @@ const ViewMenu = ({ collection, documentId, activeView, locale, depth, contentLo
154
155
  size: "xs",
155
156
  variant: 'history' === activeView ? 'filled' : 'text',
156
157
  onClick: ()=>navigate({
157
- to: '/admin/collections/$collection/$id/history',
158
+ to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
158
159
  params: {
159
160
  collection,
160
161
  id: documentId
@@ -172,7 +173,7 @@ const ViewMenu = ({ collection, documentId, activeView, locale, depth, contentLo
172
173
  variant: 'edit' === activeView ? 'filled' : 'outlined',
173
174
  className: classnames('byline-view-menu-button', view_menu_module.button),
174
175
  onClick: ()=>navigate({
175
- to: '/admin/collections/$collection/$id',
176
+ to: getAdminRoutePath('collections', '$collection', '$id'),
176
177
  params: {
177
178
  collection,
178
179
  id: documentId
@@ -188,7 +189,7 @@ const ViewMenu = ({ collection, documentId, activeView, locale, depth, contentLo
188
189
  variant: 'api' === activeView ? 'filled' : 'outlined',
189
190
  className: classnames('byline-view-menu-button', view_menu_module.button),
190
191
  onClick: ()=>navigate({
191
- to: '/admin/collections/$collection/$id/api',
192
+ to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
192
193
  params: {
193
194
  collection,
194
195
  id: documentId
@@ -32,6 +32,19 @@
32
32
  * Burns a refresh-token rotation only when the access token actually
33
33
  * fails verification — not on every request. The "one session" UX is
34
34
  * the consequence of this helper working invisibly behind each call.
35
+ *
36
+ * Resolution is memoized per request (`oncePerRequest`): every call within
37
+ * one logical request returns the same `RequestContext` instance — same
38
+ * actor snapshot, same `requestId`. Two invariants depend on this:
39
+ *
40
+ * - The read-authorization layer binds each `ReadContext` to one
41
+ * immutable authority token that includes `requestId`; a fresh id per
42
+ * call makes any two reads sharing a `ReadContext` (the admin tree
43
+ * view, hook-threaded nested reads) throw
44
+ * 'ReadContext cannot be reused across request authorities'.
45
+ * - The refresh path rotates the refresh token; a second unmemoized call
46
+ * in the same request would re-run the dance against the
47
+ * already-rotated cookie and sign the admin out.
35
48
  */
36
49
  import { type RequestContext } from '@byline/auth';
37
50
  export declare function getAdminRequestContext(): Promise<RequestContext>;
@@ -2,12 +2,16 @@ import { ERR_UNAUTHENTICATED } from "@byline/auth";
2
2
  import { getServerConfig } from "@byline/core";
3
3
  import { v7 } from "uuid";
4
4
  import { clearSessionCookies, readAccessTokenCookie, readRefreshTokenCookie, setSessionCookies } from "./auth-cookies.js";
5
+ import { oncePerRequest } from "./request-scope.js";
5
6
  function requireSessionProvider() {
6
7
  const provider = getServerConfig().sessionProvider;
7
8
  if (!provider) throw new Error("No sessionProvider configured on ServerConfig. Construct a JwtSessionProvider in your server config and pass it to initBylineCore().");
8
9
  return provider;
9
10
  }
10
11
  async function getAdminRequestContext() {
12
+ return oncePerRequest('byline:admin-request-context', resolveAdminRequestContext);
13
+ }
14
+ async function resolveAdminRequestContext() {
11
15
  const provider = requireSessionProvider();
12
16
  const accessToken = readAccessTokenCookie();
13
17
  if (accessToken) try {
@@ -0,0 +1,16 @@
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
+ * Resolve `factory` at most once per HTTP request for a given `key`. The
10
+ * returned promise is memoized on the current Start request — rejections
11
+ * included, since a request's cookies cannot change mid-flight and retrying
12
+ * a failed refresh would burn a second rotation on an already-rotated
13
+ * token. Outside the Start runtime (seed scripts, unit tests, background
14
+ * jobs) there is no request to key on and the factory runs unmemoized.
15
+ */
16
+ export declare function oncePerRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
@@ -0,0 +1,24 @@
1
+ import { getRequest } from "@tanstack/react-start/server";
2
+ const requestScopes = new WeakMap();
3
+ function oncePerRequest(key, factory) {
4
+ const request = currentRequest();
5
+ if (!request) return factory();
6
+ let scope = requestScopes.get(request);
7
+ if (!scope) {
8
+ scope = new Map();
9
+ requestScopes.set(request, scope);
10
+ }
11
+ const existing = scope.get(key);
12
+ if (existing) return existing;
13
+ const pending = factory();
14
+ scope.set(key, pending);
15
+ return pending;
16
+ }
17
+ function currentRequest() {
18
+ try {
19
+ return getRequest();
20
+ } catch {
21
+ return;
22
+ }
23
+ }
24
+ export { oncePerRequest };
@@ -1,16 +1,17 @@
1
1
  import { useRouteContext } from "@tanstack/react-router";
2
- const ADMIN_ROUTE_ID = '/_byline/admin';
3
- function useAbility(ability) {
4
- const { user } = useRouteContext({
5
- from: ADMIN_ROUTE_ID
2
+ import { getAdminRouteId } from "../routes/admin-path.js";
3
+ function useAbilityRouteContext() {
4
+ return useRouteContext({
5
+ from: getAdminRouteId()
6
6
  });
7
+ }
8
+ function useAbility(ability) {
9
+ const { user } = useAbilityRouteContext();
7
10
  if (user.is_super_admin) return true;
8
11
  return user.abilities.includes(ability);
9
12
  }
10
13
  function useAbilities() {
11
- const { user } = useRouteContext({
12
- from: ADMIN_ROUTE_ID
13
- });
14
+ const { user } = useAbilityRouteContext();
14
15
  const has = (ability)=>user.is_super_admin || user.abilities.includes(ability);
15
16
  const hasAny = (abilities)=>user.is_super_admin || abilities.some((key)=>user.abilities.includes(key));
16
17
  return {
@@ -5,5 +5,43 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ /**
9
+ * The host's *public-read* `BylineClient` singleton — the strict
10
+ * "preview-can-never-apply" sibling of `getViewerBylineClient`.
11
+ *
12
+ * Configured with `actor: null` and `readMode: 'published'`, full stop.
13
+ * The `requestContext` factory does not consult the `byline_preview`
14
+ * cookie or attempt any admin-session resolution, so an admin browsing
15
+ * with preview mode on cannot accidentally elevate a read that flows
16
+ * through this client. `assertActorCanPerform` permits the null actor
17
+ * exactly on `read` with `readMode === 'published'`, which is what the
18
+ * factory always returns.
19
+ *
20
+ * **Use this client for:**
21
+ * - RSS feeds, Atom feeds, sitemaps
22
+ * - JSON or other endpoints exposed to third-party consumers
23
+ * - any response that an upstream CDN / cache might serve to multiple
24
+ * visitors without keying off the `byline_preview` cookie
25
+ *
26
+ * **Use `getViewerBylineClient` instead for:**
27
+ * - user-facing public pages where an admin's preview-mode session
28
+ * should be honoured
29
+ *
30
+ * Both clients share the same module-scoped singleton pattern so the
31
+ * SDK's per-instance `collectionRecordCache` is amortised across the
32
+ * process lifetime.
33
+ *
34
+ * Companion: `getAdminBylineClient` in `./byline-client.ts` for the
35
+ * admin webapp's authenticated reads (resolves the `RequestContext`
36
+ * from session cookies, memoized per request).
37
+ */
38
+ import { type RequestContext } from '@byline/auth';
8
39
  import { type BylineClient } from '@byline/client';
40
+ /**
41
+ * Anonymous, published-only context — memoized per request so the
42
+ * context (and its requestId) is stable across every read in one
43
+ * request: reads sharing a ReadContext must bind a single request
44
+ * authority.
45
+ */
46
+ export declare function resolvePublicRequestContext(): Promise<RequestContext>;
9
47
  export declare function getPublicBylineClient(): BylineClient;
@@ -1,15 +1,19 @@
1
1
  import { createRequestContext } from "@byline/auth";
2
2
  import { createBylineClient } from "@byline/client";
3
3
  import { getServerConfig } from "@byline/core";
4
+ import { oncePerRequest } from "../auth/request-scope.js";
5
+ function resolvePublicRequestContext() {
6
+ return oncePerRequest('byline:public-request-context', async ()=>createRequestContext({
7
+ readMode: 'published'
8
+ }));
9
+ }
4
10
  let cachedClient;
5
11
  function getPublicBylineClient() {
6
12
  if (cachedClient) return cachedClient;
7
13
  cachedClient = createBylineClient({
8
14
  config: getServerConfig(),
9
- requestContext: ()=>createRequestContext({
10
- readMode: 'published'
11
- })
15
+ requestContext: resolvePublicRequestContext
12
16
  });
13
17
  return cachedClient;
14
18
  }
15
- export { getPublicBylineClient };
19
+ export { getPublicBylineClient, resolvePublicRequestContext };
@@ -7,6 +7,12 @@
7
7
  */
8
8
  import { type BylineClient } from '@byline/client';
9
9
  export declare function getViewerBylineClient(): BylineClient;
10
+ /**
11
+ * Preview-aware viewer context — memoized per request so every read in
12
+ * one request binds the same context instance (and requestId): reads
13
+ * sharing a ReadContext must resolve a single request authority.
14
+ */
15
+ export declare function resolveViewerRequestContext(): Promise<import("@byline/auth").RequestContext>;
10
16
  /**
11
17
  * Resolve whether the current request should surface non-published
12
18
  * versions. True iff the preview cookie is set **and** a valid admin
@@ -3,30 +3,35 @@ import { createBylineClient } from "@byline/client";
3
3
  import { getServerConfig } from "@byline/core";
4
4
  import { getAdminRequestContext } from "../auth/auth-context.js";
5
5
  import { readPreviewCookie } from "../auth/preview-cookies.js";
6
+ import { oncePerRequest } from "../auth/request-scope.js";
6
7
  let cachedClient;
7
8
  function getViewerBylineClient() {
8
9
  if (cachedClient) return cachedClient;
9
10
  cachedClient = createBylineClient({
10
11
  config: getServerConfig(),
11
- requestContext: async ()=>{
12
- if (!readPreviewCookie()) return createRequestContext({
13
- readMode: 'published'
14
- });
15
- try {
16
- const ctx = await getAdminRequestContext();
17
- return {
18
- ...ctx,
19
- readMode: 'any'
20
- };
21
- } catch {
22
- return createRequestContext({
23
- readMode: 'published'
24
- });
25
- }
26
- }
12
+ requestContext: resolveViewerRequestContext
27
13
  });
28
14
  return cachedClient;
29
15
  }
16
+ function resolveViewerRequestContext() {
17
+ return oncePerRequest('byline:viewer-request-context', resolveViewerContext);
18
+ }
19
+ async function resolveViewerContext() {
20
+ if (!readPreviewCookie()) return createRequestContext({
21
+ readMode: 'published'
22
+ });
23
+ try {
24
+ const ctx = await getAdminRequestContext();
25
+ return {
26
+ ...ctx,
27
+ readMode: 'any'
28
+ };
29
+ } catch {
30
+ return createRequestContext({
31
+ readMode: 'published'
32
+ });
33
+ }
34
+ }
30
35
  async function isPreviewActive() {
31
36
  if (!readPreviewCookie()) return false;
32
37
  try {
@@ -36,4 +41,4 @@ async function isPreviewActive() {
36
41
  return false;
37
42
  }
38
43
  }
39
- export { getViewerBylineClient, isPreviewActive };
44
+ export { getViewerBylineClient, isPreviewActive, resolveViewerRequestContext };
@@ -4,7 +4,8 @@ const CODED_ERROR_NAMES = new Set([
4
4
  'AdminPermissionsError',
5
5
  'AdminRolesError',
6
6
  'AdminUsersError',
7
- 'AuthError'
7
+ 'AuthError',
8
+ 'BylineError'
8
9
  ]);
9
10
  class BylineCodedError extends Error {
10
11
  constructor(payload){
@@ -0,0 +1,22 @@
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
+ /** Build an admin URL or parameterized TanStack route from client-safe config. */
9
+ export declare function getAdminRoutePath(...segments: ReadonlyArray<string | number>): string;
10
+ /** Build the matching pathless-group route ID used by generated host route trees. */
11
+ export declare function getAdminRouteId(...segments: ReadonlyArray<string | number>): string;
12
+ /** True when a pathname is the target route or one of its segment-delimited descendants. */
13
+ export declare function isRoutePathWithin(pathname: string, target: string): boolean;
14
+ /**
15
+ * Accept an untrusted callback only when it is an unencoded, same-origin path
16
+ * inside the configured admin mount.
17
+ */
18
+ export declare function resolveAdminCallbackPath(value: unknown): string | undefined;
19
+ /** Resolve an untrusted callback or fall back to the configured admin dashboard. */
20
+ export declare function resolveAdminSignInRedirect(value: unknown): string;
21
+ /** Match the admin root exactly and admin child entries by pathname prefix. */
22
+ export declare function isAdminRoutePathActive(pathname: string, ...segments: ReadonlyArray<string>): boolean;
@@ -0,0 +1,35 @@
1
+ import { getClientConfig, normalizeRootRelativeRedirect } from "@byline/core";
2
+ function getAdminRoutePath(...segments) {
3
+ const configuredBase = getClientConfig().routes.admin;
4
+ const normalizedBase = configuredBase.replace(/^\/+|\/+$/g, '');
5
+ const base = normalizedBase ? `/${normalizedBase}` : '/';
6
+ const suffix = segments.map((segment)=>String(segment).replace(/^\/+|\/+$/g, '')).filter(Boolean).join('/');
7
+ if (!suffix) return base;
8
+ return '/' === base ? `/${suffix}` : `${base}/${suffix}`;
9
+ }
10
+ function getAdminRouteId(...segments) {
11
+ return `/_byline${getAdminRoutePath(...segments)}`;
12
+ }
13
+ function isRoutePathWithin(pathname, target) {
14
+ const current = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
15
+ const base = target.length > 1 ? target.replace(/\/+$/, '') : target;
16
+ return current === base || ('/' === base ? current.startsWith('/') : current.startsWith(`${base}/`));
17
+ }
18
+ function resolveAdminCallbackPath(value) {
19
+ if ('string' != typeof value) return;
20
+ const normalized = normalizeRootRelativeRedirect(value);
21
+ if (!normalized) return;
22
+ const url = new URL(normalized, 'https://byline.invalid');
23
+ if (!isRoutePathWithin(url.pathname, getAdminRoutePath())) return;
24
+ return `${url.pathname}${url.search}${url.hash}`;
25
+ }
26
+ function resolveAdminSignInRedirect(value) {
27
+ return resolveAdminCallbackPath(value) ?? getAdminRoutePath();
28
+ }
29
+ function isAdminRoutePathActive(pathname, ...segments) {
30
+ const target = getAdminRoutePath(...segments);
31
+ const current = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
32
+ if (0 === segments.length) return current === target;
33
+ return isRoutePathWithin(current, target);
34
+ }
35
+ export { getAdminRouteId, getAdminRoutePath, isAdminRoutePathActive, isRoutePathWithin, resolveAdminCallbackPath, resolveAdminSignInRedirect };