@abinnovision/payloadcms-mcpx 2.0.0-beta.8 → 2.0.0

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 (77) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +56 -36
  3. package/dist/admin/capability-matrix.d.mts +17 -7
  4. package/dist/admin/capability-matrix.mjs +374 -272
  5. package/dist/admin/capability-toggles.mjs +79 -42
  6. package/dist/admin/confirmations-panel.mjs +232 -0
  7. package/dist/admin/confirmations.d.mts +15 -0
  8. package/dist/admin/confirmations.mjs +71 -0
  9. package/dist/admin/endpoint-url.mjs +7 -0
  10. package/dist/admin/index.d.mts +2 -1
  11. package/dist/admin/index.mjs +2 -1
  12. package/dist/admin/setup-guide.d.mts +6 -4
  13. package/dist/admin/setup-guide.mjs +51 -33
  14. package/dist/api-keys/capability-matrix.d.mts +12 -10
  15. package/dist/api-keys/capability-matrix.mjs +17 -37
  16. package/dist/api-keys/collection.mjs +18 -19
  17. package/dist/api-keys/confirmation-view.mjs +7 -0
  18. package/dist/api-keys/fields.mjs +67 -53
  19. package/dist/auth/resolve.mjs +79 -30
  20. package/dist/capabilities.d.mts +2 -0
  21. package/dist/capabilities.mjs +69 -15
  22. package/dist/define-tool.d.mts +10 -6
  23. package/dist/define-tool.mjs +8 -1
  24. package/dist/endpoint/confirmations.mjs +124 -0
  25. package/dist/endpoint/handler.mjs +25 -27
  26. package/dist/endpoint/http.mjs +27 -0
  27. package/dist/endpoint/server.mjs +52 -27
  28. package/dist/endpoint/upload.mjs +104 -0
  29. package/dist/grants/confirmation.mjs +75 -0
  30. package/dist/grants/grant.mjs +137 -0
  31. package/dist/guards.mjs +11 -2
  32. package/dist/i18n.mjs +3 -1
  33. package/dist/index.d.mts +2 -2
  34. package/dist/index.mjs +1 -1
  35. package/dist/options.mjs +73 -62
  36. package/dist/plugin.d.mts +3 -2
  37. package/dist/plugin.mjs +19 -4
  38. package/dist/request.mjs +5 -1
  39. package/dist/result.mjs +11 -1
  40. package/dist/schema/admin-hidden.mjs +4 -4
  41. package/dist/schema/describe.mjs +12 -18
  42. package/dist/schema/index.mjs +5 -6
  43. package/dist/schema/lexical-pointer.mjs +8 -8
  44. package/dist/schema/lexical.mjs +21 -15
  45. package/dist/schema/outline.mjs +4 -5
  46. package/dist/schema/path.mjs +13 -4
  47. package/dist/schema/pointer.mjs +11 -17
  48. package/dist/schema/shape.mjs +39 -51
  49. package/dist/schema/walk.mjs +68 -21
  50. package/dist/tools/builtin.mjs +7 -1
  51. package/dist/tools/create-document.mjs +38 -26
  52. package/dist/tools/delete-document.mjs +154 -0
  53. package/dist/tools/describe-schema.mjs +7 -5
  54. package/dist/tools/document.mjs +147 -21
  55. package/dist/tools/duplicate-document.mjs +50 -0
  56. package/dist/tools/find-documents.mjs +15 -20
  57. package/dist/tools/find-versions.mjs +11 -15
  58. package/dist/tools/get-document.mjs +31 -15
  59. package/dist/tools/links.mjs +53 -0
  60. package/dist/tools/list-capabilities.mjs +17 -7
  61. package/dist/tools/patch-document.mjs +60 -54
  62. package/dist/tools/publish-document.mjs +23 -30
  63. package/dist/tools/query-paths.mjs +4 -2
  64. package/dist/tools/run-confirmed.mjs +114 -0
  65. package/dist/tools/shared.mjs +66 -50
  66. package/dist/tools/validate-document.mjs +10 -11
  67. package/dist/tools/versions.mjs +24 -10
  68. package/dist/types.d.mts +228 -60
  69. package/dist/upload/file.mjs +177 -0
  70. package/dist/version.mjs +4 -2
  71. package/dist/write/draft-guard.mjs +46 -37
  72. package/dist/write/patch.mjs +4 -4
  73. package/dist/write/publish-intent.mjs +25 -11
  74. package/dist/write/row-ids.mjs +6 -2
  75. package/dist/write/write-data.mjs +18 -31
  76. package/package.json +15 -14
  77. package/dist/builtin-tool-names.mjs +0 -16
