@byline/core 6.7.2 → 6.8.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/@types/site-config.d.ts +15 -1
- package/dist/config/config.js +8 -1
- package/dist/config/validate-passkey-config.d.ts +4 -0
- package/dist/config/validate-passkey-config.js +89 -0
- package/dist/config/validate-passkey-config.test.node.d.ts +1 -0
- package/dist/config/validate-passkey-config.test.node.js +152 -0
- package/dist/core.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/scheduler/validate-scheduler-config.d.ts +2 -1
- package/dist/scheduler/validate-scheduler-config.js +9 -4
- package/dist/scheduler/validate-scheduler-config.test.node.js +17 -0
- package/package.json +3 -3
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import type { PasswordSignInProtection, SessionProvider } from '@byline/auth';
|
|
8
|
+
import type { PasskeyRequestProtection, PasswordSignInProtection, SessionProvider } from '@byline/auth';
|
|
9
9
|
import type { RecurringTaskDefinition } from '../scheduler/types.js';
|
|
10
10
|
import type { SlugifierFn } from '../utils/slugify.js';
|
|
11
11
|
import type { FilenameSlugifierFn } from '../utils/slugify-filename.js';
|
|
@@ -351,6 +351,10 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
|
|
|
351
351
|
* ```
|
|
352
352
|
*/
|
|
353
353
|
sessionProvider?: SessionProvider;
|
|
354
|
+
/** Explicit WebAuthn trust configuration; never inferred from request headers. */
|
|
355
|
+
passkeys?: PasskeyConfig;
|
|
356
|
+
/** Shared WebAuthn network admission and independent process capacity; required when enabled. */
|
|
357
|
+
passkeyRequests?: PasskeyRequestProtection;
|
|
354
358
|
/** Required by the host password sign-in endpoint; shared limiter and trusted IP resolver. */
|
|
355
359
|
passwordSignIn?: PasswordSignInProtection;
|
|
356
360
|
/**
|
|
@@ -476,3 +480,13 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
|
|
|
476
480
|
export type ResolvedServerConfig<TAdminStore = unknown> = Omit<ServerConfig<TAdminStore>, 'routes'> & {
|
|
477
481
|
routes: RoutesConfig;
|
|
478
482
|
};
|
|
483
|
+
/** Server-owned WebAuthn trust configuration. No related-origin expansion is supported. */
|
|
484
|
+
export interface PasskeyConfig {
|
|
485
|
+
/** Canonical lowercase DNS name, without scheme, port or path. */
|
|
486
|
+
rpId: string;
|
|
487
|
+
rpName: string;
|
|
488
|
+
/** Exact canonical origins, including a non-default port when needed. */
|
|
489
|
+
origins: readonly string[];
|
|
490
|
+
/** Explicit local-development exception, restricted to http://localhost origins. */
|
|
491
|
+
allowInsecureLocalhost?: boolean;
|
|
492
|
+
}
|
package/dist/config/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { commitHookAttachment, prepareHookAttachment, } from './attach-hooks.js'
|
|
|
2
2
|
import { resolveRoutes } from './routes.js';
|
|
3
3
|
import { validateAdminConfigs, validateBlockAdminConfigs } from './validate-admin-configs.js';
|
|
4
4
|
import { validateCollections } from './validate-collections.js';
|
|
5
|
+
import { resolvePasskeyConfig } from './validate-passkey-config.js';
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Global config storage
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -79,7 +80,13 @@ export function defineServerConfig(config) {
|
|
|
79
80
|
/** Resolve and validate server config without replacing the registered singleton. */
|
|
80
81
|
export function resolveServerConfig(config) {
|
|
81
82
|
validateCollections(config.collections);
|
|
82
|
-
const
|
|
83
|
+
const passkeys = resolvePasskeyConfig(config.passkeys, config.sessionProvider);
|
|
84
|
+
if (passkeys &&
|
|
85
|
+
(typeof config.passkeyRequests?.resolveClientIp !== 'function' ||
|
|
86
|
+
typeof config.passkeyRequests.limiter?.acquire !== 'function' ||
|
|
87
|
+
typeof config.passkeyRequests.limiter?.consume !== 'function'))
|
|
88
|
+
throw new Error('Passkeys require passkeyRequests with trusted client IP resolution and admission');
|
|
89
|
+
const resolved = { ...config, passkeys, routes: resolveRoutes(config.routes) };
|
|
83
90
|
preparedHookAttachments.set(resolved, prepareHookAttachment(resolved));
|
|
84
91
|
return resolved;
|
|
85
92
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SessionProvider } from '@byline/auth';
|
|
2
|
+
import type { PasskeyConfig } from '../@types/site-config.js';
|
|
3
|
+
/** Pure boot-time validation; deliberately contains no Node or cryptographic imports. */
|
|
4
|
+
export declare function resolvePasskeyConfig(config: PasskeyConfig | undefined, provider: Pick<SessionProvider, 'capabilities' | 'passkeys'> | undefined): PasskeyConfig | undefined;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const methods = [
|
|
2
|
+
'signInOptions',
|
|
3
|
+
'verifySignIn',
|
|
4
|
+
'passwordProof',
|
|
5
|
+
'proofOptions',
|
|
6
|
+
'verifyProof',
|
|
7
|
+
'enrollmentOptions',
|
|
8
|
+
'verifyEnrollment',
|
|
9
|
+
'listCredentials',
|
|
10
|
+
'renameCredential',
|
|
11
|
+
'removeCredential',
|
|
12
|
+
];
|
|
13
|
+
function invalid(reason) {
|
|
14
|
+
throw new Error(`Invalid passkey configuration: ${reason}`);
|
|
15
|
+
}
|
|
16
|
+
/** Pure boot-time validation; deliberately contains no Node or cryptographic imports. */
|
|
17
|
+
export function resolvePasskeyConfig(config, provider) {
|
|
18
|
+
const supported = provider?.capabilities.passkeys;
|
|
19
|
+
if (supported !== undefined && typeof supported !== 'boolean') {
|
|
20
|
+
invalid('provider capability must be a boolean');
|
|
21
|
+
}
|
|
22
|
+
if (config === undefined) {
|
|
23
|
+
if (supported === true || provider?.passkeys !== undefined) {
|
|
24
|
+
invalid('an enabled provider requires explicit relying-party configuration');
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
29
|
+
invalid('expected an object');
|
|
30
|
+
if (supported !== true || !provider?.passkeys)
|
|
31
|
+
invalid('provider does not support passkeys');
|
|
32
|
+
for (const method of methods) {
|
|
33
|
+
if (typeof provider.passkeys[method] !== 'function')
|
|
34
|
+
invalid(`provider is missing ${method}`);
|
|
35
|
+
}
|
|
36
|
+
const allowed = new Set(['rpId', 'rpName', 'origins', 'allowInsecureLocalhost']);
|
|
37
|
+
if (Object.keys(config).some((key) => !allowed.has(key)))
|
|
38
|
+
invalid('unknown configuration option');
|
|
39
|
+
if (typeof config.rpId !== 'string' ||
|
|
40
|
+
config.rpId.length > 253 ||
|
|
41
|
+
!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(config.rpId) ||
|
|
42
|
+
(config.rpId !== 'localhost' && !config.rpId.includes('.')) ||
|
|
43
|
+
/^[\d.]+$/.test(config.rpId)) {
|
|
44
|
+
invalid('rpId must be a canonical DNS name or localhost');
|
|
45
|
+
}
|
|
46
|
+
if (typeof config.rpName !== 'string' || !config.rpName.trim() || config.rpName.length > 200) {
|
|
47
|
+
invalid('rpName must contain 1–200 characters');
|
|
48
|
+
}
|
|
49
|
+
if (config.allowInsecureLocalhost !== undefined &&
|
|
50
|
+
typeof config.allowInsecureLocalhost !== 'boolean') {
|
|
51
|
+
invalid('allowInsecureLocalhost must be a boolean');
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(config.origins) || config.origins.length === 0)
|
|
54
|
+
invalid('origins are required');
|
|
55
|
+
const origins = [];
|
|
56
|
+
for (const origin of config.origins) {
|
|
57
|
+
if (typeof origin !== 'string')
|
|
58
|
+
invalid('origins must be strings');
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(origin);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
invalid('origin is not a URL');
|
|
65
|
+
}
|
|
66
|
+
if (url.origin !== origin || url.username || url.password)
|
|
67
|
+
invalid('origins must be exact origins');
|
|
68
|
+
const local = config.allowInsecureLocalhost === true &&
|
|
69
|
+
config.rpId === 'localhost' &&
|
|
70
|
+
url.hostname === 'localhost' &&
|
|
71
|
+
url.protocol === 'http:';
|
|
72
|
+
if (url.protocol !== 'https:' && !local)
|
|
73
|
+
invalid('origins require HTTPS outside explicit localhost development');
|
|
74
|
+
if (url.hostname !== config.rpId && !url.hostname.endsWith(`.${config.rpId}`)) {
|
|
75
|
+
invalid('origin must belong to the configured relying party');
|
|
76
|
+
}
|
|
77
|
+
if (origins.includes(origin))
|
|
78
|
+
invalid('origins must be unique');
|
|
79
|
+
origins.push(origin);
|
|
80
|
+
}
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
rpId: config.rpId,
|
|
83
|
+
rpName: config.rpName,
|
|
84
|
+
origins: Object.freeze(origins),
|
|
85
|
+
...(config.allowInsecureLocalhost === undefined
|
|
86
|
+
? {}
|
|
87
|
+
: { allowInsecureLocalhost: config.allowInsecureLocalhost }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { testAdapter } from '../storage/db-adapter.test-helper.js';
|
|
3
|
+
import { resolveServerConfig } from './config.js';
|
|
4
|
+
import { resolvePasskeyConfig } from './validate-passkey-config.js';
|
|
5
|
+
const unused = async () => {
|
|
6
|
+
throw new Error('Configuration validation must not execute provider operations');
|
|
7
|
+
};
|
|
8
|
+
const passwordOnly = {
|
|
9
|
+
capabilities: { passwordChange: true, magicLink: false, sso: false },
|
|
10
|
+
signInWithPassword: unused,
|
|
11
|
+
verifyAccessToken: unused,
|
|
12
|
+
refreshSession: unused,
|
|
13
|
+
revokeSession: unused,
|
|
14
|
+
resolveActor: unused,
|
|
15
|
+
};
|
|
16
|
+
const ceremonies = {
|
|
17
|
+
signInOptions: unused,
|
|
18
|
+
verifySignIn: unused,
|
|
19
|
+
passwordProof: unused,
|
|
20
|
+
proofOptions: unused,
|
|
21
|
+
verifyProof: unused,
|
|
22
|
+
enrollmentOptions: unused,
|
|
23
|
+
verifyEnrollment: unused,
|
|
24
|
+
listCredentials: unused,
|
|
25
|
+
renameCredential: unused,
|
|
26
|
+
removeCredential: unused,
|
|
27
|
+
};
|
|
28
|
+
const enabled = {
|
|
29
|
+
...passwordOnly,
|
|
30
|
+
capabilities: { ...passwordOnly.capabilities, passkeys: true },
|
|
31
|
+
passkeys: ceremonies,
|
|
32
|
+
};
|
|
33
|
+
const config = {
|
|
34
|
+
rpId: 'example.com',
|
|
35
|
+
rpName: 'Byline',
|
|
36
|
+
origins: ['https://example.com', 'https://edit.example.com:8443'],
|
|
37
|
+
};
|
|
38
|
+
describe('optional passkey configuration', () => {
|
|
39
|
+
it('keeps existing password-only and absent providers working', () => {
|
|
40
|
+
expect(resolvePasskeyConfig(undefined, passwordOnly)).toBeUndefined();
|
|
41
|
+
expect(resolvePasskeyConfig(undefined, undefined)).toBeUndefined();
|
|
42
|
+
expect(resolvePasskeyConfig(undefined, {
|
|
43
|
+
...passwordOnly,
|
|
44
|
+
capabilities: { ...passwordOnly.capabilities, passkeys: false },
|
|
45
|
+
})).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
it('validates and snapshots explicitly configured trust without invoking a ceremony', () => {
|
|
48
|
+
const origins = [...config.origins];
|
|
49
|
+
const resolved = resolvePasskeyConfig({ ...config, origins }, enabled);
|
|
50
|
+
origins.push('https://evil.example.com');
|
|
51
|
+
expect(resolved.origins).toEqual(config.origins);
|
|
52
|
+
expect(Object.isFrozen(resolved)).toBe(true);
|
|
53
|
+
expect(Object.isFrozen(resolved.origins)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it('runs validation through server configuration resolution', () => {
|
|
56
|
+
expect(() => resolveServerConfig({
|
|
57
|
+
collections: [],
|
|
58
|
+
db: testAdapter({}),
|
|
59
|
+
i18n: {
|
|
60
|
+
admin: { defaultLocale: 'en', locales: [] },
|
|
61
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
62
|
+
},
|
|
63
|
+
sessionProvider: passwordOnly,
|
|
64
|
+
passkeys: config,
|
|
65
|
+
})).toThrow('provider does not support passkeys');
|
|
66
|
+
});
|
|
67
|
+
it('requires configuration and capability to agree', () => {
|
|
68
|
+
expect(() => resolvePasskeyConfig(undefined, enabled)).toThrow('explicit relying-party');
|
|
69
|
+
expect(() => resolvePasskeyConfig(config, passwordOnly)).toThrow('does not support');
|
|
70
|
+
expect(() => resolvePasskeyConfig(config, undefined)).toThrow('does not support');
|
|
71
|
+
expect(() => resolvePasskeyConfig(config, { ...enabled, passkeys: undefined })).toThrow('does not support');
|
|
72
|
+
expect(() => resolvePasskeyConfig(undefined, { ...passwordOnly, passkeys: ceremonies })).toThrow('explicit relying-party');
|
|
73
|
+
});
|
|
74
|
+
it.each(Object.keys(ceremonies))('rejects a provider missing %s', (method) => {
|
|
75
|
+
const partial = { ...ceremonies, [method]: undefined };
|
|
76
|
+
expect(() => resolvePasskeyConfig(config, { ...enabled, passkeys: partial })).toThrow(`missing ${method}`);
|
|
77
|
+
});
|
|
78
|
+
it.each([
|
|
79
|
+
'',
|
|
80
|
+
'example.com:443',
|
|
81
|
+
'https://example.com',
|
|
82
|
+
'*.example.com',
|
|
83
|
+
'EXAMPLE.COM',
|
|
84
|
+
'example.com.',
|
|
85
|
+
'127.0.0.1',
|
|
86
|
+
'[::1]',
|
|
87
|
+
'com',
|
|
88
|
+
])('rejects invalid RP ID %s', (rpId) => {
|
|
89
|
+
expect(() => resolvePasskeyConfig({ ...config, rpId }, enabled)).toThrow('rpId');
|
|
90
|
+
});
|
|
91
|
+
it.each([
|
|
92
|
+
'http://example.com',
|
|
93
|
+
'https://example.com/',
|
|
94
|
+
'https://example.com/path',
|
|
95
|
+
'https://example.com?x=1',
|
|
96
|
+
'https://example.com#fragment',
|
|
97
|
+
'https://user@example.com',
|
|
98
|
+
'https://EXAMPLE.COM',
|
|
99
|
+
'https://example.com:443',
|
|
100
|
+
'https://evil-example.com',
|
|
101
|
+
'https://example.com.evil.com',
|
|
102
|
+
'https://other.com',
|
|
103
|
+
'null',
|
|
104
|
+
'*',
|
|
105
|
+
])('rejects an unsafe or noncanonical origin %s', (origin) => {
|
|
106
|
+
expect(() => resolvePasskeyConfig({ ...config, origins: [origin] }, enabled)).toThrow('Invalid passkey');
|
|
107
|
+
});
|
|
108
|
+
it('permits HTTP only for explicitly enabled localhost development', () => {
|
|
109
|
+
const local = { rpId: 'localhost', rpName: 'Local', origins: ['http://localhost:5173'] };
|
|
110
|
+
expect(() => resolvePasskeyConfig(local, enabled)).toThrow('HTTPS');
|
|
111
|
+
expect(resolvePasskeyConfig({ ...local, allowInsecureLocalhost: true }, enabled)?.origins).toEqual(local.origins);
|
|
112
|
+
expect(() => resolvePasskeyConfig({ ...config, origins: ['http://example.com'], allowInsecureLocalhost: true }, enabled)).toThrow('HTTPS');
|
|
113
|
+
expect(() => resolvePasskeyConfig({ ...local, origins: ['http://foo.localhost'], allowInsecureLocalhost: true }, enabled)).toThrow('HTTPS');
|
|
114
|
+
});
|
|
115
|
+
it.each([
|
|
116
|
+
{ rpName: '' },
|
|
117
|
+
{ rpName: ' ' },
|
|
118
|
+
{ rpName: 'x'.repeat(201) },
|
|
119
|
+
{ origins: [] },
|
|
120
|
+
{ origins: ['https://example.com', 'https://example.com'] },
|
|
121
|
+
{ origins: [42] },
|
|
122
|
+
{ allowInsecureLocalhost: 'true' },
|
|
123
|
+
{ relatedOrigins: true },
|
|
124
|
+
])('rejects malformed or unsupported configuration %j', (invalid) => {
|
|
125
|
+
expect(() => resolvePasskeyConfig({ ...config, ...invalid }, enabled)).toThrow('Invalid passkey');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('passkey host protection configuration', () => {
|
|
129
|
+
const server = {
|
|
130
|
+
collections: [],
|
|
131
|
+
db: testAdapter({}),
|
|
132
|
+
i18n: {
|
|
133
|
+
admin: { defaultLocale: 'en', locales: [] },
|
|
134
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
135
|
+
},
|
|
136
|
+
sessionProvider: enabled,
|
|
137
|
+
passkeys: config,
|
|
138
|
+
};
|
|
139
|
+
it('fails closed at boot without trusted IP and admission methods', () => {
|
|
140
|
+
expect(() => resolveServerConfig(server)).toThrow('passkeyRequests');
|
|
141
|
+
expect(() => resolveServerConfig({
|
|
142
|
+
...server,
|
|
143
|
+
passkeyRequests: { resolveClientIp: () => null, limiter: {} },
|
|
144
|
+
})).toThrow('passkeyRequests');
|
|
145
|
+
});
|
|
146
|
+
it('accepts explicit protection without invoking it', () => {
|
|
147
|
+
expect(resolveServerConfig({
|
|
148
|
+
...server,
|
|
149
|
+
passkeyRequests: { resolveClientIp: unused, limiter: { acquire: unused, consume: unused } },
|
|
150
|
+
}).passkeys).toEqual(config);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/dist/core.js
CHANGED
|
@@ -87,6 +87,7 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
87
87
|
tasks: configuredTasks,
|
|
88
88
|
adapter: composed.db,
|
|
89
89
|
passwordSignIn: resolvedConfig.passwordSignIn,
|
|
90
|
+
passkeyRequests: resolvedConfig.passkeyRequests,
|
|
90
91
|
});
|
|
91
92
|
// Freeze a snapshot of the validated task set: a new array of new, frozen
|
|
92
93
|
// objects, then freeze the array itself. Without this, `core.recurringTasks`
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { type CollectionGroupBucket, groupCollectionsForAdmin, } from './config/
|
|
|
5
5
|
export { resolveRoutes } from './config/routes.js';
|
|
6
6
|
export { validateAdminConfigs, validateBlockAdminConfigs, validateCollectionGroups, } from './config/validate-admin-configs.js';
|
|
7
7
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
8
|
+
export { resolvePasskeyConfig } from './config/validate-passkey-config.js';
|
|
8
9
|
export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
|
|
9
10
|
export * from './defaults/default-values.js';
|
|
10
11
|
export { getHostRequestBridge, type HostCookieSetOptions, type HostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { groupCollectionsForAdmin, } from './config/group-collections.js';
|
|
|
17
17
|
export { resolveRoutes } from './config/routes.js';
|
|
18
18
|
export { validateAdminConfigs, validateBlockAdminConfigs, validateCollectionGroups, } from './config/validate-admin-configs.js';
|
|
19
19
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
20
|
+
export { resolvePasskeyConfig } from './config/validate-passkey-config.js';
|
|
20
21
|
export { getBylineCore, initBylineCore } from './core.js';
|
|
21
22
|
export * from './defaults/default-values.js';
|
|
22
23
|
export { getHostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import type { PasswordSignInProtection } from '@byline/auth';
|
|
8
|
+
import type { PasskeyRequestProtection, PasswordSignInProtection } from '@byline/auth';
|
|
9
9
|
import type { ISchedulerStore, RecurringTaskDefinition } from './types.js';
|
|
10
10
|
/**
|
|
11
11
|
* Boot-time gate. Recurring tasks registered against an adapter that does not
|
|
@@ -14,6 +14,7 @@ import type { ISchedulerStore, RecurringTaskDefinition } from './types.js';
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function validateSchedulerConfig(params: {
|
|
16
16
|
passwordSignIn?: PasswordSignInProtection;
|
|
17
|
+
passkeyRequests?: PasskeyRequestProtection;
|
|
17
18
|
tasks?: readonly RecurringTaskDefinition[];
|
|
18
19
|
adapter: {
|
|
19
20
|
scheduler?: ISchedulerStore;
|
|
@@ -13,10 +13,15 @@ import { validateRecurringTasks } from './validate-tasks.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export function validateSchedulerConfig(params) {
|
|
15
15
|
const tasks = params.tasks ?? [];
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
for (const [name, protection] of [
|
|
17
|
+
['Password sign-in', params.passwordSignIn],
|
|
18
|
+
['Passkeys', params.passkeyRequests],
|
|
19
|
+
]) {
|
|
20
|
+
const cleanup = protection?.limiter.requiredCleanupTask;
|
|
21
|
+
if (cleanup && !tasks.some((task) => task.name === cleanup)) {
|
|
22
|
+
throw new Error(`${name} requires recurring task "${cleanup}". Register the limiter's cleanupTask ` +
|
|
23
|
+
'in recurringTasks and start the host scheduler (or invoke runDueTasks externally).');
|
|
24
|
+
}
|
|
20
25
|
}
|
|
21
26
|
if (tasks.length === 0)
|
|
22
27
|
return;
|
|
@@ -52,3 +52,20 @@ describe('validateSchedulerConfig', () => {
|
|
|
52
52
|
})).toThrow(/interval/i);
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
it('requires passkey counter cleanup and accepts one shared task for both limiters', () => {
|
|
56
|
+
const protection = {
|
|
57
|
+
resolveClientIp: () => '192.0.2.1',
|
|
58
|
+
limiter: {
|
|
59
|
+
requiredCleanupTask: task.name,
|
|
60
|
+
acquire: async () => () => { },
|
|
61
|
+
consume: async () => ({ allowed: true, retryAfterSeconds: 0 }),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
expect(() => validateSchedulerConfig({ passkeyRequests: protection, adapter: { scheduler: store } })).toThrow('Passkeys requires recurring task');
|
|
65
|
+
expect(() => validateSchedulerConfig({
|
|
66
|
+
passwordSignIn: protection,
|
|
67
|
+
passkeyRequests: protection,
|
|
68
|
+
tasks: [task],
|
|
69
|
+
adapter: { scheduler: store },
|
|
70
|
+
})).not.toThrow();
|
|
71
|
+
});
|
package/package.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "6.
|
|
5
|
+
"version": "6.8.0",
|
|
6
6
|
"engines": {
|
|
7
|
-
"node": ">=
|
|
7
|
+
"node": ">=22.22.2"
|
|
8
8
|
},
|
|
9
9
|
"description": "Byline CMS core package",
|
|
10
10
|
"keywords": [
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"dist"
|
|
83
83
|
],
|
|
84
84
|
"dependencies": {
|
|
85
|
-
"@byline/auth": "6.
|
|
85
|
+
"@byline/auth": "6.8.0",
|
|
86
86
|
"dotenv": "^18.0.8",
|
|
87
87
|
"pino": "^10.4.0",
|
|
88
88
|
"sharp": "^0.35.5",
|