@abinnovision/payloadcms-mcpx 1.0.0-beta.4 → 1.0.0-beta.5

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
@@ -44,6 +44,9 @@ export default buildConfig({
44
44
  posts: { read: true, write: true },
45
45
  tags: true, // shorthand for { read: true }
46
46
  },
47
+ globals: {
48
+ "site-settings": { read: true, write: true },
49
+ },
47
50
  limits: { maxLimit: 25, maxDepth: 1 },
48
51
  }),
49
52
  ],
@@ -56,8 +59,8 @@ The plugin adds:
56
59
  JSON responses; `GET`/`DELETE` answer 405),
57
60
  - an `mcpx-api-keys` collection (admin group "MCP") holding the keys and their
58
61
  capability checkboxes,
59
- - a draft guard on every collection, so any write carrying the MCP request
60
- marker lands as a draft, including writes made by custom tools.
62
+ - a draft guard on every collection and global, so any write carrying the MCP
63
+ request marker lands as a draft, including writes made by custom tools.
61
64
 
62
65
  ## API keys
63
66
 
@@ -118,19 +121,20 @@ Claude Desktop (no direct HTTP header support) via `mcp-remote`:
118
121
 
119
122
  ## Tools
120
123
 
121
- The surface is fixed at seven tools plus your custom ones. `tools/list`
122
- reflects the key: write tools disappear for read-only keys, and every
123
- `collection` enum contains only the slugs the key may touch.
124
+ The surface is fixed at seven tools plus your custom ones; exposing a global
125
+ adds an argument, never a tool. `tools/list` reflects the key: write tools
126
+ disappear for read-only keys, and every `collection` and `global` enum contains
127
+ only the slugs the key may touch.
124
128
 
125
129
  | Tool | Purpose | Key arguments |
126
130
  | ------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
127
131
  | `listCapabilities` | What this key may do; call first to orient. | none |
128
- | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection`, `paths?`, `expand?` |
132
+ | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection` \| `global`, `paths?`, `expand?` |
129
133
  | `findDocuments` | Query documents. | `collection`, `where?`, `sort?`, `limit?`, `page?`, `depth?`, `select?`, `locale?`, `draft?` |
130
- | `getDocument` | Read one document or a subtree of it. | `collection`, `id`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
131
- | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection`, `id`, `locale`, `patches`, `expectedUpdatedAt?` |
134
+ | `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
135
+ | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
132
136
  | `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
133
- | `validateDocument` | Publish blockers without writing. | `collection`, `id`, `locale` |
137
+ | `validateDocument` | Publish blockers without writing. | `collection` + `id` \| `global`, `locale` |
134
138
 
135
139
  Rules the tools enforce and explain in their own descriptions:
136
140
 
@@ -161,13 +165,55 @@ Rules the tools enforce and explain in their own descriptions:
161
165
  `deletedAt`) are never listed and never writable; `readOnly` fields are
162
166
  listed but refused on write.
163
167
 
168
+ ## Globals
169
+
170
+ A global is exposed the same way a collection is, and reached through the same
171
+ tools rather than tools of its own:
172
+
173
+ ```ts
174
+ mcpxPlugin({
175
+ collections: { pages: { read: true, write: true } },
176
+ globals: { "site-settings": { read: true, write: true } },
177
+ });
178
+ ```
179
+
180
+ Two rules follow from a global being a singleton, and because JSON Schema cannot
181
+ state either one, both are enforced in the handler and repeated in every
182
+ affected tool description:
183
+
184
+ - Pass exactly **one** of `collection` and `global`.
185
+ - `id` is required with `collection` and must be omitted with `global`.
186
+
187
+ Refusals name the offending argument and the slug, so one failed call teaches
188
+ the rule. `findDocuments` and `createDocument` stay collection-only: there is
189
+ nothing to list and nothing to create when the document always exists. They
190
+ reject a `global` argument by name.
191
+
192
+ Globals get their own `capabilities.globals.<name>` checkbox group, a separate
193
+ namespace from `capabilities.collections.<name>`, so a global may share a
194
+ camelCase name with a collection. Keys issued before a global was exposed have
195
+ no such group, and an absent checkbox reads as `false`, so they stay closed to
196
+ every global until one is ticked.
197
+
198
+ Globals always carry `updatedAt` — Payload appends it and there is no
199
+ `timestamps: false` for globals — so `expectedUpdatedAt` behaves as it does for
200
+ collections. The one exception is a global that has never been saved: it has no
201
+ `updatedAt` to compare against, so the first write must omit
202
+ `expectedUpdatedAt`, and supplying one is refused as a concurrency failure.
203
+
204
+ If `tools/list` omits `global` entirely, no global is exposed to that key; the
205
+ argument only appears once one is. A deployment that uses no globals sees the
206
+ tool schemas exactly as they were.
207
+
164
208
  ## Drafts and publish blockers
165
209
 
166
210
  Draft-only writing is enforced on the Payload operation, not in the tool
167
211
  handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
168
212
  from every write carrying the MCP request marker, so custom tools and anything
169
213
  else writing through the same request are covered too. A `beforeChange` hook
170
- refuses any write that would still not land as a draft.
214
+ refuses any write that would still not land as a draft. Globals expose the same
215
+ `beforeOperation` interception point at the same position in the operation, so
216
+ they are guarded exactly as strongly as collections, exposed or not.
171
217
 
172
218
  Publish blockers are advisory. Payload skips validation on draft saves (unless
173
219
  `versions.drafts.validate` is set), so after every write the plugin re-runs
@@ -224,6 +270,10 @@ Builtin tools reject them instead.
224
270
  | `collections.<slug>.read` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
225
271
  | `collections.<slug>.write` | `false` | Expose `patchDocument`, `createDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