@@ -1,47 +1,25 @@
1
- import { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite } from "../capabilities.mjs";
1
+ import { CAPABILITIES_FIELD, STORED_OPERATIONS } from "../capabilities.mjs";
2
2
  //#region src/api-keys/capability-matrix.ts
3
3
  /**
4
4
  * The group's `admin.description`, rendered by the matrix in Payload's
5
- * description slot. It explains both markings in the table: an unticked box
6
- * and a dash.
7
- */ const CAPABILITIES_DESCRIPTION = "What this key may do. An unticked box is a refusal, and a dash means the plugin config does not expose that operation at all.";
8
- /**
9
- * The operations a collection or global can expose. The generated checkboxes
10
- * and the matrix column headers share this wording.
11
- */ const CAPABILITY_OPERATIONS = [
12
- {
13
- id: "read",
14
- label: "Read",
15
- description: "Describe, find and read."
16
- },
17
- {
18
- id: "write",
19
- label: "Write",
20
- description: "Create, patch and validate drafts."
21
- },
22
- {
23
- id: "publish",
24
- label: "Publish",
25
- description: "Promote the current draft to what the public sees."
26
- }
27
- ];
28
- const UPLOAD_HINT = "Files are uploaded in the admin panel.";
29
- const LIVE_HINT = "Writes go live immediately.";
30
- const hintFor = (entity) => {
31
- const hints = [canWrite(entity) && !canCreate(entity) ? UPLOAD_HINT : void 0, isLiveWrite(entity) ? LIVE_HINT : void 0].filter((hint) => hint !== void 0);
32
- return hints.length > 0 ? { hint: hints.join(" ") } : {};
33
- };
5
+ * description slot. It explains how to read the controls.
6
+ */ const CAPABILITIES_DESCRIPTION = "What this key may do. Each level includes the ones before it, and a missing segment means the plugin config does not expose it. A shield means this key's user approves each call.";
34
7
  const toRow = (entity) => ({
35
8
  fieldName: entity.fieldName,
36
- label: entity.slug,
9
+ slug: entity.slug,
37
10
  read: entity.read,
38
- write: canWrite(entity),
39
- publish: canPublish(entity),
40
- ...hintFor(entity)
11
+ write: entity.write,
12
+ publish: entity.publish,
13
+ delete: entity.delete,
14
+ deleteUnattended: entity.deleteUnattended,
15
+ ...entity.liveWrite ? { live: true } : {}
41
16
  });
42
17
  /**
18
+ * The operations a row exposes, in stored order.
19
+ */ const rowOperations = (row) => STORED_OPERATIONS.filter((operation) => row[operation.id]);
20
+ /**
43
21
  * The single description of what the config exposes. `createCapabilityFields`
44
- * and the admin component both draw from it, so a cell cannot appear without a
22
+ * and the admin component both draw from it, so a control cannot appear without a
45
23
  * field behind it.
46
24
  */ const createCapabilityMatrix = (options) => ({
47
25
  collections: options.collections.map(toRow),
@@ -53,6 +31,8 @@ const toRow = (entity) => ({
53
31
  });
54
32
  const cellPath = (basePath, namespace, fieldName, operation) => `${basePath}.${namespace}.${fieldName}.${operation}`;
55
33
  const toolPath = (basePath, name) => `${basePath}.tools.${name}`;
56
- /** Every path the matrix can address, in a stable order. */ const capabilityPaths = (matrix, basePath = CAPABILITIES_FIELD) => [...["collections", "globals"].flatMap((namespace) => matrix[namespace].flatMap((row) => CAPABILITY_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)))), ...matrix.tools.map((tool) => toolPath(basePath, tool.name))];
34
+ /**
35
+ * Every path the matrix can address, in a stable order.
36
+ */ const capabilityPaths = (matrix, basePath = CAPABILITIES_FIELD) => [...["collections", "globals"].flatMap((namespace) => matrix[namespace].flatMap((row) => rowOperations(row).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)))), ...matrix.tools.map((tool) => toolPath(basePath, tool.name))];
57
37
  //#endregion
