@abinnovision/payloadcms-mcpx 2.0.0-beta.19 → 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/dist/admin/capability-matrix.mjs +213 -316
- 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 +3 -6
- package/dist/api-keys/capability-matrix.mjs +9 -48
- package/dist/api-keys/collection.mjs +2 -5
- package/dist/api-keys/confirmation-view.mjs +2 -1
- package/dist/api-keys/fields.mjs +33 -55
- 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/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,
|
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { hashApiKey } from "../api-keys/key.mjs";
|
|
3
|
-
import { GRANT_ID, NO_REQ, expOf, issueRow, rowsWith } from "./grant.mjs";
|
|
4
|
-
import { ValidationError } from "payload";
|
|
1
|
+
import { NO_REQ, ROW_ID, claimRow, expOf, issueRow, rowHandle, rowPrefixes, rowsWith } from "./grant.mjs";
|
|
5
2
|
//#region src/grants/confirmation.ts
|
|
6
3
|
const CONFIRMATION_TTL_MS = 9e5;
|
|
7
4
|
const HANDLE = /^[\da-f]{64}$/;
|
|
8
|
-
const
|
|
9
|
-
const usedPrefix = (apiKeyId) => `mcpx-confirmation-used:${String(apiKeyId)}:`;
|
|
10
|
-
const asConfirmation = (row, now) => row && isPlainObject(row.data) && expOf(row.data) > now ? row.data : void 0;
|
|
11
|
-
/**
|
|
12
|
-
* The handle the admin panel uses for the entry of `id`: its HMAC, which is
|
|
13
|
-
* also what the row key holds.
|
|
14
|
-
*/ const confirmationHandle = (payload, id) => hashApiKey(payload.secret, id);
|
|
5
|
+
const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : void 0;
|
|
15
6
|
/**
|
|
16
7
|
* Stores a pending call and returns its id. A key may hold at most
|
|
17
8
|
* {@link CONFIRMATIONS_PER_KEY} calls.
|
|
@@ -21,8 +12,7 @@ const asConfirmation = (row, now) => row && isPlainObject(row.data) && expOf(row
|
|
|
21
12
|
state: "pending"
|
|
22
13
|
};
|
|
23
14
|
return await issueRow(payload, slug, {
|
|
24
|
-
|
|
25
|
-
usedPrefix: usedPrefix(call.apiKeyId),
|
|
15
|
+
...rowPrefixes("confirmation", call.apiKeyId),
|
|
26
16
|
cap: 50,
|
|
27
17
|
capMessage: `This key already has ${String(50)} calls waiting for approval. Run or abandon them first; each expires 15 minutes after it was requested.`,
|
|
28
18
|
ttlMs: CONFIRMATION_TTL_MS,
|
|
@@ -33,40 +23,19 @@ const asConfirmation = (row, now) => row && isPlainObject(row.data) && expOf(row
|
|
|
33
23
|
* The unexpired call of `apiKeyId` with `id`, without claiming it. A call of
|
|
34
24
|
* another key is `undefined`, as is a malformed id, before any database read.
|
|
35
25
|
*/ const readConfirmation = async (payload, slug, apiKeyId, id) => {
|
|
36
|
-
if (!
|
|
37
|
-
const key = `${
|
|
26
|
+
if (!ROW_ID.test(id)) return;
|
|
27
|
+
const key = `${rowPrefixes("confirmation", apiKeyId).prefix}${rowHandle(payload, id)}`;
|
|
38
28
|
const rows = await rowsWith(payload, slug, [key]);
|
|
39
29
|
return asConfirmation(rows.find((row) => row.key === key), Date.now());
|
|
40
30
|
};
|
|
41
31
|
/**
|
|
42
|
-
* Marks the call used and removes it.
|
|
43
|
-
*
|
|
44
|
-
*/ const claimConfirmation = async (payload, slug, apiKeyId, id, exp) =>
|
|
45
|
-
const handle = confirmationHandle(payload, id);
|
|
46
|
-
try {
|
|
47
|
-
await payload.db.create({
|
|
48
|
-
collection: slug,
|
|
49
|
-
data: {
|
|
50
|
-
key: `${usedPrefix(apiKeyId)}${handle}`,
|
|
51
|
-
data: { exp }
|
|
52
|
-
},
|
|
53
|
-
req: NO_REQ
|
|
54
|
-
});
|
|
55
|
-
} catch (error) {
|
|
56
|
-
if (error instanceof ValidationError) return false;
|
|
57
|
-
throw error;
|
|
58
|
-
}
|
|
59
|
-
await payload.db.deleteOne({
|
|
60
|
-
collection: slug,
|
|
61
|
-
where: { key: { equals: `${entryPrefix(apiKeyId)}${handle}` } },
|
|
62
|
-
req: NO_REQ
|
|
63
|
-
});
|
|
64
|
-
return true;
|
|
65
|
-
};
|
|
32
|
+
* Marks the call used and removes it. Of two concurrent claims exactly one
|
|
33
|
+
* returns `true`.
|
|
34
|
+
*/ const claimConfirmation = async (payload, slug, apiKeyId, id, exp) => await claimRow(payload, slug, rowPrefixes("confirmation", apiKeyId), rowHandle(payload, id), exp);
|
|
66
35
|
/**
|
|
67
36
|
* Every unexpired call of `apiKeyId` with its handle, oldest first.
|
|
68
37
|
*/ const listConfirmations = async (payload, slug, apiKeyId) => {
|
|
69
|
-
const prefix =
|
|
38
|
+
const { prefix } = rowPrefixes("confirmation", apiKeyId);
|
|
70
39
|
const now = Date.now();
|
|
71
40
|
return (await rowsWith(payload, slug, [prefix])).flatMap((row) => {
|
|
72
41
|
const confirmation = asConfirmation(row, now);
|
|
@@ -81,7 +50,7 @@ const asConfirmation = (row, now) => row && isPlainObject(row.data) && expOf(row
|
|
|
81
50
|
* returns the handles it changed. A call that was decided, run or expired is
|
|
82
51
|
* left alone and not returned.
|
|
83
52
|
*/ const decideConfirmations = async (payload, slug, apiKeyId, handles, state) => {
|
|
84
|
-
const prefix =
|
|
53
|
+
const { prefix } = rowPrefixes("confirmation", apiKeyId);
|
|
85
54
|
const keys = new Set(handles.filter((handle) => HANDLE.test(handle)).map((handle) => `${prefix}${handle}`));
|
|
86
55
|
if (keys.size === 0) return [];
|
|
87
56
|
const now = Date.now();
|
|
@@ -103,4 +72,4 @@ const asConfirmation = (row, now) => row && isPlainObject(row.data) && expOf(row
|
|
|
103
72
|
return decided;
|
|
104
73
|
};
|
|
105
74
|
//#endregion
|
|
106
|
-
export { claimConfirmation,
|
|
75
|
+
export { claimConfirmation, decideConfirmations, issueConfirmation, listConfirmations, readConfirmation };
|