@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.
Files changed (44) hide show
  1. package/dist/@types/admin-types.d.ts +6 -6
  2. package/dist/@types/admin-types.js +3 -3
  3. package/dist/@types/form-admin-config.test.node.js +1 -1
  4. package/dist/@types/singleton-admin-config.test.node.js +8 -5
  5. package/dist/@types/site-config.d.ts +15 -1
  6. package/dist/config/config.d.ts +2 -2
  7. package/dist/config/config.js +14 -7
  8. package/dist/config/group-collections.js +1 -1
  9. package/dist/config/group-collections.test.node.js +6 -6
  10. package/dist/config/validate-admin-configs.d.ts +1 -1
  11. package/dist/config/validate-admin-configs.js +10 -10
  12. package/dist/config/validate-admin-configs.test.node.js +22 -22
  13. package/dist/config/validate-passkey-config.d.ts +4 -0
  14. package/dist/config/validate-passkey-config.js +89 -0
  15. package/dist/config/validate-passkey-config.test.node.d.ts +1 -0
  16. package/dist/config/validate-passkey-config.test.node.js +152 -0
  17. package/dist/core.js +1 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/paths/path-dialects.test.node.js +2 -2
  21. package/dist/paths/paths.test.node.js +1 -1
  22. package/dist/scheduler/validate-scheduler-config.d.ts +2 -1
  23. package/dist/scheduler/validate-scheduler-config.js +9 -4
  24. package/dist/scheduler/validate-scheduler-config.test.node.js +17 -0
  25. package/dist/services/document-lifecycle/copy-to-locale.js +2 -1
  26. package/dist/services/document-lifecycle/delete-locale.js +27 -0
  27. package/dist/services/document-lifecycle/internals.d.ts +3 -2
  28. package/dist/services/document-lifecycle/internals.js +3 -3
  29. package/dist/services/document-lifecycle/merge-write-locale.d.ts +12 -0
  30. package/dist/services/document-lifecycle/merge-write-locale.js +53 -0
  31. package/dist/services/document-lifecycle/persistence.d.ts +4 -1
  32. package/dist/services/document-lifecycle/persistence.js +23 -1
  33. package/dist/services/document-lifecycle/update.js +2 -2
  34. package/dist/services/document-lifecycle.test.node.js +4 -2
  35. package/dist/services/singleton-lifecycle/copy-to-locale.js +1 -1
  36. package/dist/services/singleton-lifecycle/update.js +1 -1
  37. package/dist/storage/index.d.ts +1 -1
  38. package/dist/storage/storage-flatten.js +8 -2
  39. package/dist/storage/storage-restore.d.ts +15 -2
  40. package/dist/storage/storage-restore.js +66 -15
  41. package/dist/storage/unavailable-blocks.test.node.d.ts +8 -0
  42. package/dist/storage/unavailable-blocks.test.node.js +132 -0
  43. package/dist/validation/document-fields.js +7 -4
  44. 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 `slug` matching the collection's
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
- slug: string;
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
- slug: string;
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 `slug` from the schema's `path`.
490
+ * Sets `path` from the schema's `path`.
491
491
  */
492
- export declare function defineAdmin<T = any>(schema: MultiCollectionDefinition, config: Omit<CollectionAdminConfig<T>, 'slug' | 'singleton'>): 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>, 'slug' | 'singleton'>): 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 `slug` from the schema's `path`.
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
- slug: schema.path,
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
- slug: schema.path,
27
+ path: schema.path,
28
28
  };
29
29
  }
30
30
  /**
@@ -17,7 +17,7 @@ describe('FormAdminConfig', () => {
17
17
  });
18
18
  it('is satisfied by a full CollectionAdminConfig', () => {
19
19
  const collection = {
20
- slug: 'pages',
20
+ path: 'pages',
21
21
  layout: { main: [] },
22
22
  };
23
23
  const asForm = collection;
@@ -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(singletonAdmin.slug).toBe(settings.path);
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
- slug: 'wrong-collection',
35
+ path: 'wrong-collection',
33
36
  });
34
37
  const singletonAdmin = defineSingletonAdmin(settings, {
35
38
  singleton: false,
36
- slug: 'wrong-singleton',
39
+ path: 'wrong-singleton',
37
40
  });
38
- expect(collectionAdmin).toMatchObject({ singleton: false, slug: pages.path });
39
- expect(singletonAdmin).toMatchObject({ singleton: true, slug: settings.path });
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
+ }
@@ -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: (slug: string) => CollectionAdminConfig | null;
15
- export declare const getSingletonAdminConfig: (slug: string) => SingletonAdminConfig | null;
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. */
@@ -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 = (slug) => {
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.slug === slug) ?? null;
59
+ return adminConfig.admin?.find((admin) => admin.path === path) ?? null;
59
60
  };
