@byline/host-tanstack-start 3.21.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.js +5 -4
- package/dist/routes/create-collection-create-route.js +4 -3
- package/dist/routes/create-collection-edit-route.js +4 -3
- package/dist/routes/create-collection-history-route.js +5 -4
- package/dist/routes/create-collection-list-route.js +3 -2
- package/dist/routes/create-sign-in-route.js +3 -2
- 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 +8 -4
- package/src/routes/create-collection-create-route.tsx +7 -3
- package/src/routes/create-collection-edit-route.tsx +7 -3
- package/src/routes/create-collection-history-route.tsx +8 -4
- package/src/routes/create-collection-list-route.tsx +3 -2
- package/src/routes/create-sign-in-route.tsx +9 -3
- 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
|
@@ -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
|
})
|
package/src/auth/auth-context.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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 } =
|
|
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 } =
|
|
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
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
|
37
|
-
* from session cookies
|
|
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:
|
|
64
|
+
requestContext: resolvePublicRequestContext,
|
|
51
65
|
})
|
|
52
66
|
return cachedClient
|
|
53
67
|
}
|
|
@@ -49,6 +49,7 @@ import { getServerConfig } from '@byline/core'
|
|
|
49
49
|
|
|
50
50
|
import { getAdminRequestContext } from '../auth/auth-context.js'
|
|
51
51
|
import { readPreviewCookie } from '../auth/preview-cookies.js'
|
|
52
|
+
import { oncePerRequest } from '../auth/request-scope.js'
|
|
52
53
|
|
|
53
54
|
let cachedClient: BylineClient | undefined
|
|
54
55
|
|
|
@@ -56,28 +57,39 @@ export function getViewerBylineClient(): BylineClient {
|
|
|
56
57
|
if (cachedClient) return cachedClient
|
|
57
58
|
cachedClient = createBylineClient({
|
|
58
59
|
config: getServerConfig(),
|
|
59
|
-
requestContext:
|
|
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
|
+
})
|