@abinnovision/payloadcms-mcpx 2.0.0-beta.18 → 2.0.0-beta.20
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 +2 -2
- package/dist/admin/capability-matrix.d.mts +2 -1
- package/dist/admin/capability-matrix.mjs +291 -340
- package/dist/admin/capability-toggles.mjs +24 -31
- package/dist/admin/confirmations-panel.mjs +61 -71
- package/dist/admin/confirmations.mjs +2 -1
- package/dist/admin/endpoint-url.mjs +7 -0
- package/dist/admin/setup-guide.mjs +2 -1
- package/dist/api-keys/capability-matrix.d.mts +4 -7
- package/dist/api-keys/capability-matrix.mjs +12 -51
- package/dist/api-keys/collection.mjs +10 -17
- package/dist/api-keys/confirmation-view.mjs +2 -1
- package/dist/api-keys/fields.mjs +53 -69
- package/dist/auth/resolve.mjs +29 -10
- package/dist/capabilities.d.mts +2 -0
- package/dist/capabilities.mjs +62 -19
- package/dist/endpoint/confirmations.mjs +14 -14
- package/dist/endpoint/handler.mjs +6 -22
- package/dist/endpoint/http.mjs +27 -0
- package/dist/endpoint/upload.mjs +11 -43
- package/dist/grants/confirmation.mjs +11 -42
- package/dist/grants/grant.mjs +58 -40
- package/dist/guards.mjs +8 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/options.mjs +34 -54
- package/dist/plugin.mjs +2 -1
- package/dist/request.mjs +5 -1
- package/dist/result.mjs +11 -1
- package/dist/schema/admin-hidden.mjs +4 -4
- package/dist/schema/describe.mjs +12 -18
- package/dist/schema/index.mjs +5 -6
- package/dist/schema/lexical-pointer.mjs +8 -8
- package/dist/schema/lexical.mjs +15 -13
- package/dist/schema/outline.mjs +4 -5
- package/dist/schema/path.mjs +4 -1
- package/dist/schema/pointer.mjs +11 -17
- package/dist/schema/shape.mjs +38 -50
- package/dist/schema/walk.mjs +57 -18
- package/dist/tools/create-document.mjs +16 -44
- package/dist/tools/delete-document.mjs +14 -17
- package/dist/tools/describe-schema.mjs +7 -5
- package/dist/tools/document.mjs +130 -44
- package/dist/tools/duplicate-document.mjs +11 -10
- package/dist/tools/find-documents.mjs +15 -20
- package/dist/tools/find-versions.mjs +10 -14
- package/dist/tools/get-document.mjs +8 -12
- package/dist/tools/links.mjs +2 -1
- package/dist/tools/list-capabilities.mjs +2 -1
- package/dist/tools/patch-document.mjs +12 -44
- package/dist/tools/publish-document.mjs +9 -27
- package/dist/tools/query-paths.mjs +1 -1
- package/dist/tools/run-confirmed.mjs +38 -50
- package/dist/tools/shared.mjs +43 -41
- package/dist/tools/validate-document.mjs +4 -6
- package/dist/tools/versions.mjs +11 -6
- package/dist/types.d.mts +12 -26
- package/dist/upload/file.mjs +10 -18
- package/dist/write/draft-guard.mjs +20 -20
- package/dist/write/patch.mjs +4 -4
- package/dist/write/publish-intent.mjs +22 -28
- package/dist/write/row-ids.mjs +3 -1
- package/dist/write/write-data.mjs +18 -31
- package/package.json +5 -5
- package/dist/builtin-tool-names.mjs +0 -19
package/dist/api-keys/fields.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CAPABILITIES_FIELD } from "../capabilities.mjs";
|
|
2
|
-
import { CAPABILITIES_DESCRIPTION,
|
|
2
|
+
import { CAPABILITIES_DESCRIPTION, createCapabilityMatrix, rowOperations } from "./capability-matrix.mjs";
|
|
3
3
|
//#region src/api-keys/fields.ts
|
|
4
4
|
const encryptKey = ({ req, value }) => typeof value === "string" ? req.payload.encrypt(value) : value;
|
|
5
5
|
const decryptKey = ({ req, value }) => {
|
|
@@ -16,7 +16,24 @@ const checkbox = (name, description) => ({
|
|
|
16
16
|
defaultValue: false,
|
|
17
17
|
admin: { description }
|
|
18
18
|
});
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Access for fields only the server writes.
|
|
21
|
+
*/ const SERVER_ONLY = {
|
|
22
|
+
create: () => false,
|
|
23
|
+
update: () => false
|
|
24
|
+
};
|
|
25
|
+
const adminField = (name, exportName, options) => ({
|
|
26
|
+
name,
|
|
27
|
+
type: "ui",
|
|
28
|
+
admin: {
|
|
29
|
+
disableListColumn: true,
|
|
30
|
+
components: { Field: {
|
|
31
|
+
path: "@abinnovision/payloadcms-mcpx/admin",
|
|
32
|
+
exportName,
|
|
33
|
+
clientProps: { endpointPath: options.endpointPath }
|
|
34
|
+
} }
|
|
35
|
+
}
|
|
36
|
+
});
|
|
20
37
|
/**
|
|
21
38
|
* Fields every key carries. Key generation and the HMAC index live in the
|
|
22
39
|
* collection-level `beforeChange` hook (see `collection.ts`), because sibling
|
|
@@ -45,10 +62,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
45
62
|
{
|
|
46
63
|
name: "lastUsedAt",
|
|
47
64
|
type: "date",
|
|
48
|
-
access:
|
|
49
|
-
create: () => false,
|
|
50
|
-
update: () => false
|
|
51
|
-
},
|
|
65
|
+
access: SERVER_ONLY,
|
|
52
66
|
admin: {
|
|
53
67
|
readOnly: true,
|
|
54
68
|
date: { pickerAppearance: "dayAndTime" },
|
|
@@ -58,10 +72,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
58
72
|
{
|
|
59
73
|
name: "apiKey",
|
|
60
74
|
type: "text",
|
|
61
|
-
access:
|
|
62
|
-
create: () => false,
|
|
63
|
-
update: () => false
|
|
64
|
-
},
|
|
75
|
+
access: SERVER_ONLY,
|
|
65
76
|
admin: {
|
|
66
77
|
readOnly: true,
|
|
67
78
|
description: "Generated when the key is created. Send it as `Authorization: Bearer <key>`."
|
|
@@ -83,47 +94,31 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
83
94
|
* a collection exposes `delete`. The component renders nothing while none is
|
|
84
95
|
* pending and never touches the form, so deciding does not mark the key
|
|
85
96
|
* modified.
|
|
86
|
-
*/ const createConfirmationFields = (options) => options.confirmations ? [
|
|
87
|
-
name: "confirmations",
|
|
88
|
-
type: "ui",
|
|
89
|
-
admin: {
|
|
90
|
-
disableListColumn: true,
|
|
91
|
-
components: { Field: {
|
|
92
|
-
path: "@abinnovision/payloadcms-mcpx/admin",
|
|
93
|
-
exportName: "McpxConfirmations",
|
|
94
|
-
clientProps: { endpointPath: options.endpointPath }
|
|
95
|
-
} }
|
|
96
|
-
}
|
|
97
|
-
}] : [];
|
|
97
|
+
*/ const createConfirmationFields = (options) => options.confirmations ? [adminField("confirmations", "McpxConfirmations", options)] : [];
|
|
98
98
|
/**
|
|
99
|
-
* Wraps the key fields and the setup guide in unnamed tabs,
|
|
100
|
-
* get the full form width. Named tabs would nest
|
|
101
|
-
* `capabilities` off the document root, which capability
|
|
99
|
+
* Wraps the key fields, the capabilities and the setup guide in unnamed tabs,
|
|
100
|
+
* so wide tables and snippets get the full form width. Named tabs would nest
|
|
101
|
+
* the data and move `capabilities` off the document root, which capability
|
|
102
|
+
* resolution reads.
|
|
102
103
|
*
|
|
103
104
|
* The guide tab only shows on update: on create there is no key to hand out.
|
|
104
|
-
*/ const
|
|
105
|
-
|
|
105
|
+
*/ const withKeyTabs = (keyFields, capabilityFields, options) => {
|
|
106
|
+
const tabs = [{
|
|
107
|
+
label: "Key",
|
|
108
|
+
fields: keyFields
|
|
109
|
+
}];
|
|
110
|
+
if (capabilityFields.length > 0) tabs.push({
|
|
111
|
+
label: "Capabilities",
|
|
112
|
+
fields: capabilityFields
|
|
113
|
+
});
|
|
114
|
+
if (options.setupGuide) tabs.push({
|
|
115
|
+
label: "Connect a client",
|
|
116
|
+
admin: { condition: (_data, _siblingData, { operation }) => operation === "update" },
|
|
117
|
+
fields: [adminField("setupGuide", "McpxSetupGuide", options)]
|
|
118
|
+
});
|
|
106
119
|
return [{
|
|
107
120
|
type: "tabs",
|
|
108
|
-
tabs
|
|
109
|
-
label: "Key",
|
|
110
|
-
fields: keyFields
|
|
111
|
-
}, {
|
|
112
|
-
label: "Connect a client",
|
|
113
|
-
admin: { condition: (_data, _siblingData, { operation }) => operation === "update" },
|
|
114
|
-
fields: [{
|
|
115
|
-
name: SETUP_GUIDE_FIELD,
|
|
116
|
-
type: "ui",
|
|
117
|
-
admin: {
|
|
118
|
-
disableListColumn: true,
|
|
119
|
-
components: { Field: {
|
|
120
|
-
path: "@abinnovision/payloadcms-mcpx/admin",
|
|
121
|
-
exportName: "McpxSetupGuide",
|
|
122
|
-
clientProps: { endpointPath: options.endpointPath }
|
|
123
|
-
} }
|
|
124
|
-
}
|
|
125
|
-
}]
|
|
126
|
-
}]
|
|
121
|
+
tabs
|
|
127
122
|
}];
|
|
128
123
|
};
|
|
129
124
|
/**
|
|
@@ -140,29 +135,18 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
140
135
|
const entityGroups = (rows) => rows.map((row) => ({
|
|
141
136
|
name: row.fieldName,
|
|
142
137
|
type: "group",
|
|
143
|
-
label: row.
|
|
144
|
-
fields:
|
|
138
|
+
label: row.slug,
|
|
139
|
+
fields: rowOperations(row).map((operation) => checkbox(operation.id, operation.description))
|
|
145
140
|
}));
|
|
146
|
-
const collectionGroups = entityGroups(matrix.collections);
|
|
147
|
-
const globalGroups = entityGroups(matrix.globals);
|
|
148
|
-
const toolCheckboxes = matrix.tools.map((tool) => checkbox(tool.name, tool.description));
|
|
149
141
|
const groups = [
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
fields: globalGroups
|
|
159
|
-
}] : [],
|
|
160
|
-
...toolCheckboxes.length > 0 ? [{
|
|
161
|
-
name: "tools",
|
|
162
|
-
type: "group",
|
|
163
|
-
fields: toolCheckboxes
|
|
164
|
-
}] : []
|
|
165
|
-
];
|
|
142
|
+
["collections", entityGroups(matrix.collections)],
|
|
143
|
+
["globals", entityGroups(matrix.globals)],
|
|
144
|
+
["tools", matrix.tools.map((tool) => checkbox(tool.name, tool.description))]
|
|
145
|
+
].filter(([, fields]) => fields.length > 0).map(([name, fields]) => ({
|
|
146
|
+
name,
|
|
147
|
+
type: "group",
|
|
148
|
+
fields
|
|
149
|
+
}));
|
|
166
150
|
if (groups.length === 0) return [];
|
|
167
151
|
return [{
|
|
168
152
|
name: CAPABILITIES_FIELD,
|
|
@@ -174,7 +158,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
174
158
|
exportName: "McpxCapabilityMatrix",
|
|
175
159
|
clientProps: {
|
|
176
160
|
matrix,
|
|
177
|
-
withinTab:
|
|
161
|
+
withinTab: true
|
|
178
162
|
}
|
|
179
163
|
} }
|
|
180
164
|
},
|
|
@@ -182,4 +166,4 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
182
166
|
}];
|
|
183
167
|
};
|
|
184
168
|
//#endregion
|
|
185
|
-
export { createCapabilityFields, createConfirmationFields, createKeyFields,
|
|
169
|
+
export { SERVER_ONLY, createCapabilityFields, createConfirmationFields, createKeyFields, withKeyTabs };
|
package/dist/auth/resolve.mjs
CHANGED
|
@@ -4,16 +4,18 @@ import { hashApiKey } from "../api-keys/key.mjs";
|
|
|
4
4
|
const BEARER = /^Bearer\s+(\S+)\s*$/i;
|
|
5
5
|
const LAST_USED_INTERVAL_MS = 36e5;
|
|
6
6
|
const toTime = (value) => typeof value === "string" || value instanceof Date ? new Date(value).getTime() : NaN;
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
8
|
+
/**
|
|
9
|
+
* The id of a relationship value, populated or not.
|
|
10
|
+
*/ const relationId = (value) => {
|
|
11
|
+
if (isId(value)) return value;
|
|
12
|
+
return isPlainObject(value) && isId(value["id"]) ? value["id"] : void 0;
|
|
10
13
|
};
|
|
11
14
|
const parseBearer = (headers) => {
|
|
12
15
|
const header = headers.get("authorization");
|
|
13
16
|
if (!header) return null;
|
|
14
17
|
return BEARER.exec(header.trim())?.[1] ?? null;
|
|
15
18
|
};
|
|
16
|
-
const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
17
19
|
/**
|
|
18
20
|
* Whether a resolved auth, from the default or a custom resolver, may become
|
|
19
21
|
* `req.user`: a user of the configured user collection with an id, and a key id.
|
|
@@ -58,6 +60,26 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
|
58
60
|
capabilities: keyDoc.capabilities
|
|
59
61
|
};
|
|
60
62
|
};
|
|
63
|
+
const KEY_SELECT = {
|
|
64
|
+
enabled: true,
|
|
65
|
+
user: true,
|
|
66
|
+
capabilities: true,
|
|
67
|
+
expiresAt: true
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* The user the key `id` acts as, or `null`, under the same checks as the
|
|
71
|
+
* bearer lookup.
|
|
72
|
+
*/ const resolveApiKeyById = async (req, options, id) => {
|
|
73
|
+
const keyDoc = await req.payload.findByID({
|
|
74
|
+
collection: options.apiKeysSlug,
|
|
75
|
+
id,
|
|
76
|
+
depth: 0,
|
|
77
|
+
overrideAccess: true,
|
|
78
|
+
disableErrors: true,
|
|
79
|
+
select: KEY_SELECT
|
|
80
|
+
});
|
|
81
|
+
return await checkApiKey(req, options, keyDoc ?? void 0);
|
|
82
|
+
};
|
|
61
83
|
/**
|
|
62
84
|
* Resolves the bearer key of a request to the user it acts as.
|
|
63
85
|
*
|
|
@@ -76,10 +98,7 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
|
76
98
|
depth: 0,
|
|
77
99
|
overrideAccess: true,
|
|
78
100
|
select: {
|
|
79
|
-
|
|
80
|
-
user: true,
|
|
81
|
-
capabilities: true,
|
|
82
|
-
expiresAt: true,
|
|
101
|
+
...KEY_SELECT,
|
|
83
102
|
lastUsedAt: true
|
|
84
103
|
}
|
|
85
104
|
});
|
|
@@ -99,10 +118,10 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
|
99
118
|
} catch (error) {
|
|
100
119
|
payload.logger.warn({
|
|
101
120
|
err: error,
|
|
102
|
-
msg: "Could not record the last use of an MCP API key"
|
|
121
|
+
msg: "[payloadcms-mcpx] Could not record the last use of an MCP API key."
|
|
103
122
|
});
|
|
104
123
|
}
|
|
105
124
|
return auth;
|
|
106
125
|
};
|
|
107
126
|
//#endregion
|
|
108
|
-
export { checkApiKey, isValidAuthResult, parseBearer, resolveApiKeyAuth };
|
|
127
|
+
export { checkApiKey, isValidAuthResult, parseBearer, relationId, resolveApiKeyAuth, resolveApiKeyById };
|
package/dist/capabilities.mjs
CHANGED
|
@@ -4,21 +4,60 @@ import { isPlainObject } from "./guards.mjs";
|
|
|
4
4
|
* Group field holding the capability checkboxes on an API key document.
|
|
5
5
|
*/ const CAPABILITIES_FIELD = "capabilities";
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
|
|
7
|
+
* The operations a collection or global can expose. The generated checkboxes
|
|
8
|
+
* and the matrix segments share this wording. `requires` names the
|
|
9
|
+
* operation a grant depends on, which the server and the matrix both enforce.
|
|
10
|
+
*/ const CAPABILITY_OPERATIONS = [
|
|
11
|
+
{
|
|
12
|
+
id: "read",
|
|
13
|
+
label: "Read",
|
|
14
|
+
description: "Describe, find and read."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "write",
|
|
18
|
+
label: "Write",
|
|
19
|
+
description: "Create, patch and validate drafts.",
|
|
20
|
+
requires: "read"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "publish",
|
|
24
|
+
label: "Publish",
|
|
25
|
+
description: "Promote the current draft to what the public sees.",
|
|
26
|
+
requires: "write"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "delete",
|
|
30
|
+
label: "Delete",
|
|
31
|
+
description: "Delete documents. Each one is approved by this key's user in the admin panel, unless the config allows trashing directly and this key is set to.",
|
|
32
|
+
requires: "read"
|
|
33
|
+
}
|
|
34
|
+
];
|
|
9
35
|
/**
|
|
10
|
-
*
|
|
11
|
-
|
|
36
|
+
* Stored as a checkbox of its own but drawn as the approval shield of the Delete
|
|
37
|
+
* control.
|
|
38
|
+
*/ const DELETE_UNATTENDED = {
|
|
39
|
+
id: "deleteUnattended",
|
|
40
|
+
label: "Trash directly",
|
|
41
|
+
description: "Move documents to trash without asking this key's user.",
|
|
42
|
+
requires: "delete"
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Every operation with a stored checkbox: the capability operations plus
|
|
46
|
+
* the unattended delete. Each follows the one it requires, which the resolve
|
|
47
|
+
* loop depends on.
|
|
48
|
+
*/ const STORED_OPERATIONS = [...CAPABILITY_OPERATIONS, DELETE_UNATTENDED];
|
|
12
49
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*/ const
|
|
50
|
+
* The operations in one run of the access levels, each requiring the one
|
|
51
|
+
* before it. Delete is chosen separately.
|
|
52
|
+
*/ const ACCESS_CHAIN = CAPABILITY_OPERATIONS.filter((operation) => operation.id !== "delete");
|
|
16
53
|
/**
|
|
17
|
-
*
|
|
18
|
-
|
|
54
|
+
* What an operation depends on. `resolveCapabilities` discards a grant whose
|
|
55
|
+
* prerequisite is off.
|
|
56
|
+
*/ const requiresOf = (operation) => {
|
|
57
|
+
const found = STORED_OPERATIONS.find((candidate) => candidate.id === operation);
|
|
58
|
+
return found && "requires" in found ? found.requires : void 0;
|
|
59
|
+
};
|
|
19
60
|
const flag = (group, name) => isPlainObject(group) && group[name] === true;
|
|
20
|
-
const writeFlag = (entity, group) => canWrite(entity) && entity.read && flag(group, "read") && flag(group, "write");
|
|
21
|
-
const publishFlag = (entity, group) => canPublish(entity) && writeFlag(entity, group) && flag(group, "publish");
|
|
22
61
|
/**
|
|
23
62
|
* Capabilities in force for a key: the plugin config decides what can exist,
|
|
24
63
|
* the key's checkboxes decide what does. A missing checkbox is `false`, so keys
|
|
@@ -29,14 +68,18 @@ const publishFlag = (entity, group) => canPublish(entity) && writeFlag(entity, g
|
|
|
29
68
|
const resolved = {};
|
|
30
69
|
for (const entity of entities) {
|
|
31
70
|
const group = isPlainObject(namespaceGroup) ? namespaceGroup[entity.fieldName] : void 0;
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
deleteUnattended: deleting && entity.deleteUnattended && flag(group, "deleteUnattended")
|
|
71
|
+
const granted = {
|
|
72
|
+
read: false,
|
|
73
|
+
write: false,
|
|
74
|
+
publish: false,
|
|
75
|
+
delete: false,
|
|
76
|
+
deleteUnattended: false
|
|
39
77
|
};
|
|
78
|
+
for (const { id } of STORED_OPERATIONS) {
|
|
79
|
+
const needs = requiresOf(id);
|
|
80
|
+
granted[id] = entity[id] && flag(group, id) && (needs === void 0 || granted[needs]);
|
|
81
|
+
}
|
|
82
|
+
resolved[entity.slug] = granted;
|
|
40
83
|
}
|
|
41
84
|
return resolved;
|
|
42
85
|
};
|
|
@@ -62,4 +105,4 @@ const publishFlag = (entity, group) => canPublish(entity) && writeFlag(entity, g
|
|
|
62
105
|
};
|
|
63
106
|
};
|
|
64
107
|
//#endregion
|
|
65
|
-
export {
|
|
108
|
+
export { ACCESS_CHAIN, CAPABILITIES_FIELD, CAPABILITY_OPERATIONS, STORED_OPERATIONS, requiresOf, resolveCapabilities, scopeSlugs };
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { isPlainObject } from "../guards.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { grantContext, grantKvSlug, rowHandle } from "../grants/grant.mjs";
|
|
3
|
+
import { respond } from "./http.mjs";
|
|
4
|
+
import { CONFIRMATIONS_PATH, DECISIONS } from "../api-keys/confirmation-view.mjs";
|
|
5
|
+
import { relationId } from "../auth/resolve.mjs";
|
|
6
|
+
import { decideConfirmations, issueConfirmation, listConfirmations } from "../grants/confirmation.mjs";
|
|
7
|
+
import { publicOrigin } from "../request.mjs";
|
|
5
8
|
import { jsonResult } from "../result.mjs";
|
|
6
9
|
import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
7
10
|
import { formatAdminURL } from "payload/shared";
|
|
8
11
|
//#region src/endpoint/confirmations.ts
|
|
9
|
-
const respond = (status, body) => Response.json(body, { status });
|
|
10
12
|
const confirmableTool = (name) => BUILTIN_TOOLS.find((tool) => tool.name === name && tool.confirm);
|
|
11
13
|
const keyViewUrl = (req, options, apiKeyId) => {
|
|
12
|
-
const { config } = req.payload;
|
|
13
14
|
return formatAdminURL({
|
|
14
|
-
adminRoute: config.routes.admin,
|
|
15
|
+
adminRoute: req.payload.config.routes.admin,
|
|
15
16
|
path: `/collections/${options.apiKeysSlug}/${String(apiKeyId)}`,
|
|
16
|
-
serverURL:
|
|
17
|
+
serverURL: publicOrigin(req)
|
|
17
18
|
});
|
|
18
19
|
};
|
|
19
20
|
/**
|
|
@@ -24,9 +25,7 @@ const keyViewUrl = (req, options, apiKeyId) => {
|
|
|
24
25
|
confirmed: false,
|
|
25
26
|
request: async (args) => {
|
|
26
27
|
const { req } = scope;
|
|
27
|
-
const slug =
|
|
28
|
-
const apiKeyId = req.context.mcpx?.apiKeyId;
|
|
29
|
-
if (slug === void 0 || apiKeyId === void 0) throw new Error("A confirmation was requested without a database KV.");
|
|
28
|
+
const { slug, apiKeyId } = grantContext(req);
|
|
30
29
|
const view = keyViewUrl(req, options, apiKeyId);
|
|
31
30
|
const { id, exp } = await issueConfirmation(req.payload, slug, {
|
|
32
31
|
apiKeyId,
|
|
@@ -54,8 +53,8 @@ const authorize = async (req, options, keyId) => {
|
|
|
54
53
|
disableErrors: true,
|
|
55
54
|
select: { user: true }
|
|
56
55
|
}).catch(() => null) : null;
|
|
57
|
-
const user =
|
|
58
|
-
if (!key ||
|
|
56
|
+
const user = relationId(key?.user);
|
|
57
|
+
if (!key || user === void 0 || String(user) !== String(req.user.id)) return respond(403, { error: "Only the user of this key may decide on its calls." });
|
|
59
58
|
return {
|
|
60
59
|
slug,
|
|
61
60
|
apiKeyId: key.id
|
|
@@ -89,7 +88,7 @@ const viewOf = async (req, entry, highlight) => {
|
|
|
89
88
|
if (allowed instanceof Response) return allowed;
|
|
90
89
|
const { payload } = req;
|
|
91
90
|
const highlighted = req.searchParams.get("confirmation");
|
|
92
|
-
const highlight = highlighted === null ? void 0 :
|
|
91
|
+
const highlight = highlighted === null ? void 0 : rowHandle(payload, highlighted);
|
|
93
92
|
const entries = (await listConfirmations(payload, allowed.slug, allowed.apiKeyId)).filter((entry) => entry.confirmation.state === "pending");
|
|
94
93
|
const confirmations = [];
|
|
95
94
|
for (const entry of entries) confirmations.push(await viewOf(req, entry, highlight));
|
|
@@ -105,7 +104,8 @@ const viewOf = async (req, entry, highlight) => {
|
|
|
105
104
|
if (allowed instanceof Response) return allowed;
|
|
106
105
|
const handles = isPlainObject(body) ? body["handles"] : void 0;
|
|
107
106
|
const decision = isPlainObject(body) ? body["decision"] : void 0;
|
|
108
|
-
|
|
107
|
+
const isDecision = (value) => DECISIONS.some((candidate) => candidate === value);
|
|
108
|
+
if (!Array.isArray(handles) || handles.length > 50 || !handles.every((handle) => typeof handle === "string") || !isDecision(decision)) return respond(400, { error: `Send "handles", at most ${String(50)}, and a "decision" of "approved" or "rejected".` });
|
|
109
109
|
const decided = await decideConfirmations(req.payload, allowed.slug, allowed.apiKeyId, handles, decision);
|
|
110
110
|
return respond(200, { decided });
|
|
111
111
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveCapabilities, scopeSlugs } from "../capabilities.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { grantsAvailable, readCapped } from "./http.mjs";
|
|
3
3
|
import { isValidAuthResult, resolveApiKeyAuth } from "../auth/resolve.mjs";
|
|
4
4
|
import { jsonRpcError } from "./errors.mjs";
|
|
5
5
|
import { createMcpServer } from "./server.mjs";
|
|
@@ -7,19 +7,6 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
7
7
|
//#region src/endpoint/handler.ts
|
|
8
8
|
const BODY_BYTES_LIMIT = 4194304;
|
|
9
9
|
const BATCH_MESSAGES_LIMIT = 10;
|
|
10
|
-
const readBody = async (req) => {
|
|
11
|
-
if (Number(req.headers.get("content-length")) > BODY_BYTES_LIMIT) return null;
|
|
12
|
-
if (!req.body) return;
|
|
13
|
-
const decoder = new TextDecoder();
|
|
14
|
-
let text = "";
|
|
15
|
-
let size = 0;
|
|
16
|
-
for await (const chunk of req.body) {
|
|
17
|
-
size += chunk.byteLength;
|
|
18
|
-
if (size > BODY_BYTES_LIMIT) return null;
|
|
19
|
-
text += decoder.decode(chunk, { stream: true });
|
|
20
|
-
}
|
|
21
|
-
return text + decoder.decode();
|
|
22
|
-
};
|
|
23
10
|
/**
|
|
24
11
|
* The scope the tools of one request see: the key's resolved capabilities, the
|
|
25
12
|
* slugs they reach, and the locales and limits of the config.
|
|
@@ -35,11 +22,8 @@ const readBody = async (req) => {
|
|
|
35
22
|
defaultLocale: localization.defaultLocale
|
|
36
23
|
} : null,
|
|
37
24
|
limits: options.limits,
|
|
38
|
-
uploads:
|
|
39
|
-
diagnostics: options.diagnostics ?
|
|
40
|
-
name: options.serverInfo.name,
|
|
41
|
-
version: options.serverInfo.version
|
|
42
|
-
} : null,
|
|
25
|
+
uploads: grantsAvailable(req, options) !== void 0,
|
|
26
|
+
diagnostics: options.diagnostics ? options.serverInfo : null,
|
|
43
27
|
exposure: {
|
|
44
28
|
collections: options.collections,
|
|
45
29
|
globals: options.globals
|
|
@@ -94,13 +78,13 @@ const readBody = async (req) => {
|
|
|
94
78
|
const scope = authenticateAs(req, options, auth);
|
|
95
79
|
let parsedBody;
|
|
96
80
|
try {
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
81
|
+
const body = await readCapped(req, BODY_BYTES_LIMIT);
|
|
82
|
+
if (body === null) return jsonRpcError({
|
|
99
83
|
status: 413,
|
|
100
84
|
code: -32e3,
|
|
101
85
|
message: `Request body too large: the limit is ${String(BODY_BYTES_LIMIT / 1024 / 1024)} MB.`
|
|
102
86
|
});
|
|
103
|
-
parsedBody =
|
|
87
|
+
parsedBody = body === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(body));
|
|
104
88
|
} catch {
|
|
105
89
|
return jsonRpcError({
|
|
106
90
|
status: 400,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { grantKvSlug } from "../grants/grant.mjs";
|
|
2
|
+
//#region src/endpoint/http.ts
|
|
3
|
+
const respond = (status, body) => Response.json(body, { status });
|
|
4
|
+
/**
|
|
5
|
+
* The body, `null` once it passes `max` bytes, or `undefined` when there is
|
|
6
|
+
* none. A declared length over `max` is refused unread. A chunked body
|
|
7
|
+
* declares none, so bytes are counted as they arrive and reading stops at
|
|
8
|
+
* `max`.
|
|
9
|
+
*/ const readCapped = async (req, max) => {
|
|
10
|
+
if (Number(req.headers.get("content-length")) > max) return null;
|
|
11
|
+
if (!req.body) return;
|
|
12
|
+
const chunks = [];
|
|
13
|
+
let size = 0;
|
|
14
|
+
for await (const chunk of req.body) {
|
|
15
|
+
size += chunk.byteLength;
|
|
16
|
+
if (size > max) return null;
|
|
17
|
+
chunks.push(chunk);
|
|
18
|
+
}
|
|
19
|
+
return Buffer.concat(chunks);
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The grant KV slug where uploads, downloads and confirmations are possible:
|
|
23
|
+
* the KV is database-backed and no custom `auth.resolve` is set, since the
|
|
24
|
+
* grant endpoints cannot replay what such a resolver authenticated by.
|
|
25
|
+
*/ const grantsAvailable = (req, options) => options.auth?.resolve === void 0 ? grantKvSlug(req.payload.config) : void 0;
|
|
26
|
+
//#endregion
|
|
27
|
+
export { grantsAvailable, readCapped, respond };
|
package/dist/endpoint/upload.mjs
CHANGED
|
@@ -1,66 +1,34 @@
|
|
|
1
1
|
import { isToolEnabled, toolInputSchema } from "../define-tool.mjs";
|
|
2
|
-
import { claimGrant
|
|
2
|
+
import { claimGrant } from "../grants/grant.mjs";
|
|
3
|
+
import { grantsAvailable, readCapped, respond } from "./http.mjs";
|
|
4
|
+
import { resolveApiKeyById } from "../auth/resolve.mjs";
|
|
5
|
+
import { parseResult } from "../result.mjs";
|
|
3
6
|
import { downloadSlugs, setUploadedFile, uploadMaxBytes } from "../upload/file.mjs";
|
|
4
7
|
import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
5
|
-
import { checkApiKey } from "../auth/resolve.mjs";
|
|
6
8
|
import { runTool } from "./server.mjs";
|
|
7
9
|
import { authenticateAs } from "./handler.mjs";
|
|
8
10
|
//#region src/endpoint/upload.ts
|
|
9
11
|
const UPLOAD_TOOLS = /* @__PURE__ */ new Set(["createDocument", "patchDocument"]);
|
|
10
|
-
const respond = (status, body) => Response.json(body, { status });
|
|
11
12
|
const refused = () => respond(403, { error: "The request was refused: the grant is unknown, expired or used, or the key no longer allows it. Call the tool again for a new one." });
|
|
12
|
-
const readExactly = async (req, size) => {
|
|
13
|
-
const declared = req.headers.get("content-length");
|
|
14
|
-
if (declared !== null && Number(declared) !== size) return null;
|
|
15
|
-
if (!req.body) return null;
|
|
16
|
-
const chunks = [];
|
|
17
|
-
let total = 0;
|
|
18
|
-
for await (const chunk of req.body) {
|
|
19
|
-
total += chunk.byteLength;
|
|
20
|
-
if (total > size) return null;
|
|
21
|
-
chunks.push(chunk);
|
|
22
|
-
}
|
|
23
|
-
return total === size ? Buffer.concat(chunks) : null;
|
|
24
|
-
};
|
|
25
13
|
const statusOf = (result, data) => {
|
|
26
14
|
if (result.isError !== true) return 200;
|
|
27
15
|
if ("updatedAt" in data || data["status"] === 423) return 409;
|
|
28
16
|
if (data["status"] === 401 || data["status"] === 403) return 403;
|
|
29
17
|
return data["error"] === "Internal error" ? 500 : 422;
|
|
30
18
|
};
|
|
31
|
-
const parseResult = (result) => {
|
|
32
|
-
const [first] = result.content;
|
|
33
|
-
return first?.type === "text" ? JSON.parse(first.text) : {};
|
|
34
|
-
};
|
|
35
19
|
const NO_SESSION = {
|
|
36
20
|
signal: new AbortController().signal,
|
|
37
21
|
requestId: "upload",
|
|
38
22
|
sendNotification: () => Promise.resolve(),
|
|
39
23
|
sendRequest: () => Promise.reject(/* @__PURE__ */ new Error("No MCP session."))
|
|
40
24
|
};
|
|
41
|
-
const reauthenticate = async (req, options, grant) => {
|
|
42
|
-
const keyDoc = await req.payload.findByID({
|
|
43
|
-
collection: options.apiKeysSlug,
|
|
44
|
-
id: grant.apiKeyId,
|
|
45
|
-
depth: 0,
|
|
46
|
-
overrideAccess: true,
|
|
47
|
-
disableErrors: true,
|
|
48
|
-
select: {
|
|
49
|
-
enabled: true,
|
|
50
|
-
user: true,
|
|
51
|
-
capabilities: true,
|
|
52
|
-
expiresAt: true
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
return await checkApiKey(req, options, keyDoc ?? void 0);
|
|
56
|
-
};
|
|
57
25
|
const claimAs = async (req, options, kind) => {
|
|
58
|
-
const slug =
|
|
26
|
+
const slug = grantsAvailable(req, options);
|
|
59
27
|
const grantId = req.headers.get("x-mcpx-grant");
|
|
60
|
-
if (slug === void 0 ||
|
|
28
|
+
if (slug === void 0 || grantId === null) return;
|
|
61
29
|
const grant = await claimGrant(req.payload, slug, grantId);
|
|
62
30
|
if (grant?.kind !== kind) return;
|
|
63
|
-
const auth = await
|
|
31
|
+
const auth = await resolveApiKeyById(req, options, grant.apiKeyId);
|
|
64
32
|
if (!auth) return;
|
|
65
33
|
const scope = authenticateAs(req, options, auth);
|
|
66
34
|
req.query = {};
|
|
@@ -80,16 +48,16 @@ const claimAs = async (req, options, kind) => {
|
|
|
80
48
|
*/ const createUploadHandler = (options) => async (req) => {
|
|
81
49
|
const { payload } = req;
|
|
82
50
|
const claimed = await claimAs(req, options, "upload");
|
|
83
|
-
|
|
84
|
-
if (!claimed || !UPLOAD_TOOLS.has(claimed.grant.tool) || !file) return refused();
|
|
51
|
+
if (!claimed || !UPLOAD_TOOLS.has(claimed.grant.tool) || !claimed.grant.file) return refused();
|
|
85
52
|
const { grant, scope } = claimed;
|
|
53
|
+
const { file } = grant;
|
|
86
54
|
const tool = BUILTIN_TOOLS.find((candidate) => candidate.name === grant.tool);
|
|
87
55
|
if (!tool || !isToolEnabled(tool, scope)) return refused();
|
|
88
56
|
const args = toolInputSchema(tool, scope).safeParse(grant.args);
|
|
89
57
|
if (!args.success) return refused();
|
|
90
58
|
if (file.size > uploadMaxBytes(payload.config)) return respond(413, { error: "The file is larger than allowed." });
|
|
91
|
-
const data = await
|
|
92
|
-
if (data
|
|
59
|
+
const data = await readCapped(req, file.size);
|
|
60
|
+
if (data?.length !== file.size) return respond(400, { error: `The body must be exactly ${String(file.size)} bytes, the size the tool call declared.` });
|
|
93
61
|
setUploadedFile(req, {
|
|
94
62
|
data,
|
|
95
63
|
mimetype: file.mimeType,
|