@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.
Files changed (63) hide show
  1. package/dist/admin/capability-matrix.mjs +213 -316
  2. package/dist/admin/capability-toggles.mjs +24 -31
  3. package/dist/admin/confirmations-panel.mjs +61 -71
  4. package/dist/admin/confirmations.mjs +2 -1
  5. package/dist/admin/endpoint-url.mjs +7 -0
  6. package/dist/admin/setup-guide.mjs +2 -1
  7. package/dist/api-keys/capability-matrix.d.mts +3 -6
  8. package/dist/api-keys/capability-matrix.mjs +9 -48
  9. package/dist/api-keys/collection.mjs +2 -5
  10. package/dist/api-keys/confirmation-view.mjs +2 -1
  11. package/dist/api-keys/fields.mjs +33 -55
  12. package/dist/auth/resolve.mjs +29 -10
  13. package/dist/capabilities.d.mts +2 -0
  14. package/dist/capabilities.mjs +62 -19
  15. package/dist/endpoint/confirmations.mjs +14 -14
  16. package/dist/endpoint/handler.mjs +6 -22
  17. package/dist/endpoint/http.mjs +27 -0
  18. package/dist/endpoint/upload.mjs +11 -43
  19. package/dist/grants/confirmation.mjs +11 -42
  20. package/dist/grants/grant.mjs +58 -40
  21. package/dist/guards.mjs +8 -1
  22. package/dist/index.d.mts +2 -2
  23. package/dist/index.mjs +1 -1
  24. package/dist/options.mjs +34 -54
  25. package/dist/plugin.mjs +2 -1
  26. package/dist/request.mjs +5 -1
  27. package/dist/result.mjs +11 -1
  28. package/dist/schema/admin-hidden.mjs +4 -4
  29. package/dist/schema/describe.mjs +12 -18
  30. package/dist/schema/index.mjs +5 -6
  31. package/dist/schema/lexical-pointer.mjs +8 -8
  32. package/dist/schema/lexical.mjs +15 -13
  33. package/dist/schema/outline.mjs +4 -5
  34. package/dist/schema/path.mjs +4 -1
  35. package/dist/schema/pointer.mjs +11 -17
  36. package/dist/schema/shape.mjs +38 -50
  37. package/dist/schema/walk.mjs +57 -18
  38. package/dist/tools/create-document.mjs +16 -44
  39. package/dist/tools/delete-document.mjs +14 -17
  40. package/dist/tools/describe-schema.mjs +7 -5
  41. package/dist/tools/document.mjs +130 -44
  42. package/dist/tools/duplicate-document.mjs +11 -10
  43. package/dist/tools/find-documents.mjs +15 -20
  44. package/dist/tools/find-versions.mjs +10 -14
  45. package/dist/tools/get-document.mjs +8 -12
  46. package/dist/tools/links.mjs +2 -1
  47. package/dist/tools/list-capabilities.mjs +2 -1
  48. package/dist/tools/patch-document.mjs +12 -44
  49. package/dist/tools/publish-document.mjs +9 -27
  50. package/dist/tools/query-paths.mjs +1 -1
  51. package/dist/tools/run-confirmed.mjs +38 -50
  52. package/dist/tools/shared.mjs +43 -41
  53. package/dist/tools/validate-document.mjs +4 -6
  54. package/dist/tools/versions.mjs +11 -6
  55. package/dist/types.d.mts +12 -26
  56. package/dist/upload/file.mjs +10 -18
  57. package/dist/write/draft-guard.mjs +20 -20
  58. package/dist/write/patch.mjs +4 -4
  59. package/dist/write/publish-intent.mjs +22 -28
  60. package/dist/write/row-ids.mjs +3 -1
  61. package/dist/write/write-data.mjs +18 -31
  62. package/package.json +5 -5
  63. package/dist/builtin-tool-names.mjs +0 -19
@@ -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 relationId = (value) => {
8
- if (typeof value === "string" || typeof value === "number") return value;
9
- if (typeof value === "object" && value !== null && "id" in value) return value.id;
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
- enabled: true,
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 };
@@ -0,0 +1,2 @@
1
+ import "./types.mjs";
2
+ import "./options.mjs";
@@ -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
- * Covers draft and live writes; {@link isLiveWrite} separates them.
8
- */ const canWrite = (entity) => entity.write !== false;
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
- * The config lets MCP change live content and a draft exists to promote.
11
- */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
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
- * Any writable collection. An upload collection also needs a file, which
14
- * depends on the request, so the tools check it per call.
15
- */ const canCreate = (entity) => canWrite(entity);
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
- * Without drafts there is no draft stage, so every write is live.
18
- */ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
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 deleting = entity.delete && flag(group, "read") && flag(group, "delete");
33
- resolved[entity.slug] = {
34
- read: entity.read && flag(group, "read"),
35
- write: writeFlag(entity, group),
36
- publish: publishFlag(entity, group),
37
- delete: deleting,
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 { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite, resolveCapabilities, scopeSlugs };
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 { CONFIRMATIONS_PATH } from "../api-keys/confirmation-view.mjs";
3
- import { grantKvSlug } from "../grants/grant.mjs";
4
- import { confirmationHandle, decideConfirmations, issueConfirmation, listConfirmations } from "../grants/confirmation.mjs";
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: config.serverURL || new URL(req.url ?? "").origin
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 = grantKvSlug(req.payload.config);
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 = isPlainObject(key?.user) ? key.user["id"] : key?.user;
58
- if (!key || typeof user !== "string" && typeof user !== "number" || String(user) !== String(req.user.id)) return respond(403, { error: "Only the user of this key may decide on its calls." });
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 : confirmationHandle(payload, highlighted);
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
- if (!Array.isArray(handles) || handles.length > 50 || !handles.every((handle) => typeof handle === "string") || decision !== "approved" && decision !== "rejected") return respond(400, { error: `Send "handles", at most ${String(50)}, and a "decision" of "approved" or "rejected".` });
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 { grantKvSlug } from "../grants/grant.mjs";
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: options.auth?.resolve === void 0 && grantKvSlug(req.payload.config) !== void 0,
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 text = await readBody(req);
98
- if (text === null) return jsonRpcError({
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 = text === void 0 ? void 0 : JSON.parse(text);
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 };
@@ -1,66 +1,34 @@
1
1
  import { isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
- import { claimGrant, grantKvSlug } from "../grants/grant.mjs";
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 = grantKvSlug(req.payload.config);
26
+ const slug = grantsAvailable(req, options);
59
27
  const grantId = req.headers.get("x-mcpx-grant");
60
- if (slug === void 0 || options.auth?.resolve || grantId === null) return;
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 reauthenticate(req, options, grant);
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
- const file = claimed?.grant.args["file"];
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 readExactly(req, file.size);
92
- if (data === null) return respond(400, { error: `The body must be exactly ${String(file.size)} bytes, the size the tool call declared.` });
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 { isPlainObject } from "../guards.mjs";
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 entryPrefix = (apiKeyId) => `mcpx-confirmation:${String(apiKeyId)}:`;
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
- prefix: entryPrefix(call.apiKeyId),
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 (!GRANT_ID.test(id)) return;
37
- const key = `${entryPrefix(apiKeyId)}${confirmationHandle(payload, id)}`;
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. The marker is a unique-key insert, so
43
- * of two concurrent claims exactly one returns `true`.
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 = entryPrefix(apiKeyId);
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 = entryPrefix(apiKeyId);
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, confirmationHandle, decideConfirmations, issueConfirmation, listConfirmations, readConfirmation };
75
+ export { claimConfirmation, decideConfirmations, issueConfirmation, listConfirmations, readConfirmation };