58
- export { CAPABILITIES_DESCRIPTION, CAPABILITY_OPERATIONS, capabilityPaths, cellPath, createCapabilityMatrix, toolPath };
38
+ export { CAPABILITIES_DESCRIPTION, capabilityPaths, cellPath, createCapabilityMatrix, rowOperations, toolPath };
@@ -1,11 +1,12 @@
1
- import { createCapabilityFields, createKeyFields, withSetupGuideTab } from "./fields.mjs";
1
+ import { SERVER_ONLY, createCapabilityFields, createConfirmationFields, createKeyFields, withKeyTabs } from "./fields.mjs";
2
2
  import { generateApiKey, hashApiKey } from "./key.mjs";
3
3
  //#region src/api-keys/collection.ts
4
4
  /**
5
5
  * Generates the key on create and keeps the lookup index in step with it.
6
6
  * Runs after field `beforeValidate` fallbacks, so on update `data.apiKey` is the
7
7
  * decrypted plaintext of the stored key.
8
- */ const keyBeforeChange = ({ data, operation, req }) => {
8
+ */ const keyBeforeChange = ({ context, data, operation, req }) => {
9
+ if (context["mcpxTouch"]) return data;
9
10
  if (operation === "create" && typeof data["apiKey"] !== "string") data["apiKey"] = generateApiKey();
10
11
  if (typeof data["apiKey"] === "string") data["apiKeyIndex"] = hashApiKey(req.payload.secret, data["apiKey"]);
11
12
  return data;
@@ -27,7 +28,12 @@ import { generateApiKey, hashApiKey } from "./key.mjs";
27
28
  admin: {
28
29
  group: "MCP",
29
30
  useAsTitle: "label",
30
- description: "Keys for MCP clients. Each key acts as its user and may only do what its capabilities allow."
31
+ description: "Keys for MCP clients. Each key acts as its user and may only do what its capabilities allow.",
32
+ ...options.setupGuide && { components: { edit: { beforeDocumentControls: [{
33
+ path: "@abinnovision/payloadcms-mcpx/admin",
34
+ exportName: "McpxSetupGuide",
35
+ clientProps: { endpointPath: options.endpointPath }
36
+ }] } } }
31
37
  },
32
38
  access: {
33
39
  create: isUser,
@@ -36,22 +42,15 @@ import { generateApiKey, hashApiKey } from "./key.mjs";
36
42
  delete: ownKeysOnly
37
43
  },
38
44
  hooks: { beforeChange: [keyBeforeChange] },
39
- fields: withSetupGuideTab([
40
- {
41
- name: "user",
42
- type: "relationship",
43
- relationTo: userCollection,
44
- required: true,
45
- access: {
46
- create: () => false,
47
- update: () => false
48
- },
49
- defaultValue: ({ req }) => isUser({ req }) ? req.user?.id : void 0,
50
- admin: { description: "The user this key acts as." }
51
- },
52
- ...createKeyFields(),
53
- ...createCapabilityFields(options)
54
- ], options)
45
+ fields: [...createConfirmationFields(options), ...withKeyTabs([{
46
+ name: "user",
47
+ type: "relationship",
48
+ relationTo: userCollection,
49
+ required: true,
50
+ access: SERVER_ONLY,
51
+ defaultValue: ({ req }) => isUser({ req }) ? req.user?.id : void 0,
52
+ admin: { description: "The user this key acts as." }
53
+ }, ...createKeyFields()], createCapabilityFields(options))]
55
54
  };
56
55
  };
57
56
  //#endregion
@@ -0,0 +1,7 @@
1
+ //#region src/api-keys/confirmation-view.ts
2
+ /**
3
+ * Path of the confirmations endpoints below the plugin's endpoint path.
4
+ */ const CONFIRMATIONS_PATH = "/confirmations";
5
+ const DECISIONS = ["approved", "rejected"];
6
+ //#endregion
7
+ export { CONFIRMATIONS_PATH, DECISIONS };
@@ -1,5 +1,5 @@
1
1
  import { CAPABILITIES_FIELD } from "../capabilities.mjs";
