@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.
- package/dist/@types/field-types.d.ts +3 -2
- package/dist/config/attach-hooks.js +21 -6
- package/dist/config/attach-hooks.test.node.js +18 -4
- package/dist/patches/patch.test.node.js +26 -0
- package/dist/paths/parse-path.js +6 -1
- package/dist/paths/path-types.d.ts +2 -1
- package/dist/paths/paths.test.node.js +21 -0
- package/dist/paths/resolve-path.js +4 -0
- package/package.json +2 -2
|
@@ -58,7 +58,8 @@ export interface FieldHookContext {
|
|
|
58
58
|
*/
|
|
59
59
|
data: Record<string, any>;
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
+
assertPathSafeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
|
|
118
118
|
},
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
153
|
-
expect(() => prepareHookAttachment({ collections: [collection('docs.archive')] })).toThrow(/
|
|
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(/
|
|
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(/
|
|
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: [
|
package/dist/paths/parse-path.js
CHANGED
|
@@ -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('[')
|
|
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.
|
|
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.
|
|
84
|
+
"@byline/auth": "4.4.1"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
87
|
"@biomejs/biome": "2.5.4",
|