@byline/core 3.20.4 → 4.0.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 (103) hide show
  1. package/dist/@types/collection-types.d.ts +72 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-data-types.d.ts +8 -10
  4. package/dist/@types/field-data-types.js +1 -1
  5. package/dist/@types/field-data-types.test.node.d.ts +1 -0
  6. package/dist/@types/field-data-types.test.node.js +70 -0
  7. package/dist/@types/field-types.d.ts +25 -1
  8. package/dist/@types/index.d.ts +1 -0
  9. package/dist/@types/index.js +1 -0
  10. package/dist/@types/query-predicate.d.ts +3 -3
  11. package/dist/@types/relation-types.d.ts +38 -0
  12. package/dist/@types/relation-types.js +8 -0
  13. package/dist/@types/search-types.d.ts +3 -4
  14. package/dist/@types/site-config.d.ts +52 -13
  15. package/dist/auth/apply-before-read.d.ts +23 -11
  16. package/dist/auth/apply-before-read.js +139 -33
  17. package/dist/auth/apply-before-read.test.node.js +241 -3
  18. package/dist/auth/index.d.ts +1 -1
  19. package/dist/auth/index.js +1 -1
  20. package/dist/auth/read-context-scope.d.ts +20 -0
  21. package/dist/auth/read-context-scope.js +48 -0
  22. package/dist/codegen/fixtures/all-fields.d.ts +342 -0
  23. package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
  24. package/dist/codegen/fixtures/all-fields.expected.js +1 -0
  25. package/dist/codegen/fixtures/all-fields.js +94 -0
  26. package/dist/codegen/index.d.ts +16 -0
  27. package/dist/codegen/index.js +431 -0
  28. package/dist/codegen/index.test.node.d.ts +1 -0
  29. package/dist/codegen/index.test.node.js +230 -0
  30. package/dist/config/attach-hooks.d.ts +25 -0
  31. package/dist/config/attach-hooks.js +130 -0
  32. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  33. package/dist/config/attach-hooks.test.node.js +173 -0
  34. package/dist/config/config-hooks.test.node.d.ts +1 -0
  35. package/dist/config/config-hooks.test.node.js +56 -0
  36. package/dist/config/config.d.ts +9 -5
  37. package/dist/config/config.js +20 -2
  38. package/dist/config/routes.d.ts +5 -5
  39. package/dist/config/routes.js +42 -9
  40. package/dist/config/routes.test.node.d.ts +1 -0
  41. package/dist/config/routes.test.node.js +152 -0
  42. package/dist/core.d.ts +3 -3
  43. package/dist/core.js +20 -14
  44. package/dist/core.test.node.d.ts +1 -0
  45. package/dist/core.test.node.js +28 -0
  46. package/dist/index.d.ts +4 -3
  47. package/dist/index.js +4 -3
  48. package/dist/lib/errors.d.ts +13 -0
  49. package/dist/lib/errors.js +14 -0
  50. package/dist/query/parse-where.d.ts +10 -1
  51. package/dist/query/parse-where.js +146 -2
  52. package/dist/query/parse-where.test.node.js +60 -1
  53. package/dist/schemas/zod/builder.js +7 -3
  54. package/dist/schemas/zod/builder.test.node.d.ts +1 -0
  55. package/dist/schemas/zod/builder.test.node.js +49 -0
  56. package/dist/services/collection-bootstrap.d.ts +1 -1
  57. package/dist/services/collection-bootstrap.test.node.js +30 -59
  58. package/dist/services/discover-counter-groups.d.ts +1 -1
  59. package/dist/services/discover-counter-groups.test.node.js +23 -0
  60. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  61. package/dist/services/document-lifecycle/audit.js +32 -1
  62. package/dist/services/document-lifecycle/create.d.ts +2 -2
  63. package/dist/services/document-lifecycle/create.js +18 -8
  64. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  65. package/dist/services/document-lifecycle/delete.js +91 -26
  66. package/dist/services/document-lifecycle/index.d.ts +1 -1
  67. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  68. package/dist/services/document-lifecycle/internals.js +22 -46
  69. package/dist/services/document-lifecycle/status.d.ts +1 -1
  70. package/dist/services/document-lifecycle/status.js +20 -3
  71. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  72. package/dist/services/document-lifecycle/system-fields.js +112 -74
  73. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  74. package/dist/services/document-lifecycle/tree.js +244 -123
  75. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  76. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  77. package/dist/services/document-lifecycle/update.d.ts +4 -4
  78. package/dist/services/document-lifecycle/update.js +11 -5
  79. package/dist/services/document-lifecycle.test.node.js +426 -16
  80. package/dist/services/document-read.d.ts +14 -6
  81. package/dist/services/document-read.js +47 -9
  82. package/dist/services/field-upload.test.node.js +70 -0
  83. package/dist/services/index.d.ts +3 -2
  84. package/dist/services/index.js +3 -2
  85. package/dist/services/normalize-numeric-fields.d.ts +23 -0
  86. package/dist/services/normalize-numeric-fields.js +89 -0
  87. package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
  88. package/dist/services/normalize-numeric-fields.test.node.js +99 -0
  89. package/dist/services/populate.d.ts +9 -23
  90. package/dist/services/populate.js +180 -28
  91. package/dist/services/populate.test.node.js +59 -0
  92. package/dist/services/richtext-embed.d.ts +39 -2
  93. package/dist/services/richtext-embed.js +4 -34
  94. package/dist/services/richtext-embed.test.node.js +15 -0
  95. package/dist/services/richtext-populate.d.ts +21 -3
  96. package/dist/services/richtext-populate.js +160 -19
  97. package/dist/services/richtext-populate.test.node.js +523 -2
  98. package/dist/services/validate-search-config.d.ts +1 -1
  99. package/dist/storage/collection-fingerprint.js +6 -0
  100. package/dist/storage/collection-fingerprint.test.node.js +21 -0
  101. package/dist/utils/root-relative-redirect.d.ts +6 -0
  102. package/dist/utils/root-relative-redirect.js +37 -0
  103. package/package.json +7 -2
