@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.
- package/CHANGELOG.md +43 -0
- package/README.md +56 -36
- package/dist/admin/capability-matrix.d.mts +17 -7
- package/dist/admin/capability-matrix.mjs +374 -272
- package/dist/admin/capability-toggles.mjs +79 -42
- package/dist/admin/confirmations-panel.mjs +232 -0
- package/dist/admin/confirmations.d.mts +15 -0
- package/dist/admin/confirmations.mjs +71 -0
- package/dist/admin/endpoint-url.mjs +7 -0
- package/dist/admin/index.d.mts +2 -1
- package/dist/admin/index.mjs +2 -1
- package/dist/admin/setup-guide.d.mts +6 -4
- package/dist/admin/setup-guide.mjs +51 -33
- package/dist/api-keys/capability-matrix.d.mts +12 -10
- package/dist/api-keys/capability-matrix.mjs +17 -37
- package/dist/api-keys/collection.mjs +18 -19
- package/dist/api-keys/confirmation-view.mjs +7 -0
- package/dist/api-keys/fields.mjs +67 -53
- package/dist/auth/resolve.mjs +79 -30
- package/dist/capabilities.d.mts +2 -0
- package/dist/capabilities.mjs +69 -15
- package/dist/define-tool.d.mts +10 -6
- package/dist/define-tool.mjs +8 -1
- package/dist/endpoint/confirmations.mjs +124 -0
- package/dist/endpoint/handler.mjs +25 -27
- package/dist/endpoint/http.mjs +27 -0
- package/dist/endpoint/server.mjs +52 -27
- package/dist/endpoint/upload.mjs +104 -0
- package/dist/grants/confirmation.mjs +75 -0
- package/dist/grants/grant.mjs +137 -0
- package/dist/guards.mjs +11 -2
- package/dist/i18n.mjs +3 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/options.mjs +73 -62
- package/dist/plugin.d.mts +3 -2
- package/dist/plugin.mjs +19 -4
- 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 +21 -15
- package/dist/schema/outline.mjs +4 -5
- package/dist/schema/path.mjs +13 -4
- package/dist/schema/pointer.mjs +11 -17
- package/dist/schema/shape.mjs +39 -51
- package/dist/schema/walk.mjs +68 -21
- package/dist/tools/builtin.mjs +7 -1
- package/dist/tools/create-document.mjs +38 -26
- package/dist/tools/delete-document.mjs +154 -0
- package/dist/tools/describe-schema.mjs +7 -5
- package/dist/tools/document.mjs +147 -21
- package/dist/tools/duplicate-document.mjs +50 -0
- package/dist/tools/find-documents.mjs +15 -20
- package/dist/tools/find-versions.mjs +11 -15
- package/dist/tools/get-document.mjs +31 -15
- package/dist/tools/links.mjs +53 -0
- package/dist/tools/list-capabilities.mjs +17 -7
- package/dist/tools/patch-document.mjs +60 -54
- package/dist/tools/publish-document.mjs +23 -30
- package/dist/tools/query-paths.mjs +4 -2
- package/dist/tools/run-confirmed.mjs +114 -0
- package/dist/tools/shared.mjs +66 -50
- package/dist/tools/validate-document.mjs +10 -11
- package/dist/tools/versions.mjs +24 -10
- package/dist/types.d.mts +228 -60
- package/dist/upload/file.mjs +177 -0
- package/dist/version.mjs +4 -2
- package/dist/write/draft-guard.mjs +46 -37
- package/dist/write/patch.mjs +4 -4
- package/dist/write/publish-intent.mjs +25 -11
- package/dist/write/row-ids.mjs +6 -2
- package/dist/write/write-data.mjs +18 -31
- package/package.json +15 -14
- package/dist/builtin-tool-names.mjs +0 -16
|
@@ -1,47 +1,25 @@
|
|
|
1
|
-
import { CAPABILITIES_FIELD,
|
|
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
|
|
6
|
-
|
|
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
|
-
|
|
9
|
+
slug: entity.slug,
|
|
37
10
|
read: entity.read,
|
|
38
|
-
write:
|
|
39
|
-
publish:
|
|
40
|
-
|
|
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
|
|
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
|
-
/**
|
|
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,
|
|
38
|
+
export { CAPABILITIES_DESCRIPTION, capabilityPaths, cellPath, createCapabilityMatrix, rowOperations, toolPath };
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { createCapabilityFields, createKeyFields,
|
|
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:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 };
|
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
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
106
|
-
fields:
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
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,
|
|
161
|
+
export { SERVER_ONLY, createCapabilityFields, createConfirmationFields, createKeyFields, withKeyTabs };
|
package/dist/auth/resolve.mjs
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
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
|
|
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 };
|
package/dist/capabilities.mjs
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
|
|
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
|
-
*
|
|
12
|
-
|
|
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
|
-
|
|
26
|
-
read:
|
|
27
|
-
write:
|
|
28
|
-
publish:
|
|
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 {
|
|
108
|
+
export { ACCESS_CHAIN, CAPABILITIES_FIELD, CAPABILITY_OPERATIONS, STORED_OPERATIONS, requiresOf, resolveCapabilities, scopeSlugs };
|
package/dist/define-tool.d.mts
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
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
|
-
/**
|
|
9
|
-
|
|
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:
|
|
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
|
package/dist/define-tool.mjs
CHANGED
|
@@ -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 };
|