@oneentry/mcp-platform-server 0.1.7 → 0.1.9
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/data/permissions.json +1 -0
- package/dist/api/build-catalog.js +13 -0
- package/dist/api/client.d.ts +2 -0
- package/dist/api/client.js +28 -8
- package/dist/api/operation-notes.js +128 -84
- package/dist/tools/api-discovery.js +4 -3
- package/knowledge/operating-rules.md +1 -1
- package/package.json +1 -1
package/data/permissions.json
CHANGED
|
@@ -214,6 +214,7 @@
|
|
|
214
214
|
"AdminTemplatePreviewsController_update": "settings.templatePreview.update",
|
|
215
215
|
"AdminTemplatePreviewsController_remove": "settings.templatePreview.delete",
|
|
216
216
|
"AdminTemplatePreviewsController_updateAttributeSetPosition": "settings.templatePreview.changePositions",
|
|
217
|
+
"AdminTemplatePreviewsController_regenerate": "settings.templatePreview.update",
|
|
217
218
|
"AdminTemplatesController_create": "settings.templates.create",
|
|
218
219
|
"AdminTemplatesController_update": "settings.templates.update",
|
|
219
220
|
"AdminTemplatesController_remove": "settings.templates.delete",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { phantomPathParams } from './client.js';
|
|
2
3
|
import { capSchema, normalizeSchema } from './normalize-schema.js';
|
|
3
4
|
import { ALWAYS_CONFIRM_PREFIXES, OPERATION_NOTES } from './operation-notes.js';
|
|
4
5
|
const API_PREFIX = '/api/admin';
|
|
@@ -167,6 +168,7 @@ export const buildCatalog = (params) => {
|
|
|
167
168
|
const withoutOpId = [];
|
|
168
169
|
const unresolvedBodies = [];
|
|
169
170
|
const contradictoryExamples = [];
|
|
171
|
+
const undeliverableParams = [];
|
|
170
172
|
for (const [fullPath, pathItem] of Object.entries(swagger['paths'])) {
|
|
171
173
|
if (!isRecord(pathItem)) {
|
|
172
174
|
continue;
|
|
@@ -225,6 +227,10 @@ export const buildCatalog = (params) => {
|
|
|
225
227
|
entry.searchText = [opId, method.toUpperCase(), path, tag, summary, permission ?? '']
|
|
226
228
|
.join(' ')
|
|
227
229
|
.toLowerCase();
|
|
230
|
+
const phantom = phantomPathParams(entry);
|
|
231
|
+
if (phantom.length > 0) {
|
|
232
|
+
undeliverableParams.push(`${opId} (${phantom.join(', ')} on ${path})`);
|
|
233
|
+
}
|
|
228
234
|
operations.push(entry);
|
|
229
235
|
}
|
|
230
236
|
}
|
|
@@ -250,6 +256,13 @@ export const buildCatalog = (params) => {
|
|
|
250
256
|
'Those fields are marked "x-example-mismatch" and listed by cms_api_describe — ' +
|
|
251
257
|
'copy the example, not the type. Report it against the CMS.');
|
|
252
258
|
}
|
|
259
|
+
if (undeliverableParams.length > 0) {
|
|
260
|
+
warnings.push(`${String(undeliverableParams.length)} operation(s) declare a path parameter that their ` +
|
|
261
|
+
`own path does not contain (${undeliverableParams.slice(0, 5).join(', ')}). ` +
|
|
262
|
+
'The value has nowhere to go, so this server refuses those calls instead of sending ' +
|
|
263
|
+
'the request without it. cms_api_describe marks them "notExecutable" and names a ' +
|
|
264
|
+
'working route where one is known. Report it against the CMS.');
|
|
265
|
+
}
|
|
253
266
|
const noted = Object.keys(OPERATION_NOTES);
|
|
254
267
|
const notedButAbsent = noted.filter((opId) => !operations.some((operation) => operation.opId === opId));
|
|
255
268
|
if (notedButAbsent.length > 0 && notedButAbsent.length <= noted.length / 2) {
|
package/dist/api/client.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export declare class RequestBuildError extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
export declare const normalizeBody: (body: unknown) => unknown;
|
|
24
24
|
export declare const unsupportedBodyFormat: (operation: Operation) => string | undefined;
|
|
25
|
+
export declare const phantomPathParams: (operation: Operation) => string[];
|
|
26
|
+
export declare const undeliverablePathParams: (operation: Operation) => string | undefined;
|
|
25
27
|
export declare const buildUrl: (baseUrl: string, operation: Operation, args: CallArgs) => string;
|
|
26
28
|
export declare class AdminApiClient {
|
|
27
29
|
private readonly baseUrl;
|
package/dist/api/client.js
CHANGED
|
@@ -31,7 +31,26 @@ export const unsupportedBodyFormat = (operation) => {
|
|
|
31
31
|
}
|
|
32
32
|
return `${base} Report it to the human and use another route to that instance.`;
|
|
33
33
|
};
|
|
34
|
+
export const phantomPathParams = (operation) => operation.params
|
|
35
|
+
.filter((param) => param.location === 'path' && !operation.path.includes(`{${param.name}}`))
|
|
36
|
+
.map((param) => param.name);
|
|
37
|
+
export const undeliverablePathParams = (operation) => {
|
|
38
|
+
const phantom = phantomPathParams(operation);
|
|
39
|
+
if (phantom.length === 0) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return (`Operation ${operation.opId} declares ${phantom.map((name) => `"${name}"`).join(', ')} as a ` +
|
|
43
|
+
`path parameter, but its path ${operation.path} has no place to put it. The value cannot ` +
|
|
44
|
+
'reach the API, so the request would go out without it and do something other than what ' +
|
|
45
|
+
'you asked. The call is not executable through this server — find the operation that ' +
|
|
46
|
+
'takes the same values where the API really expects them with cms_api_search, and read ' +
|
|
47
|
+
'"note" in cms_api_describe for a route that has been verified.');
|
|
48
|
+
};
|
|
34
49
|
export const buildUrl = (baseUrl, operation, args) => {
|
|
50
|
+
const undeliverable = undeliverablePathParams(operation);
|
|
51
|
+
if (undeliverable !== undefined) {
|
|
52
|
+
throw new RequestBuildError(undeliverable);
|
|
53
|
+
}
|
|
35
54
|
let path = operation.path;
|
|
36
55
|
const provided = args.path ?? {};
|
|
37
56
|
for (const param of operation.params) {
|
|
@@ -94,11 +113,11 @@ const readBody = async (response) => {
|
|
|
94
113
|
return text.slice(0, 2000);
|
|
95
114
|
}
|
|
96
115
|
};
|
|
97
|
-
const isLoginPage = (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return
|
|
116
|
+
const isLoginPage = (body) => typeof body === 'string' && body.trimStart().startsWith('<');
|
|
117
|
+
const describeResponse = (response, body) => {
|
|
118
|
+
const contentType = response.headers.get('content-type') ?? 'no content type';
|
|
119
|
+
const text = typeof body === 'string' ? body : JSON.stringify(body ?? null);
|
|
120
|
+
return `${contentType}, body starts with "${text.trimStart().slice(0, 80).replace(/\s+/g, ' ')}"`;
|
|
102
121
|
};
|
|
103
122
|
const extractMessage = (body, fallback) => {
|
|
104
123
|
if (typeof body === 'string') {
|
|
@@ -163,17 +182,18 @@ export class AdminApiClient {
|
|
|
163
182
|
response = await attempt(await this.tokens.refresh());
|
|
164
183
|
}
|
|
165
184
|
body = await readBody(response);
|
|
166
|
-
if (response.ok && isLoginPage(
|
|
185
|
+
if (response.ok && isLoginPage(body)) {
|
|
167
186
|
response = await attempt(await this.tokens.refresh());
|
|
168
187
|
body = await readBody(response);
|
|
169
|
-
if (response.ok && isLoginPage(
|
|
188
|
+
if (response.ok && isLoginPage(body)) {
|
|
170
189
|
return {
|
|
171
190
|
ok: false,
|
|
172
191
|
error: {
|
|
173
192
|
status: response.status,
|
|
174
193
|
message: `${operation.method.toUpperCase()} ${url} answered ${String(response.status)} with ` +
|
|
175
194
|
'the instance login page instead of a result: the admin session is not valid and ' +
|
|
176
|
-
're-authenticating did not change that. Nothing was written by this call.'
|
|
195
|
+
're-authenticating did not change that. Nothing was written by this call. ' +
|
|
196
|
+
`The response was ${describeResponse(response, body)}.`,
|
|
177
197
|
hint: 'Check the credentials this server runs with, then retry. Treat every write since ' +
|
|
178
198
|
'the last verified read as unconfirmed and read those entities back.',
|
|
179
199
|
},
|
|
@@ -57,37 +57,50 @@ export const OPERATION_NOTES = {
|
|
|
57
57
|
'A body of { limit, offset, langCode } answers 400 complaining about langCode, because ' +
|
|
58
58
|
'the validator is describing the query parameter you did not send.',
|
|
59
59
|
},
|
|
60
|
+
AdminProductsController_removeMany: {
|
|
61
|
+
note: 'Not executable through this server: the document declares "ids" as a path parameter ' +
|
|
62
|
+
'while the path is /products, so the value has nowhere to go and the request would go ' +
|
|
63
|
+
'out as a bare DELETE /products. The verified way to delete a chosen set is the ' +
|
|
64
|
+
'operation on DELETE /api/admin/products/category/{pageId} with a body of ' +
|
|
65
|
+
'{ "ids": [ … ] } — find it with cms_api_search { "query": "products category delete" }. ' +
|
|
66
|
+
'The same route with the ids in the query string answers 500 instead, so send them in ' +
|
|
67
|
+
'the body. Deleting products one by one with AdminProductsController_remove is the other ' +
|
|
68
|
+
'route and needs no category.',
|
|
69
|
+
},
|
|
60
70
|
AdminProductsController_setStatusForProducts: {
|
|
61
|
-
|
|
62
|
-
'
|
|
63
|
-
'
|
|
64
|
-
'AdminProductsController_findAll.
|
|
65
|
-
'AdminProductsController_update
|
|
71
|
+
note: 'The status id goes in "statusId". The older field name "id" is accepted for the same ' +
|
|
72
|
+
'value, and a body carrying neither answers 400 rather than writing an empty status. ' +
|
|
73
|
+
'The call re-indexes what it changed, so the new status is visible to ' +
|
|
74
|
+
'AdminProductsController_findAll and not only to a read by id. For a single product, ' +
|
|
75
|
+
'sending statusId through AdminProductsController_update does the same job.',
|
|
66
76
|
verifyWith: {
|
|
67
77
|
opId: 'AdminProductsController_findOne',
|
|
68
78
|
check: 'statusId',
|
|
69
|
-
why: 'the
|
|
79
|
+
why: 'the response says the call was accepted, not which products ended up with the status',
|
|
70
80
|
},
|
|
71
81
|
},
|
|
72
82
|
AdminAttributesSetsController_create: {
|
|
73
|
-
note: 'Inside an attribute, "validators", "localizeInfos"
|
|
74
|
-
'locale first: validators.en_US.requiredValidator, not
|
|
75
|
-
'A flat map
|
|
83
|
+
note: 'Inside an attribute, "validators", "localizeInfos", "listTitles" and "additionalFields" ' +
|
|
84
|
+
'are keyed by locale first: validators.en_US.requiredValidator, not ' +
|
|
85
|
+
'validators.requiredValidator. A flat map answers 400 here, and the message names the ' +
|
|
86
|
+
'attribute and the field — only AdminAttributesSetsController_updateSchema still accepts ' +
|
|
87
|
+
'one silently. ' +
|
|
76
88
|
LIST_EXTRAS_NOTE,
|
|
77
89
|
example: LIST_ATTRIBUTE_WITH_EXTRAS,
|
|
78
90
|
verifyWith: {
|
|
79
91
|
opId: 'AdminAttributesSetsController_findOne',
|
|
80
92
|
check: 'schema[].validators.<locale>',
|
|
81
|
-
why: 'the raw set
|
|
93
|
+
why: 'the raw set is where a locale key is either present or not, whatever the panel shows',
|
|
82
94
|
},
|
|
83
95
|
},
|
|
84
96
|
AdminAttributesSetsController_update: {
|
|
85
|
-
note: 'Inside an attribute, "validators", "localizeInfos"
|
|
86
|
-
'locale first
|
|
87
|
-
'Dropping an attribute from the set
|
|
88
|
-
'
|
|
89
|
-
'read
|
|
90
|
-
'
|
|
97
|
+
note: 'Inside an attribute, "validators", "localizeInfos", "listTitles" and "additionalFields" ' +
|
|
98
|
+
'are keyed by locale first, and a flat map answers 400 here as it does on create. ' +
|
|
99
|
+
'Dropping an attribute from the set clears the values entities held under its key, but ' +
|
|
100
|
+
'not by the time the call returns: the cleanup runs behind the write, so for a short ' +
|
|
101
|
+
'window a read still shows the removed key. Do not build an update from a read taken ' +
|
|
102
|
+
'immediately after removing an attribute — compare the keys you are about to send against ' +
|
|
103
|
+
'the current set and drop the ones it no longer defines. ' +
|
|
91
104
|
LIST_EXTRAS_NOTE,
|
|
92
105
|
example: LIST_ATTRIBUTE_WITH_EXTRAS,
|
|
93
106
|
verifyWith: {
|
|
@@ -97,12 +110,18 @@ export const OPERATION_NOTES = {
|
|
|
97
110
|
},
|
|
98
111
|
},
|
|
99
112
|
AdminAttributesSetsController_updateSchema: {
|
|
100
|
-
note: 'The body is the schema object itself, never wrapped as { "schema": … }.
|
|
101
|
-
'
|
|
102
|
-
'
|
|
103
|
-
'
|
|
113
|
+
note: 'The body is the schema object itself, never wrapped as { "schema": … }. Locale-keyed ' +
|
|
114
|
+
'rules apply here too, and so does the cleanup rule: an attribute removed here has its ' +
|
|
115
|
+
'values cleared behind the write, so a read taken straight afterwards can still show the ' +
|
|
116
|
+
'old key. ' +
|
|
104
117
|
LIST_EXTRAS_NOTE,
|
|
105
118
|
example: LIST_ATTRIBUTE_WITH_EXTRAS,
|
|
119
|
+
silentNoOp: 'This is the one route that does NOT check locale keying: a flat "validators", ' +
|
|
120
|
+
'"localizeInfos", "listTitles" or "additionalFields" answers 200 and is stored where no ' +
|
|
121
|
+
'consumer reads it, so a field you meant to make required is simply not enforced. Create ' +
|
|
122
|
+
'and update of the set answer 400 for the same body. Wrapping the body as ' +
|
|
123
|
+
'{ "schema": … } is worse and equally quiet: it answers 200 and replaces the set with a ' +
|
|
124
|
+
'single attribute called "schema". Read the set back after every schema replace.',
|
|
106
125
|
verifyWith: {
|
|
107
126
|
opId: 'AdminAttributesSetsController_findOne',
|
|
108
127
|
check: 'schema[].validators.<locale>',
|
|
@@ -120,13 +139,20 @@ export const OPERATION_NOTES = {
|
|
|
120
139
|
'and reports no error; the only repair is uploading the file again. That matters because ' +
|
|
121
140
|
'previewLink.default[0] is the inline placeholder a site renders while the full image ' +
|
|
122
141
|
'loads, so an image without it cannot be shown progressively. The stored record carries ' +
|
|
123
|
-
'no "alt" and no "title"
|
|
124
|
-
'
|
|
142
|
+
'no "alt" and no "title", but the attribute slot you write it into keeps whatever keys ' +
|
|
143
|
+
'you add: send "alt" and "title" beside the upload\'s own fields and they come back from ' +
|
|
144
|
+
'the public read. Use exactly those two names — the admin panel offers the same pair on ' +
|
|
145
|
+
'an image attribute value and writes them into the same slot, so text set either way ' +
|
|
146
|
+
'survives an edit made the other way.',
|
|
125
147
|
},
|
|
126
148
|
AdminMenusController_create: {
|
|
127
|
-
note: '
|
|
128
|
-
'
|
|
129
|
-
'
|
|
149
|
+
note: 'A create may carry pagesIds. The ids are checked before anything is written: one that ' +
|
|
150
|
+
'does not exist answers 404 naming it and no menu is created, so a typo costs nothing to ' +
|
|
151
|
+
'clean up. What the create does NOT do is nest anything — pagesIds is a flat set wherever ' +
|
|
152
|
+
'it appears, and the pages arrive as siblings at the top level whatever their relationship ' +
|
|
153
|
+
'in the page tree. Creating the menu empty and attaching with ' +
|
|
154
|
+
'AdminMenusController_update is equally valid and the better shape when you are building ' +
|
|
155
|
+
'the tree level by level.',
|
|
130
156
|
},
|
|
131
157
|
AdminMenusController_update: {
|
|
132
158
|
note: '"pagesIds" is a flat set of page ids and nothing else: nesting is NOT taken from the ' +
|
|
@@ -145,46 +171,52 @@ export const OPERATION_NOTES = {
|
|
|
145
171
|
AdminMenusController_createCustomItem: {
|
|
146
172
|
note: 'A custom item is for anything that is not a page: a product, an external address, a ' +
|
|
147
173
|
'column heading. An empty "value" is rejected, so a heading with no link needs a ' +
|
|
148
|
-
'placeholder target such as "#". The
|
|
149
|
-
'
|
|
150
|
-
'
|
|
151
|
-
'
|
|
152
|
-
'
|
|
153
|
-
'the
|
|
174
|
+
'placeholder target such as "#". The create body takes localizeInfos and value only: the ' +
|
|
175
|
+
'item arrives at the top level and is nested afterwards with ' +
|
|
176
|
+
'AdminMenusController_updateCustomItemPosition. Custom items and page items are numbered ' +
|
|
177
|
+
'separately, so the same number can name one of each — which is why a parent is addressed ' +
|
|
178
|
+
'by kind as well as number. Reads return "parentType" beside "parentId", and a public read ' +
|
|
179
|
+
'also returns "itemType" for the item itself, which is the value its children have to send.',
|
|
154
180
|
verifyWith: {
|
|
155
181
|
opId: 'AdminMenusController_findOne',
|
|
156
|
-
check: 'the
|
|
157
|
-
why: 'a
|
|
182
|
+
check: 'the parent of the item, its parentType, and where it appears in the tree',
|
|
183
|
+
why: 'a parent resolved to the other kind puts the item under a plausible wrong branch',
|
|
158
184
|
},
|
|
159
185
|
},
|
|
160
186
|
AdminMenusController_updatePosition: {
|
|
161
|
-
example: {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
'
|
|
168
|
-
'
|
|
169
|
-
'
|
|
170
|
-
'
|
|
171
|
-
'
|
|
187
|
+
example: {
|
|
188
|
+
position: { leftObjectId: null, rightObjectId: null },
|
|
189
|
+
newParentId: 12,
|
|
190
|
+
newParentType: 'custom',
|
|
191
|
+
},
|
|
192
|
+
note: '"position" is required even when the order does not matter, and it must be an object: a ' +
|
|
193
|
+
'body without it answers 400 naming the field, and an empty object is not enough — send ' +
|
|
194
|
+
'{ leftObjectId: null, rightObjectId: null }. Both re-parenting and sibling order apply, ' +
|
|
195
|
+
'and a public read returns the items in that order. Neighbours are addressed by the id of ' +
|
|
196
|
+
'the item, not of its position, and page items and custom items are numbered separately: ' +
|
|
197
|
+
'in a mixed row name each neighbour with "leftObjectType" and "rightObjectType", or a ' +
|
|
198
|
+
'neighbour of the other kind is not found and the item lands at the edge of the list. ' +
|
|
199
|
+
'"newParentType" says which kind the new parent is; omit it and the number is looked up ' +
|
|
200
|
+
'among pages first, which is not necessarily the parent you meant.',
|
|
172
201
|
verifyWith: {
|
|
173
202
|
opId: 'AdminMenusController_findOne',
|
|
174
|
-
check: 'the parent of the item you moved, and the order of its siblings',
|
|
175
|
-
why: '
|
|
203
|
+
check: 'the parent of the item you moved, its parentType, and the order of its siblings',
|
|
204
|
+
why: 'a neighbour or parent resolved to the other kind answers success and moves it elsewhere',
|
|
176
205
|
},
|
|
177
206
|
},
|
|
178
207
|
AdminMenusController_updateCustomItemPosition: {
|
|
179
|
-
example: {
|
|
208
|
+
example: {
|
|
209
|
+
position: { leftObjectId: null, rightObjectId: null },
|
|
210
|
+
newParentId: 12,
|
|
211
|
+
newParentType: 'custom',
|
|
212
|
+
},
|
|
180
213
|
note: 'Same body as AdminMenusController_updatePosition: "position" is required and must be an ' +
|
|
181
|
-
'object — { leftObjectId: null, rightObjectId: null } when the order does not matter
|
|
182
|
-
|
|
183
|
-
'public reads order items by page id rather than by the positions stored here.',
|
|
214
|
+
'object — { leftObjectId: null, rightObjectId: null } when the order does not matter — and ' +
|
|
215
|
+
'the kind of every neighbour and of the new parent is named alongside its number.',
|
|
184
216
|
verifyWith: {
|
|
185
217
|
opId: 'AdminMenusController_findOne',
|
|
186
|
-
check: 'the parent of the item you moved, and the order of its siblings',
|
|
187
|
-
why: '
|
|
218
|
+
check: 'the parent of the item you moved, its parentType, and the order of its siblings',
|
|
219
|
+
why: 'a neighbour or parent resolved to the other kind answers success and moves it elsewhere',
|
|
188
220
|
},
|
|
189
221
|
},
|
|
190
222
|
AdminFormsController_create: {
|
|
@@ -201,7 +233,14 @@ export const OPERATION_NOTES = {
|
|
|
201
233
|
note: '"formModuleConfigs" is a full replacement list, not a patch. Omitting it deletes every ' +
|
|
202
234
|
'module binding of the form AND the submissions recorded against those bindings, and the ' +
|
|
203
235
|
'call still answers 200 true. Read the form first and send its current formModuleConfigs ' +
|
|
204
|
-
'back unless changing them is the point.
|
|
236
|
+
'back unless changing them is the point. An existing binding is matched by its own "id" ' +
|
|
237
|
+
'and by nothing else: an entry carrying the same formId, moduleId and entityIdentifiers ' +
|
|
238
|
+
'but no id is created as a NEW binding, the previous one is deleted, and the submissions ' +
|
|
239
|
+
'counted against it stop being counted — the count route for the form goes to zero. ' +
|
|
240
|
+
'Worse, a public read of the entity keeps naming the previous binding for as long as its ' +
|
|
241
|
+
'answer is cached, so a site submitting against it answers 400 "Incorrect formIdentifier ' +
|
|
242
|
+
'for provided config" after the edit rather than during it. Echo formModuleConfigs[].id ' +
|
|
243
|
+
'back on every entry you are keeping. This is also the only operation that creates a ' +
|
|
205
244
|
'binding: an entry with formId, moduleId and either isGlobal or entityIdentifiers. ' +
|
|
206
245
|
'The rating behaviour of a review form lives in that same entry and has no operation of ' +
|
|
207
246
|
'its own: isRating, ratingCalculation, maxRatingScale, allowHalfRatings, allowRerating ' +
|
|
@@ -225,8 +264,9 @@ export const OPERATION_NOTES = {
|
|
|
225
264
|
},
|
|
226
265
|
verifyWith: {
|
|
227
266
|
opId: 'AdminFormsController_findOne',
|
|
228
|
-
check: 'formModuleConfigs',
|
|
229
|
-
why: 'an omitted formModuleConfigs is applied as "unbind everything", submissions included'
|
|
267
|
+
check: 'formModuleConfigs[].id',
|
|
268
|
+
why: 'an omitted formModuleConfigs is applied as "unbind everything", submissions included, ' +
|
|
269
|
+
'and a binding sent without its id comes back with a new one, which is the same loss',
|
|
230
270
|
},
|
|
231
271
|
},
|
|
232
272
|
AdminFormDataController_create: {
|
|
@@ -241,9 +281,13 @@ export const OPERATION_NOTES = {
|
|
|
241
281
|
'not mean the config is optional. A form with type null fails earlier with "Form has ' +
|
|
242
282
|
'incorrect type". Field validation is wired into the Content API only: through the Admin ' +
|
|
243
283
|
'API a missing required field is stored rather than rejected, so a submission accepted ' +
|
|
244
|
-
'here does not prove a visitor\'s submission would pass. A field of type text takes ' +
|
|
245
|
-
'
|
|
246
|
-
'
|
|
284
|
+
'here does not prove a visitor\'s submission would pass. A field of type text takes a ' +
|
|
285
|
+
'list of ONE object carrying EXACTLY ONE of plainValue, htmlValue or mdValue and no ' +
|
|
286
|
+
'other key: [{ "plainValue": "…" }]. A bare string, a list of strings, ' +
|
|
287
|
+
'[{ "value": … }], two of the three keys together and the four-key shape an entity ' +
|
|
288
|
+
'attribute of type text uses (htmlValue, mdValue, plainValue, params) are each ' +
|
|
289
|
+
'rejected, the last two with a message that names no field. On a rating form the score ' +
|
|
290
|
+
'attribute must be marked isRatingValue, or the ' +
|
|
247
291
|
'form is invalid with "Form must have a rating marker". This route also treats one ' +
|
|
248
292
|
'authenticated author as one submission per entity, so it rejects the second review of a ' +
|
|
249
293
|
'product with "You have already rated": a bulk import of visitor reviews cannot go ' +
|
|
@@ -257,10 +301,10 @@ export const OPERATION_NOTES = {
|
|
|
257
301
|
AdminPagesController_update: {
|
|
258
302
|
note: 'Omitting parentId does not leave the parent alone — it moves the page to the root and ' +
|
|
259
303
|
'decrements the former parent\'s childrenCount. Read the page first and send parentId ' +
|
|
260
|
-
'back unchanged unless you mean to re-parent it.
|
|
261
|
-
'
|
|
262
|
-
'
|
|
263
|
-
'
|
|
304
|
+
'back unchanged unless you mean to re-parent it. "position" is safe to echo back: a ' +
|
|
305
|
+
'listing returns it as a lexorank string, and this operation accepts that string and ' +
|
|
306
|
+
'ignores it, so a read-modify-write body no longer has to be stripped of the field. It ' +
|
|
307
|
+
'also does not reorder anything — the page position operation is what changes order.',
|
|
264
308
|
verifyWith: {
|
|
265
309
|
opId: 'AdminPagesController_findOne',
|
|
266
310
|
check: 'parentId',
|
|
@@ -286,25 +330,24 @@ export const OPERATION_NOTES = {
|
|
|
286
330
|
'under "mailing", which belongs to the mailing module. Placeholders depend on the module: ' +
|
|
287
331
|
'{{ product.title }} and {{ product.<marker> }} for the catalogue, {{ user.<marker> }} ' +
|
|
288
332
|
'for the recipient. Events support six modules — catalog, forms, orders, users, payments ' +
|
|
289
|
-
'and discounts.
|
|
290
|
-
'
|
|
291
|
-
|
|
292
|
-
'
|
|
293
|
-
'
|
|
294
|
-
'
|
|
295
|
-
'the object and reporting success.',
|
|
333
|
+
'and discounts. A moduleId outside those six answers 400 and the message names every ' +
|
|
334
|
+
'module that is accepted; the same check runs on update, so an event cannot be moved onto ' +
|
|
335
|
+
'an unsupported one either. There is no content module among them, so "notify when this ' +
|
|
336
|
+
'page changes" is not an events task: say so rather than looking for a body that gets ' +
|
|
337
|
+
'through. Whether an event that fired actually reached anyone is a separate read — ' +
|
|
338
|
+
'AdminEventsController_findEmailLogs.',
|
|
296
339
|
verifyWith: {
|
|
297
340
|
opId: 'AdminEventsController_findOne',
|
|
298
341
|
check: 'moduleId and localizeInfos.<locale>.title',
|
|
299
|
-
why: '
|
|
342
|
+
why: 'a name sent only as "name" is stored and never displayed, and the create still answers 201',
|
|
300
343
|
},
|
|
301
344
|
},
|
|
302
345
|
AdminEventsController_update: {
|
|
303
346
|
note: 'The event name, the mail subject and the mail body all live in ' +
|
|
304
347
|
'localizeInfos.<locale> as "title", "subject" and "template", with "push" for the push ' +
|
|
305
348
|
'channel. Nothing about the message belongs under "mailing". Supported modules are ' +
|
|
306
|
-
'catalog, forms, orders, users, payments and discounts; any other moduleId
|
|
307
|
-
'
|
|
349
|
+
'catalog, forms, orders, users, payments and discounts; any other moduleId answers 400, ' +
|
|
350
|
+
'checked against the merged result so a partial update cannot slip past it.',
|
|
308
351
|
example: {
|
|
309
352
|
localizeInfos: {
|
|
310
353
|
en_US: {
|
|
@@ -337,28 +380,29 @@ export const OPERATION_NOTES = {
|
|
|
337
380
|
},
|
|
338
381
|
},
|
|
339
382
|
AdminDiscountsController_createCoupon: {
|
|
340
|
-
note: '
|
|
341
|
-
'for everyone, any number of times.
|
|
342
|
-
'
|
|
343
|
-
'
|
|
344
|
-
'
|
|
345
|
-
'
|
|
383
|
+
note: '"isReusable" is accepted in the body, and the default when you omit it is TRUE for this ' +
|
|
384
|
+
'operation — one code, valid for everyone, any number of times. That default is the ' +
|
|
385
|
+
'opposite of the one AdminDiscountsController_generateCouponsByMask applies, so on a ' +
|
|
386
|
+
'"first order" discount the field decides whether one customer gets the discount or ' +
|
|
387
|
+
'everyone does, permanently. State the reuse you mean rather than relying on either ' +
|
|
388
|
+
'default.',
|
|
346
389
|
verifyWith: {
|
|
347
390
|
opId: 'AdminDiscountsController_findOne',
|
|
348
391
|
check: 'the coupon\'s isReusable',
|
|
349
|
-
why: '
|
|
392
|
+
why: 'an omitted field takes a default that differs between the two ways of making a coupon',
|
|
350
393
|
},
|
|
351
394
|
},
|
|
352
395
|
AdminDiscountsController_generateCouponsByMask: {
|
|
353
|
-
note: '
|
|
354
|
-
'after the order that used it.
|
|
355
|
-
'
|
|
396
|
+
note: '"isReusable" is accepted in the body here too, and the default when you omit it is ' +
|
|
397
|
+
'FALSE — each generated code stops working after the order that used it. Send the field ' +
|
|
398
|
+
'explicitly when the reuse matters; the two coupon operations disagree only in what an ' +
|
|
399
|
+
'omitted value means.',
|
|
356
400
|
},
|
|
357
401
|
AdminProductsController_update: {
|
|
358
402
|
note: 'Always include "blocks" — send [] when there is nothing to set, because omitting it ' +
|
|
359
403
|
'fails the update. Never include "forms": the schema accepts the field and saving rejects ' +
|
|
360
|
-
'it.
|
|
361
|
-
'
|
|
404
|
+
'it. A product status can be set here with "statusId", or for many products in one call ' +
|
|
405
|
+
'with AdminProductsController_setStatusForProducts.',
|
|
362
406
|
},
|
|
363
407
|
AdminBlocksController_update: {
|
|
364
408
|
note: 'Omitting blockPages detaches the block from every page it was on. Read the block first ' +
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { unsupportedBodyFormat } from '../api/client.js';
|
|
2
|
+
import { undeliverablePathParams, unsupportedBodyFormat } from '../api/client.js';
|
|
3
3
|
import { errorResult, jsonResult } from './result.js';
|
|
4
4
|
const sampleValue = (schema, example) => {
|
|
5
5
|
if (example !== undefined) {
|
|
@@ -110,7 +110,7 @@ export const registerApiDiscovery = (server, deps) => {
|
|
|
110
110
|
: 'Operation ids come from cms_api_search — do not construct them by hand.',
|
|
111
111
|
});
|
|
112
112
|
}
|
|
113
|
-
const formatDenial = unsupportedBodyFormat(operation);
|
|
113
|
+
const formatDenial = unsupportedBodyFormat(operation) ?? undeliverablePathParams(operation);
|
|
114
114
|
const properties = operation.body?.schema.properties;
|
|
115
115
|
const loose = properties
|
|
116
116
|
? Object.entries(properties)
|
|
@@ -170,7 +170,8 @@ export const registerApiDiscovery = (server, deps) => {
|
|
|
170
170
|
next: formatDenial
|
|
171
171
|
? operation.body?.contentType === 'multipart/form-data'
|
|
172
172
|
? 'Do not call this with cms_api_call — upload with cms_upload_file or cms_import_file_from_url.'
|
|
173
|
-
: 'Do not call this operation
|
|
173
|
+
: 'Do not call this operation. Take the route named in "notExecutable" or in ' +
|
|
174
|
+
'"note" above, and report it as unavailable through MCP when there is none.'
|
|
174
175
|
: operation.risk === 'read'
|
|
175
176
|
? 'Call it with cms_api_call, copying the "example" above.'
|
|
176
177
|
: operation.verifyWith
|
|
@@ -51,7 +51,7 @@ Ordering is a lexorank **string** on parent-scoped Admin operations and a **numb
|
|
|
51
51
|
|
|
52
52
|
## A read straight after a write can lag
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
An **admin** read by id shows your write immediately; admin lists and **every public read**, one entity included, lag by seconds. So re-read after a pause and **never repeat the write** — that makes a duplicate somebody cleans up by hand. Never swallow a failed read into an empty result either: the next run then recreates everything.
|
|
55
55
|
|
|
56
56
|
## A 200 means accepted not applied
|
|
57
57
|
|
package/package.json
CHANGED