@next-buildr/payload 0.0.0-stage → 2.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.
Files changed (250) hide show
  1. package/LICENSE +21 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/adapter/adapter.test.d.ts +2 -0
  4. package/dist/adapter/adapter.test.d.ts.map +1 -0
  5. package/dist/adapter/adapter.test.js +282 -0
  6. package/dist/adapter/canvas-data-source.d.ts +9 -0
  7. package/dist/adapter/canvas-data-source.d.ts.map +1 -0
  8. package/dist/adapter/canvas-data-source.js +24 -0
  9. package/dist/adapter/document-adapter.d.ts +20 -0
  10. package/dist/adapter/document-adapter.d.ts.map +1 -0
  11. package/dist/adapter/document-adapter.js +183 -0
  12. package/dist/adapter/http.d.ts +34 -0
  13. package/dist/adapter/http.d.ts.map +1 -0
  14. package/dist/adapter/http.js +72 -0
  15. package/dist/adapter/index.d.ts +4 -0
  16. package/dist/adapter/index.d.ts.map +1 -0
  17. package/dist/adapter/index.js +5 -0
  18. package/dist/admin/index.d.ts +4 -0
  19. package/dist/admin/index.d.ts.map +1 -0
  20. package/dist/admin/index.js +2 -0
  21. package/dist/admin/layout-field-connected.d.ts +6 -0
  22. package/dist/admin/layout-field-connected.d.ts.map +1 -0
  23. package/dist/admin/layout-field-connected.js +16 -0
  24. package/dist/admin/layout-field-view.d.ts +16 -0
  25. package/dist/admin/layout-field-view.d.ts.map +1 -0
  26. package/dist/admin/layout-field-view.js +14 -0
  27. package/dist/admin/layout-field.d.ts +6 -0
  28. package/dist/admin/layout-field.d.ts.map +1 -0
  29. package/dist/admin/layout-field.js +13 -0
  30. package/dist/admin/layout-field.test.d.ts +2 -0
  31. package/dist/admin/layout-field.test.d.ts.map +1 -0
  32. package/dist/admin/layout-field.test.js +41 -0
  33. package/dist/contract.d.ts +289 -0
  34. package/dist/contract.d.ts.map +1 -0
  35. package/dist/contract.js +176 -0
  36. package/dist/data/build-context.d.ts +20 -0
  37. package/dist/data/build-context.d.ts.map +1 -0
  38. package/dist/data/build-context.js +25 -0
  39. package/dist/data/data.test.d.ts +2 -0
  40. package/dist/data/data.test.d.ts.map +1 -0
  41. package/dist/data/data.test.js +261 -0
  42. package/dist/data/index.d.ts +10 -0
  43. package/dist/data/index.d.ts.map +1 -0
  44. package/dist/data/index.js +5 -0
  45. package/dist/data/normalize.d.ts +14 -0
  46. package/dist/data/normalize.d.ts.map +1 -0
  47. package/dist/data/normalize.js +128 -0
  48. package/dist/data/payload-data-source.d.ts +42 -0
  49. package/dist/data/payload-data-source.d.ts.map +1 -0
  50. package/dist/data/payload-data-source.js +179 -0
  51. package/dist/data/payload-data-source.test.d.ts +2 -0
  52. package/dist/data/payload-data-source.test.d.ts.map +1 -0
  53. package/dist/data/payload-data-source.test.js +160 -0
  54. package/dist/data/resolve-layout.d.ts +45 -0
  55. package/dist/data/resolve-layout.d.ts.map +1 -0
  56. package/dist/data/resolve-layout.js +111 -0
  57. package/dist/data/schema-from-fields.d.ts +63 -0
  58. package/dist/data/schema-from-fields.d.ts.map +1 -0
  59. package/dist/data/schema-from-fields.js +178 -0
  60. package/dist/data/where.d.ts +53 -0
  61. package/dist/data/where.d.ts.map +1 -0
  62. package/dist/data/where.js +210 -0
  63. package/dist/mcp/http-backend.d.ts +35 -0
  64. package/dist/mcp/http-backend.d.ts.map +1 -0
  65. package/dist/mcp/http-backend.js +337 -0
  66. package/dist/mcp/http-backend.test.d.ts +2 -0
  67. package/dist/mcp/http-backend.test.d.ts.map +1 -0
  68. package/dist/mcp/http-backend.test.js +178 -0
  69. package/dist/mcp/index.d.ts +2 -0
  70. package/dist/mcp/index.d.ts.map +1 -0
  71. package/dist/mcp/index.js +2 -0
  72. package/dist/mcp/route.d.ts +63 -0
  73. package/dist/mcp/route.d.ts.map +1 -0
  74. package/dist/mcp/route.js +230 -0
  75. package/dist/next/get-document.d.ts +64 -0
  76. package/dist/next/get-document.d.ts.map +1 -0
  77. package/dist/next/get-document.js +208 -0
  78. package/dist/next/get-document.test.d.ts +2 -0
  79. package/dist/next/get-document.test.d.ts.map +1 -0
  80. package/dist/next/get-document.test.js +155 -0
  81. package/dist/next/index.d.ts +9 -0
  82. package/dist/next/index.d.ts.map +1 -0
  83. package/dist/next/index.js +4 -0
  84. package/dist/next/next-helpers.test.d.ts +2 -0
  85. package/dist/next/next-helpers.test.d.ts.map +1 -0
  86. package/dist/next/next-helpers.test.js +122 -0
  87. package/dist/next/revalidate.d.ts +44 -0
  88. package/dist/next/revalidate.d.ts.map +1 -0
  89. package/dist/next/revalidate.js +45 -0
  90. package/dist/next/seo.d.ts +43 -0
  91. package/dist/next/seo.d.ts.map +1 -0
  92. package/dist/next/seo.js +53 -0
  93. package/dist/next/tags.d.ts +23 -0
  94. package/dist/next/tags.d.ts.map +1 -0
  95. package/dist/next/tags.js +31 -0
  96. package/dist/plugin/access.d.ts +19 -0
  97. package/dist/plugin/access.d.ts.map +1 -0
  98. package/dist/plugin/access.js +32 -0
  99. package/dist/plugin/collections/form-submissions.d.ts +10 -0
  100. package/dist/plugin/collections/form-submissions.d.ts.map +1 -0
  101. package/dist/plugin/collections/form-submissions.js +37 -0
  102. package/dist/plugin/collections/templates.d.ts +13 -0
  103. package/dist/plugin/collections/templates.d.ts.map +1 -0
  104. package/dist/plugin/collections/templates.js +118 -0
  105. package/dist/plugin/endpoints/access.test.d.ts +2 -0
  106. package/dist/plugin/endpoints/access.test.d.ts.map +1 -0
  107. package/dist/plugin/endpoints/access.test.js +91 -0
  108. package/dist/plugin/endpoints/adapter-contract.test.d.ts +2 -0
  109. package/dist/plugin/endpoints/adapter-contract.test.d.ts.map +1 -0
  110. package/dist/plugin/endpoints/adapter-contract.test.js +45 -0
  111. package/dist/plugin/endpoints/context.d.ts +65 -0
  112. package/dist/plugin/endpoints/context.d.ts.map +1 -0
  113. package/dist/plugin/endpoints/context.js +113 -0
  114. package/dist/plugin/endpoints/data-context.d.ts +8 -0
  115. package/dist/plugin/endpoints/data-context.d.ts.map +1 -0
  116. package/dist/plugin/endpoints/data-context.js +63 -0
  117. package/dist/plugin/endpoints/data-endpoints.test.d.ts +2 -0
  118. package/dist/plugin/endpoints/data-endpoints.test.d.ts.map +1 -0
  119. package/dist/plugin/endpoints/data-endpoints.test.js +125 -0
  120. package/dist/plugin/endpoints/data-query.test.d.ts +2 -0
  121. package/dist/plugin/endpoints/data-query.test.d.ts.map +1 -0
  122. package/dist/plugin/endpoints/data-query.test.js +96 -0
  123. package/dist/plugin/endpoints/data-schema.d.ts +5 -0
  124. package/dist/plugin/endpoints/data-schema.d.ts.map +1 -0
  125. package/dist/plugin/endpoints/data-schema.js +21 -0
  126. package/dist/plugin/endpoints/data.d.ts +10 -0
  127. package/dist/plugin/endpoints/data.d.ts.map +1 -0
  128. package/dist/plugin/endpoints/data.js +96 -0
  129. package/dist/plugin/endpoints/document.d.ts +11 -0
  130. package/dist/plugin/endpoints/document.d.ts.map +1 -0
  131. package/dist/plugin/endpoints/document.js +119 -0
  132. package/dist/plugin/endpoints/documents.d.ts +10 -0
  133. package/dist/plugin/endpoints/documents.d.ts.map +1 -0
  134. package/dist/plugin/endpoints/documents.js +145 -0
  135. package/dist/plugin/endpoints/endpoints.test-kit.d.ts +42 -0
  136. package/dist/plugin/endpoints/endpoints.test-kit.d.ts.map +1 -0
  137. package/dist/plugin/endpoints/endpoints.test-kit.js +141 -0
  138. package/dist/plugin/endpoints/endpoints.test.d.ts +2 -0
  139. package/dist/plugin/endpoints/endpoints.test.d.ts.map +1 -0
  140. package/dist/plugin/endpoints/endpoints.test.js +209 -0
  141. package/dist/plugin/endpoints/forms.d.ts +16 -0
  142. package/dist/plugin/endpoints/forms.d.ts.map +1 -0
  143. package/dist/plugin/endpoints/forms.js +199 -0
  144. package/dist/plugin/endpoints/forms.test.d.ts +2 -0
  145. package/dist/plugin/endpoints/forms.test.d.ts.map +1 -0
  146. package/dist/plugin/endpoints/forms.test.js +346 -0
  147. package/dist/plugin/endpoints/guards.d.ts +9 -0
  148. package/dist/plugin/endpoints/guards.d.ts.map +1 -0
  149. package/dist/plugin/endpoints/guards.js +35 -0
  150. package/dist/plugin/endpoints/index.d.ts +6 -0
  151. package/dist/plugin/endpoints/index.d.ts.map +1 -0
  152. package/dist/plugin/endpoints/index.js +32 -0
  153. package/dist/plugin/endpoints/locales-no-fallback.test.d.ts +2 -0
  154. package/dist/plugin/endpoints/locales-no-fallback.test.d.ts.map +1 -0
  155. package/dist/plugin/endpoints/locales-no-fallback.test.js +30 -0
  156. package/dist/plugin/endpoints/locales.test.d.ts +2 -0
  157. package/dist/plugin/endpoints/locales.test.d.ts.map +1 -0
  158. package/dist/plugin/endpoints/locales.test.js +128 -0
  159. package/dist/plugin/endpoints/manifest.d.ts +5 -0
  160. package/dist/plugin/endpoints/manifest.d.ts.map +1 -0
  161. package/dist/plugin/endpoints/manifest.js +18 -0
  162. package/dist/plugin/endpoints/mcp-http-backend.test.d.ts +2 -0
  163. package/dist/plugin/endpoints/mcp-http-backend.test.d.ts.map +1 -0
  164. package/dist/plugin/endpoints/mcp-http-backend.test.js +84 -0
  165. package/dist/plugin/endpoints/mcp-off.test.d.ts +2 -0
  166. package/dist/plugin/endpoints/mcp-off.test.d.ts.map +1 -0
  167. package/dist/plugin/endpoints/mcp-off.test.js +33 -0
  168. package/dist/plugin/endpoints/mcp-publish.test.d.ts +2 -0
  169. package/dist/plugin/endpoints/mcp-publish.test.d.ts.map +1 -0
  170. package/dist/plugin/endpoints/mcp-publish.test.js +42 -0
  171. package/dist/plugin/endpoints/mcp-route.test.d.ts +2 -0
  172. package/dist/plugin/endpoints/mcp-route.test.d.ts.map +1 -0
  173. package/dist/plugin/endpoints/mcp-route.test.js +132 -0
  174. package/dist/plugin/endpoints/mcp.test.d.ts +2 -0
  175. package/dist/plugin/endpoints/mcp.test.d.ts.map +1 -0
  176. package/dist/plugin/endpoints/mcp.test.js +231 -0
  177. package/dist/plugin/endpoints/media.d.ts +7 -0
  178. package/dist/plugin/endpoints/media.d.ts.map +1 -0
  179. package/dist/plugin/endpoints/media.js +115 -0
  180. package/dist/plugin/endpoints/media.test.d.ts +2 -0
  181. package/dist/plugin/endpoints/media.test.d.ts.map +1 -0
  182. package/dist/plugin/endpoints/media.test.js +95 -0
  183. package/dist/plugin/endpoints/publish-a11y.test.d.ts +2 -0
  184. package/dist/plugin/endpoints/publish-a11y.test.d.ts.map +1 -0
  185. package/dist/plugin/endpoints/publish-a11y.test.js +27 -0
  186. package/dist/plugin/endpoints/publish.d.ts +9 -0
  187. package/dist/plugin/endpoints/publish.d.ts.map +1 -0
  188. package/dist/plugin/endpoints/publish.js +81 -0
  189. package/dist/plugin/endpoints/rename.d.ts +9 -0
  190. package/dist/plugin/endpoints/rename.d.ts.map +1 -0
  191. package/dist/plugin/endpoints/rename.js +47 -0
  192. package/dist/plugin/endpoints/respond.d.ts +9 -0
  193. package/dist/plugin/endpoints/respond.d.ts.map +1 -0
  194. package/dist/plugin/endpoints/respond.js +10 -0
  195. package/dist/plugin/endpoints/samples.d.ts +5 -0
  196. package/dist/plugin/endpoints/samples.d.ts.map +1 -0
  197. package/dist/plugin/endpoints/samples.js +42 -0
  198. package/dist/plugin/endpoints/save.d.ts +9 -0
  199. package/dist/plugin/endpoints/save.d.ts.map +1 -0
  200. package/dist/plugin/endpoints/save.js +58 -0
  201. package/dist/plugin/endpoints/session.d.ts +5 -0
  202. package/dist/plugin/endpoints/session.d.ts.map +1 -0
  203. package/dist/plugin/endpoints/session.js +30 -0
  204. package/dist/plugin/endpoints/templates.test.d.ts +2 -0
  205. package/dist/plugin/endpoints/templates.test.d.ts.map +1 -0
  206. package/dist/plugin/endpoints/templates.test.js +191 -0
  207. package/dist/plugin/fields.d.ts +28 -0
  208. package/dist/plugin/fields.d.ts.map +1 -0
  209. package/dist/plugin/fields.js +62 -0
  210. package/dist/plugin/forms/forms.test.d.ts +2 -0
  211. package/dist/plugin/forms/forms.test.d.ts.map +1 -0
  212. package/dist/plugin/forms/forms.test.js +88 -0
  213. package/dist/plugin/forms/notify.d.ts +6 -0
  214. package/dist/plugin/forms/notify.d.ts.map +1 -0
  215. package/dist/plugin/forms/notify.js +13 -0
  216. package/dist/plugin/forms/rate-limit.d.ts +24 -0
  217. package/dist/plugin/forms/rate-limit.d.ts.map +1 -0
  218. package/dist/plugin/forms/rate-limit.js +36 -0
  219. package/dist/plugin/forms/validate.d.ts +22 -0
  220. package/dist/plugin/forms/validate.d.ts.map +1 -0
  221. package/dist/plugin/forms/validate.js +79 -0
  222. package/dist/plugin/hooks/layout-hook.d.ts +9 -0
  223. package/dist/plugin/hooks/layout-hook.d.ts.map +1 -0
  224. package/dist/plugin/hooks/layout-hook.js +33 -0
  225. package/dist/plugin/hooks/layout-hook.test.d.ts +2 -0
  226. package/dist/plugin/hooks/layout-hook.test.d.ts.map +1 -0
  227. package/dist/plugin/hooks/layout-hook.test.js +191 -0
  228. package/dist/plugin/hooks/process-layout.d.ts +32 -0
  229. package/dist/plugin/hooks/process-layout.d.ts.map +1 -0
  230. package/dist/plugin/hooks/process-layout.js +68 -0
  231. package/dist/plugin/hooks/read-layout.d.ts +17 -0
  232. package/dist/plugin/hooks/read-layout.d.ts.map +1 -0
  233. package/dist/plugin/hooks/read-layout.js +39 -0
  234. package/dist/plugin/index.d.ts +18 -0
  235. package/dist/plugin/index.d.ts.map +1 -0
  236. package/dist/plugin/index.js +105 -0
  237. package/dist/plugin/locales.d.ts +32 -0
  238. package/dist/plugin/locales.d.ts.map +1 -0
  239. package/dist/plugin/locales.js +56 -0
  240. package/dist/plugin/options.d.ts +77 -0
  241. package/dist/plugin/options.d.ts.map +1 -0
  242. package/dist/plugin/options.js +123 -0
  243. package/dist/plugin/plugin.test.d.ts +2 -0
  244. package/dist/plugin/plugin.test.d.ts.map +1 -0
  245. package/dist/plugin/plugin.test.js +186 -0
  246. package/dist/plugin/write-guard.d.ts +14 -0
  247. package/dist/plugin/write-guard.d.ts.map +1 -0
  248. package/dist/plugin/write-guard.js +18 -0
  249. package/package.json +107 -3
  250. package/README.md +0 -3