226
272
  | `collections.<slug>.allowLiveWrites` | `false` | Permit writes to a collection without drafts (they land live). |
273
+ | `globals` | `{}` | Allow-list of globals. `true` means `{ read: true }`. |
274
+ | `globals.<slug>.read` | `true` | Expose `describeSchema`, `getDocument`. |
275
+ | `globals.<slug>.write` | `false` | Expose `patchDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
276
+ | `globals.<slug>.allowLiveWrites` | `false` | Permit writes to a global without drafts (they land live). |
227
277
  | `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
228
278
  | `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
229
279
  | `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
@@ -247,15 +297,14 @@ key of every user.
247
297
  - The endpoint authenticates with Bearer keys only; admin JWTs and cookies are
248
298
  ignored. Keys cannot authenticate REST or GraphQL.
249
299
  - Every operation runs under the linked user with `overrideAccess: false`.
250
- - Not covered in v1: `delete` (no tool exists and none is generated), globals,
251
- uploads. Custom tools are trusted code and can do what the linked user may.
300
+ - Not covered in v1: `delete` (no tool exists and none is generated), uploads.
301
+ Custom tools are trusted code and can do what the linked user may.
252
302
 
253
303
  ## Non-goals of v1 / roadmap
254
304
 
255
- Globals, deletes, uploads, markdown authoring for rich text, row addressing by
256
- id instead of index, cross-locale publish blockers, pagination of
257
- `describeSchema` with `expand`, and a handler-level timeout are all deliberate
258
- omissions for now.
305
+ Deletes, uploads, markdown authoring for rich text, row addressing by id
306
+ instead of index, cross-locale publish blockers, pagination of `describeSchema`
307
+ with `expand`, and a handler-level timeout are all deliberate omissions for now.
259
308
 
260
309
  ## License
261
310
 
@@ -56,9 +56,10 @@ const checkbox = (name, description) => ({
56
56
  }
57
57
  ];