@@ -5,8 +5,11 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { describe, expect, it } from 'vitest';
9
- import { collectRichTextLeaves, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
8
+ import { AdminAuth, createRequestContext } from '@byline/auth';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { applyBeforeRead } from '../auth/apply-before-read.js';
11
+ import { createReadContext, populateDocuments } from './populate.js';
12
+ import { collectRichTextLeaves, createRichTextDocumentReader, populateRichTextFields, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
10
13
  // ---------------------------------------------------------------------------
11
14
  // Fixture — a synthetic collection that exercises every nesting type the
12
15
  // walker needs to handle: top-level, group, array, blocks (with two block
@@ -109,6 +112,524 @@ describe('collectRichTextLeaves', () => {
109
112
  expect(out).toEqual([]);
110
113
  });
111
114
  });
115
+ describe('createRichTextDocumentReader', () => {
116
+ const target = {
117
+ path: 'media',
118
+ labels: { singular: 'Media', plural: 'Media' },
119
+ fields: [
120
+ { name: 'title', type: 'text', label: 'Title' },
121
+ { name: 'tenant', type: 'text', label: 'Tenant' },
122
+ ],
123
+ hooks: { beforeRead: () => ({ tenant: 'alice' }) },
124
+ };
125
+ function dbMock() {
126
+ const getCollectionByPath = vi.fn().mockResolvedValue({ id: 'media-id', path: 'media' });
127
+ const getDocumentsByDocumentIds = vi.fn().mockResolvedValue([]);
128
+ return {
129
+ db: {
130
+ queries: {
131
+ collections: { getCollectionByPath },
132
+ documents: { getDocumentsByDocumentIds },
133
+ },
134
+ },
135
+ getCollectionByPath,
136
+ getDocumentsByDocumentIds,
137
+ };
138
+ }
139
+ it('asserts target ability before adapter access', async () => {
140
+ const { db, getCollectionByPath, getDocumentsByDocumentIds } = dbMock();
141
+ const read = createRichTextDocumentReader({
142
+ db,
143
+ collections: [target],
144
+ requestContext: createRequestContext({
145
+ actor: new AdminAuth({ id: 'denied', abilities: ['collections.posts.read'] }),
146
+ readMode: 'published',
147
+ }),
148
+ readContext: createReadContext(),
149
+ readMode: 'published',
150
+ });
151
+ await expect(read({ collectionPath: 'media', documentIds: ['m1'] })).rejects.toMatchObject({
152
+ code: 'ERR_FORBIDDEN',
153
+ });
154
+ expect(getCollectionByPath).not.toHaveBeenCalled();
155
+ expect(getDocumentsByDocumentIds).not.toHaveBeenCalled();
156
+ });
157
+ it('applies target beforeRead filters to the batch', async () => {
158
+ const { db, getDocumentsByDocumentIds } = dbMock();
159
+ const read = createRichTextDocumentReader({
160
+ db,
161
+ collections: [target],
162
+ requestContext: createRequestContext({
163
+ actor: new AdminAuth({ id: 'reader', abilities: ['collections.media.read'] }),
164
+ readMode: 'published',
165
+ }),
166
+ readContext: createReadContext(),
167
+ readMode: 'published',
168
+ });
169
+ await read({ collectionPath: 'media', documentIds: ['m1'] });
170
+ expect(getDocumentsByDocumentIds).toHaveBeenCalledWith(expect.objectContaining({
171
+ collection_id: 'media-id',
172
+ document_ids: ['m1'],
173
+ readMode: 'published',
174
+ filters: [expect.objectContaining({ kind: 'field', fieldName: 'tenant', value: 'alice' })],
175
+ }));
176
+ });
177
+ it('isolates direct readers by default and shares compiled filters only with an explicit domain', async () => {
178
+ const beforeRead = vi.fn(() => ({ tenant: 'alice' }));
179
+ const scopedTarget = {
180
+ ...target,
181
+ hooks: { beforeRead },
182
+ };
183
+ const { db, getDocumentsByDocumentIds } = dbMock();
184
+ const readContext = createReadContext();
185
+ const requestContext = createRequestContext({
186
+ actor: new AdminAuth({ id: 'reader', abilities: ['collections.media.read'] }),
187
+ readMode: 'published',
188
+ });
189
+ const reader = (securityDomain) => createRichTextDocumentReader({
190
+ db,
191
+ collections: [scopedTarget],
192
+ requestContext,
193
+ readContext,
194
+ readMode: 'published',
195
+ securityDomain,
196
+ });
197
+ await reader()({ collectionPath: 'media', documentIds: ['implicit-a'] });
198
+ await reader()({ collectionPath: 'media', documentIds: ['implicit-b'] });
199
+ const sharedDomain = {};
200
+ await reader(sharedDomain)({ collectionPath: 'media', documentIds: ['explicit-a'] });
201
+ await reader(sharedDomain)({ collectionPath: 'media', documentIds: ['explicit-b'] });
202
+ const calls = getDocumentsByDocumentIds.mock.calls;
203
+ expect(calls[1]?.[0].filters).not.toBe(calls[0]?.[0].filters);
204
+ expect(calls[3]?.[0].filters).toBe(calls[2]?.[0].filters);
205
+ expect(beforeRead).toHaveBeenCalledTimes(3);
206
+ });
207
+ it('reuses compiled security filters across relation and rich-text target population', async () => {
208
+ const source = {
209
+ path: 'posts',
210
+ labels: { singular: 'Post', plural: 'Posts' },
211
+ fields: [
212
+ {
213
+ name: 'media',
214
+ type: 'relation',
215
+ label: 'Media',
216
+ targetCollection: 'media',
217
+ },
218
+ ],
219
+ };
220
+ const { db, getDocumentsByDocumentIds } = dbMock();
221
+ const readContext = createReadContext();
222
+ const securityDomain = {};
223
+ const requestContext = createRequestContext({
224
+ actor: new AdminAuth({
225
+ id: 'reader',
226
+ abilities: ['collections.posts.read', 'collections.media.read'],
227
+ }),
228
+ readMode: 'published',
229
+ });
230
+ await populateDocuments({
231
+ db,
232
+ collections: [source, target],
233
+ collectionId: 'posts',
234
+ documents: [
235
+ {
236
+ document_id: 'p1',
237
+ fields: {
238
+ media: { targetCollectionId: 'media', targetDocumentId: 'm1' },
239
+ },
240
+ },
241
+ ],
242
+ populate: true,
243
+ requestContext,
244
+ readContext,
245
+ readMode: 'published',
246
+ securityDomain,
247
+ });
248
+ const relationFilters = getDocumentsByDocumentIds.mock.calls[0]?.[0].filters;
249
+ const read = createRichTextDocumentReader({
250
+ db,
251
+ collections: [source, target],
252
+ requestContext,
253
+ readContext,
254
+ readMode: 'published',
255
+ securityDomain,
256
+ });
257
+ await read({ collectionPath: 'media', documentIds: ['m2'] });
258
+ expect(getDocumentsByDocumentIds.mock.calls[1]?.[0].filters).toBe(relationFilters);
259
+ });
260
+ it('rejects relation populate reuse under another actor before adapter access', async () => {
261
+ const scopedTarget = {
262
+ ...target,
263
+ hooks: {
264
+ beforeRead: ({ requestContext }) => ({ tenant: requestContext.actor?.id }),
265
+ },
266
+ };
267
+ const source = {
268
+ path: 'posts',
269
+ labels: { singular: 'Post', plural: 'Posts' },
270
+ fields: [
271
+ {
272
+ name: 'media',
273
+ type: 'relation',
274
+ label: 'Media',
275
+ targetCollection: 'media',
276
+ },
277
+ ],
278
+ };
279
+ const { db, getCollectionByPath, getDocumentsByDocumentIds } = dbMock();
280
+ const readContext = createReadContext();
281
+ const requestId = 'shared-request';
282
+ const contextFor = (id) => createRequestContext({
283
+ actor: new AdminAuth({ id, abilities: ['collections.media.read'] }),
284
+ requestId,
285
+ readMode: 'published',
286
+ });
287
+ const populateFor = (id, requestContext) => populateDocuments({
288
+ db,
289
+ collections: [source, scopedTarget],
290
+ collectionId: 'posts',
291
+ documents: [
292
+ {
293
+ document_id: `p-${id}`,
294
+ fields: {
295
+ media: { targetCollectionId: 'media', targetDocumentId: `m-${id}` },
296
+ },
297
+ },
298
+ ],
299
+ populate: true,
300
+ requestContext,
301
+ readContext,
302
+ readMode: 'published',
303
+ });
304
+ await populateFor('alice', contextFor('alice'));
305
+ await expect(populateFor('bob', contextFor('bob'))).rejects.toThrow('cannot be reused across request authorities');
306
+ expect(getDocumentsByDocumentIds).toHaveBeenCalledOnce();
307
+ expect(getCollectionByPath).not.toHaveBeenCalled();
308
+ });
309
+ it('rejects rich-text reader reuse under another actor before adapter access', async () => {
310
+ const scopedTarget = {
311
+ ...target,
312
+ hooks: {
313
+ beforeRead: ({ requestContext }) => ({ tenant: requestContext.actor?.id }),
314
+ },
315
+ };
316
+ const { db, getCollectionByPath, getDocumentsByDocumentIds } = dbMock();
317
+ const readContext = createReadContext();
318
+ const requestId = 'shared-request';
319
+ const readerFor = (id) => createRichTextDocumentReader({
320
+ db,
321
+ collections: [scopedTarget],
322
+ requestContext: createRequestContext({
323
+ actor: new AdminAuth({ id, abilities: ['collections.media.read'] }),
324
+ requestId,
325
+ readMode: 'published',
326
+ }),
327
+ readContext,
328
+ readMode: 'published',
329
+ });
330
+ await readerFor('alice')({ collectionPath: 'media', documentIds: ['m1'] });
331
+ await expect(readerFor('bob')({ collectionPath: 'media', documentIds: ['m2'] })).rejects.toThrow('cannot be reused across request authorities');
332
+ expect(getCollectionByPath).toHaveBeenCalledOnce();
333
+ expect(getDocumentsByDocumentIds).toHaveBeenCalledOnce();
334
+ });
335
+ it('rejects malformed relation and rich-text predicates before adapter access', async () => {
336
+ const malformedTarget = {
337
+ ...target,
338
+ hooks: { beforeRead: () => ({ typoedTenant: 'alice' }) },
339
+ };
340
+ const source = {
341
+ path: 'posts',
342
+ labels: { singular: 'Post', plural: 'Posts' },
343
+ fields: [
344
+ {
345
+ name: 'media',
346
+ type: 'relation',
347
+ label: 'Media',
348
+ targetCollection: 'media',
349
+ },
350
+ ],
351
+ };
352
+ const requestContext = createRequestContext({
353
+ actor: new AdminAuth({ id: 'reader', abilities: ['collections.media.read'] }),
354
+ readMode: 'published',
355
+ });
356
+ const relationDb = dbMock();
357
+ await expect(populateDocuments({
358
+ db: relationDb.db,
359
+ collections: [source, malformedTarget],
360
+ collectionId: 'posts',
361
+ documents: [
362
+ {
363
+ document_id: 'p1',
364
+ fields: {
365
+ media: { targetCollectionId: 'media', targetDocumentId: 'm1' },
366
+ },
367
+ },
368
+ ],
369
+ populate: true,
370
+ requestContext,
371
+ readContext: createReadContext(),
372
+ readMode: 'published',
373
+ })).rejects.toThrow('unknown field');
374
+ expect(relationDb.getCollectionByPath).not.toHaveBeenCalled();
375
+ expect(relationDb.getDocumentsByDocumentIds).not.toHaveBeenCalled();
376
+ const richTextDb = dbMock();
377
+ const read = createRichTextDocumentReader({
378
+ db: richTextDb.db,
379
+ collections: [malformedTarget],
380
+ requestContext,
381
+ readContext: createReadContext(),
382
+ readMode: 'published',
383
+ });
384
+ await expect(read({ collectionPath: 'media', documentIds: ['m1'] })).rejects.toThrow('unknown field');
385
+ expect(richTextDb.getCollectionByPath).not.toHaveBeenCalled();
386
+ expect(richTextDb.getDocumentsByDocumentIds).not.toHaveBeenCalled();
387
+ });
388
+ it('runs target rich-text population and actor-aware afterRead once per materialization', async () => {
389
+ const afterRead = vi.fn((ctx) => {
390
+ if (ctx.requestContext.actor?.id === 'reader')
391
+ delete ctx.doc.fields.secret;
392
+ });
393
+ const recursiveTarget = {
394
+ ...target,
395
+ fields: [
396
+ ...target.fields,
397
+ {
398
+ name: 'body',
399
+ type: 'richText',
400
+ label: 'Body',
401
+ populateRelationsOnRead: true,
402
+ },
403
+ { name: 'secret', type: 'text', label: 'Secret' },
404
+ ],
405
+ hooks: { ...target.hooks, afterRead },
406
+ };
407
+ const doc = {
408
+ document_version_id: 'v1',
409
+ document_id: 'm1',
410
+ fields: { title: 'Media', tenant: 'alice', body: {}, secret: 'hidden' },
411
+ };
412
+ const { db, getDocumentsByDocumentIds } = dbMock();
413
+ getDocumentsByDocumentIds.mockResolvedValue([doc]);
414
+ const readContext = createReadContext();
415
+ const nestedResults = [];
416
+ const richTextPopulate = vi.fn(async (ctx) => {
417
+ nestedResults.push(await ctx.readDocuments({ collectionPath: 'media', documentIds: ['m1'] }));
418
+ ctx.value.refreshed = true;
419
+ });
420
+ const requestContext = createRequestContext({
421
+ actor: new AdminAuth({ id: 'reader', abilities: ['collections.media.read'] }),
422
+ readMode: 'published',
423
+ });
424
+ const read = createRichTextDocumentReader({
425
+ db,
426
+ collections: [recursiveTarget],
427
+ requestContext,
428
+ readContext,
429
+ readMode: 'published',
430
+ richTextPopulate,
431
+ });
432
+ const first = await read({ collectionPath: 'media', documentIds: ['m1'] });
433
+ const second = await read({ collectionPath: 'media', documentIds: ['m1'] });
434
+ expect(first[0]?.fields.body.refreshed).toBe(true);
435
+ expect(first[0]?.fields).not.toHaveProperty('secret');
436
+ expect(second[0]).toBe(first[0]);
437
+ expect(nestedResults).toEqual([[]]);
438
+ expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
439
+ expect(richTextPopulate).toHaveBeenCalledTimes(1);
440
+ expect(afterRead).toHaveBeenCalledWith(expect.objectContaining({ requestContext, readContext }));
441
+ expect(readContext.readCount).toBe(1);
442
+ expect(readContext.visited).toContain('media-id:m1');
443
+ });
444
+ it('shares active reader state with a beforeRead-scoped context', async () => {
445
+ const recursiveTarget = {
446
+ ...target,
447
+ fields: [
448
+ ...target.fields,
449
+ {
450
+ name: 'body',
451
+ type: 'richText',
452
+ label: 'Body',
453
+ populateRelationsOnRead: true,
454
+ },
455
+ ],
456
+ hooks: undefined,
457
+ };
458
+ let scopedContext;
459
+ const gate = {
460
+ path: 'gate',
461
+ labels: { singular: 'Gate', plural: 'Gates' },
462
+ fields: [],
463
+ hooks: {
464
+ beforeRead: ({ readContext }) => {
465
+ scopedContext = readContext;
466
+ },
467
+ },
468
+ };
469
+ const requestContext = createRequestContext({
470
+ actor: new AdminAuth({ id: 'reader', abilities: ['collections.media.read'] }),
471
+ readMode: 'published',
472
+ });
473
+ const readContext = createReadContext();
474
+ await applyBeforeRead({ definition: gate, requestContext, readContext });
475
+ const { db, getDocumentsByDocumentIds } = dbMock();
476
+ getDocumentsByDocumentIds.mockImplementation(async () => [
477
+ {
478
+ document_version_id: 'v1',
479
+ document_id: 'm1',
480
+ fields: { title: 'Media', tenant: 'alice', body: {} },
481
+ },
482
+ ]);
483
+ let nestedResult;
484
+ let reentered = false;
485
+ let nestedRead;
486
+ const richTextPopulate = vi.fn(async () => {
487
+ if (reentered)
488
+ return;
489
+ reentered = true;
490
+ nestedResult = await nestedRead({ collectionPath: 'media', documentIds: ['m1'] });
491
+ });
492
+ if (!scopedContext)
493
+ throw new Error('beforeRead did not receive a scoped ReadContext');
494
+ nestedRead = createRichTextDocumentReader({
495
+ db,
496
+ collections: [recursiveTarget],
497
+ requestContext,
498
+ readContext: scopedContext,
499
+ readMode: 'published',
500
+ richTextPopulate,
501
+ });
502
+ const outerRead = createRichTextDocumentReader({
503
+ db,
504
+ collections: [recursiveTarget],
505
+ requestContext,
506
+ readContext,
507
+ readMode: 'published',
508
+ richTextPopulate,
509
+ });
510
+ const result = await outerRead({ collectionPath: 'media', documentIds: ['m1'] });
511
+ expect(result).toHaveLength(1);
512
+ expect(nestedResult).toEqual([]);
513
+ expect(getDocumentsByDocumentIds).toHaveBeenCalledOnce();
514
+ expect(richTextPopulate).toHaveBeenCalledOnce();
515
+ });
516
+ it('reuses a later batch target completed recursively instead of processing stale batch data', async () => {
517
+ const afterRead = vi.fn((ctx) => {
518
+ delete ctx.doc.fields.secret;
519
+ ctx.doc.fields.redacted = true;
520
+ });
521
+ const batchTarget = {
522
+ ...target,
523
+ fields: [
524
+ ...target.fields,
525
+ {
526
+ name: 'body',
527
+ type: 'richText',
528
+ label: 'Body',
529
+ populateRelationsOnRead: true,
530
+ },
531
+ { name: 'secret', type: 'text', label: 'Secret' },
532
+ ],
533
+ hooks: { ...target.hooks, afterRead },
534
+ };
535
+ const outerA = {
536
+ document_version_id: 'v1',
537
+ document_id: 'm1',
538
+ fields: { title: 'A', tenant: 'alice', body: { link: 'm2' }, secret: 'a' },
539
+ };
540
+ const staleOuterB = {
541
+ document_version_id: 'v2',
542
+ document_id: 'm2',
543
+ fields: { title: 'stale B', tenant: 'alice', body: {}, secret: 'stale' },
544
+ };
545
+ const recursiveB = {
546
+ document_version_id: 'v2',
547
+ document_id: 'm2',
548
+ fields: { title: 'fresh B', tenant: 'alice', body: {}, secret: 'hidden' },
549
+ };
550
+ const { db, getDocumentsByDocumentIds } = dbMock();
551
+ getDocumentsByDocumentIds.mockImplementation(async ({ document_ids }) => document_ids.length === 2 ? [outerA, staleOuterB] : [recursiveB]);
552
+ const richTextPopulate = vi.fn(async (ctx) => {
553
+ if (typeof ctx.value.link !== 'string')
554
+ return;
555
+ const linked = await ctx.readDocuments({
556
+ collectionPath: 'media',
557
+ documentIds: [ctx.value.link],
558
+ });
559
+ ctx.value.linkedTitle = linked[0]?.fields.title;
560
+ });
561
+ const readContext = createReadContext();
562
+ const read = createRichTextDocumentReader({
563
+ db,
564
+ collections: [batchTarget],
565
+ requestContext: createRequestContext({
566
+ actor: new AdminAuth({ id: 'reader', abilities: ['collections.media.read'] }),
567
+ readMode: 'published',
568
+ }),
569
+ readContext,
570
+ readMode: 'published',
571
+ richTextPopulate,
572
+ });
573
+ const result = await read({ collectionPath: 'media', documentIds: ['m1', 'm2'] });
574
+ expect(result[0]?.fields.body.linkedTitle).toBe('fresh B');
575
+ expect(result[1]).toBe(recursiveB);
576
+ expect(result[1]).not.toBe(staleOuterB);
577
+ expect(result[1]?.fields).toMatchObject({ title: 'fresh B', redacted: true });
578
+ expect(result[1]?.fields).not.toHaveProperty('secret');
579
+ expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(2);
580
+ expect(richTextPopulate).toHaveBeenCalledTimes(2);
581
+ expect(afterRead).toHaveBeenCalledTimes(2);
582
+ expect(readContext.readCount).toBe(2);
583
+ });
584
+ it('enforces the shared read budget before returning target documents', async () => {
585
+ const { db, getDocumentsByDocumentIds } = dbMock();
586
+ getDocumentsByDocumentIds.mockResolvedValue([
587
+ { document_version_id: 'v1', document_id: 'm1', fields: { title: 'Media' } },
588
+ ]);
589
+ const read = createRichTextDocumentReader({
590
+ db,
591
+ collections: [target],
592
+ requestContext: createRequestContext({
593
+ actor: new AdminAuth({ id: 'reader', abilities: ['collections.media.read'] }),
594
+ readMode: 'published',
595
+ }),
596
+ readContext: createReadContext({ maxReads: 0 }),
597
+ readMode: 'published',
598
+ });
599
+ await expect(read({ collectionPath: 'media', documentIds: ['m1'] })).rejects.toMatchObject({
600
+ code: 'ERR_READ_BUDGET_EXCEEDED',
601
+ });
602
+ });
603
+ });
604
+ describe('populateRichTextFields auth context', () => {
605
+ it('passes the operation context and secure reader to the adapter', async () => {
606
+ const requestContext = createRequestContext({
607
+ actor: new AdminAuth({ id: 'reader', abilities: ['collections.posts.read'] }),
608
+ readMode: 'published',
609
+ });
610
+ const readContext = createReadContext();
611
+ const readDocuments = vi.fn();
612
+ const populate = vi.fn();
613
+ await populateRichTextFields({
614
+ fields: [
615
+ {
616
+ name: 'body',
617
+ type: 'richText',
618
+ label: 'Body',
619
+ populateRelationsOnRead: true,
620
+ },
621
+ ],
622
+ collectionPath: 'posts',
623
+ documents: [{ fields: { body: richTextValue('body') } }],
624
+ populate,
625
+ readContext,
626
+ requestContext,
627
+ readMode: 'published',
628
+ readDocuments,
629
+ });
630
+ expect(populate).toHaveBeenCalledWith(expect.objectContaining({ requestContext, readContext, readMode: 'published', readDocuments }));
631
+ });
632
+ });
112
633
  // ---------------------------------------------------------------------------
