@byline/client 5.0.0 → 5.1.1

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.
@@ -6,45 +6,13 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  /**
9
- * Request-scoped auth context for admin server functions.
9
+ * Verification-only request context. Renewal belongs to the explicit host endpoint.
10
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.
11
+ * oncePerRequest preserves the same context, actor snapshot, and requestId for
12
+ * every call in one logical request. The read-authorization layer binds each
13
+ * ReadContext to one authority token including requestId. Generating a new id
14
+ * per call would make nested reads sharing a ReadContext throw:
15
+ * "ReadContext cannot be reused across request authorities".
48
16
  */
49
17
  import { type RequestContext } from '@byline/auth';
50
18
  export declare function getAdminRequestContext(): Promise<RequestContext>;
@@ -6,120 +6,33 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  /**
9
- * Request-scoped auth context for admin server functions.
9
+ * Verification-only request context. Renewal belongs to the explicit host endpoint.
10
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.
11
+ * oncePerRequest preserves the same context, actor snapshot, and requestId for
12
+ * every call in one logical request. The read-authorization layer binds each
13
+ * ReadContext to one authority token including requestId. Generating a new id
14
+ * per call would make nested reads sharing a ReadContext throw:
15
+ * "ReadContext cannot be reused across request authorities".
48
16
  */
49
- import { ERR_UNAUTHENTICATED } from '@byline/auth';
17
+ import { ERR_ACCESS_EXPIRED, ERR_UNAUTHENTICATED } from '@byline/auth';
50
18
  import { getServerConfig } from '@byline/core';
51
19
  import { v7 as uuidv7 } from 'uuid';
52
20
  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();
21
+ import { readAccessTokenCookie, readRefreshTokenCookie } from './session-cookies.js';
22
+ export function getAdminRequestContext() {
23
+ return oncePerRequest('byline:admin-request-context', async () => {
24
+ const provider = getServerConfig().sessionProvider;
25
+ if (!provider)
26
+ throw new Error('no sessionProvider configured');
27
+ const access = readAccessTokenCookie();
28
+ if (!access) {
29
+ if (readRefreshTokenCookie())
30
+ throw ERR_ACCESS_EXPIRED({ message: 'access credential absent; explicit renewal required' });
31
+ throw ERR_UNAUTHENTICATED({ message: 'no admin session' });
92
32
  }
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
- };
33
+ const { actor, sessionId } = await provider.verifyAccessToken(access);
34
+ if (!sessionId)
35
+ throw new Error('session provider omitted login identity');
36
+ return { actor, sessionId, requestId: uuidv7(), readMode: 'any' };
37
+ });
125
38
  }
@@ -10,7 +10,7 @@
10
10
  * `HostRequestBridge` and mocks the config's `sessionProvider` so the
11
11
  * branches can be exercised without a live Postgres or JWT machinery.
12
12
  */
13
- import { AdminAuth, AuthError, AuthErrorCodes } from '@byline/auth';
13
+ import { AdminAuth, AuthError, AuthErrorCodes, ERR_ACCESS_EXPIRED, ERR_REVOKED_TOKEN, } from '@byline/auth';
14
14
  import { registerHostRequestBridge } from '@byline/core';
