@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.
- package/dist/admin-shell/admin-activity/list.js +15 -5
- package/dist/admin-shell/admin-roles/delete.js +2 -1
- package/dist/admin-shell/admin-roles/list.js +2 -1
- package/dist/admin-shell/admin-users/delete.js +2 -1
- package/dist/admin-shell/admin-users/list.js +4 -7
- package/dist/admin-shell/chrome/admin-app-bar.js +2 -1
- package/dist/admin-shell/chrome/admin-menu-drawer.js +9 -8
- package/dist/admin-shell/chrome/admin-menu-provider.js +2 -1
- package/dist/admin-shell/chrome/dashboard.js +4 -3
- package/dist/admin-shell/chrome/sign-in-page.d.ts +2 -2
- package/dist/admin-shell/chrome/sign-in-page.js +2 -2
- package/dist/admin-shell/collections/create.js +4 -3
- package/dist/admin-shell/collections/document-history.js +6 -1
- package/dist/admin-shell/collections/edit.js +18 -14
- package/dist/admin-shell/collections/history.js +5 -4
- package/dist/admin-shell/collections/list.js +8 -7
- package/dist/admin-shell/collections/restore-version-modal.js +2 -1
- package/dist/admin-shell/collections/tree-list.js +53 -17
- package/dist/admin-shell/collections/view-menu.js +9 -8
- package/dist/auth/auth-context.d.ts +13 -0
- package/dist/auth/auth-context.js +4 -0
- package/dist/auth/request-scope.d.ts +16 -0
- package/dist/auth/request-scope.js +24 -0
- package/dist/integrations/abilities.js +8 -7
- package/dist/integrations/byline-public-client.d.ts +38 -0
- package/dist/integrations/byline-public-client.js +8 -4
- package/dist/integrations/byline-viewer-client.d.ts +6 -0
- package/dist/integrations/byline-viewer-client.js +22 -17
- package/dist/integrations/start-errors.js +2 -1
- package/dist/routes/admin-path.d.ts +22 -0
- package/dist/routes/admin-path.js +35 -0
- package/dist/routes/create-admin-account-route.js +3 -2
- package/dist/routes/create-admin-activity-route.js +3 -2
- package/dist/routes/create-admin-dashboard-route.js +2 -1
- package/dist/routes/create-admin-layout-route.d.ts +1 -1
- package/dist/routes/create-admin-layout-route.js +3 -1
- package/dist/routes/create-admin-permissions-route.js +3 -2
- package/dist/routes/create-admin-role-edit-route.js +4 -3
- package/dist/routes/create-admin-roles-list-route.js +3 -2
- package/dist/routes/create-admin-user-edit-route.js +4 -3
- package/dist/routes/create-admin-users-list-route.js +3 -2
- package/dist/routes/create-collection-api-route.d.ts +1 -7
- package/dist/routes/create-collection-api-route.js +12 -8
- package/dist/routes/create-collection-create-route.js +4 -3
- package/dist/routes/create-collection-edit-route.d.ts +1 -7
- package/dist/routes/create-collection-edit-route.js +9 -6
- package/dist/routes/create-collection-history-route.d.ts +1 -7
- package/dist/routes/create-collection-history-route.js +10 -7
- package/dist/routes/create-collection-list-route.js +3 -2
- package/dist/routes/create-sign-in-route.js +3 -2
- package/dist/routes/get-content-locale-route-config.d.ts +12 -0
- package/dist/routes/get-content-locale-route-config.js +16 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/sign-in-path.d.ts +13 -0
- package/dist/routes/sign-in-path.js +19 -0
- package/dist/server-fns/collections/delete-outcome.d.ts +30 -0
- package/dist/server-fns/collections/delete-outcome.js +34 -0
- package/dist/server-fns/collections/delete.d.ts +1 -4
- package/dist/server-fns/collections/delete.js +2 -4
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/tree-mutation.d.ts +46 -0
- package/dist/server-fns/collections/tree-mutation.js +109 -0
- package/dist/server-fns/collections/tree-read.d.ts +6 -0
- package/dist/server-fns/collections/tree-read.js +15 -0
- package/dist/server-fns/collections/tree.d.ts +5 -4
- package/dist/server-fns/collections/tree.js +15 -12
- package/dist/server-fns/collections/update.js +2 -1
- package/dist/vite/client-hook-boundary.d.ts +19 -0
- package/dist/vite/client-hook-boundary.js +20 -0
- package/dist/vite/index.d.ts +8 -0
- package/dist/vite/index.js +1 -0
- package/package.json +15 -9
- package/src/admin-shell/admin-activity/list.tsx +13 -3
- package/src/admin-shell/admin-roles/delete.tsx +2 -1
- package/src/admin-shell/admin-roles/list.tsx +2 -1
- package/src/admin-shell/admin-users/delete.tsx +3 -2
- package/src/admin-shell/admin-users/list.tsx +9 -8
- package/src/admin-shell/chrome/admin-app-bar.tsx +2 -1
- package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -8
- package/src/admin-shell/chrome/admin-menu-provider.tsx +2 -1
- package/src/admin-shell/chrome/dashboard.tsx +4 -3
- package/src/admin-shell/chrome/sign-in-page.tsx +3 -3
- package/src/admin-shell/collections/create.tsx +4 -3
- package/src/admin-shell/collections/document-history.tsx +5 -0
- package/src/admin-shell/collections/edit.tsx +20 -13
- package/src/admin-shell/collections/history.tsx +6 -5
- package/src/admin-shell/collections/list.tsx +8 -7
- package/src/admin-shell/collections/restore-version-modal.tsx +2 -1
- package/src/admin-shell/collections/tree-list.tsx +78 -22
- package/src/admin-shell/collections/view-menu.tsx +9 -8
- package/src/auth/auth-context.test.node.ts +87 -0
- package/src/auth/auth-context.ts +18 -0
- package/src/auth/request-scope.test.node.ts +93 -0
- package/src/auth/request-scope.ts +62 -0
- package/src/integrations/abilities.tsx +14 -4
- package/src/integrations/byline-client.ts +6 -4
- package/src/integrations/byline-public-client.ts +18 -4
- package/src/integrations/byline-viewer-client.ts +30 -18
- package/src/integrations/request-context-contract.test.node.ts +131 -0
- package/src/integrations/request-context-factories.test.node.ts +140 -0
- package/src/integrations/start-errors.test.node.ts +22 -0
- package/src/integrations/start-errors.ts +1 -0
- package/src/routes/admin-path.test.node.ts +129 -0
- package/src/routes/admin-path.ts +66 -0
- package/src/routes/auth-routes.test.node.ts +39 -0
- package/src/routes/create-admin-account-route.tsx +3 -2
- package/src/routes/create-admin-activity-route.tsx +3 -2
- package/src/routes/create-admin-dashboard-route.tsx +2 -1
- package/src/routes/create-admin-layout-route.tsx +4 -4
- package/src/routes/create-admin-permissions-route.tsx +3 -2
- package/src/routes/create-admin-role-edit-route.tsx +7 -3
- package/src/routes/create-admin-roles-list-route.tsx +3 -2
- package/src/routes/create-admin-user-edit-route.tsx +7 -3
- package/src/routes/create-admin-users-list-route.tsx +3 -2
- package/src/routes/create-collection-api-route.tsx +15 -14
- package/src/routes/create-collection-create-route.tsx +7 -3
- package/src/routes/create-collection-edit-route.tsx +12 -12
- package/src/routes/create-collection-history-route.tsx +13 -13
- package/src/routes/create-collection-list-route.tsx +3 -2
- package/src/routes/create-sign-in-route.tsx +9 -3
- package/src/routes/get-content-locale-route-config.ts +26 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/sign-in-path.test.node.ts +79 -0
- package/src/routes/sign-in-path.ts +36 -0
- package/src/server-fns/collections/delete-outcome.test.node.ts +72 -0
- package/src/server-fns/collections/delete-outcome.ts +80 -0
- package/src/server-fns/collections/delete.ts +2 -1
- package/src/server-fns/collections/get.ts +2 -0
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/tree-mutation.test.node.ts +225 -0
- package/src/server-fns/collections/tree-mutation.ts +165 -0
- package/src/server-fns/collections/tree-read.test.node.ts +46 -0
- package/src/server-fns/collections/tree-read.ts +26 -0
- package/src/server-fns/collections/tree.ts +26 -10
- package/src/server-fns/collections/update.ts +3 -0
- package/src/vite/client-hook-boundary.test.node.ts +50 -0
- package/src/vite/client-hook-boundary.ts +45 -0
- package/src/vite/index.ts +9 -0
|
@@ -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:
|
|
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
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
* Direct regression coverage for the public and viewer request-context
|
|
11
|
+
* factories: both must be request-stable (same instance, same requestId,
|
|
12
|
+
* for every call within one Start request) so reads sharing a
|
|
13
|
+
* `ReadContext` bind a single request authority.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { AdminAuth } from '@byline/auth'
|
|
17
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
18
|
+
|
|
19
|
+
const mocks = vi.hoisted(() => ({
|
|
20
|
+
verifyAccessToken: vi.fn(),
|
|
21
|
+
refreshSession: vi.fn(),
|
|
22
|
+
getCookie: vi.fn(),
|
|
23
|
+
setCookie: vi.fn(),
|
|
24
|
+
getRequestHeader: vi.fn(),
|
|
25
|
+
getRequest: vi.fn(),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
vi.mock('@byline/core', async () => {
|
|
29
|
+
const actual = await vi.importActual<typeof import('@byline/core')>('@byline/core')
|
|
30
|
+
return {
|
|
31
|
+
...actual,
|
|
32
|
+
getServerConfig: () => ({
|
|
33
|
+
sessionProvider: {
|
|
34
|
+
verifyAccessToken: mocks.verifyAccessToken,
|
|
35
|
+
refreshSession: mocks.refreshSession,
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
42
|
+
getCookie: mocks.getCookie,
|
|
43
|
+
setCookie: mocks.setCookie,
|
|
44
|
+
getRequestHeader: mocks.getRequestHeader,
|
|
45
|
+
getRequest: mocks.getRequest,
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
import { resolvePublicRequestContext } from './byline-public-client.js'
|
|
49
|
+
import { resolveViewerRequestContext } from './byline-viewer-client.js'
|
|
50
|
+
|
|
51
|
+
function cookiesReturn(values: Record<string, string | undefined>) {
|
|
52
|
+
mocks.getCookie.mockImplementation((name: string) => values[name])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('request-context factories', () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
vi.clearAllMocks()
|
|
58
|
+
mocks.getRequest.mockImplementation(() => {
|
|
59
|
+
throw new Error('No StartEvent found in AsyncLocalStorage')
|
|
60
|
+
})
|
|
61
|
+
cookiesReturn({})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('resolvePublicRequestContext', () => {
|
|
65
|
+
it('returns the same anonymous published context for every call in one request', async () => {
|
|
66
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
67
|
+
|
|
68
|
+
const first = await resolvePublicRequestContext()
|
|
69
|
+
const second = await resolvePublicRequestContext()
|
|
70
|
+
|
|
71
|
+
expect(second).toBe(first)
|
|
72
|
+
expect(first.actor).toBeNull()
|
|
73
|
+
expect(first.readMode).toBe('published')
|
|
74
|
+
expect(second.requestId).toBe(first.requestId)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('resolves independently across requests', async () => {
|
|
78
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
79
|
+
const first = await resolvePublicRequestContext()
|
|
80
|
+
mocks.getRequest.mockReturnValue({ id: 'request-b' })
|
|
81
|
+
const second = await resolvePublicRequestContext()
|
|
82
|
+
|
|
83
|
+
expect(second).not.toBe(first)
|
|
84
|
+
expect(second.requestId).not.toBe(first.requestId)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('resolveViewerRequestContext', () => {
|
|
89
|
+
it('returns the same anonymous context per request when no preview cookie is set', async () => {
|
|
90
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
91
|
+
|
|
92
|
+
const first = await resolveViewerRequestContext()
|
|
93
|
+
const second = await resolveViewerRequestContext()
|
|
94
|
+
|
|
95
|
+
expect(second).toBe(first)
|
|
96
|
+
expect(first.actor).toBeNull()
|
|
97
|
+
expect(first.readMode).toBe('published')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('returns the same admin any-mode context per request in preview mode', async () => {
|
|
101
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
102
|
+
cookiesReturn({ byline_preview: '1', byline_access_token: 'valid-access' })
|
|
103
|
+
const actor = new AdminAuth({ id: 'admin-1', abilities: [] })
|
|
104
|
+
mocks.verifyAccessToken.mockResolvedValue({ actor })
|
|
105
|
+
|
|
106
|
+
const first = await resolveViewerRequestContext()
|
|
107
|
+
const second = await resolveViewerRequestContext()
|
|
108
|
+
|
|
109
|
+
expect(second).toBe(first)
|
|
110
|
+
expect(first.actor).toBe(actor)
|
|
111
|
+
expect(first.readMode).toBe('any')
|
|
112
|
+
// The viewer context and the nested admin resolution are both
|
|
113
|
+
// memoized — one session verification per request, not per call.
|
|
114
|
+
expect(mocks.verifyAccessToken).toHaveBeenCalledTimes(1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('falls back to one stable anonymous context when the preview session is stale', async () => {
|
|
118
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
119
|
+
cookiesReturn({ byline_preview: '1', byline_access_token: 'stale' })
|
|
120
|
+
mocks.verifyAccessToken.mockRejectedValue(new Error('expired'))
|
|
121
|
+
|
|
122
|
+
const first = await resolveViewerRequestContext()
|
|
123
|
+
const second = await resolveViewerRequestContext()
|
|
124
|
+
|
|
125
|
+
expect(second).toBe(first)
|
|
126
|
+
expect(first.actor).toBeNull()
|
|
127
|
+
expect(first.readMode).toBe('published')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('resolves independently across requests', async () => {
|
|
131
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
132
|
+
const first = await resolveViewerRequestContext()
|
|
133
|
+
mocks.getRequest.mockReturnValue({ id: 'request-b' })
|
|
134
|
+
const second = await resolveViewerRequestContext()
|
|
135
|
+
|
|
136
|
+
expect(second).not.toBe(first)
|
|
137
|
+
expect(second.requestId).not.toBe(first.requestId)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ERR_CONFLICT, ERR_TREE_HOOK_COMMITTED } from '@byline/core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { bylineCodedErrorAdapter } from './start-errors.js'
|
|
5
|
+
|
|
6
|
+
describe('Byline coded error serialization', () => {
|
|
7
|
+
it.each([
|
|
8
|
+
ERR_CONFLICT({ message: 'stale tree' }),
|
|
9
|
+
ERR_TREE_HOOK_COMMITTED({ message: 'tree committed; hook failed' }),
|
|
10
|
+
])('preserves core BylineError code $code', (error) => {
|
|
11
|
+
expect(bylineCodedErrorAdapter.test(error)).toBe(true)
|
|
12
|
+
|
|
13
|
+
const payload = bylineCodedErrorAdapter.toSerializable(error)
|
|
14
|
+
const restored = bylineCodedErrorAdapter.fromSerializable(payload)
|
|
15
|
+
|
|
16
|
+
expect(restored).toMatchObject({
|
|
17
|
+
name: 'BylineError',
|
|
18
|
+
code: error.code,
|
|
19
|
+
message: error.message,
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { defineClientConfig } from '@byline/core'
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getAdminRouteId,
|
|
6
|
+
getAdminRoutePath,
|
|
7
|
+
isAdminRoutePathActive,
|
|
8
|
+
isRoutePathWithin,
|
|
9
|
+
resolveAdminCallbackPath,
|
|
10
|
+
resolveAdminSignInRedirect,
|
|
11
|
+
} from './admin-path.js'
|
|
12
|
+
|
|
13
|
+
function registerRoutes(admin?: string): void {
|
|
14
|
+
defineClientConfig({
|
|
15
|
+
serverURL: 'http://localhost:3000',
|
|
16
|
+
routes: admin ? { admin } : undefined,
|
|
17
|
+
collections: [],
|
|
18
|
+
admin: [],
|
|
19
|
+
i18n: {
|
|
20
|
+
interface: { defaultLocale: 'en', locales: [] },
|
|
21
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('getAdminRoutePath', () => {
|
|
27
|
+
beforeEach(() => registerRoutes('/cms/'))
|
|
28
|
+
|
|
29
|
+
it.each([
|
|
30
|
+
[[], '/cms'],
|
|
31
|
+
[['collections'], '/cms/collections'],
|
|
32
|
+
[['collections', '$collection'], '/cms/collections/$collection'],
|
|
33
|
+
[['collections', '$collection', 'create'], '/cms/collections/$collection/create'],
|
|
34
|
+
[['collections', '$collection', '$id'], '/cms/collections/$collection/$id'],
|
|
35
|
+
[['collections', '$collection', '$id', 'history'], '/cms/collections/$collection/$id/history'],
|
|
36
|
+
[['collections', '$collection', '$id', 'api'], '/cms/collections/$collection/$id/api'],
|
|
37
|
+
[['users'], '/cms/users'],
|
|
38
|
+
[['users', '$id'], '/cms/users/$id'],
|
|
39
|
+
[['roles'], '/cms/roles'],
|
|
40
|
+
[['roles', '$id'], '/cms/roles/$id'],
|
|
41
|
+
[['permissions'], '/cms/permissions'],
|
|
42
|
+
[['activity'], '/cms/activity'],
|
|
43
|
+
[['account'], '/cms/account'],
|
|
44
|
+
])('joins %j against the configured admin mount', (segments, expected) => {
|
|
45
|
+
expect(getAdminRoutePath(...segments)).toBe(expected)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('safely joins slash-delimited segments', () => {
|
|
49
|
+
expect(getAdminRoutePath('/collections/', '/pages/')).toBe('/cms/collections/pages')
|
|
50
|
+
registerRoutes('cms')
|
|
51
|
+
expect(getAdminRoutePath('users')).toBe('/cms/users')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('preserves a configured multi-segment admin mount', () => {
|
|
55
|
+
registerRoutes('/internal/cms/')
|
|
56
|
+
expect(getAdminRoutePath('users')).toBe('/internal/cms/users')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('accepts callbacks inside a configured multi-segment admin mount', () => {
|
|
60
|
+
registerRoutes('/internal/cms/')
|
|
61
|
+
expect(resolveAdminCallbackPath('/internal/cms/users?status=active#results')).toBe(
|
|
62
|
+
'/internal/cms/users?status=active#results'
|
|
63
|
+
)
|
|
64
|
+
expect(resolveAdminCallbackPath('/internal/cms-old/users')).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('matches dashboard, collection, and trailing-slash paths', () => {
|
|
68
|
+
expect(isAdminRoutePathActive('/cms')).toBe(true)
|
|
69
|
+
expect(isAdminRoutePathActive('/cms/')).toBe(true)
|
|
70
|
+
expect(isAdminRoutePathActive('/cms/collections/pages', 'collections')).toBe(true)
|
|
71
|
+
expect(isAdminRoutePathActive('/cms/users', 'collections')).toBe(false)
|
|
72
|
+
expect(isAdminRoutePathActive('/cms/collections-old', 'collections')).toBe(false)
|
|
73
|
+
expect(isRoutePathWithin('/cms/users/123', '/cms/users')).toBe(true)
|
|
74
|
+
expect(isRoutePathWithin('/cms/users-archive', '/cms/users')).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it.each([
|
|
78
|
+
['/cms', '/cms'],
|
|
79
|
+
['/cms/', '/cms/'],
|
|
80
|
+
[
|
|
81
|
+
'/cms/collections/pages?query=hello%20world#results',
|
|
82
|
+
'/cms/collections/pages?query=hello%20world#results',
|
|
83
|
+
],
|
|
84
|
+
])('accepts a safe admin callback %j', (callback, expected) => {
|
|
85
|
+
expect(resolveAdminCallbackPath(callback)).toBe(expected)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it.each([
|
|
89
|
+
undefined,
|
|
90
|
+
'',
|
|
91
|
+
' /cms',
|
|
92
|
+
'https://evil.test/cms',
|
|
93
|
+
'//evil.test/cms',
|
|
94
|
+
'/\\evil.test/cms',
|
|
95
|
+
'/admin',
|
|
96
|
+
'/cms-old',
|
|
97
|
+
'/cms\\users',
|
|
98
|
+
'%2Fcms',
|
|
99
|
+
'/%2F%2Fevil.test',
|
|
100
|
+
'/cms/%2e%2e/account',
|
|
101
|
+
'/cms/%2e/account',
|
|
102
|
+
'/cms/%252e%252e/account',
|
|
103
|
+
'/cms/%5c%5cevil.test',
|
|
104
|
+
'/cms/../account',
|
|
105
|
+
'/cms/./account',
|
|
106
|
+
'/cms\u0085account',
|
|
107
|
+
])('rejects an unsafe or non-admin callback %j', (callback) => {
|
|
108
|
+
expect(resolveAdminCallbackPath(callback)).toBeUndefined()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('sends direct and rejected-callback sign-ins to the configured dashboard', () => {
|
|
112
|
+
expect(resolveAdminSignInRedirect(undefined)).toBe('/cms')
|
|
113
|
+
expect(resolveAdminSignInRedirect('https://evil.test')).toBe('/cms')
|
|
114
|
+
expect(resolveAdminSignInRedirect('/cms/account')).toBe('/cms/account')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('builds route IDs matching the custom filesystem mount', () => {
|
|
118
|
+
expect(getAdminRouteId()).toBe('/_byline/cms')
|
|
119
|
+
expect(getAdminRouteId('collections', '$collection', '$id', 'history')).toBe(
|
|
120
|
+
'/_byline/cms/collections/$collection/$id/history'
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('keeps the default admin mount backward compatible', () => {
|
|
125
|
+
registerRoutes()
|
|
126
|
+
expect(getAdminRoutePath()).toBe('/admin')
|
|
127
|
+
expect(getAdminRoutePath('collections', '$collection')).toBe('/admin/collections/$collection')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getClientConfig, normalizeRootRelativeRedirect } from '@byline/core'
|
|
10
|
+
|
|
11
|
+
/** Build an admin URL or parameterized TanStack route from client-safe config. */
|
|
12
|
+
export function getAdminRoutePath(...segments: ReadonlyArray<string | number>): string {
|
|
13
|
+
const configuredBase = getClientConfig().routes.admin
|
|
14
|
+
const normalizedBase = configuredBase.replace(/^\/+|\/+$/g, '')
|
|
15
|
+
const base = normalizedBase ? `/${normalizedBase}` : '/'
|
|
16
|
+
const suffix = segments
|
|
17
|
+
.map((segment) => String(segment).replace(/^\/+|\/+$/g, ''))
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.join('/')
|
|
20
|
+
|
|
21
|
+
if (!suffix) return base
|
|
22
|
+
return base === '/' ? `/${suffix}` : `${base}/${suffix}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build the matching pathless-group route ID used by generated host route trees. */
|
|
26
|
+
export function getAdminRouteId(...segments: ReadonlyArray<string | number>): string {
|
|
27
|
+
return `/_byline${getAdminRoutePath(...segments)}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when a pathname is the target route or one of its segment-delimited descendants. */
|
|
31
|
+
export function isRoutePathWithin(pathname: string, target: string): boolean {
|
|
32
|
+
const current = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname
|
|
33
|
+
const base = target.length > 1 ? target.replace(/\/+$/, '') : target
|
|
34
|
+
return (
|
|
35
|
+
current === base || (base === '/' ? current.startsWith('/') : current.startsWith(`${base}/`))
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Accept an untrusted callback only when it is an unencoded, same-origin path
|
|
41
|
+
* inside the configured admin mount.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveAdminCallbackPath(value: unknown): string | undefined {
|
|
44
|
+
if (typeof value !== 'string') return undefined
|
|
45
|
+
const normalized = normalizeRootRelativeRedirect(value)
|
|
46
|
+
if (!normalized) return undefined
|
|
47
|
+
const url = new URL(normalized, 'https://byline.invalid')
|
|
48
|
+
if (!isRoutePathWithin(url.pathname, getAdminRoutePath())) return undefined
|
|
49
|
+
return `${url.pathname}${url.search}${url.hash}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolve an untrusted callback or fall back to the configured admin dashboard. */
|
|
53
|
+
export function resolveAdminSignInRedirect(value: unknown): string {
|
|
54
|
+
return resolveAdminCallbackPath(value) ?? getAdminRoutePath()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Match the admin root exactly and admin child entries by pathname prefix. */
|
|
58
|
+
export function isAdminRoutePathActive(
|
|
59
|
+
pathname: string,
|
|
60
|
+
...segments: ReadonlyArray<string>
|
|
61
|
+
): boolean {
|
|
62
|
+
const target = getAdminRoutePath(...segments)
|
|
63
|
+
const current = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname
|
|
64
|
+
if (segments.length === 0) return current === target
|
|
65
|
+
return isRoutePathWithin(current, target)
|
|
66
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineClientConfig } from '@byline/core'
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { resolveAdminCallbackPath, resolveAdminSignInRedirect } from './admin-path.js'
|
|
5
|
+
import { configureSignInRoutePath, getSignInRoutePath } from './sign-in-path.js'
|
|
6
|
+
|
|
7
|
+
describe('admin authentication routes', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
defineClientConfig({
|
|
10
|
+
serverURL: 'http://localhost:3000',
|
|
11
|
+
routes: { admin: '/cms/', signIn: '/staff/login/' },
|
|
12
|
+
collections: [],
|
|
13
|
+
admin: [],
|
|
14
|
+
i18n: {
|
|
15
|
+
interface: { defaultLocale: 'en', locales: [] },
|
|
16
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('sanitizes sign-in route search before it reaches the form', () => {
|
|
22
|
+
expect(resolveAdminCallbackPath('/cms/account')).toBe('/cms/account')
|
|
23
|
+
expect(resolveAdminCallbackPath('https://evil.test')).toBeUndefined()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('uses the custom mount for direct sign-in and callback navigation', () => {
|
|
27
|
+
expect(resolveAdminSignInRedirect(undefined)).toBe('/cms')
|
|
28
|
+
expect(resolveAdminSignInRedirect('/cms/collections/pages/1')).toBe('/cms/collections/pages/1')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('shares routes.signIn between guard and sign-out consumers', () => {
|
|
32
|
+
expect(getSignInRoutePath()).toBe('/staff/login')
|
|
33
|
+
expect(configureSignInRoutePath('/staff/login/')).toBe('/staff/login')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not allow the deprecated layout override to silently diverge', () => {
|
|
37
|
+
expect(() => configureSignInRoutePath('/auth/login')).toThrow(/must match routes\.signIn/)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -14,6 +14,7 @@ import { Container, Section } from '@byline/ui/react'
|
|
|
14
14
|
|
|
15
15
|
import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
|
|
16
16
|
import { type AccountResponse, getAccount } from '../server-fns/admin-account/index.js'
|
|
17
|
+
import { getAdminRoutePath } from './admin-path.js'
|
|
17
18
|
|
|
18
19
|
function displayNameFor(account: {
|
|
19
20
|
given_name: string | null
|
|
@@ -43,8 +44,8 @@ export function createAdminAccountRoute(path: string) {
|
|
|
43
44
|
<>
|
|
44
45
|
<BreadcrumbsClient
|
|
45
46
|
breadcrumbs={[
|
|
46
|
-
{ label: t('chrome.menu.dashboard'), href:
|
|
47
|
-
{ label: t('chrome.account'), href: '
|
|
47
|
+
{ label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
|
|
48
|
+
{ label: t('chrome.account'), href: getAdminRoutePath('account') },
|
|
48
49
|
]}
|
|
49
50
|
/>
|
|
50
51
|
<Section className="pb-2">
|
|
@@ -14,6 +14,7 @@ import { z } from 'zod'
|
|
|
14
14
|
import { ActivitySystemView } from '../admin-shell/admin-activity/list.js'
|
|
15
15
|
import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
|
|
16
16
|
import { getSystemActivityLog } from '../server-fns/admin-activity/index.js'
|
|
17
|
+
import { getAdminRoutePath } from './admin-path.js'
|
|
17
18
|
import type { SystemActivityResponse } from '../server-fns/admin-activity/index.js'
|
|
18
19
|
|
|
19
20
|
const searchSchema = z.object({
|
|
@@ -62,8 +63,8 @@ export function createAdminActivityRoute(path: string) {
|
|
|
62
63
|
<>
|
|
63
64
|
<BreadcrumbsClient
|
|
64
65
|
breadcrumbs={[
|
|
65
|
-
{ label: t('chrome.menu.dashboard'), href:
|
|
66
|
-
{ label: t('activity.title'), href: '
|
|
66
|
+
{ label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
|
|
67
|
+
{ label: t('activity.title'), href: getAdminRoutePath('activity') },
|
|
67
68
|
]}
|
|
68
69
|
/>
|
|
69
70
|
<ActivitySystemView data={data} />
|
|
@@ -14,6 +14,7 @@ import { useTranslation } from '@byline/i18n/react'
|
|
|
14
14
|
import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
|
|
15
15
|
import { AdminDashboard } from '../admin-shell/chrome/dashboard.js'
|
|
16
16
|
import { type CollectionStatusCount, getCollectionStats } from '../server-fns/collections/index.js'
|
|
17
|
+
import { getAdminRoutePath } from './admin-path.js'
|
|
17
18
|
|
|
18
19
|
export function createAdminDashboardRoute(path: string) {
|
|
19
20
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
|
|
@@ -44,7 +45,7 @@ export function createAdminDashboardRoute(path: string) {
|
|
|
44
45
|
return (
|
|
45
46
|
<>
|
|
46
47
|
<BreadcrumbsClient
|
|
47
|
-
breadcrumbs={[{ label: t('chrome.menu.dashboard'), href:
|
|
48
|
+
breadcrumbs={[{ label: t('chrome.menu.dashboard'), href: getAdminRoutePath() }]}
|
|
48
49
|
/>
|
|
49
50
|
<AdminDashboard statsMap={statsMap} />
|
|
50
51
|
</>
|