@abinnovision/payloadcms-mcpx 2.0.0-beta.14 → 2.0.0-beta.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -39
- package/dist/admin/capability-matrix.d.mts +4 -3
- package/dist/admin/capability-matrix.mjs +262 -111
- package/dist/admin/capability-toggles.mjs +83 -59
- package/dist/api-keys/capability-matrix.d.mts +3 -4
- package/dist/api-keys/capability-matrix.mjs +27 -18
- package/dist/api-keys/fields.mjs +2 -2
- package/dist/capabilities.mjs +3 -2
- package/dist/options.mjs +5 -2
- package/dist/tools/create-document.mjs +7 -1
- package/dist/tools/links.mjs +52 -0
- package/dist/tools/list-capabilities.mjs +2 -2
- package/dist/tools/patch-document.mjs +7 -1
- package/dist/tools/publish-document.mjs +7 -1
- package/dist/tools/validate-document.mjs +2 -2
- package/dist/types.d.mts +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,25 +86,25 @@ Claude Desktop and the MCP Inspector are covered in
|
|
|
86
86
|
|
|
87
87
|
## Options
|
|
88
88
|
|
|
89
|
-
| Option | Default | Description
|
|
90
|
-
| ---------------------------- | ----------------------------------------- |
|
|
91
|
-
| `collections` | required | Collections to expose. `true` means `{}`, everything it supports.
|
|
92
|
-
| `collections.<slug>.read` | `true` | Expose the read tools for this collection.
|
|
93
|
-
| `collections.<slug>.write` | `true` | Expose the write tools. Without drafts, a write goes live. |
|
|
94
|
-
| `collections.<slug>.publish` | `true` with drafts | Expose `publishDocument`. Needs `versions.drafts` and `write`.
|
|
95
|
-
| `collections.<slug>.delete` | `false` | Expose `deleteDocument`. Each delete is approved in the admin. `"unattended"` also offers trashing without approval; needs `trash`. |
|
|
96
|
-
| `globals` | `{}` | Globals to expose, with `read`, `write` and `publish`.
|
|
97
|
-
| `userCollection` | `config.admin.user`, then `users` | Auth collection whose users the keys act as.
|
|
98
|
-
| `apiKeys.slug` | `mcpx-api-keys` | Slug of the key collection.
|
|
99
|
-
| `apiKeys.setupGuide` | `true` | Add the "Connect a client" tab to saved keys.
|
|
100
|
-
| `apiKeys.overrideCollection` | none | Function that receives the key collection and returns it.
|
|
101
|
-
| `endpoint.path` | `/mcpx` | Endpoint path below the API route.
|
|
102
|
-
| `limits.maxLimit` | `25` | Highest `limit` a client may pass to a list tool.
|
|
103
|
-
| `limits.maxDepth` | `1` | Highest `depth` a client may pass to a read tool.
|
|
104
|
-
| `tools` | `[]` | Custom tools, see [`docs/custom-tools.md`](./docs/custom-tools.md).
|
|
105
|
-
| `auth.resolve` | none | Replace or wrap the key lookup.
|
|
106
|
-
| `serverInfo` | `payloadcms-mcpx` and the package version | `{ name, version }` reported to clients.
|
|
107
|
-
| `diagnostics` | `true` | Expose the server version plus repository and issue links.
|
|
89
|
+
| Option | Default | Description |
|
|
90
|
+
| ---------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
91
|
+
| `collections` | required | Collections to expose. `true` means `{}`, everything it supports. |
|
|
92
|
+
| `collections.<slug>.read` | `true` | Expose the read tools for this collection. |
|
|
93
|
+
| `collections.<slug>.write` | `true` | Expose the write tools. Needs `read`. Without drafts, a write goes live. |
|
|
94
|
+
| `collections.<slug>.publish` | `true` with drafts | Expose `publishDocument`. Needs `versions.drafts` and `write`. |
|
|
95
|
+
| `collections.<slug>.delete` | `false` | Expose `deleteDocument`. Needs `read`. Each delete is approved in the admin. `"unattended"` also offers trashing without approval; needs `trash`. |
|
|
96
|
+
| `globals` | `{}` | Globals to expose, with `read`, `write` and `publish`. |
|
|
97
|
+
| `userCollection` | `config.admin.user`, then `users` | Auth collection whose users the keys act as. |
|
|
98
|
+
| `apiKeys.slug` | `mcpx-api-keys` | Slug of the key collection. |
|
|
99
|
+
| `apiKeys.setupGuide` | `true` | Add the "Connect a client" tab to saved keys. |
|
|
100
|
+
| `apiKeys.overrideCollection` | none | Function that receives the key collection and returns it. |
|
|
101
|
+
| `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
|
|
102
|
+
| `limits.maxLimit` | `25` | Highest `limit` a client may pass to a list tool. |
|
|
103
|
+
| `limits.maxDepth` | `1` | Highest `depth` a client may pass to a read tool. |
|
|
104
|
+
| `tools` | `[]` | Custom tools, see [`docs/custom-tools.md`](./docs/custom-tools.md). |
|
|
105
|
+
| `auth.resolve` | none | Replace or wrap the key lookup. |
|
|
106
|
+
| `serverInfo` | `payloadcms-mcpx` and the package version | `{ name, version }` reported to clients. |
|
|
107
|
+
| `diagnostics` | `true` | Expose the server version plus repository and issue links. |
|
|
108
108
|
|
|
109
109
|
The config only takes capabilities away: `true` exposes everything the entity supports, and a key's
|
|
110
110
|
checkboxes decide what each key may do. `publish: false` keeps MCP writes as drafts. On an entity
|
|
@@ -116,19 +116,19 @@ every check.
|
|
|
116
116
|
|
|
117
117
|
## Tools
|
|
118
118
|
|
|
119
|
-
| Tool | What it does | Needs
|
|
120
|
-
| ------------------ | ------------------------------------------------------------------ |
|
|
121
|
-
| `listCapabilities` | Lists what this key may do. Clients call it first. | any key
|
|
122
|
-
| `describeSchema` | Describes the fields at one schema path, with the paths below it. | `read`
|
|
123
|
-
| `findDocuments` | Queries a collection with a Payload `where`, `sort` and `select`. | `read`
|
|
124
|
-
| `getDocument` | Reads a document, a subtree of it, an old version or a diff. | `read`
|
|
125
|
-
| `findVersions` | Lists the version history of a document or global, without bodies. | `read`
|
|
126
|
-
| `patchDocument` | Applies JSON Patch operations to the current draft, or a new file. | `write`
|
|
127
|
-
| `createDocument` | Creates a draft from a seed, with a file in an upload collection. | `write`
|
|
128
|
-
| `validateDocument` | Lists what blocks publishing, without saving. | `write`
|
|
129
|
-
| `publishDocument` | Publishes the current draft. | `
|
|
130
|
-
| `deleteDocument` | Asks to delete a document, which the key's user approves. | `delete`
|
|
131
|
-
| `runConfirmed` | Runs the approved calls. | `delete`
|
|
119
|
+
| Tool | What it does | Needs |
|
|
120
|
+
| ------------------ | ------------------------------------------------------------------ | --------- |
|
|
121
|
+
| `listCapabilities` | Lists what this key may do. Clients call it first. | any key |
|
|
122
|
+
| `describeSchema` | Describes the fields at one schema path, with the paths below it. | `read` |
|
|
123
|
+
| `findDocuments` | Queries a collection with a Payload `where`, `sort` and `select`. | `read` |
|
|
124
|
+
| `getDocument` | Reads a document, a subtree of it, an old version or a diff. | `read` |
|
|
125
|
+
| `findVersions` | Lists the version history of a document or global, without bodies. | `read` |
|
|
126
|
+
| `patchDocument` | Applies JSON Patch operations to the current draft, or a new file. | `write` |
|
|
127
|
+
| `createDocument` | Creates a draft from a seed, with a file in an upload collection. | `write` |
|
|
128
|
+
| `validateDocument` | Lists what blocks publishing, without saving. | `write` |
|
|
129
|
+
| `publishDocument` | Publishes the current draft. | `publish` |
|
|
130
|
+
| `deleteDocument` | Asks to delete a document, which the key's user approves. | `delete` |
|
|
131
|
+
| `runConfirmed` | Runs the approved calls. | `delete` |
|
|
132
132
|
|
|
133
133
|
Collections and globals use the same tools. A tool that addresses one document takes either
|
|
134
134
|
`collection` and `id`, or `global` alone. [`docs/concepts.md`](./docs/concepts.md#tools) lists
|
|
@@ -142,15 +142,18 @@ The plugin config sets the upper bound: which collections and globals are expose
|
|
|
142
142
|
each one is readable, writable and publishable. A key cannot go past it.
|
|
143
143
|
|
|
144
144
|
Each key carries one checkbox per exposed entity and operation (`read`, `write`, `publish`,
|
|
145
|
-
`delete`), and
|
|
146
|
-
one per custom tool. The admin panel shows them as a matrix with a row per entity
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
145
|
+
`delete`, `deleteUnattended`), and
|
|
146
|
+
one per custom tool. The admin panel shows them as a matrix with a row per entity, an Access
|
|
147
|
+
control (None, Read, Write, Publish) and a Delete control (Off, Approval, Trash). Each access level
|
|
148
|
+
includes the ones before it, and a level the config does not expose has no segment. `publish`
|
|
149
|
+
exists only where the config exposes `write` and `publish` on an entity with drafts, and counts
|
|
150
|
+
only when `write` is ticked as well. `write` counts only when `read` is ticked, so a key never
|
|
151
|
+
writes what it cannot read. An entity without drafts carries a "Live" badge, as its writes go live
|
|
152
|
+
immediately.
|
|
150
153
|
`delete` exists only where the config sets `delete: true` on a collection, and counts only when
|
|
151
|
-
`read` is ticked as well.
|
|
154
|
+
`read` is ticked as well. Setting access to None clears it. A delete runs only after the key's user
|
|
152
155
|
approved it on the key's edit view, except where the config sets `delete: "unattended"` and the
|
|
153
|
-
key
|
|
156
|
+
key's Delete control is set to Trash: there the document moves to trash at once. See
|
|
154
157
|
[deleting](./docs/concepts.md#deleting).
|
|
155
158
|
|
|
156
159
|
`tools/list` follows the key. A tool the key cannot use is not listed, and each `collection` and
|
|
@@ -25,9 +25,10 @@ interface McpxCapabilityMatrixProps {
|
|
|
25
25
|
withinTab?: boolean;
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* What an API key may do, as one table per namespace: a row per entity
|
|
29
|
-
*
|
|
30
|
-
* checkbox fields back every
|
|
28
|
+
* What an API key may do, as one table per namespace: a row per entity with
|
|
29
|
+
* segmented Access and Delete controls, and an "All" row that sets every row.
|
|
30
|
+
* The nested checkbox fields back every control, so the stored document is
|
|
31
|
+
* unaffected.
|
|
31
32
|
*/
|
|
32
33
|
export declare const McpxCapabilityMatrix: React.FC<McpxCapabilityMatrixProps>;
|
|
33
34
|
//#endregion
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { CAPABILITIES_FIELD } from "../capabilities.mjs";
|
|
3
|
-
import { CAPABILITY_OPERATIONS,
|
|
4
|
-
import {
|
|
3
|
+
import { CAPABILITY_OPERATIONS, STORED_OPERATIONS, capabilityPaths, toolPath } from "../api-keys/capability-matrix.mjs";
|
|
4
|
+
import { accessLevelOf, accessLevelsOf, allAccessLevel, allDeleteMode, buildToggleActions, deleteModeOf, toolsState } from "./capability-toggles.mjs";
|
|
5
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
-
import { CheckboxInput, FieldDescription, FieldLabel, useForm, useFormFields } from "@payloadcms/ui";
|
|
6
|
+
import { Button, CheckboxInput, FieldDescription, FieldLabel, Pill, useForm, useFormFields } from "@payloadcms/ui";
|
|
7
7
|
import { useCallback, useMemo } from "react";
|
|
8
8
|
//#region src/admin/capability-matrix.tsx
|
|
9
|
-
const NOT_EXPOSED = "The plugin config does not expose this.";
|
|
10
9
|
const BASE_CLASS = "mcpx-capabilities";
|
|
10
|
+
const LIVE_WRITE = "Writes go live immediately.";
|
|
11
11
|
const SHEET = `
|
|
12
12
|
.${BASE_CLASS} tbody tr:hover { background: var(--theme-elevation-50); }
|
|
13
|
-
.${BASE_CLASS}__toggle:hover { text-decoration: underline; }
|
|
14
13
|
.${BASE_CLASS} section:last-of-type { margin-bottom: 0; }
|
|
14
|
+
.${BASE_CLASS}__segments {
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
border: 1px solid var(--theme-elevation-150);
|
|
17
|
+
border-radius: var(--style-radius-s);
|
|
18
|
+
background: var(--theme-input-bg);
|
|
19
|
+
}
|
|
20
|
+
.${BASE_CLASS}__segments .btn { border: 0; border-radius: 0; white-space: nowrap; }
|
|
21
|
+
.${BASE_CLASS}__segments .btn + .btn { border-inline-start: 1px solid var(--theme-elevation-150); }
|
|
22
|
+
.${BASE_CLASS}__segments .btn:first-child { border-start-start-radius: inherit; border-end-start-radius: inherit; }
|
|
23
|
+
.${BASE_CLASS}__segments .btn:last-child { border-start-end-radius: inherit; border-end-end-radius: inherit; }
|
|
24
|
+
.${BASE_CLASS}__segments .btn--style-transparent { --hover-bg: var(--theme-elevation-100); }
|
|
25
|
+
.${BASE_CLASS}__segments .${BASE_CLASS}__danger { --bg-color: var(--theme-error-500); --hover-bg: var(--theme-error-600); }
|
|
15
26
|
`;
|
|
16
27
|
const CELL_PADDING = "calc(var(--base) * 0.6)";
|
|
17
28
|
const EDGE_PADDING = "calc(var(--base) * 0.8)";
|
|
@@ -57,30 +68,35 @@ const styles = {
|
|
|
57
68
|
fontWeight: "normal",
|
|
58
69
|
wordBreak: "break-word"
|
|
59
70
|
},
|
|
60
|
-
rowButton: {
|
|
61
|
-
padding: 0,
|
|
62
|
-
border: 0,
|
|
63
|
-
background: "none",
|
|
64
|
-
color: "inherit",
|
|
65
|
-
font: "inherit",
|
|
66
|
-
cursor: "pointer",
|
|
67
|
-
textAlign: "start",
|
|
68
|
-
textDecoration: "none"
|
|
69
|
-
},
|
|
70
71
|
hint: {
|
|
71
72
|
display: "block",
|
|
72
73
|
color: "var(--theme-elevation-400)"
|
|
73
74
|
},
|
|
75
|
+
badge: {
|
|
76
|
+
display: "inline-flex",
|
|
77
|
+
marginInlineStart: "calc(var(--base) * 0.4)",
|
|
78
|
+
verticalAlign: "middle",
|
|
79
|
+
cursor: "help"
|
|
80
|
+
},
|
|
81
|
+
visuallyHidden: {
|
|
82
|
+
position: "absolute",
|
|
83
|
+
width: "1px",
|
|
84
|
+
height: "1px",
|
|
85
|
+
overflow: "hidden",
|
|
86
|
+
clip: "rect(0 0 0 0)",
|
|
87
|
+
whiteSpace: "nowrap"
|
|
88
|
+
},
|
|
74
89
|
cell: {
|
|
75
90
|
padding: CELL_PADDING,
|
|
76
91
|
verticalAlign: "middle",
|
|
77
92
|
textAlign: "center"
|
|
78
93
|
},
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
94
|
+
controlCell: {
|
|
95
|
+
padding: CELL_PADDING,
|
|
96
|
+
verticalAlign: "middle",
|
|
97
|
+
textAlign: "start"
|
|
98
|
+
},
|
|
99
|
+
lastCell: { paddingInlineEnd: EDGE_PADDING }
|
|
84
100
|
};
|
|
85
101
|
const Box = ({ label, onChange, readOnly, state }) => /*#__PURE__*/ jsx(CheckboxInput, {
|
|
86
102
|
checked: state === "on",
|
|
@@ -89,116 +105,250 @@ const Box = ({ label, onChange, readOnly, state }) => /*#__PURE__*/ jsx(Checkbox
|
|
|
89
105
|
partialChecked: state === "mixed",
|
|
90
106
|
readOnly
|
|
91
107
|
});
|
|
108
|
+
const descriptionOf = (operation) => STORED_OPERATIONS.find((candidate) => candidate.id === operation)?.description;
|
|
109
|
+
const ACCESS_OPTIONS = [{
|
|
110
|
+
id: "none",
|
|
111
|
+
label: "None",
|
|
112
|
+
title: "No access"
|
|
113
|
+
}, ...CAPABILITY_OPERATIONS.filter((operation) => operation.id !== "delete").map((operation) => ({
|
|
114
|
+
id: operation.id,
|
|
115
|
+
label: operation.label,
|
|
116
|
+
title: operation.description
|
|
117
|
+
}))];
|
|
118
|
+
const DELETE_OPTIONS = [
|
|
119
|
+
{
|
|
120
|
+
id: "off",
|
|
121
|
+
label: "Off",
|
|
122
|
+
title: "No delete"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "approve",
|
|
126
|
+
label: "Approval",
|
|
127
|
+
title: descriptionOf("delete")
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "trash",
|
|
131
|
+
label: "Trash",
|
|
132
|
+
title: descriptionOf("deleteUnattended")
|
|
133
|
+
}
|
|
134
|
+
];
|
|
135
|
+
const ARROW_STEPS = {
|
|
136
|
+
ArrowDown: 1,
|
|
137
|
+
ArrowLeft: -1,
|
|
138
|
+
ArrowRight: 1,
|
|
139
|
+
ArrowUp: -1
|
|
140
|
+
};
|
|
141
|
+
const Segments = ({ included = [], label, onSelect, options, readOnly, selected }) => {
|
|
142
|
+
const focusable = options.some((option) => option.id === selected) ? selected : options[0]?.id;
|
|
143
|
+
const onKeyDown = (event) => {
|
|
144
|
+
const step = ARROW_STEPS[event.key];
|
|
145
|
+
if (readOnly || step === void 0) return;
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
const radios = [...event.currentTarget.querySelectorAll("[role=\"radio\"]")];
|
|
148
|
+
const current = radios.findIndex((radio) => radio === document.activeElement);
|
|
149
|
+
const next = Math.min(Math.max(current + step, 0), radios.length - 1);
|
|
150
|
+
const option = options[next];
|
|
151
|
+
if (option) {
|
|
152
|
+
radios[next]?.focus();
|
|
153
|
+
onSelect(option.id);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
return /*#__PURE__*/ jsx("div", {
|
|
157
|
+
"aria-label": label,
|
|
158
|
+
className: `${BASE_CLASS}__segments`,
|
|
159
|
+
onKeyDown,
|
|
160
|
+
role: "radiogroup",
|
|
161
|
+
children: options.map((option) => {
|
|
162
|
+
const checked = option.id === selected;
|
|
163
|
+
return /*#__PURE__*/ jsx(Button, {
|
|
164
|
+
buttonStyle: checked ? "primary" : included.includes(option.id) ? "pill" : "transparent",
|
|
165
|
+
className: checked && option.id === "trash" ? `${BASE_CLASS}__danger` : "",
|
|
166
|
+
disabled: readOnly,
|
|
167
|
+
extraButtonProps: {
|
|
168
|
+
"aria-checked": checked,
|
|
169
|
+
role: "radio",
|
|
170
|
+
tabIndex: option.id === focusable ? 0 : -1,
|
|
171
|
+
title: option.title
|
|
172
|
+
},
|
|
173
|
+
margin: false,
|
|
174
|
+
onClick: () => {
|
|
175
|
+
onSelect(option.id);
|
|
176
|
+
},
|
|
177
|
+
size: "small",
|
|
178
|
+
children: option.label
|
|
179
|
+
}, option.id);
|
|
180
|
+
})
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
const includedBelow = (options, level) => {
|
|
184
|
+
const selected = options.findIndex((option) => option.id === level);
|
|
185
|
+
return selected < 1 ? [] : options.slice(1, selected).map((option) => option.id);
|
|
186
|
+
};
|
|
187
|
+
const accessOptions = (rows) => ACCESS_OPTIONS.filter((option) => rows.some((row) => accessLevelsOf(row).includes(option.id)));
|
|
92
188
|
const LABEL_WIDTH = "calc(var(--base) * 9)";
|
|
93
189
|
const OPERATION_WIDTH = "calc(var(--base) * 3.5)";
|
|
94
|
-
const
|
|
95
|
-
const
|
|
190
|
+
const ACCESS_WIDTH = "calc(var(--base) * 13)";
|
|
191
|
+
const DELETE_WIDTH = "calc(var(--base) * 10)";
|
|
192
|
+
const Columns = ({ widths }) => /*#__PURE__*/ jsxs("colgroup", { children: [/*#__PURE__*/ jsx("col", { style: { width: LABEL_WIDTH } }), widths.map((width, index) => /*#__PURE__*/ jsx("col", { style: { width } }, index))] });
|
|
193
|
+
const lastCellStyle = {
|
|
96
194
|
...styles.cell,
|
|
97
195
|
...styles.lastCell
|
|
98
|
-
}
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
196
|
+
};
|
|
197
|
+
const controlStyle = (isLast) => isLast ? {
|
|
198
|
+
...styles.controlCell,
|
|
199
|
+
...styles.lastCell
|
|
200
|
+
} : styles.controlCell;
|
|
201
|
+
const LiveBadge = () => /*#__PURE__*/ jsx("span", {
|
|
202
|
+
style: styles.badge,
|
|
203
|
+
title: LIVE_WRITE,
|
|
204
|
+
children: /*#__PURE__*/ jsxs(Pill, {
|
|
205
|
+
pillStyle: "warning",
|
|
206
|
+
size: "small",
|
|
207
|
+
children: [/*#__PURE__*/ jsx("span", {
|
|
208
|
+
"aria-hidden": "true",
|
|
209
|
+
children: "Live"
|
|
210
|
+
}), /*#__PURE__*/ jsx("span", {
|
|
211
|
+
style: styles.visuallyHidden,
|
|
212
|
+
children: LIVE_WRITE
|
|
213
|
+
})]
|
|
214
|
+
})
|
|
104
215
|
});
|
|
105
216
|
const NamespaceTable = ({ matrix, namespace, path, readOnly, toggle, values }) => {
|
|
106
217
|
const rows = matrix[namespace.id];
|
|
107
|
-
const
|
|
218
|
+
const withDelete = rows.some((row) => row.delete);
|
|
219
|
+
const allOptions = accessOptions(rows);
|
|
220
|
+
const allLevel = allAccessLevel(matrix, path, namespace.id, values);
|
|
221
|
+
const entries = namespace.title.toLowerCase();
|
|
108
222
|
return /*#__PURE__*/ jsx("section", {
|
|
109
223
|
style: styles.section,
|
|
110
224
|
children: /*#__PURE__*/ jsxs("table", {
|
|
111
225
|
style: styles.table,
|
|
112
226
|
children: [
|
|
113
|
-
/*#__PURE__*/ jsx(Columns, {
|
|
227
|
+
/*#__PURE__*/ jsx(Columns, { widths: withDelete ? [ACCESS_WIDTH, DELETE_WIDTH] : [ACCESS_WIDTH] }),
|
|
114
228
|
/*#__PURE__*/ jsxs("thead", {
|
|
115
229
|
style: styles.head,
|
|
116
|
-
children: [/*#__PURE__*/ jsxs("tr", { children: [
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
...styles.headTitle,
|
|
120
|
-
...styles.topStart
|
|
121
|
-
},
|
|
122
|
-
children: namespace.title
|
|
123
|
-
}), columns.map((operation, column) => /*#__PURE__*/ jsx("th", {
|
|
124
|
-
scope: "col",
|
|
125
|
-
style: column === columns.length - 1 ? {
|
|
126
|
-
...styles.headOperation,
|
|
127
|
-
...styles.topEnd
|
|
128
|
-
} : styles.headOperation,
|
|
129
|
-
title: operation.description,
|
|
130
|
-
children: operation.label
|
|
131
|
-
}, operation.id))] }), /*#__PURE__*/ jsxs("tr", { children: [/*#__PURE__*/ jsx("th", {
|
|
132
|
-
scope: "row",
|
|
133
|
-
style: {
|
|
134
|
-
...styles.headLabel,
|
|
135
|
-
...styles.rule
|
|
136
|
-
},
|
|
137
|
-
children: "All"
|
|
138
|
-
}), columns.map((operation, column) => {
|
|
139
|
-
const state = columnState(matrix, path, namespace.id, operation.id, values);
|
|
140
|
-
return /*#__PURE__*/ jsx("td", {
|
|
230
|
+
children: [/*#__PURE__*/ jsxs("tr", { children: [
|
|
231
|
+
/*#__PURE__*/ jsx("th", {
|
|
232
|
+
scope: "col",
|
|
141
233
|
style: {
|
|
142
|
-
...
|
|
234
|
+
...styles.headTitle,
|
|
235
|
+
...styles.topStart
|
|
236
|
+
},
|
|
237
|
+
children: namespace.title
|
|
238
|
+
}),
|
|
239
|
+
/*#__PURE__*/ jsx("th", {
|
|
240
|
+
scope: "col",
|
|
241
|
+
style: {
|
|
242
|
+
...styles.headOperation,
|
|
243
|
+
textAlign: "start",
|
|
244
|
+
...withDelete ? {} : styles.topEnd
|
|
245
|
+
},
|
|
246
|
+
children: "Access"
|
|
247
|
+
}),
|
|
248
|
+
withDelete ? /*#__PURE__*/ jsx("th", {
|
|
249
|
+
scope: "col",
|
|
250
|
+
style: {
|
|
251
|
+
...styles.headOperation,
|
|
252
|
+
textAlign: "start",
|
|
253
|
+
...styles.topEnd
|
|
254
|
+
},
|
|
255
|
+
children: "Delete"
|
|
256
|
+
}) : null
|
|
257
|
+
] }), /*#__PURE__*/ jsxs("tr", { children: [
|
|
258
|
+
/*#__PURE__*/ jsx("th", {
|
|
259
|
+
scope: "row",
|
|
260
|
+
style: {
|
|
261
|
+
...styles.headLabel,
|
|
143
262
|
...styles.rule
|
|
144
263
|
},
|
|
145
|
-
children:
|
|
146
|
-
|
|
147
|
-
|
|
264
|
+
children: "All"
|
|
265
|
+
}),
|
|
266
|
+
/*#__PURE__*/ jsx("td", {
|
|
267
|
+
style: {
|
|
268
|
+
...controlStyle(!withDelete),
|
|
269
|
+
...styles.rule
|
|
270
|
+
},
|
|
271
|
+
children: /*#__PURE__*/ jsx(Segments, {
|
|
272
|
+
included: includedBelow(allOptions, allLevel),
|
|
273
|
+
label: `Access for every ${entries} entry`,
|
|
274
|
+
onSelect: (level) => {
|
|
148
275
|
toggle({
|
|
149
|
-
kind: "
|
|
276
|
+
kind: "access",
|
|
150
277
|
namespace: namespace.id,
|
|
151
|
-
|
|
152
|
-
value: state !== "on"
|
|
278
|
+
level
|
|
153
279
|
});
|
|
154
280
|
},
|
|
281
|
+
options: allOptions,
|
|
155
282
|
readOnly,
|
|
156
|
-
|
|
283
|
+
selected: allLevel
|
|
157
284
|
})
|
|
158
|
-
},
|
|
159
|
-
|
|
285
|
+
}),
|
|
286
|
+
withDelete ? /*#__PURE__*/ jsx("td", {
|
|
287
|
+
style: {
|
|
288
|
+
...controlStyle(true),
|
|
289
|
+
...styles.rule
|
|
290
|
+
},
|
|
291
|
+
children: /*#__PURE__*/ jsx(Segments, {
|
|
292
|
+
label: `Delete for every ${entries} entry`,
|
|
293
|
+
onSelect: (mode) => {
|
|
294
|
+
toggle({
|
|
295
|
+
kind: "deleteMode",
|
|
296
|
+
namespace: namespace.id,
|
|
297
|
+
mode
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
options: DELETE_OPTIONS.filter((option) => option.id !== "trash"),
|
|
301
|
+
readOnly,
|
|
302
|
+
selected: allDeleteMode(matrix, path, namespace.id, values)
|
|
303
|
+
})
|
|
304
|
+
}) : null
|
|
305
|
+
] })]
|
|
160
306
|
}),
|
|
161
307
|
/*#__PURE__*/ jsx("tbody", { children: rows.map((row) => {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
308
|
+
const options = accessOptions([row]);
|
|
309
|
+
const level = accessLevelOf(path, namespace.id, row, values);
|
|
310
|
+
return /*#__PURE__*/ jsxs("tr", { children: [
|
|
311
|
+
/*#__PURE__*/ jsxs("th", {
|
|
312
|
+
scope: "row",
|
|
313
|
+
style: styles.rowHeader,
|
|
314
|
+
children: [row.label, row.live ? /*#__PURE__*/ jsx(LiveBadge, {}) : null]
|
|
315
|
+
}),
|
|
316
|
+
/*#__PURE__*/ jsx("td", {
|
|
317
|
+
style: controlStyle(!withDelete),
|
|
318
|
+
children: /*#__PURE__*/ jsx(Segments, {
|
|
319
|
+
included: includedBelow(options, level),
|
|
320
|
+
label: `Access for ${row.label}`,
|
|
321
|
+
onSelect: (next) => {
|
|
322
|
+
toggle({
|
|
323
|
+
kind: "access",
|
|
324
|
+
namespace: namespace.id,
|
|
325
|
+
fieldName: row.fieldName,
|
|
326
|
+
level: next
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
options,
|
|
330
|
+
readOnly,
|
|
331
|
+
selected: level
|
|
332
|
+
})
|
|
333
|
+
}),
|
|
334
|
+
withDelete ? /*#__PURE__*/ jsx("td", {
|
|
335
|
+
style: controlStyle(true),
|
|
336
|
+
children: row.delete ? /*#__PURE__*/ jsx(Segments, {
|
|
337
|
+
label: `Delete for ${row.label}`,
|
|
338
|
+
onSelect: (mode) => {
|
|
339
|
+
toggle({
|
|
340
|
+
kind: "deleteMode",
|
|
341
|
+
namespace: namespace.id,
|
|
342
|
+
fieldName: row.fieldName,
|
|
343
|
+
mode
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
options: DELETE_OPTIONS.filter((option) => option.id !== "trash" || row.deleteUnattended),
|
|
347
|
+
readOnly,
|
|
348
|
+
selected: deleteModeOf(path, namespace.id, row, values)
|
|
349
|
+
}) : null
|
|
350
|
+
}) : null
|
|
351
|
+
] }, row.fieldName);
|
|
202
352
|
}) })
|
|
203
353
|
]
|
|
204
354
|
})
|
|
@@ -211,7 +361,7 @@ const ToolsTable = ({ matrix, path, readOnly, toggle, values }) => {
|
|
|
211
361
|
children: /*#__PURE__*/ jsxs("table", {
|
|
212
362
|
style: styles.table,
|
|
213
363
|
children: [
|
|
214
|
-
/*#__PURE__*/ jsx(Columns, {
|
|
364
|
+
/*#__PURE__*/ jsx(Columns, { widths: [OPERATION_WIDTH] }),
|
|
215
365
|
/*#__PURE__*/ jsxs("thead", {
|
|
216
366
|
style: styles.head,
|
|
217
367
|
children: [/*#__PURE__*/ jsxs("tr", { children: [/*#__PURE__*/ jsx("th", {
|
|
@@ -237,7 +387,7 @@ const ToolsTable = ({ matrix, path, readOnly, toggle, values }) => {
|
|
|
237
387
|
children: "All"
|
|
238
388
|
}), /*#__PURE__*/ jsx("td", {
|
|
239
389
|
style: {
|
|
240
|
-
...
|
|
390
|
+
...lastCellStyle,
|
|
241
391
|
...styles.rule
|
|
242
392
|
},
|
|
243
393
|
children: /*#__PURE__*/ jsx(Box, {
|
|
@@ -261,7 +411,7 @@ const ToolsTable = ({ matrix, path, readOnly, toggle, values }) => {
|
|
|
261
411
|
children: tool.description
|
|
262
412
|
})]
|
|
263
413
|
}), /*#__PURE__*/ jsx("td", {
|
|
264
|
-
style:
|
|
414
|
+
style: lastCellStyle,
|
|
265
415
|
children: /*#__PURE__*/ jsx(Box, {
|
|
266
416
|
label: `Enable ${tool.name}`,
|
|
267
417
|
onChange: () => {
|
|
@@ -280,9 +430,10 @@ const ToolsTable = ({ matrix, path, readOnly, toggle, values }) => {
|
|
|
280
430
|
});
|
|
281
431
|
};
|
|
282
432
|
/**
|
|
283
|
-
* What an API key may do, as one table per namespace: a row per entity
|
|
284
|
-
*
|
|
285
|
-
* checkbox fields back every
|
|
433
|
+
* What an API key may do, as one table per namespace: a row per entity with
|
|
434
|
+
* segmented Access and Delete controls, and an "All" row that sets every row.
|
|
435
|
+
* The nested checkbox fields back every control, so the stored document is
|
|
436
|
+
* unaffected.
|
|
286
437
|
*/ const McpxCapabilityMatrix = ({ field, matrix, path = CAPABILITIES_FIELD, readOnly = false, withinTab = false }) => {
|
|
287
438
|
const { dispatchFields, setModified } = useForm();
|
|
288
439
|
const paths = useMemo(() => capabilityPaths(matrix, path), [matrix, path]);
|
|
@@ -1,60 +1,59 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { STORED_OPERATIONS, cellPath, toolPath } from "../api-keys/capability-matrix.mjs";
|
|
2
2
|
//#region src/admin/capability-toggles.ts
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* How far a key may go with an entity. Each level includes the ones before it.
|
|
5
|
+
*/ const ACCESS_LEVELS = [
|
|
6
|
+
"none",
|
|
7
|
+
"read",
|
|
8
|
+
"write",
|
|
9
|
+
"publish"
|
|
10
|
+
];
|
|
11
|
+
const toolsState = (matrix, basePath, values) => {
|
|
12
|
+
const granted = matrix.tools.filter((tool) => values[toolPath(basePath, tool.name)] === true).length;
|
|
6
13
|
if (granted === 0) return "off";
|
|
7
|
-
return granted ===
|
|
14
|
+
return granted === matrix.tools.length ? "on" : "mixed";
|
|
8
15
|
};
|
|
9
16
|
/**
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
*/ const columnState = (matrix, basePath, namespace, operation, values) => stateOf(matrix[namespace].filter((row) => row[operation]).map((row) => cellPath(basePath, namespace, row.fieldName, operation)), values);
|
|
13
|
-
const ROW_OPERATIONS = CAPABILITY_OPERATIONS.filter((operation) => operation.id !== "delete" && operation.id !== "deleteUnattended");
|
|
17
|
+
* The levels a row offers: none, then each operation the config exposes.
|
|
18
|
+
*/ const accessLevelsOf = (row) => ACCESS_LEVELS.filter((level) => level === "none" || row[level]);
|
|
14
19
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*/ const
|
|
18
|
-
|
|
20
|
+
* The highest level whose flags are all granted, counting only operations the
|
|
21
|
+
* config exposes. A write without a read reads as none, as on the server.
|
|
22
|
+
*/ const accessLevelOf = (basePath, namespace, row, values) => {
|
|
23
|
+
let reached = "none";
|
|
24
|
+
for (const level of ACCESS_LEVELS) {
|
|
25
|
+
if (level === "none") continue;
|
|
26
|
+
if (!row[level] || values[cellPath(basePath, namespace, row.fieldName, level)] !== true) break;
|
|
27
|
+
reached = level;
|
|
28
|
+
}
|
|
29
|
+
return reached;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The delete mode a row's stored flags read as.
|
|
33
|
+
*/ const deleteModeOf = (basePath, namespace, row, values) => {
|
|
34
|
+
const granted = (operation) => values[cellPath(basePath, namespace, row.fieldName, operation)] === true;
|
|
35
|
+
if (!granted("read") || !granted("delete")) return "off";
|
|
36
|
+
return row.deleteUnattended && granted("deleteUnattended") ? "trash" : "approve";
|
|
37
|
+
};
|
|
38
|
+
const requiresOf = (operation) => {
|
|
39
|
+
const found = STORED_OPERATIONS.find((candidate) => candidate.id === operation);
|
|
40
|
+
return found && "requires" in found ? found.requires : void 0;
|
|
41
|
+
};
|
|
42
|
+
const prerequisitesOf = (operation) => {
|
|
43
|
+
const required = requiresOf(operation);
|
|
44
|
+
return required ? [required, ...prerequisitesOf(required)] : [];
|
|
45
|
+
};
|
|
19
46
|
const reconcile = (next, touched) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
write: true
|
|
24
|
-
};
|
|
25
|
-
if (touched.has("write") && !result.write) result = {
|
|
26
|
-
...result,
|
|
27
|
-
publish: false
|
|
28
|
-
};
|
|
29
|
-
if (touched.has("deleteUnattended") && result.deleteUnattended) result = {
|
|
30
|
-
...result,
|
|
31
|
-
delete: true
|
|
32
|
-
};
|
|
33
|
-
if ((touched.has("delete") || touched.has("deleteUnattended")) && result.delete) result = {
|
|
34
|
-
...result,
|
|
35
|
-
read: true
|
|
36
|
-
};
|
|
37
|
-
if (touched.has("read") && !result.read) result = {
|
|
38
|
-
...result,
|
|
39
|
-
delete: false
|
|
40
|
-
};
|
|
41
|
-
if ((touched.has("delete") || touched.has("read")) && !result.delete) result = {
|
|
42
|
-
...result,
|
|
43
|
-
deleteUnattended: false
|
|
44
|
-
};
|
|
47
|
+
const result = { ...next };
|
|
48
|
+
for (const operation of touched) if (result[operation]) for (const required of prerequisitesOf(operation)) result[required] = true;
|
|
49
|
+
else for (const { id } of STORED_OPERATIONS) if (prerequisitesOf(id).includes(operation)) result[id] = false;
|
|
45
50
|
return result;
|
|
46
51
|
};
|
|
47
52
|
const rowActions = (basePath, namespace, row, values, changes) => {
|
|
48
|
-
const current =
|
|
49
|
-
read: values[cellPath(basePath, namespace, row.fieldName, "read")] === true,
|
|
50
|
-
write: values[cellPath(basePath, namespace, row.fieldName, "write")] === true,
|
|
51
|
-
publish: values[cellPath(basePath, namespace, row.fieldName, "publish")] === true,
|
|
52
|
-
delete: values[cellPath(basePath, namespace, row.fieldName, "delete")] === true,
|
|
53
|
-
deleteUnattended: values[cellPath(basePath, namespace, row.fieldName, "deleteUnattended")] === true
|
|
54
|
-
};
|
|
53
|
+
const current = Object.fromEntries(STORED_OPERATIONS.map((operation) => [operation.id, values[cellPath(basePath, namespace, row.fieldName, operation.id)] === true]));
|
|
55
54
|
const touched = /* @__PURE__ */ new Set();
|
|
56
55
|
const next = { ...current };
|
|
57
|
-
for (const operation of
|
|
56
|
+
for (const operation of STORED_OPERATIONS) {
|
|
58
57
|
const change = changes[operation.id];
|
|
59
58
|
if (change !== void 0 && row[operation.id]) {
|
|
60
59
|
next[operation.id] = change;
|
|
@@ -62,32 +61,57 @@ const rowActions = (basePath, namespace, row, values, changes) => {
|
|
|
62
61
|
}
|
|
63
62
|
}
|
|
64
63
|
const reconciled = reconcile(next, touched);
|
|
65
|
-
return
|
|
64
|
+
return STORED_OPERATIONS.filter((operation) => row[operation.id] && reconciled[operation.id] !== current[operation.id]).map((operation) => ({
|
|
66
65
|
type: "UPDATE",
|
|
67
66
|
path: cellPath(basePath, namespace, row.fieldName, operation.id),
|
|
68
67
|
value: reconciled[operation.id]
|
|
69
68
|
}));
|
|
70
69
|
};
|
|
70
|
+
const grantsOf = (level) => {
|
|
71
|
+
const rank = ACCESS_LEVELS.indexOf(level);
|
|
72
|
+
return {
|
|
73
|
+
read: rank >= 1,
|
|
74
|
+
write: rank >= 2,
|
|
75
|
+
publish: rank >= 3
|
|
76
|
+
};
|
|
77
|
+
};
|
|
71
78
|
/**
|
|
72
|
-
* The form-state updates one click implies, as literal actions. Every
|
|
73
|
-
*
|
|
74
|
-
* to
|
|
79
|
+
* The form-state updates one click implies, as literal actions. Every click
|
|
80
|
+
* comes through here, so the dependency rules apply to the "All" controls too:
|
|
81
|
+
* picking Write ticks read, and lowering access to none clears delete.
|
|
75
82
|
*
|
|
76
|
-
* Only cells whose value changes get an action, so a
|
|
77
|
-
* nothing does not mark the form modified.
|
|
83
|
+
* Only cells whose value changes get an action, so a click that changes
|
|
84
|
+
* nothing does not mark the form modified. A level above a row's ceiling
|
|
85
|
+
* grants what the row exposes and no more.
|
|
78
86
|
*/ const buildToggleActions = (matrix, basePath, values, intent) => {
|
|
79
87
|
if (intent.kind === "tool" || intent.kind === "tools") return matrix.tools.filter((tool) => intent.kind === "tools" || tool.name === intent.name).filter((tool) => values[toolPath(basePath, tool.name)] === true !== intent.value).map((tool) => ({
|
|
80
88
|
type: "UPDATE",
|
|
81
89
|
path: toolPath(basePath, tool.name),
|
|
82
90
|
value: intent.value
|
|
83
91
|
}));
|
|
84
|
-
const rows = matrix[intent.namespace].filter((row) => intent.
|
|
85
|
-
const changes = intent.kind === "
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} : { [intent.operation]: intent.value };
|
|
92
|
+
const rows = matrix[intent.namespace].filter((row) => intent.fieldName === void 0 || row.fieldName === intent.fieldName);
|
|
93
|
+
const changes = intent.kind === "access" ? grantsOf(intent.level) : {
|
|
94
|
+
delete: intent.mode !== "off",
|
|
95
|
+
deleteUnattended: intent.mode === "trash"
|
|
96
|
+
};
|
|
90
97
|
return rows.flatMap((row) => rowActions(basePath, intent.namespace, row, values, changes));
|
|
91
98
|
};
|
|
99
|
+
/**
|
|
100
|
+
* The level a namespace's "All" control shows: the one that would change
|
|
101
|
+
* nothing if picked, so rows capped below it still count. `undefined` while
|
|
102
|
+
* the rows differ.
|
|
103
|
+
*/ const allAccessLevel = (matrix, basePath, namespace, values) => ACCESS_LEVELS.find((level) => buildToggleActions(matrix, basePath, values, {
|
|
104
|
+
kind: "access",
|
|
105
|
+
namespace,
|
|
106
|
+
level
|
|
107
|
+
}).length === 0);
|
|
108
|
+
/**
|
|
109
|
+
* The delete mode the "All" control shows, by the same rule. It offers no
|
|
110
|
+
* trash, so a row that trashes directly reads as mixed.
|
|
111
|
+
*/ const allDeleteMode = (matrix, basePath, namespace, values) => ["off", "approve"].find((mode) => buildToggleActions(matrix, basePath, values, {
|
|
112
|
+
kind: "deleteMode",
|
|
113
|
+
namespace,
|
|
114
|
+
mode
|
|
115
|
+
}).length === 0);
|
|
92
116
|
//#endregion
|
|
93
|
-
export {
|
|
117
|
+
export { accessLevelOf, accessLevelsOf, allAccessLevel, allDeleteMode, buildToggleActions, deleteModeOf, toolsState };
|
|
@@ -2,8 +2,7 @@ import "../options.mjs";
|
|
|
2
2
|
//#region src/api-keys/capability-matrix.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* One entity's row. The booleans say what the plugin config exposes, not what
|
|
5
|
-
* the key was granted. A `false`
|
|
6
|
-
* refusal by config rather than a gap.
|
|
5
|
+
* the key was granted. A `false` leaves its segment out of the row's controls.
|
|
7
6
|
*/
|
|
8
7
|
export interface CapabilityRow {
|
|
9
8
|
fieldName: string;
|
|
@@ -17,9 +16,9 @@ export interface CapabilityRow {
|
|
|
17
16
|
delete: boolean;
|
|
18
17
|
deleteUnattended: boolean;
|
|
19
18
|
/**
|
|
20
|
-
*
|
|
19
|
+
* Writes go live immediately, as the entity keeps no drafts.
|
|
21
20
|
*/
|
|
22
|
-
|
|
21
|
+
live?: boolean;
|
|
23
22
|
}
|
|
24
23
|
interface CapabilityTool {
|
|
25
24
|
name: string;
|
|
@@ -2,12 +2,12 @@ import { CAPABILITIES_FIELD, canPublish, canWrite, isLiveWrite } from "../capabi
|
|
|
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.";
|
|
5
|
+
* description slot. It explains how to read the segmented 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.";
|
|
8
7
|
/**
|
|
9
8
|
* The operations a collection or global can expose. The generated checkboxes
|
|
10
|
-
* and the matrix
|
|
9
|
+
* and the matrix segments share this wording. `requires` names the
|
|
10
|
+
* operation a grant depends on, which the server and the matrix both enforce.
|
|
11
11
|
*/ const CAPABILITY_OPERATIONS = [
|
|
12
12
|
{
|
|
13
13
|
id: "read",
|
|
@@ -17,26 +17,35 @@ import { CAPABILITIES_FIELD, canPublish, canWrite, isLiveWrite } from "../capabi
|
|
|
17
17
|
{
|
|
18
18
|
id: "write",
|
|
19
19
|
label: "Write",
|
|
20
|
-
description: "Create, patch and validate drafts."
|
|
20
|
+
description: "Create, patch and validate drafts.",
|
|
21
|
+
requires: "read"
|
|
21
22
|
},
|
|
22
23
|
{
|
|
23
24
|
id: "publish",
|
|
24
25
|
label: "Publish",
|
|
25
|
-
description: "Promote the current draft to what the public sees."
|
|
26
|
+
description: "Promote the current draft to what the public sees.",
|
|
27
|
+
requires: "write"
|
|
26
28
|
},
|
|
27
29
|
{
|
|
28
30
|
id: "delete",
|
|
29
31
|
label: "Delete",
|
|
30
|
-
description: "Delete documents
|
|
31
|
-
|
|
32
|
-
{
|
|
33
|
-
id: "deleteUnattended",
|
|
34
|
-
label: "Delete without approval",
|
|
35
|
-
description: "Move documents to trash without asking this key's user."
|
|
32
|
+
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.",
|
|
33
|
+
requires: "read"
|
|
36
34
|
}
|
|
37
35
|
];
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Stored as a checkbox of its own but drawn as the Trash segment of the Delete
|
|
38
|
+
* control.
|
|
39
|
+
*/ const DELETE_UNATTENDED = {
|
|
40
|
+
id: "deleteUnattended",
|
|
41
|
+
label: "Trash directly",
|
|
42
|
+
description: "Move documents to trash without asking this key's user.",
|
|
43
|
+
requires: "delete"
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Every operation with a stored checkbox: the capability operations plus
|
|
47
|
+
* the unattended delete.
|
|
48
|
+
*/ const STORED_OPERATIONS = [...CAPABILITY_OPERATIONS, DELETE_UNATTENDED];
|
|
40
49
|
const toRow = (entity) => ({
|
|
41
50
|
fieldName: entity.fieldName,
|
|
42
51
|
label: entity.slug,
|
|
@@ -45,11 +54,11 @@ const toRow = (entity) => ({
|
|
|
45
54
|
publish: canPublish(entity),
|
|
46
55
|
delete: entity.delete,
|
|
47
56
|
deleteUnattended: entity.deleteUnattended,
|
|
48
|
-
...
|
|
57
|
+
...isLiveWrite(entity) ? { live: true } : {}
|
|
49
58
|
});
|
|
50
59
|
/**
|
|
51
60
|
* The single description of what the config exposes. `createCapabilityFields`
|
|
52
|
-
* and the admin component both draw from it, so a
|
|
61
|
+
* and the admin component both draw from it, so a control cannot appear without a
|
|
53
62
|
* field behind it.
|
|
54
63
|
*/ const createCapabilityMatrix = (options) => ({
|
|
55
64
|
collections: options.collections.map(toRow),
|
|
@@ -63,6 +72,6 @@ const cellPath = (basePath, namespace, fieldName, operation) => `${basePath}.${n
|
|
|
63
72
|
const toolPath = (basePath, name) => `${basePath}.tools.${name}`;
|
|
64
73
|
/**
|
|
65
74
|
* Every path the matrix can address, in a stable order.
|
|
66
|
-
*/ const capabilityPaths = (matrix, basePath = CAPABILITIES_FIELD) => [...["collections", "globals"].flatMap((namespace) => matrix[namespace].flatMap((row) =>
|
|
75
|
+
*/ const capabilityPaths = (matrix, basePath = CAPABILITIES_FIELD) => [...["collections", "globals"].flatMap((namespace) => matrix[namespace].flatMap((row) => STORED_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)))), ...matrix.tools.map((tool) => toolPath(basePath, tool.name))];
|
|
67
76
|
//#endregion
|
|
68
|
-
export { CAPABILITIES_DESCRIPTION, CAPABILITY_OPERATIONS, capabilityPaths, cellPath, createCapabilityMatrix, toolPath };
|
|
77
|
+
export { CAPABILITIES_DESCRIPTION, CAPABILITY_OPERATIONS, STORED_OPERATIONS, capabilityPaths, cellPath, createCapabilityMatrix, toolPath };
|
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, STORED_OPERATIONS, createCapabilityMatrix } 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 }) => {
|
|
@@ -141,7 +141,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
141
141
|
name: row.fieldName,
|
|
142
142
|
type: "group",
|
|
143
143
|
label: row.label,
|
|
144
|
-
fields:
|
|
144
|
+
fields: STORED_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => checkbox(operation.id, operation.description))
|
|
145
145
|
}));
|
|
146
146
|
const collectionGroups = entityGroups(matrix.collections);
|
|
147
147
|
const globalGroups = entityGroups(matrix.globals);
|
package/dist/capabilities.mjs
CHANGED
|
@@ -17,7 +17,8 @@ import { isPlainObject } from "./guards.mjs";
|
|
|
17
17
|
* Without drafts there is no draft stage, so every write is live.
|
|
18
18
|
*/ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
|
|
19
19
|
const flag = (group, name) => isPlainObject(group) && group[name] === true;
|
|
20
|
-
const
|
|
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");
|
|
21
22
|
/**
|
|
22
23
|
* Capabilities in force for a key: the plugin config decides what can exist,
|
|
23
24
|
* the key's checkboxes decide what does. A missing checkbox is `false`, so keys
|
|
@@ -31,7 +32,7 @@ const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write"
|
|
|
31
32
|
const deleting = entity.delete && flag(group, "read") && flag(group, "delete");
|
|
32
33
|
resolved[entity.slug] = {
|
|
33
34
|
read: entity.read && flag(group, "read"),
|
|
34
|
-
write:
|
|
35
|
+
write: writeFlag(entity, group),
|
|
35
36
|
publish: publishFlag(entity, group),
|
|
36
37
|
delete: deleting,
|
|
37
38
|
deleteUnattended: deleting && entity.deleteUnattended && flag(group, "deleteUnattended")
|
package/dist/options.mjs
CHANGED
|
@@ -64,18 +64,21 @@ const normalizeCapabilities = (kind, config, raw) => {
|
|
|
64
64
|
if (unknownKey !== void 0) fail(`${kind} "${slug}" has the unknown option "${unknownKey}". The options are ${names.join(", ")}.`);
|
|
65
65
|
const flag = (name) => readFlag(kind, slug, name, settings[name]);
|
|
66
66
|
const read = flag("read") ?? true;
|
|
67
|
-
const write = flag("write") ??
|
|
67
|
+
const write = flag("write") ?? read;
|
|
68
68
|
const publish = flag("publish");
|
|
69
69
|
const hasDrafts = hasDraftsEnabled(config);
|
|
70
70
|
if (publish === false && write && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so every write goes live. Set write: false or remove publish: false.`);
|
|
71
71
|
if (publish === true && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so there is nothing to publish. Enable versions.drafts or remove publish: true.`);
|
|
72
|
+
if (!read && (flag("write") === true || publish === true)) fail(`${kind} "${slug}" has read: false but write or publish on. Writing and publishing need read.`);
|
|
72
73
|
if (publish === true && !write) fail(`${kind} "${slug}" has publish: true but write: false. Publishing follows write.`);
|
|
74
|
+
const deleting = normalizeDelete(kind, config, settings["delete"]);
|
|
75
|
+
if (!read && deleting.delete) fail(`${kind} "${slug}" has read: false but delete on. Deleting needs read.`);
|
|
73
76
|
return {
|
|
74
77
|
read,
|
|
75
78
|
write: write ? publish === false ? "draft" : "live" : false,
|
|
76
79
|
hasDrafts,
|
|
77
80
|
hasVersions: read && Boolean(config.versions),
|
|
78
|
-
...
|
|
81
|
+
...deleting
|
|
79
82
|
};
|
|
80
83
|
};
|
|
81
84
|
const assertGlobalExposable = (global) => {
|
|
@@ -6,6 +6,7 @@ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
|
6
6
|
import { assertAcceptsFile, fileShape, fileWriteRefusal, uploadHandoff, uploadedFile } from "../upload/file.mjs";
|
|
7
7
|
import { liveWriteSentence, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor } from "./shared.mjs";
|
|
8
8
|
import { readDraft, resolveEntity } from "./document.mjs";
|
|
9
|
+
import { documentLinks } from "./links.mjs";
|
|
9
10
|
import { stripRowIds } from "../write/row-ids.mjs";
|
|
10
11
|
import { z } from "zod";
|
|
11
12
|
import { APIError } from "payload";
|
|
@@ -14,7 +15,7 @@ const uploadSentence = (scope) => {
|
|
|
14
15
|
const slugs = patchOnlySlugs(scope);
|
|
15
16
|
return slugs.length === 0 ? "" : ` Documents in ${slugs.join(", ")} are files and cannot be created here. Upload the file in the admin panel, then edit its fields with patchDocument.`;
|
|
16
17
|
};
|
|
17
|
-
const DESCRIPTION = (scope) => `Creates a document in a collection. Use it only when the document does not exist yet. Returns "id", "status", "updatedAt" and, if any, "publishBlockers". "publishBlockersUnavailable" means that check failed. "data" may leave required fields empty for patchDocument to fill later, except in a collection whose listCapabilities entry has "draftValidation" true or "drafts" false. A field describeSchema does not list is refused, and "id" is always assigned.
|
|
18
|
+
const DESCRIPTION = (scope) => `Creates a document in a collection. Use it only when the document does not exist yet. Returns "id", "status", "updatedAt" and, if any, "publishBlockers". "publishBlockersUnavailable" means that check failed. "data" may leave required fields empty for patchDocument to fill later, except in a collection whose listCapabilities entry has "draftValidation" true or "drafts" false. A field describeSchema does not list is refused, and "id" is always assigned. The response links the document with "adminUrl" and "previewUrl".
|
|
18
19
|
|
|
19
20
|
${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
|
|
20
21
|
/**
|
|
@@ -104,6 +105,11 @@ ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
|
|
|
104
105
|
id: saved["id"],
|
|
105
106
|
status: saved["_status"],
|
|
106
107
|
updatedAt: saved["updatedAt"],
|
|
108
|
+
...await documentLinks(scope.req, {
|
|
109
|
+
target,
|
|
110
|
+
doc: saved,
|
|
111
|
+
locale
|
|
112
|
+
}),
|
|
107
113
|
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
108
114
|
...validation.unavailable ? { publishBlockersUnavailable: true } : {}
|
|
109
115
|
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { formatAdminURL } from "payload/shared";
|
|
2
|
+
//#region src/tools/links.ts
|
|
3
|
+
const livePreviewOf = (req, target) => {
|
|
4
|
+
if (target.config.admin.livePreview) return target.config.admin.livePreview;
|
|
5
|
+
const root = req.payload.config.admin.livePreview;
|
|
6
|
+
return (target.kind === "collection" ? root?.collections : root?.globals)?.includes(target.slug) ? root : void 0;
|
|
7
|
+
};
|
|
8
|
+
const previewOf = async (req, args) => {
|
|
9
|
+
const { target, doc, locale } = args;
|
|
10
|
+
const { preview } = target.config.admin;
|
|
11
|
+
if (preview) return await preview(doc, {
|
|
12
|
+
locale: locale ?? "",
|
|
13
|
+
req,
|
|
14
|
+
token: null
|
|
15
|
+
});
|
|
16
|
+
const url = livePreviewOf(req, target)?.url;
|
|
17
|
+
if (typeof url !== "function") return url;
|
|
18
|
+
const { localization } = req.payload.config;
|
|
19
|
+
const entry = localization ? localization.locales.find((item) => item.code === locale) : void 0;
|
|
20
|
+
return await url({
|
|
21
|
+
...target.kind === "collection" ? { collectionConfig: target.config } : { globalConfig: target.config },
|
|
22
|
+
data: doc,
|
|
23
|
+
locale: entry ?? {
|
|
24
|
+
code: locale ?? "",
|
|
25
|
+
label: locale ?? ""
|
|
26
|
+
},
|
|
27
|
+
payload: req.payload,
|
|
28
|
+
req
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The admin panel URL of a written document, and its preview URL where the
|
|
33
|
+
* entity configures one. Both sit at the origin of `serverURL` when set, else
|
|
34
|
+
* of the MCP request. A preview that fails is left out: the write has landed.
|
|
35
|
+
*/ const documentLinks = async (req, args) => {
|
|
36
|
+
const { target, doc, locale } = args;
|
|
37
|
+
const { config } = req.payload;
|
|
38
|
+
const origin = config.serverURL || new URL(req.url ?? "").origin;
|
|
39
|
+
const path = target.kind === "collection" ? `/collections/${target.slug}/${String(doc["id"])}` : `/globals/${target.slug}`;
|
|
40
|
+
const adminUrl = formatAdminURL({
|
|
41
|
+
adminRoute: config.routes.admin,
|
|
42
|
+
path,
|
|
43
|
+
serverURL: origin
|
|
44
|
+
});
|
|
45
|
+
const preview = await previewOf(req, args).catch(() => void 0);
|
|
46
|
+
return {
|
|
47
|
+
adminUrl: locale ? `${adminUrl}?locale=${encodeURIComponent(locale)}` : adminUrl,
|
|
48
|
+
...preview ? { previewUrl: new URL(preview, origin).toString() } : {}
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { documentLinks };
|
|
@@ -37,7 +37,7 @@ const translateLabel = (scope, label, fallback) => {
|
|
|
37
37
|
const collections = scope.exposure.collections.flatMap((entry) => {
|
|
38
38
|
const capability = scope.capabilities.collections[entry.slug];
|
|
39
39
|
const collection = payload.collections[entry.slug];
|
|
40
|
-
if (!capability || !collection || !
|
|
40
|
+
if (!capability || !collection || !capability.read) return [];
|
|
41
41
|
const { config } = collection;
|
|
42
42
|
const description = translate(config.admin.description);
|
|
43
43
|
return [{
|
|
@@ -60,7 +60,7 @@ const translateLabel = (scope, label, fallback) => {
|
|
|
60
60
|
const globals = scope.exposure.globals.flatMap((entry) => {
|
|
61
61
|
const capability = scope.capabilities.globals[entry.slug];
|
|
62
62
|
const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
|
|
63
|
-
if (!capability || !config || !
|
|
63
|
+
if (!capability || !config || !capability.read) return [];
|
|
64
64
|
const description = translate(config.admin.description);
|
|
65
65
|
return [{
|
|
66
66
|
slug: entry.slug,
|
|
@@ -7,6 +7,7 @@ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
|
7
7
|
import { assertAcceptsFile, fileShape, fileSlugs, fileWriteRefusal, uploadHandoff, uploadedFile } from "../upload/file.mjs";
|
|
8
8
|
import { entityShape, idShape, liveWriteSentence, localeOf, localeShape } from "./shared.mjs";
|
|
9
9
|
import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
|
|
10
|
+
import { documentLinks } from "./links.mjs";
|
|
10
11
|
import { withTransaction } from "../write/transaction.mjs";
|
|
11
12
|
import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
|
|
12
13
|
import { buildWriteData } from "../write/write-data.mjs";
|
|
@@ -22,7 +23,7 @@ Read the document with getDocument right before patching. Append to a list with
|
|
|
22
23
|
|
|
23
24
|
In a rich text field a pointer continues into the rich text state: "/content/root/children/2" is a node, "/content/root/children/2/tag" one of its properties and "/content/root/children/3/fields/tone" a field of a block node. getDocument "outline" lists each node's pointer and "version". Build nodes from describeSchema "nodeProperties". Check a position before writing to it, e.g. {"op":"test","path":"/content/root/children/2/type","value":"heading"}.
|
|
24
25
|
|
|
25
|
-
The response carries "updatedAt" and, if any, "publishBlockers": what must be fixed before publishing. "publishBlockersUnavailable" means that check failed. The write stands either way. "notApplied" lists pointers whose value did not change or cannot be read back, for example where field access denies the update.`;
|
|
26
|
+
The response carries "updatedAt" and, if any, "publishBlockers": what must be fixed before publishing. "publishBlockersUnavailable" means that check failed. The write stands either way. "notApplied" lists pointers whose value did not change or cannot be read back, for example where field access denies the update. The response links the document with "adminUrl" and "previewUrl".`;
|
|
26
27
|
const POINTER = z.string().regex(JSON_POINTER_PATTERN);
|
|
27
28
|
const PATCHES_LIMIT = 500;
|
|
28
29
|
/**
|
|
@@ -130,6 +131,11 @@ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operat
|
|
|
130
131
|
...identityOf(target, saved["id"]),
|
|
131
132
|
status: saved["_status"],
|
|
132
133
|
updatedAt: saved["updatedAt"],
|
|
134
|
+
...await documentLinks(scope.req, {
|
|
135
|
+
target,
|
|
136
|
+
doc: saved,
|
|
137
|
+
locale
|
|
138
|
+
}),
|
|
133
139
|
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
134
140
|
...validation.unavailable ? { publishBlockersUnavailable: true } : {},
|
|
135
141
|
...notApplied.length > 0 ? { notApplied } : {}
|
|
@@ -2,6 +2,7 @@ import { defineMcpxTool } from "../define-tool.mjs";
|
|
|
2
2
|
import { jsonResult } from "../result.mjs";
|
|
3
3
|
import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
|
|
4
4
|
import { collectLocaleBlockers, identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
|
|
5
|
+
import { documentLinks } from "./links.mjs";
|
|
5
6
|
import { withPublishIntent } from "../write/publish-intent.mjs";
|
|
6
7
|
import { withTransaction } from "../write/transaction.mjs";
|
|
7
8
|
import { z } from "zod";
|
|
@@ -10,7 +11,7 @@ import { z } from "zod";
|
|
|
10
11
|
* with drafts and the key has both the `write` and `publish` checkboxes.
|
|
11
12
|
*/ const publishDocument = defineMcpxTool({
|
|
12
13
|
name: "publishDocument",
|
|
13
|
-
description: `Makes the current draft of one document or global public, in every locale or only in "locale". A scoped publish keeps the other locales of localized fields as last published, and a never-published document goes live in full. A draft with publish blockers is refused, so call validateDocument first. Payload checks only the published locale, so "otherLocaleBlockers" lists what other locales still lack. There is no unpublish.`,
|
|
14
|
+
description: `Makes the current draft of one document or global public, in every locale or only in "locale". A scoped publish keeps the other locales of localized fields as last published, and a never-published document goes live in full. A draft with publish blockers is refused, so call validateDocument first. Payload checks only the published locale, so "otherLocaleBlockers" lists what other locales still lack. There is no unpublish. The response links the document with "adminUrl" and "previewUrl".`,
|
|
14
15
|
annotations: {
|
|
15
16
|
destructiveHint: true,
|
|
16
17
|
openWorldHint: false
|
|
@@ -63,6 +64,11 @@ import { z } from "zod";
|
|
|
63
64
|
...identityOf(target, saved["id"]),
|
|
64
65
|
status: saved["_status"],
|
|
65
66
|
updatedAt: saved["updatedAt"],
|
|
67
|
+
...await documentLinks(scope.req, {
|
|
68
|
+
target,
|
|
69
|
+
doc: saved,
|
|
70
|
+
locale
|
|
71
|
+
}),
|
|
66
72
|
...others.blockers.length > 0 ? { otherLocaleBlockers: others.blockers } : {},
|
|
67
73
|
...others.unavailable ? { otherLocaleBlockersUnavailable: true } : {}
|
|
68
74
|
});
|
|
@@ -4,8 +4,8 @@ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
|
4
4
|
import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
|
|
5
5
|
import { collectLocaleBlockers, identityOf, readDraft, resolveDocument } from "./document.mjs";
|
|
6
6
|
/**
|
|
7
|
-
* Gated on write
|
|
8
|
-
* to a caller who can act on them.
|
|
7
|
+
* Gated on write, which implies read, because publish blockers only mean
|
|
8
|
+
* something to a caller who can act on them.
|
|
9
9
|
*
|
|
10
10
|
* It reads the document twice on purpose: once under the key's own access to
|
|
11
11
|
* refuse a caller who may not see it, then privileged, so the check covers every
|
package/dist/types.d.mts
CHANGED
|
@@ -36,7 +36,7 @@ export interface McpxCollectionOptions {
|
|
|
36
36
|
/**
|
|
37
37
|
* Expose `patchDocument`, `validateDocument` and `createDocument`. An upload
|
|
38
38
|
* collection is created in only where MCP accepts its files. Without drafts
|
|
39
|
-
* a write changes live content. Default `true
|
|
39
|
+
* a write changes live content. Needs `read`. Default `true` while `read` is.
|
|
40
40
|
*/
|
|
41
41
|
write?: boolean;
|
|
42
42
|
/**
|
|
@@ -48,8 +48,8 @@ export interface McpxCollectionOptions {
|
|
|
48
48
|
/**
|
|
49
49
|
* Expose `deleteDocument`, which deletes one document once the key's user
|
|
50
50
|
* approved the call in the admin panel. On a collection with `trash` it
|
|
51
|
-
* moves the document to trash. `"unattended"` also lets a key
|
|
52
|
-
*
|
|
51
|
+
* moves the document to trash. `"unattended"` also lets a key set to it move
|
|
52
|
+
* documents to trash without approval; it needs `trash`. Needs `read`.
|
|
53
53
|
* Default `false`.
|
|
54
54
|
*/
|
|
55
55
|
delete?: boolean | "unattended";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@abinnovision/payloadcms-mcpx",
|
|
4
|
-
"version": "2.0.0-beta.
|
|
4
|
+
"version": "2.0.0-beta.16",
|
|
5
5
|
"description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"payload",
|