@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 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. | `write`, `publish` |
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 and a column per
147
- operation. A cell the config does not expose shows a dash. `publish` exists only where the config
148
- exposes `write` and `publish` on an entity with drafts, and counts only when `write` is ticked as
149
- well. A write checkbox on an entity without drafts is marked "Writes go live immediately."
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. The row toggle leaves it alone. A delete runs only after the key's user
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 ticks "Delete without approval": there the document moves to trash at once. See
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, a
29
- * column per operation and a bulk toggle in each column header. The nested
30
- * checkbox fields back every cell, so the stored document is unaffected.
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, capabilityPaths, cellPath, toolPath } from "../api-keys/capability-matrix.mjs";
4
- import { buildToggleActions, columnState, rowState, toolsState } from "./capability-toggles.mjs";
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
- lastCell: { paddingInlineEnd: EDGE_PADDING },
80
- dash: {
81
- color: "var(--theme-elevation-400)",
82
- cursor: "help"
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 Columns = ({ count }) => /*#__PURE__*/ jsxs("colgroup", { children: [/*#__PURE__*/ jsx("col", { style: { width: LABEL_WIDTH } }), Array.from({ length: count }, (_, index) => /*#__PURE__*/ jsx("col", { style: { width: OPERATION_WIDTH } }, index))] });
95
- const cellStyle = (isLast) => isLast ? {
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
- } : styles.cell;
99
- const Dash = () => /*#__PURE__*/ jsx("span", {
100
- "aria-label": NOT_EXPOSED,
101
- style: styles.dash,
102
- title: NOT_EXPOSED,
103
- children: "—"
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 columns = CAPABILITY_OPERATIONS.filter((operation) => rows.some((row) => row[operation.id]));
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, { count: columns.length }),
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: [/*#__PURE__*/ jsx("th", {
117
- scope: "col",
118
- style: {
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
- ...cellStyle(column === columns.length - 1),
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: /*#__PURE__*/ jsx(Box, {
146
- label: `${operation.label} every ${namespace.title.toLowerCase()} entry`,
147
- onChange: () => {
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: "column",
276
+ kind: "access",
150
277
  namespace: namespace.id,
151
- operation: operation.id,
152
- value: state !== "on"
278
+ level
153
279
  });
154
280
  },
281
+ options: allOptions,
155
282
  readOnly,
156
- state
283
+ selected: allLevel
157
284
  })
158
- }, operation.id);
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 state = rowState(path, namespace.id, row, values);
163
- return /*#__PURE__*/ jsxs("tr", { children: [/*#__PURE__*/ jsxs("th", {
164
- scope: "row",
165
- style: styles.rowHeader,
166
- children: [/*#__PURE__*/ jsx("button", {
167
- className: `${BASE_CLASS}__toggle`,
168
- disabled: readOnly,
169
- onClick: () => {
170
- toggle({
171
- kind: "row",
172
- namespace: namespace.id,
173
- fieldName: row.fieldName,
174
- value: state !== "on"
175
- });
176
- },
177
- style: styles.rowButton,
178
- title: `Toggle every capability for ${row.label}`,
179
- type: "button",
180
- children: row.label
181
- }), row.hint ? /*#__PURE__*/ jsx("span", {
182
- style: styles.hint,
183
- children: row.hint
184
- }) : null]
185
- }), columns.map((operation, column) => /*#__PURE__*/ jsx("td", {
186
- style: cellStyle(column === columns.length - 1),
187
- children: row[operation.id] ? /*#__PURE__*/ jsx(Box, {
188
- label: `${operation.label} ${row.label}`,
189
- onChange: () => {
190
- toggle({
191
- kind: "cell",
192
- namespace: namespace.id,
193
- fieldName: row.fieldName,
194
- operation: operation.id,
195
- value: values[cellPath(path, namespace.id, row.fieldName, operation.id)] !== true
196
- });
197
- },
198
- readOnly,
199
- state: values[cellPath(path, namespace.id, row.fieldName, operation.id)] === true ? "on" : "off"
200
- }) : /*#__PURE__*/ jsx(Dash, {})
201
- }, operation.id))] }, row.fieldName);
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, { count: 1 }),
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
- ...cellStyle(true),
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: cellStyle(true),
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, a
284
- * column per operation and a bulk toggle in each column header. The nested
285
- * checkbox fields back every cell, so the stored document is unaffected.
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 { CAPABILITY_OPERATIONS, cellPath, toolPath } from "../api-keys/capability-matrix.mjs";
1
+ import { STORED_OPERATIONS, cellPath, toolPath } from "../api-keys/capability-matrix.mjs";
2
2
  //#region src/admin/capability-toggles.ts
3
- const stateOf = (paths, values) => {
4
- if (paths.length === 0) return "off";
5
- const granted = paths.filter((path) => values[path] === true).length;
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 === paths.length ? "on" : "mixed";
14
+ return granted === matrix.tools.length ? "on" : "mixed";
8
15
  };
9
16
  /**
10
- * Whether a column's bulk toggle reads as on, off or indeterminate. Cells the
11
- * config does not expose are ignored: two granted cells and one dash read as on.
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
- * Whether an entity's row toggle reads as on, off or indeterminate, over the
16
- * operations the config exposes for that row, deletes aside.
17
- */ const rowState = (basePath, namespace, row, values) => stateOf(ROW_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)), values);
18
- const toolsState = (matrix, basePath, values) => stateOf(matrix.tools.map((tool) => toolPath(basePath, tool.name)), values);
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
- let result = next;
21
- if (touched.has("publish") && result.publish) result = {
22
- ...result,
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 CAPABILITY_OPERATIONS) {
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 CAPABILITY_OPERATIONS.filter((operation) => row[operation.id] && reconciled[operation.id] !== current[operation.id]).map((operation) => ({
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 toggle
73
- * (cell, row, column or tool) comes through here, so the publish rule applies
74
- * to bulk grants too.
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 bulk toggle that changes
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.kind === "column" || row.fieldName === intent.fieldName);
85
- const changes = intent.kind === "row" ? {
86
- read: intent.value,
87
- write: intent.value,
88
- publish: intent.value
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 { buildToggleActions, columnState, rowState, toolsState };
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` renders as a dash, so absence reads as a
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
- * Said only where a row departs from what its column header promises.
19
+ * Writes go live immediately, as the entity keeps no drafts.
21
20
  */
22
- hint?: string;
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 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.";
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 column headers share this wording.
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, each approved by this key's user in the admin panel."
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
- const LIVE_HINT = "Writes go live immediately.";
39
- const hintFor = (entity) => isLiveWrite(entity) ? { hint: LIVE_HINT } : {};
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
- ...hintFor(entity)
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 cell cannot appear without 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) => CAPABILITY_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)))), ...matrix.tools.map((tool) => toolPath(basePath, tool.name))];
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 };
@@ -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, 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: CAPABILITY_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => checkbox(operation.id, operation.description))
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);
@@ -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 publishFlag = (entity, group) => canPublish(entity) && flag(group, "write") && flag(group, "publish");
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: canWrite(entity) && flag(group, "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") ?? true;
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
- ...normalizeDelete(kind, config, settings["delete"])
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 || !(capability.read || capability.write)) return [];
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 || !(capability.read || capability.write)) return [];
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 rather than read, because publish blockers only mean something
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 with its own
52
- * checkbox move documents to trash without approval; it needs `trash`.
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.14",
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",