2
- import { CAPABILITIES_DESCRIPTION, CAPABILITY_OPERATIONS, createCapabilityMatrix } from "./capability-matrix.mjs";
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
- const SETUP_GUIDE_FIELD = "setupGuide";
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
@@ -34,13 +51,28 @@ const SETUP_GUIDE_FIELD = "setupGuide";
34
51
  defaultValue: true,
35
52
  admin: { description: "Disabled keys are refused without revoking them." }
36
53
  },
54
+ {
55
+ name: "expiresAt",
56
+ type: "date",
57
+ admin: {
58
+ date: { pickerAppearance: "dayAndTime" },
59
+ description: "Refused from this time on. Leave empty for no expiry."
60
+ }
61
+ },
62
+ {
63
+ name: "lastUsedAt",
64
+ type: "date",
65
+ access: SERVER_ONLY,
66
+ admin: {
67
+ readOnly: true,
68
+ date: { pickerAppearance: "dayAndTime" },
69
+ description: "Updated at most once an hour."
70
+ }
71
+ },
37
72
  {
38
73
  name: "apiKey",
39
74
  type: "text",
40
- access: {
41
- create: () => false,
42
- update: () => false
43
- },
75
+ access: SERVER_ONLY,
44
76
  admin: {
45
77
  readOnly: true,
46
78
  description: "Generated when the key is created. Send it as `Authorization: Bearer <key>`."
@@ -58,34 +90,27 @@ const SETUP_GUIDE_FIELD = "setupGuide";
58
90
  }
59
91
  ];
