@oneentry/mcp-platform-server 0.1.4 → 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.
@@ -0,0 +1,394 @@
1
+ export const ALWAYS_CONFIRM_PREFIXES = [
2
+ '/immutable-settings',
3
+ '/admins',
4
+ '/backups',
5
+ '/modules',
6
+ '/payments/webhook',
7
+ '/auth/logout/all-users',
8
+ '/settings-general',
9
+ '/system/captcha-keys',
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.';
52
+ export const OPERATION_NOTES = {
53
+ AdminProductsController_findAll: {
54
+ readOnly: true,
55
+ note: 'Pagination and locale are query parameters, not body fields: pass limit, offset and ' +
56
+ 'langCode in "query". The body is an ARRAY of filter objects — send [] for no filter. ' +
57
+ 'A body of { limit, offset, langCode } answers 400 complaining about langCode, because ' +
58
+ 'the validator is describing the query parameter you did not send.',
59
+ },
60
+ AdminProductsController_setStatusForProducts: {
61
+ silentNoOp: 'The status id goes in "id", not "statusId". With "statusId" the handler reads ' +
62
+ 'undefined, writes NULL over the product status and still answers 201 true. The call ' +
63
+ 'also never re-indexes, so even a correct write stays invisible to ' +
64
+ 'AdminProductsController_findAll. Setting statusId through ' +
65
+ 'AdminProductsController_update is the route that works today.',
66
+ verifyWith: {
67
+ opId: 'AdminProductsController_findOne',
68
+ check: 'statusId',
69
+ why: 'the call answers 201 true whether or not any row changed',
70
+ },
71
+ },
72
+ AdminAttributesSetsController_create: {
73
+ note: 'Inside an attribute, "validators", "localizeInfos" and "listTitles" are keyed by ' +
74
+ 'locale first: validators.en_US.requiredValidator, not validators.requiredValidator. ' +
75
+ 'A flat map is stored verbatim and read by nobody. ' +
76
+ LIST_EXTRAS_NOTE,
77
+ example: LIST_ATTRIBUTE_WITH_EXTRAS,
78
+ verifyWith: {
79
+ opId: 'AdminAttributesSetsController_findOne',
80
+ check: 'schema[].validators.<locale>',
81
+ why: 'the raw set shows exactly what you wrote, including a flat map that no consumer reads',
82
+ },
83
+ },
84
+ AdminAttributesSetsController_update: {
85
+ note: 'Inside an attribute, "validators", "localizeInfos" and "listTitles" are keyed by ' +
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,
93
+ verifyWith: {
94
+ opId: 'AdminAttributesSetsController_findOne',
95
+ check: 'schema[].validators.<locale>',
96
+ why: 'the raw set shows what you wrote; the projection a site reads shows what it gets',
97
+ },
98
+ },
99
+ AdminAttributesSetsController_updateSchema: {
100
+ note: 'The body is the schema object itself, never wrapped as { "schema": … }. The wrapped ' +
101
+ 'form answers 200 and replaces the set with a single attribute called "schema". ' +
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,
106
+ verifyWith: {
107
+ opId: 'AdminAttributesSetsController_findOne',
108
+ check: 'schema[].validators.<locale>',
109
+ why: 'a wrapped or flat body answers 200 and destroys or discards what you sent',
110
+ },
111
+ },
112
+ AdminFileUploadController_uploadFiles: {
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.',
125
+ },
126
+ AdminMenusController_create: {
127
+ note: 'Create the menu with an empty pagesIds, then attach pages with AdminMenusController_update. ' +
128
+ 'A non-empty pagesIds on create answers 500 (null value in column "page_id"). That 500 is ' +
129
+ 'known — do not report it as an unexplained server failure.',
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
+ },
190
+ AdminFormsController_create: {
191
+ note: 'Send the payload wrapped under "newForm". The form "type" is missing from the schema but ' +
192
+ 'is accepted and persisted: order | sign_in_up | collection | data | rating. A contact ' +
193
+ 'form is "data". Omit it and the form is created with type null.',
194
+ verifyWith: {
195
+ opId: 'AdminFormsController_findAll',
196
+ check: 'type',
197
+ why: 'the field is absent from the schema, so an omitted type is not reported as missing',
198
+ },
199
+ },
200
+ AdminFormsController_update: {
201
+ note: '"formModuleConfigs" is a full replacement list, not a patch. Omitting it deletes every ' +
202
+ 'module binding of the form AND the submissions recorded against those bindings, and the ' +
203
+ 'call still answers 200 true. Read the form first and send its current formModuleConfigs ' +
204
+ 'back unless changing them is the point. This is also the only operation that creates a ' +
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
+ },
226
+ verifyWith: {
227
+ opId: 'AdminFormsController_findOne',
228
+ check: 'formModuleConfigs',
229
+ why: 'an omitted formModuleConfigs is applied as "unbind everything", submissions included',
230
+ },
231
+ },
232
+ AdminFormDataController_create: {
233
+ note: 'A submission needs formIdentifier, formModuleConfigId, moduleEntityIdentifier and ' +
234
+ 'locale-keyed formData. formModuleConfigId is the id of a module binding, which only ' +
235
+ 'AdminFormsController_update creates and only AdminFormsController_findOne reports, under ' +
236
+ 'formModuleConfigs[].id — a freshly created form has none and accepts nothing. The server ' +
237
+ 'joins the config back to its form and compares identifiers, so a config id belonging to ' +
238
+ 'another form, or to no form, answers 400 "Incorrect formIdentifier for provided config": ' +
239
+ 'the message names formIdentifier, the wrong field is usually the config id. Checks run ' +
240
+ 'fields first, then the form type, then the config, so a field error arriving first does ' +
241
+ 'not mean the config is optional. A form with type null fails earlier with "Form has ' +
242
+ 'incorrect type". Field validation is wired into the Content API only: through the Admin ' +
243
+ '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
+ '[{ "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.',
251
+ verifyWith: {
252
+ opId: 'AdminFormDataController_findByFormMarker',
253
+ check: 'the submission you sent',
254
+ why: 'the stored submission is the only proof the binding it names is the intended one',
255
+ },
256
+ },
257
+ AdminPagesController_update: {
258
+ note: 'Omitting parentId does not leave the parent alone — it moves the page to the root and ' +
259
+ 'decrements the former parent\'s childrenCount. Read the page first and send parentId ' +
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.',
264
+ verifyWith: {
265
+ opId: 'AdminPagesController_findOne',
266
+ check: 'parentId',
267
+ why: 'an omitted parentId is applied as "move to root", and the update still answers 200',
268
+ },
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
+ },
363
+ AdminBlocksController_update: {
364
+ note: 'Omitting blockPages detaches the block from every page it was on. Read the block first ' +
365
+ 'and send its current page list back unless you mean to change it.',
366
+ verifyWith: {
367
+ opId: 'AdminBlocksController_findOne',
368
+ check: 'blockPages',
369
+ why: 'an omitted blockPages is applied as "detach everything", and the update answers 200',
370
+ },
371
+ },
372
+ AdminProductsController_countAll: { readOnly: true },
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
+ },
380
+ AdminProductsController_findAllByWithOutCategory: { readOnly: true },
381
+ AdminProductsController_findAllByCategoryIdForAdmin: { readOnly: true },
382
+ AdminProductsController_countByCategoryId: { readOnly: true },
383
+ AdminProductsController_countByCategoryMarker: { readOnly: true },
384
+ AdminUsersController_findAllByConditions: { readOnly: true },
385
+ AdminUsersController_search: { readOnly: true },
386
+ AdminFormDataController_findByFormMarker: { readOnly: true },
387
+ AdminBlocksController_findCartComplementProductsByBody: { readOnly: true },
388
+ AdminBlocksController_findCartSimilarProductsByBody: { readOnly: true },
389
+ AdminBlocksController_findWishlistSimilarProductsByBody: { readOnly: true },
390
+ AdminUserGroupsController_getGroupRoutesWithPermissions: { readOnly: true },
391
+ };
392
+ export const readOnlyOpIds = () => Object.entries(OPERATION_NOTES)
393
+ .filter(([, note]) => note.readOnly === true)
394
+ .map(([opId]) => opId);
@@ -1,5 +1,10 @@
1
1
  export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
