@byline/core 4.4.0 → 4.4.1

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.
@@ -58,7 +58,8 @@ export interface FieldHookContext {
58
58
  */
59
59
  data: Record<string, any>;
60
60
  /**
61
- * Dot-path of the field inside the document, e.g. `"content.0.richText"`.
61
+ * Instance path of the field inside the document, e.g.
62
+ * `"content[id=block-id].richText"`.
62
63
  */
63
64
  path: string;
64
65
  /**
@@ -85,7 +86,7 @@ export interface FieldHookContext {
85
86
  * items (a checkbox that unchecks its siblings) or clearing a dependent
86
87
  * field when its driver changes. Paths use the same dot + bracket
87
88
  * notation as `FieldHookContext.path`, e.g.
88
- * `files[1].filesGroup.generateThumbnail`.
89
+ * `files[id=file-id].filesGroup.generateThumbnail`.
89
90
  */
90
91
  setFieldValue: (path: string, value: any) => void;
91
92
  }
@@ -10,7 +10,7 @@ export function prepareHookAttachment(config) {
10
10
  const collections = new Map();
11
11
  const uploadFields = new Map();
12
12
  for (const collection of config.collections) {
13
- assertDotFreeSegment(collection.path, `collection path "${collection.path}"`);
13
+ assertPathSafeSegment(collection.path, `collection path "${collection.path}"`);
14
14
  collections.set(collection.path, collection);
15
15
  indexUploadFields(collection, uploadFields);
16
16
  }
@@ -99,7 +99,7 @@ function assertOwnershipIntact(label, current, owned) {
99
99
  function indexUploadFields(collection, uploadFields) {
100
100
  const leafPaths = new Map();
101
101
  walkFieldDeclarations(collection.fields, (field, segments) => {
102
- assertDotFreeSegment(field.name, `field name "${field.name}" in collection "${collection.path}"`);
102
+ assertPathSafeSegment(field.name, `field name "${field.name}" in collection "${collection.path}"`);
103
103
  if ((field.type !== 'file' && field.type !== 'image') || field.upload === undefined)
104
104
  return;
105
105
  const canonical = `${collection.path}.${formatDeclarationPath(segments)}`;
@@ -114,12 +114,27 @@ function indexUploadFields(collection, uploadFields) {
114
114
  // block never reaches the field visitor, so inferring block types from
115
115
  // field segments alone would silently stop validating them.
116
116
  onBlock: (block) => {
117
- assertDotFreeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
117
+ assertPathSafeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
118
118
  },
119
119
  });
120
120
  }
121
- function assertDotFreeSegment(segment, label) {
122
- if (segment.length === 0 || segment.includes('.')) {
123
- throw new Error(`${label} must be a non-empty, dot-free hook registry path segment.`);
121
+ /**
122
+ * A name that appears as a path segment must not contain the grammar's own
123
+ * punctuation.
124
+ *
125
+ * `.` separates segments in every notation. `[` and `]` delimit item selectors
126
+ * in instance paths, which field names already reach — a field named `a[0]`
127
+ * would not survive the round trip through `parseInstancePath`. Both are
128
+ * rejected where the name is declared rather than left to corrupt a path
129
+ * later.
130
+ */
131
+ function assertPathSafeSegment(segment, label) {
132
+ if (segment.length === 0) {
133
+ throw new Error(`${label} must be a non-empty path segment.`);
134
+ }
135
+ for (const character of ['.', '[', ']']) {
136
+ if (segment.includes(character)) {
137
+ throw new Error(`${label} must not contain "${character}" — it is field path grammar punctuation.`);
138
+ }
124
139
  }
125
140
  }
@@ -149,11 +149,11 @@ describe('server hook attachment', () => {
149
149
  await expect(initBylineCore(invalid, {})).rejects.toThrow(/translations bundle/i);
150
150
  expect(definition.hooks).toBe(stable);
151
151
  });
152
- it('rejects dotted registry segments and duplicate upload leaf names at boot', () => {
153
- expect(() => prepareHookAttachment({ collections: [collection('docs.archive')] })).toThrow(/dot-free/);
152
+ it('rejects path-punctuation in registry segments and duplicate upload leaf names at boot', () => {
153
+ expect(() => prepareHookAttachment({ collections: [collection('docs.archive')] })).toThrow(/grammar punctuation/);
154
154
  const dottedField = collection();
155
155
  dottedField.fields = [{ name: 'meta.asset', type: 'file', upload: {} }];
156
- expect(() => prepareHookAttachment({ collections: [dottedField] })).toThrow(/dot-free/);
156
+ expect(() => prepareHookAttachment({ collections: [dottedField] })).toThrow(/grammar punctuation/);
157
157
  const dottedBlock = collection();
158
158
  dottedBlock.fields = [
159
159
  {
@@ -162,7 +162,21 @@ describe('server hook attachment', () => {
162
162
  blocks: [{ blockType: 'hero.large', fields: [] }],
163
163
  },
164
164
  ];
165
- expect(() => prepareHookAttachment({ collections: [dottedBlock] })).toThrow(/dot-free/);
165
+ expect(() => prepareHookAttachment({ collections: [dottedBlock] })).toThrow(/grammar punctuation/);
166
+ // Brackets delimit item selectors in instance paths, which field names
167
+ // reach — so they are rejected for the same reason dots are.
168
+ const bracketField = collection();
169
+ bracketField.fields = [{ name: 'asset[0]', type: 'file', upload: {} }];
170
+ expect(() => prepareHookAttachment({ collections: [bracketField] })).toThrow(/grammar punctuation/);
171
+ const bracketBlock = collection();
172
+ bracketBlock.fields = [
173
+ {
174
+ name: 'content',
175
+ type: 'blocks',
176
+ blocks: [{ blockType: 'hero[large]', fields: [] }],
177
+ },
178
+ ];
179
+ expect(() => prepareHookAttachment({ collections: [bracketBlock] })).toThrow(/grammar punctuation/);
166
180
  const duplicate = collection();
167
181
  duplicate.fields = [
168
182
  { name: 'primary', type: 'group', fields: [{ name: 'asset', type: 'file', upload: {} }] },
@@ -156,6 +156,32 @@ describe('applyPatches', () => {
156
156
  const moved = afterMove;
157
157
  expect(moved.reviews.map((r) => r._id)).toEqual(['c', 'a', 'b']);
158
158
  });
159
+ it('applies field patches against the array order at that point in the patch stream', () => {
160
+ const original = {
161
+ links: [
162
+ { _id: 'a', link: 'A' },
163
+ { _id: 'b', link: 'B' },
164
+ ],
165
+ };
166
+ const positional = applyPatches(DocsDefinition, original, [
167
+ { kind: 'array.move', path: 'links', itemId: 'b', toIndex: 0 },
168
+ { kind: 'field.set', path: 'links[0].link', value: 'moved B' },
169
+ ]);
170
+ expect(positional.errors).toHaveLength(0);
171
+ expect(positional.doc.links).toEqual([
172
+ { _id: 'b', link: 'moved B' },
173
+ { _id: 'a', link: 'A' },
174
+ ]);
175
+ const stable = applyPatches(DocsDefinition, original, [
176
+ { kind: 'array.move', path: 'links', itemId: 'b', toIndex: 0 },
177
+ { kind: 'field.set', path: 'links[id=b].link', value: 'same B' },
178
+ ]);
179
+ expect(stable.errors).toHaveLength(0);
180
+ expect(stable.doc.links).toEqual([
181
+ { _id: 'b', link: 'same B' },
182
+ { _id: 'a', link: 'A' },
183
+ ]);
184
+ });
159
185
  it('array.remove removes an item by stable _id', () => {
160
186
  const original = {
161
187
  reviews: [
@@ -28,8 +28,13 @@ const ID_TOKEN = /^\[id=(.+)\]$/;
28
28
  export function parseDeclarationPath(path) {
29
29
  if (path.trim() === '')
30
30
  return { ok: false, reason: 'empty' };
31
- if (path.includes('[') || path.includes(']'))
31
+ if (path.includes('['))
32
32
  return { ok: false, reason: 'index' };
33
+ // A closing bracket with no opener is not an item selector the caller wrote
34
+ // in the wrong dialect — it is a typo, and saying so beats advice about
35
+ // declaration paths not taking indices.
36
+ if (path.includes(']'))
37
+ return { ok: false, reason: 'malformed' };
33
38
  const segments = [];
34
39
  for (const part of path.split('.')) {
35
40
  if (part === '')
@@ -80,7 +80,8 @@ export interface ResolveOptions {
80
80
  * - `'forbidden'` — stop and report `status: 'blocks'`. Used by the admin
81
81
  * `fields{}` maps, where per-field overrides inside a block belong to the
82
82
  * blockType-keyed `blockAdmin` registry so that one registration applies
83
- * wherever the block renders.
83
+ * wherever the block renders. This bars *traversal* only: a path ending on
84
+ * the blocks field resolves `ok`, since that field is itself overridable.
84
85
  */
85
86
  readonly blocks?: 'qualified' | 'forbidden';
86
87
  }
@@ -68,6 +68,11 @@ describe('parseDeclarationPath', () => {
68
68
  reason: 'index',
69
69
  });
70
70
  });
71
+ it('distinguishes a stray bracket from a wrong-dialect index', () => {
72
+ // `index` steers the author from instance notation to declaration
73
+ // notation; that advice is misleading for what is only a typo.
74
+ expect(parseDeclarationPath('files].caption')).toEqual({ ok: false, reason: 'malformed' });
75
+ });
71
76
  it('rejects empty paths and empty segments', () => {
72
77
  expect(parseDeclarationPath('')).toEqual({ ok: false, reason: 'empty' });
73
78
  expect(parseDeclarationPath(' ')).toEqual({ ok: false, reason: 'empty' });
@@ -104,6 +109,13 @@ describe('parseInstancePath', () => {
104
109
  expect(parseInstancePath(bad).ok).toBe(false);
105
110
  }
106
111
  });
112
+ it('rejects an id containing a closing bracket rather than truncating it', () => {
113
+ // Ids are UUIDv7 today, so this is unreachable in practice. Pinned because
114
+ // the id token is scanned to the first `]`: the guarantee worth holding is
115
+ // that an id which somehow contains one is rejected outright, never
116
+ // silently shortened into a different — and possibly valid — id.
117
+ expect(parseInstancePath('gallery[id=a]b].alt').ok).toBe(false);
118
+ });
107
119
  });
108
120
  describe('formatting', () => {
109
121
  it('round-trips a declaration path', () => {
@@ -255,6 +267,15 @@ describe('resolveDeclarationPath', () => {
255
267
  it('reports `blocks` even for an unqualified path into a block', () => {
256
268
  expect(resolveDeclarationPath(fields, 'content.alt', { blocks: 'forbidden' }).status).toBe('blocks');
257
269
  });
270
+ it('resolves the blocks field itself under `forbidden`', () => {
271
+ // `forbidden` bars *traversal* into a block, not addressing the blocks
272
+ // field. A blocks field carries a label like any other, so an admin
273
+ // `fields{}` key naming it is a legitimate override target — what the
274
+ // narrowing rules out is reaching past it to the fields inside.
275
+ const result = resolveDeclarationPath(fields, 'content', { blocks: 'forbidden' });
276
+ expect(result.status).toBe('ok');
277
+ expect(result.status === 'ok' && result.field.type).toBe('blocks');
278
+ });
258
279
  it('rejects an unknown block type', () => {
259
280
  expect(resolveDeclarationPath(fields, 'content.audioBlock.alt').status).toBe('unresolved');
260
281
  });
@@ -80,6 +80,10 @@ export function resolveDeclarationPath(fields, path, options = {}) {
80
80
  if (field == null)
81
81
  return { status: 'unresolved', at: i };
82
82
  resolved.push({ kind: 'field', name: field.name });
83
+ // Deliberately ahead of the `blocks: 'forbidden'` check below: that policy
84
+ // bars *traversal* into a block, so a path ending on the blocks field
85
+ // resolves normally. The field carries a label like any other and is a
86
+ // legitimate admin override target.
83
87
  const isLast = i === input.length - 1;
84
88
  if (isLast)
85
89
  return { status: 'ok', field, segments: resolved };
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": "4.4.0",
5
+ "version": "4.4.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -81,7 +81,7 @@
81
81
  "pino": "^10.3.1",
82
82
  "sharp": "^0.35.3",
83
83
  "zod": "^4.4.3",
84
- "@byline/auth": "4.4.0"
84
+ "@byline/auth": "4.4.1"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@biomejs/biome": "2.5.4",