@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.
package/dist/client.d.ts CHANGED
@@ -8,13 +8,14 @@
8
8
  import { type RequestContext } from '@byline/auth';
9
9
  import type { BylineLogger, CollectionDefinition, CollectionRegistry, IDbAdapter, IStorageProvider, RichTextPopulateFn, RichTextToTextFn, SearchProvider, SlugifierFn } from '@byline/core';
10
10
  import { CollectionHandle } from './collection-handle.js';
11
+ import type { RegisteredCollections } from './register.js';
11
12
  import type { BylineClientConfig, ClientSearchResults, ZoneSearchOptions } from './types.js';
12
13
  /**
13
14
  * The main Byline client instance. Holds the database adapter, collection
14
15
  * definitions, and optional storage provider. Use `collection(path)` to get
15
16
  * a scoped handle for querying and mutating documents.
16
17
  */
17
- export declare class BylineClient<TRegistry extends CollectionRegistry = CollectionRegistry> {
18
+ export declare class BylineClient<TRegistry extends CollectionRegistry = RegisteredCollections> {
18
19
  readonly db: IDbAdapter;
19
20
  readonly collections: readonly CollectionDefinition[];
20
21
  readonly storage: IStorageProvider | undefined;
@@ -71,6 +72,8 @@ export declare class BylineClient<TRegistry extends CollectionRegistry = Collect
71
72
  resolveCollectionId(path: string): Promise<string>;
72
73
  }
73
74
  /**
74
- * Create a new Byline client instance.
75
+ * Create a new Byline client instance. The registry generic defaults to
76
+ * `RegisteredCollections`, so in an app whose generated types augment
77
+ * `Register` the returned client is fully typed with no explicit generic.
75
78
  */
76
- export declare function createBylineClient<TRegistry extends CollectionRegistry = CollectionRegistry>(config: BylineClientConfig): BylineClient<TRegistry>;
79
+ export declare function createBylineClient<TRegistry extends CollectionRegistry = RegisteredCollections>(config: BylineClientConfig): BylineClient<TRegistry>;
package/dist/client.js CHANGED
@@ -174,7 +174,9 @@ export class BylineClient {
174
174
  }
175
175
  }
176
176
  /**
177
- * Create a new Byline client instance.
177
+ * Create a new Byline client instance. The registry generic defaults to
178
+ * `RegisteredCollections`, so in an app whose generated types augment
179
+ * `Register` the returned client is fully typed with no explicit generic.
178
180
  */
179
181
  export function createBylineClient(config) {
180
182
  return new BylineClient(config);
package/dist/index.d.ts CHANGED
@@ -8,4 +8,5 @@
8
8
  export { type AuditLogEntry, type AuditLogPage, type ChangeStatusResult, type CreateDocumentResult, type CycleRelationValue, createReadContext, type DeleteDocumentResult, ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateSpec, type ReadContext, type ReadMode, type RestoreVersionResult, type UnpublishResult, type UnresolvedRelationValue, type UpdateDocumentResult, } from '@byline/core';
9
9
  export { BylineClient, createBylineClient } from './client.js';
10
10
  export { CollectionHandle } from './collection-handle.js';
11
+ export type { Register, RegisteredCollections } from './register.js';
11
12
  export type { AuditLogOptions, BylineClientConfig, ClientDocument, ClientSearchResults, CollectionSearchOptions, CreateOptions, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, GetTreeParentOptions, HydratedSearchHit, PlaceTreeNodeOptions, PopulatedRelation, ReindexResult, RemoveFromTreeOptions, SearchFacetBucket, SearchHit, SearchResults, SortDirection, SortSpec, TreeNode, TreeParentResult, UpdateOptions, WhereClause, WhereValue, WithPopulated, WithPopulatedMany, ZoneSearchOptions, } from './types.js';
@@ -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;
@@ -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,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 {};
@@ -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>;