2
2
  export type RiskLevel = 'read' | 'write' | 'destructive';
3
+ export interface VerifyWith {
4
+ opId: string;
5
+ check: string;
6
+ why: string;
7
+ }
3
8
  export interface OperationParam {
4
9
  name: string;
5
10
  location: 'path' | 'query' | 'header';
@@ -13,6 +18,7 @@ export interface JsonSchema {
13
18
  format?: string;
14
19
  description?: string;
15
20
  example?: unknown;
21
+ default?: unknown;
16
22
  enum?: unknown[];
17
23
  items?: JsonSchema;
18
24
  properties?: Record<string, JsonSchema>;
@@ -20,6 +26,8 @@ export interface JsonSchema {
20
26
  'x-loose'?: boolean;
21
27
  'x-source-type'?: string;
22
28
  'x-truncated'?: boolean;
29
+ 'x-example-mismatch'?: string;
30
+ 'x-unresolved'?: boolean;
23
31
  }
24
32
  export interface Operation {
25
33
  opId: string;
@@ -38,8 +46,16 @@ export interface Operation {
38
46
  required: boolean;
39
47
  };
40
48
  responseSummary?: string;
49
+ note?: string;
50
+ curatedExample?: unknown;
51
+ verifyWith?: VerifyWith;
52
+ silentNoOp?: string;
41
53
  searchText: string;
42
54
  }
55
+ export interface CatalogCoverage {
56
+ unexposedOpIds: string[];
57
+ permissionsWithoutOperation: string[];
58
+ }
43
59
  export interface Catalog {
44
60
  version: 1;
45
61
  builtAt: string;
@@ -47,5 +63,6 @@ export interface Catalog {
47
63
  swaggerHash: string;
48
64
  permissions: string[];
49
65
  warnings: string[];
66
+ coverage: CatalogCoverage;
50
67
  operations: Operation[];
51
68
  }
@@ -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>;
@@ -0,0 +1,189 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { readFile, realpath, stat } from 'node:fs/promises';
3
+ import { basename, extname, isAbsolute, relative, resolve } from 'node:path';
4
+ export class UploadSourceError extends Error {
5
+ }
6
+ const CONTENT_TYPES = {
7
+ '.png': 'image/png',
8
+ '.jpg': 'image/jpeg',
9
+ '.jpeg': 'image/jpeg',
10
+ '.gif': 'image/gif',
11
+ '.webp': 'image/webp',
12
+ '.svg': 'image/svg+xml',
13
+ '.avif': 'image/avif',
14
+ '.ico': 'image/x-icon',
15
+ '.pdf': 'application/pdf',
16
+ '.mp4': 'video/mp4',
17
+ '.webm': 'video/webm',
18
+ '.mov': 'video/quicktime',
19
+ '.mp3': 'audio/mpeg',
20
+ '.zip': 'application/zip',
21
+ '.csv': 'text/csv',
22
+ '.json': 'application/json',
23
+ '.txt': 'text/plain',
24
+ };
25
+ export const contentTypeOf = (filename) => CONTENT_TYPES[extname(filename).toLowerCase()] ?? 'application/octet-stream';
26
+ export const resolveUploadOperation = (catalog) => catalog
27
+ .operations()
28
+ .find((operation) => operation.method === 'post' && operation.body?.contentType === 'multipart/form-data');
29
+ const asMegabytes = (bytes) => `${(bytes / 1_048_576).toFixed(1)} MB`;
30
+ export const readLocalUpload = async (params) => {
31
+ const { path, root, maxBytes } = params;
32
+ const requested = isAbsolute(path) ? path : resolve(root, path);
33
+ let realRoot;
34
+ try {
35
+ realRoot = await realpath(root);
36
+ }
37
+ catch {
38
+ throw new UploadSourceError(`The upload root "${root}" does not exist. Point --upload-root at a directory that holds ` +
39
+ 'the files to upload.');
40
+ }
41
+ let real;
42
+ try {
43
+ real = await realpath(requested);
44
+ }
45
+ catch {
46
+ throw new UploadSourceError(`No file at "${requested}". Paths are resolved against the upload root "${realRoot}".`);
47
+ }
48
+ const inside = relative(realRoot, real);
49
+ if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) {
50
+ throw new UploadSourceError(`"${requested}" resolves to "${real}", which is outside the upload root "${realRoot}". ` +
51
+ 'Only files under that root can be uploaded — move the file there or start the server ' +
52
+ 'with a different --upload-root.');
53
+ }
54
+ const info = await stat(real);
55
+ if (!info.isFile()) {
56
+ throw new UploadSourceError(`"${real}" is not a regular file. Upload one file per call.`);
57
+ }
58
+ if (info.size === 0) {
59
+ throw new UploadSourceError(`"${real}" is empty. An empty upload consumes quota and stores nothing useful.`);
60
+ }
61
+ if (info.size > maxBytes) {
62
+ throw new UploadSourceError(`"${real}" is ${asMegabytes(info.size)}, over the ${asMegabytes(maxBytes)} upload limit. ` +
63
+ 'Raise --upload-max-bytes deliberately or upload a smaller file.');
64
+ }
65
+ const filename = basename(real);
66
+ return { bytes: await readFile(real), filename, contentType: contentTypeOf(filename) };
67
+ };
68
+ const isPrivateAddress = (address, family) => {
69
+ if (family === 6) {
70
+ const normalized = address.toLowerCase().split('%')[0] ?? '';
71
+ if (normalized === '::1' || normalized === '::') {
72
+ return true;
73
+ }
74
+ if (/^f[cd]/.test(normalized) || normalized.startsWith('fe8') || normalized.startsWith('fe9') ||
75
+ normalized.startsWith('fea') || normalized.startsWith('feb')) {
76
+ return true;
77
+ }
78
+ const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized);
79
+ if (dotted?.[1] !== undefined) {
80
+ return isPrivateAddress(dotted[1], 4);
81
+ }
82
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized);
83
+ if (hex?.[1] !== undefined && hex[2] !== undefined) {
84
+ const high = Number.parseInt(hex[1], 16);
85
+ const low = Number.parseInt(hex[2], 16);
86
+ const octets = [high >> 8, high & 0xff, low >> 8, low & 0xff];
87
+ return isPrivateAddress(octets.join('.'), 4);
88
+ }
89
+ return false;
90
+ }
91
+ const parts = address.split('.').map(Number);
92
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
93
+ return true;
94
+ }
95
+ const [a = 0, b = 0] = parts;
96
+ return (a === 0 ||
97
+ a === 10 ||
98
+ a === 127 ||
99
+ (a === 100 && b >= 64 && b <= 127) ||
100
+ (a === 169 && b === 254) ||
101
+ (a === 172 && b >= 16 && b <= 31) ||
102
+ (a === 192 && b === 168) ||
103
+ (a === 198 && (b === 18 || b === 19)) ||
104
+ a >= 224);
105
+ };
106
+ const assertPublicUrl = async (raw, allowedHosts) => {
107
+ let url;
108
+ try {
109
+ url = new URL(raw);
110
+ }
111
+ catch {
112
+ throw new UploadSourceError(`"${raw}" is not a URL. Pass an absolute http or https address.`);
113
+ }
114
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
115
+ throw new UploadSourceError(`The scheme "${url.protocol}" is not supported. Only http and https addresses are fetched.`);
116
+ }
117
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
118
+ if (allowedHosts.length > 0 && !allowedHosts.includes(host)) {
119
+ throw new UploadSourceError(`The host "${host}" is not in the upload allowlist (${allowedHosts.join(', ')}). ` +
120
+ 'Ask the operator to add it with --upload-allowed-hosts.');
121
+ }
122
+ const resolved = await lookup(host, { all: true, verbatim: true }).catch(() => {
123
+ throw new UploadSourceError(`The host "${host}" does not resolve. Check the address.`);
124
+ });
125
+ const blocked = resolved.filter((entry) => isPrivateAddress(entry.address, entry.family));
126
+ if (blocked.length > 0) {
127
+ throw new UploadSourceError(`The host "${host}" resolves to a private or loopback address (${blocked[0]?.address ?? ''}). ` +
128
+ 'This server does not fetch from the network it runs in — download the file and use ' +
129
+ 'cms_upload_file instead.');
130
+ }
131
+ return url;
132
+ };
133
+ const filenameFromUrl = (url, override) => {
134
+ const fromOverride = override?.trim();
135
+ if (fromOverride) {
136
+ return basename(fromOverride);
137
+ }
138
+ const last = basename(decodeURIComponent(url.pathname));
139
+ return last === '' || last === '/' ? 'upload' : last;
140
+ };
141
+ const MAX_REDIRECTS = 3;
142
+ export const fetchRemoteUpload = async (params) => {
143
+ const { allowedHosts, maxBytes, timeoutMs } = params;
144
+ let target = await assertPublicUrl(params.url, allowedHosts);
145
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
146
+ let response;
147
+ try {
148
+ response = await fetch(target, {
149
+ redirect: 'manual',
150
+ signal: AbortSignal.timeout(timeoutMs),
151
+ });
152
+ }
153
+ catch (error) {
154
+ throw new UploadSourceError(`Fetching ${target.toString()} failed: ${error instanceof Error ? error.message : String(error)}`);
155
+ }
156
+ if (response.status >= 300 && response.status < 400) {
157
+ const location = response.headers.get('location');
158
+ if (!location) {
159
+ throw new UploadSourceError(`${target.toString()} answered ${String(response.status)} with no location header.`);
160
+ }
161
+ if (hop === MAX_REDIRECTS) {
162
+ throw new UploadSourceError(`${params.url} redirects more than ${String(MAX_REDIRECTS)} times. Pass the final address.`);
163
+ }
164
+ target = await assertPublicUrl(new URL(location, target).toString(), allowedHosts);
165
+ continue;
166
+ }
167
+ if (!response.ok) {
168
+ throw new UploadSourceError(`${target.toString()} answered ${String(response.status)}. Nothing was uploaded.`);
169
+ }
170
+ const declared = Number(response.headers.get('content-length') ?? '');
171
+ if (Number.isFinite(declared) && declared > maxBytes) {
172
+ throw new UploadSourceError(`${target.toString()} declares ${asMegabytes(declared)}, over the ${asMegabytes(maxBytes)} ` +
173
+ 'upload limit. Nothing was downloaded.');
174
+ }
175
+ const bytes = new Uint8Array(await response.arrayBuffer());
176
+ if (bytes.byteLength === 0) {
177
+ throw new UploadSourceError(`${target.toString()} returned an empty body.`);
178
+ }
179
+ if (bytes.byteLength > maxBytes) {
180
+ throw new UploadSourceError(`${target.toString()} returned ${asMegabytes(bytes.byteLength)}, over the ` +
181
+ `${asMegabytes(maxBytes)} upload limit.`);
182
+ }
183
+ const filename = filenameFromUrl(target, params.filename);
184
+ const headerType = (response.headers.get('content-type') ?? '').split(';')[0]?.trim();
185
+ const contentType = headerType && headerType !== 'application/octet-stream' ? headerType : contentTypeOf(filename);
186
+ return { bytes, filename, contentType };
187
+ }
188
+ throw new UploadSourceError(`${params.url} could not be fetched within ${String(MAX_REDIRECTS)} redirects.`);
189
+ };