60
- export const getCollectionAdminConfig = (slug) => {
61
- const admin = getAdminResourceConfig(slug);
61
+ export const getCollectionAdminConfig = (path) => {
62
+ const admin = getAdminResourceConfig(path);
62
63
  return admin?.singleton === true ? null : admin;
63
64
  };
64
- export const getSingletonAdminConfig = (slug) => {
65
- const admin = getAdminResourceConfig(slug);
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 resolved = { ...config, routes: resolveRoutes(config.routes) };
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.slug, entry.group);
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
- { slug: 'images', group: 'media' },
33
- { slug: 'authors', group: 'authorities' },
34
- { slug: 'categories', group: 'taxonomy' },
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, { slug: 'videos', group: 'media' }], groups);
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, slug: settings.path, group: 'media' },
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], [{ slug: 'pages', group: 'ghost' }], groups);
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. Slug pairing — `admin.slug` matches exactly one `collection.path`.
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 resource "${admin.slug}": \`group: '${admin.group}'\` does not name a declared collection group (${known}). Add it to \`AdminConfig.collectionGroups\`, or remove the \`group\` property.`);
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. Slug pairing — `admin.slug` matches exactly one `collection.path`.
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 — slug pairing.
214
- const collection = collectionsByPath.get(admin.slug);
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.slug}" has no matching singleton definition (no singleton with \`path: '${admin.slug}'\` was registered).`);
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.slug}" has no matching collection (no collection with \`path: '${admin.slug}'\` was registered).`);
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.slug}" targets a collection definition. Register it with \`defineAdmin\`, or point it at a singleton definition.`);
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.slug}" targets a singleton definition. Register it with \`defineSingletonAdmin\`, or point it at a multi-collection definition.`);
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 slug = admin.slug;
227
+ const path = admin.path;
228
228
  const fail = (msg) => {
229
229
  const resource = admin.singleton === true ? 'Singleton' : 'Collection';
230
- throw new Error(`${resource} "${slug}": ${msg}`);
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
- slug: 'news',
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, {}), slug: collection.path };
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, {}), slug: 'missing-settings' };
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 = { slug: singleton.path };
91
+ const admin = { path: singleton.path };
92
92
  expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/targets a singleton definition/);
93
93
  });
94
- // Rule 1 — slug pairing.
95
- it('rejects an admin config whose slug has no matching collection', () => {
96
- const orphan = { slug: 'nonexistent', layout: { main: [] } };
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
- slug: 'news',
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
- slug: 'news',
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
- slug: 'news',
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
- slug: 'library',
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
- slug: 'news',
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
- slug: 'news',
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([{ slug: 'news', group: 'media' }], [collection], groups)).not.toThrow();
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([{ slug: 'news' }], [collection], groups)).not.toThrow();
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([{ slug: 'news' }], [collection])).not.toThrow();
527
+ expect(() => validateAdminConfigs([{ path: 'news' }], [collection])).not.toThrow();
528
528
  });
529
529
  it('rejects a duplicate group name', () => {
530
- expect(() => validateAdminConfigs([{ slug: 'news' }], [collection], [
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([{ slug: 'news' }], [collection], [{ name: ' ', label: 'Media' }])).toThrow(/blank `name`/);
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([{ slug: 'news' }], [collection], [{ name: 'media', label: '' }])).toThrow(/blank `label`/);
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([{ slug: 'news', group: 'medai' }], [collection], groups)).toThrow(/does not name a declared collection group/);
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([{ slug: 'news', group: 'medai' }], [collection], groups)).toThrow(/"media", "taxonomy"/);
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([{ slug: 'news', group: 'media' }], [collection])).toThrow(/was not declared/);
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 {};