@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.
- package/README.md +29 -2
- package/dist/api/build-catalog.js +122 -17
- package/dist/api/catalog.d.ts +1 -0
- package/dist/api/catalog.js +22 -1
- package/dist/api/client.d.ts +7 -0
- package/dist/api/client.js +63 -5
- package/dist/api/normalize-schema.js +6 -1
- package/dist/api/operation-notes.d.ts +11 -0
- package/dist/api/operation-notes.js +394 -0
- package/dist/api/types.d.ts +17 -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/knowledge/ru-en-terms.d.ts +1 -0
- package/dist/knowledge/ru-en-terms.js +67 -0
- package/dist/knowledge/search.js +2 -1
- package/dist/server.js +3 -0
- package/dist/tools/api-call.js +14 -1
- package/dist/tools/api-discovery.js +119 -13
- package/dist/tools/docs.js +4 -1
- package/dist/tools/guide.js +19 -3
- package/dist/tools/upload.d.ts +3 -0
- package/dist/tools/upload.js +229 -0
- package/dist/tools/whoami.js +5 -0
- package/knowledge/operating-rules.md +51 -44
- package/package.json +2 -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:
|
|
@@ -1,24 +1,69 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { capSchema, normalizeSchema } from './normalize-schema.js';
|
|
3
|
+
import { ALWAYS_CONFIRM_PREFIXES, OPERATION_NOTES } from './operation-notes.js';
|
|
3
4
|
const API_PREFIX = '/api/admin';
|
|
4
5
|
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
5
|
-
const ALWAYS_CONFIRM = [
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
/^\/backups/,
|
|
9
|
-
/^\/modules/,
|
|
10
|
-
/^\/payments\/webhook/,
|
|
11
|
-
/^\/auth\/logout\/all-users/,
|
|
12
|
-
/^\/settings-general/,
|
|
13
|
-
/^\/system\/captcha-keys/,
|
|
14
|
-
];
|
|
15
|
-
const riskOf = (method) => {
|
|
6
|
+
const ALWAYS_CONFIRM = ALWAYS_CONFIRM_PREFIXES.map((prefix) => new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
|
|
7
|
+
const isGated = (path) => ALWAYS_CONFIRM.some((pattern) => pattern.test(path));
|
|
8
|
+
const riskOf = (method, opId, path) => {
|
|
16
9
|
if (method === 'get') {
|
|
17
10
|
return 'read';
|
|
18
11
|
}
|
|
12
|
+
if (method === 'post' && OPERATION_NOTES[opId]?.readOnly === true && !isGated(path)) {
|
|
13
|
+
return 'read';
|
|
14
|
+
}
|
|
19
15
|
return method === 'delete' ? 'destructive' : 'write';
|
|
20
16
|
};
|
|
21
17
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
const jsonTypeOf = (value) => {
|
|
19
|
+
if (value === null) {
|
|
20
|
+
return 'null';
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
return 'array';
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === 'number') {
|
|
26
|
+
return Number.isInteger(value) ? 'integer' : 'number';
|
|
27
|
+
}
|
|
28
|
+
return typeof value;
|
|
29
|
+
};
|
|
30
|
+
const typeFits = (declared, actual) => declared === actual ||
|
|
31
|
+
(declared === 'number' && actual === 'integer') ||
|
|
32
|
+
(declared === 'integer' && actual === 'number');
|
|
33
|
+
const markExampleMismatch = (schema, field) => {
|
|
34
|
+
if (schema['x-loose'] === true || schema.example === undefined || schema.type === undefined) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const actual = jsonTypeOf(schema.example);
|
|
38
|
+
if (!typeFits(schema.type, actual)) {
|
|
39
|
+
const message = `declared "${schema.type}", example is ${actual}`;
|
|
40
|
+
schema['x-example-mismatch'] = message;
|
|
41
|
+
return `${field} (${message})`;
|
|
42
|
+
}
|
|
43
|
+
if (schema.type === 'array' && schema.items?.type !== undefined && schema.items['x-loose'] !== true) {
|
|
44
|
+
const first = schema.example[0];
|
|
45
|
+
if (first !== undefined && !typeFits(schema.items.type, jsonTypeOf(first))) {
|
|
46
|
+
const message = `items declared "${schema.items.type}", example items are ${jsonTypeOf(first)}`;
|
|
47
|
+
schema['x-example-mismatch'] = message;
|
|
48
|
+
return `${field} (${message})`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
};
|
|
53
|
+
const exampleMismatches = (body) => {
|
|
54
|
+
const properties = body?.schema.properties;
|
|
55
|
+
if (!properties) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const found = [];
|
|
59
|
+
for (const [name, schema] of Object.entries(properties)) {
|
|
60
|
+
const mismatch = markExampleMismatch(schema, name);
|
|
61
|
+
if (mismatch) {
|
|
62
|
+
found.push(mismatch);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return found;
|
|
66
|
+
};
|
|
22
67
|
const asString = (value) => typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
23
68
|
const responseSummary = (responses) => {
|
|
24
69
|
if (!isRecord(responses)) {
|
|
@@ -60,6 +105,21 @@ const buildParams = (raw, components) => {
|
|
|
60
105
|
}
|
|
61
106
|
return params;
|
|
62
107
|
};
|
|
108
|
+
const bodyExample = (media) => {
|
|
109
|
+
if (media['example'] !== undefined) {
|
|
110
|
+
return media['example'];
|
|
111
|
+
}
|
|
112
|
+
const examples = media['examples'];
|
|
113
|
+
if (!isRecord(examples)) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
for (const entry of Object.values(examples)) {
|
|
117
|
+
if (isRecord(entry) && entry['value'] !== undefined) {
|
|
118
|
+
return entry['value'];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
};
|
|
63
123
|
const buildBody = (raw, components) => {
|
|
64
124
|
if (!isRecord(raw)) {
|
|
65
125
|
return undefined;
|
|
@@ -76,9 +136,18 @@ const buildBody = (raw, components) => {
|
|
|
76
136
|
if (!isRecord(media)) {
|
|
77
137
|
return undefined;
|
|
78
138
|
}
|
|
139
|
+
const schema = capSchema(normalizeSchema(media['schema'], components));
|
|
140
|
+
const example = bodyExample(media);
|
|
141
|
+
if (example !== undefined && schema.example === undefined) {
|
|
142
|
+
schema.example = example;
|
|
143
|
+
}
|
|
144
|
+
const description = asString(raw['description']);
|
|
145
|
+
if (description && schema['x-unresolved'] === true) {
|
|
146
|
+
schema.description = description.replace(/\s+/g, ' ').slice(0, 240);
|
|
147
|
+
}
|
|
79
148
|
return {
|
|
80
149
|
contentType,
|
|
81
|
-
schema
|
|
150
|
+
schema,
|
|
82
151
|
required: raw['required'] === true,
|
|
83
152
|
};
|
|
84
153
|
};
|
|
@@ -96,6 +165,8 @@ export const buildCatalog = (params) => {
|
|
|
96
165
|
const operations = [];
|
|
97
166
|
const foreignPaths = [];
|
|
98
167
|
const withoutOpId = [];
|
|
168
|
+
const unresolvedBodies = [];
|
|
169
|
+
const contradictoryExamples = [];
|
|
99
170
|
for (const [fullPath, pathItem] of Object.entries(swagger['paths'])) {
|
|
100
171
|
if (!isRecord(pathItem)) {
|
|
101
172
|
continue;
|
|
@@ -118,9 +189,18 @@ export const buildCatalog = (params) => {
|
|
|
118
189
|
const tags = operation['tags'];
|
|
119
190
|
const tag = (Array.isArray(tags) ? asString(tags[0]) : undefined) ?? 'Other';
|
|
120
191
|
const summary = asString(operation['summary']) ?? asString(operation['description']) ?? '';
|
|
121
|
-
const risk = riskOf(method);
|
|
122
|
-
const alwaysConfirm = method !== 'get' &&
|
|
192
|
+
const risk = riskOf(method, opId, path);
|
|
193
|
+
const alwaysConfirm = method !== 'get' && isGated(path);
|
|
123
194
|
const permission = byOpId[opId];
|
|
195
|
+
const body = buildBody(operation['requestBody'], components);
|
|
196
|
+
if (body?.schema['x-unresolved'] === true) {
|
|
197
|
+
unresolvedBodies.push(body.schema.example === undefined ? `${opId} (no example)` : opId);
|
|
198
|
+
}
|
|
199
|
+
const mismatches = exampleMismatches(body);
|
|
200
|
+
if (mismatches.length > 0) {
|
|
201
|
+
contradictoryExamples.push(`${opId}: ${mismatches.join('; ')}`);
|
|
202
|
+
}
|
|
203
|
+
const notes = OPERATION_NOTES[opId];
|
|
124
204
|
const entry = {
|
|
125
205
|
opId,
|
|
126
206
|
method,
|
|
@@ -132,12 +212,14 @@ export const buildCatalog = (params) => {
|
|
|
132
212
|
risk,
|
|
133
213
|
alwaysConfirm,
|
|
134
214
|
params: buildParams(operation['parameters'], components),
|
|
135
|
-
...(
|
|
136
|
-
? { body: buildBody(operation['requestBody'], components) }
|
|
137
|
-
: {}),
|
|
215
|
+
...(body ? { body } : {}),
|
|
138
216
|
...(responseSummary(operation['responses'])
|
|
139
217
|
? { responseSummary: responseSummary(operation['responses']) }
|
|
140
218
|
: {}),
|
|
219
|
+
...(notes?.note ? { note: notes.note } : {}),
|
|
220
|
+
...(notes?.example !== undefined ? { curatedExample: notes.example } : {}),
|
|
221
|
+
...(notes?.verifyWith ? { verifyWith: notes.verifyWith } : {}),
|
|
222
|
+
...(notes?.silentNoOp ? { silentNoOp: notes.silentNoOp } : {}),
|
|
141
223
|
searchText: '',
|
|
142
224
|
};
|
|
143
225
|
entry.searchText = [opId, method.toUpperCase(), path, tag, summary, permission ?? '']
|
|
@@ -156,6 +238,26 @@ export const buildCatalog = (params) => {
|
|
|
156
238
|
warnings.push(`${String(withoutOpId.length)} operation(s) have no operationId and cannot be called ` +
|
|
157
239
|
`(e.g. ${withoutOpId.slice(0, 3).join(', ')}). Report it against the CMS.`);
|
|
158
240
|
}
|
|
241
|
+
if (unresolvedBodies.length > 0) {
|
|
242
|
+
warnings.push(`${String(unresolvedBodies.length)} operation(s) declare a request body whose schema ` +
|
|
243
|
+
`does not resolve in the document (${unresolvedBodies.slice(0, 5).join(', ')}). ` +
|
|
244
|
+
'Their body schema is marked "x-unresolved": true — build the body from the example ' +
|
|
245
|
+
'cms_api_describe returns, and never from the empty schema. Report it against the CMS.');
|
|
246
|
+
}
|
|
247
|
+
if (contradictoryExamples.length > 0) {
|
|
248
|
+
warnings.push(`${String(contradictoryExamples.length)} field(s) carry an example that contradicts their ` +
|
|
249
|
+
`own declared type (${contradictoryExamples.slice(0, 3).join(' | ')}). ` +
|
|
250
|
+
'Those fields are marked "x-example-mismatch" and listed by cms_api_describe — ' +
|
|
251
|
+
'copy the example, not the type. Report it against the CMS.');
|
|
252
|
+
}
|
|
253
|
+
const noted = Object.keys(OPERATION_NOTES);
|
|
254
|
+
const notedButAbsent = noted.filter((opId) => !operations.some((operation) => operation.opId === opId));
|
|
255
|
+
if (notedButAbsent.length > 0 && notedButAbsent.length <= noted.length / 2) {
|
|
256
|
+
warnings.push(`${String(notedButAbsent.length)} operation(s) carry a curated behaviour note but are ` +
|
|
257
|
+
`absent from this catalog (${notedButAbsent.slice(0, 5).join(', ')}). ` +
|
|
258
|
+
'Either this instance does not expose them, or the note table has gone stale — ' +
|
|
259
|
+
'the note is not shown for an operation that is not there.');
|
|
260
|
+
}
|
|
159
261
|
const known = new Set(operations.map((o) => o.opId));
|
|
160
262
|
const missing = Object.keys(byOpId).filter((opId) => !known.has(opId) && !opId.startsWith('Developer') && !opId.startsWith('Content'));
|
|
161
263
|
if (missing.length > 0) {
|
|
@@ -173,6 +275,8 @@ export const buildCatalog = (params) => {
|
|
|
173
275
|
: 'The bundled permission map was generated from a different platform revision than ' +
|
|
174
276
|
'this instance runs — update the package if the catalog looks incomplete.'));
|
|
175
277
|
}
|
|
278
|
+
const mappedPermissions = new Set(Object.values(byOpId));
|
|
279
|
+
const permissionsWithoutOperation = permissions.filter((permission) => !mappedPermissions.has(permission));
|
|
176
280
|
const knownPermissions = new Set(permissions);
|
|
177
281
|
const orphanPermissions = [
|
|
178
282
|
...new Set(operations
|
|
@@ -191,6 +295,7 @@ export const buildCatalog = (params) => {
|
|
|
191
295
|
swaggerHash: createHash('sha256').update(rawSwagger).digest('hex').slice(0, 16),
|
|
192
296
|
permissions,
|
|
193
297
|
warnings,
|
|
298
|
+
coverage: { unexposedOpIds: missing, permissionsWithoutOperation },
|
|
194
299
|
operations,
|
|
195
300
|
};
|
|
196
301
|
};
|
package/dist/api/catalog.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export declare class OperationCatalog {
|
|
|
26
26
|
get(opId: string): Operation | undefined;
|
|
27
27
|
operations(): readonly Operation[];
|
|
28
28
|
suggest(opId: string, limit?: number): string[];
|
|
29
|
+
unexposedMatches(query: string, limit?: number): string[];
|
|
29
30
|
search(params: {
|
|
30
31
|
query: string;
|
|
31
32
|
tag?: string;
|
package/dist/api/catalog.js
CHANGED
|
@@ -28,7 +28,11 @@ export class OperationCatalog {
|
|
|
28
28
|
if (catalog.version !== 1) {
|
|
29
29
|
throw new Error(`Unsupported catalog version ${String(catalog.version)}`);
|
|
30
30
|
}
|
|
31
|
-
this.catalog = {
|
|
31
|
+
this.catalog = {
|
|
32
|
+
...catalog,
|
|
33
|
+
warnings: catalog.warnings ?? [],
|
|
34
|
+
coverage: catalog.coverage ?? { unexposedOpIds: [], permissionsWithoutOperation: [] },
|
|
35
|
+
};
|
|
32
36
|
this.byOpId = new Map(catalog.operations.map((o) => [o.opId, o]));
|
|
33
37
|
}
|
|
34
38
|
static permissionMap() {
|
|
@@ -70,6 +74,7 @@ export class OperationCatalog {
|
|
|
70
74
|
'answers 200 with an OpenAPI document.',
|
|
71
75
|
...(auth.error === undefined ? [] : [loginWarning(auth.error)]),
|
|
72
76
|
],
|
|
77
|
+
coverage: { unexposedOpIds: [], permissionsWithoutOperation: [] },
|
|
73
78
|
operations: [],
|
|
74
79
|
});
|
|
75
80
|
}
|
|
@@ -96,6 +101,22 @@ export class OperationCatalog {
|
|
|
96
101
|
.slice(0, limit)
|
|
97
102
|
.map((entry) => entry.opId);
|
|
98
103
|
}
|
|
104
|
+
unexposedMatches(query, limit = 5) {
|
|
105
|
+
const terms = query
|
|
106
|
+
.toLowerCase()
|
|
107
|
+
.split(/[\s/.]+/)
|
|
108
|
+
.filter((term) => term.length > 2);
|
|
109
|
+
if (terms.length === 0) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const { unexposedOpIds, permissionsWithoutOperation } = this.catalog.coverage;
|
|
113
|
+
return [...unexposedOpIds, ...permissionsWithoutOperation]
|
|
114
|
+
.filter((name) => {
|
|
115
|
+
const haystack = name.toLowerCase();
|
|
116
|
+
return terms.some((term) => haystack.includes(term));
|
|
117
|
+
})
|
|
118
|
+
.slice(0, limit);
|
|
119
|
+
}
|
|
99
120
|
search(params) {
|
|
100
121
|
const terms = params.query
|
|
101
122
|
.toLowerCase()
|
package/dist/api/client.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type CallResult = {
|
|
|
21
21
|
export declare class RequestBuildError extends Error {
|
|
22
22
|
}
|
|
23
23
|
export declare const normalizeBody: (body: unknown) => unknown;
|
|
24
|
+
export declare const unsupportedBodyFormat: (operation: Operation) => string | undefined;
|
|
24
25
|
export declare const buildUrl: (baseUrl: string, operation: Operation, args: CallArgs) => string;
|
|
25
26
|
export declare class AdminApiClient {
|
|
26
27
|
private readonly baseUrl;
|
|
@@ -32,5 +33,11 @@ export declare class AdminApiClient {
|
|
|
32
33
|
timeoutMs: number;
|
|
33
34
|
});
|
|
34
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;
|
|
35
42
|
fetchPermissions(adminId: number): Promise<string[]>;
|
|
36
43
|
}
|
package/dist/api/client.js
CHANGED
|
@@ -16,6 +16,21 @@ export const normalizeBody = (body) => {
|
|
|
16
16
|
return body;
|
|
17
17
|
}
|
|
18
18
|
};
|
|
19
|
+
export const unsupportedBodyFormat = (operation) => {
|
|
20
|
+
const contentType = operation.body?.contentType;
|
|
21
|
+
if (contentType === undefined || /^application\/(\w[\w.+-]*\+)?json$/.test(contentType)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
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.`;
|
|
33
|
+
};
|
|
19
34
|
export const buildUrl = (baseUrl, operation, args) => {
|
|
20
35
|
let path = operation.path;
|
|
21
36
|
const provided = args.path ?? {};
|
|
@@ -48,6 +63,10 @@ const hintFor = (status, operation) => {
|
|
|
48
63
|
if (status === 400 || status === 422) {
|
|
49
64
|
const loose = operation.body?.schema['x-loose'] === true;
|
|
50
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
|
+
: '') +
|
|
51
70
|
'Search the knowledge base with cms_docs_search before retrying.');
|
|
52
71
|
}
|
|
53
72
|
if (status === 403) {
|
|
@@ -75,6 +94,12 @@ const readBody = async (response) => {
|
|
|
75
94
|
return text.slice(0, 2000);
|
|
76
95
|
}
|
|
77
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
|
+
};
|
|
78
103
|
const extractMessage = (body, fallback) => {
|
|
79
104
|
if (typeof body === 'string') {
|
|
80
105
|
return body.slice(0, 500);
|
|
@@ -101,7 +126,7 @@ export class AdminApiClient {
|
|
|
101
126
|
}
|
|
102
127
|
async call(operation, args) {
|
|
103
128
|
const url = buildUrl(this.baseUrl, operation, args);
|
|
104
|
-
|
|
129
|
+
return this.send(operation, url, (token) => {
|
|
105
130
|
const headers = { authorization: `Bearer ${token}` };
|
|
106
131
|
let payload;
|
|
107
132
|
if (args.body !== undefined && operation.method !== 'get') {
|
|
@@ -114,12 +139,46 @@ export class AdminApiClient {
|
|
|
114
139
|
...(payload !== undefined ? { body: payload } : {}),
|
|
115
140
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
116
141
|
});
|
|
117
|
-
};
|
|
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) {
|
|
118
158
|
let response;
|
|
159
|
+
let body;
|
|
119
160
|
try {
|
|
120
|
-
response = await
|
|
161
|
+
response = await attempt(await this.tokens.accessToken());
|
|
121
162
|
if (response.status === 401) {
|
|
122
|
-
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
|
+
}
|
|
123
182
|
}
|
|
124
183
|
}
|
|
125
184
|
catch (error) {
|
|
@@ -132,7 +191,6 @@ export class AdminApiClient {
|
|
|
132
191
|
},
|
|
133
192
|
};
|
|
134
193
|
}
|
|
135
|
-
const body = await readBody(response);
|
|
136
194
|
if (!response.ok) {
|
|
137
195
|
const hint = hintFor(response.status, operation);
|
|
138
196
|
return {
|
|
@@ -36,7 +36,12 @@ export const normalizeSchema = (node, components, depth = 0, refStack = []) => {
|
|
|
36
36
|
}
|
|
37
37
|
const target = components[name];
|
|
38
38
|
if (target === undefined) {
|
|
39
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
type: 'object',
|
|
41
|
+
description: `(unresolved ${name})`,
|
|
42
|
+
'x-loose': true,
|
|
43
|
+
'x-unresolved': true,
|
|
44
|
+
};
|
|
40
45
|
}
|
|
41
46
|
return normalizeSchema(target, components, depth, [...refStack, name]);
|
|
42
47
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { VerifyWith } from './types.js';
|
|
2
|
+
export interface OperationNote {
|
|
3
|
+
note?: string;
|
|
4
|
+
example?: unknown;
|
|
5
|
+
verifyWith?: VerifyWith;
|
|
6
|
+
silentNoOp?: string;
|
|
7
|
+
readOnly?: true;
|
|
8
|
+
}
|
|
9
|
+
export declare const ALWAYS_CONFIRM_PREFIXES: readonly string[];
|
|
10
|
+
export declare const OPERATION_NOTES: Readonly<Record<string, OperationNote>>;
|
|
11
|
+
export declare const readOnlyOpIds: () => string[];
|