60
92
  /**
61
- * Wraps the key fields and the setup guide in unnamed tabs, so wide snippets
93
+ * The pending calls of the key, above everything else on its edit view, where
94
+ * a collection exposes `delete`. The component renders nothing while none is
95
+ * pending and never touches the form, so deciding does not mark the key
96
+ * modified.
97
+ */ const createConfirmationFields = (options) => options.confirmations ? [adminField("confirmations", "McpxConfirmations", options)] : [];
98
+ /**
99
+ * Wraps the key fields and the capabilities in unnamed tabs, so wide tables
62
100
  * get the full form width. Named tabs would nest the data and move
63
101
  * `capabilities` off the document root, which capability resolution reads.
64
- *
65
- * The guide tab only shows on update: on create there is no key to hand out.
66
- */ const withSetupGuideTab = (keyFields, options) => {
67
- if (!options.setupGuide) return keyFields;
102
+ */ const withKeyTabs = (keyFields, capabilityFields) => {
103
+ const tabs = [{
104
+ label: "Key",
105
+ fields: keyFields
106
+ }];
107
+ if (capabilityFields.length > 0) tabs.push({
108
+ label: "Capabilities",
109
+ fields: capabilityFields
110
+ });
68
111
  return [{
69
112
  type: "tabs",
70
- tabs: [{
71
- label: "Key",
72
- fields: keyFields
73
- }, {
74
- label: "Connect a client",
75
- admin: { condition: (_data, _siblingData, { operation }) => operation === "update" },
76
- fields: [{
77
- name: SETUP_GUIDE_FIELD,
78
- type: "ui",
79
- admin: {
80
- disableListColumn: true,
81
- components: { Field: {
82
- path: "@abinnovision/payloadcms-mcpx/admin",
83
- exportName: "McpxSetupGuide",
84
- clientProps: { endpointPath: options.endpointPath }
85
- } }
86
- }
87
- }]
88
- }]
113
+ tabs
89
114
  }];
90
115
  };
91
116
  /**
@@ -102,29 +127,18 @@ const SETUP_GUIDE_FIELD = "setupGuide";
102
127
  const entityGroups = (rows) => rows.map((row) => ({
103
128
  name: row.fieldName,
104
129
  type: "group",
105
- label: row.label,
106
- fields: CAPABILITY_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => checkbox(operation.id, operation.description))
130
+ label: row.slug,
131
+ fields: rowOperations(row).map((operation) => checkbox(operation.id, operation.description))
107
132
  }));
108
- const collectionGroups = entityGroups(matrix.collections);
109
- const globalGroups = entityGroups(matrix.globals);
110
- const toolCheckboxes = matrix.tools.map((tool) => checkbox(tool.name, tool.description));
111
133
  const groups = [
112
- ...collectionGroups.length > 0 ? [{
113
- name: "collections",
114
- type: "group",
115
- fields: collectionGroups
116
- }] : [],
117
- ...globalGroups.length > 0 ? [{
118
- name: "globals",
119
- type: "group",
120
- fields: globalGroups
121
- }] : [],
122
- ...toolCheckboxes.length > 0 ? [{
123
- name: "tools",
124
- type: "group",
125
- fields: toolCheckboxes
126
- }] : []
127
- ];
134
+ ["collections", entityGroups(matrix.collections)],
135
+ ["globals", entityGroups(matrix.globals)],
136
+ ["tools", matrix.tools.map((tool) => checkbox(tool.name, tool.description))]
137
+ ].filter(([, fields]) => fields.length > 0).map(([name, fields]) => ({
138
+ name,
139
+ type: "group",
140
+ fields
141
+ }));
128
142
  if (groups.length === 0) return [];
129
143
  return [{
130
144
  name: CAPABILITIES_FIELD,
@@ -136,7 +150,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
136
150
  exportName: "McpxCapabilityMatrix",
137
151
  clientProps: {
138
152
  matrix,
139
- withinTab: options.setupGuide
153
+ withinTab: true
140
154
  }
141
155
  } }
142
156
  },
@@ -144,4 +158,4 @@ const SETUP_GUIDE_FIELD = "setupGuide";
144
158
  }];
145
159
  };
146
160
  //#endregion
147
- export { createCapabilityFields, createKeyFields, withSetupGuideTab };
161
+ export { SERVER_ONLY, createCapabilityFields, createConfirmationFields, createKeyFields, withKeyTabs };
@@ -2,16 +2,20 @@ import { isPlainObject } from "../guards.mjs";
2
2
  import { hashApiKey } from "../api-keys/key.mjs";
3
3
  //#region src/auth/resolve.ts
4
4
  const BEARER = /^Bearer\s+(\S+)\s*$/i;
5
- const relationId = (value) => {
6
- if (typeof value === "string" || typeof value === "number") return value;
7
- if (typeof value === "object" && value !== null && "id" in value) return value.id;
5
+ const LAST_USED_INTERVAL_MS = 36e5;
6
+ const toTime = (value) => typeof value === "string" || value instanceof Date ? new Date(value).getTime() : NaN;
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;
8
13
  };
9
14
  const parseBearer = (headers) => {
10
15
  const header = headers.get("authorization");
11
16
  if (!header) return null;
12
17
  return BEARER.exec(header.trim())?.[1] ?? null;
13
18
  };
14
- const isId = (value) => typeof value === "string" || typeof value === "number";
15
19
  /**
16
20
  * Whether a resolved auth, from the default or a custom resolver, may become
17
21
  * `req.user`: a user of the configured user collection with an id, and a key id.
@@ -20,31 +24,14 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
20
24
  return isId(auth["user"]["id"]) && auth["user"]["collection"] === options.userCollection && isId(auth["apiKeyId"]);
21
25
  };
22
26
  /**
23
- * Resolves the bearer key of a request to the user it acts as.
24
- *
25
- * The key is looked up by its HMAC index, the same way Payload resolves its own
26
- * API keys. A missing, unknown, disabled or orphaned key yields `null`; nothing
27
- * here throws, so the handler alone decides how a refusal looks.
28
- */ const resolveApiKeyAuth = async (req, options) => {
29
- const key = parseBearer(req.headers);
30
- if (key === null) return null;
31
- const { payload } = req;
32
- const { docs } = await payload.find({
33
- collection: options.apiKeysSlug,
34
- where: { apiKeyIndex: { equals: hashApiKey(payload.secret, key) } },
35
- limit: 1,
36
- pagination: false,
37
- depth: 0,
38
- overrideAccess: true,
39
- select: {
40
- enabled: true,
41
- user: true,
42
- capabilities: true
43
- }
44
- });
45
- const keyDoc = docs[0];
27
+ * The checks a key passes once it is looked up: enabled, not expired, and its
28
+ * user exists, is verified and is not locked out. Yields the user it acts as,
29
+ * or `null`.
30
+ */ const checkApiKey = async (req, options, keyDoc) => {
46
31
  const userId = relationId(keyDoc?.user);
47
32
  if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
33
+ if (toTime(keyDoc.expiresAt) <= Date.now()) return null;
34
+ const { payload } = req;
48
35
  const userCollection = payload.collections[options.userCollection];
49
36
  const lookup = {
50
37
  collection: options.userCollection,
@@ -61,8 +48,7 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
61
48
  showHiddenFields: true,
62
49
  select: { lockUntil: true }
63
50
  })]);
