@abinnovision/payloadcms-mcpx 1.0.0-beta.9 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +328 -211
- package/dist/api-keys/fields.mjs +22 -5
- package/dist/api-keys/setup-guide.mjs +6 -4
- package/dist/auth/resolve.mjs +5 -7
- package/dist/capabilities.mjs +23 -4
- package/dist/client/index.d.mts +2 -2
- package/dist/client/setup-guide.d.mts +1 -1
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
- package/dist/endpoint/handler.mjs +11 -5
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +18 -26
- package/dist/i18n.mjs +4 -15
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -3
- package/dist/options.mjs +30 -26
- package/dist/plugin.mjs +1 -0
- package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
- package/dist/request.mjs +8 -0
- package/dist/result.d.mts +11 -0
- package/dist/result.mjs +20 -0
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +8 -0
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +195 -27
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +77 -30
- package/dist/schema/shape.mjs +133 -51
- package/dist/schema/walk.mjs +44 -64
- package/dist/tools/{index.mjs → builtin.mjs} +8 -5
- package/dist/tools/create-document.mjs +34 -15
- package/dist/tools/describe-schema.mjs +21 -7
- package/dist/tools/find-documents.mjs +13 -6
- package/dist/tools/get-document.mjs +45 -11
- package/dist/tools/list-capabilities.mjs +19 -9
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +32 -21
- package/dist/tools/publish-document.mjs +79 -0
- package/dist/tools/shared.mjs +84 -32
- package/dist/tools/target.mjs +7 -11
- package/dist/tools/validate-document.mjs +20 -12
- package/dist/types.d.mts +110 -42
- package/dist/types.mjs +3 -4
- package/dist/version.mjs +1 -1
- package/dist/write/draft-guard.mjs +47 -44
- package/dist/write/patch.mjs +174 -92
- package/dist/write/publish-blockers.mjs +13 -12
- package/dist/write/publish-intent.mjs +17 -0
- package/dist/write/transaction.mjs +8 -3
- package/package.json +3 -3
- package/dist/i18n.d.mts +0 -1
- package/dist/options.d.mts +0 -2
- package/dist/schema/lexical.d.mts +0 -1
- package/dist/schema/walk.d.mts +0 -3
- package/dist/tools/target.d.mts +0 -3
- package/dist/tools/types.d.mts +0 -5
- package/dist/write/publish-blockers.d.mts +0 -15
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
import { jsonResult } from "../
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
2
2
|
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
3
|
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
4
5
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Gated on write rather than read, because publish blockers only mean
|
|
8
|
+
* something to a caller who can act on them.
|
|
9
|
+
*
|
|
10
|
+
* It reads the document twice on purpose: once under the key's own access to
|
|
11
|
+
* refuse a caller who may not see it, then privileged, so the check runs over
|
|
12
|
+
* every field rather than the subset the user can read. It carries no
|
|
13
|
+
* `readOnlyHint`, because the traversal fires field hooks.
|
|
14
|
+
*/ const validateDocument = defineMcpxTool({
|
|
7
15
|
name: "validateDocument",
|
|
8
16
|
description: `Reports what still prevents a human from publishing the draft, without writing anything. The same list patchDocument returns after a write; use it to check work or to answer "is this ready".
|
|
9
17
|
|
|
10
|
-
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
},
|
|
18
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
19
|
+
|
|
20
|
+
Nothing is written, but the check runs the same field-level beforeValidate and beforeChange hooks a save would, so a hook with side effects fires. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing.`,
|
|
21
|
+
annotations: { openWorldHint: false },
|
|
15
22
|
isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
|
|
16
23
|
inputSchema: (scope) => ({
|
|
17
24
|
...targetShape(scope, "write", {
|
|
@@ -24,7 +31,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
24
31
|
description: "Locale to validate."
|
|
25
32
|
})
|
|
26
33
|
}),
|
|
27
|
-
handler: async (args, scope) => {
|
|
34
|
+
handler: async ({ args, scope }) => {
|
|
28
35
|
const target = resolveTarget(scope, args, "write");
|
|
29
36
|
const id = requireIdFor(target, args.id);
|
|
30
37
|
const locale = localeOf(scope, args.locale);
|
|
@@ -39,7 +46,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
39
46
|
locale,
|
|
40
47
|
privileged: true
|
|
41
48
|
});
|
|
42
|
-
const
|
|
49
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
43
50
|
doc,
|
|
44
51
|
entity: target
|
|
45
52
|
});
|
|
@@ -47,9 +54,10 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
47
54
|
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
48
55
|
status: doc["_status"],
|
|
49
56
|
updatedAt: doc["updatedAt"],
|
|
50
|
-
publishBlockers
|
|
57
|
+
publishBlockers: validation.blockers,
|
|
58
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {}
|
|
51
59
|
});
|
|
52
60
|
}
|
|
53
|
-
};
|
|
61
|
+
});
|
|
54
62
|
//#endregion
|
|
55
63
|
export { validateDocument };
|
package/dist/types.d.mts
CHANGED
|
@@ -12,74 +12,132 @@ declare module "payload" {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* How far an exposed entity lets MCP writes reach.
|
|
16
|
+
*
|
|
17
|
+
* - `false`: no write tool touches it.
|
|
18
|
+
* - `"draft"`: writes land as drafts and nothing MCP does changes what the
|
|
19
|
+
* public sees. Requires `versions.drafts`.
|
|
20
|
+
* - `"live"`: MCP may change live content. On an entity with drafts that means
|
|
21
|
+
* `publishDocument` is exposed; on one without, where there is no draft to
|
|
22
|
+
* land on, it means the write itself is permitted and lands live.
|
|
17
23
|
*/
|
|
24
|
+
type McpxWriteMode = "draft" | "live" | false;
|
|
25
|
+
/** A key can only enable what the config exposes here. */
|
|
18
26
|
interface McpxCollectionOptions {
|
|
19
|
-
/** Expose `describeSchema`, `findDocuments
|
|
27
|
+
/** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
|
|
20
28
|
read?: boolean;
|
|
21
29
|
/**
|
|
22
|
-
* Expose `patchDocument`, `
|
|
23
|
-
* `
|
|
30
|
+
* Expose `patchDocument`, `validateDocument` and, unless this is an upload
|
|
31
|
+
* collection, `createDocument`, and how far those writes reach. Default
|
|
32
|
+
* `false`.
|
|
24
33
|
*/
|
|
25
|
-
write?:
|
|
26
|
-
/**
|
|
27
|
-
* Permit writes to a collection without drafts. Such writes land on the live
|
|
28
|
-
* document because there is no draft to land on. Default `false`.
|
|
29
|
-
*/
|
|
30
|
-
allowLiveWrites?: boolean;
|
|
34
|
+
write?: McpxWriteMode;
|
|
31
35
|
}
|
|
32
|
-
/**
|
|
33
|
-
* What an exposed global offers to MCP clients. Structurally the same as
|
|
34
|
-
* {@link McpxCollectionOptions}, kept separate because the tools it names
|
|
35
|
-
* differ: a global is a singleton, so neither `findDocuments` nor
|
|
36
|
-
* `createDocument` reaches one.
|
|
37
|
-
*/
|
|
36
|
+
/** A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
|
|
38
37
|
interface McpxGlobalOptions {
|
|
39
38
|
/** Expose `describeSchema` and `getDocument`. Default `true`. */
|
|
40
39
|
read?: boolean;
|
|
41
40
|
/**
|
|
42
|
-
* Expose `patchDocument` and `validateDocument
|
|
43
|
-
*
|
|
41
|
+
* Expose `patchDocument` and `validateDocument`, and how far those writes
|
|
42
|
+
* reach. Default `false`.
|
|
44
43
|
*/
|
|
45
|
-
write?:
|
|
46
|
-
/**
|
|
47
|
-
* Permit writes to a global without drafts. Such writes land on the live
|
|
48
|
-
* document because there is no draft to land on. Default `false`.
|
|
49
|
-
*/
|
|
50
|
-
allowLiveWrites?: boolean;
|
|
44
|
+
write?: McpxWriteMode;
|
|
51
45
|
}
|
|
52
46
|
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
47
|
+
/** What the config exposes, before an API key's checkboxes narrow it. */
|
|
48
|
+
interface McpxExposedEntity {
|
|
49
|
+
slug: string;
|
|
50
|
+
read: boolean;
|
|
51
|
+
write: McpxWriteMode;
|
|
52
|
+
hasDrafts: boolean;
|
|
53
|
+
/** An upload document is a file, and no tool here can supply one. */
|
|
54
|
+
isUpload: boolean;
|
|
55
|
+
/** Name of the capability group on the key document. */
|
|
56
|
+
fieldName: string;
|
|
57
|
+
}
|
|
58
|
+
/** What a tool knows about the current request. */
|
|
59
|
+
interface McpxToolScope {
|
|
60
|
+
req: PayloadRequest;
|
|
61
|
+
capabilities: McpxResolvedCapabilities;
|
|
62
|
+
readable: string[];
|
|
63
|
+
writable: string[];
|
|
64
|
+
publishable: string[];
|
|
65
|
+
readableGlobals: string[];
|
|
66
|
+
writableGlobals: string[];
|
|
67
|
+
publishableGlobals: string[];
|
|
68
|
+
/** `null` when localization is off. */
|
|
69
|
+
locales: null | string[];
|
|
70
|
+
defaultLocale: null | string;
|
|
71
|
+
limits: {
|
|
72
|
+
maxLimit: number;
|
|
73
|
+
maxDepth: number;
|
|
74
|
+
};
|
|
75
|
+
exposure: {
|
|
76
|
+
collections: McpxExposedEntity[];
|
|
77
|
+
globals: McpxExposedEntity[];
|
|
78
|
+
};
|
|
79
|
+
}
|
|
53
80
|
/**
|
|
54
|
-
* A
|
|
55
|
-
*
|
|
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.
|
|
56
84
|
*/
|
|
57
|
-
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape
|
|
85
|
+
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
|
|
58
86
|
/** camelCase, unique, not one of the builtin tool names. */
|
|
59
87
|
name: string;
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
/** Built per request so it can state what this key's writes actually do. */
|
|
89
|
+
description: string | ((scope: McpxToolScope) => string);
|
|
62
90
|
annotations?: ToolAnnotations;
|
|
91
|
+
/**
|
|
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.
|
|
95
|
+
*/
|
|
96
|
+
isEnabled?: (scope: McpxToolScope) => boolean;
|
|
97
|
+
/**
|
|
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.
|
|
101
|
+
*/
|
|
102
|
+
inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
|
|
63
103
|
handler(ctx: {
|
|
64
|
-
args:
|
|
104
|
+
args: Args;
|
|
105
|
+
scope: McpxToolScope;
|
|
106
|
+
/** Shorthand for `scope.req`. */
|
|
65
107
|
req: PayloadRequest;
|
|
66
108
|
extra: McpxToolExtra;
|
|
67
109
|
}): CallToolResult | Promise<CallToolResult>;
|
|
68
110
|
}
|
|
111
|
+
/** Argument type erased, so a registry can hold tools of differing shapes. */
|
|
112
|
+
type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
|
|
113
|
+
/** Fixed shape; arguments inferred from it. */
|
|
114
|
+
declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
115
|
+
inputSchema?: Shape;
|
|
116
|
+
}): McpxTool<Shape>;
|
|
117
|
+
/** Per-request shape returned as an object literal; arguments inferred from it. */
|
|
118
|
+
declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
119
|
+
inputSchema: (scope: McpxToolScope) => Shape;
|
|
120
|
+
}): McpxAnyTool;
|
|
69
121
|
/**
|
|
70
|
-
*
|
|
71
|
-
* from
|
|
72
|
-
*/
|
|
73
|
-
declare const defineMcpxTool: <Shape extends z.ZodRawShape>(tool: McpxTool<Shape>) => McpxTool<Shape>;
|
|
74
|
-
/**
|
|
75
|
-
* Outcome of resolving an API key. `user` must carry `collection`.
|
|
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.
|
|
76
124
|
*/
|
|
125
|
+
declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
|
|
126
|
+
inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
|
|
127
|
+
}): McpxAnyTool;
|
|
77
128
|
interface McpxAuthResult {
|
|
129
|
+
/** Must carry `collection`. */
|
|
78
130
|
user: TypedUser;
|
|
79
131
|
apiKeyId: number | string;
|
|
80
132
|
/** The `capabilities` group as stored on the key document. */
|
|
81
133
|
capabilities: unknown;
|
|
82
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
|
+
*/
|
|
83
141
|
type McpxPluginOptions = {
|
|
84
142
|
/** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
|
|
85
143
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
@@ -108,7 +166,7 @@ type McpxPluginOptions = {
|
|
|
108
166
|
/** Upper bound for `depth` on reads. Default 1. */
|
|
109
167
|
maxDepth?: number;
|
|
110
168
|
};
|
|
111
|
-
tools?:
|
|
169
|
+
tools?: McpxAnyTool[];
|
|
112
170
|
auth?: {
|
|
113
171
|
/** Replace or wrap the default key resolution. Return `null` for 401. */
|
|
114
172
|
resolve?: (args: {
|
|
@@ -121,21 +179,31 @@ type McpxPluginOptions = {
|
|
|
121
179
|
version?: string;
|
|
122
180
|
};
|
|
123
181
|
};
|
|
182
|
+
/** What a key may do with one entity. Globals reuse this shape. */
|
|
124
183
|
interface McpxCollectionCapabilities {
|
|
125
184
|
read: boolean;
|
|
126
185
|
write: boolean;
|
|
186
|
+
/** Only ever true where the config sets `write: "live"` and drafts exist. */
|
|
187
|
+
publish: boolean;
|
|
127
188
|
}
|
|
128
|
-
/**
|
|
129
|
-
* Capabilities in force for one request: plugin config AND key checkboxes.
|
|
130
|
-
*/
|
|
189
|
+
/** In force for one request: plugin config AND key checkboxes. */
|
|
131
190
|
interface McpxResolvedCapabilities {
|
|
132
191
|
collections: Record<string, McpxCollectionCapabilities>;
|
|
133
192
|
globals: Record<string, McpxCollectionCapabilities>;
|
|
134
193
|
tools: Record<string, boolean>;
|
|
135
194
|
}
|
|
195
|
+
/** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
|
|
136
196
|
interface McpxRequestContext {
|
|
137
197
|
apiKeyId: number | string;
|
|
138
198
|
capabilities: McpxResolvedCapabilities;
|
|
139
199
|
}
|
|
200
|
+
/** One reason a human could not publish the draft as it stands. */
|
|
201
|
+
interface PublishBlocker {
|
|
202
|
+
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
203
|
+
field?: string;
|
|
204
|
+
message: string;
|
|
205
|
+
/** JSON Pointer to the offending value, e.g. "/layout/2/title". */
|
|
206
|
+
path: string;
|
|
207
|
+
}
|
|
140
208
|
//#endregion
|
|
141
|
-
export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
|
|
209
|
+
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool };
|
package/dist/types.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
//#region src/types.ts
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/ const defineMcpxTool = (tool) => tool;
|
|
2
|
+
function defineMcpxTool(tool) {
|
|
3
|
+
return tool;
|
|
4
|
+
}
|
|
6
5
|
//#endregion
|
|
7
6
|
export { defineMcpxTool };
|
package/dist/version.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import { hasPublishIntent, takePublishIntent } from "./publish-intent.mjs";
|
|
2
|
+
import { isMcpxRequest } from "../request.mjs";
|
|
1
3
|
import { APIError } from "payload";
|
|
2
4
|
import { hasDraftsEnabled } from "payload/shared";
|
|
3
5
|
//#region src/write/draft-guard.ts
|
|
4
|
-
/**
|
|
5
|
-
* Operation arguments that widen or redirect a write. Cleared on every MCP
|
|
6
|
-
* create and update so a tool cannot smuggle them in.
|
|
7
|
-
*/ 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([
|
|
8
7
|
"where",
|
|
9
8
|
"publishAllLocales",
|
|
10
9
|
"publishSpecificLocale",
|
|
@@ -14,26 +13,28 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
14
13
|
"overwriteExistingFiles"
|
|
15
14
|
]);
|
|
16
15
|
/**
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
* same `req`, including those made by custom tools.
|
|
20
|
-
*/ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
|
|
21
|
-
/**
|
|
22
|
-
* Forces every MCP write into a draft save.
|
|
16
|
+
* Forces every MCP write into a draft save, unless it is the one write
|
|
17
|
+
* `publishDocument` asked for.
|
|
23
18
|
*
|
|
24
19
|
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
25
20
|
* `data._status !== "published"`, so `_status` is dropped and left to Payload.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* the
|
|
30
|
-
|
|
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.
|
|
23
|
+
*
|
|
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.
|
|
28
|
+
*/ const scrubWriteArgs = (args, publishing) => {
|
|
31
29
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
32
30
|
if (next["data"] && typeof next["data"] === "object") {
|
|
33
31
|
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
34
|
-
next["data"] =
|
|
32
|
+
next["data"] = publishing ? {
|
|
33
|
+
...data,
|
|
34
|
+
_status: "published"
|
|
35
|
+
} : data;
|
|
35
36
|
}
|
|
36
|
-
next["draft"] =
|
|
37
|
+
next["draft"] = !publishing;
|
|
37
38
|
next["autosave"] = false;
|
|
38
39
|
next["overrideLock"] = false;
|
|
39
40
|
next["trash"] = false;
|
|
@@ -42,52 +43,54 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
42
43
|
const forceDraftWrite = (hookArgs) => {
|
|
43
44
|
const { args, operation, req } = hookArgs;
|
|
44
45
|
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
45
|
-
|
|
46
|
+
const publishing = operation === "update" && hasPublishIntent(args.data);
|
|
47
|
+
return scrubWriteArgs(args, publishing);
|
|
46
48
|
};
|
|
47
49
|
/**
|
|
48
|
-
* The global counterpart of {@link forceDraftWrite}
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* exists, so only `update` is intercepted. `STRIPPED_ARGS` covers the three
|
|
56
|
-
* publish vectors `updateGlobal` accepts; the rest of the set does not exist on
|
|
57
|
-
* that signature and filtering it is a harmless no-op. `slug` survives the
|
|
58
|
-
* filter, so the operation still knows what it is updating.
|
|
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.
|
|
59
57
|
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
60
58
|
const { operation, req } = hookArgs;
|
|
61
59
|
const args = hookArgs.args;
|
|
62
60
|
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
63
|
-
return scrubWriteArgs(args);
|
|
61
|
+
return scrubWriteArgs(args, hasPublishIntent(args["data"]));
|
|
64
62
|
};
|
|
65
63
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*/ const
|
|
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);
|
|
71
70
|
if (!isMcpxRequest(req)) return;
|
|
72
71
|
const status = data._status;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
const expected = publishing ? "published" : "draft";
|
|
73
|
+
if (status === expected) return;
|
|
74
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
|
|
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);
|
|
76
76
|
};
|
|
77
|
-
|
|
78
|
-
|
|
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);
|
|
79
82
|
return data;
|
|
80
83
|
};
|
|
81
|
-
|
|
84
|
+
const refusePublishGlobal = ({ data, global, req }) => {
|
|
82
85
|
const next = data;
|
|
83
|
-
|
|
86
|
+
refuseUnlessExpected(req, global.slug, next);
|
|
84
87
|
return next;
|
|
85
88
|
};
|
|
86
89
|
/**
|
|
87
90
|
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
88
91
|
* (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
|
|
89
92
|
* exist. Applied to the built collection list so nothing can join later
|
|
90
|
-
* without being covered.
|
|
93
|
+
* without being covered. Both are appended last, so a user hook cannot win.
|
|
91
94
|
*/ const installDraftGuards = (collections) => collections.map((collection) => ({
|
|
92
95
|
...collection,
|
|
93
96
|
hooks: {
|
|
@@ -110,4 +113,4 @@ const refusePublish = ({ collection, data, req }) => {
|
|
|
110
113
|
}
|
|
111
114
|
}));
|
|
112
115
|
//#endregion
|
|
113
|
-
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards,
|
|
116
|
+
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };
|