@byline/client 3.21.0 → 4.1.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/client.d.ts +6 -3
- package/dist/client.js +3 -1
- package/dist/collection-handle.d.ts +33 -38
- package/dist/collection-handle.js +175 -137
- package/dist/index.d.ts +2 -1
- package/dist/read-context.d.ts +11 -0
- package/dist/read-context.js +36 -0
- package/dist/register.d.ts +44 -0
- package/dist/register.js +8 -0
- package/dist/search.js +23 -6
- package/dist/server/admin-context.d.ts +50 -0
- package/dist/server/admin-context.js +125 -0
- package/dist/server/admin-context.test.node.d.ts +8 -0
- package/dist/server/admin-context.test.node.js +256 -0
- package/dist/server/browser.d.ts +8 -0
- package/dist/server/browser.js +18 -0
- package/dist/server/clients.d.ts +92 -0
- package/dist/server/clients.js +170 -0
- package/dist/server/index.d.ts +26 -0
- package/dist/server/index.js +26 -0
- package/dist/server/preview-cookies.d.ts +19 -0
- package/dist/server/preview-cookies.js +69 -0
- package/dist/server/request-context-contract.test.node.d.ts +8 -0
- package/dist/server/request-context-contract.test.node.js +114 -0
- package/dist/server/request-context-factories.test.node.d.ts +8 -0
- package/dist/server/request-context-factories.test.node.js +116 -0
- package/dist/server/request-scope.d.ts +17 -0
- package/dist/server/request-scope.js +50 -0
- package/dist/server/request-scope.test.node.d.ts +8 -0
- package/dist/server/request-scope.test.node.js +89 -0
- package/dist/server/session-cookies.d.ts +34 -0
- package/dist/server/session-cookies.js +95 -0
- package/dist/types.d.ts +60 -23
- package/package.json +12 -5
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind one authenticated request identity to a logical ReadContext without
|
|
3
|
+
* putting forgeable auth state on the public ReadContext shape. Each operation
|
|
4
|
+
* gets an immutable clone carrying its effective read mode.
|
|
5
|
+
*/
|
|
6
|
+
import { bindReadContextAuthority, compileBeforeReadFilters } from '@byline/core';
|
|
7
|
+
const readSecurityDomains = new WeakMap();
|
|
8
|
+
export function getReadSecurityDomain(client) {
|
|
9
|
+
let domain = readSecurityDomains.get(client);
|
|
10
|
+
if (!domain) {
|
|
11
|
+
domain = {};
|
|
12
|
+
readSecurityDomains.set(client, domain);
|
|
13
|
+
}
|
|
14
|
+
return domain;
|
|
15
|
+
}
|
|
16
|
+
export async function resolveReadRequestContext(client, readContext, readMode, supplied) {
|
|
17
|
+
const candidate = supplied ?? (await client.resolveRequestContext());
|
|
18
|
+
const base = Object.freeze({ ...candidate });
|
|
19
|
+
bindReadContextAuthority(readContext, base);
|
|
20
|
+
return { ...base, readMode };
|
|
21
|
+
}
|
|
22
|
+
export function resolveReadSecurityFilters(client, definition, requestContext, readContext, bypass) {
|
|
23
|
+
if (bypass)
|
|
24
|
+
return Promise.resolve(undefined);
|
|
25
|
+
return compileBeforeReadFilters({
|
|
26
|
+
definition,
|
|
27
|
+
requestContext,
|
|
28
|
+
readContext,
|
|
29
|
+
securityDomain: getReadSecurityDomain(client),
|
|
30
|
+
parseContext: {
|
|
31
|
+
collections: client.collections,
|
|
32
|
+
resolveCollectionId: (path) => client.resolveCollectionId(path),
|
|
33
|
+
logger: client.logger,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
import type { CollectionRegistry } from '@byline/core';
|
|
9
|
+
/**
|
|
10
|
+
* Application type registration point (the TanStack Router / Payload
|
|
11
|
+
* "Register" pattern). `@byline/core/codegen` appends a declaration-merge
|
|
12
|
+
* block to the app's generated collection types:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* declare module '@byline/client' {
|
|
16
|
+
* interface Register {
|
|
17
|
+
* collections: CollectionFieldsByPath
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Once merged, every bare `BylineClient` in the app's program — including
|
|
23
|
+
* the getters on `@byline/client/server` — resolves to
|
|
24
|
+
* `BylineClient<CollectionFieldsByPath>`, so `client.collection('news')`
|
|
25
|
+
* autocompletes collection paths and returns the generated field shapes
|
|
26
|
+
* with no per-call generics and no type assertions.
|
|
27
|
+
*
|
|
28
|
+
* Exactly one application per TypeScript program can augment this
|
|
29
|
+
* interface: declaration merging is global to the program, so two apps
|
|
30
|
+
* sharing a tsconfig project would collide. One app per tsconfig (the
|
|
31
|
+
* supported layout) is safe.
|
|
32
|
+
*/
|
|
33
|
+
export interface Register {
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The app's registered collection registry, or the loose
|
|
37
|
+
* `CollectionRegistry` fallback when no augmentation is present. The
|
|
38
|
+
* fallback keeps unaugmented consumers — downstream apps that haven't
|
|
39
|
+
* migrated, scripts compiled outside the app program, this package's own
|
|
40
|
+
* tests — compiling exactly as before.
|
|
41
|
+
*/
|
|
42
|
+
export type RegisteredCollections = Register extends {
|
|
43
|
+
collections: infer TCollections extends CollectionRegistry;
|
|
44
|
+
} ? TCollections : CollectionRegistry;
|
package/dist/register.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
package/dist/search.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { assertActorCanPerform, createReadContext, ERR_VALIDATION, getCollectionAdminConfig, resolveItemViewColumns, resolveSearchZones, } from '@byline/core';
|
|
9
|
+
import { resolveReadRequestContext, resolveReadSecurityFilters } from './read-context.js';
|
|
9
10
|
/**
|
|
10
11
|
* Authorise (and optionally hydrate) provider hits, per collection:
|
|
11
12
|
*
|
|
@@ -75,8 +76,8 @@ export async function finalizeSearchHits(params) {
|
|
|
75
76
|
allowedByCollection.set(collectionPath, null);
|
|
76
77
|
continue;
|
|
77
78
|
}
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
79
|
+
const securityFilters = await resolveReadSecurityFilters(client, definition, requestContext, readCtx);
|
|
80
|
+
if (securityFilters == null) {
|
|
80
81
|
allowedByCollection.set(collectionPath, null);
|
|
81
82
|
continue;
|
|
82
83
|
}
|
|
@@ -143,7 +144,9 @@ export async function zoneSearch(client, options) {
|
|
|
143
144
|
}
|
|
144
145
|
// Per-member read gate: exclude collections the actor can't read; only
|
|
145
146
|
// when *none* are readable does the ability error surface.
|
|
146
|
-
const
|
|
147
|
+
const readMode = options.status ?? 'published';
|
|
148
|
+
const readCtx = createReadContext();
|
|
149
|
+
const requestContext = await resolveReadRequestContext(client, readCtx, readMode);
|
|
147
150
|
const readable = new Set();
|
|
148
151
|
let firstAbilityError;
|
|
149
152
|
for (const member of members) {
|
|
@@ -159,11 +162,22 @@ export async function zoneSearch(client, options) {
|
|
|
159
162
|
if (readable.size === 0) {
|
|
160
163
|
throw firstAbilityError;
|
|
161
164
|
}
|
|
165
|
+
let aggregateRestricted = readable.size !== members.length;
|
|
166
|
+
if (!options._bypassBeforeRead) {
|
|
167
|
+
for (const member of members) {
|
|
168
|
+
if (!readable.has(member.path))
|
|
169
|
+
continue;
|
|
170
|
+
const securityFilters = await resolveReadSecurityFilters(client, member, requestContext, readCtx);
|
|
171
|
+
if (securityFilters == null)
|
|
172
|
+
continue;
|
|
173
|
+
aggregateRestricted = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
162
176
|
const results = await provider.search({
|
|
163
177
|
query: options.query,
|
|
164
178
|
zone: options.zone,
|
|
165
179
|
locale: options.locale ?? client.defaultLocale,
|
|
166
|
-
status:
|
|
180
|
+
status: readMode,
|
|
167
181
|
where: options.where,
|
|
168
182
|
facets: options.facets,
|
|
169
183
|
limit: options.limit,
|
|
@@ -179,6 +193,9 @@ export async function zoneSearch(client, options) {
|
|
|
179
193
|
status: options.status,
|
|
180
194
|
hydrate: options.hydrate,
|
|
181
195
|
bypassBeforeRead: options._bypassBeforeRead,
|
|
196
|
+
readContext: readCtx,
|
|
182
197
|
});
|
|
183
|
-
return
|
|
198
|
+
return aggregateRestricted
|
|
199
|
+
? { hits, total: hits.length }
|
|
200
|
+
: { hits, total: results.total, facets: results.facets };
|
|
184
201
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
* Request-scoped auth context for admin server functions.
|
|
10
|
+
*
|
|
11
|
+
* Reads the session cookies, verifies the access token, transparently
|
|
12
|
+
* refreshes when needed, and returns a `RequestContext` carrying the
|
|
13
|
+
* authenticated `AdminAuth`. Every admin server fn calls this as its
|
|
14
|
+
* first step — it is the single point where the admin transport boundary
|
|
15
|
+
* meets the actor/ability machinery.
|
|
16
|
+
*
|
|
17
|
+
* Flow, in order:
|
|
18
|
+
*
|
|
19
|
+
* 1. Read the access cookie.
|
|
20
|
+
* 2. If present, try `sessionProvider.verifyAccessToken`. On success,
|
|
21
|
+
* return the context — no DB write, no cookie churn.
|
|
22
|
+
* 3. On verify failure (or missing access cookie), read the refresh
|
|
23
|
+
* cookie. If present, call `sessionProvider.refreshSession` — this
|
|
24
|
+
* rotates the refresh token atomically and issues a new access
|
|
25
|
+
* token. Write both fresh cookies to the response.
|
|
26
|
+
* 4. Verify the new access token (populate the actor) and return the
|
|
27
|
+
* context.
|
|
28
|
+
* 5. Any failure along the way clears both cookies so the browser
|
|
29
|
+
* stops sending a session the server has already rejected, and
|
|
30
|
+
* throws `ERR_UNAUTHENTICATED`.
|
|
31
|
+
*
|
|
32
|
+
* Burns a refresh-token rotation only when the access token actually
|
|
33
|
+
* fails verification — not on every request. The "one session" UX is
|
|
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.
|
|
48
|
+
*/
|
|
49
|
+
import { type RequestContext } from '@byline/auth';
|
|
50
|
+
export declare function getAdminRequestContext(): Promise<RequestContext>;
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
* Request-scoped auth context for admin server functions.
|
|
10
|
+
*
|
|
11
|
+
* Reads the session cookies, verifies the access token, transparently
|
|
12
|
+
* refreshes when needed, and returns a `RequestContext` carrying the
|
|
13
|
+
* authenticated `AdminAuth`. Every admin server fn calls this as its
|
|
14
|
+
* first step — it is the single point where the admin transport boundary
|
|
15
|
+
* meets the actor/ability machinery.
|
|
16
|
+
*
|
|
17
|
+
* Flow, in order:
|
|
18
|
+
*
|
|
19
|
+
* 1. Read the access cookie.
|
|
20
|
+
* 2. If present, try `sessionProvider.verifyAccessToken`. On success,
|
|
21
|
+
* return the context — no DB write, no cookie churn.
|
|
22
|
+
* 3. On verify failure (or missing access cookie), read the refresh
|
|
23
|
+
* cookie. If present, call `sessionProvider.refreshSession` — this
|
|
24
|
+
* rotates the refresh token atomically and issues a new access
|
|
25
|
+
* token. Write both fresh cookies to the response.
|
|
26
|
+
* 4. Verify the new access token (populate the actor) and return the
|
|
27
|
+
* context.
|
|
28
|
+
* 5. Any failure along the way clears both cookies so the browser
|
|
29
|
+
* stops sending a session the server has already rejected, and
|
|
30
|
+
* throws `ERR_UNAUTHENTICATED`.
|
|
31
|
+
*
|
|
32
|
+
* Burns a refresh-token rotation only when the access token actually
|
|
33
|
+
* fails verification — not on every request. The "one session" UX is
|
|
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.
|
|
48
|
+
*/
|
|
49
|
+
import { ERR_UNAUTHENTICATED } from '@byline/auth';
|
|
50
|
+
import { getServerConfig } from '@byline/core';
|
|
51
|
+
import { v7 as uuidv7 } from 'uuid';
|
|
52
|
+
import { oncePerRequest } from './request-scope.js';
|
|
53
|
+
import { clearSessionCookies, readAccessTokenCookie, readRefreshTokenCookie, setSessionCookies, } from './session-cookies.js';
|
|
54
|
+
function requireSessionProvider() {
|
|
55
|
+
const provider = getServerConfig().sessionProvider;
|
|
56
|
+
if (!provider) {
|
|
57
|
+
throw new Error('No sessionProvider configured on ServerConfig. ' +
|
|
58
|
+
'Construct a JwtSessionProvider in your server config and pass it to initBylineCore().');
|
|
59
|
+
}
|
|
60
|
+
return provider;
|
|
61
|
+
}
|
|
62
|
+
export async function getAdminRequestContext() {
|
|
63
|
+
return oncePerRequest('byline:admin-request-context', resolveAdminRequestContext);
|
|
64
|
+
}
|
|
65
|
+
async function resolveAdminRequestContext() {
|
|
66
|
+
const provider = requireSessionProvider();
|
|
67
|
+
const accessToken = readAccessTokenCookie();
|
|
68
|
+
// Happy path: valid access token.
|
|
69
|
+
if (accessToken) {
|
|
70
|
+
try {
|
|
71
|
+
const { actor } = await provider.verifyAccessToken(accessToken);
|
|
72
|
+
return {
|
|
73
|
+
actor,
|
|
74
|
+
requestId: uuidv7(),
|
|
75
|
+
readMode: 'any',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Fall through to refresh — we'll burn a rotation only when the
|
|
80
|
+
// access token genuinely can't verify.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Refresh path: swap the refresh cookie for a fresh token pair.
|
|
84
|
+
const refreshToken = readRefreshTokenCookie();
|
|
85
|
+
if (!refreshToken) {
|
|
86
|
+
// Only emit cookie clears when the browser actually sent a stale access
|
|
87
|
+
// cookie — otherwise the response carries no Set-Cookie at all, which
|
|
88
|
+
// lets shared caches (Cloudflare) cache public pages for anonymous
|
|
89
|
+
// visitors. Set-Cookie on the response is a hard bypass signal for CDNs.
|
|
90
|
+
if (accessToken) {
|
|
91
|
+
clearSessionCookies();
|
|
92
|
+
}
|
|
93
|
+
throw ERR_UNAUTHENTICATED({ message: 'no admin session' });
|
|
94
|
+
}
|
|
95
|
+
let refreshed;
|
|
96
|
+
try {
|
|
97
|
+
refreshed = await provider.refreshSession({ refreshToken });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
clearSessionCookies();
|
|
101
|
+
throw ERR_UNAUTHENTICATED({
|
|
102
|
+
message: 'admin session could not be refreshed',
|
|
103
|
+
cause: err,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Write the new cookies so subsequent requests skip the refresh path.
|
|
107
|
+
setSessionCookies(refreshed);
|
|
108
|
+
// Verify the freshly-minted access token to extract the actor.
|
|
109
|
+
let verified;
|
|
110
|
+
try {
|
|
111
|
+
verified = await provider.verifyAccessToken(refreshed.accessToken);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
clearSessionCookies();
|
|
115
|
+
throw ERR_UNAUTHENTICATED({
|
|
116
|
+
message: 'refreshed access token did not verify',
|
|
117
|
+
cause: err,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
actor: verified.actor,
|
|
122
|
+
requestId: uuidv7(),
|
|
123
|
+
readMode: 'any',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,256 @@
|
|
|
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
|
+
* Unit coverage for the admin request-context resolver. Registers a fake
|
|
10
|
+
* `HostRequestBridge` and mocks the config's `sessionProvider` so the
|
|
11
|
+
* branches can be exercised without a live Postgres or JWT machinery.
|
|
12
|
+
*/
|
|
13
|
+
import { AdminAuth, AuthError, AuthErrorCodes } from '@byline/auth';
|
|
14
|
+
import { registerHostRequestBridge } from '@byline/core';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
const mocks = vi.hoisted(() => ({
|
|
17
|
+
verifyAccessToken: vi.fn(),
|
|
18
|
+
refreshSession: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@byline/core', async () => {
|
|
21
|
+
const actual = await vi.importActual('@byline/core');
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
getServerConfig: () => ({
|
|
25
|
+
sessionProvider: {
|
|
26
|
+
verifyAccessToken: mocks.verifyAccessToken,
|
|
27
|
+
refreshSession: mocks.refreshSession,
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
import { getAdminRequestContext } from './admin-context.js';
|
|
33
|
+
const BRIDGE_SLOT = Symbol.for('__byline_host_request_bridge__');
|
|
34
|
+
const previousBridge = globalThis[BRIDGE_SLOT];
|
|
35
|
+
const bridge = {
|
|
36
|
+
getRequest: vi.fn(),
|
|
37
|
+
getCookie: vi.fn(),
|
|
38
|
+
setCookie: vi.fn(),
|
|
39
|
+
};
|
|
40
|
+
const { verifyAccessToken, refreshSession } = mocks;
|
|
41
|
+
const { getCookie, setCookie } = bridge;
|
|
42
|
+
function cookiesReturn(values) {
|
|
43
|
+
getCookie.mockImplementation((name) => values[name]);
|
|
44
|
+
}
|
|
45
|
+
function stubActor() {
|
|
46
|
+
return new AdminAuth({
|
|
47
|
+
id: 'admin-1',
|
|
48
|
+
abilities: ['collections.pages.read'],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
describe('getAdminRequestContext', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
registerHostRequestBridge(bridge);
|
|
55
|
+
// Default: no request in scope, so every call resolves fresh. The
|
|
56
|
+
// per-request memoization tests below override this with a stable
|
|
57
|
+
// request object.
|
|
58
|
+
bridge.getRequest.mockReturnValue(undefined);
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
globalThis[BRIDGE_SLOT] = previousBridge;
|
|
63
|
+
});
|
|
64
|
+
it('returns the actor directly when the access token verifies', async () => {
|
|
65
|
+
cookiesReturn({
|
|
66
|
+
byline_access_token: 'valid-access',
|
|
67
|
+
byline_refresh_token: 'some-refresh',
|
|
68
|
+
});
|
|
69
|
+
const actor = stubActor();
|
|
70
|
+
verifyAccessToken.mockResolvedValueOnce({ actor });
|
|
71
|
+
const ctx = await getAdminRequestContext();
|
|
72
|
+
expect(ctx.actor).toBe(actor);
|
|
73
|
+
expect(ctx.readMode).toBe('any');
|
|
74
|
+
expect(refreshSession).not.toHaveBeenCalled();
|
|
75
|
+
expect(setCookie).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
it('refreshes when the access token fails verification', async () => {
|
|
78
|
+
cookiesReturn({
|
|
79
|
+
byline_access_token: 'stale-access',
|
|
80
|
+
byline_refresh_token: 'valid-refresh',
|
|
81
|
+
});
|
|
82
|
+
const actor = stubActor();
|
|
83
|
+
verifyAccessToken.mockRejectedValueOnce(new Error('expired')).mockResolvedValueOnce({ actor });
|
|
84
|
+
refreshSession.mockResolvedValueOnce({
|
|
85
|
+
accessToken: 'fresh-access',
|
|
86
|
+
refreshToken: 'fresh-refresh',
|
|
87
|
+
accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
|
|
88
|
+
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
|
89
|
+
});
|
|
90
|
+
const ctx = await getAdminRequestContext();
|
|
91
|
+
expect(ctx.actor).toBe(actor);
|
|
92
|
+
expect(refreshSession).toHaveBeenCalledWith({ refreshToken: 'valid-refresh' });
|
|
93
|
+
// Both access + refresh cookies should have been written with maxAge > 0.
|
|
94
|
+
expect(setCookie).toHaveBeenCalledTimes(2);
|
|
95
|
+
const accessCall = setCookie.mock.calls.find((c) => c[0] === 'byline_access_token');
|
|
96
|
+
expect(accessCall?.[1]).toBe('fresh-access');
|
|
97
|
+
expect(accessCall?.[2]?.maxAge).toBeGreaterThan(0);
|
|
98
|
+
});
|
|
99
|
+
it('refreshes when the access cookie is missing but a refresh cookie exists', async () => {
|
|
100
|
+
cookiesReturn({ byline_refresh_token: 'only-refresh' });
|
|
101
|
+
const actor = stubActor();
|
|
102
|
+
refreshSession.mockResolvedValueOnce({
|
|
103
|
+
accessToken: 'fresh-access',
|
|
104
|
+
refreshToken: 'fresh-refresh',
|
|
105
|
+
accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
|
|
106
|
+
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
|
107
|
+
});
|
|
108
|
+
verifyAccessToken.mockResolvedValueOnce({ actor });
|
|
109
|
+
const ctx = await getAdminRequestContext();
|
|
110
|
+
expect(ctx.actor).toBe(actor);
|
|
111
|
+
// The access-token path was skipped (no cookie) so verifyAccessToken ran
|
|
112
|
+
// only for the freshly-issued token.
|
|
113
|
+
expect(verifyAccessToken).toHaveBeenCalledTimes(1);
|
|
114
|
+
expect(verifyAccessToken).toHaveBeenCalledWith('fresh-access');
|
|
115
|
+
});
|
|
116
|
+
it('throws ERR_UNAUTHENTICATED without emitting Set-Cookie when no cookies are sent', async () => {
|
|
117
|
+
cookiesReturn({});
|
|
118
|
+
try {
|
|
119
|
+
await getAdminRequestContext();
|
|
120
|
+
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
expect(err).toBeInstanceOf(AuthError);
|
|
124
|
+
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
125
|
+
}
|
|
126
|
+
// Anonymous visitors must produce zero Set-Cookie headers so shared
|
|
127
|
+
// caches (Cloudflare) can cache public pages — a Set-Cookie on the
|
|
128
|
+
// response is a hard bypass signal for CDNs.
|
|
129
|
+
expect(setCookie).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
it('clears the stale access cookie when only an access token was sent', async () => {
|
|
132
|
+
cookiesReturn({ byline_access_token: 'stale' });
|
|
133
|
+
verifyAccessToken.mockRejectedValueOnce(new Error('expired'));
|
|
134
|
+
try {
|
|
135
|
+
await getAdminRequestContext();
|
|
136
|
+
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
140
|
+
}
|
|
141
|
+
const clears = setCookie.mock.calls.filter((c) => c[2]?.maxAge === 0);
|
|
142
|
+
const clearedNames = new Set(clears.map((c) => c[0]));
|
|
143
|
+
expect(clearedNames.has('byline_access_token')).toBe(true);
|
|
144
|
+
expect(clearedNames.has('byline_refresh_token')).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
it('clears cookies and throws when refresh itself fails', async () => {
|
|
147
|
+
cookiesReturn({
|
|
148
|
+
byline_access_token: 'stale',
|
|
149
|
+
byline_refresh_token: 'bad-refresh',
|
|
150
|
+
});
|
|
151
|
+
verifyAccessToken.mockRejectedValueOnce(new Error('expired'));
|
|
152
|
+
refreshSession.mockRejectedValueOnce(new Error('revoked'));
|
|
153
|
+
try {
|
|
154
|
+
await getAdminRequestContext();
|
|
155
|
+
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
expect(err).toBeInstanceOf(AuthError);
|
|
159
|
+
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
160
|
+
}
|
|
161
|
+
const clears = setCookie.mock.calls.filter((c) => c[2]?.maxAge === 0);
|
|
162
|
+
expect(clears.length).toBe(2);
|
|
163
|
+
});
|
|
164
|
+
it('clears cookies and throws when the refreshed access token fails to verify', async () => {
|
|
165
|
+
cookiesReturn({
|
|
166
|
+
byline_access_token: 'stale',
|
|
167
|
+
byline_refresh_token: 'valid',
|
|
168
|
+
});
|
|
169
|
+
verifyAccessToken
|
|
170
|
+
.mockRejectedValueOnce(new Error('expired'))
|
|
171
|
+
.mockRejectedValueOnce(new Error('still bad'));
|
|
172
|
+
refreshSession.mockResolvedValueOnce({
|
|
173
|
+
accessToken: 'fresh-but-somehow-bad',
|
|
174
|
+
refreshToken: 'fresh-refresh',
|
|
175
|
+
accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
|
|
176
|
+
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
await getAdminRequestContext();
|
|
180
|
+
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
184
|
+
}
|
|
185
|
+
const clears = setCookie.mock.calls.filter((c) => c[2]?.maxAge === 0);
|
|
186
|
+
expect(clears.length).toBe(2);
|
|
187
|
+
});
|
|
188
|
+
describe('per-request memoization', () => {
|
|
189
|
+
it('returns the same context instance — and requestId — for every call in one request', async () => {
|
|
190
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
191
|
+
cookiesReturn({
|
|
192
|
+
byline_access_token: 'valid-access',
|
|
193
|
+
byline_refresh_token: 'some-refresh',
|
|
194
|
+
});
|
|
195
|
+
verifyAccessToken.mockResolvedValue({ actor: stubActor() });
|
|
196
|
+
const first = await getAdminRequestContext();
|
|
197
|
+
const second = await getAdminRequestContext();
|
|
198
|
+
// Reads sharing a ReadContext bind one request authority; the token
|
|
199
|
+
// includes requestId, so it must be stable within a request.
|
|
200
|
+
expect(second).toBe(first);
|
|
201
|
+
expect(second.requestId).toBe(first.requestId);
|
|
202
|
+
expect(verifyAccessToken).toHaveBeenCalledTimes(1);
|
|
203
|
+
});
|
|
204
|
+
it('resolves independently across different requests', async () => {
|
|
205
|
+
cookiesReturn({
|
|
206
|
+
byline_access_token: 'valid-access',
|
|
207
|
+
byline_refresh_token: 'some-refresh',
|
|
208
|
+
});
|
|
209
|
+
verifyAccessToken.mockResolvedValue({ actor: stubActor() });
|
|
210
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
211
|
+
const first = await getAdminRequestContext();
|
|
212
|
+
bridge.getRequest.mockReturnValue({ id: 'request-b' });
|
|
213
|
+
const second = await getAdminRequestContext();
|
|
214
|
+
expect(second).not.toBe(first);
|
|
215
|
+
expect(second.requestId).not.toBe(first.requestId);
|
|
216
|
+
expect(verifyAccessToken).toHaveBeenCalledTimes(2);
|
|
217
|
+
});
|
|
218
|
+
it('burns at most one refresh rotation per request', async () => {
|
|
219
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
220
|
+
cookiesReturn({
|
|
221
|
+
byline_access_token: 'stale-access',
|
|
222
|
+
byline_refresh_token: 'valid-refresh',
|
|
223
|
+
});
|
|
224
|
+
const actor = stubActor();
|
|
225
|
+
verifyAccessToken.mockRejectedValueOnce(new Error('expired')).mockResolvedValueOnce({ actor });
|
|
226
|
+
refreshSession.mockResolvedValueOnce({
|
|
227
|
+
accessToken: 'fresh-access',
|
|
228
|
+
refreshToken: 'fresh-refresh',
|
|
229
|
+
accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
|
|
230
|
+
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
|
231
|
+
});
|
|
232
|
+
const first = await getAdminRequestContext();
|
|
233
|
+
// A second unmemoized call would re-run the refresh dance against the
|
|
234
|
+
// request's stale cookie — now already rotated — and sign the admin out.
|
|
235
|
+
const second = await getAdminRequestContext();
|
|
236
|
+
expect(second).toBe(first);
|
|
237
|
+
expect(refreshSession).toHaveBeenCalledTimes(1);
|
|
238
|
+
});
|
|
239
|
+
it('memoizes the unauthenticated rejection within one request', async () => {
|
|
240
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
241
|
+
cookiesReturn({
|
|
242
|
+
byline_access_token: 'stale',
|
|
243
|
+
byline_refresh_token: 'bad-refresh',
|
|
244
|
+
});
|
|
245
|
+
verifyAccessToken.mockRejectedValue(new Error('expired'));
|
|
246
|
+
refreshSession.mockRejectedValue(new Error('revoked'));
|
|
247
|
+
await expect(getAdminRequestContext()).rejects.toMatchObject({
|
|
248
|
+
code: AuthErrorCodes.UNAUTHENTICATED,
|
|
249
|
+
});
|
|
250
|
+
await expect(getAdminRequestContext()).rejects.toMatchObject({
|
|
251
|
+
code: AuthErrorCodes.UNAUTHENTICATED,
|
|
252
|
+
});
|
|
253
|
+
expect(refreshSession).toHaveBeenCalledTimes(1);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
* Browser-condition stub for `@byline/client/server`. Bundlers that
|
|
10
|
+
* resolve the `browser` export condition land here instead of the real
|
|
11
|
+
* server entry, so an accidental import from client-side code fails
|
|
12
|
+
* loudly at load time instead of shipping session machinery to the
|
|
13
|
+
* browser.
|
|
14
|
+
*/
|
|
15
|
+
throw new Error('@byline/client/server is server-only. Import it from server routes, ' +
|
|
16
|
+
'loaders, server functions, scripts, or lifecycle hooks — never from ' +
|
|
17
|
+
'browser code.');
|
|
18
|
+
export {};
|