@byline/client 4.0.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.
@@ -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
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Resolve `factory` at most once per HTTP request for a given `key`. The
10
+ * returned promise is memoized on the current request — rejections
11
+ * included, since a request's cookies cannot change mid-flight and retrying
12
+ * a failed refresh would burn a second rotation on an already-rotated
13
+ * token. Outside a request (seed scripts, unit tests, background jobs) —
14
+ * or before a host bridge is registered — there is no request to key on
15
+ * and the factory runs unmemoized.
16
+ */
17
+ export declare function oncePerRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
@@ -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
+ * Per-request memoization for `RequestContext` factories.
10
+ *
11
+ * `RequestContext.requestId` is documented as "per logical request", and
12
+ * the read-authorization layer binds every `ReadContext` to one immutable
13
+ * request authority whose token includes that id. A factory that mints a
14
+ * fresh context per call breaks both contracts as soon as two reads share
15
+ * a `ReadContext` (the admin tree view, hook-threaded nested reads):
16
+ * the second bind sees a different authority token and throws
17
+ * 'ReadContext cannot be reused across request authorities'.
18
+ *
19
+ * Memoizing on the current request (via the registered
20
+ * `HostRequestBridge`) makes every context factory request-stable. It
21
+ * also means at most one session verification — and at most one
22
+ * refresh-token rotation — per request, instead of one per call.
23
+ */
24
+ import { tryGetHostRequestBridge } from '@byline/core';
25
+ const requestScopes = new WeakMap();
26
+ /**
27
+ * Resolve `factory` at most once per HTTP request for a given `key`. The
28
+ * returned promise is memoized on the current request — rejections
29
+ * included, since a request's cookies cannot change mid-flight and retrying
30
+ * a failed refresh would burn a second rotation on an already-rotated
31
+ * token. Outside a request (seed scripts, unit tests, background jobs) —
32
+ * or before a host bridge is registered — there is no request to key on
33
+ * and the factory runs unmemoized.
34
+ */
35
+ export function oncePerRequest(key, factory) {
36
+ const request = tryGetHostRequestBridge()?.getRequest();
37
+ if (!request)
38
+ return factory();
39
+ let scope = requestScopes.get(request);
40
+ if (!scope) {
41
+ scope = new Map();
42
+ requestScopes.set(request, scope);
43
+ }
44
+ const existing = scope.get(key);
45
+ if (existing)
46
+ return existing;
47
+ const pending = factory();
48
+ scope.set(key, pending);
49
+ return pending;
50
+ }
@@ -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 {};