@abinnovision/payloadcms-mcpx 1.0.0-beta.13 → 1.0.0-beta.15
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 +229 -166
- package/dist/api-keys/fields.mjs +7 -3
- package/dist/api-keys/setup-guide.mjs +6 -4
- package/dist/auth/resolve.mjs +1 -3
- package/dist/capabilities.mjs +8 -8
- package/dist/endpoint/errors.mjs +0 -2
- package/dist/endpoint/server.mjs +6 -20
- package/dist/i18n.mjs +4 -15
- package/dist/options.mjs +7 -20
- package/dist/result.d.mts +3 -5
- package/dist/result.mjs +3 -5
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/lexical.mjs +160 -27
- package/dist/schema/pointer.mjs +5 -11
- package/dist/schema/shape.mjs +21 -19
- package/dist/schema/walk.mjs +36 -54
- package/dist/tools/builtin.mjs +4 -6
- package/dist/tools/create-document.mjs +19 -6
- package/dist/tools/describe-schema.mjs +9 -1
- package/dist/tools/find-documents.mjs +8 -1
- package/dist/tools/get-document.mjs +5 -1
- package/dist/tools/list-capabilities.mjs +10 -2
- package/dist/tools/patch-document.mjs +7 -1
- package/dist/tools/publish-document.mjs +13 -15
- package/dist/tools/shared.mjs +39 -37
- package/dist/tools/target.mjs +3 -7
- package/dist/tools/validate-document.mjs +9 -1
- package/dist/types.d.mts +38 -77
- package/dist/write/draft-guard.mjs +33 -65
- package/dist/write/patch.mjs +22 -60
- package/dist/write/publish-blockers.mjs +6 -12
- package/dist/write/publish-intent.mjs +13 -35
- package/dist/write/transaction.mjs +2 -3
- package/package.json +1 -1
package/dist/types.d.mts
CHANGED
|
@@ -22,27 +22,18 @@ declare module "payload" {
|
|
|
22
22
|
* land on, it means the write itself is permitted and lands live.
|
|
23
23
|
*/
|
|
24
24
|
type McpxWriteMode = "draft" | "live" | false;
|
|
25
|
-
/**
|
|
26
|
-
* What an exposed collection offers to MCP clients. A key can only enable
|
|
27
|
-
* what the config exposes here.
|
|
28
|
-
*/
|
|
25
|
+
/** A key can only enable what the config exposes here. */
|
|
29
26
|
interface McpxCollectionOptions {
|
|
30
|
-
/**
|
|
31
|
-
* Expose `describeSchema`, `findDocuments` and `getDocument`. Default `true`.
|
|
32
|
-
*/
|
|
27
|
+
/** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
|
|
33
28
|
read?: boolean;
|
|
34
29
|
/**
|
|
35
|
-
* Expose `patchDocument`, `
|
|
36
|
-
* far those writes reach. Default
|
|
30
|
+
* Expose `patchDocument`, `validateDocument` and, unless this is an upload
|
|
31
|
+
* collection, `createDocument`, and how far those writes reach. Default
|
|
32
|
+
* `false`.
|
|
37
33
|
*/
|
|
38
34
|
write?: McpxWriteMode;
|
|
39
35
|
}
|
|
40
|
-
/**
|
|
41
|
-
* What an exposed global offers to MCP clients. Structurally the same as
|
|
42
|
-
* {@link McpxCollectionOptions}, kept separate because the tools it names
|
|
43
|
-
* differ: a global is a singleton, so neither `findDocuments` nor
|
|
44
|
-
* `createDocument` reaches one.
|
|
45
|
-
*/
|
|
36
|
+
/** A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
|
|
46
37
|
interface McpxGlobalOptions {
|
|
47
38
|
/** Expose `describeSchema` and `getDocument`. Default `true`. */
|
|
48
39
|
read?: boolean;
|
|
@@ -53,77 +44,60 @@ interface McpxGlobalOptions {
|
|
|
53
44
|
write?: McpxWriteMode;
|
|
54
45
|
}
|
|
55
46
|
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
56
|
-
/**
|
|
57
|
-
* A collection or global the plugin config exposes, before an API key's
|
|
58
|
-
* checkboxes narrow it further.
|
|
59
|
-
*/
|
|
47
|
+
/** What the config exposes, before an API key's checkboxes narrow it. */
|
|
60
48
|
interface McpxExposedEntity {
|
|
61
49
|
slug: string;
|
|
62
50
|
read: boolean;
|
|
63
51
|
write: McpxWriteMode;
|
|
64
52
|
hasDrafts: boolean;
|
|
53
|
+
/** An upload document is a file, and no tool here can supply one. */
|
|
54
|
+
isUpload: boolean;
|
|
65
55
|
/** Name of the capability group on the key document. */
|
|
66
56
|
fieldName: string;
|
|
67
57
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Everything a tool knows about the current request: the authenticated
|
|
70
|
-
* request, what this key may touch and the limits in force.
|
|
71
|
-
*/
|
|
58
|
+
/** What a tool knows about the current request. */
|
|
72
59
|
interface McpxToolScope {
|
|
73
60
|
req: PayloadRequest;
|
|
74
61
|
capabilities: McpxResolvedCapabilities;
|
|
75
|
-
/** Collection slugs the key may read / write / publish. */
|
|
76
62
|
readable: string[];
|
|
77
63
|
writable: string[];
|
|
78
64
|
publishable: string[];
|
|
79
|
-
/** Global slugs the key may read / write / publish. */
|
|
80
65
|
readableGlobals: string[];
|
|
81
66
|
writableGlobals: string[];
|
|
82
67
|
publishableGlobals: string[];
|
|
83
|
-
/**
|
|
68
|
+
/** `null` when localization is off. */
|
|
84
69
|
locales: null | string[];
|
|
85
70
|
defaultLocale: null | string;
|
|
86
71
|
limits: {
|
|
87
72
|
maxLimit: number;
|
|
88
73
|
maxDepth: number;
|
|
89
74
|
};
|
|
90
|
-
/** What the plugin config exposes, before the key's checkboxes apply. */
|
|
91
75
|
exposure: {
|
|
92
76
|
collections: McpxExposedEntity[];
|
|
93
77
|
globals: McpxExposedEntity[];
|
|
94
78
|
};
|
|
95
79
|
}
|
|
96
80
|
/**
|
|
97
|
-
* A tool
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* `Args` only needs stating when `inputSchema` is built per request, which
|
|
102
|
-
* leaves no static shape to infer from; a tool with a fixed shape gets its
|
|
103
|
-
* argument type from that shape.
|
|
81
|
+
* A tool, builtin or custom. Runs with `req.user` resolved from the key and
|
|
82
|
+
* `req.context.mcpx` set. `Args` only needs stating when `inputSchema` is built
|
|
83
|
+
* per request, leaving no static shape to infer from.
|
|
104
84
|
*/
|
|
105
85
|
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
|
|
106
86
|
/** camelCase, unique, not one of the builtin tool names. */
|
|
107
87
|
name: string;
|
|
108
|
-
/**
|
|
109
|
-
* Fixed text, or text built per request so it can state what this key's
|
|
110
|
-
* writes actually do.
|
|
111
|
-
*/
|
|
88
|
+
/** Built per request so it can state what this key's writes actually do. */
|
|
112
89
|
description: string | ((scope: McpxToolScope) => string);
|
|
113
90
|
annotations?: ToolAnnotations;
|
|
114
91
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* collection and global capabilities. Defining it replaces that checkbox
|
|
119
|
-
* check rather than adding to it.
|
|
92
|
+
* A tool that is not enabled never appears in `tools/list`. Defaults to the
|
|
93
|
+
* tool's own checkbox on the API key; defining it replaces that check rather
|
|
94
|
+
* than adding to it.
|
|
120
95
|
*/
|
|
121
96
|
isEnabled?: (scope: McpxToolScope) => boolean;
|
|
122
97
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* it had not been passed.
|
|
98
|
+
* Built per request so enums can be narrowed to what the key may touch.
|
|
99
|
+
* Registered strictly either way: an unknown argument is rejected by name
|
|
100
|
+
* rather than stripped.
|
|
127
101
|
*/
|
|
128
102
|
inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
|
|
129
103
|
handler(ctx: {
|
|
@@ -134,44 +108,36 @@ interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z
|
|
|
134
108
|
extra: McpxToolExtra;
|
|
135
109
|
}): CallToolResult | Promise<CallToolResult>;
|
|
136
110
|
}
|
|
137
|
-
/**
|
|
138
|
-
* A tool with its argument type erased, which is how a registry holds tools of
|
|
139
|
-
* differing input shapes. Each tool validates its own arguments through its
|
|
140
|
-
* input schema.
|
|
141
|
-
*/
|
|
111
|
+
/** Argument type erased, so a registry can hold tools of differing shapes. */
|
|
142
112
|
type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
|
|
143
|
-
/**
|
|
144
|
-
* Defines a tool with a fixed input shape. The handler's arguments are
|
|
145
|
-
* inferred from that shape.
|
|
146
|
-
*/
|
|
113
|
+
/** Fixed shape; arguments inferred from it. */
|
|
147
114
|
declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
148
115
|
inputSchema?: Shape;
|
|
149
116
|
}): McpxTool<Shape>;
|
|
150
|
-
/**
|
|
151
|
-
* Defines a tool whose input shape is built per request and returned as an
|
|
152
|
-
* object literal. The handler's arguments are inferred from that literal, so
|
|
153
|
-
* a scope-narrowed enum still types as the value it produces.
|
|
154
|
-
*/
|
|
117
|
+
/** Per-request shape returned as an object literal; arguments inferred from it. */
|
|
155
118
|
declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
156
119
|
inputSchema: (scope: McpxToolScope) => Shape;
|
|
157
120
|
}): McpxAnyTool;
|
|
158
121
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* handler's arguments are stated instead: `defineMcpxTool<Args>({ ... })`.
|
|
122
|
+
* Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
|
|
123
|
+
* builtins do. Nothing to infer from, so state the arguments instead.
|
|
162
124
|
*/
|
|
163
125
|
declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
|
|
164
126
|
inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
|
|
165
127
|
}): McpxAnyTool;
|
|
166
|
-
/**
|
|
167
|
-
* Outcome of resolving an API key. `user` must carry `collection`.
|
|
168
|
-
*/
|
|
169
128
|
interface McpxAuthResult {
|
|
129
|
+
/** Must carry `collection`. */
|
|
170
130
|
user: TypedUser;
|
|
171
131
|
apiKeyId: number | string;
|
|
172
132
|
/** The `capabilities` group as stored on the key document. */
|
|
173
133
|
capabilities: unknown;
|
|
174
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Everything the plugin accepts. `collections` is the only required option.
|
|
137
|
+
*
|
|
138
|
+
* A type alias rather than an interface: `definePlugin` constrains its options
|
|
139
|
+
* to `Record<string, unknown>`, which interfaces do not satisfy.
|
|
140
|
+
*/
|
|
175
141
|
type McpxPluginOptions = {
|
|
176
142
|
/** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
|
|
177
143
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
@@ -213,30 +179,25 @@ type McpxPluginOptions = {
|
|
|
213
179
|
version?: string;
|
|
214
180
|
};
|
|
215
181
|
};
|
|
182
|
+
/** What a key may do with one entity. Globals reuse this shape. */
|
|
216
183
|
interface McpxCollectionCapabilities {
|
|
217
184
|
read: boolean;
|
|
218
185
|
write: boolean;
|
|
219
|
-
/**
|
|
220
|
-
* Whether the key may publish this entity's draft. Only ever true where the
|
|
221
|
-
* config sets `write: "live"` and the entity has drafts.
|
|
222
|
-
*/
|
|
186
|
+
/** Only ever true where the config sets `write: "live"` and drafts exist. */
|
|
223
187
|
publish: boolean;
|
|
224
188
|
}
|
|
225
|
-
/**
|
|
226
|
-
* Capabilities in force for one request: plugin config AND key checkboxes.
|
|
227
|
-
*/
|
|
189
|
+
/** In force for one request: plugin config AND key checkboxes. */
|
|
228
190
|
interface McpxResolvedCapabilities {
|
|
229
191
|
collections: Record<string, McpxCollectionCapabilities>;
|
|
230
192
|
globals: Record<string, McpxCollectionCapabilities>;
|
|
231
193
|
tools: Record<string, boolean>;
|
|
232
194
|
}
|
|
195
|
+
/** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
|
|
233
196
|
interface McpxRequestContext {
|
|
234
197
|
apiKeyId: number | string;
|
|
235
198
|
capabilities: McpxResolvedCapabilities;
|
|
236
199
|
}
|
|
237
|
-
/**
|
|
238
|
-
* One reason a human could not publish the draft as it stands.
|
|
239
|
-
*/
|
|
200
|
+
/** One reason a human could not publish the draft as it stands. */
|
|
240
201
|
interface PublishBlocker {
|
|
241
202
|
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
242
203
|
field?: string;
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { hasPublishIntent, takePublishIntent } from "./publish-intent.mjs";
|
|
2
2
|
import { isMcpxRequest } from "../request.mjs";
|
|
3
3
|
import { APIError } from "payload";
|
|
4
4
|
import { hasDraftsEnabled } from "payload/shared";
|
|
5
5
|
//#region src/write/draft-guard.ts
|
|
6
|
-
/**
|
|
7
|
-
* Operation arguments that widen or redirect a write. Cleared on every MCP
|
|
8
|
-
* create and update, publishes included, so a tool cannot smuggle them in.
|
|
9
|
-
*/ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
6
|
+
/** Cleared on every MCP write, publishes included, so none can be smuggled in. */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
10
7
|
"where",
|
|
11
8
|
"publishAllLocales",
|
|
12
9
|
"publishSpecificLocale",
|
|
@@ -21,19 +18,13 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
21
18
|
*
|
|
22
19
|
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
23
20
|
* `data._status !== "published"`, so `_status` is dropped and left to Payload.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* builtin tools. Deletes are not guarded in v1; custom tools that delete are
|
|
27
|
-
* the integrator's responsibility. `restoreVersion` and `duplicate` are outside
|
|
28
|
-
* the operation filter too — `restoreVersion` is caught by `refusePublish`
|
|
29
|
-
* because it runs the collection's `beforeChange` hooks, and anything going
|
|
30
|
-
* straight to `payload.db` bypasses all of this.
|
|
21
|
+
* Writing it here rather than in the tool keeps the tool honest, since this is
|
|
22
|
+
* the only thing that can grant a publish.
|
|
31
23
|
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* publish, and this is the only thing that can grant it.
|
|
24
|
+
* Not covered: deletes, `duplicate`, files (the local API lifts `file` and
|
|
25
|
+
* `filePath` onto `req` before this runs), and anything going straight to
|
|
26
|
+
* `payload.db`. `restoreVersion` is caught by {@link refusePublish} instead,
|
|
27
|
+
* because it runs the collection's `beforeChange` hooks.
|
|
37
28
|
*/ const scrubWriteArgs = (args, publishing) => {
|
|
38
29
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
39
30
|
if (next["data"] && typeof next["data"] === "object") {
|
|
@@ -50,72 +41,49 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
50
41
|
return next;
|
|
51
42
|
};
|
|
52
43
|
const forceDraftWrite = (hookArgs) => {
|
|
53
|
-
const { args,
|
|
44
|
+
const { args, operation, req } = hookArgs;
|
|
54
45
|
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
55
|
-
const publishing = operation === "update" &&
|
|
56
|
-
kind: "collection",
|
|
57
|
-
slug: collection.slug,
|
|
58
|
-
id: args.id
|
|
59
|
-
});
|
|
46
|
+
const publishing = operation === "update" && hasPublishIntent(args.data);
|
|
60
47
|
return scrubWriteArgs(args, publishing);
|
|
61
48
|
};
|
|
62
49
|
/**
|
|
63
|
-
* The global counterpart of {@link forceDraftWrite}, with one
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* {@link refusePublishGlobal} with no status and is refused there. The alarm,
|
|
71
|
-
* not the correction, is load-bearing for globals.
|
|
72
|
-
*
|
|
73
|
-
* The publish branch matters for the same reason. `publishDocument` passes
|
|
74
|
-
* `draft: false` at the call site because the hook cannot, and this hook must
|
|
75
|
-
* put `_status` back rather than strip it.
|
|
76
|
-
*
|
|
77
|
-
* The global operation union has no `create` member because a global always
|
|
78
|
-
* exists, so only `update` is intercepted.
|
|
50
|
+
* The global counterpart of {@link forceDraftWrite}, with one difference that
|
|
51
|
+
* decides where the guarantee lives: `updateGlobal` destructures `draft` and
|
|
52
|
+
* the publish arguments *before* it runs `beforeOperation` and re-reads only
|
|
53
|
+
* `data` afterwards, so setting them here is a no-op. What lands is `data` with
|
|
54
|
+
* `_status` stripped, which makes {@link refusePublishGlobal} the alarm that
|
|
55
|
+
* actually holds the line. `publishDocument` therefore passes `draft: false` at
|
|
56
|
+
* the call site, and this hook puts `_status` back rather than stripping it.
|
|
79
57
|
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
80
|
-
const {
|
|
58
|
+
const { operation, req } = hookArgs;
|
|
81
59
|
const args = hookArgs.args;
|
|
82
60
|
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
83
|
-
|
|
84
|
-
kind: "global",
|
|
85
|
-
slug: global.slug
|
|
86
|
-
});
|
|
87
|
-
return scrubWriteArgs(args, publishing);
|
|
61
|
+
return scrubWriteArgs(args, hasPublishIntent(args["data"]));
|
|
88
62
|
};
|
|
89
63
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* `beforeChange` hook runs.
|
|
97
|
-
*/ const refuseUnlessExpected = (req, target, data) => {
|
|
64
|
+
* Throws instead of correcting `_status`, because Payload has already chosen
|
|
65
|
+
* the write branch by the time a `beforeChange` hook runs. Unreachable for a
|
|
66
|
+
* collection if {@link forceDraftWrite} did its job; the guarantee itself for a
|
|
67
|
+
* global. Last hook that needs the marker, so it takes it off.
|
|
68
|
+
*/ const refuseUnlessExpected = (req, slug, data) => {
|
|
69
|
+
const publishing = takePublishIntent(data);
|
|
98
70
|
if (!isMcpxRequest(req)) return;
|
|
99
71
|
const status = data._status;
|
|
100
|
-
const publishing = isClaimedPublish(target.kind, target.slug);
|
|
101
72
|
const expected = publishing ? "published" : "draft";
|
|
102
73
|
if (status === expected) return;
|
|
103
|
-
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${
|
|
74
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
|
|
104
75
|
throw new APIError(publishing ? "This publish was refused because it would not have saved a published document." : "MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
|
|
105
76
|
};
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Installs {@link refuseUnlessExpected} on every collection write. Returns
|
|
79
|
+
* `data` unchanged when the write is allowed; the hook exists for its throw.
|
|
80
|
+
*/ const refusePublish = ({ collection, data, req }) => {
|
|
81
|
+
refuseUnlessExpected(req, collection.slug, data);
|
|
111
82
|
return data;
|
|
112
83
|
};
|
|
113
|
-
|
|
84
|
+
const refusePublishGlobal = ({ data, global, req }) => {
|
|
114
85
|
const next = data;
|
|
115
|
-
refuseUnlessExpected(req,
|
|
116
|
-
kind: "global",
|
|
117
|
-
slug: global.slug
|
|
118
|
-
}, next);
|
|
86
|
+
refuseUnlessExpected(req, global.slug, next);
|
|
119
87
|
return next;
|
|
120
88
|
};
|
|
121
89
|
/**
|
package/dist/write/patch.mjs
CHANGED
|
@@ -6,10 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { Pointer, applyPatch } from "rfc6902";
|
|
7
7
|
//#region src/write/patch.ts
|
|
8
8
|
const POINTER = z.string().regex(JSON_POINTER_PATTERN);
|
|
9
|
-
/**
|
|
10
|
-
* One RFC 6902 operation as accepted by `patchDocument`. Discriminated on `op`
|
|
11
|
-
* so an operation carries only the members RFC 6902 defines for it.
|
|
12
|
-
*/ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
|
|
9
|
+
/** Discriminated on `op`, so an operation carries only its own members. */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
|
|
13
10
|
z.strictObject({
|
|
14
11
|
op: z.literal("add"),
|
|
15
12
|
path: POINTER,
|
|
@@ -40,31 +37,18 @@ const POINTER = z.string().regex(JSON_POINTER_PATTERN);
|
|
|
40
37
|
value: z.unknown()
|
|
41
38
|
})
|
|
42
39
|
]).describe("An RFC 6902 operation.");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*/ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
|
|
46
|
-
/**
|
|
47
|
-
* The pointer an operation removes a value from, if it removes one at all.
|
|
48
|
-
*/ const droppedPointer = (operation) => {
|
|
40
|
+
const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
|
|
41
|
+
const droppedPointer = (operation) => {
|
|
49
42
|
if (operation.op === "remove") return operation.path;
|
|
50
43
|
return operation.op === "move" ? operation.from : void 0;
|
|
51
44
|
};
|
|
52
|
-
|
|
53
|
-
* Whether a pointer addresses a list element rather than a field.
|
|
54
|
-
*/ const isElementPointer = (pointer) => {
|
|
45
|
+
const isElementPointer = (pointer) => {
|
|
55
46
|
const last = pointer.split("/").pop() ?? "";
|
|
56
47
|
return last === "-" || /^\d+$/.test(last);
|
|
57
48
|
};
|
|
58
49
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
* descended into.
|
|
62
|
-
*/ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
|
|
63
|
-
/**
|
|
64
|
-
* Visits every row in a value: a plain object that carries `blockType` or
|
|
65
|
-
* sits directly inside an array. Rich text states manage their own nodes and
|
|
66
|
-
* are never descended into.
|
|
67
|
-
*/ const walkRows = (value, visit, isRow = false) => {
|
|
50
|
+
/** Its nodes manage their own ids, so it is never descended into. */ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
|
|
51
|
+
/** A row is a plain object carrying `blockType`, or one sitting in an array. */ const walkRows = (value, visit, isRow = false) => {
|
|
68
52
|
if (Array.isArray(value)) {
|
|
69
53
|
for (const entry of value) walkRows(entry, visit, true);
|
|
70
54
|
return;
|
|
@@ -97,10 +81,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
97
81
|
delete row["id"];
|
|
98
82
|
});
|
|
99
83
|
};
|
|
100
|
-
/**
|
|
101
|
-
* Drops every row id from a copy of `value`. Used on create, where no stored
|
|
102
|
-
* row exists and any incoming id is client-invented.
|
|
103
|
-
*/ const stripRowIds = (value) => {
|
|
84
|
+
/** For create, where no stored row exists and any incoming id is invented. */ const stripRowIds = (value) => {
|
|
104
85
|
const next = structuredClone(value);
|
|
105
86
|
walkRows(next, (row) => {
|
|
106
87
|
delete row["id"];
|
|
@@ -122,34 +103,24 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
122
103
|
op: "add"
|
|
123
104
|
} : cloned;
|
|
124
105
|
};
|
|
125
|
-
/**
|
|
126
|
-
* The value an operation writes at its path: the one it carries, or the one it
|
|
127
|
-
* takes from `from`. A `remove` writes nothing.
|
|
128
|
-
*/ const effectiveValue = (operation, doc) => {
|
|
106
|
+
/** The one it carries, or the one it takes from `from`. `remove` writes nothing. */ const effectiveValue = (operation, doc) => {
|
|
129
107
|
if ("value" in operation) return operation.value;
|
|
130
108
|
return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
|
|
131
109
|
};
|
|
132
|
-
/**
|
|
133
|
-
* Whether a resolved pointer lands in a read-only field. A pointer that stops
|
|
134
|
-
* short of one addresses a subtree, and the fields beneath it decide.
|
|
135
|
-
*/ const resolvesReadOnly = (resolution) => {
|
|
110
|
+
/** A pointer stopping short addresses a subtree; the fields beneath decide. */ const resolvesReadOnly = (resolution) => {
|
|
136
111
|
if (resolution.descriptor) return resolution.descriptor.readOnly === true;
|
|
137
112
|
const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
|
|
138
113
|
return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
|
|
139
114
|
};
|
|
140
|
-
/**
|
|
141
|
-
* Whether the pointer addresses something read-only. An element carries no
|
|
142
|
-
* descriptor of its own, so the field it belongs to is read one segment up.
|
|
143
|
-
*/ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
|
|
115
|
+
/** An element has no descriptor, so its field is read one segment up. */ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
|
|
144
116
|
doc: target.doc,
|
|
145
117
|
pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
|
|
146
118
|
ref: target.ref
|
|
147
119
|
}));
|
|
148
120
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* in a read-only field.
|
|
121
|
+
* Checked against the document as it stands when this operation runs: both
|
|
122
|
+
* pointers must resolve, the written value must pass write validation, and what
|
|
123
|
+
* it drops must not sit in a read-only field.
|
|
153
124
|
*/ const findOperationProblems = (config, target) => {
|
|
154
125
|
const { doc, operation, ref } = target;
|
|
155
126
|
const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
|
|
@@ -191,13 +162,10 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
191
162
|
}
|
|
192
163
|
};
|
|
193
164
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* the
|
|
197
|
-
*
|
|
198
|
-
* The copy means a failing operation leaves the original untouched, and the
|
|
199
|
-
* caller writes nothing unless the whole batch came back applied, so a
|
|
200
|
-
* partially applied batch is never persisted.
|
|
165
|
+
* One evolving copy, so an operation depending on an earlier one resolves
|
|
166
|
+
* against the shape it actually modifies, and a failure leaves the original
|
|
167
|
+
* untouched. The caller writes nothing unless the whole batch came back
|
|
168
|
+
* applied, so a partial batch is never persisted.
|
|
201
169
|
*/ const applyPatchOperations = (config, target) => {
|
|
202
170
|
const next = structuredClone(target.doc);
|
|
203
171
|
for (const [index, operation] of target.patches.entries()) {
|
|
@@ -214,18 +182,15 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
214
182
|
reconcileRowIds(next, target.doc);
|
|
215
183
|
return { next };
|
|
216
184
|
};
|
|
217
|
-
|
|
218
|
-
* Keys Payload manages on a row that travel back into the write unchanged.
|
|
219
|
-
*/ const ROW_KEYS = /* @__PURE__ */ new Set([
|
|
185
|
+
const ROW_KEYS = /* @__PURE__ */ new Set([
|
|
220
186
|
"blockName",
|
|
221
187
|
"blockType",
|
|
222
188
|
"id"
|
|
223
189
|
]);
|
|
224
190
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* is left out, so the write-back carries only what a client could have set.
|
|
191
|
+
* Everything Payload maintains or derives (`_status`, timestamps, join and
|
|
192
|
+
* virtual fields, upload base fields) is left out, so the write-back carries
|
|
193
|
+
* only what a client could have set.
|
|
229
194
|
*/ const pickDescribed = (config, value, at) => {
|
|
230
195
|
const { fields, prefix, isRow } = at;
|
|
231
196
|
const relative = describeAddressableFields(fields).flatMap((descriptor) => {
|
|
@@ -279,10 +244,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
279
244
|
}
|
|
280
245
|
return result;
|
|
281
246
|
};
|
|
282
|
-
/**
|
|
283
|
-
* The data handed to `payload.update` after a patch: the patched document
|
|
284
|
-
* reduced to the fields the client may write, plus row identity keys.
|
|
285
|
-
*/ const buildWriteData = (config, target, doc) => {
|
|
247
|
+
/** The patched document reduced to writable fields, plus row identity keys. */ const buildWriteData = (config, target, doc) => {
|
|
286
248
|
return pickDescribed(config, doc, {
|
|
287
249
|
fields: target.flattenedFields,
|
|
288
250
|
prefix: [],
|
|
@@ -5,23 +5,17 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
5
5
|
/**
|
|
6
6
|
* Runs Payload's own field validation over a draft without saving anything.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
* instead of throwing. The `beforeValidate` pass runs first because some field
|
|
13
|
-
* hooks (Lexical's) prepare state in `context` that their `beforeChange`
|
|
14
|
-
* counterpart depends on.
|
|
8
|
+
* The same traversal a real save runs, exported from `payload` and called with
|
|
9
|
+
* `skipValidation: false` so it collects into `errors` instead of throwing. The
|
|
10
|
+
* `beforeValidate` pass runs first because some field hooks (Lexical's) prepare
|
|
11
|
+
* state in `context` that their `beforeChange` counterpart depends on.
|
|
15
12
|
*
|
|
16
13
|
* Nothing is written: `data` is a copy, the context is a scratch copy and the
|
|
17
14
|
* locale merge actions are discarded. `overrideAccess` is true because the
|
|
18
15
|
* question is "could this be published", not "may this client write it".
|
|
19
16
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* `unavailable` marks a traversal that threw, which is not the same answer as
|
|
24
|
-
* a document with nothing wrong with it.
|
|
17
|
+
* `unavailable` marks a traversal that threw, which is not the same answer as a
|
|
18
|
+
* document with nothing wrong with it.
|
|
25
19
|
*/ const collectPublishBlockers = async (req, target) => {
|
|
26
20
|
const { doc, entity } = target;
|
|
27
21
|
const id = doc["id"];
|
|
@@ -1,39 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
2
|
//#region src/write/publish-intent.ts
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* re-entrant write to the same document — an `afterChange` hook calling
|
|
15
|
-
* `payload.update`, say — cannot ride along on it. Only the first operation to
|
|
16
|
-
* ask gets it.
|
|
17
|
-
*/ const claimPublishIntent = (target) => {
|
|
18
|
-
const active = activeFor(target);
|
|
19
|
-
if (!active || active.claimed) return false;
|
|
20
|
-
active.claimed = true;
|
|
3
|
+
const PUBLISH_INTENT = "__mcpxPublishIntent";
|
|
4
|
+
const TOKEN = randomUUID();
|
|
5
|
+
const withPublishIntent = (data) => ({
|
|
6
|
+
...data,
|
|
7
|
+
[PUBLISH_INTENT]: TOKEN
|
|
8
|
+
});
|
|
9
|
+
const carries = (data) => typeof data === "object" && data !== null && data[PUBLISH_INTENT] === TOKEN;
|
|
10
|
+
const hasPublishIntent = (data) => carries(data);
|
|
11
|
+
/** Asked by the last hook that needs it, so it takes the marker off. */ const takePublishIntent = (data) => {
|
|
12
|
+
if (!carries(data)) return false;
|
|
13
|
+
delete data[PUBLISH_INTENT];
|
|
21
14
|
return true;
|
|
22
15
|
};
|
|
23
|
-
/**
|
|
24
|
-
* Whether this change belongs to the operation that claimed the intent, which
|
|
25
|
-
* is what lets the `beforeChange` alarm accept a published status.
|
|
26
|
-
*
|
|
27
|
-
* The id is deliberately not compared here. A `beforeChange` hook reads it from
|
|
28
|
-
* the loaded document, where Payload has already coerced it to the collection's
|
|
29
|
-
* id type, while the claim above saw the raw tool argument; comparing the two
|
|
30
|
-
* would refuse a legitimate publish over `1` versus `"1"`. Nothing is lost: a
|
|
31
|
-
* nested write to another document of the same collection during the publish
|
|
32
|
-
* cannot claim the intent, so `forceDraftWrite` has already stripped its
|
|
33
|
-
* `_status` and it fails the alarm on that.
|
|
34
|
-
*/ const isClaimedPublish = (kind, slug) => {
|
|
35
|
-
const active = store.getStore();
|
|
36
|
-
return active?.kind === kind && active.slug === slug && active.claimed;
|
|
37
|
-
};
|
|
38
16
|
//#endregion
|
|
39
|
-
export {
|
|
17
|
+
export { hasPublishIntent, takePublishIntent, withPublishIntent };
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { commitTransaction, initTransaction, killTransaction } from "payload";
|
|
2
2
|
//#region src/write/transaction.ts
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* support, or a request that already owns one, run `fn` as is.
|
|
4
|
+
* Adapters without transaction support, or a request that already owns one, run
|
|
5
|
+
* `fn` as is.
|
|
7
6
|
*
|
|
8
7
|
* Atomicity, not isolation: neither SQLite nor Postgres at read committed locks
|
|
9
8
|
* the row on the read, so an `expectedUpdatedAt` check remains best effort. Nor
|
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": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.15",
|
|
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",
|