64
- const storedLock = lock?.["lockUntil"];
65
- const lockUntil = typeof storedLock === "string" || storedLock instanceof Date ? new Date(storedLock).getTime() : NaN;
51
+ const lockUntil = toTime(lock?.["lockUntil"]);
66
52
  if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
67
53
  return {
68
54
  user: {
@@ -74,5 +60,68 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
74
60
  capabilities: keyDoc.capabilities
75
61
  };
76
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
+ };
83
+ /**
84
+ * Resolves the bearer key of a request to the user it acts as.
85
+ *
86
+ * The key is looked up by its HMAC index, the same way Payload resolves its own
87
+ * API keys. A missing, unknown, disabled or orphaned key yields `null`; nothing
88
+ * here throws, so the handler alone decides how a refusal looks.
89
+ */ const resolveApiKeyAuth = async (req, options) => {
90
+ const key = parseBearer(req.headers);
91
+ if (key === null) return null;
92
+ const { payload } = req;
93
+ const { docs } = await payload.find({
94
+ collection: options.apiKeysSlug,
95
+ where: { apiKeyIndex: { equals: hashApiKey(payload.secret, key) } },
96
+ limit: 1,
97
+ pagination: false,
98
+ depth: 0,
99
+ overrideAccess: true,
100
+ select: {
101
+ ...KEY_SELECT,
102
+ lastUsedAt: true
103
+ }
104
+ });
105
+ const keyDoc = docs[0];
106
+ const auth = await checkApiKey(req, options, keyDoc);
107
+ if (!auth || !keyDoc) return null;
108
+ const lastUsed = toTime(keyDoc.lastUsedAt);
109
+ if (Number.isNaN(lastUsed) || Date.now() - lastUsed > LAST_USED_INTERVAL_MS) try {
110
+ await payload.update({
111
+ collection: options.apiKeysSlug,
112
+ id: keyDoc.id,
113
+ data: { lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() },
114
+ overrideAccess: true,
115
+ depth: 0,
116
+ context: { mcpxTouch: true }
117
+ });
118
+ } catch (error) {
119
+ payload.logger.warn({
120
+ err: error,
121
+ msg: "[payloadcms-mcpx] Could not record the last use of an MCP API key."
122
+ });
123
+ }
124
+ return auth;
125
+ };
77
126
  //#endregion
78
- export { isValidAuthResult, parseBearer, resolveApiKeyAuth };
127
+ export { checkApiKey, isValidAuthResult, parseBearer, relationId, resolveApiKeyAuth, resolveApiKeyById };
@@ -0,0 +1,2 @@
1
+ import "./types.mjs";
2
+ import "./options.mjs";
@@ -1,17 +1,63 @@
1
1
  import { isPlainObject } from "./guards.mjs";
2
2
  //#region src/capabilities.ts
