@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,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 {};
|
|
@@ -0,0 +1,89 @@
|
|
|
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 per-request memoization. Registers a fake
|
|
10
|
+
* `HostRequestBridge` so "which request am I in" can be simulated: a
|
|
11
|
+
* stable object per request, or `undefined` when running outside a
|
|
12
|
+
* request.
|
|
13
|
+
*/
|
|
14
|
+
import { registerHostRequestBridge } from '@byline/core';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
import { oncePerRequest } from './request-scope.js';
|
|
17
|
+
const BRIDGE_SLOT = Symbol.for('__byline_host_request_bridge__');
|
|
18
|
+
const previousBridge = globalThis[BRIDGE_SLOT];
|
|
19
|
+
const bridge = {
|
|
20
|
+
getRequest: vi.fn(),
|
|
21
|
+
getCookie: vi.fn(),
|
|
22
|
+
setCookie: vi.fn(),
|
|
23
|
+
};
|
|
24
|
+
describe('oncePerRequest', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
registerHostRequestBridge(bridge);
|
|
28
|
+
// Default: no request in scope, so every call resolves fresh.
|
|
29
|
+
bridge.getRequest.mockReturnValue(undefined);
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
;
|
|
33
|
+
globalThis[BRIDGE_SLOT] = previousBridge;
|
|
34
|
+
});
|
|
35
|
+
it('resolves the factory once per request per key', async () => {
|
|
36
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
37
|
+
const factory = vi.fn(async () => ({ token: Math.random() }));
|
|
38
|
+
const first = await oncePerRequest('ctx', factory);
|
|
39
|
+
const second = await oncePerRequest('ctx', factory);
|
|
40
|
+
expect(second).toBe(first);
|
|
41
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
42
|
+
});
|
|
43
|
+
it('keys memoization by request identity', async () => {
|
|
44
|
+
const requestA = { id: 'a' };
|
|
45
|
+
const requestB = { id: 'b' };
|
|
46
|
+
const factory = vi.fn(async () => ({ token: Math.random() }));
|
|
47
|
+
bridge.getRequest.mockReturnValue(requestA);
|
|
48
|
+
const first = await oncePerRequest('ctx', factory);
|
|
49
|
+
bridge.getRequest.mockReturnValue(requestB);
|
|
50
|
+
const second = await oncePerRequest('ctx', factory);
|
|
51
|
+
expect(second).not.toBe(first);
|
|
52
|
+
expect(factory).toHaveBeenCalledTimes(2);
|
|
53
|
+
});
|
|
54
|
+
it('isolates entries by key within one request', async () => {
|
|
55
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
56
|
+
const admin = vi.fn(async () => 'admin-context');
|
|
57
|
+
const publicCtx = vi.fn(async () => 'public-context');
|
|
58
|
+
await expect(oncePerRequest('admin', admin)).resolves.toBe('admin-context');
|
|
59
|
+
await expect(oncePerRequest('public', publicCtx)).resolves.toBe('public-context');
|
|
60
|
+
await expect(oncePerRequest('admin', admin)).resolves.toBe('admin-context');
|
|
61
|
+
expect(admin).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(publicCtx).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
it('memoizes rejections for the remainder of the request', async () => {
|
|
65
|
+
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
66
|
+
const factory = vi.fn(async () => {
|
|
67
|
+
throw new Error('no admin session');
|
|
68
|
+
});
|
|
69
|
+
await expect(oncePerRequest('ctx', factory)).rejects.toThrow('no admin session');
|
|
70
|
+
await expect(oncePerRequest('ctx', factory)).rejects.toThrow('no admin session');
|
|
71
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
it('runs unmemoized outside a request', async () => {
|
|
74
|
+
const factory = vi.fn(async () => ({ token: Math.random() }));
|
|
75
|
+
const first = await oncePerRequest('ctx', factory);
|
|
76
|
+
const second = await oncePerRequest('ctx', factory);
|
|
77
|
+
expect(second).not.toBe(first);
|
|
78
|
+
expect(factory).toHaveBeenCalledTimes(2);
|
|
79
|
+
});
|
|
80
|
+
it('runs unmemoized when no host bridge is registered', async () => {
|
|
81
|
+
;
|
|
82
|
+
globalThis[BRIDGE_SLOT] = undefined;
|
|
83
|
+
const factory = vi.fn(async () => ({ token: Math.random() }));
|
|
84
|
+
const first = await oncePerRequest('ctx', factory);
|
|
85
|
+
const second = await oncePerRequest('ctx', factory);
|
|
86
|
+
expect(second).not.toBe(first);
|
|
87
|
+
expect(factory).toHaveBeenCalledTimes(2);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
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 ACCESS_TOKEN_COOKIE = "byline_access_token";
|
|
9
|
+
export declare const REFRESH_TOKEN_COOKIE = "byline_refresh_token";
|
|
10
|
+
/** Read the access-token cookie. Returns undefined when not present. */
|
|
11
|
+
export declare function readAccessTokenCookie(): string | undefined;
|
|
12
|
+
/** Read the refresh-token cookie. Returns undefined when not present. */
|
|
13
|
+
export declare function readRefreshTokenCookie(): string | undefined;
|
|
14
|
+
export interface SessionCookieTokens {
|
|
15
|
+
accessToken: string;
|
|
16
|
+
refreshToken: string;
|
|
17
|
+
accessTokenExpiresAt: Date;
|
|
18
|
+
refreshTokenExpiresAt: Date;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Write both access and refresh cookies. Called after sign-in and after
|
|
22
|
+
* every transparent refresh in `getAdminRequestContext()`.
|
|
23
|
+
*
|
|
24
|
+
* `maxAge` is derived from each token's own expiry claim so the browser
|
|
25
|
+
* drops the cookies at the same moment the server would reject them —
|
|
26
|
+
* saves round trips when the refresh token has fully expired.
|
|
27
|
+
*/
|
|
28
|
+
export declare function setSessionCookies(tokens: SessionCookieTokens): void;
|
|
29
|
+
/**
|
|
30
|
+
* Clear both session cookies. Called on sign-out and on any auth failure
|
|
31
|
+
* during `getAdminRequestContext()` (ensures the browser does not keep
|
|
32
|
+
* trying a token that the server has already rejected).
|
|
33
|
+
*/
|
|
34
|
+
export declare function clearSessionCookies(): void;
|
|
@@ -0,0 +1,95 @@
|
|
|
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 admin session management.
|
|
10
|
+
*
|
|
11
|
+
* Two separate httpOnly cookies, one for each token half:
|
|
12
|
+
*
|
|
13
|
+
* - `byline_access_token` — short-lived JWT; sent on every request.
|
|
14
|
+
* - `byline_refresh_token` — long-lived opaque string; sent back only
|
|
15
|
+
* when the access cookie is missing or
|
|
16
|
+
* expired, to mint a new access token.
|
|
17
|
+
*
|
|
18
|
+
* Presenting "one session" to the user is a function of the middleware —
|
|
19
|
+
* `getAdminRequestContext()` transparently refreshes the access cookie
|
|
20
|
+
* using the refresh cookie, so the UI layer doesn't have to care.
|
|
21
|
+
*
|
|
22
|
+
* All cookies set here use:
|
|
23
|
+
* - `httpOnly: true` — inaccessible to JavaScript (XSS-hardened).
|
|
24
|
+
* - `sameSite: 'lax'` — sent on top-level navigations but not
|
|
25
|
+
* cross-origin subrequests (reasonable default
|
|
26
|
+
* for an admin SSR app).
|
|
27
|
+
* - `secure: true` in production — https-only; dev keeps it off so
|
|
28
|
+
* cookies work on http://localhost.
|
|
29
|
+
* - `path: '/'` — available everywhere in the app.
|
|
30
|
+
*
|
|
31
|
+
* Cookie transport goes through the registered `HostRequestBridge`, so
|
|
32
|
+
* this module is host-framework agnostic.
|
|
33
|
+
*/
|
|
34
|
+
import { getHostRequestBridge } from '@byline/core';
|
|
35
|
+
export const ACCESS_TOKEN_COOKIE = 'byline_access_token';
|
|
36
|
+
export const REFRESH_TOKEN_COOKIE = 'byline_refresh_token';
|
|
37
|
+
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
38
|
+
/** Read the access-token cookie. Returns undefined when not present. */
|
|
39
|
+
export function readAccessTokenCookie() {
|
|
40
|
+
return getHostRequestBridge().getCookie(ACCESS_TOKEN_COOKIE);
|
|
41
|
+
}
|
|
42
|
+
/** Read the refresh-token cookie. Returns undefined when not present. */
|
|
43
|
+
export function readRefreshTokenCookie() {
|
|
44
|
+
return getHostRequestBridge().getCookie(REFRESH_TOKEN_COOKIE);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Write both access and refresh cookies. Called after sign-in and after
|
|
48
|
+
* every transparent refresh in `getAdminRequestContext()`.
|
|
49
|
+
*
|
|
50
|
+
* `maxAge` is derived from each token's own expiry claim so the browser
|
|
51
|
+
* drops the cookies at the same moment the server would reject them —
|
|
52
|
+
* saves round trips when the refresh token has fully expired.
|
|
53
|
+
*/
|
|
54
|
+
export function setSessionCookies(tokens) {
|
|
55
|
+
const bridge = getHostRequestBridge();
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const accessMaxAgeSeconds = Math.max(0, Math.floor((tokens.accessTokenExpiresAt.getTime() - now) / 1000));
|
|
58
|
+
const refreshMaxAgeSeconds = Math.max(0, Math.floor((tokens.refreshTokenExpiresAt.getTime() - now) / 1000));
|
|
59
|
+
bridge.setCookie(ACCESS_TOKEN_COOKIE, tokens.accessToken, {
|
|
60
|
+
httpOnly: true,
|
|
61
|
+
sameSite: 'lax',
|
|
62
|
+
secure: IS_PROD,
|
|
63
|
+
path: '/',
|
|
64
|
+
maxAge: accessMaxAgeSeconds,
|
|
65
|
+
});
|
|
66
|
+
bridge.setCookie(REFRESH_TOKEN_COOKIE, tokens.refreshToken, {
|
|
67
|
+
httpOnly: true,
|
|
68
|
+
sameSite: 'lax',
|
|
69
|
+
secure: IS_PROD,
|
|
70
|
+
path: '/',
|
|
71
|
+
maxAge: refreshMaxAgeSeconds,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Clear both session cookies. Called on sign-out and on any auth failure
|
|
76
|
+
* during `getAdminRequestContext()` (ensures the browser does not keep
|
|
77
|
+
* trying a token that the server has already rejected).
|
|
78
|
+
*/
|
|
79
|
+
export function clearSessionCookies() {
|
|
80
|
+
const bridge = getHostRequestBridge();
|
|
81
|
+
bridge.setCookie(ACCESS_TOKEN_COOKIE, '', {
|
|
82
|
+
httpOnly: true,
|
|
83
|
+
sameSite: 'lax',
|
|
84
|
+
secure: IS_PROD,
|
|
85
|
+
path: '/',
|
|
86
|
+
maxAge: 0,
|
|
87
|
+
});
|
|
88
|
+
bridge.setCookie(REFRESH_TOKEN_COOKIE, '', {
|
|
89
|
+
httpOnly: true,
|
|
90
|
+
sameSite: 'lax',
|
|
91
|
+
secure: IS_PROD,
|
|
92
|
+
path: '/',
|
|
93
|
+
maxAge: 0,
|
|
94
|
+
});
|
|
95
|
+
}
|
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": "4.
|
|
5
|
+
"version": "4.2.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -31,14 +31,21 @@
|
|
|
31
31
|
"import": "./dist/index.js",
|
|
32
32
|
"require": "./dist/index.js"
|
|
33
33
|
},
|
|
34
|
+
"./server": {
|
|
35
|
+
"types": "./dist/server/index.d.ts",
|
|
36
|
+
"browser": "./dist/server/browser.js",
|
|
37
|
+
"import": "./dist/server/index.js",
|
|
38
|
+
"require": "./dist/server/index.js"
|
|
39
|
+
},
|
|
34
40
|
"./package.json": "./package.json"
|
|
35
41
|
},
|
|
36
42
|
"files": [
|
|
37
43
|
"dist"
|
|
38
44
|
],
|
|
39
45
|
"dependencies": {
|
|
40
|
-
"
|
|
41
|
-
"@byline/
|
|
46
|
+
"uuid": "^14.0.1",
|
|
47
|
+
"@byline/core": "4.2.0",
|
|
48
|
+
"@byline/auth": "4.2.0"
|
|
42
49
|
},
|
|
43
50
|
"devDependencies": {
|
|
44
51
|
"@biomejs/biome": "2.5.2",
|
|
@@ -51,8 +58,8 @@
|
|
|
51
58
|
"tsx": "^4.23.0",
|
|
52
59
|
"typescript": "^7.0.2",
|
|
53
60
|
"vitest": "^4.1.10",
|
|
54
|
-
"@byline/db-postgres": "4.
|
|
55
|
-
"@byline/search-postgres": "4.
|
|
61
|
+
"@byline/db-postgres": "4.2.0",
|
|
62
|
+
"@byline/search-postgres": "4.2.0"
|
|
56
63
|
},
|
|
57
64
|
"publishConfig": {
|
|
58
65
|
"access": "public",
|