@byline/core 6.7.2 → 6.9.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/admin-types.d.ts +6 -6
- package/dist/@types/admin-types.js +3 -3
- package/dist/@types/form-admin-config.test.node.js +1 -1
- package/dist/@types/singleton-admin-config.test.node.js +8 -5
- package/dist/@types/site-config.d.ts +15 -1
- package/dist/config/config.d.ts +2 -2
- package/dist/config/config.js +14 -7
- package/dist/config/group-collections.js +1 -1
- package/dist/config/group-collections.test.node.js +6 -6
- package/dist/config/validate-admin-configs.d.ts +1 -1
- package/dist/config/validate-admin-configs.js +10 -10
- package/dist/config/validate-admin-configs.test.node.js +22 -22
- 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/paths/path-dialects.test.node.js +2 -2
- package/dist/paths/paths.test.node.js +1 -1
- 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/dist/services/document-lifecycle/copy-to-locale.js +2 -1
- package/dist/services/document-lifecycle/delete-locale.js +27 -0
- package/dist/services/document-lifecycle/internals.d.ts +3 -2
- package/dist/services/document-lifecycle/internals.js +3 -3
- package/dist/services/document-lifecycle/merge-write-locale.d.ts +12 -0
- package/dist/services/document-lifecycle/merge-write-locale.js +53 -0
- package/dist/services/document-lifecycle/persistence.d.ts +4 -1
- package/dist/services/document-lifecycle/persistence.js +23 -1
- package/dist/services/document-lifecycle/update.js +2 -2
- package/dist/services/document-lifecycle.test.node.js +4 -2
- package/dist/services/singleton-lifecycle/copy-to-locale.js +1 -1
- package/dist/services/singleton-lifecycle/update.js +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/storage-flatten.js +8 -2
- package/dist/storage/storage-restore.d.ts +15 -2
- package/dist/storage/storage-restore.js +66 -15
- package/dist/storage/unavailable-blocks.test.node.d.ts +8 -0
- package/dist/storage/unavailable-blocks.test.node.js +132 -0
- package/dist/validation/document-fields.js +7 -4
- package/package.json +3 -3
|
@@ -251,14 +251,14 @@ export interface FormAdminConfig {
|
|
|
251
251
|
* Admin UI configuration for a collection.
|
|
252
252
|
* This is the presentation/UI layer — separate from the data schema.
|
|
253
253
|
*
|
|
254
|
-
* Linked to a `CollectionDefinition` by `
|
|
254
|
+
* Linked to a `CollectionDefinition` by `path` matching the collection's
|
|
255
255
|
* `path`.
|
|
256
256
|
*/
|
|
257
257
|
export interface CollectionAdminConfig<T = any> extends FormAdminConfig {
|
|
258
258
|
/** Explicit resource discriminator. Stamped by `defineAdmin`. */
|
|
259
259
|
singleton?: false;
|
|
260
260
|
/** Must match the `path` of the corresponding `CollectionDefinition`. */
|
|
261
|
-
|
|
261
|
+
path: string;
|
|
262
262
|
/**
|
|
263
263
|
* Dashboard group this collection belongs to. Must name an entry in
|
|
264
264
|
* `AdminConfig.collectionGroups`; an unknown name throws at startup.
|
|
@@ -447,7 +447,7 @@ export interface SingletonAdminConfig<T = any> extends FormAdminConfig {
|
|
|
447
447
|
/** Explicit resource discriminator. Stamped by `defineSingletonAdmin`. */
|
|
448
448
|
singleton: true;
|
|
449
449
|
/** Must match the `path` of the corresponding `SingletonDefinition`. */
|
|
450
|
-
|
|
450
|
+
path: string;
|
|
451
451
|
/**
|
|
452
452
|
* Dashboard group this resource belongs to. The registry remains named
|
|
453
453
|
* `AdminConfig.collectionGroups` for backwards compatibility.
|
|
@@ -487,14 +487,14 @@ export interface ListActionComponentProps {
|
|
|
487
487
|
}
|
|
488
488
|
/**
|
|
489
489
|
* Type-safe factory for creating a `CollectionAdminConfig` linked to a schema.
|
|
490
|
-
* Sets `
|
|
490
|
+
* Sets `path` from the schema's `path`.
|
|
491
491
|
*/
|
|
492
|
-
export declare function defineAdmin<T = any>(schema: MultiCollectionDefinition, config: Omit<CollectionAdminConfig<T>, '
|
|
492
|
+
export declare function defineAdmin<T = any>(schema: MultiCollectionDefinition, config: Omit<CollectionAdminConfig<T>, 'path' | 'singleton'>): CollectionAdminConfig<T>;
|
|
493
493
|
/**
|
|
494
494
|
* Type-safe factory for a `SingletonAdminConfig` linked to a singleton schema.
|
|
495
495
|
* Sets both the schema path and resource discriminator for authors.
|
|
496
496
|
*/
|
|
497
|
-
export declare function defineSingletonAdmin<T = any>(schema: SingletonDefinition, config: Omit<SingletonAdminConfig<T>, '
|
|
497
|
+
export declare function defineSingletonAdmin<T = any>(schema: SingletonDefinition, config: Omit<SingletonAdminConfig<T>, 'path' | 'singleton'>): SingletonAdminConfig<T>;
|
|
498
498
|
/**
|
|
499
499
|
* Admin UI configuration for a block — the block-scoped analogue of
|
|
500
500
|
* `CollectionAdminConfig`, carrying **rendering overrides only**.
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
9
|
* Type-safe factory for creating a `CollectionAdminConfig` linked to a schema.
|
|
10
|
-
* Sets `
|
|
10
|
+
* Sets `path` from the schema's `path`.
|
|
11
11
|
*/
|
|
12
12
|
export function defineAdmin(schema, config) {
|
|
13
13
|
return {
|
|
14
14
|
...config,
|
|
15
15
|
singleton: false,
|
|
16
|
-
|
|
16
|
+
path: schema.path,
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
@@ -24,7 +24,7 @@ export function defineSingletonAdmin(schema, config) {
|
|
|
24
24
|
return {
|
|
25
25
|
...config,
|
|
26
26
|
singleton: true,
|
|
27
|
-
|
|
27
|
+
path: schema.path,
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
@@ -24,19 +24,22 @@ describe('singleton admin config', () => {
|
|
|
24
24
|
const singletonAdmin = defineSingletonAdmin(settings, {});
|
|
25
25
|
expect(collectionAdmin.singleton).toBe(false);
|
|
26
26
|
expect(singletonAdmin.singleton).toBe(true);
|
|
27
|
-
expect(
|
|
27
|
+
expect(collectionAdmin.path).toBe(pages.path);
|
|
28
|
+
expect(singletonAdmin.path).toBe(settings.path);
|
|
29
|
+
expect(collectionAdmin).not.toHaveProperty('slug');
|
|
30
|
+
expect(singletonAdmin).not.toHaveProperty('slug');
|
|
28
31
|
});
|
|
29
32
|
it('does not let untyped config input override factory-owned identity', () => {
|
|
30
33
|
const collectionAdmin = defineAdmin(pages, {
|
|
31
34
|
singleton: true,
|
|
32
|
-
|
|
35
|
+
path: 'wrong-collection',
|
|
33
36
|
});
|
|
34
37
|
const singletonAdmin = defineSingletonAdmin(settings, {
|
|
35
38
|
singleton: false,
|
|
36
|
-
|
|
39
|
+
path: 'wrong-singleton',
|
|
37
40
|
});
|
|
38
|
-
expect(collectionAdmin).toMatchObject({ singleton: false,
|
|
39
|
-
expect(singletonAdmin).toMatchObject({ singleton: true,
|
|
41
|
+
expect(collectionAdmin).toMatchObject({ singleton: false, path: pages.path });
|
|
42
|
+
expect(singletonAdmin).toMatchObject({ singleton: true, path: settings.path });
|
|
40
43
|
});
|
|
41
44
|
it('shares the reusable form composition contract', () => {
|
|
42
45
|
const singletonAdmin = defineSingletonAdmin(settings, {
|
|
@@ -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.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ import type { AdminConfig, CollectionAdminConfig, CollectionDefinition, Resolved
|
|
|
11
11
|
* still throw the loud "Byline has not been configured" error.
|
|
12
12
|
*/
|
|
13
13
|
export declare const getCollectionDefinition: (path: string) => CollectionDefinition | null;
|
|
14
|
-
export declare const getCollectionAdminConfig: (
|
|
15
|
-
export declare const getSingletonAdminConfig: (
|
|
14
|
+
export declare const getCollectionAdminConfig: (path: string) => CollectionAdminConfig | null;
|
|
15
|
+
export declare const getSingletonAdminConfig: (path: string) => SingletonAdminConfig | null;
|
|
16
16
|
export declare function defineAdminConfig(config: AdminConfig): ResolvedAdminConfig;
|
|
17
17
|
export declare function defineServerConfig<TAdminStore = unknown>(config: ServerConfig<TAdminStore>): ResolvedServerConfig<TAdminStore>;
|
|
18
18
|
/** Resolve and validate server config without replacing the registered singleton. */
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -51,18 +52,18 @@ export const getCollectionDefinition = (path) => {
|
|
|
51
52
|
return null;
|
|
52
53
|
return config.collections.find((collection) => collection.path === path) ?? null;
|
|
53
54
|
};
|
|
54
|
-
const getAdminResourceConfig = (
|
|
55
|
+
const getAdminResourceConfig = (path) => {
|
|
55
56
|
const adminConfig = getAdminConfigInstance();
|
|
56
57
|
if (adminConfig == null)
|
|
57
58
|
return null;
|
|
58
|
-
return adminConfig.admin?.find((admin) => admin.
|
|
59
|
+
return adminConfig.admin?.find((admin) => admin.path === path) ?? null;
|
|
59
60
|
};
|
|
60
|
-
export const getCollectionAdminConfig = (
|
|
61
|
-
const admin = getAdminResourceConfig(
|
|
61
|
+
export const getCollectionAdminConfig = (path) => {
|
|
62
|
+
const admin = getAdminResourceConfig(path);
|
|
62
63
|
return admin?.singleton === true ? null : admin;
|
|
63
64
|
};
|
|
64
|
-
export const getSingletonAdminConfig = (
|
|
65
|
-
const admin = getAdminResourceConfig(
|
|
65
|
+
export const getSingletonAdminConfig = (path) => {
|
|
66
|
+
const admin = getAdminResourceConfig(path);
|
|
66
67
|
return admin?.singleton === true ? admin : null;
|
|
67
68
|
};
|
|
68
69
|
export function defineAdminConfig(config) {
|
|
@@ -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
|
}
|
|
@@ -33,7 +33,7 @@ export function groupCollectionsForAdmin(collections, admin, collectionGroups) {
|
|
|
33
33
|
const groupByCollectionPath = new Map();
|
|
34
34
|
for (const entry of admin ?? []) {
|
|
35
35
|
if (entry.group != null)
|
|
36
|
-
groupByCollectionPath.set(entry.
|
|
36
|
+
groupByCollectionPath.set(entry.path, entry.group);
|
|
37
37
|
}
|
|
38
38
|
const membersByGroup = new Map();
|
|
39
39
|
for (const group of collectionGroups ?? []) {
|
|
@@ -29,9 +29,9 @@ const groups = [
|
|
|
29
29
|
{ name: 'taxonomy', label: 'Taxonomies' },
|
|
30
30
|
];
|
|
31
31
|
const admin = [
|
|
32
|
-
{
|
|
33
|
-
{
|
|
34
|
-
{
|
|
32
|
+
{ path: 'images', group: 'media' },
|
|
33
|
+
{ path: 'authors', group: 'authorities' },
|
|
34
|
+
{ path: 'categories', group: 'taxonomy' },
|
|
35
35
|
];
|
|
36
36
|
describe('groupCollectionsForAdmin', () => {
|
|
37
37
|
it('returns one ungrouped bucket when no registry is declared', () => {
|
|
@@ -67,13 +67,13 @@ describe('groupCollectionsForAdmin', () => {
|
|
|
67
67
|
});
|
|
68
68
|
it('preserves collection declaration order within a bucket', () => {
|
|
69
69
|
const more = define('videos');
|
|
70
|
-
const result = groupCollectionsForAdmin([more, images], [...admin, {
|
|
70
|
+
const result = groupCollectionsForAdmin([more, images], [...admin, { path: 'videos', group: 'media' }], groups);
|
|
71
71
|
expect(result[0]?.collections).toEqual([more, images]);
|
|
72
72
|
});
|
|
73
73
|
it('groups singleton and multi-collection resources together in declaration order', () => {
|
|
74
74
|
const resourceAdmin = [
|
|
75
75
|
...admin,
|
|
76
|
-
{ singleton: true,
|
|
76
|
+
{ singleton: true, path: settings.path, group: 'media' },
|
|
77
77
|
];
|
|
78
78
|
const result = groupCollectionsForAdmin([images, settings], resourceAdmin, groups);
|
|
79
79
|
expect(result[0]?.collections).toEqual([images, settings]);
|
|
@@ -85,7 +85,7 @@ describe('groupCollectionsForAdmin', () => {
|
|
|
85
85
|
it('treats an undeclared group name as ungrouped rather than throwing', () => {
|
|
86
86
|
// Boot validation rejects this configuration, but the function stays total
|
|
87
87
|
// so a renderer can never crash on a stale or hand-built config object.
|
|
88
|
-
const result = groupCollectionsForAdmin([pages], [{
|
|
88
|
+
const result = groupCollectionsForAdmin([pages], [{ path: 'pages', group: 'ghost' }], groups);
|
|
89
89
|
expect(result).toEqual([{ name: null, label: null, collections: [pages] }]);
|
|
90
90
|
});
|
|
91
91
|
it('ignores admin configs whose collection is not registered', () => {
|
|
@@ -25,7 +25,7 @@ export declare function validateCollectionGroups(collectionGroups: readonly Coll
|
|
|
25
25
|
* Validate every admin config in a configuration.
|
|
26
26
|
*
|
|
27
27
|
* Enforced rules (per admin config):
|
|
28
|
-
* 1.
|
|
28
|
+
* 1. Path pairing — `admin.path` matches exactly one `collection.path`.
|
|
29
29
|
* 2. Placement — when `layout` is present, every top-level schema field
|
|
30
30
|
* name appears exactly once across the union of all `tabSet.tabs[].fields`,
|
|
31
31
|
* all `row.fields`, all `group.fields`, `layout.main`, and `layout.sidebar`.
|
|
@@ -91,14 +91,14 @@ export function validateCollectionGroups(collectionGroups, admins) {
|
|
|
91
91
|
const known = declared.size === 0
|
|
92
92
|
? '`collectionGroups` was not declared, or is empty'
|
|
93
93
|
: `declared groups: ${[...declared].map((name) => `"${name}"`).join(', ')}`;
|
|
94
|
-
throw new Error(`Admin
|
|
94
|
+
throw new Error(`Admin config for path "${admin.path}": \`group: '${admin.group}'\` does not name a declared collection group (${known}). Add it to \`AdminConfig.collectionGroups\`, or remove the \`group\` property.`);
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
/**
|
|
98
98
|
* Validate every admin config in a configuration.
|
|
99
99
|
*
|
|
100
100
|
* Enforced rules (per admin config):
|
|
101
|
-
* 1.
|
|
101
|
+
* 1. Path pairing — `admin.path` matches exactly one `collection.path`.
|
|
102
102
|
* 2. Placement — when `layout` is present, every top-level schema field
|
|
103
103
|
* name appears exactly once across the union of all `tabSet.tabs[].fields`,
|
|
104
104
|
* all `row.fields`, all `group.fields`, `layout.main`, and `layout.sidebar`.
|
|
@@ -210,24 +210,24 @@ export function validateBlockAdminConfigs(blockAdmins, collections) {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
function validateOne(admin, collectionsByPath) {
|
|
213
|
-
// Rule 1 —
|
|
214
|
-
const collection = collectionsByPath.get(admin.
|
|
213
|
+
// Rule 1 — path pairing.
|
|
214
|
+
const collection = collectionsByPath.get(admin.path);
|
|
215
215
|
if (collection == null) {
|
|
216
216
|
if (admin.singleton === true) {
|
|
217
|
-
throw new Error(`Singleton admin config "${admin.
|
|
217
|
+
throw new Error(`Singleton admin config "${admin.path}" has no matching singleton definition (no singleton with \`path: '${admin.path}'\` was registered).`);
|
|
218
218
|
}
|
|
219
|
-
throw new Error(`Admin config "${admin.
|
|
219
|
+
throw new Error(`Admin config "${admin.path}" has no matching collection (no collection with \`path: '${admin.path}'\` was registered).`);
|
|
220
220
|
}
|
|
221
221
|
if (admin.singleton === true && !isSingleton(collection)) {
|
|
222
|
-
throw new Error(`Singleton admin config "${admin.
|
|
222
|
+
throw new Error(`Singleton admin config "${admin.path}" targets a collection definition. Register it with \`defineAdmin\`, or point it at a singleton definition.`);
|
|
223
223
|
}
|
|
224
224
|
if (admin.singleton !== true && isSingleton(collection)) {
|
|
225
|
-
throw new Error(`Collection admin config "${admin.
|
|
225
|
+
throw new Error(`Collection admin config "${admin.path}" targets a singleton definition. Register it with \`defineSingletonAdmin\`, or point it at a multi-collection definition.`);
|
|
226
226
|
}
|
|
227
|
-
const
|
|
227
|
+
const path = admin.path;
|
|
228
228
|
const fail = (msg) => {
|
|
229
229
|
const resource = admin.singleton === true ? 'Singleton' : 'Collection';
|
|
230
|
-
throw new Error(`${resource} "${
|
|
230
|
+
throw new Error(`${resource} "${path}": ${msg}`);
|
|
231
231
|
};
|
|
232
232
|
if (admin.singleton === true) {
|
|
233
233
|
for (const key of COLLECTION_ONLY_ADMIN_KEYS) {
|
|
@@ -21,7 +21,7 @@ const collection = {
|
|
|
21
21
|
],
|
|
22
22
|
};
|
|
23
23
|
const baseAdmin = {
|
|
24
|
-
|
|
24
|
+
path: 'news',
|
|
25
25
|
tabSets: [
|
|
26
26
|
{
|
|
27
27
|
name: 'main',
|
|
@@ -80,20 +80,20 @@ describe('validateAdminConfigs', () => {
|
|
|
80
80
|
expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/lockPath.*not allowed on a singleton admin config/);
|
|
81
81
|
});
|
|
82
82
|
it('rejects a singleton admin config targeting a multi-collection', () => {
|
|
83
|
-
const admin = { ...defineSingletonAdmin(singleton, {}),
|
|
83
|
+
const admin = { ...defineSingletonAdmin(singleton, {}), path: collection.path };
|
|
84
84
|
expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/targets a collection definition/);
|
|
85
85
|
});
|
|
86
86
|
it('rejects a singleton admin config with no matching definition', () => {
|
|
87
|
-
const admin = { ...defineSingletonAdmin(singleton, {}),
|
|
87
|
+
const admin = { ...defineSingletonAdmin(singleton, {}), path: 'missing-settings' };
|
|
88
88
|
expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/no matching singleton definition/);
|
|
89
89
|
});
|
|
90
90
|
it('rejects a multi-collection admin config targeting a singleton', () => {
|
|
91
|
-
const admin = {
|
|
91
|
+
const admin = { path: singleton.path };
|
|
92
92
|
expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/targets a singleton definition/);
|
|
93
93
|
});
|
|
94
|
-
// Rule 1 —
|
|
95
|
-
it('rejects an admin config whose
|
|
96
|
-
const orphan = {
|
|
94
|
+
// Rule 1 — path pairing.
|
|
95
|
+
it('rejects an admin config whose path has no matching collection', () => {
|
|
96
|
+
const orphan = { path: 'nonexistent', layout: { main: [] } };
|
|
97
97
|
expect(() => validateAdminConfigs([orphan], [collection])).toThrow(/no matching collection/);
|
|
98
98
|
});
|
|
99
99
|
// Rule 2 — placement bookkeeping.
|
|
@@ -177,7 +177,7 @@ describe('validateAdminConfigs', () => {
|
|
|
177
177
|
// Rule 5 — nesting.
|
|
178
178
|
it('rejects a row whose fields include a non-field primitive', () => {
|
|
179
179
|
const admin = {
|
|
180
|
-
|
|
180
|
+
path: 'news',
|
|
181
181
|
tabSets: [
|
|
182
182
|
{
|
|
183
183
|
name: 'main',
|
|
@@ -198,7 +198,7 @@ describe('validateAdminConfigs', () => {
|
|
|
198
198
|
});
|
|
199
199
|
it('rejects a tab set referenced from inside a tab', () => {
|
|
200
200
|
const admin = {
|
|
201
|
-
|
|
201
|
+
path: 'news',
|
|
202
202
|
tabSets: [
|
|
203
203
|
{
|
|
204
204
|
name: 'inner',
|
|
@@ -226,7 +226,7 @@ describe('validateAdminConfigs', () => {
|
|
|
226
226
|
});
|
|
227
227
|
it('rejects a group nested inside another group', () => {
|
|
228
228
|
const admin = {
|
|
229
|
-
|
|
229
|
+
path: 'news',
|
|
230
230
|
tabSets: [
|
|
231
231
|
{
|
|
232
232
|
name: 'main',
|
|
@@ -290,7 +290,7 @@ describe('validateAdminConfigs', () => {
|
|
|
290
290
|
],
|
|
291
291
|
};
|
|
292
292
|
const nestedAdmin = (fields) => ({
|
|
293
|
-
|
|
293
|
+
path: 'library',
|
|
294
294
|
fields: fields,
|
|
295
295
|
});
|
|
296
296
|
it('accepts a dotted fields key through array and group declarations', () => {
|
|
@@ -386,14 +386,14 @@ describe('validateAdminConfigs', () => {
|
|
|
386
386
|
// Layout omitted — bookkeeping skipped, primitive declarations still checked.
|
|
387
387
|
it('skips placement bookkeeping when layout is omitted', () => {
|
|
388
388
|
const admin = {
|
|
389
|
-
|
|
389
|
+
path: 'news',
|
|
390
390
|
// No layout — renderer will synthesise a default.
|
|
391
391
|
};
|
|
392
392
|
expect(() => validateAdminConfigs([admin], [collection])).not.toThrow();
|
|
393
393
|
});
|
|
394
394
|
it('still rejects a primitive collision when layout is omitted', () => {
|
|
395
395
|
const admin = {
|
|
396
|
-
|
|
396
|
+
path: 'news',
|
|
397
397
|
rows: [
|
|
398
398
|
{ name: 'titles', fields: ['text1', 'text2'] },
|
|
399
399
|
// 'title' is also a schema field — collision still caught.
|
|
@@ -518,34 +518,34 @@ describe('validateAdminConfigs — collection groups', () => {
|
|
|
518
518
|
{ name: 'taxonomy', label: 'Taxonomies' },
|
|
519
519
|
];
|
|
520
520
|
it('accepts a valid registry and a valid reference', () => {
|
|
521
|
-
expect(() => validateAdminConfigs([{
|
|
521
|
+
expect(() => validateAdminConfigs([{ path: 'news', group: 'media' }], [collection], groups)).not.toThrow();
|
|
522
522
|
});
|
|
523
523
|
it('accepts a collection with no group when a registry is declared', () => {
|
|
524
|
-
expect(() => validateAdminConfigs([{
|
|
524
|
+
expect(() => validateAdminConfigs([{ path: 'news' }], [collection], groups)).not.toThrow();
|
|
525
525
|
});
|
|
526
526
|
it('is a no-op when no registry and no group references are present', () => {
|
|
527
|
-
expect(() => validateAdminConfigs([{
|
|
527
|
+
expect(() => validateAdminConfigs([{ path: 'news' }], [collection])).not.toThrow();
|
|
528
528
|
});
|
|
529
529
|
it('rejects a duplicate group name', () => {
|
|
530
|
-
expect(() => validateAdminConfigs([{
|
|
530
|
+
expect(() => validateAdminConfigs([{ path: 'news' }], [collection], [
|
|
531
531
|
{ name: 'media', label: 'Media' },
|
|
532
532
|
{ name: 'media', label: 'Media Library' },
|
|
533
533
|
])).toThrow(/declared more than once/);
|
|
534
534
|
});
|
|
535
535
|
it('rejects a blank group name', () => {
|
|
536
|
-
expect(() => validateAdminConfigs([{
|
|
536
|
+
expect(() => validateAdminConfigs([{ path: 'news' }], [collection], [{ name: ' ', label: 'Media' }])).toThrow(/blank `name`/);
|
|
537
537
|
});
|
|
538
538
|
it('rejects a blank group label', () => {
|
|
539
|
-
expect(() => validateAdminConfigs([{
|
|
539
|
+
expect(() => validateAdminConfigs([{ path: 'news' }], [collection], [{ name: 'media', label: '' }])).toThrow(/blank `label`/);
|
|
540
540
|
});
|
|
541
541
|
it('rejects a group reference that names no declared group', () => {
|
|
542
|
-
expect(() => validateAdminConfigs([{
|
|
542
|
+
expect(() => validateAdminConfigs([{ path: 'news', group: 'medai' }], [collection], groups)).toThrow(/does not name a declared collection group/);
|
|
543
543
|
});
|
|
544
544
|
it('names the declared groups in the unresolved-reference error', () => {
|
|
545
|
-
expect(() => validateAdminConfigs([{
|
|
545
|
+
expect(() => validateAdminConfigs([{ path: 'news', group: 'medai' }], [collection], groups)).toThrow(/"media", "taxonomy"/);
|
|
546
546
|
});
|
|
547
547
|
it('rejects a group reference when no registry was declared at all', () => {
|
|
548
|
-
expect(() => validateAdminConfigs([{
|
|
548
|
+
expect(() => validateAdminConfigs([{ path: 'news', group: 'media' }], [collection])).toThrow(/was not declared/);
|
|
549
549
|
});
|
|
550
550
|
// Registry sanity must not be skipped by the `admins` early return.
|
|
551
551
|
it('validates the registry even when there are no admin configs', () => {
|
|
@@ -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 {};
|