58
58
  /**
59
- * One checkbox per exposed operation, grouped per collection and per custom
60
- * tool. Only operations the plugin config exposes get a checkbox, so a key can
61
- * never enable more than the config allows. Everything defaults to off.
59
+ * One checkbox per exposed operation, grouped per collection, per global and
60
+ * per custom tool. Only operations the plugin config exposes get a checkbox, so
61
+ * a key can never enable more than the config allows. Everything defaults to
62
+ * off, which is why a key issued before a capability existed stays closed to it.
62
63
  */ const createCapabilityFields = (options) => {
63
64
  const collectionGroups = options.collections.map((collection) => ({
64
65
  name: collection.fieldName,
@@ -66,16 +67,30 @@ const checkbox = (name, description) => ({
66
67
  label: collection.slug,
67
68
  fields: [...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [], ...collection.write ? [checkbox("write", "Create, patch and validate drafts.")] : []]
68
69
  }));
69
- const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
70
- const groups = [...collectionGroups.length > 0 ? [{
71
- name: "collections",
72
- type: "group",
73
- fields: collectionGroups
74
- }] : [], ...toolCheckboxes.length > 0 ? [{
75
- name: "tools",
70
+ const globalGroups = options.globals.map((global) => ({
71
+ name: global.fieldName,
76
72
  type: "group",
77
- fields: toolCheckboxes
78
- }] : []];
73
+ label: global.slug,
74
+ fields: [...global.read ? [checkbox("read", "Describe and read this global.")] : [], ...global.write ? [checkbox("write", "Patch and validate this global's draft.")] : []]
75
+ }));
76
+ const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
77
+ const groups = [
78
+ ...collectionGroups.length > 0 ? [{
79
+ name: "collections",
80
+ type: "group",
81
+ fields: collectionGroups
82
+ }] : [],
83
+ ...globalGroups.length > 0 ? [{
84
+ name: "globals",
85
+ type: "group",
86
+ fields: globalGroups
87
+ }] : [],
88
+ ...toolCheckboxes.length > 0 ? [{
89
+ name: "tools",
90
+ type: "group",
91
+ fields: toolCheckboxes
92
+ }] : []
93
+ ];
79
94
  if (groups.length === 0) return [];
80
95
  return [{
81
96
  name: CAPABILITIES_FIELD,
@@ -8,6 +8,7 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
8
8
  * issued before a capability existed stay closed.
9
9
  */ const resolveCapabilities = (options, keyCapabilities) => {
10
10
  const collectionsGroup = isRecord(keyCapabilities) ? keyCapabilities["collections"] : void 0;
11
+ const globalsGroup = isRecord(keyCapabilities) ? keyCapabilities["globals"] : void 0;
11
12
  const toolsGroup = isRecord(keyCapabilities) ? keyCapabilities["tools"] : void 0;
12
13
  const collections = {};
13
14
  for (const collection of options.collections) {
@@ -17,14 +18,26 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
17
18
  write: collection.write && flag(group, "write")
18
19
  };
19
20
  }
21
+ const globals = {};
22
+ for (const global of options.globals) {
23
+ const group = isRecord(globalsGroup) ? globalsGroup[global.fieldName] : void 0;
24
+ globals[global.slug] = {
25
+ read: global.read && flag(group, "read"),
26
+ write: global.write && flag(group, "write")
27
+ };
28
+ }
20
29
  const tools = {};
21
30
  for (const tool of options.tools) tools[tool.name] = flag(toolsGroup, tool.name);
22
31
  return {
23
32
  collections,
33
+ globals,
24
34
  tools
25
35
  };
26
36
  };
27
- const readableSlugs = (capabilities) => Object.entries(capabilities.collections).filter(([, value]) => value.read).map(([slug]) => slug);
28
- const writableSlugs = (capabilities) => Object.entries(capabilities.collections).filter(([, value]) => value.write).map(([slug]) => slug);
37
+ const pick = (entries, operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
38
+ const readableSlugs = (capabilities) => pick(capabilities.collections, "read");
39
+ const writableSlugs = (capabilities) => pick(capabilities.collections, "write");
40
+ const readableGlobalSlugs = (capabilities) => pick(capabilities.globals, "read");
41
+ const writableGlobalSlugs = (capabilities) => pick(capabilities.globals, "write");
29
42
  //#endregion
30
- export { CAPABILITIES_FIELD, readableSlugs, resolveCapabilities, writableSlugs };
43
+ export { CAPABILITIES_FIELD, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
@@ -1,4 +1,4 @@
1
- import { readableSlugs, resolveCapabilities, writableSlugs } from "../capabilities.mjs";
1
+ import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
2
2
  import { resolveApiKeyAuth } from "../auth/resolve.mjs";
3
3
  import { jsonRpcError } from "./result.mjs";
4
4
  import { createMcpServer } from "./server.mjs";
@@ -12,6 +12,8 @@ const buildScope = (req, options, capabilities) => {
12
12
  capabilities,
13
13
  readable: readableSlugs(capabilities),
14
14
  writable: writableSlugs(capabilities),
15
+ readableGlobals: readableGlobalSlugs(capabilities),
16
+ writableGlobals: writableGlobalSlugs(capabilities),
15
17
  locales: localization ? localization.localeCodes : null,
16
18
  defaultLocale: localization ? localization.defaultLocale : null
17
19
  };
@@ -18,7 +18,7 @@ import { z } from "zod";
18
18
  const server = new McpServer({
19
19
  name: options.serverInfo.name,
20
20
  version: options.serverInfo.version
21
- }, { instructions: "Start with listCapabilities, then describeSchema for the collection you work on. Writes always land as drafts; a human publishes." });
21
+ }, { instructions: "Start with listCapabilities, then describeSchema for the collection or global you work on. Writes always land as drafts; a human publishes." });
22
22
  const guarded = (run) => async () => {
23
23
  try {
24
24
  return await run();
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
1
+ import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
2
2
  import { mcpxPlugin } from "./plugin.mjs";
3
3
  import { isMcpxRequest } from "./write/draft-guard.mjs";
4
4
  import { PublishBlocker } from "./write/publish-blockers.mjs";
5
- export { type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type PublishBlocker, defineMcpxTool, isMcpxRequest, mcpxPlugin };
5
+ export { type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type PublishBlocker, defineMcpxTool, isMcpxRequest, mcpxPlugin };
@@ -0,0 +1,2 @@
1
+ import "./types.mjs";
2
+ import "payload";
package/dist/options.mjs CHANGED
@@ -30,6 +30,19 @@ const assertWritable = (collection, options) => {
30
30
  if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
31
31
  if (!options.hasDrafts && !options.allowLiveWrites) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
32
32
  };
33
+ /**
34
+ * Refuses globals that must never be reachable. Globals cannot be auth or
35
+ * upload entities, so only Payload's own reserved namespace is left to guard.
36
+ */ const assertGlobalExposable = (global) => {
37
+ if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
38
+ };
39
+ /**
40
+ * `GlobalConfig` has no `timestamps` option and `sanitizeGlobal` always appends
41
+ * `createdAt`/`updatedAt`, so the concurrency check the collection path guards
42
+ * for is always available here. Drafts are the only requirement left.
43
+ */ const assertGlobalWritable = (global, options) => {
44
+ if (!options.hasDrafts && !options.allowLiveWrites) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
45
+ };
33
46
  const normalizeCollections = (config, options, apiKeysSlug) => {
34
47
  const collections = config.collections ?? [];
35
48
  const fieldNames = /* @__PURE__ */ new Set();
@@ -54,6 +67,30 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
54
67
  return [normalized];
55
68
  });
56
69
  };
70
+ const normalizeGlobals = (config, options) => {
71
+ const globals = config.globals ?? [];
72
+ const fieldNames = /* @__PURE__ */ new Set();
73
+ return Object.entries(options.globals ?? {}).flatMap(([slug, raw]) => {
74
+ if (raw === void 0) return [];
75
+ const global = globals.find((candidate) => candidate.slug === slug);
76
+ if (!global) return fail(`Exposed global "${slug}" does not exist.`);
77
+ assertGlobalExposable(global);
78
+ const settings = raw === true ? {} : raw;
79
+ const hasDrafts = hasDraftsEnabled(global);
80
+ const normalized = {
81
+ slug,
82
+ read: settings.read ?? true,
83
+ write: settings.write ?? false,
84
+ allowLiveWrites: settings.allowLiveWrites ?? false,
85
+ hasDrafts,
86
+ fieldName: toCamelCase(slug)
87
+ };
88
+ if (normalized.write) assertGlobalWritable(global, normalized);
89
+ if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
90
+ fieldNames.add(normalized.fieldName);
91
+ return [normalized];
92
+ });
93
+ };
57
94
  const assertUserCollection = (config, slug) => {
58
95
  const collection = (config.collections ?? []).find((candidate) => candidate.slug === slug);
59
96
  if (!collection) fail(`User collection "${slug}" does not exist.`);
@@ -91,6 +128,7 @@ const normalizeLimits = (limits) => {
91
128
  assertTools(tools);
92
129
  return {
93
130
  collections: normalizeCollections(config, options, apiKeysSlug),
131
+ globals: normalizeGlobals(config, options),
94
132
  userCollection,
95
133
  apiKeysSlug,
96
134
  endpointPath: options.endpoint?.path ?? DEFAULT_ENDPOINT_PATH,
package/dist/plugin.d.mts CHANGED
@@ -2,7 +2,7 @@ import { McpxPluginOptions } from "./types.mjs";
2
2
  //#region src/plugin.d.ts
3
3
  /**
4
4
  * Mounts the MCP endpoint, adds the API key collection and installs the
5
- * draft guard on every collection.
5
+ * draft guard on every collection and global.
6
6
  */
7
7
  declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
8
8
  //#endregion
package/dist/plugin.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
2
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
3
  import { normalizeOptions } from "./options.mjs";
4
- import { installDraftGuards } from "./write/draft-guard.mjs";
4
+ import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
5
  import { definePlugin } from "payload";
6
6
  //#region src/plugin.ts
7
7
  /**
8
8
  * Mounts the MCP endpoint, adds the API key collection and installs the
9
- * draft guard on every collection.
9
+ * draft guard on every collection and global.
10
10
  */ const mcpxPlugin = definePlugin({
11
11
  slug: "@abinnovision/payloadcms-mcpx",
12
12
  order: 100,
@@ -17,6 +17,7 @@ import { definePlugin } from "payload";
17
17
  return {
18
18
  ...config,
19
19
  collections: installDraftGuards([...config.collections ?? [], apiKeysCollection]),
20
+ globals: installGlobalDraftGuards(config.globals ?? []),
20
21
  endpoints: [
21
22
  ...config.endpoints ?? [],
22
23
  {
@@ -1,4 +1,4 @@
1
- import { blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, splitPath } from "./walk.mjs";
1
+ import { blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
2
2
  //#region src/schema/describe.ts
3
3
  const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor) => descriptor.type === "blocks");
4
4
  /**
@@ -8,8 +8,8 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
8
8
  * the blocks it accepts, so `/layout/sections/sectionWrapper/modules/hero`
9
9
  * reaches `hero` as it exists under `pages` specifically. The slug sits where
10
10
  * a pointer into a document would carry the element's index.
11
- */ const fieldsAtSchemaPath = (config, collection, schemaPath) => {
12
- let fields = collection.flattenedFields;
11
+ */ const fieldsAtSchemaPath = (config, target, schemaPath) => {
12
+ let fields = target.flattenedFields;
13
13
  let blockType;
14
14
  let remaining = splitPath(schemaPath);
15
15
  while (remaining.length > 0) {
@@ -34,24 +34,25 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
34
34
  };
35
35
  };
36
36
  /**
37
- * Describes a collection root, or one block reached through a schema path.
38
- */ const describeNode = (config, collection, schemaPath = "") => {
39
- const { blockType, fields } = fieldsAtSchemaPath(config, collectionOf(config, collection), schemaPath);
37
+ * Describes a collection or global root, or one block reached through a schema
38
+ * path.
39
+ */ const describeNode = (config, ref, schemaPath = "") => {
40
+ const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
40
41
  const descriptors = describeFields(fields);
41
42
  const next = descriptors.flatMap((descriptor) => (descriptor.blocks ?? []).map((slug) => `${schemaPath}${descriptor.path}/${slug}`));
42
43
  return {
43
44
  ...blockType === void 0 ? {} : { blockType },
44
- collection,
45
+ ...ref.kind === "collection" ? { collection: ref.slug } : { global: ref.slug },
45
46
  fields: descriptors,
46
47
  ...next.length > 0 ? { next } : {},
47
48
  schemaPath
48
49
  };
49
50
  };
50
51
  /**
51
- * Every schema path reachable from a collection root, capped at
52
+ * Every schema path reachable from an entity root, capped at
52
53
  * {@link REACHABLE_PATHS_LIMIT}. `truncated` tells the caller the cap was hit
53
54
  * and explicit paths are the way to go deeper.
54
- */ const reachableSchemaPaths = (config, collection) => {
55
+ */ const reachableSchemaPaths = (config, ref) => {
55
56
  const seen = [];
56
57
  let truncated = false;
57
58
  const walk = (schemaPath, visited) => {
@@ -60,7 +61,7 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
60
61
  return;
61
62
  }
62
63
  seen.push(schemaPath);
63
- for (const descriptor of describeNode(config, collection, schemaPath).fields) for (const slug of descriptor.blocks ?? []) {
64
+ for (const descriptor of describeNode(config, ref, schemaPath).fields) for (const slug of descriptor.blocks ?? []) {
64
65
  if (visited.includes(slug)) continue;
65
66
  walk(`${schemaPath}${descriptor.path}/${slug}`, [...visited, slug]);
66
67
  }
@@ -1,4 +1,4 @@
1
- import { blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, splitPath } from "./walk.mjs";
1
+ import { blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
2
2
  //#region src/schema/pointer.ts
3
3
  const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
4
4
  const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isIndexSegment(segment) : part === segment);
@@ -32,7 +32,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
32
32
  * can only be resolved by reading `blockType` off `sections[3]`, since a blocks
33
33
  * field admits many shapes at the same index.
34
34
  */ const resolveDataPointer = (config, target) => {
35
- let fields = collectionOf(config, target.collection).flattenedFields;
35
+ let fields = targetOf(config, target.ref).flattenedFields;
36
36
  let data = target.doc;
37
37
  let blockType;
38
38
  let segments = splitPath(target.pointer);
@@ -0,0 +1 @@
1
+ import "payload";
@@ -109,10 +109,10 @@ const withRows = (descriptor, field) => ({
109
109
  if (field.type === "array" && path[1] === "*") return findBlocksField(field.flattenedFields, path.slice(2));
110
110
  }
111
111
  };
112
- const collectionOf = (config, collection) => {
113
- const found = config.collections.find((candidate) => candidate.slug === collection);
114
- if (!found) throw new Error(`Unknown collection "${collection}".`);
112
+ const targetOf = (config, ref) => {
113
+ const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
114
+ if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
115
115
  return found;
116
116
  };
117
117
  //#endregion
118
- export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, pointerFromPayloadPath, splitPath, staticDescription };
118
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
@@ -1,5 +1,6 @@
1
1
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, ensureAllowed, localeOf, localeShape, readDraft } from "./shared.mjs";
2
+ import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
3
+ import { resolveTarget } from "./target.mjs";
3
4
  import { validateWriteValue } from "../schema/shape.mjs";
4
5
  import { stripRowIds } from "../write/patch.mjs";
5
6
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
@@ -16,7 +17,7 @@ const createDocument = {
16
17
  },
17
18
  isEnabled: (scope) => scope.writable.length > 0,
18
19
  inputSchema: (scope) => ({
19
- collection: collectionEnum(scope.writable).describe("Collection to create the document in."),
20
+ collection: slugEnum(scope.writable).describe("Collection to create the document in."),
20
21
  ...localeShape(scope, {
21
22
  required: true,
22
23
  description: "Locale the localized fields of the seed belong to."
@@ -24,14 +25,14 @@ const createDocument = {
24
25
  data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
25
26
  }),
26
27
  handler: async (args, scope) => {
27
- const collection = ensureAllowed(scope, args.collection, "write");
28
+ const target = resolveTarget(scope, { collection: args.collection }, "write");
28
29
  const { payload } = scope.req;
29
30
  const locale = localeOf(scope, args.locale);
30
31
  const { id: _ignored, ...seed } = args.data;
31
32
  const problems = validateWriteValue(payload.config, {
32
33
  pointer: "",
33
34
  resolution: {
34
- fields: collection.flattenedFields,
35
+ fields: target.config.flattenedFields,
35
36
  prefix: []
36
37
  }
37
38
  }, seed);
@@ -45,15 +46,15 @@ const createDocument = {
45
46
  req: scope.req,
46
47
  ...locale === void 0 ? {} : { locale }
47
48
  });
48
- const saved = await readDraft(scope, {
49
- collection: args.collection,
49
+ const saved = await readTarget(scope, {
50
+ target,
50
51
  id: created["id"],
51
52
  locale,
52
53
  privileged: true
53
54
  });
54
55
  const publishBlockers = await collectPublishBlockers(scope.req, {
55
- collection,
56
- doc: saved
56
+ doc: saved,
57
+ entity: target
57
58
  });
58
59
  return jsonResult({
59
60
  id: saved["id"],
@@ -1,5 +1,6 @@
1
1
  import { jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, ensureAllowed } from "./shared.mjs";
2
+ import { targetShape } from "./shared.mjs";
3
+ import { refOf, resolveTarget } from "./target.mjs";
3
4
  import { describeNode, reachableSchemaPaths } from "../schema/describe.mjs";
4
5
  import { z } from "zod";
5
6
  //#region src/tools/describe-schema.ts
@@ -7,6 +8,8 @@ const describeSchema = {
7
8
  name: "describeSchema",
8
9
  description: `Describes the writable shape of a document, one node at a time.
9
10
 
11
+ Pass exactly one of "collection" and "global". A global is a singleton: it has no id, is not listed by findDocuments and cannot be created.
12
+
10
13
  Call it with no "paths" to get a collection's own fields. Every "blocks" field stops there and lists the block slugs it accepts instead of nesting them; each node's "next" lists the ready-to-use paths for those blocks, so pass any entry of "next" as a "paths" element to descend, e.g. "/layout/sections/sectionWrapper" and then "/layout/sections/sectionWrapper/modules/hero". A block is described as it exists at that position, because the same block can accept different children elsewhere.
11
14
 
12
15
  Paths here use the same JSON Pointer syntax as getDocument and patchDocument, and are already resolved through anything that does not nest in the stored document. The difference is only what stands in an element position: a path names an array element "*" and a block by its slug, where a pointer into a document carries a 0-based index. So "/items/*/title" is written at "/items/0/title", and "/layout/sections/hero" at "/layout/sections/0".
@@ -16,19 +19,22 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
16
19
  readOnlyHint: true,
17
20
  openWorldHint: false
18
21
  },
19
- isEnabled: (scope) => scope.readable.length > 0,
22
+ isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
20
23
  inputSchema: (scope) => ({
21
- collection: collectionEnum(scope.readable).describe("Collection to describe."),
24
+ ...targetShape(scope, "read", {
25
+ collection: "Collection to describe.",
26
+ global: "Global to describe."
27
+ }),
22
28
  paths: z.array(z.string()).optional().describe("Schema paths to describe, e.g. \"/layout/sections/sectionWrapper\". Omit for the collection root."),
23
29
  expand: z.boolean().optional().describe("Return every node reachable from the root in one response. Ignores paths.")
24
30
  }),
25
31
  handler: (args, scope) => {
26
- ensureAllowed(scope, args.collection, "read");
32
+ const ref = refOf(resolveTarget(scope, args, "read"));
27
33
  const { config } = scope.req.payload;
28
- const expanded = args.expand === true ? reachableSchemaPaths(config, args.collection) : void 0;
34
+ const expanded = args.expand === true ? reachableSchemaPaths(config, ref) : void 0;
29
35
  const nodes = (expanded?.paths ?? (args.paths && args.paths.length > 0 ? args.paths : [""])).map((schemaPath) => {
30
36
  try {
31
- return describeNode(config, args.collection, schemaPath);
37
+ return describeNode(config, ref, schemaPath);
32
38
  } catch (error) {
33
39
  return {
34
40
  error: error instanceof Error ? error.message : "Unknown error",
@@ -1,5 +1,6 @@
1
1
  import { jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, depthShape, ensureAllowed, localeOf, localeShape } from "./shared.mjs";
2
+ import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
3
+ import { resolveTarget } from "./target.mjs";
3
4
  import { z } from "zod";
4
5
  //#region src/tools/find-documents.ts
5
6
  const findDocuments = {
@@ -11,7 +12,7 @@ const findDocuments = {
11
12
  },
12
13
  isEnabled: (scope) => scope.readable.length > 0,
13
14
  inputSchema: (scope) => ({
14
- collection: collectionEnum(scope.readable).describe("Collection to search."),
15
+ collection: slugEnum(scope.readable).describe("Collection to search."),
15
16
  where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
16
17
  sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
17
18
  limit: z.number().int().min(1).max(scope.options.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.options.limits.maxLimit)}.`),
@@ -25,7 +26,7 @@ const findDocuments = {
25
26
  draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
26
27
  }),
27
28
  handler: async (args, scope) => {
28
- ensureAllowed(scope, args.collection, "read");
29
+ resolveTarget(scope, { collection: args.collection }, "read");
29
30
  const locale = localeOf(scope, args.locale);
30
31
  const result = await scope.req.payload.find({
31
32
  collection: args.collection,