@byline/core 3.21.0 → 4.1.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 (77) hide show
  1. package/dist/@types/collection-types.d.ts +66 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-types.d.ts +25 -1
  4. package/dist/@types/query-predicate.d.ts +3 -3
  5. package/dist/@types/search-types.d.ts +3 -4
  6. package/dist/@types/site-config.d.ts +51 -12
  7. package/dist/auth/apply-before-read.d.ts +23 -11
  8. package/dist/auth/apply-before-read.js +139 -33
  9. package/dist/auth/apply-before-read.test.node.js +241 -3
  10. package/dist/auth/index.d.ts +1 -1
  11. package/dist/auth/index.js +1 -1
  12. package/dist/auth/read-context-scope.d.ts +20 -0
  13. package/dist/auth/read-context-scope.js +48 -0
  14. package/dist/codegen/index.js +34 -3
  15. package/dist/codegen/index.test.node.js +20 -2
  16. package/dist/config/attach-hooks.d.ts +25 -0
  17. package/dist/config/attach-hooks.js +130 -0
  18. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  19. package/dist/config/attach-hooks.test.node.js +173 -0
  20. package/dist/config/config-hooks.test.node.d.ts +1 -0
  21. package/dist/config/config-hooks.test.node.js +56 -0
  22. package/dist/config/config.d.ts +9 -5
  23. package/dist/config/config.js +20 -2
  24. package/dist/config/routes.d.ts +5 -5
  25. package/dist/config/routes.js +42 -9
  26. package/dist/config/routes.test.node.d.ts +1 -0
  27. package/dist/config/routes.test.node.js +152 -0
  28. package/dist/core.d.ts +2 -2
  29. package/dist/core.js +20 -14
  30. package/dist/core.test.node.d.ts +1 -0
  31. package/dist/core.test.node.js +28 -0
  32. package/dist/host/host-request-bridge.d.ts +62 -0
  33. package/dist/host/host-request-bridge.js +38 -0
  34. package/dist/index.d.ts +5 -3
  35. package/dist/index.js +5 -3
  36. package/dist/lib/errors.d.ts +13 -0
  37. package/dist/lib/errors.js +14 -0
  38. package/dist/query/parse-where.d.ts +9 -0
  39. package/dist/query/parse-where.js +146 -2
  40. package/dist/query/parse-where.test.node.js +60 -1
  41. package/dist/services/collection-bootstrap.test.node.js +30 -59
  42. package/dist/services/discover-counter-groups.test.node.js +23 -0
  43. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  44. package/dist/services/document-lifecycle/audit.js +32 -1
  45. package/dist/services/document-lifecycle/create.js +13 -6
  46. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  47. package/dist/services/document-lifecycle/delete.js +91 -26
  48. package/dist/services/document-lifecycle/index.d.ts +1 -1
  49. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  50. package/dist/services/document-lifecycle/internals.js +22 -46
  51. package/dist/services/document-lifecycle/status.d.ts +1 -1
  52. package/dist/services/document-lifecycle/status.js +20 -3
  53. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  54. package/dist/services/document-lifecycle/system-fields.js +112 -74
  55. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  56. package/dist/services/document-lifecycle/tree.js +244 -123
  57. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  58. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  59. package/dist/services/document-lifecycle/update.js +2 -1
  60. package/dist/services/document-lifecycle.test.node.js +360 -16
  61. package/dist/services/document-read.d.ts +14 -6
  62. package/dist/services/document-read.js +47 -9
  63. package/dist/services/field-upload.test.node.js +70 -0
  64. package/dist/services/index.d.ts +2 -2
  65. package/dist/services/index.js +2 -2
  66. package/dist/services/populate.d.ts +7 -3
  67. package/dist/services/populate.js +180 -28
  68. package/dist/services/populate.test.node.js +59 -0
  69. package/dist/services/richtext-embed.d.ts +39 -2
  70. package/dist/services/richtext-embed.js +4 -34
  71. package/dist/services/richtext-embed.test.node.js +15 -0
  72. package/dist/services/richtext-populate.d.ts +20 -2
  73. package/dist/services/richtext-populate.js +160 -19
  74. package/dist/services/richtext-populate.test.node.js +523 -2
  75. package/dist/utils/root-relative-redirect.d.ts +6 -0
  76. package/dist/utils/root-relative-redirect.js +37 -0
  77. package/package.json +2 -2