@@ -0,0 +1,84 @@
1
+ import { runBackendContract } from '@next-buildr/mcp/testing';
2
+ import { handleEndpoints } from 'payload';
3
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4
+ import { createPayloadMcpBackend } from '../../mcp/http-backend.js';
5
+ import { boot, withChild } from './endpoints.test-kit.js';
6
+ const KEYS = {
7
+ agent: 'agent-key-0123456789',
8
+ readOnly: 'ro-key-0123456789',
9
+ plain: 'plain-key-0123456789',
10
+ };
11
+ let h;
12
+ /** A `fetch` that answers from the live Payload of the harness, exactly as the site would over HTTP. */
13
+ const payloadFetch = (input, init) => handleEndpoints({ config: h.payload.config, request: new Request(input, init) });
14
+ const backendFor = (apiKey) => createPayloadMcpBackend({
15
+ baseUrl: 'http://localhost/api',
16
+ apiKey,
17
+ collections: ['pages'],
18
+ fetch: payloadFetch,
19
+ retries: 0,
20
+ });
21
+ beforeAll(async () => {
22
+ h = await boot('mcp-http-backend', {
23
+ mcp: { enabled: true, allowPublish: true, collections: ['pages'] },
24
+ access: {
25
+ edit: ({ req }) => !String(req.user.email).startsWith('ro'),
26
+ publish: ({ req }) => String(req.user.email).startsWith('pub'),
27
+ },
28
+ }, { collections: [{ slug: 'users', auth: { useAPIKey: true }, fields: [] }] });
29
+ const users = [
30
+ { email: 'pub-agent@example.com', apiKey: KEYS.agent },
31
+ { email: 'ro-agent@example.com', apiKey: KEYS.readOnly },
32
+ { email: 'plain-agent@example.com', apiKey: KEYS.plain },
33
+ ];
34
+ for (const user of users) {
35
+ await h.payload.create({
36
+ collection: 'users',
37
+ data: { ...user, password: 'secret-password', enableAPIKey: true },
38
+ });
39
+ }
40
+ });
41
+ afterAll(() => h.close());
42
+ runBackendContract({
43
+ name: 'payload HTTP backend (live Payload, SQLite)',
44
+ async create() {
45
+ const doc = await h.payload.create({
46
+ collection: 'pages',
47
+ data: { title: 'Existing', slug: `existing-${Date.now()}`, buildrRevision: 0 },
48
+ draft: true,
49
+ });
50
+ // Give the draft a real layout so load/save round-trip a non-empty document.
51
+ await h.payload.update({
52
+ collection: 'pages',
53
+ id: doc.id,
54
+ data: { layout: withChild({ title: { kind: 'static', value: 'Hi' } }), buildrRevision: 1 },
55
+ draft: true,
56
+ });
57
+ return {
58
+ backend: backendFor(KEYS.agent),
59
+ existing: { collection: 'pages', id: String(doc.id) },
60
+ missing: { collection: 'pages', id: '999999' },
61
+ collection: 'pages',
62
+ unknownCollection: 'nope',
63
+ dataSchemaCollection: 'pages',
64
+ noDataSchemaCollection: 'nope',
65
+ readOnlyBackend: backendFor(KEYS.readOnly),
66
+ noPublishBackend: backendFor(KEYS.plain),
67
+ };
68
+ },
69
+ });
70
+ describe('against a live Payload', () => {
71
+ it('reports the agent user and its permissions', async () => {
72
+ const session = await backendFor(KEYS.agent).getSession();
73
+ expect(session.ok && session.value.user.email).toBe('pub-agent@example.com');
74
+ expect(session.ok && session.value.permissions.canPublish).toBe(true);
75
+ });
76
+ it('turns a rejected key into forbidden without echoing it', async () => {
77
+ const result = await backendFor('not-a-real-key-000000').getSession();
78
+ expect(result.ok).toBe(false);
79
+ if (!result.ok) {
80
+ expect(result.error.code).toBe('forbidden');
81
+ expect(JSON.stringify(result.error)).not.toContain('not-a-real-key-000000');
82
+ }
83
+ });
84
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mcp-off.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-off.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/mcp-off.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,33 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { boot } from './endpoints.test-kit.js';
3
+ const KEY = 'agent-key-0123456789';
4
+ let h;
5
+ beforeAll(async () => {
6
+ h = await boot('mcp-off', {}, { collections: [{ slug: 'users', auth: { useAPIKey: true }, fields: [] }] });
7
+ await h.payload.create({
8
+ collection: 'users',
9
+ data: {
10
+ email: 'agent@example.com',
11
+ password: 'secret-password',
12
+ enableAPIKey: true,
13
+ apiKey: KEY,
14
+ },
15
+ });
16
+ });
17
+ afterAll(() => h.close());
18
+ const asAgent = (method, path) => h.call(method, path, { token: null, headers: { authorization: `users API-Key ${KEY}` } });
19
+ describe('mcp disabled (the default)', () => {
20
+ it('refuses API-key requests to the builder, but not a session', async () => {
21
+ const doc = await h.payload.create({ collection: 'pages', data: { title: 'A' }, draft: true });
22
+ expect((await asAgent('GET', `/buildr/documents/pages/${doc.id}`)).status).toBe(403);
23
+ expect((await asAgent('GET', '/buildr/session')).body.permissions.canEdit).toBe(false);
24
+ expect((await h.call('GET', `/buildr/documents/pages/${doc.id}`)).status).toBe(200);
25
+ });
26
+ it('does not register the list and create endpoints', async () => {
27
+ expect((await h.call('GET', '/buildr/documents?collection=pages')).status).toBe(404);
28
+ const create = await h.call('POST', '/buildr/documents', {
29
+ body: { collection: 'pages', title: 'x' },
30
+ });
31
+ expect(create.status).toBe(404);
32
+ });
33
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mcp-publish.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-publish.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/mcp-publish.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,42 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { boot, title, withChild } from './endpoints.test-kit.js';
3
+ const KEY = 'agent-key-0123456789';
4
+ const PLAIN_KEY = `${KEY}-2`;
5
+ let h;
6
+ beforeAll(async () => {
7
+ h = await boot('mcp-publish', {
8
+ mcp: { enabled: true, allowPublish: true },
9
+ access: {
10
+ publish: ({ req }) => String(req.user.email).startsWith('pub'),
11
+ },
12
+ }, { collections: [{ slug: 'users', auth: { useAPIKey: true }, fields: [] }] });
13
+ const users = [
14
+ { email: 'pub-agent@example.com', apiKey: KEY },
15
+ { email: 'plain-agent@example.com', apiKey: PLAIN_KEY },
16
+ ];
17
+ for (const user of users) {
18
+ await h.payload.create({
19
+ collection: 'users',
20
+ data: { ...user, password: 'secret-password', enableAPIKey: true },
21
+ });
22
+ }
23
+ });
24
+ afterAll(() => h.close());
25
+ const as = (key, method, path, body) => h.call(method, path, {
26
+ token: null,
27
+ headers: { authorization: `users API-Key ${key}` },
28
+ ...(body === undefined ? {} : { body }),
29
+ });
30
+ describe('mcp.allowPublish', () => {
31
+ it('lets an API-key user publish only when access.publish also allows it', async () => {
32
+ const doc = await h.payload.create({ collection: 'pages', data: { title: 'P' }, draft: true });
33
+ const path = `/buildr/documents/pages/${doc.id}`;
34
+ const document = withChild(title('Hi'));
35
+ const saved = await as(KEY, 'PUT', path, { document, baseRevision: 0, autosave: false });
36
+ expect(saved.status).toBe(200);
37
+ const denied = await as(PLAIN_KEY, 'POST', `${path}/publish`, { baseRevision: 1 });
38
+ expect(denied.status).toBe(403);
39
+ expect((await as(KEY, 'GET', '/buildr/session')).body.permissions.canPublish).toBe(true);
40
+ expect((await as(KEY, 'POST', `${path}/publish`, { baseRevision: 1 })).status).toBe(200);
41
+ });
42
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mcp-route.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-route.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/mcp-route.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,132 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { runToolScenario } from '@next-buildr/mcp/testing';
4
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
5
+ import { createBuildrMcpRoute } from '../../mcp/route.js';
6
+ import { boot, meta } from './endpoints.test-kit.js';
7
+ const KEY = 'agent-key-0123456789';
8
+ const URL_ = 'http://localhost/api/buildr/mcp';
9
+ let h;
10
+ const clients = [];
11
+ beforeAll(async () => {
12
+ h = await boot('mcp-route', { mcp: { enabled: true, collections: ['pages'] } }, { collections: [{ slug: 'users', auth: { useAPIKey: true }, fields: [] }] });
13
+ await h.payload.create({
14
+ collection: 'users',
15
+ data: {
16
+ email: 'agent@example.com',
17
+ password: 'secret-password',
18
+ enableAPIKey: true,
19
+ apiKey: KEY,
20
+ },
21
+ });
22
+ });
23
+ afterEach(async () => {
24
+ await Promise.all(clients.splice(0).map((client) => client.close()));
25
+ });
26
+ afterAll(() => h.close());
27
+ const makeRoute = (extra = {}) => createBuildrMcpRoute({ payload: h.payload, registry: { meta }, ...extra });
28
+ async function connect(route = makeRoute(), headers = {}) {
29
+ const transport = new StreamableHTTPClientTransport(new URL(URL_), {
30
+ requestInit: { headers: { authorization: `users API-Key ${KEY}`, ...headers } },
31
+ fetch: (input, init) => {
32
+ const request = new Request(input, init);
33
+ return request.method === 'POST' ? route.POST(request) : route.GET(request);
34
+ },
35
+ });
36
+ const client = new Client({ name: 'http-test', version: '0.0.0' });
37
+ clients.push(client);
38
+ await client.connect(transport);
39
+ return client;
40
+ }
41
+ const post = (route, headers) => route.POST(new Request(URL_, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'content-type': 'application/json',
45
+ accept: 'application/json, text/event-stream',
46
+ ...headers,
47
+ },
48
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
49
+ }));
50
+ describe('the MCP route over Streamable HTTP', () => {
51
+ it('runs the shared tool scenario as the agent user and saves a draft', async () => {
52
+ const client = await connect();
53
+ const { ref, revision } = await runToolScenario(client, {
54
+ collection: 'pages',
55
+ tree: { type: 'buildr/widget', props: { title: 'Hello from agent' } },
56
+ expectText: 'Hello from agent',
57
+ });
58
+ expect(revision).toBeGreaterThan(0);
59
+ const stored = (await h.payload.findByID({
60
+ collection: 'pages',
61
+ id: ref.id,
62
+ draft: true,
63
+ }));
64
+ expect(Object.values(stored.layout.nodes).map((node) => node.type)).toContain('buildr/widget');
65
+ expect(stored.buildrUpdatedBy).toBeDefined();
66
+ });
67
+ it('keeps open documents between requests of the same user', async () => {
68
+ const route = makeRoute();
69
+ const first = await connect(route);
70
+ const created = await first.callTool({
71
+ name: 'create_document',
72
+ arguments: { collection: 'pages', title: 'Kept' },
73
+ });
74
+ const sessionId = created.structuredContent.sessionId;
75
+ // A different client (a new HTTP connection) of the same user continues the same working copy.
76
+ const second = await connect(route);
77
+ const outline = await second.callTool({ name: 'get_outline', arguments: { sessionId } });
78
+ expect(outline.isError).not.toBe(true);
79
+ });
80
+ it('answers 401 without a key, with a wrong key and with only a session cookie', async () => {
81
+ const route = makeRoute();
82
+ expect((await post(route, {})).status).toBe(401);
83
+ expect((await post(route, { authorization: 'users API-Key wrong-key-000000000' })).status).toBe(401);
84
+ expect((await post(route, { cookie: `payload-token=${h.token}` })).status).toBe(401);
85
+ expect((await post(route, { authorization: `JWT ${h.token}` })).status).toBe(401);
86
+ // Even next to a valid key a cookie is ignored, not used.
87
+ expect((await post(route, {
88
+ authorization: `users API-Key ${KEY}`,
89
+ cookie: `payload-token=${h.token}`,
90
+ })).status).toBe(200);
91
+ });
92
+ it('refuses a foreign browser origin and accepts the site itself', async () => {
93
+ const route = makeRoute();
94
+ const auth = { authorization: `users API-Key ${KEY}` };
95
+ expect((await post(route, { ...auth, origin: 'https://evil.example' })).status).toBe(403);
96
+ expect((await post(route, { ...auth, origin: 'http://localhost' })).status).toBe(200);
97
+ const allowed = makeRoute({ allowedOrigins: ['https://claude.ai'] });
98
+ expect((await post(allowed, { ...auth, origin: 'https://claude.ai' })).status).toBe(200);
99
+ });
100
+ it('is stateless: GET and DELETE are 405', async () => {
101
+ const route = makeRoute();
102
+ expect((await route.GET(new Request(URL_))).status).toBe(405);
103
+ expect((await route.DELETE(new Request(URL_, { method: 'DELETE' }))).status).toBe(405);
104
+ });
105
+ it('rate limits per user', async () => {
106
+ const route = makeRoute({ rateLimit: { limit: 2, windowMs: 60_000 } });
107
+ const auth = { authorization: `users API-Key ${KEY}` };
108
+ expect((await post(route, auth)).status).toBe(200);
109
+ expect((await post(route, auth)).status).toBe(200);
110
+ const limited = await post(route, auth);
111
+ expect(limited.status).toBe(429);
112
+ expect(limited.headers.get('retry-after')).not.toBeNull();
113
+ });
114
+ it('blocks a client that keeps failing to authenticate', async () => {
115
+ const route = makeRoute();
116
+ for (let n = 0; n < 31; n += 1)
117
+ await post(route, { 'x-forwarded-for': '203.0.113.9' });
118
+ const blocked = await post(route, {
119
+ 'x-forwarded-for': '203.0.113.9',
120
+ authorization: `users API-Key ${KEY}`,
121
+ });
122
+ expect(blocked.status).toBe(429);
123
+ });
124
+ it('is disabled (404) when the plugin has no mcp.enabled', async () => {
125
+ const off = createBuildrMcpRoute({
126
+ payload: { config: { endpoints: [] } },
127
+ registry: { meta },
128
+ });
129
+ expect((await post(off, { authorization: `users API-Key ${KEY}` })).status).toBe(404);
130
+ expect((await off.GET(new Request(URL_))).status).toBe(404);
131
+ });
132
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mcp.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/mcp.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,231 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { documentSummarySchema } from '../../contract.js';
3
+ import { boot, title, withChild } from './endpoints.test-kit.js';
4
+ const KEY = 'agent-key-0123456789';
5
+ let blocked = false;
6
+ let h;
7
+ let agent;
8
+ beforeAll(async () => {
9
+ h = await boot('mcp', {
10
+ collections: {
11
+ pages: { context: 'page', templates: true, path: (doc) => `/${String(doc['slug'])}` },
12
+ posts: { context: 'post' },
13
+ },
14
+ mcp: {
15
+ enabled: true,
16
+ collections: ['pages'],
17
+ rateLimiter: {
18
+ hit: () => (blocked ? { allowed: false, retryAfterSeconds: 7 } : { allowed: true }),
19
+ },
20
+ },
21
+ }, {
22
+ // Only "editor*" users may create pages; every signed-in user may read and update them.
23
+ pageAccess: {
24
+ create: ({ req }) => String(req.user.email).startsWith('editor'),
25
+ read: ({ req }) => req.user !== null && req.user !== undefined,
26
+ update: ({ req }) => req.user !== null && req.user !== undefined,
27
+ },
28
+ collections: [
29
+ { slug: 'users', admin: { useAsTitle: 'email' }, auth: { useAPIKey: true }, fields: [] },
30
+ {
31
+ slug: 'posts',
32
+ admin: { useAsTitle: 'title' },
33
+ fields: [{ name: 'title', type: 'text' }],
34
+ versions: { drafts: true },
35
+ },
36
+ ],
37
+ });
38
+ await h.payload.create({
39
+ collection: 'users',
40
+ data: {
41
+ email: 'editor-agent@example.com',
42
+ password: 'secret-password',
43
+ enableAPIKey: true,
44
+ apiKey: KEY,
45
+ },
46
+ });
47
+ agent = (method, path, body) => h.call(method, path, {
48
+ token: null,
49
+ headers: { authorization: `users API-Key ${KEY}` },
50
+ ...(body === undefined ? {} : { body }),
51
+ });
52
+ });
53
+ afterAll(() => h.close());
54
+ describe('API-key access', () => {
55
+ it('reaches the session endpoint as the agent user, without publish permission', async () => {
56
+ const session = await agent('GET', '/buildr/session');
57
+ expect(session.status).toBe(200);
58
+ expect(session.body.user.email).toBe('editor-agent@example.com');
59
+ expect(session.body.permissions).toMatchObject({ canEdit: true, canPublish: false });
60
+ });
61
+ it('is refused without a key', async () => {
62
+ const list = await h.call('GET', '/buildr/documents?collection=pages', { token: null });
63
+ expect(list.status).toBe(401);
64
+ const create = await h.call('POST', '/buildr/documents', {
65
+ token: null,
66
+ body: { collection: 'pages', title: 'x' },
67
+ });
68
+ expect(create.status).toBe(401);
69
+ });
70
+ });
71
+ describe('POST /buildr/documents', () => {
72
+ it('creates a draft that is never published and attributes it to the agent user', async () => {
73
+ const created = await agent('POST', '/buildr/documents', {
74
+ collection: 'pages',
75
+ title: 'About us',
76
+ slug: 'about-us',
77
+ });
78
+ expect(created.status).toBe(201);
79
+ const summary = documentSummarySchema.parse(created.body);
80
+ expect(summary).toMatchObject({
81
+ title: 'About us',
82
+ slug: 'about-us',
83
+ status: 'draft',
84
+ revision: 0,
85
+ layoutSource: 'builtin',
86
+ previewPath: '/about-us',
87
+ });
88
+ const stored = await h.payload.findByID({
89
+ collection: 'pages',
90
+ id: summary.ref.id,
91
+ draft: true,
92
+ depth: 0,
93
+ });
94
+ expect(stored['_status']).toBe('draft');
95
+ expect(stored['buildrUpdatedBy']).toMatchObject({ relationTo: 'users' });
96
+ // The stored (non-draft) document is a draft too: nothing was published.
97
+ const live = await h.payload.findByID({
98
+ collection: 'pages',
99
+ id: summary.ref.id,
100
+ draft: false,
101
+ depth: 0,
102
+ });
103
+ expect(live['_status']).toBe('draft');
104
+ });
105
+ it('answers 403 to a user without create access', async () => {
106
+ const other = await h.login('viewer@example.com');
107
+ const before = (await h.payload.count({ collection: 'pages' })).totalDocs;
108
+ const denied = await h.call('POST', '/buildr/documents', {
109
+ token: other,
110
+ body: { collection: 'pages', title: 'Nope' },
111
+ });
112
+ expect(denied.status).toBe(403);
113
+ expect((await h.payload.count({ collection: 'pages' })).totalDocs).toBe(before);
114
+ });
115
+ it('rejects bodies that do not match the contract and unknown collections', async () => {
116
+ expect((await agent('POST', '/buildr/documents', { collection: 'pages' })).status).toBe(400);
117
+ const badSlug = { collection: 'pages', title: 'x', slug: 'Not OK' };
118
+ expect((await agent('POST', '/buildr/documents', badSlug)).status).toBe(400);
119
+ const unknown = { collection: 'nope', title: 'x' };
120
+ expect((await agent('POST', '/buildr/documents', unknown)).status).toBe(404);
121
+ });
122
+ it('keeps an agent out of collections outside mcp.collections, but not a browser user', async () => {
123
+ const body = { collection: 'posts', title: 'Hello' };
124
+ expect((await agent('POST', '/buildr/documents', body)).status).toBe(403);
125
+ expect((await agent('GET', '/buildr/documents?collection=posts')).status).toBe(403);
126
+ expect((await h.call('POST', '/buildr/documents', { body })).status).toBe(201);
127
+ });
128
+ it('is limited per API-key user; browser sessions are not limited', async () => {
129
+ blocked = true;
130
+ try {
131
+ const limited = await agent('POST', '/buildr/documents', {
132
+ collection: 'pages',
133
+ title: 'Too many',
134
+ });
135
+ expect(limited.status).toBe(429);
136
+ const browser = await h.call('POST', '/buildr/documents', {
137
+ body: { collection: 'pages', title: 'Browser' },
138
+ });
139
+ expect(browser.status).toBe(201);
140
+ }
141
+ finally {
142
+ blocked = false;
143
+ }
144
+ });
145
+ });
146
+ describe('GET /buildr/documents', () => {
147
+ it('lists documents with title, slug, status and layout source, and searches', async () => {
148
+ const all = await agent('GET', '/buildr/documents?collection=pages');
149
+ expect(all.status).toBe(200);
150
+ expect(all.body.page).toBe(1);
151
+ const titles = all.body.items.map((item) => item.title);
152
+ expect(titles).toContain('About us');
153
+ expect(documentSummarySchema.parse(all.body.items[0])).toBeDefined();
154
+ const found = await agent('GET', '/buildr/documents?collection=pages&search=about');
155
+ expect(found.body.items.map((item) => item.title)).toEqual(['About us']);
156
+ const none = await agent('GET', '/buildr/documents?collection=pages&search=zzzz');
157
+ expect(none.body.items).toEqual([]);
158
+ });
159
+ it('needs a collection, and a builder collection', async () => {
160
+ expect((await agent('GET', '/buildr/documents')).status).toBe(400);
161
+ expect((await agent('GET', '/buildr/documents?collection=users')).status).toBe(404);
162
+ });
163
+ });
164
+ describe('writes by an API key', () => {
165
+ it('saves like the editor and stamps buildrUpdatedBy in every version', async () => {
166
+ const created = await agent('POST', '/buildr/documents', {
167
+ collection: 'pages',
168
+ title: 'Save me',
169
+ });
170
+ const { id } = created.body.ref;
171
+ const path = `/buildr/documents/pages/${id}`;
172
+ const save = (baseRevision) => agent('PUT', path, { document: withChild(title('Hi')), baseRevision, autosave: false });
173
+ expect((await save(0)).status).toBe(200);
174
+ const versions = await h.payload.findVersions({
175
+ collection: 'pages',
176
+ where: { parent: { equals: id } },
177
+ depth: 0,
178
+ limit: 5,
179
+ });
180
+ expect(versions.docs.length).toBeGreaterThan(0);
181
+ for (const version of versions.docs) {
182
+ expect(version.version['buildrUpdatedBy']).toMatchObject({
183
+ relationTo: 'users',
184
+ });
185
+ }
186
+ // The conflict rule is the same as in the editor.
187
+ expect((await save(0)).status).toBe(409);
188
+ });
189
+ it('cannot publish unless mcp.allowPublish is on', async () => {
190
+ const created = await agent('POST', '/buildr/documents', {
191
+ collection: 'pages',
192
+ title: 'Stay draft',
193
+ });
194
+ const { id } = created.body.ref;
195
+ const denied = await agent('POST', `/buildr/documents/pages/${id}/publish`, {
196
+ baseRevision: 0,
197
+ });
198
+ expect(denied.status).toBe(403);
199
+ const stored = await h.payload.findByID({ collection: 'pages', id, draft: true, depth: 0 });
200
+ expect(stored['_status']).toBe('draft');
201
+ });
202
+ });
203
+ describe('GET /buildr/documents/:collection/:id/revision (PB-143)', () => {
204
+ it('answers with the revision and names the user who saved, without the document', async () => {
205
+ const created = await agent('POST', '/buildr/documents', {
206
+ collection: 'pages',
207
+ title: 'Watched',
208
+ });
209
+ const { id } = created.body.ref;
210
+ const path = `/buildr/documents/pages/${id}`;
211
+ const before = await agent('GET', `${path}/revision`);
212
+ expect(before.status).toBe(200);
213
+ expect(before.body).toMatchObject({ revision: 0, updatedBy: 'editor-agent@example.com' });
214
+ expect(before.body.document).toBeUndefined();
215
+ const saved = await agent('PUT', path, {
216
+ document: withChild(title('Hi')),
217
+ baseRevision: 0,
218
+ autosave: false,
219
+ });
220
+ expect(saved.status).toBe(200);
221
+ const after = await agent('GET', `${path}/revision`);
222
+ expect(after.body).toMatchObject({ revision: 1, updatedBy: 'editor-agent@example.com' });
223
+ expect(typeof after.body.updatedAt).toBe('string');
224
+ });
225
+ it('needs a signed-in user and an existing document', async () => {
226
+ const anonymous = await h.call('GET', '/buildr/documents/pages/1/revision', { token: null });
227
+ expect(anonymous.status).toBe(401);
228
+ const missing = await agent('GET', '/buildr/documents/pages/999999/revision');
229
+ expect(missing.status).toBe(404);
230
+ });
231
+ });
@@ -0,0 +1,7 @@
1
+ import type { Endpoint } from 'payload';
2
+ import { type EndpointEnv } from './context.ts';
3
+ /** `GET /api/buildr/media?search&type&page`: the picker's listing, newest first. */
4
+ export declare const mediaListEndpoint: (env: EndpointEnv) => Endpoint;
5
+ /** `POST /api/buildr/media` (multipart `file`, `alt`): uploads through the Local API; `alt` is required. */
6
+ export declare const mediaUploadEndpoint: (env: EndpointEnv) => Endpoint;
7
+ //# sourceMappingURL=media.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/media.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAkB,MAAM,SAAS,CAAC;AAIxD,OAAO,EAAW,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AA0BzD,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,QAAS,WAAW,KAAG,QA4CnD,CAAC;AAEH,4GAA4G;AAC5G,eAAO,MAAM,mBAAmB,QAAS,WAAW,KAAG,QAsCrD,CAAC"}