3
- /** Group field holding the capability checkboxes on an API key document. */ const CAPABILITIES_FIELD = "capabilities";
4
- /** Covers draft and live writes; {@link isLiveWrite} separates them. */ const canWrite = (entity) => entity.write !== false;
5
- /** The config lets MCP change live content and a draft exists to promote. */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
6
3
  /**
7
- * An upload document is a file, and no tool here carries one. Its own fields
8
- * stay patchable; the first version is made in the admin panel.
9
- */ const canCreate = (entity) => canWrite(entity) && !entity.isUpload;
4
+ * Group field holding the capability checkboxes on an API key document.
5
+ */ const CAPABILITIES_FIELD = "capabilities";
10
6
  /**
11
- * Without drafts there is no draft stage, so every write is live.
12
- */ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
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
+ ];
35
+ /**
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];
49
+ /**
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");
53
+ /**
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
+ };
13
60
  const flag = (group, name) => isPlainObject(group) && group[name] === true;
14
- const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write") && flag(group, "publish");
15
61
  /**
16
62
  * Capabilities in force for a key: the plugin config decides what can exist,
17
63
  * the key's checkboxes decide what does. A missing checkbox is `false`, so keys
@@ -22,11 +68,18 @@ const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write"
22
68
  const resolved = {};
23
69
  for (const entity of entities) {
24
70
  const group = isPlainObject(namespaceGroup) ? namespaceGroup[entity.fieldName] : void 0;
25
- resolved[entity.slug] = {
26
- read: entity.read && flag(group, "read"),
27
- write: canWrite(entity) && flag(group, "write"),
28
- publish: publishFlag(entity, group)
71
+ const granted = {
72
+ read: false,
73
+ write: false,
74
+ publish: false,
75
+ delete: false,
76
+ deleteUnattended: false
29
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;
30
83
  }
31
84
  return resolved;
32
85
  };
@@ -47,8 +100,9 @@ const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write"
47
100
  return {
48
101
  readable: slugsWith("read"),
49
102
  writable: slugsWith("write"),
50
- publishable: slugsWith("publish")
103
+ publishable: slugsWith("publish"),
104
+ deletable: slugsWith("delete")
51
105
  };
52
106
  };
53
107
  //#endregion
54
- 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,23 @@
1
- import { McpxAnyTool, McpxTool, McpxToolScope } from "./types.mjs";
1
+ import { McpxAnyTool, McpxTool, McpxToolConfirm, McpxToolDefinition, McpxToolScope } from "./types.mjs";
2
2
  import { z } from "zod";
3
3
  //#region src/define-tool.d.ts
4
- /** Fixed shape; arguments inferred from it. */
5
- export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
4
+ /**
5
+ * Fixed shape; arguments inferred from it.
6
+ */
7
+ export declare function defineMcpxTool<Shape extends z.ZodRawShape, Confirm extends McpxToolConfirm | undefined = undefined>(tool: McpxToolDefinition<Shape, z.infer<z.ZodObject<Shape>>, Confirm> & {
6
8
  inputSchema?: Shape;
7
9
  }): McpxTool<Shape>;
8
- /** Per-request shape returned as an object literal; arguments inferred from it. */
9
- export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
10
+ /**
11
+ * Per-request shape returned as an object literal; arguments inferred from it.
12
+ */
13
+ export declare function defineMcpxTool<Shape extends z.ZodRawShape, Confirm extends McpxToolConfirm | undefined = undefined>(tool: McpxToolDefinition<Shape, z.infer<z.ZodObject<Shape>>, Confirm> & {
10
14
  inputSchema: (scope: McpxToolScope) => Shape;
11
15
  }): McpxAnyTool;
12
16
  /**
13
17
  * Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
14
18
  * builtins do. Nothing to infer from, so state the arguments instead.
15
19
  */
16
- export declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
20
+ export declare function defineMcpxTool<Args, Confirm extends McpxToolConfirm | undefined = undefined>(tool: McpxToolDefinition<z.ZodRawShape, Args, Confirm> & {
17
21
  inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
18
22
  }): McpxAnyTool;
19
23
  //#endregion
@@ -1,6 +1,13 @@
1
+ import { z } from "zod";
1
2
  //#region src/define-tool.ts
2
3
  function defineMcpxTool(tool) {
3
4
  return tool;
4
5
  }
6
+ /**
7
+ * Strict, so an unknown argument is rejected by name instead of stripped.
8
+ */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
9
+ /**
10
+ * A tool that does not decide for itself is gated by its own checkbox.
11
+ */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
5
12
  //#endregion
6
- export { defineMcpxTool };
13
+ export { defineMcpxTool, isToolEnabled, toolInputSchema };