@@ -28,13 +28,31 @@ describe('emitCollectionTypes', () => {
28
28
  it('emits a versioned header, verifiable body hash, and canonical formatting', () => {
29
29
  const { source, hash } = emitCollectionTypes([collection('plain', [])]);
30
30
  const body = source.split('\n').slice(4).join('\n');
31
- expect(source).toMatch(/^\/\/ Generated by @byline\/core\/codegen\n\/\/ Format version: 1\n\/\/ Hash: [a-f0-9]{64}\n\n/);
32
- expect(hash).toBe(createHash('sha256').update(`@byline/core/codegen:collection-types:v1\n${body}`).digest('hex'));
31
+ expect(source).toMatch(/^\/\/ Generated by @byline\/core\/codegen\n\/\/ Format version: 2\n\/\/ Hash: [a-f0-9]{64}\n\n/);
32
+ expect(hash).toBe(createHash('sha256').update(`@byline/core/codegen:collection-types:v2\n${body}`).digest('hex'));
33
33
  expect(source).toContain(`// Hash: ${hash}`);
34
34
  expect(source.endsWith('\n')).toBe(true);
35
35
  expect(source).not.toContain('\r');
36
36
  expect(source).not.toContain(';');
37
37
  });
38
+ it('declares every type inside the @byline/generated-types augmentation block', () => {
39
+ const { source } = emitCollectionTypes([collection('plain', [{ name: 'title', type: 'text' }])]);
40
+ expect(source).toContain("declare module '@byline/generated-types' {");
41
+ expect(source).toContain(' export type PlainFields = {');
42
+ expect(source).toContain(' export type CollectionPath = keyof CollectionFieldsByPath');
43
+ // No module-level type exports outside the augmentation blocks.
44
+ expect(source).not.toMatch(/^export /m);
45
+ });
46
+ it('emits the @byline/client Register augmentation referencing the generated registry', () => {
47
+ const { source } = emitCollectionTypes([collection('plain', [])]);
48
+ expect(source).toContain([
49
+ "declare module '@byline/client' {",
50
+ ' interface Register {',
51
+ " collections: import('@byline/generated-types').CollectionFieldsByPath",
52
+ ' }',
53
+ '}',
54
+ ].join('\n'));
55
+ });
38
56
  it('sorts collections and declarations without changing field, select, or block union order', () => {
39
57
  const alpha = collection('alpha', [
40
58
  {
@@ -0,0 +1,25 @@
1
+ import type { CollectionDefinition, CollectionHooks, CollectionHooksLoader, FileField, ImageField, ServerHooksConfig, UploadHooks, UploadHooksLoader } from '../@types/index.js';
2
+ type CollectionHookValue = CollectionHooks | CollectionHooksLoader;
3
+ type UploadHookValue = UploadHooks | UploadHooksLoader;
4
+ type UploadField = ImageField | FileField;
5
+ export type HookAttachmentOperation = {
6
+ kind: 'collection';
7
+ target: CollectionDefinition;
8
+ hooks: CollectionHookValue | undefined;
9
+ } | {
10
+ kind: 'upload';
11
+ target: UploadField;
12
+ hooks: UploadHookValue | undefined;
13
+ };
14
+ /**
15
+ * Validate a server hook registry and prepare its in-place definition updates.
16
+ * This phase never mutates definitions, so callers may safely abandon the plan
17
+ * when later initialization work fails.
18
+ */
19
+ export declare function prepareHookAttachment(config: {
20
+ collections: readonly CollectionDefinition[];
21
+ hooks?: ServerHooksConfig;
22
+ }): HookAttachmentOperation[];
23
+ /** Commit a previously validated attachment plan synchronously. */
24
+ export declare function commitHookAttachment(operations: readonly HookAttachmentOperation[]): void;
25
+ export {};
@@ -0,0 +1,130 @@
1
+ const registryCollectionHooks = new WeakMap();
2
+ const registryUploadHooks = new WeakMap();
3
+ /**
4
+ * Validate a server hook registry and prepare its in-place definition updates.
5
+ * This phase never mutates definitions, so callers may safely abandon the plan
6
+ * when later initialization work fails.
7
+ */
8
+ export function prepareHookAttachment(config) {
9
+ const collections = new Map();
10
+ const uploadFields = new Map();
11
+ for (const collection of config.collections) {
12
+ assertDotFreeSegment(collection.path, `collection path "${collection.path}"`);
13
+ collections.set(collection.path, collection);
14
+ indexUploadFields(collection, uploadFields);
15
+ }
16
+ const requestedCollections = new Map(Object.entries(config.hooks?.collections ?? {}));
17
+ const requestedUploads = new Map(Object.entries(config.hooks?.uploads ?? {}));
18
+ for (const path of requestedCollections.keys()) {
19
+ if (!collections.has(path)) {
20
+ throw new Error(`ServerConfig.hooks.collections references unknown collection "${path}".`);
21
+ }
22
+ }
23
+ for (const path of requestedUploads.keys()) {
24
+ if (!uploadFields.has(path)) {
25
+ throw new Error(`ServerConfig.hooks.uploads references unknown or non-upload field "${path}".`);
26
+ }
27
+ }
28
+ const operations = [];
29
+ for (const collection of config.collections) {
30
+ const requested = requestedCollections.get(collection.path);
31
+ planCollectionAttachment(collection, requested, operations);
32
+ }
33
+ for (const [path, field] of uploadFields) {
34
+ const requested = requestedUploads.get(path);
35
+ planUploadAttachment(path, field, requested, operations);
36
+ }
37
+ return operations;
38
+ }
39
+ /** Commit a previously validated attachment plan synchronously. */
40
+ export function commitHookAttachment(operations) {
41
+ for (const operation of operations) {
42
+ if (operation.kind === 'collection') {
43
+ operation.target.hooks = operation.hooks;
44
+ if (operation.hooks === undefined)
45
+ registryCollectionHooks.delete(operation.target);
46
+ else
47
+ registryCollectionHooks.set(operation.target, operation.hooks);
48
+ continue;
49
+ }
50
+ // Upload operations are prepared only for upload-capable fields.
51
+ const upload = operation.target.upload;
52
+ if (!upload)
53
+ continue;
54
+ upload.hooks = operation.hooks;
55
+ if (operation.hooks === undefined)
56
+ registryUploadHooks.delete(operation.target);
57
+ else
58
+ registryUploadHooks.set(operation.target, operation.hooks);
59
+ }
60
+ }
61
+ function planCollectionAttachment(collection, requested, operations) {
62
+ const owned = registryCollectionHooks.get(collection);
63
+ assertOwnershipIntact(`collection "${collection.path}"`, collection.hooks, owned);
64
+ if (requested === undefined) {
65
+ if (owned !== undefined) {
66
+ operations.push({ kind: 'collection', target: collection, hooks: undefined });
67
+ }
68
+ return;
69
+ }
70
+ if (owned === undefined && collection.hooks !== undefined) {
71
+ throw new Error(`ServerConfig.hooks.collections cannot replace definition-authored hooks on collection "${collection.path}".`);
72
+ }
73
+ if (requested !== collection.hooks) {
74
+ operations.push({ kind: 'collection', target: collection, hooks: requested });
75
+ }
76
+ }
77
+ function planUploadAttachment(path, field, requested, operations) {
78
+ const owned = registryUploadHooks.get(field);
79
+ assertOwnershipIntact(`upload field "${path}"`, field.upload?.hooks, owned);
80
+ if (requested === undefined) {
81
+ if (owned !== undefined) {
82
+ operations.push({ kind: 'upload', target: field, hooks: undefined });
83
+ }
84
+ return;
85
+ }
86
+ if (owned === undefined && field.upload?.hooks !== undefined) {
87
+ throw new Error(`ServerConfig.hooks.uploads cannot replace definition-authored hooks on upload field "${path}".`);
88
+ }
89
+ if (requested !== field.upload?.hooks) {
90
+ operations.push({ kind: 'upload', target: field, hooks: requested });
91
+ }
92
+ }
93
+ function assertOwnershipIntact(label, current, owned) {
94
+ if (owned !== undefined && current !== owned) {
95
+ throw new Error(`Registry-owned hooks on ${label} changed outside ServerConfig.hooks; refusing to overwrite definition state.`);
96
+ }
97
+ }
98
+ function indexUploadFields(collection, uploadFields) {
99
+ const leafPaths = new Map();
100
+ const walk = (fields, prefix) => {
101
+ for (const field of fields) {
102
+ assertDotFreeSegment(field.name, `field name "${field.name}" in collection "${collection.path}"`);
103
+ const path = [...prefix, field.name];
104
+ if ((field.type === 'file' || field.type === 'image') && field.upload !== undefined) {
105
+ const canonical = [collection.path, ...path].join('.');
106
+ const previous = leafPaths.get(field.name);
107
+ if (previous !== undefined) {
108
+ throw new Error(`Collection "${collection.path}" has duplicate upload-capable leaf name "${field.name}" at "${previous}" and "${canonical}". Upload leaf names must be unique within a collection.`);
109
+ }
110
+ leafPaths.set(field.name, canonical);
111
+ uploadFields.set(canonical, field);
112
+ }
113
+ if (field.type === 'group' || field.type === 'array') {
114
+ walk(field.fields, path);
115
+ }
116
+ else if (field.type === 'blocks') {
117
+ for (const block of field.blocks) {
118
+ assertDotFreeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
119
+ walk(block.fields, [...path, block.blockType]);
120
+ }
121
+ }
122
+ }
123
+ };
124
+ walk(collection.fields, []);
125
+ }
126
+ function assertDotFreeSegment(segment, label) {
127
+ if (segment.length === 0 || segment.includes('.')) {
128
+ throw new Error(`${label} must be a non-empty, dot-free hook registry path segment.`);
129
+ }
130
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { resolveHooks, resolveUploadHooks } from '../@types/collection-types.js';
3
+ import { initBylineCore } from '../core.js';
4
+ import { commitHookAttachment, prepareHookAttachment } from './attach-hooks.js';
5
+ function collection(path = 'documents') {
6
+ return {
7
+ path,
8
+ labels: { singular: 'Document', plural: 'Documents' },
9
+ fields: [{ name: 'title', type: 'text' }],
10
+ };
11
+ }
12
+ function nestedUploadCollection() {
13
+ const nested = { name: 'asset', type: 'file', upload: {} };
14
+ const arrayFile = { name: 'publicationFile', type: 'file', upload: {} };
15
+ const blockFile = { name: 'backgroundImage', type: 'file', upload: {} };
16
+ return {
17
+ definition: {
18
+ path: 'documents',
19
+ labels: { singular: 'Document', plural: 'Documents' },
20
+ fields: [
21
+ { name: 'metadata', type: 'group', fields: [nested] },
22
+ {
23
+ name: 'files',
24
+ type: 'array',
25
+ fields: [{ name: 'filesGroup', type: 'group', fields: [arrayFile] }],
26
+ },
27
+ {
28
+ name: 'content',
29
+ type: 'blocks',
30
+ blocks: [{ blockType: 'hero', fields: [blockFile] }],
31
+ },
32
+ ],
33
+ },
34
+ nested,
35
+ arrayFile,
36
+ blockFile,
37
+ };
38
+ }
39
+ describe('server hook attachment', () => {
40
+ it('prepares without mutation, then attaches collection and canonical nested upload hooks', async () => {
41
+ const { definition, nested, arrayFile, blockFile } = nestedUploadCollection();
42
+ const collectionHooks = {};
43
+ const nestedHooks = {};
44
+ const arrayLoader = vi.fn(() => Promise.resolve({}));
45
+ const blockHooks = {};
46
+ const operations = prepareHookAttachment({
47
+ collections: [definition],
48
+ hooks: {
49
+ collections: { documents: collectionHooks },
50
+ uploads: {
51
+ 'documents.metadata.asset': nestedHooks,
52
+ 'documents.files.filesGroup.publicationFile': arrayLoader,
53
+ 'documents.content.hero.backgroundImage': blockHooks,
54
+ },
55
+ },
56
+ });
57
+ expect(definition.hooks).toBeUndefined();
58
+ expect(nested.upload?.hooks).toBeUndefined();
59
+ expect(arrayFile.upload?.hooks).toBeUndefined();
60
+ expect(blockFile.upload?.hooks).toBeUndefined();
61
+ commitHookAttachment(operations);
62
+ expect(await resolveHooks(definition)).toBe(collectionHooks);
63
+ expect(await resolveUploadHooks(nested.upload?.hooks)).toBe(nestedHooks);
64
+ expect(await resolveUploadHooks(arrayFile.upload?.hooks)).toEqual({});
65
+ expect(arrayLoader).toHaveBeenCalledOnce();
66
+ expect(await resolveUploadHooks(blockFile.upload?.hooks)).toBe(blockHooks);
67
+ });
68
+ it('rejects unknown collection, field, block, and non-upload targets', () => {
69
+ const { definition } = nestedUploadCollection();
70
+ const cases = [
71
+ { collections: { missing: {} } },
72
+ { uploads: { 'missing.asset': {} } },
73
+ { uploads: { 'documents.metadata.missing': {} } },
74
+ { uploads: { 'documents.content.missing.backgroundImage': {} } },
75
+ { uploads: { 'documents.metadata': {} } },
76
+ ];
77
+ for (const hooks of cases) {
78
+ expect(() => prepareHookAttachment({ collections: [definition], hooks })).toThrow(/unknown|non-upload/);
79
+ }
80
+ });
81
+ it('rejects definition-authored hooks and ownership drift', () => {
82
+ const definition = collection();
83
+ const authored = {};
84
+ definition.hooks = authored;
85
+ expect(prepareHookAttachment({ collections: [definition] })).toEqual([]);
86
+ expect(definition.hooks).toBe(authored);
87
+ expect(() => prepareHookAttachment({
88
+ collections: [definition],
89
+ hooks: { collections: { documents: {} } },
90
+ })).toThrow(/definition-authored/);
91
+ definition.hooks = undefined;
92
+ const first = {};
93
+ commitHookAttachment(prepareHookAttachment({
94
+ collections: [definition],
95
+ hooks: { collections: { documents: first } },
96
+ }));
97
+ definition.hooks = authored;
98
+ expect(() => prepareHookAttachment({
99
+ collections: [definition],
100
+ hooks: { collections: { documents: {} } },
101
+ })).toThrow(/changed outside ServerConfig\.hooks/);
102
+ const { definition: uploadDefinition, nested } = nestedUploadCollection();
103
+ if (!nested.upload)
104
+ throw new Error('test fixture must be upload-capable');
105
+ const authoredUpload = {};
106
+ nested.upload.hooks = authoredUpload;
107
+ expect(prepareHookAttachment({ collections: [uploadDefinition] })).toEqual([]);
108
+ expect(nested.upload.hooks).toBe(authoredUpload);
109
+ expect(() => prepareHookAttachment({
110
+ collections: [uploadDefinition],
111
+ hooks: { uploads: { 'documents.metadata.asset': {} } },
112
+ })).toThrow(/definition-authored/);
113
+ });
114
+ it('replaces and removes registry-owned hooks across successful reinitialization', () => {
115
+ const definition = collection();
116
+ const first = vi.fn(() => Promise.resolve({}));
117
+ const second = vi.fn(() => Promise.resolve({}));
118
+ commitHookAttachment(prepareHookAttachment({
119
+ collections: [definition],
120
+ hooks: { collections: { documents: first } },
121
+ }));
122
+ expect(definition.hooks).toBe(first);
123
+ commitHookAttachment(prepareHookAttachment({
124
+ collections: [definition],
125
+ hooks: { collections: { documents: second } },
126
+ }));
127
+ expect(definition.hooks).toBe(second);
128
+ commitHookAttachment(prepareHookAttachment({ collections: [definition] }));
129
+ expect(definition.hooks).toBeUndefined();
130
+ });
131
+ it('leaves prior registry hooks intact when init fails after preparation', async () => {
132
+ const definition = collection();
133
+ const stable = {};
134
+ const replacement = {};
135
+ commitHookAttachment(prepareHookAttachment({
136
+ collections: [definition],
137
+ hooks: { collections: { documents: stable } },
138
+ }));
139
+ const invalid = {
140
+ serverURL: 'https://example.test',
141
+ collections: [definition],
142
+ db: {},
143
+ hooks: { collections: { documents: replacement } },
144
+ i18n: {
145
+ interface: { defaultLocale: 'en', locales: ['en'] },
146
+ content: { defaultLocale: 'en', locales: [] },
147
+ },
148
+ };
149
+ await expect(initBylineCore(invalid, {})).rejects.toThrow(/translations bundle/i);
150
+ expect(definition.hooks).toBe(stable);
151
+ });
152
+ it('rejects dotted registry segments and duplicate upload leaf names at boot', () => {
153
+ expect(() => prepareHookAttachment({ collections: [collection('docs.archive')] })).toThrow(/dot-free/);
154
+ const dottedField = collection();
155
+ dottedField.fields = [{ name: 'meta.asset', type: 'file', upload: {} }];
156
+ expect(() => prepareHookAttachment({ collections: [dottedField] })).toThrow(/dot-free/);
157
+ const dottedBlock = collection();
158
+ dottedBlock.fields = [
159
+ {
160
+ name: 'content',
161
+ type: 'blocks',
162
+ blocks: [{ blockType: 'hero.large', fields: [] }],
163
+ },
164
+ ];
165
+ expect(() => prepareHookAttachment({ collections: [dottedBlock] })).toThrow(/dot-free/);
166
+ const duplicate = collection();
167
+ duplicate.fields = [
168
+ { name: 'primary', type: 'group', fields: [{ name: 'asset', type: 'file', upload: {} }] },
169
+ { name: 'secondary', type: 'group', fields: [{ name: 'asset', type: 'image', upload: {} }] },
170
+ ];
171
+ expect(() => prepareHookAttachment({ collections: [duplicate] })).toThrow(/duplicate upload-capable leaf name/);
172
+ });
173
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { defineClientConfig, defineServerConfig, getCollectionDefinition } from './config.js';
3
+ const SERVER_CONFIG = Symbol.for('__byline_server_config__');
4
+ const CLIENT_CONFIG = Symbol.for('__byline_client_config__');
5
+ const previousServer = globalThis[SERVER_CONFIG];
6
+ const previousClient = globalThis[CLIENT_CONFIG];
7
+ afterEach(() => {
8
+ const globals = globalThis;
9
+ if (previousServer === undefined)
10
+ delete globals[SERVER_CONFIG];
11
+ else
12
+ globals[SERVER_CONFIG] = previousServer;
13
+ if (previousClient === undefined)
14
+ delete globals[CLIENT_CONFIG];
15
+ else
16
+ globals[CLIENT_CONFIG] = previousClient;
17
+ });
18
+ function definition(path) {
19
+ return {
20
+ path,
21
+ labels: { singular: path, plural: path },
22
+ fields: [{ name: 'title', type: 'text' }],
23
+ };
24
+ }
25
+ const i18n = {
26
+ interface: { defaultLocale: 'en', locales: [] },
27
+ content: { defaultLocale: 'en', locales: [] },
28
+ };
29
+ describe('collection definition config preference', () => {
30
+ it('uses client definitions when only client config is registered', () => {
31
+ const globals = globalThis;
32
+ delete globals[SERVER_CONFIG];
33
+ delete globals[CLIENT_CONFIG];
34
+ const client = definition('client-only');
35
+ defineClientConfig({ serverURL: 'https://example.test', collections: [client], i18n });
36
+ expect(getCollectionDefinition('client-only')).toBe(client);
37
+ });
38
+ it('prefers hook-attached server definitions when both configs are registered', () => {
39
+ const globals = globalThis;
40
+ delete globals[SERVER_CONFIG];
41
+ delete globals[CLIENT_CONFIG];
42
+ const client = definition('docs');
43
+ const server = definition('docs');
44
+ const hooks = {};
45
+ defineClientConfig({ serverURL: 'https://example.test', collections: [client], i18n });
46
+ defineServerConfig({
47
+ serverURL: 'https://example.test',
48
+ collections: [server],
49
+ db: {},
50
+ hooks: { collections: { docs: hooks } },
51
+ i18n,
52
+ });
53
+ expect(getCollectionDefinition('docs')).toBe(server);
54
+ expect(getCollectionDefinition('docs')?.hooks).toBe(hooks);
55
+ });
56
+ });
@@ -1,4 +1,4 @@
1
- import type { ClientConfig, CollectionAdminConfig, CollectionDefinition, ColumnDefinition, ServerConfig } from '../@types/index.js';
1
+ import type { ClientConfig, CollectionAdminConfig, CollectionDefinition, ColumnDefinition, ResolvedClientConfig, ResolvedServerConfig, ServerConfig } from '../@types/index.js';
2
2
  /**
3
3
  * Resolve a collection definition by `path`. Returns `null` either when
4
4
  * no config has been registered (e.g. unit tests, isolated tooling) or
@@ -23,10 +23,14 @@ export declare const getCollectionAdminConfig: (slug: string) => CollectionAdmin
23
23
  * working until it is removed.
24
24
  */
25
25
  export declare const resolveItemViewColumns: (config: CollectionAdminConfig | null | undefined) => ColumnDefinition[] | undefined;
26
- export declare function defineClientConfig(config: ClientConfig): void;
27
- export declare function defineServerConfig(config: ServerConfig): void;
28
- export declare function getClientConfig(): ClientConfig;
29
- export declare function getServerConfig(): ServerConfig;
26
+ export declare function defineClientConfig(config: ClientConfig): ResolvedClientConfig;
27
+ export declare function defineServerConfig<TAdminStore = unknown>(config: ServerConfig<TAdminStore>): ResolvedServerConfig<TAdminStore>;
28
+ /** Resolve and validate server config without replacing the registered singleton. */
29
+ export declare function resolveServerConfig<TAdminStore = unknown>(config: ServerConfig<TAdminStore>): ResolvedServerConfig<TAdminStore>;
30
+ /** Internal commit step used after initialization has completed successfully. */
31
+ export declare function registerServerConfig<TAdminStore = unknown>(config: ResolvedServerConfig<TAdminStore>): ResolvedServerConfig<TAdminStore>;
32
+ export declare function getClientConfig(): ResolvedClientConfig;
33
+ export declare function getServerConfig(): ResolvedServerConfig;
30
34
  /**
31
35
  * Order a set of locale codes by their position in the configured content
32
36
  * locale list. The order source is `i18n.content.locales` — the authoritative,
@@ -1,3 +1,5 @@
1
+ import { commitHookAttachment, prepareHookAttachment, } from './attach-hooks.js';
2
+ import { resolveRoutes } from './routes.js';
1
3
  import { validateAdminConfigs } from './validate-admin-configs.js';
2
4
  import { validateCollections } from './validate-collections.js';
3
5
  // ---------------------------------------------------------------------------
@@ -10,6 +12,7 @@ import { validateCollections } from './validate-collections.js';
10
12
  const BYLINE_SERVER_CONFIG = Symbol.for('__byline_server_config__');
11
13
  const BYLINE_CLIENT_CONFIG = Symbol.for('__byline_client_config__');
12
14
  const BYLINE_CORE = Symbol.for('__byline_core__');
15
+ const preparedHookAttachments = new WeakMap();
13
16
  function getServerConfigInstance() {
14
17
  return globalThis[BYLINE_SERVER_CONFIG] ?? null;
15
18
  }
@@ -43,7 +46,7 @@ function setBylineCoreInstance(core) {
43
46
  * still throw the loud "Byline has not been configured" error.
44
47
  */
45
48
  export const getCollectionDefinition = (path) => {
46
- const config = getClientConfigInstance() ?? getServerConfigInstance();
49
+ const config = getServerConfigInstance() ?? getClientConfigInstance();
47
50
  if (config == null)
48
51
  return null;
49
52
  return config.collections.find((collection) => collection.path === path) ?? null;
@@ -68,11 +71,26 @@ export const resolveItemViewColumns = (config) => config?.itemView ?? config?.pi
68
71
  export function defineClientConfig(config) {
69
72
  validateCollections(config.collections);
70
73
  validateAdminConfigs(config.admin, config.collections);
71
- setClientConfigInstance(config);
74
+ const resolved = { ...config, routes: resolveRoutes(config.routes) };
75
+ setClientConfigInstance(resolved);
76
+ return resolved;
72
77
  }
73
78
  export function defineServerConfig(config) {
79
+ return registerServerConfig(resolveServerConfig(config));
80
+ }
81
+ /** Resolve and validate server config without replacing the registered singleton. */
82
+ export function resolveServerConfig(config) {
74
83
  validateCollections(config.collections);
84
+ const resolved = { ...config, routes: resolveRoutes(config.routes) };
85
+ preparedHookAttachments.set(resolved, prepareHookAttachment(resolved));
86
+ return resolved;
87
+ }
88
+ /** Internal commit step used after initialization has completed successfully. */
89
+ export function registerServerConfig(config) {
90
+ const attachments = preparedHookAttachments.get(config) ?? prepareHookAttachment(config);
91
+ commitHookAttachment(attachments);
75
92
  setServerConfigInstance(config);
93
+ return config;
76
94
  }
77
95
  export function getClientConfig() {
78
96
  const clientConfig = getClientConfigInstance();
@@ -5,11 +5,11 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { RoutesConfig } from '../@types/site-config.js';
8
+ import type { RoutesConfig, RoutesConfigInput } from '../@types/site-config.js';
9
9
  /**
10
10
  * Merge a user-supplied (potentially partial) routes config with the
11
- * built-in defaults. Empty / unset keys fall back to `'/admin'` and
12
- * `'/api'`. Returns a fully-populated `RoutesConfig` so consumers don't
13
- * need null checks at every call site.
11
+ * built-in defaults. Empty / unset keys fall back to `'/admin'`, `'/api'`,
12
+ * and `'/sign-in'`. Returns a fully-populated `RoutesConfig` so consumers
13
+ * don't need null checks at every call site.
14
14
  */
15
- export declare function resolveRoutes(routes?: Partial<RoutesConfig>): RoutesConfig;
15
+ export declare function resolveRoutes(routes?: RoutesConfigInput): RoutesConfig;
@@ -5,21 +5,54 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- /** Default route segments. Installations override either key on the
9
- * config object; callers read the merged shape via `resolveRoutes()`. */
8
+ import { normalizeRootRelativeRedirect } from '../utils/root-relative-redirect.js';
9
+ /** Default route paths resolved at an explicit configuration boundary. */
10
10
  const DEFAULT_ROUTES = {
11
11
  admin: '/admin',
12
12
  api: '/api',
13
+ signIn: '/sign-in',
13
14
  };
15
+ function normalizeRoutePath(value, fallback, name) {
16
+ const input = value?.trim() || fallback;
17
+ if (input.includes('\\') ||
18
+ input.includes('?') ||
19
+ input.includes('#') ||
20
+ input.includes('%') ||
21
+ input.includes(':') ||
22
+ /\s/.test(input)) {
23
+ throw new Error(`routes.${name} must be an unencoded URL path without query, hash, whitespace, backslashes, or control characters`);
24
+ }
25
+ const segments = input.split('/').filter(Boolean);
26
+ if (segments.length === 0 || segments.some((segment) => segment === '.' || segment === '..')) {
27
+ throw new Error(`routes.${name} must contain at least one URL path segment`);
28
+ }
29
+ const normalized = `/${segments.join('/')}`;
30
+ if (normalizeRootRelativeRedirect(normalized) !== normalized) {
31
+ throw new Error(`routes.${name} contains an unsafe URL code point`);
32
+ }
33
+ return normalized;
34
+ }
14
35
  /**
15
36
  * Merge a user-supplied (potentially partial) routes config with the
16
- * built-in defaults. Empty / unset keys fall back to `'/admin'` and
17
- * `'/api'`. Returns a fully-populated `RoutesConfig` so consumers don't
18
- * need null checks at every call site.
37
+ * built-in defaults. Empty / unset keys fall back to `'/admin'`, `'/api'`,
38
+ * and `'/sign-in'`. Returns a fully-populated `RoutesConfig` so consumers
39
+ * don't need null checks at every call site.
19
40
  */
20
41
  export function resolveRoutes(routes) {
21
- return {
22
- admin: routes?.admin ?? DEFAULT_ROUTES.admin,
23
- api: routes?.api ?? DEFAULT_ROUTES.api,
24
- };
42
+ const admin = normalizeRoutePath(routes?.admin, DEFAULT_ROUTES.admin, 'admin');
43
+ const api = normalizeRoutePath(routes?.api, DEFAULT_ROUTES.api, 'api');
44
+ if (routes?.signIn?.trim().startsWith('//')) {
45
+ throw new Error('routes.signIn must not be a protocol-relative URL');
46
+ }
47
+ const signIn = normalizeRoutePath(routes?.signIn, DEFAULT_ROUTES.signIn, 'signIn');
48
+ if (pathsOverlap(admin, api)) {
49
+ throw new Error('routes.admin and routes.api must use separate URL path trees');
50
+ }
51
+ if (pathsOverlap(signIn, admin) || pathsOverlap(signIn, api)) {
52
+ throw new Error('routes.signIn must be outside the admin and API route trees');
53
+ }
54
+ return Object.freeze({ admin, api, signIn });
55
+ }
56
+ function pathsOverlap(left, right) {
57
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
25
58
  }
@@ -0,0 +1 @@
1
+ export {};