@byline/client 4.0.0 → 4.2.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.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/register.d.ts +44 -0
- package/dist/register.js +8 -0
- 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/package.json +12 -5
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
* The server-side `BylineClient` singletons — four request-authority
|
|
10
|
+
* flavours over one shared `getServerConfig()` wiring. All four share the
|
|
11
|
+
* module-scoped singleton pattern so the SDK's per-instance
|
|
12
|
+
* `collectionRecordCache` is amortised across the process lifetime; what
|
|
13
|
+
* differs is only how each resolves its `RequestContext`:
|
|
14
|
+
*
|
|
15
|
+
* - `getPublicBylineClient()` — anonymous, published-only. Strict
|
|
16
|
+
* "preview can never apply": use it for RSS/Atom feeds, sitemaps,
|
|
17
|
+
* JSON endpoints for third parties, and anything an upstream CDN
|
|
18
|
+
* might serve without keying off the `byline_preview` cookie.
|
|
19
|
+
* - `getViewerBylineClient()` — the preview-aware sibling. Behaves
|
|
20
|
+
* identically to the public client until both the `byline_preview`
|
|
21
|
+
* cookie is set **and** a valid admin session resolves; then the
|
|
22
|
+
* per-call factory returns the authenticated `AdminAuth` instead of
|
|
23
|
+
* the anonymous null actor. Use it on user-facing public reads where
|
|
24
|
+
* an admin's preview-mode session should be honoured.
|
|
25
|
+
* - `getAdminBylineClient()` — resolves the authenticated admin actor
|
|
26
|
+
* per request via `getAdminRequestContext` (session cookies, with the
|
|
27
|
+
* transparent refresh dance). Every admin server fn read goes through
|
|
28
|
+
* this client, so the full read pipeline (`beforeRead` →
|
|
29
|
+
* `findDocuments` → `populate` → `afterRead`) is uniform between
|
|
30
|
+
* admin and any external client.
|
|
31
|
+
* - `getSystemBylineClient()` — bound to an explicit super-admin
|
|
32
|
+
* context for **system / background** work that is not scoped to an
|
|
33
|
+
* HTTP request: lifecycle-hook search indexing, maintenance scripts,
|
|
34
|
+
* seeds, migrations. Does not read cookies, so it works outside any
|
|
35
|
+
* host request runtime. The context is auditable by its stable id.
|
|
36
|
+
*
|
|
37
|
+
* Why `assertActorCanPerform` cares (viewer client): the auth layer only
|
|
38
|
+
* permits anonymous reads when `readMode === 'published'`. If a server fn
|
|
39
|
+
* passes `status: 'any'` to surface drafts, an anonymous request trips
|
|
40
|
+
* the gate and throws — but a preview-mode admin sails through. The
|
|
41
|
+
* source-view selection itself remains a per-call decision: the SDK's
|
|
42
|
+
* `resolveReadMode` defaults to `'published'` regardless of context, so
|
|
43
|
+
* every server fn still has to opt in by passing `status: 'any'`.
|
|
44
|
+
*
|
|
45
|
+
* The viewer contract for callers:
|
|
46
|
+
*
|
|
47
|
+
* 1. Use `getViewerBylineClient()` on any user-facing public read
|
|
48
|
+
* where an admin's preview-mode session should be honoured; use
|
|
49
|
+
* `getPublicBylineClient()` where preview must never apply.
|
|
50
|
+
* 2. Call `isPreviewActive()` once per server fn to decide whether to
|
|
51
|
+
* pass `status: 'any'` on the read.
|
|
52
|
+
*
|
|
53
|
+
* Stale preview cookies fail closed: if the preview cookie is present but
|
|
54
|
+
* no valid admin session resolves, the viewer factory returns the same
|
|
55
|
+
* anonymous + `'published'` context the public client would have used, so
|
|
56
|
+
* the worst case is "preview cookie does nothing".
|
|
57
|
+
*
|
|
58
|
+
* In an application whose generated collection types augment `Register`
|
|
59
|
+
* (see `../register.ts`), every getter here returns a fully typed
|
|
60
|
+
* `BylineClient` — `client.collection('news')` autocompletes paths and
|
|
61
|
+
* returns the generated field shapes.
|
|
62
|
+
*/
|
|
63
|
+
import { type RequestContext } from '@byline/auth';
|
|
64
|
+
import { type BylineClient } from '../client.js';
|
|
65
|
+
export declare function getAdminBylineClient(): BylineClient;
|
|
66
|
+
export declare function getSystemBylineClient(): BylineClient;
|
|
67
|
+
/**
|
|
68
|
+
* Anonymous, published-only context — memoized per request so the
|
|
69
|
+
* context (and its requestId) is stable across every read in one
|
|
70
|
+
* request: reads sharing a ReadContext must bind a single request
|
|
71
|
+
* authority.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolvePublicRequestContext(): Promise<RequestContext>;
|
|
74
|
+
export declare function getPublicBylineClient(): BylineClient;
|
|
75
|
+
/**
|
|
76
|
+
* Preview-aware viewer context — memoized per request so every read in
|
|
77
|
+
* one request binds the same context instance (and requestId): reads
|
|
78
|
+
* sharing a ReadContext must resolve a single request authority.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolveViewerRequestContext(): Promise<RequestContext>;
|
|
81
|
+
export declare function getViewerBylineClient(): BylineClient;
|
|
82
|
+
/**
|
|
83
|
+
* Resolve whether the current request should surface non-published
|
|
84
|
+
* versions. True iff the preview cookie is set **and** a valid admin
|
|
85
|
+
* session resolves. Server fns call this once and pass the result to
|
|
86
|
+
* `status: preview ? 'any' : 'published'` on the SDK read.
|
|
87
|
+
*
|
|
88
|
+
* Defensive by design: the cookie alone is not enough. A signed-out
|
|
89
|
+
* browser carrying an old preview cookie still gets `false` here, which
|
|
90
|
+
* keeps stray query strings or shared links from leaking drafts.
|
|
91
|
+
*/
|
|
92
|
+
export declare function isPreviewActive(): Promise<boolean>;
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
* The server-side `BylineClient` singletons — four request-authority
|
|
10
|
+
* flavours over one shared `getServerConfig()` wiring. All four share the
|
|
11
|
+
* module-scoped singleton pattern so the SDK's per-instance
|
|
12
|
+
* `collectionRecordCache` is amortised across the process lifetime; what
|
|
13
|
+
* differs is only how each resolves its `RequestContext`:
|
|
14
|
+
*
|
|
15
|
+
* - `getPublicBylineClient()` — anonymous, published-only. Strict
|
|
16
|
+
* "preview can never apply": use it for RSS/Atom feeds, sitemaps,
|
|
17
|
+
* JSON endpoints for third parties, and anything an upstream CDN
|
|
18
|
+
* might serve without keying off the `byline_preview` cookie.
|
|
19
|
+
* - `getViewerBylineClient()` — the preview-aware sibling. Behaves
|
|
20
|
+
* identically to the public client until both the `byline_preview`
|
|
21
|
+
* cookie is set **and** a valid admin session resolves; then the
|
|
22
|
+
* per-call factory returns the authenticated `AdminAuth` instead of
|
|
23
|
+
* the anonymous null actor. Use it on user-facing public reads where
|
|
24
|
+
* an admin's preview-mode session should be honoured.
|
|
25
|
+
* - `getAdminBylineClient()` — resolves the authenticated admin actor
|
|
26
|
+
* per request via `getAdminRequestContext` (session cookies, with the
|
|
27
|
+
* transparent refresh dance). Every admin server fn read goes through
|
|
28
|
+
* this client, so the full read pipeline (`beforeRead` →
|
|
29
|
+
* `findDocuments` → `populate` → `afterRead`) is uniform between
|
|
30
|
+
* admin and any external client.
|
|
31
|
+
* - `getSystemBylineClient()` — bound to an explicit super-admin
|
|
32
|
+
* context for **system / background** work that is not scoped to an
|
|
33
|
+
* HTTP request: lifecycle-hook search indexing, maintenance scripts,
|
|
34
|
+
* seeds, migrations. Does not read cookies, so it works outside any
|
|
35
|
+
* host request runtime. The context is auditable by its stable id.
|
|
36
|
+
*
|
|
37
|
+
* Why `assertActorCanPerform` cares (viewer client): the auth layer only
|
|
38
|
+
* permits anonymous reads when `readMode === 'published'`. If a server fn
|
|
39
|
+
* passes `status: 'any'` to surface drafts, an anonymous request trips
|
|
40
|
+
* the gate and throws — but a preview-mode admin sails through. The
|
|
41
|
+
* source-view selection itself remains a per-call decision: the SDK's
|
|
42
|
+
* `resolveReadMode` defaults to `'published'` regardless of context, so
|
|
43
|
+
* every server fn still has to opt in by passing `status: 'any'`.
|
|
44
|
+
*
|
|
45
|
+
* The viewer contract for callers:
|
|
46
|
+
*
|
|
47
|
+
* 1. Use `getViewerBylineClient()` on any user-facing public read
|
|
48
|
+
* where an admin's preview-mode session should be honoured; use
|
|
49
|
+
* `getPublicBylineClient()` where preview must never apply.
|
|
50
|
+
* 2. Call `isPreviewActive()` once per server fn to decide whether to
|
|
51
|
+
* pass `status: 'any'` on the read.
|
|
52
|
+
*
|
|
53
|
+
* Stale preview cookies fail closed: if the preview cookie is present but
|
|
54
|
+
* no valid admin session resolves, the viewer factory returns the same
|
|
55
|
+
* anonymous + `'published'` context the public client would have used, so
|
|
56
|
+
* the worst case is "preview cookie does nothing".
|
|
57
|
+
*
|
|
58
|
+
* In an application whose generated collection types augment `Register`
|
|
59
|
+
* (see `../register.ts`), every getter here returns a fully typed
|
|
60
|
+
* `BylineClient` — `client.collection('news')` autocompletes paths and
|
|
61
|
+
* returns the generated field shapes.
|
|
62
|
+
*/
|
|
63
|
+
import { createRequestContext, createSuperAdminContext } from '@byline/auth';
|
|
64
|
+
import { getServerConfig } from '@byline/core';
|
|
65
|
+
import { createBylineClient } from '../client.js';
|
|
66
|
+
import { getAdminRequestContext } from './admin-context.js';
|
|
67
|
+
import { readPreviewCookie } from './preview-cookies.js';
|
|
68
|
+
import { oncePerRequest } from './request-scope.js';
|
|
69
|
+
let cachedAdminClient;
|
|
70
|
+
export function getAdminBylineClient() {
|
|
71
|
+
if (cachedAdminClient)
|
|
72
|
+
return cachedAdminClient;
|
|
73
|
+
cachedAdminClient = createBylineClient({
|
|
74
|
+
config: getServerConfig(),
|
|
75
|
+
// Resolved from the current request's session cookies, memoized per
|
|
76
|
+
// request: every read in one request binds the same context instance
|
|
77
|
+
// (and requestId), which the ReadContext authority check requires.
|
|
78
|
+
// `getAdminRequestContext` runs the refresh dance on its own and throws
|
|
79
|
+
// `ERR_UNAUTHENTICATED` when no session is present — the client
|
|
80
|
+
// surfaces the throw verbatim.
|
|
81
|
+
requestContext: getAdminRequestContext,
|
|
82
|
+
});
|
|
83
|
+
return cachedAdminClient;
|
|
84
|
+
}
|
|
85
|
+
let cachedSystemClient;
|
|
86
|
+
export function getSystemBylineClient() {
|
|
87
|
+
if (cachedSystemClient)
|
|
88
|
+
return cachedSystemClient;
|
|
89
|
+
cachedSystemClient = createBylineClient({
|
|
90
|
+
config: getServerConfig(),
|
|
91
|
+
requestContext: createSuperAdminContext({ id: 'byline-system-client' }),
|
|
92
|
+
});
|
|
93
|
+
return cachedSystemClient;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Anonymous, published-only context — memoized per request so the
|
|
97
|
+
* context (and its requestId) is stable across every read in one
|
|
98
|
+
* request: reads sharing a ReadContext must bind a single request
|
|
99
|
+
* authority.
|
|
100
|
+
*/
|
|
101
|
+
export function resolvePublicRequestContext() {
|
|
102
|
+
return oncePerRequest('byline:public-request-context', async () => createRequestContext({ readMode: 'published' }));
|
|
103
|
+
}
|
|
104
|
+
let cachedPublicClient;
|
|
105
|
+
export function getPublicBylineClient() {
|
|
106
|
+
if (cachedPublicClient)
|
|
107
|
+
return cachedPublicClient;
|
|
108
|
+
cachedPublicClient = createBylineClient({
|
|
109
|
+
config: getServerConfig(),
|
|
110
|
+
requestContext: resolvePublicRequestContext,
|
|
111
|
+
});
|
|
112
|
+
return cachedPublicClient;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Preview-aware viewer context — memoized per request so every read in
|
|
116
|
+
* one request binds the same context instance (and requestId): reads
|
|
117
|
+
* sharing a ReadContext must resolve a single request authority.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveViewerRequestContext() {
|
|
120
|
+
return oncePerRequest('byline:viewer-request-context', resolveViewerContext);
|
|
121
|
+
}
|
|
122
|
+
async function resolveViewerContext() {
|
|
123
|
+
// No preview cookie → behave exactly like the public client.
|
|
124
|
+
// Cheap path; no JWT verification, no DB lookup.
|
|
125
|
+
if (!readPreviewCookie()) {
|
|
126
|
+
return createRequestContext({ readMode: 'published' });
|
|
127
|
+
}
|
|
128
|
+
// Preview cookie present → try the admin context. A failure means
|
|
129
|
+
// the cookie is stale (admin signed out, session expired, refresh
|
|
130
|
+
// rejected). We swallow the error and fall back to the public
|
|
131
|
+
// context so the page renders instead of erroring.
|
|
132
|
+
try {
|
|
133
|
+
const ctx = await getAdminRequestContext();
|
|
134
|
+
return { ...ctx, readMode: 'any' };
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return createRequestContext({ readMode: 'published' });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let cachedViewerClient;
|
|
141
|
+
export function getViewerBylineClient() {
|
|
142
|
+
if (cachedViewerClient)
|
|
143
|
+
return cachedViewerClient;
|
|
144
|
+
cachedViewerClient = createBylineClient({
|
|
145
|
+
config: getServerConfig(),
|
|
146
|
+
requestContext: resolveViewerRequestContext,
|
|
147
|
+
});
|
|
148
|
+
return cachedViewerClient;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Resolve whether the current request should surface non-published
|
|
152
|
+
* versions. True iff the preview cookie is set **and** a valid admin
|
|
153
|
+
* session resolves. Server fns call this once and pass the result to
|
|
154
|
+
* `status: preview ? 'any' : 'published'` on the SDK read.
|
|
155
|
+
*
|
|
156
|
+
* Defensive by design: the cookie alone is not enough. A signed-out
|
|
157
|
+
* browser carrying an old preview cookie still gets `false` here, which
|
|
158
|
+
* keeps stray query strings or shared links from leaking drafts.
|
|
159
|
+
*/
|
|
160
|
+
export async function isPreviewActive() {
|
|
161
|
+
if (!readPreviewCookie())
|
|
162
|
+
return false;
|
|
163
|
+
try {
|
|
164
|
+
await getAdminRequestContext();
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
* `@byline/client/server` — the client SDK's server-side entry point.
|
|
10
|
+
*
|
|
11
|
+
* Request-bound `BylineClient` getters and the session/preview machinery
|
|
12
|
+
* behind them, implemented against the `HostRequestBridge` seam
|
|
13
|
+
* (`@byline/core`) so this surface is host-framework agnostic. A host
|
|
14
|
+
* adapter (e.g. `@byline/host-tanstack-start`) registers the bridge at
|
|
15
|
+
* server boot; application code imports the getters from here and never
|
|
16
|
+
* touches the host framework.
|
|
17
|
+
*
|
|
18
|
+
* Server-only: the package's `browser` export condition resolves this
|
|
19
|
+
* subpath to a stub that throws, so an accidental import from browser
|
|
20
|
+
* code fails loudly at bundle time rather than leaking server machinery.
|
|
21
|
+
*/
|
|
22
|
+
export { getAdminRequestContext } from './admin-context.js';
|
|
23
|
+
export { getAdminBylineClient, getPublicBylineClient, getSystemBylineClient, getViewerBylineClient, isPreviewActive, resolvePublicRequestContext, resolveViewerRequestContext, } from './clients.js';
|
|
24
|
+
export { clearPreviewCookie, PREVIEW_COOKIE, readPreviewCookie, setPreviewCookie, } from './preview-cookies.js';
|
|
25
|
+
export { oncePerRequest } from './request-scope.js';
|
|
26
|
+
export { ACCESS_TOKEN_COOKIE, clearSessionCookies, REFRESH_TOKEN_COOKIE, readAccessTokenCookie, readRefreshTokenCookie, type SessionCookieTokens, setSessionCookies, } from './session-cookies.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
* `@byline/client/server` — the client SDK's server-side entry point.
|
|
10
|
+
*
|
|
11
|
+
* Request-bound `BylineClient` getters and the session/preview machinery
|
|
12
|
+
* behind them, implemented against the `HostRequestBridge` seam
|
|
13
|
+
* (`@byline/core`) so this surface is host-framework agnostic. A host
|
|
14
|
+
* adapter (e.g. `@byline/host-tanstack-start`) registers the bridge at
|
|
15
|
+
* server boot; application code imports the getters from here and never
|
|
16
|
+
* touches the host framework.
|
|
17
|
+
*
|
|
18
|
+
* Server-only: the package's `browser` export condition resolves this
|
|
19
|
+
* subpath to a stub that throws, so an accidental import from browser
|
|
20
|
+
* code fails loudly at bundle time rather than leaking server machinery.
|
|
21
|
+
*/
|
|
22
|
+
export { getAdminRequestContext } from './admin-context.js';
|
|
23
|
+
export { getAdminBylineClient, getPublicBylineClient, getSystemBylineClient, getViewerBylineClient, isPreviewActive, resolvePublicRequestContext, resolveViewerRequestContext, } from './clients.js';
|
|
24
|
+
export { clearPreviewCookie, PREVIEW_COOKIE, readPreviewCookie, setPreviewCookie, } from './preview-cookies.js';
|
|
25
|
+
export { oncePerRequest } from './request-scope.js';
|
|
26
|
+
export { ACCESS_TOKEN_COOKIE, clearSessionCookies, REFRESH_TOKEN_COOKIE, readAccessTokenCookie, readRefreshTokenCookie, setSessionCookies, } from './session-cookies.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
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 declare const PREVIEW_COOKIE = "byline_preview";
|
|
9
|
+
/** True iff the preview cookie is currently set on the request. */
|
|
10
|
+
export declare function readPreviewCookie(): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Write the preview cookie. Callers should ensure the request is from a
|
|
13
|
+
* valid admin session before invoking — the cookie itself is just a flag
|
|
14
|
+
* and carries no proof of authorisation. See `enablePreviewModeFn` for
|
|
15
|
+
* the canonical entry point.
|
|
16
|
+
*/
|
|
17
|
+
export declare function setPreviewCookie(): void;
|
|
18
|
+
/** Clear the preview cookie. Safe to call from any context. */
|
|
19
|
+
export declare function clearPreviewCookie(): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
* Cookie helpers for the front-end *preview mode* toggle.
|
|
10
|
+
*
|
|
11
|
+
* Preview mode lets a signed-in admin see draft (and other non-published)
|
|
12
|
+
* versions on the public host pages without changing any markup or route
|
|
13
|
+
* structure. The mechanic is intentionally narrow:
|
|
14
|
+
*
|
|
15
|
+
* - A single httpOnly cookie (`byline_preview=1`) carries the user's
|
|
16
|
+
* "show me drafts" intent across requests within the same browser
|
|
17
|
+
* session.
|
|
18
|
+
* - The cookie's mere *presence* is the signal — there is no payload to
|
|
19
|
+
* verify. The actual safety check is the admin session: when the
|
|
20
|
+
* viewer client (or a server fn) sees the cookie, it tries to resolve
|
|
21
|
+
* `getAdminRequestContext()` and only elevates the read mode when a
|
|
22
|
+
* valid admin session resolves. A stale preview cookie on an
|
|
23
|
+
* anonymous browser falls through to public/published reads silently.
|
|
24
|
+
*
|
|
25
|
+
* Cookie attributes:
|
|
26
|
+
* - `httpOnly: true` — inaccessible to JS; toggled only via server fns.
|
|
27
|
+
* - `sameSite: 'lax'` — sent on top-level navigations only.
|
|
28
|
+
* - `secure: true` in production — https-only.
|
|
29
|
+
* - `path: '/'` — visible to every public route.
|
|
30
|
+
* - `maxAge: 1 day` — preview is meant to be a short-lived editorial
|
|
31
|
+
* mode, not a permanent state. Admins can
|
|
32
|
+
* re-enable from the admin UI when needed.
|
|
33
|
+
*
|
|
34
|
+
* Cookie transport goes through the registered `HostRequestBridge`, so
|
|
35
|
+
* this module is host-framework agnostic.
|
|
36
|
+
*/
|
|
37
|
+
import { getHostRequestBridge } from '@byline/core';
|
|
38
|
+
export const PREVIEW_COOKIE = 'byline_preview';
|
|
39
|
+
const PREVIEW_MAX_AGE_SECONDS = 60 * 60 * 24; // 1 day
|
|
40
|
+
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
41
|
+
/** True iff the preview cookie is currently set on the request. */
|
|
42
|
+
export function readPreviewCookie() {
|
|
43
|
+
return getHostRequestBridge().getCookie(PREVIEW_COOKIE) === '1';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Write the preview cookie. Callers should ensure the request is from a
|
|
47
|
+
* valid admin session before invoking — the cookie itself is just a flag
|
|
48
|
+
* and carries no proof of authorisation. See `enablePreviewModeFn` for
|
|
49
|
+
* the canonical entry point.
|
|
50
|
+
*/
|
|
51
|
+
export function setPreviewCookie() {
|
|
52
|
+
getHostRequestBridge().setCookie(PREVIEW_COOKIE, '1', {
|
|
53
|
+
httpOnly: true,
|
|
54
|
+
sameSite: 'lax',
|
|
55
|
+
secure: IS_PROD,
|
|
56
|
+
path: '/',
|
|
57
|
+
maxAge: PREVIEW_MAX_AGE_SECONDS,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Clear the preview cookie. Safe to call from any context. */
|
|
61
|
+
export function clearPreviewCookie() {
|
|
62
|
+
getHostRequestBridge().setCookie(PREVIEW_COOKIE, '', {
|
|
63
|
+
httpOnly: true,
|
|
64
|
+
sameSite: 'lax',
|
|
65
|
+
secure: IS_PROD,
|
|
66
|
+
path: '/',
|
|
67
|
+
maxAge: 0,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -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,114 @@
|
|
|
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
|
+
* Cross-layer contract test: a real `BylineClient` wired to the real
|
|
10
|
+
* cookie-derived request factory (`getAdminRequestContext`) over a fake
|
|
11
|
+
* `HostRequestBridge`, running two operations that share one
|
|
12
|
+
* `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, auth tests called the factory
|
|
17
|
+
* 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
|
+
import { AdminAuth } from '@byline/auth';
|
|
22
|
+
import { createReadContext, registerHostRequestBridge } from '@byline/core';
|
|
23
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
24
|
+
const mocks = vi.hoisted(() => ({
|
|
25
|
+
verifyAccessToken: vi.fn(),
|
|
26
|
+
refreshSession: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('@byline/core', async () => {
|
|
29
|
+
const actual = await vi.importActual('@byline/core');
|
|
30
|
+
return {
|
|
31
|
+
...actual,
|
|
32
|
+
getServerConfig: () => ({
|
|
33
|
+
sessionProvider: {
|
|
34
|
+
verifyAccessToken: mocks.verifyAccessToken,
|
|
35
|
+
refreshSession: mocks.refreshSession,
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
import { createBylineClient } from '../client.js';
|
|
41
|
+
import { getAdminRequestContext } from './admin-context.js';
|
|
42
|
+
const BRIDGE_SLOT = Symbol.for('__byline_host_request_bridge__');
|
|
43
|
+
const previousBridge = globalThis[BRIDGE_SLOT];
|
|
44
|
+
const bridge = {
|
|
45
|
+
getRequest: vi.fn(),
|
|
46
|
+
getCookie: vi.fn(),
|
|
47
|
+
setCookie: vi.fn(),
|
|
48
|
+
};
|
|
49
|
+
const postsCollection = {
|
|
50
|
+
path: 'posts',
|
|
51
|
+
labels: { singular: 'Post', plural: 'Posts' },
|
|
52
|
+
fields: [{ name: 'title', type: 'text', label: 'Title' }],
|
|
53
|
+
};
|
|
54
|
+
function mockDb() {
|
|
55
|
+
return {
|
|
56
|
+
commands: { collections: {}, documents: {}, counters: {} },
|
|
57
|
+
queries: {
|
|
58
|
+
collections: {
|
|
59
|
+
getCollectionByPath: vi.fn().mockResolvedValue({ id: 'col-1', version: 1 }),
|
|
60
|
+
},
|
|
61
|
+
documents: {
|
|
62
|
+
findDocuments: vi.fn().mockResolvedValue({ documents: [], total: 0 }),
|
|
63
|
+
getDocumentsByDocumentIds: vi.fn().mockResolvedValue([]),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
describe('request factory ↔ client authority contract', () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.clearAllMocks();
|
|
71
|
+
registerHostRequestBridge(bridge);
|
|
72
|
+
bridge.getRequest.mockReturnValue(undefined);
|
|
73
|
+
bridge.getCookie.mockImplementation((name) => name === 'byline_access_token' ? 'valid-access' : undefined);
|
|
74
|
+
mocks.verifyAccessToken.mockResolvedValue({
|
|
75
|
+
actor: new AdminAuth({ id: 'admin-1', abilities: ['collections.posts.read'] }),
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
;
|
|
80
|
+
globalThis[BRIDGE_SLOT] = previousBridge;
|
|
81
|
+
});
|
|
82
|
+
it('two reads sharing a ReadContext bind one authority within a request', async () => {
|
|
83
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
84
|
+
const db = mockDb();
|
|
85
|
+
const beforeRead = vi.fn(() => ({ title: 'scoped' }));
|
|
86
|
+
const client = createBylineClient({
|
|
87
|
+
db,
|
|
88
|
+
collections: [{ ...postsCollection, hooks: { beforeRead } }],
|
|
89
|
+
requestContext: getAdminRequestContext,
|
|
90
|
+
});
|
|
91
|
+
const readContext = createReadContext();
|
|
92
|
+
// The admin tree view shape: two top-level reads, one shared ReadContext.
|
|
93
|
+
await client.collection('posts').find({ status: 'any', _readContext: readContext });
|
|
94
|
+
await client.collection('posts').find({ status: 'any', _readContext: readContext });
|
|
95
|
+
expect(db.queries.documents.findDocuments).toHaveBeenCalledTimes(2);
|
|
96
|
+
// One session verification and one beforeRead compile for the whole
|
|
97
|
+
// request — the factory resolved a single request-stable context.
|
|
98
|
+
expect(mocks.verifyAccessToken).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(beforeRead).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
it('still rejects a ReadContext carried across two different requests', async () => {
|
|
102
|
+
const db = mockDb();
|
|
103
|
+
const client = createBylineClient({
|
|
104
|
+
db,
|
|
105
|
+
collections: [{ ...postsCollection, hooks: { beforeRead: () => ({ title: 'scoped' }) } }],
|
|
106
|
+
requestContext: getAdminRequestContext,
|
|
107
|
+
});
|
|
108
|
+
const readContext = createReadContext();
|
|
109
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
110
|
+
await client.collection('posts').find({ status: 'any', _readContext: readContext });
|
|
111
|
+
bridge.getRequest.mockReturnValue({ id: 'request-b' });
|
|
112
|
+
await expect(client.collection('posts').find({ status: 'any', _readContext: readContext })).rejects.toThrow('cannot be reused across request authorities');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -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,116 @@
|
|
|
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
|
+
* Direct regression coverage for the public and viewer request-context
|
|
10
|
+
* factories: both must be request-stable (same instance, same requestId,
|
|
11
|
+
* for every call within one request) so reads sharing a `ReadContext`
|
|
12
|
+
* bind a single request authority.
|
|
13
|
+
*/
|
|
14
|
+
import { AdminAuth } from '@byline/auth';
|
|
15
|
+
import { registerHostRequestBridge } from '@byline/core';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
const mocks = vi.hoisted(() => ({
|
|
18
|
+
verifyAccessToken: vi.fn(),
|
|
19
|
+
refreshSession: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('@byline/core', async () => {
|
|
22
|
+
const actual = await vi.importActual('@byline/core');
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
getServerConfig: () => ({
|
|
26
|
+
sessionProvider: {
|
|
27
|
+
verifyAccessToken: mocks.verifyAccessToken,
|
|
28
|
+
refreshSession: mocks.refreshSession,
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
import { resolvePublicRequestContext, resolveViewerRequestContext } from './clients.js';
|
|
34
|
+
const BRIDGE_SLOT = Symbol.for('__byline_host_request_bridge__');
|
|
35
|
+
const previousBridge = globalThis[BRIDGE_SLOT];
|
|
36
|
+
const bridge = {
|
|
37
|
+
getRequest: vi.fn(),
|
|
38
|
+
getCookie: vi.fn(),
|
|
39
|
+
setCookie: vi.fn(),
|
|
40
|
+
};
|
|
41
|
+
function cookiesReturn(values) {
|
|
42
|
+
bridge.getCookie.mockImplementation((name) => values[name]);
|
|
43
|
+
}
|
|
44
|
+
describe('request-context factories', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
registerHostRequestBridge(bridge);
|
|
48
|
+
bridge.getRequest.mockReturnValue(undefined);
|
|
49
|
+
cookiesReturn({});
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
;
|
|
53
|
+
globalThis[BRIDGE_SLOT] = previousBridge;
|
|
54
|
+
});
|
|
55
|
+
describe('resolvePublicRequestContext', () => {
|
|
56
|
+
it('returns the same anonymous published context for every call in one request', async () => {
|
|
57
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
58
|
+
const first = await resolvePublicRequestContext();
|
|
59
|
+
const second = await resolvePublicRequestContext();
|
|
60
|
+
expect(second).toBe(first);
|
|
61
|
+
expect(first.actor).toBeNull();
|
|
62
|
+
expect(first.readMode).toBe('published');
|
|
63
|
+
expect(second.requestId).toBe(first.requestId);
|
|
64
|
+
});
|
|
65
|
+
it('resolves independently across requests', async () => {
|
|
66
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
67
|
+
const first = await resolvePublicRequestContext();
|
|
68
|
+
bridge.getRequest.mockReturnValue({ id: 'request-b' });
|
|
69
|
+
const second = await resolvePublicRequestContext();
|
|
70
|
+
expect(second).not.toBe(first);
|
|
71
|
+
expect(second.requestId).not.toBe(first.requestId);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('resolveViewerRequestContext', () => {
|
|
75
|
+
it('returns the same anonymous context per request when no preview cookie is set', async () => {
|
|
76
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
77
|
+
const first = await resolveViewerRequestContext();
|
|
78
|
+
const second = await resolveViewerRequestContext();
|
|
79
|
+
expect(second).toBe(first);
|
|
80
|
+
expect(first.actor).toBeNull();
|
|
81
|
+
expect(first.readMode).toBe('published');
|
|
82
|
+
});
|
|
83
|
+
it('returns the same admin any-mode context per request in preview mode', async () => {
|
|
84
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
85
|
+
cookiesReturn({ byline_preview: '1', byline_access_token: 'valid-access' });
|
|
86
|
+
const actor = new AdminAuth({ id: 'admin-1', abilities: [] });
|
|
87
|
+
mocks.verifyAccessToken.mockResolvedValue({ actor });
|
|
88
|
+
const first = await resolveViewerRequestContext();
|
|
89
|
+
const second = await resolveViewerRequestContext();
|
|
90
|
+
expect(second).toBe(first);
|
|
91
|
+
expect(first.actor).toBe(actor);
|
|
92
|
+
expect(first.readMode).toBe('any');
|
|
93
|
+
// The viewer context and the nested admin resolution are both
|
|
94
|
+
// memoized — one session verification per request, not per call.
|
|
95
|
+
expect(mocks.verifyAccessToken).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
it('falls back to one stable anonymous context when the preview session is stale', async () => {
|
|
98
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
99
|
+
cookiesReturn({ byline_preview: '1', byline_access_token: 'stale' });
|
|
100
|
+
mocks.verifyAccessToken.mockRejectedValue(new Error('expired'));
|
|
101
|
+
const first = await resolveViewerRequestContext();
|
|
102
|
+
const second = await resolveViewerRequestContext();
|
|
103
|
+
expect(second).toBe(first);
|
|
104
|
+
expect(first.actor).toBeNull();
|
|
105
|
+
expect(first.readMode).toBe('published');
|
|
106
|
+
});
|
|
107
|
+
it('resolves independently across requests', async () => {
|
|
108
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
109
|
+
const first = await resolveViewerRequestContext();
|
|
110
|
+
bridge.getRequest.mockReturnValue({ id: 'request-b' });
|
|
111
|
+
const second = await resolveViewerRequestContext();
|
|
112
|
+
expect(second).not.toBe(first);
|
|
113
|
+
expect(second.requestId).not.toBe(first.requestId);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|