113
634
  // resolvePopulateOnRead — default-derivation table
114
635
  // ---------------------------------------------------------------------------
@@ -25,4 +25,4 @@ export interface SearchProviderPresence {
25
25
  * actually opts in. Installations that don't use search leave
26
26
  * `ServerConfig.search` unset and pass cleanly.
27
27
  */
28
- export declare function validateSearchConfig(collections: CollectionDefinition[], adapters: SearchProviderPresence): void;
28
+ export declare function validateSearchConfig(collections: readonly CollectionDefinition[], adapters: SearchProviderPresence): void;
@@ -33,6 +33,12 @@ function canonicalField(field) {
33
33
  base.targetCollection = field.targetCollection;
34
34
  if (field.displayField !== undefined)
35
35
  base.displayField = field.displayField;
36
+ if (field.hasMany === true)
37
+ base.hasMany = true;
38
+ if (field.minItems !== undefined)
39
+ base.minItems = field.minItems;
40
+ if (field.maxItems !== undefined)
41
+ base.maxItems = field.maxItems;
36
42
  return base;
37
43
  case 'datetime':
38
44
  if (field.mode !== undefined)
@@ -145,6 +145,27 @@ describe('fingerprintCollection', () => {
145
145
  rel.targetCollection = 'tags';
146
146
  expect(await fingerprintCollection(b)).not.toBe(a);
147
147
  });
148
+ it('changes when a relation becomes hasMany', async () => {
149
+ const a = await fingerprintCollection(baseCollection());
150
+ const b = baseCollection();
151
+ const rel = b.fields.find((f) => f.name === 'category');
152
+ rel.hasMany = true;
153
+ expect(await fingerprintCollection(b)).not.toBe(a);
154
+ });
155
+ it.each([
156
+ 'minItems',
157
+ 'maxItems',
158
+ ])('changes when relation %s changes', async (constraint) => {
159
+ const a = baseCollection();
160
+ const aRel = a.fields.find((f) => f.name === 'category');
161
+ aRel.hasMany = true;
162
+ aRel[constraint] = 1;
163
+ const b = baseCollection();
164
+ const bRel = b.fields.find((f) => f.name === 'category');
165
+ bRel.hasMany = true;
166
+ bRel[constraint] = 2;
167
+ expect(await fingerprintCollection(b)).not.toBe(await fingerprintCollection(a));
168
+ });
148
169
  it('changes when a block variant is renamed', async () => {
149
170
  const a = await fingerprintCollection(baseCollection());
150
171
  const b = baseCollection();
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Normalize an untrusted redirect target to a same-origin, root-relative URL.
3
+ * Encoded path data is rejected rather than decoded so traversal and URL parser
4
+ * differences cannot change the destination after validation.
5
+ */
6
+ export declare function normalizeRootRelativeRedirect(value: string): string | undefined;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Normalize an untrusted redirect target to a same-origin, root-relative URL.
3
+ * Encoded path data is rejected rather than decoded so traversal and URL parser
4
+ * differences cannot change the destination after validation.
5
+ */
6
+ export function normalizeRootRelativeRedirect(value) {
7
+ if (value.length === 0 || value !== value.trim())
8
+ return undefined;
9
+ if (!value.startsWith('/') || value.startsWith('//') || value.includes('\\'))
10
+ return undefined;
11
+ if (hasUnsafeCodePoint(value))
12
+ return undefined;
13
+ const pathname = value.split(/[?#]/, 1)[0];
14
+ if (!pathname || pathname.includes('%'))
15
+ return undefined;
16
+ if (pathname.split('/').some((segment) => segment === '.' || segment === '..'))
17
+ return undefined;
18
+ let url;
19
+ try {
20
+ url = new URL(value, 'https://byline.invalid');
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ if (url.origin !== 'https://byline.invalid')
26
+ return undefined;
27
+ return `${url.pathname}${url.search}${url.hash}`;
28
+ }
29
+ function hasUnsafeCodePoint(value) {
30
+ return Array.from(value).some((character) => {
31
+ const codePoint = character.codePointAt(0) ?? 0;
32
+ return (codePoint <= 0x1f ||
33
+ codePoint === 0x7f ||
34
+ (codePoint >= 0x80 && codePoint <= 0x9f) ||
35
+ (codePoint >= 0xd800 && codePoint <= 0xdfff));
36
+ });
37
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.20.4",
5
+ "version": "4.0.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -31,6 +31,11 @@
31
31
  "import": "./dist/index.js",
32
32
  "require": "./dist/index.js"
33
33
  },
34
+ "./codegen": {
35
+ "types": "./dist/codegen/index.d.ts",
36
+ "import": "./dist/codegen/index.js",
37
+ "require": "./dist/codegen/index.js"
38
+ },
34
39
  "./zod-schemas": {
35
40
  "types": "./dist/schemas/zod/index.d.ts",
36
41
  "import": "./dist/schemas/zod/index.js",
@@ -76,7 +81,7 @@
76
81
  "pino": "^10.3.1",
77
82
  "sharp": "^0.35.3",
78
83
  "zod": "^4.4.3",
79
- "@byline/auth": "3.20.4"
84
+ "@byline/auth": "4.0.0"
80
85
  },
81
86
  "devDependencies": {
82
87
  "@biomejs/biome": "2.5.2",