@oneentry/mcp-platform-server 0.1.5 → 0.1.6
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/README.md +29 -2
- package/dist/api/build-catalog.js +1 -0
- package/dist/api/client.d.ts +6 -0
- package/dist/api/client.js +57 -8
- package/dist/api/operation-notes.d.ts +1 -0
- package/dist/api/operation-notes.js +258 -10
- package/dist/api/types.d.ts +1 -0
- package/dist/api/upload.d.ts +23 -0
- package/dist/api/upload.js +189 -0
- package/dist/bin/cli.js +7 -1
- package/dist/config/config.d.ts +37 -0
- package/dist/config/config.js +15 -0
- package/dist/server.js +3 -0
- package/dist/tools/api-discovery.js +12 -2
- package/dist/tools/guide.js +10 -6
- package/dist/tools/upload.d.ts +3 -0
- package/dist/tools/upload.js +229 -0
- package/knowledge/operating-rules.md +33 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ arguments, so a prompt-injected instruction cannot swap identities:
|
|
|
71
71
|
|
|
72
72
|
Sessions never share tokens or confirm tokens. `GET /health` reports liveness and session count.
|
|
73
73
|
|
|
74
|
-
## The
|
|
74
|
+
## The nine tools
|
|
75
75
|
|
|
76
76
|
| tool | what it does |
|
|
77
77
|
|---|---|
|
|
@@ -79,12 +79,39 @@ Sessions never share tokens or confirm tokens. `GET /health` reports liveness an
|
|
|
79
79
|
| `cms_docs_search` | search the knowledge base, returns sections with snippets |
|
|
80
80
|
| `cms_docs_read` | read one section, with its sibling sections listed for paging |
|
|
81
81
|
| `cms_api_search` | find operations by keyword / tag / method / mutating |
|
|
82
|
-
| `cms_api_describe` | params, body schema, required permission, risk, confirm gating |
|
|
82
|
+
| `cms_api_describe` | params, body schema, required permission, risk, confirm gating, and `curatedBody` where a shape has been verified on a live instance |
|
|
83
83
|
| `cms_api_call` | execute one operation; `dryRun` and confirm-gating for mutations |
|
|
84
|
+
| `cms_upload_file` | upload one file from this machine as `multipart/form-data` (local mode only) |
|
|
85
|
+
| `cms_import_file_from_url` | fetch one file over http(s) and upload it in a single step |
|
|
84
86
|
| `cms_whoami` | mode, base URL, admin + permissions, knowledge commit, catalog state and warnings |
|
|
85
87
|
|
|
86
88
|
Plus two MCP resources: `oneentry://knowledge/mcp/operating-rules` and `oneentry://knowledge/index`.
|
|
87
89
|
|
|
90
|
+
### Uploading files
|
|
91
|
+
|
|
92
|
+
`cms_api_call` sends JSON only, and the upload endpoint wants `multipart/form-data` — which is why
|
|
93
|
+
the two upload tools exist rather than a note telling the agent to go around the server. They run
|
|
94
|
+
through the same gate as any other write: `--allow=write`, the local permission check, `dryRun`, and
|
|
95
|
+
the audit line.
|
|
96
|
+
|
|
97
|
+
Both bound their **source**, because a path or a URL arrives as a tool argument and a tool argument
|
|
98
|
+
can be prompt-injected:
|
|
99
|
+
|
|
100
|
+
| flag | env | default | what it bounds |
|
|
101
|
+
|---|---|---|---|
|
|
102
|
+
| `--upload-root` | `ONEENTRY_MCP_UPLOAD_ROOT` | the process working directory | `cms_upload_file` reads nothing outside this directory, symlinks resolved first |
|
|
103
|
+
| `--upload-max-bytes` | `ONEENTRY_MCP_UPLOAD_MAX_BYTES` | 25 MiB | size of one file, checked on disk and on the wire |
|
|
104
|
+
| `--upload-allowed-hosts` | `ONEENTRY_MCP_UPLOAD_ALLOWED_HOSTS` | empty | hosts `cms_import_file_from_url` may fetch from |
|
|
105
|
+
|
|
106
|
+
`cms_upload_file` is refused in remote mode: there is no shared filesystem, and a session-supplied
|
|
107
|
+
path would read the host's files. `cms_import_file_from_url` refuses any address resolving to a
|
|
108
|
+
loopback, private, link-local or carrier-grade-NAT range, re-checks every redirect hop, and in remote
|
|
109
|
+
mode stays disabled until the operator sets an allowlist.
|
|
110
|
+
|
|
111
|
+
Pass `template` — the **numeric id** of a `/template-previews` record — on the first upload. Without
|
|
112
|
+
it the file is stored with no `previewLink`, nothing reports the omission, and the only repair is
|
|
113
|
+
uploading the file again.
|
|
114
|
+
|
|
88
115
|
## Write safety
|
|
89
116
|
|
|
90
117
|
Read-only by default. `--allow` (or `ONEENTRY_MCP_ALLOW`) raises it:
|
|
@@ -217,6 +217,7 @@ export const buildCatalog = (params) => {
|
|
|
217
217
|
? { responseSummary: responseSummary(operation['responses']) }
|
|
218
218
|
: {}),
|
|
219
219
|
...(notes?.note ? { note: notes.note } : {}),
|
|
220
|
+
...(notes?.example !== undefined ? { curatedExample: notes.example } : {}),
|
|
220
221
|
...(notes?.verifyWith ? { verifyWith: notes.verifyWith } : {}),
|
|
221
222
|
...(notes?.silentNoOp ? { silentNoOp: notes.silentNoOp } : {}),
|
|
222
223
|
searchText: '',
|
package/dist/api/client.d.ts
CHANGED
|
@@ -33,5 +33,11 @@ export declare class AdminApiClient {
|
|
|
33
33
|
timeoutMs: number;
|
|
34
34
|
});
|
|
35
35
|
call(operation: Operation, args: CallArgs): Promise<CallResult>;
|
|
36
|
+
upload(operation: Operation, args: CallArgs, payload: {
|
|
37
|
+
bytes: Uint8Array;
|
|
38
|
+
filename: string;
|
|
39
|
+
contentType: string;
|
|
40
|
+
}): Promise<CallResult>;
|
|
41
|
+
private send;
|
|
36
42
|
fetchPermissions(adminId: number): Promise<string[]>;
|
|
37
43
|
}
|
package/dist/api/client.js
CHANGED
|
@@ -21,9 +21,15 @@ export const unsupportedBodyFormat = (operation) => {
|
|
|
21
21
|
if (contentType === undefined || /^application\/(\w[\w.+-]*\+)?json$/.test(contentType)) {
|
|
22
22
|
return undefined;
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
'can only send application/json. The call is not executable through
|
|
26
|
-
'shape will help.
|
|
24
|
+
const base = `Operation ${operation.opId} expects a "${contentType}" request body, and cms_api_call ` +
|
|
25
|
+
'can only send application/json. The call is not executable through cms_api_call — no ' +
|
|
26
|
+
'body shape will help.';
|
|
27
|
+
if (contentType === 'multipart/form-data') {
|
|
28
|
+
return (`${base} Upload through cms_upload_file (a file on the machine running this server) or ` +
|
|
29
|
+
'cms_import_file_from_url (this server fetches it). Both keep the allow level, the ' +
|
|
30
|
+
'confirm gate and the audit line.');
|
|
31
|
+
}
|
|
32
|
+
return `${base} Report it to the human and use another route to that instance.`;
|
|
27
33
|
};
|
|
28
34
|
export const buildUrl = (baseUrl, operation, args) => {
|
|
29
35
|
let path = operation.path;
|
|
@@ -57,6 +63,10 @@ const hintFor = (status, operation) => {
|
|
|
57
63
|
if (status === 400 || status === 422) {
|
|
58
64
|
const loose = operation.body?.schema['x-loose'] === true;
|
|
59
65
|
return (`Validation rejected the payload. ${loose ? 'This body has loosely typed fields — copy the shape from the example in cms_api_describe. ' : ''}` +
|
|
66
|
+
(operation.curatedExample !== undefined
|
|
67
|
+
? 'A body shape verified on a live instance is in cms_api_describe under "curatedBody" — ' +
|
|
68
|
+
'compare yours with it before changing anything else. '
|
|
69
|
+
: '') +
|
|
60
70
|
'Search the knowledge base with cms_docs_search before retrying.');
|
|
61
71
|
}
|
|
62
72
|
if (status === 403) {
|
|
@@ -84,6 +94,12 @@ const readBody = async (response) => {
|
|
|
84
94
|
return text.slice(0, 2000);
|
|
85
95
|
}
|
|
86
96
|
};
|
|
97
|
+
const isLoginPage = (response, body) => {
|
|
98
|
+
if ((response.headers.get('content-type') ?? '').includes('text/html')) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return typeof body === 'string' && body.trimStart().startsWith('<');
|
|
102
|
+
};
|
|
87
103
|
const extractMessage = (body, fallback) => {
|
|
88
104
|
if (typeof body === 'string') {
|
|
89
105
|
return body.slice(0, 500);
|
|
@@ -110,7 +126,7 @@ export class AdminApiClient {
|
|
|
110
126
|
}
|
|
111
127
|
async call(operation, args) {
|
|
112
128
|
const url = buildUrl(this.baseUrl, operation, args);
|
|
113
|
-
|
|
129
|
+
return this.send(operation, url, (token) => {
|
|
114
130
|
const headers = { authorization: `Bearer ${token}` };
|
|
115
131
|
let payload;
|
|
116
132
|
if (args.body !== undefined && operation.method !== 'get') {
|
|
@@ -123,12 +139,46 @@ export class AdminApiClient {
|
|
|
123
139
|
...(payload !== undefined ? { body: payload } : {}),
|
|
124
140
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
125
141
|
});
|
|
126
|
-
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async upload(operation, args, payload) {
|
|
145
|
+
const url = buildUrl(this.baseUrl, operation, args);
|
|
146
|
+
return this.send(operation, url, (token) => {
|
|
147
|
+
const form = new FormData();
|
|
148
|
+
form.append('file', new Blob([payload.bytes], { type: payload.contentType }), payload.filename);
|
|
149
|
+
return fetch(url, {
|
|
150
|
+
method: operation.method.toUpperCase(),
|
|
151
|
+
headers: { authorization: `Bearer ${token}` },
|
|
152
|
+
body: form,
|
|
153
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async send(operation, url, attempt) {
|
|
127
158
|
let response;
|
|
159
|
+
let body;
|
|
128
160
|
try {
|
|
129
|
-
response = await
|
|
161
|
+
response = await attempt(await this.tokens.accessToken());
|
|
130
162
|
if (response.status === 401) {
|
|
131
|
-
response = await
|
|
163
|
+
response = await attempt(await this.tokens.refresh());
|
|
164
|
+
}
|
|
165
|
+
body = await readBody(response);
|
|
166
|
+
if (response.ok && isLoginPage(response, body)) {
|
|
167
|
+
response = await attempt(await this.tokens.refresh());
|
|
168
|
+
body = await readBody(response);
|
|
169
|
+
if (response.ok && isLoginPage(response, body)) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
error: {
|
|
173
|
+
status: response.status,
|
|
174
|
+
message: `${operation.method.toUpperCase()} ${url} answered ${String(response.status)} with ` +
|
|
175
|
+
'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.',
|
|
177
|
+
hint: 'Check the credentials this server runs with, then retry. Treat every write since ' +
|
|
178
|
+
'the last verified read as unconfirmed and read those entities back.',
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
132
182
|
}
|
|
133
183
|
}
|
|
134
184
|
catch (error) {
|
|
@@ -141,7 +191,6 @@ export class AdminApiClient {
|
|
|
141
191
|
},
|
|
142
192
|
};
|
|
143
193
|
}
|
|
144
|
-
const body = await readBody(response);
|
|
145
194
|
if (!response.ok) {
|
|
146
195
|
const hint = hintFor(response.status, operation);
|
|
147
196
|
return {
|
|
@@ -8,6 +8,47 @@ export const ALWAYS_CONFIRM_PREFIXES = [
|
|
|
8
8
|
'/settings-general',
|
|
9
9
|
'/system/captcha-keys',
|
|
10
10
|
];
|
|
11
|
+
const LIST_ATTRIBUTE_WITH_EXTRAS = {
|
|
12
|
+
type: 'list',
|
|
13
|
+
identifier: 'labels',
|
|
14
|
+
multiselect: true,
|
|
15
|
+
localizeInfos: { en_US: { title: 'Labels' } },
|
|
16
|
+
listTitles: {
|
|
17
|
+
en_US: [
|
|
18
|
+
{
|
|
19
|
+
title: 'Dishwasher safe',
|
|
20
|
+
value: 'dishwasher-safe',
|
|
21
|
+
position: 1,
|
|
22
|
+
extended: {
|
|
23
|
+
type: 'image',
|
|
24
|
+
value: {
|
|
25
|
+
filename: 'dishwasher-safe.png',
|
|
26
|
+
downloadLink: 'https://your-instance.example/files/dishwasher-safe.png',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: 'Cherry',
|
|
32
|
+
value: 'cherry',
|
|
33
|
+
position: 2,
|
|
34
|
+
extended: { type: 'string', value: '#d11241' },
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const LIST_EXTRAS_NOTE = 'The curated example is ONE attribute as it appears in the set\'s schema list. Three ' +
|
|
40
|
+
'details in it are each enough on their own to leave the field blank in the admin panel ' +
|
|
41
|
+
'while every read returns exactly what you sent. "extended" carries NO locale key — it is ' +
|
|
42
|
+
'flat, "type" and "value", and it is the one exception to the locale-first rule that ' +
|
|
43
|
+
'applies to "localizeInfos", "validators" and "listTitles" around it. Its "type" is one of ' +
|
|
44
|
+
'string, integer, realNumber, fixedPointNumber, date, dateAndTime, time, image, file, json, ' +
|
|
45
|
+
'and for image or file the value is ONE object, not a list. "multiselect": true belongs to ' +
|
|
46
|
+
'the attribute: without it every selected option is stored and read back, and the panel ' +
|
|
47
|
+
'shows only the first one. Two neighbouring fields look right and are not: ' +
|
|
48
|
+
'"additionalFields" is a separate marker-keyed list of fields and holds no option extras, ' +
|
|
49
|
+
'and an "image" key on the option itself is stored and read by nothing. Option extras are ' +
|
|
50
|
+
'also absent from entity reads — a site gets them from the attribute definition, not from ' +
|
|
51
|
+
'the product.';
|
|
11
52
|
export const OPERATION_NOTES = {
|
|
12
53
|
AdminProductsController_findAll: {
|
|
13
54
|
readOnly: true,
|
|
@@ -31,7 +72,9 @@ export const OPERATION_NOTES = {
|
|
|
31
72
|
AdminAttributesSetsController_create: {
|
|
32
73
|
note: 'Inside an attribute, "validators", "localizeInfos" and "listTitles" are keyed by ' +
|
|
33
74
|
'locale first: validators.en_US.requiredValidator, not validators.requiredValidator. ' +
|
|
34
|
-
'A flat map is stored verbatim and read by nobody.'
|
|
75
|
+
'A flat map is stored verbatim and read by nobody. ' +
|
|
76
|
+
LIST_EXTRAS_NOTE,
|
|
77
|
+
example: LIST_ATTRIBUTE_WITH_EXTRAS,
|
|
35
78
|
verifyWith: {
|
|
36
79
|
opId: 'AdminAttributesSetsController_findOne',
|
|
37
80
|
check: 'schema[].validators.<locale>',
|
|
@@ -40,7 +83,13 @@ export const OPERATION_NOTES = {
|
|
|
40
83
|
},
|
|
41
84
|
AdminAttributesSetsController_update: {
|
|
42
85
|
note: 'Inside an attribute, "validators", "localizeInfos" and "listTitles" are keyed by ' +
|
|
43
|
-
'locale first. A flat map is accepted and stored where nothing reads it.'
|
|
86
|
+
'locale first. A flat map is accepted and stored where nothing reads it. ' +
|
|
87
|
+
'Dropping an attribute from the set does not remove the values entities already hold ' +
|
|
88
|
+
'under its key: they stay on every entity, are invisible in the panel, and a ' +
|
|
89
|
+
'read-modify-write cycle sends them back, so they outlive the attribute indefinitely. ' +
|
|
90
|
+
'Strip the removed keys from each entity yourself, in the same run. ' +
|
|
91
|
+
LIST_EXTRAS_NOTE,
|
|
92
|
+
example: LIST_ATTRIBUTE_WITH_EXTRAS,
|
|
44
93
|
verifyWith: {
|
|
45
94
|
opId: 'AdminAttributesSetsController_findOne',
|
|
46
95
|
check: 'schema[].validators.<locale>',
|
|
@@ -50,7 +99,10 @@ export const OPERATION_NOTES = {
|
|
|
50
99
|
AdminAttributesSetsController_updateSchema: {
|
|
51
100
|
note: 'The body is the schema object itself, never wrapped as { "schema": … }. The wrapped ' +
|
|
52
101
|
'form answers 200 and replaces the set with a single attribute called "schema". ' +
|
|
53
|
-
'Locale-keyed rules apply here too
|
|
102
|
+
'Locale-keyed rules apply here too, and so does the residue rule: an attribute removed ' +
|
|
103
|
+
'here leaves its values on every entity that had one. ' +
|
|
104
|
+
LIST_EXTRAS_NOTE,
|
|
105
|
+
example: LIST_ATTRIBUTE_WITH_EXTRAS,
|
|
54
106
|
verifyWith: {
|
|
55
107
|
opId: 'AdminAttributesSetsController_findOne',
|
|
56
108
|
check: 'schema[].validators.<locale>',
|
|
@@ -58,15 +110,83 @@ export const OPERATION_NOTES = {
|
|
|
58
110
|
},
|
|
59
111
|
},
|
|
60
112
|
AdminFileUploadController_uploadFiles: {
|
|
61
|
-
note: 'The
|
|
62
|
-
'
|
|
63
|
-
'
|
|
113
|
+
note: 'The body is multipart, so cms_api_call cannot send it — use cms_upload_file for a file ' +
|
|
114
|
+
'on the machine running this server, or cms_import_file_from_url to have the server ' +
|
|
115
|
+
'fetch it. Both keep the allow level, the confirm gate and the audit line. The binary ' +
|
|
116
|
+
'part is accepted under "file" and under "files" alike; everything else — type, entity, ' +
|
|
117
|
+
'id, compress, edit, template — is a query parameter. The "template" parameter is the ' +
|
|
118
|
+
'NUMERIC id of a /template-previews record, not a boolean flag. A fresh instance has no ' +
|
|
119
|
+
'such records, and an upload without a valid template id stores the file with no preview ' +
|
|
120
|
+
'and reports no error; the only repair is uploading the file again. That matters because ' +
|
|
121
|
+
'previewLink.default[0] is the inline placeholder a site renders while the full image ' +
|
|
122
|
+
'loads, so an image without it cannot be shown progressively. The stored record carries ' +
|
|
123
|
+
'no "alt" and no "title": alternative text has to live in a sibling attribute, and the ' +
|
|
124
|
+
'project has to agree on one naming convention for it before the first upload.',
|
|
64
125
|
},
|
|
65
126
|
AdminMenusController_create: {
|
|
66
127
|
note: 'Create the menu with an empty pagesIds, then attach pages with AdminMenusController_update. ' +
|
|
67
128
|
'A non-empty pagesIds on create answers 500 (null value in column "page_id"). That 500 is ' +
|
|
68
129
|
'known — do not report it as an unexplained server failure.',
|
|
69
130
|
},
|
|
131
|
+
AdminMenusController_update: {
|
|
132
|
+
note: '"pagesIds" is a flat set of page ids and nothing else: nesting is NOT taken from the ' +
|
|
133
|
+
'page tree, so a menu whose pages are nested in the content tree still reads back flat. ' +
|
|
134
|
+
'Build the levels afterwards, top down, with the position operations. A page can appear ' +
|
|
135
|
+
'in the set once — a page that has to sit in two places gets a custom item with the same ' +
|
|
136
|
+
'target for the second one, otherwise one of the two silently disappears. The label a ' +
|
|
137
|
+
'menu item shows is the page\'s "menuTitle", not its "title", so a menu built without ' +
|
|
138
|
+
'setting menuTitle arrives carrying page headings.',
|
|
139
|
+
verifyWith: {
|
|
140
|
+
opId: 'AdminMenusController_findOne',
|
|
141
|
+
check: 'the included pages, their parents and their labels',
|
|
142
|
+
why: 'the update answers 200 for a flat result as readily as for the tree you intended',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
AdminMenusController_createCustomItem: {
|
|
146
|
+
note: 'A custom item is for anything that is not a page: a product, an external address, a ' +
|
|
147
|
+
'column heading. An empty "value" is rejected, so a heading with no link needs a ' +
|
|
148
|
+
'placeholder target such as "#". The parent reference is a bare number with no kind ' +
|
|
149
|
+
'attached, while custom items and page items are numbered separately — so a custom item ' +
|
|
150
|
+
'and a page item can share an id, and children hung on that number are then returned ' +
|
|
151
|
+
'under BOTH parents. Before nesting anything, read the menu and check that the included ' +
|
|
152
|
+
'pages and the custom items share no id; if they do, the only route today is to recreate ' +
|
|
153
|
+
'the custom item until its id differs.',
|
|
154
|
+
verifyWith: {
|
|
155
|
+
opId: 'AdminMenusController_findOne',
|
|
156
|
+
check: 'the ids of included pages against the ids of customItems',
|
|
157
|
+
why: 'a shared id duplicates a whole branch, and the write that caused it answers 200',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
AdminMenusController_updatePosition: {
|
|
161
|
+
example: { position: { leftObjectId: null, rightObjectId: null }, newParentId: 12 },
|
|
162
|
+
note: '"position" is required even when the order does not matter, and it must be an object: ' +
|
|
163
|
+
'omitting it answers 500 about destructuring, and an empty object is not enough — send ' +
|
|
164
|
+
'{ leftObjectId: null, rightObjectId: null }. Re-parenting through "newParentId" does ' +
|
|
165
|
+
'apply; the ordering does not.',
|
|
166
|
+
silentNoOp: 'Re-parenting lands, but sibling order does not change: the call answers success and ' +
|
|
167
|
+
'every root item keeps the position it had. Public reads do not take item order from ' +
|
|
168
|
+
'positions either — page items arrive ordered by page id and custom items after them. ' +
|
|
169
|
+
'There is no body that produces a chosen order today, so build the menu correct in ' +
|
|
170
|
+
'composition and nesting, tell the human the order is not reproducible, and report it ' +
|
|
171
|
+
'rather than trying further bodies.',
|
|
172
|
+
verifyWith: {
|
|
173
|
+
opId: 'AdminMenusController_findOne',
|
|
174
|
+
check: 'the parent of the item you moved, and the order of its siblings',
|
|
175
|
+
why: 'the parent change applies while the order does not, and one response covers both',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
AdminMenusController_updateCustomItemPosition: {
|
|
179
|
+
example: { position: { leftObjectId: null, rightObjectId: null }, newParentId: 12 },
|
|
180
|
+
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
|
+
silentNoOp: 'Re-parenting lands; sibling order does not. See AdminMenusController_updatePosition — ' +
|
|
183
|
+
'public reads order items by page id rather than by the positions stored here.',
|
|
184
|
+
verifyWith: {
|
|
185
|
+
opId: 'AdminMenusController_findOne',
|
|
186
|
+
check: 'the parent of the item you moved, and the order of its siblings',
|
|
187
|
+
why: 'the parent change applies while the order does not, and one response covers both',
|
|
188
|
+
},
|
|
189
|
+
},
|
|
70
190
|
AdminFormsController_create: {
|
|
71
191
|
note: 'Send the payload wrapped under "newForm". The form "type" is missing from the schema but ' +
|
|
72
192
|
'is accepted and persisted: order | sign_in_up | collection | data | rating. A contact ' +
|
|
@@ -82,7 +202,27 @@ export const OPERATION_NOTES = {
|
|
|
82
202
|
'module binding of the form AND the submissions recorded against those bindings, and the ' +
|
|
83
203
|
'call still answers 200 true. Read the form first and send its current formModuleConfigs ' +
|
|
84
204
|
'back unless changing them is the point. This is also the only operation that creates a ' +
|
|
85
|
-
'binding: an entry with formId, moduleId and either isGlobal or entityIdentifiers.'
|
|
205
|
+
'binding: an entry with formId, moduleId and either isGlobal or entityIdentifiers. ' +
|
|
206
|
+
'The rating behaviour of a review form lives in that same entry and has no operation of ' +
|
|
207
|
+
'its own: isRating, ratingCalculation, maxRatingScale, allowHalfRatings, allowRerating ' +
|
|
208
|
+
'and isAnonymous. There is no other route to them, which is why they are worth reading ' +
|
|
209
|
+
'here before asking a human to set them in the panel.',
|
|
210
|
+
example: {
|
|
211
|
+
formModuleConfigs: [
|
|
212
|
+
{
|
|
213
|
+
formId: 5,
|
|
214
|
+
moduleId: 3,
|
|
215
|
+
isGlobal: true,
|
|
216
|
+
entityIdentifiers: [],
|
|
217
|
+
isRating: true,
|
|
218
|
+
ratingCalculation: 'average',
|
|
219
|
+
maxRatingScale: 5,
|
|
220
|
+
allowHalfRatings: false,
|
|
221
|
+
allowRerating: false,
|
|
222
|
+
isAnonymous: true,
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
},
|
|
86
226
|
verifyWith: {
|
|
87
227
|
opId: 'AdminFormsController_findOne',
|
|
88
228
|
check: 'formModuleConfigs',
|
|
@@ -101,7 +241,13 @@ export const OPERATION_NOTES = {
|
|
|
101
241
|
'not mean the config is optional. A form with type null fails earlier with "Form has ' +
|
|
102
242
|
'incorrect type". Field validation is wired into the Content API only: through the Admin ' +
|
|
103
243
|
'API a missing required field is stored rather than rejected, so a submission accepted ' +
|
|
104
|
-
'here does not prove a visitor\'s submission would pass.'
|
|
244
|
+
'here does not prove a visitor\'s submission would pass. A field of type text takes ' +
|
|
245
|
+
'[{ "htmlValue": "…" }] — a bare string, a list of strings and [{ "value": … }] are all ' +
|
|
246
|
+
'rejected. On a rating form the score attribute must be marked isRatingValue, or the ' +
|
|
247
|
+
'form is invalid with "Form must have a rating marker". This route also treats one ' +
|
|
248
|
+
'authenticated author as one submission per entity, so it rejects the second review of a ' +
|
|
249
|
+
'product with "You have already rated": a bulk import of visitor reviews cannot go ' +
|
|
250
|
+
'through the Admin API and has to use the visitor route, which this server does not call.',
|
|
105
251
|
verifyWith: {
|
|
106
252
|
opId: 'AdminFormDataController_findByFormMarker',
|
|
107
253
|
check: 'the submission you sent',
|
|
@@ -111,13 +257,109 @@ export const OPERATION_NOTES = {
|
|
|
111
257
|
AdminPagesController_update: {
|
|
112
258
|
note: 'Omitting parentId does not leave the parent alone — it moves the page to the root and ' +
|
|
113
259
|
'decrements the former parent\'s childrenCount. Read the page first and send parentId ' +
|
|
114
|
-
'back unchanged unless you mean to re-parent it.'
|
|
260
|
+
'back unchanged unless you mean to re-parent it. One field cannot be echoed back at all: ' +
|
|
261
|
+
'a listing returns "position" as a lexorank string while this operation expects an object ' +
|
|
262
|
+
'and answers 400 "position must be either object or array" — drop the field from a ' +
|
|
263
|
+
'read-modify-write body and reorder with the page position operation instead.',
|
|
115
264
|
verifyWith: {
|
|
116
265
|
opId: 'AdminPagesController_findOne',
|
|
117
266
|
check: 'parentId',
|
|
118
267
|
why: 'an omitted parentId is applied as "move to root", and the update still answers 200',
|
|
119
268
|
},
|
|
120
269
|
},
|
|
270
|
+
AdminEventsController_create: {
|
|
271
|
+
example: {
|
|
272
|
+
moduleId: 1,
|
|
273
|
+
localizeInfos: {
|
|
274
|
+
en_US: {
|
|
275
|
+
title: 'New product published',
|
|
276
|
+
subject: 'A new product is live',
|
|
277
|
+
template: '<p>{{ product.title }} is now available.</p>',
|
|
278
|
+
push: '{{ product.title }} is now available',
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
note: 'The name shown in the events list is localizeInfos.<locale>.title. "name" is what the ' +
|
|
283
|
+
'create body declares as required, and a name written only there is stored and never ' +
|
|
284
|
+
'displayed, so send both. The message itself lives in the same locale object — "subject" ' +
|
|
285
|
+
'for the mail subject, "template" for the mail body, "push" for the push body — and not ' +
|
|
286
|
+
'under "mailing", which belongs to the mailing module. Placeholders depend on the module: ' +
|
|
287
|
+
'{{ product.title }} and {{ product.<marker> }} for the catalogue, {{ user.<marker> }} ' +
|
|
288
|
+
'for the recipient. Events support six modules — catalog, forms, orders, users, payments ' +
|
|
289
|
+
'and discounts. There is no content module among them, so an event on a page or a block ' +
|
|
290
|
+
'attribute cannot be built this way; see silentNoOp.',
|
|
291
|
+
silentNoOp: 'A moduleId outside the six supported modules is accepted: the event is created, reads ' +
|
|
292
|
+
'back complete, and never fires — the panel draws no settings for it and nothing is ever ' +
|
|
293
|
+
'sent. Nothing in the response distinguishes it from a working event. If the task needs ' +
|
|
294
|
+
'"notify when this page changes", say that events do not cover it rather than creating ' +
|
|
295
|
+
'the object and reporting success.',
|
|
296
|
+
verifyWith: {
|
|
297
|
+
opId: 'AdminEventsController_findOne',
|
|
298
|
+
check: 'moduleId and localizeInfos.<locale>.title',
|
|
299
|
+
why: 'an event on an unsupported module reads back fully populated and still does nothing',
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
AdminEventsController_update: {
|
|
303
|
+
note: 'The event name, the mail subject and the mail body all live in ' +
|
|
304
|
+
'localizeInfos.<locale> as "title", "subject" and "template", with "push" for the push ' +
|
|
305
|
+
'channel. Nothing about the message belongs under "mailing". Supported modules are ' +
|
|
306
|
+
'catalog, forms, orders, users, payments and discounts; any other moduleId is accepted ' +
|
|
307
|
+
'and the event never fires.',
|
|
308
|
+
example: {
|
|
309
|
+
localizeInfos: {
|
|
310
|
+
en_US: {
|
|
311
|
+
title: 'New product published',
|
|
312
|
+
subject: 'A new product is live',
|
|
313
|
+
template: '<p>{{ product.title }} is now available.</p>',
|
|
314
|
+
push: '{{ product.title }} is now available',
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
verifyWith: {
|
|
319
|
+
opId: 'AdminEventsController_findOne',
|
|
320
|
+
check: 'localizeInfos.<locale>.subject and .template',
|
|
321
|
+
why: 'a subject written under "mailing" is stored there and never used',
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
AdminDiscountsController_create: {
|
|
325
|
+
note: 'A discount with an empty "conditions" and no coupons applies to every order of every ' +
|
|
326
|
+
'customer, without end. The object is created, the amount is right, and the meaning is ' +
|
|
327
|
+
'the opposite of "15% off the first order for a subscriber" — a sentence that carries ' +
|
|
328
|
+
'three limits, none of which is expressed by the amount. State the discount in words, ' +
|
|
329
|
+
'then check each limit against the body: coupons are the gate (with coupons present, the ' +
|
|
330
|
+
'conditions apply only when a coupon is used), and conditions narrow which products, ' +
|
|
331
|
+
'categories or totals are touched. Conditions over attributes work on indexed attributes ' +
|
|
332
|
+
'only and match nothing, silently, on the rest.',
|
|
333
|
+
verifyWith: {
|
|
334
|
+
opId: 'AdminDiscountsController_findOne',
|
|
335
|
+
check: 'conditions and the coupon list',
|
|
336
|
+
why: 'a discount meant for some customers cannot have both of them empty',
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
AdminDiscountsController_createCoupon: {
|
|
340
|
+
note: 'A coupon created from a code you supply comes back isReusable: true — one code, valid ' +
|
|
341
|
+
'for everyone, any number of times. "isReusable" is not accepted in the body, only ' +
|
|
342
|
+
'reported in the response, so the reuse behaviour follows from WHICH operation created ' +
|
|
343
|
+
'the coupon. For codes that expire on first use, generate them with ' +
|
|
344
|
+
'AdminDiscountsController_generateCouponsByMask instead. On a "first order" discount the ' +
|
|
345
|
+
'difference decides whether one customer gets a discount or everyone does, permanently.',
|
|
346
|
+
verifyWith: {
|
|
347
|
+
opId: 'AdminDiscountsController_findOne',
|
|
348
|
+
check: 'the coupon\'s isReusable',
|
|
349
|
+
why: 'reuse is not something the create body can set, so the response is the only place it appears',
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
AdminDiscountsController_generateCouponsByMask: {
|
|
353
|
+
note: 'Coupons generated from a mask come back isReusable: false — each code stops working ' +
|
|
354
|
+
'after the order that used it. That is the only route to single-use codes: a coupon ' +
|
|
355
|
+
'created from a supplied string is reusable and cannot be changed through the body.',
|
|
356
|
+
},
|
|
357
|
+
AdminProductsController_update: {
|
|
358
|
+
note: 'Always include "blocks" — send [] when there is nothing to set, because omitting it ' +
|
|
359
|
+
'fails the update. Never include "forms": the schema accepts the field and saving rejects ' +
|
|
360
|
+
'it. This is also the working route for a product status: send statusId here rather than ' +
|
|
361
|
+
'through the bulk set-status operation.',
|
|
362
|
+
},
|
|
121
363
|
AdminBlocksController_update: {
|
|
122
364
|
note: 'Omitting blockPages detaches the block from every page it was on. Read the block first ' +
|
|
123
365
|
'and send its current page list back unless you mean to change it.',
|
|
@@ -128,7 +370,13 @@ export const OPERATION_NOTES = {
|
|
|
128
370
|
},
|
|
129
371
|
},
|
|
130
372
|
AdminProductsController_countAll: { readOnly: true },
|
|
131
|
-
AdminProductsController_findByIds: {
|
|
373
|
+
AdminProductsController_findByIds: {
|
|
374
|
+
readOnly: true,
|
|
375
|
+
note: 'The way to verify a batch write without one read per entity: pass every id you wrote and ' +
|
|
376
|
+
'compare the values in the response. A batch write that reports success can still miss a ' +
|
|
377
|
+
'single entity, and a per-entity read loop is what makes agents verify a sample instead ' +
|
|
378
|
+
'of the whole set.',
|
|
379
|
+
},
|
|
132
380
|
AdminProductsController_findAllByWithOutCategory: { readOnly: true },
|
|
133
381
|
AdminProductsController_findAllByCategoryIdForAdmin: { readOnly: true },
|
|
134
382
|
AdminProductsController_countByCategoryId: { readOnly: true },
|
package/dist/api/types.d.ts
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OperationCatalog } from './catalog.js';
|
|
2
|
+
import type { Operation } from './types.js';
|
|
3
|
+
export interface UploadPayload {
|
|
4
|
+
bytes: Uint8Array;
|
|
5
|
+
filename: string;
|
|
6
|
+
contentType: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class UploadSourceError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export declare const contentTypeOf: (filename: string) => string;
|
|
11
|
+
export declare const resolveUploadOperation: (catalog: OperationCatalog) => Operation | undefined;
|
|
12
|
+
export declare const readLocalUpload: (params: {
|
|
13
|
+
path: string;
|
|
14
|
+
root: string;
|
|
15
|
+
maxBytes: number;
|
|
16
|
+
}) => Promise<UploadPayload>;
|
|
17
|
+
export declare const fetchRemoteUpload: (params: {
|
|
18
|
+
url: string;
|
|
19
|
+
allowedHosts: readonly string[];
|
|
20
|
+
maxBytes: number;
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
filename?: string;
|
|
23
|
+
}) => Promise<UploadPayload>;
|