15
15
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
16
  const mocks = vi.hoisted(() => ({
@@ -67,51 +67,30 @@ describe('getAdminRequestContext', () => {
67
67
  byline_refresh_token: 'some-refresh',
68
68
  });
69
69
  const actor = stubActor();
70
- verifyAccessToken.mockResolvedValueOnce({ actor });
70
+ verifyAccessToken.mockResolvedValueOnce({ actor, sessionId: 'test-login' });
71
71
  const ctx = await getAdminRequestContext();
72
72
  expect(ctx.actor).toBe(actor);
73
73
  expect(ctx.readMode).toBe('any');
74
74
  expect(refreshSession).not.toHaveBeenCalled();
75
75
  expect(setCookie).not.toHaveBeenCalled();
76
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);
77
+ it.each([
78
+ ERR_ACCESS_EXPIRED({ message: 'expired' }),
79
+ ERR_REVOKED_TOKEN({ message: 'revoked' }),
80
+ new Error('database unavailable'),
81
+ ])('never renews or writes cookies after verification failure: %s', async (error) => {
82
+ cookiesReturn({ byline_access_token: 'old', byline_refresh_token: 'retained' });
83
+ verifyAccessToken.mockRejectedValueOnce(error);
84
+ await expect(getAdminRequestContext()).rejects.toBe(error);
85
+ expect(refreshSession).not.toHaveBeenCalled();
86
+ expect(setCookie).not.toHaveBeenCalled();
98
87
  });
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');
88
+ it('reports explicit renewal required when only the persistent refresh cookie survives', async () => {
89
+ cookiesReturn({ byline_refresh_token: 'retained' });
90
+ await expect(getAdminRequestContext()).rejects.toMatchObject({ code: 'ERR_ACCESS_EXPIRED' });
91
+ expect(verifyAccessToken).not.toHaveBeenCalled();
92
+ expect(refreshSession).not.toHaveBeenCalled();
93
+ expect(setCookie).not.toHaveBeenCalled();
115
94
  });
116
95
  it('throws ERR_UNAUTHENTICATED without emitting Set-Cookie when no cookies are sent', async () => {
117
96
  cookiesReturn({});
@@ -128,63 +107,6 @@ describe('getAdminRequestContext', () => {
128
107
  // response is a hard bypass signal for CDNs.
129
108
  expect(setCookie).not.toHaveBeenCalled();
130
109
  });
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
110
  describe('per-request memoization', () => {
189
111
  it('returns the same context instance — and requestId — for every call in one request', async () => {
190
112
  bridge.getRequest.mockReturnValue({ id: 'request-a' });
@@ -192,7 +114,7 @@ describe('getAdminRequestContext', () => {
192
114
  byline_access_token: 'valid-access',
193
115
  byline_refresh_token: 'some-refresh',
194
116
  });
195
- verifyAccessToken.mockResolvedValue({ actor: stubActor() });
117
+ verifyAccessToken.mockResolvedValue({ actor: stubActor(), sessionId: 'test-login' });
196
118
  const first = await getAdminRequestContext();
197
119
  const second = await getAdminRequestContext();
198
120
  // Reads sharing a ReadContext bind one request authority; the token
@@ -206,7 +128,7 @@ describe('getAdminRequestContext', () => {
206
128
  byline_access_token: 'valid-access',
207
129
  byline_refresh_token: 'some-refresh',
208
130
  });
209
- verifyAccessToken.mockResolvedValue({ actor: stubActor() });
131
+ verifyAccessToken.mockResolvedValue({ actor: stubActor(), sessionId: 'test-login' });
210
132
  bridge.getRequest.mockReturnValue({ id: 'request-a' });
211
133
  const first = await getAdminRequestContext();
212
134
  bridge.getRequest.mockReturnValue({ id: 'request-b' });
@@ -215,42 +137,15 @@ describe('getAdminRequestContext', () => {
215
137
  expect(second.requestId).not.toBe(first.requestId);
216
138
  expect(verifyAccessToken).toHaveBeenCalledTimes(2);
217
139
  });
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);
140
+ it('memoizes expiry without any rotation or cookie write', async () => {
141
+ bridge.getRequest.mockReturnValue({});
142
+ cookiesReturn({ byline_access_token: 'expired', byline_refresh_token: 'retained' });
143
+ verifyAccessToken.mockRejectedValue(ERR_ACCESS_EXPIRED({ message: 'expired' }));
144
+ await expect(getAdminRequestContext()).rejects.toMatchObject({ code: 'ERR_ACCESS_EXPIRED' });
145
+ await expect(getAdminRequestContext()).rejects.toMatchObject({ code: 'ERR_ACCESS_EXPIRED' });
146
+ expect(verifyAccessToken).toHaveBeenCalledOnce();
147
+ expect(refreshSession).not.toHaveBeenCalled();
148
+ expect(setCookie).not.toHaveBeenCalled();
254
149
  });
255
150
  });
256
151
  });
@@ -73,6 +73,7 @@ describe('request factory ↔ client authority contract', () => {
73
73
  bridge.getRequest.mockReturnValue(undefined);
74
74
  bridge.getCookie.mockImplementation((name) => name === 'byline_access_token' ? 'valid-access' : undefined);
75
75
  mocks.verifyAccessToken.mockResolvedValue({
76
+ sessionId: 'test-login',
76
77
  actor: new AdminAuth({ id: 'admin-1', abilities: ['collections.posts.read'] }),
77
78
  });
78
79
  });
@@ -84,7 +84,7 @@ describe('request-context factories', () => {
84
84
  bridge.getRequest.mockReturnValue({ id: 'request-a' });
85
85
  cookiesReturn({ byline_preview: '1', byline_access_token: 'valid-access' });
86
86
  const actor = new AdminAuth({ id: 'admin-1', abilities: [] });
87
- mocks.verifyAccessToken.mockResolvedValue({ actor });
87
+ mocks.verifyAccessToken.mockResolvedValue({ actor, sessionId: 'test-login' });
88
88
  const first = await resolveViewerRequestContext();
89
89
  const second = await resolveViewerRequestContext();
90
90
  expect(second).toBe(first);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/client",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "5.0.0",
5
+ "version": "5.1.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -44,21 +44,21 @@
44
44
  ],
45
45
  "dependencies": {
46
46
  "uuid": "^14.0.2",
47
- "@byline/auth": "5.0.0",
48
- "@byline/core": "5.0.0"
47
+ "@byline/auth": "5.1.1",
48
+ "@byline/core": "5.1.1"
49
49
  },
50
50
  "devDependencies": {
51
- "@biomejs/biome": "2.5.10",
52
- "@types/node": "^26.4.0",
51
+ "@biomejs/biome": "2.5.12",
52
+ "@types/node": "^26.5.0",
53
53
  "chokidar": "^5.0.0",
54
54
  "chokidar-cli": "^3.0.0",
55
55
  "dotenv": "^17.4.2",
56
56
  "npm-run-all": "^4.1.5",
57
- "tsc-alias": "^1.9.2",
57
+ "tsc-alias": "^1.9.4",
58
58
  "typescript": "^7.0.2",
59
- "vitest": "^4.1.11",
60
- "@byline/search-postgres": "5.0.0",
61
- "@byline/db-postgres": "5.0.0"
59
+ "vitest": "^5.0.0",
60
+ "@byline/db-postgres": "5.1.1",
61
+ "@byline/search-postgres": "5.1.1"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public",