@next-buildr/payload 0.0.0-stage → 2.0.1

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/README.md +4 -2
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/adapter/adapter.test.d.ts +2 -0
  5. package/dist/adapter/adapter.test.d.ts.map +1 -0
  6. package/dist/adapter/adapter.test.js +282 -0
  7. package/dist/adapter/canvas-data-source.d.ts +9 -0
  8. package/dist/adapter/canvas-data-source.d.ts.map +1 -0
  9. package/dist/adapter/canvas-data-source.js +24 -0
  10. package/dist/adapter/document-adapter.d.ts +20 -0
  11. package/dist/adapter/document-adapter.d.ts.map +1 -0
  12. package/dist/adapter/document-adapter.js +183 -0
  13. package/dist/adapter/http.d.ts +34 -0
  14. package/dist/adapter/http.d.ts.map +1 -0
  15. package/dist/adapter/http.js +72 -0
  16. package/dist/adapter/index.d.ts +4 -0
  17. package/dist/adapter/index.d.ts.map +1 -0
  18. package/dist/adapter/index.js +5 -0
  19. package/dist/admin/index.d.ts +4 -0
  20. package/dist/admin/index.d.ts.map +1 -0
  21. package/dist/admin/index.js +2 -0
  22. package/dist/admin/layout-field-connected.d.ts +6 -0
  23. package/dist/admin/layout-field-connected.d.ts.map +1 -0
  24. package/dist/admin/layout-field-connected.js +16 -0
  25. package/dist/admin/layout-field-view.d.ts +16 -0
  26. package/dist/admin/layout-field-view.d.ts.map +1 -0
  27. package/dist/admin/layout-field-view.js +14 -0
  28. package/dist/admin/layout-field.d.ts +6 -0
  29. package/dist/admin/layout-field.d.ts.map +1 -0
  30. package/dist/admin/layout-field.js +13 -0
  31. package/dist/admin/layout-field.test.d.ts +2 -0
  32. package/dist/admin/layout-field.test.d.ts.map +1 -0
  33. package/dist/admin/layout-field.test.js +41 -0
  34. package/dist/contract.d.ts +289 -0
  35. package/dist/contract.d.ts.map +1 -0
  36. package/dist/contract.js +176 -0
  37. package/dist/data/build-context.d.ts +20 -0
  38. package/dist/data/build-context.d.ts.map +1 -0
  39. package/dist/data/build-context.js +25 -0
  40. package/dist/data/data.test.d.ts +2 -0
  41. package/dist/data/data.test.d.ts.map +1 -0
  42. package/dist/data/data.test.js +261 -0
  43. package/dist/data/index.d.ts +10 -0
  44. package/dist/data/index.d.ts.map +1 -0
  45. package/dist/data/index.js +5 -0
  46. package/dist/data/normalize.d.ts +14 -0
  47. package/dist/data/normalize.d.ts.map +1 -0
  48. package/dist/data/normalize.js +128 -0
  49. package/dist/data/payload-data-source.d.ts +42 -0
  50. package/dist/data/payload-data-source.d.ts.map +1 -0
  51. package/dist/data/payload-data-source.js +179 -0
  52. package/dist/data/payload-data-source.test.d.ts +2 -0
  53. package/dist/data/payload-data-source.test.d.ts.map +1 -0
  54. package/dist/data/payload-data-source.test.js +160 -0
  55. package/dist/data/resolve-layout.d.ts +45 -0
  56. package/dist/data/resolve-layout.d.ts.map +1 -0
  57. package/dist/data/resolve-layout.js +111 -0
  58. package/dist/data/schema-from-fields.d.ts +63 -0
  59. package/dist/data/schema-from-fields.d.ts.map +1 -0
  60. package/dist/data/schema-from-fields.js +178 -0
  61. package/dist/data/where.d.ts +53 -0
  62. package/dist/data/where.d.ts.map +1 -0
  63. package/dist/data/where.js +210 -0
  64. package/dist/mcp/http-backend.d.ts +35 -0
  65. package/dist/mcp/http-backend.d.ts.map +1 -0
  66. package/dist/mcp/http-backend.js +337 -0
  67. package/dist/mcp/http-backend.test.d.ts +2 -0
  68. package/dist/mcp/http-backend.test.d.ts.map +1 -0
  69. package/dist/mcp/http-backend.test.js +178 -0
  70. package/dist/mcp/index.d.ts +2 -0
  71. package/dist/mcp/index.d.ts.map +1 -0
  72. package/dist/mcp/index.js +2 -0
  73. package/dist/mcp/route.d.ts +63 -0
  74. package/dist/mcp/route.d.ts.map +1 -0
  75. package/dist/mcp/route.js +230 -0
  76. package/dist/next/get-document.d.ts +64 -0
  77. package/dist/next/get-document.d.ts.map +1 -0
  78. package/dist/next/get-document.js +208 -0
  79. package/dist/next/get-document.test.d.ts +2 -0
  80. package/dist/next/get-document.test.d.ts.map +1 -0
  81. package/dist/next/get-document.test.js +155 -0
  82. package/dist/next/index.d.ts +9 -0
  83. package/dist/next/index.d.ts.map +1 -0
  84. package/dist/next/index.js +4 -0
  85. package/dist/next/next-helpers.test.d.ts +2 -0
  86. package/dist/next/next-helpers.test.d.ts.map +1 -0
  87. package/dist/next/next-helpers.test.js +122 -0
  88. package/dist/next/revalidate.d.ts +44 -0
  89. package/dist/next/revalidate.d.ts.map +1 -0
  90. package/dist/next/revalidate.js +45 -0
  91. package/dist/next/seo.d.ts +43 -0
  92. package/dist/next/seo.d.ts.map +1 -0
  93. package/dist/next/seo.js +53 -0
  94. package/dist/next/tags.d.ts +23 -0
  95. package/dist/next/tags.d.ts.map +1 -0
  96. package/dist/next/tags.js +31 -0
  97. package/dist/plugin/access.d.ts +19 -0
  98. package/dist/plugin/access.d.ts.map +1 -0
  99. package/dist/plugin/access.js +32 -0
  100. package/dist/plugin/collections/form-submissions.d.ts +10 -0
  101. package/dist/plugin/collections/form-submissions.d.ts.map +1 -0
  102. package/dist/plugin/collections/form-submissions.js +37 -0
  103. package/dist/plugin/collections/templates.d.ts +13 -0
  104. package/dist/plugin/collections/templates.d.ts.map +1 -0
  105. package/dist/plugin/collections/templates.js +118 -0
  106. package/dist/plugin/endpoints/access.test.d.ts +2 -0
  107. package/dist/plugin/endpoints/access.test.d.ts.map +1 -0
  108. package/dist/plugin/endpoints/access.test.js +91 -0
  109. package/dist/plugin/endpoints/adapter-contract.test.d.ts +2 -0
  110. package/dist/plugin/endpoints/adapter-contract.test.d.ts.map +1 -0
  111. package/dist/plugin/endpoints/adapter-contract.test.js +45 -0
  112. package/dist/plugin/endpoints/context.d.ts +65 -0
  113. package/dist/plugin/endpoints/context.d.ts.map +1 -0
  114. package/dist/plugin/endpoints/context.js +113 -0
  115. package/dist/plugin/endpoints/data-context.d.ts +8 -0
  116. package/dist/plugin/endpoints/data-context.d.ts.map +1 -0
  117. package/dist/plugin/endpoints/data-context.js +63 -0
  118. package/dist/plugin/endpoints/data-endpoints.test.d.ts +2 -0
  119. package/dist/plugin/endpoints/data-endpoints.test.d.ts.map +1 -0
  120. package/dist/plugin/endpoints/data-endpoints.test.js +125 -0
  121. package/dist/plugin/endpoints/data-query.test.d.ts +2 -0
  122. package/dist/plugin/endpoints/data-query.test.d.ts.map +1 -0
  123. package/dist/plugin/endpoints/data-query.test.js +96 -0
  124. package/dist/plugin/endpoints/data-schema.d.ts +5 -0
  125. package/dist/plugin/endpoints/data-schema.d.ts.map +1 -0
  126. package/dist/plugin/endpoints/data-schema.js +21 -0
  127. package/dist/plugin/endpoints/data.d.ts +10 -0
  128. package/dist/plugin/endpoints/data.d.ts.map +1 -0
  129. package/dist/plugin/endpoints/data.js +96 -0
  130. package/dist/plugin/endpoints/document.d.ts +11 -0
  131. package/dist/plugin/endpoints/document.d.ts.map +1 -0
  132. package/dist/plugin/endpoints/document.js +119 -0
  133. package/dist/plugin/endpoints/documents.d.ts +10 -0
  134. package/dist/plugin/endpoints/documents.d.ts.map +1 -0
  135. package/dist/plugin/endpoints/documents.js +145 -0
  136. package/dist/plugin/endpoints/endpoints.test-kit.d.ts +42 -0
  137. package/dist/plugin/endpoints/endpoints.test-kit.d.ts.map +1 -0
  138. package/dist/plugin/endpoints/endpoints.test-kit.js +141 -0
  139. package/dist/plugin/endpoints/endpoints.test.d.ts +2 -0
  140. package/dist/plugin/endpoints/endpoints.test.d.ts.map +1 -0
  141. package/dist/plugin/endpoints/endpoints.test.js +209 -0
  142. package/dist/plugin/endpoints/forms.d.ts +16 -0
  143. package/dist/plugin/endpoints/forms.d.ts.map +1 -0
  144. package/dist/plugin/endpoints/forms.js +199 -0
  145. package/dist/plugin/endpoints/forms.test.d.ts +2 -0
  146. package/dist/plugin/endpoints/forms.test.d.ts.map +1 -0
  147. package/dist/plugin/endpoints/forms.test.js +346 -0
  148. package/dist/plugin/endpoints/guards.d.ts +9 -0
  149. package/dist/plugin/endpoints/guards.d.ts.map +1 -0
  150. package/dist/plugin/endpoints/guards.js +35 -0
  151. package/dist/plugin/endpoints/index.d.ts +6 -0
  152. package/dist/plugin/endpoints/index.d.ts.map +1 -0
  153. package/dist/plugin/endpoints/index.js +32 -0
  154. package/dist/plugin/endpoints/locales-no-fallback.test.d.ts +2 -0
  155. package/dist/plugin/endpoints/locales-no-fallback.test.d.ts.map +1 -0
  156. package/dist/plugin/endpoints/locales-no-fallback.test.js +30 -0
  157. package/dist/plugin/endpoints/locales.test.d.ts +2 -0
  158. package/dist/plugin/endpoints/locales.test.d.ts.map +1 -0
  159. package/dist/plugin/endpoints/locales.test.js +128 -0
  160. package/dist/plugin/endpoints/manifest.d.ts +5 -0
  161. package/dist/plugin/endpoints/manifest.d.ts.map +1 -0
  162. package/dist/plugin/endpoints/manifest.js +18 -0
  163. package/dist/plugin/endpoints/mcp-http-backend.test.d.ts +2 -0
  164. package/dist/plugin/endpoints/mcp-http-backend.test.d.ts.map +1 -0
  165. package/dist/plugin/endpoints/mcp-http-backend.test.js +84 -0
  166. package/dist/plugin/endpoints/mcp-off.test.d.ts +2 -0
  167. package/dist/plugin/endpoints/mcp-off.test.d.ts.map +1 -0
  168. package/dist/plugin/endpoints/mcp-off.test.js +33 -0
  169. package/dist/plugin/endpoints/mcp-publish.test.d.ts +2 -0
  170. package/dist/plugin/endpoints/mcp-publish.test.d.ts.map +1 -0
  171. package/dist/plugin/endpoints/mcp-publish.test.js +42 -0
  172. package/dist/plugin/endpoints/mcp-route.test.d.ts +2 -0
  173. package/dist/plugin/endpoints/mcp-route.test.d.ts.map +1 -0
  174. package/dist/plugin/endpoints/mcp-route.test.js +132 -0
  175. package/dist/plugin/endpoints/mcp.test.d.ts +2 -0
  176. package/dist/plugin/endpoints/mcp.test.d.ts.map +1 -0
  177. package/dist/plugin/endpoints/mcp.test.js +231 -0
  178. package/dist/plugin/endpoints/media.d.ts +7 -0
  179. package/dist/plugin/endpoints/media.d.ts.map +1 -0
  180. package/dist/plugin/endpoints/media.js +115 -0
  181. package/dist/plugin/endpoints/media.test.d.ts +2 -0
  182. package/dist/plugin/endpoints/media.test.d.ts.map +1 -0
  183. package/dist/plugin/endpoints/media.test.js +95 -0
  184. package/dist/plugin/endpoints/publish-a11y.test.d.ts +2 -0
  185. package/dist/plugin/endpoints/publish-a11y.test.d.ts.map +1 -0
  186. package/dist/plugin/endpoints/publish-a11y.test.js +27 -0
  187. package/dist/plugin/endpoints/publish.d.ts +9 -0
  188. package/dist/plugin/endpoints/publish.d.ts.map +1 -0
  189. package/dist/plugin/endpoints/publish.js +81 -0
  190. package/dist/plugin/endpoints/rename.d.ts +9 -0
  191. package/dist/plugin/endpoints/rename.d.ts.map +1 -0
  192. package/dist/plugin/endpoints/rename.js +47 -0
  193. package/dist/plugin/endpoints/respond.d.ts +9 -0
  194. package/dist/plugin/endpoints/respond.d.ts.map +1 -0
  195. package/dist/plugin/endpoints/respond.js +10 -0
  196. package/dist/plugin/endpoints/samples.d.ts +5 -0
  197. package/dist/plugin/endpoints/samples.d.ts.map +1 -0
  198. package/dist/plugin/endpoints/samples.js +42 -0
  199. package/dist/plugin/endpoints/save.d.ts +9 -0
  200. package/dist/plugin/endpoints/save.d.ts.map +1 -0
  201. package/dist/plugin/endpoints/save.js +58 -0
  202. package/dist/plugin/endpoints/session.d.ts +5 -0
  203. package/dist/plugin/endpoints/session.d.ts.map +1 -0
  204. package/dist/plugin/endpoints/session.js +30 -0
  205. package/dist/plugin/endpoints/templates.test.d.ts +2 -0
  206. package/dist/plugin/endpoints/templates.test.d.ts.map +1 -0
  207. package/dist/plugin/endpoints/templates.test.js +191 -0
  208. package/dist/plugin/fields.d.ts +28 -0
  209. package/dist/plugin/fields.d.ts.map +1 -0
  210. package/dist/plugin/fields.js +62 -0
  211. package/dist/plugin/forms/forms.test.d.ts +2 -0
  212. package/dist/plugin/forms/forms.test.d.ts.map +1 -0
  213. package/dist/plugin/forms/forms.test.js +88 -0
  214. package/dist/plugin/forms/notify.d.ts +6 -0
  215. package/dist/plugin/forms/notify.d.ts.map +1 -0
  216. package/dist/plugin/forms/notify.js +13 -0
  217. package/dist/plugin/forms/rate-limit.d.ts +24 -0
  218. package/dist/plugin/forms/rate-limit.d.ts.map +1 -0
  219. package/dist/plugin/forms/rate-limit.js +36 -0
  220. package/dist/plugin/forms/validate.d.ts +22 -0
  221. package/dist/plugin/forms/validate.d.ts.map +1 -0
  222. package/dist/plugin/forms/validate.js +79 -0
  223. package/dist/plugin/hooks/layout-hook.d.ts +9 -0
  224. package/dist/plugin/hooks/layout-hook.d.ts.map +1 -0
  225. package/dist/plugin/hooks/layout-hook.js +33 -0
  226. package/dist/plugin/hooks/layout-hook.test.d.ts +2 -0
  227. package/dist/plugin/hooks/layout-hook.test.d.ts.map +1 -0
  228. package/dist/plugin/hooks/layout-hook.test.js +191 -0
  229. package/dist/plugin/hooks/process-layout.d.ts +32 -0
  230. package/dist/plugin/hooks/process-layout.d.ts.map +1 -0
  231. package/dist/plugin/hooks/process-layout.js +68 -0
  232. package/dist/plugin/hooks/read-layout.d.ts +17 -0
  233. package/dist/plugin/hooks/read-layout.d.ts.map +1 -0
  234. package/dist/plugin/hooks/read-layout.js +39 -0
  235. package/dist/plugin/index.d.ts +18 -0
  236. package/dist/plugin/index.d.ts.map +1 -0
  237. package/dist/plugin/index.js +105 -0
  238. package/dist/plugin/locales.d.ts +32 -0
  239. package/dist/plugin/locales.d.ts.map +1 -0
  240. package/dist/plugin/locales.js +56 -0
  241. package/dist/plugin/options.d.ts +77 -0
  242. package/dist/plugin/options.d.ts.map +1 -0
  243. package/dist/plugin/options.js +123 -0
  244. package/dist/plugin/plugin.test.d.ts +2 -0
  245. package/dist/plugin/plugin.test.d.ts.map +1 -0
  246. package/dist/plugin/plugin.test.js +186 -0
  247. package/dist/plugin/write-guard.d.ts +14 -0
  248. package/dist/plugin/write-guard.d.ts.map +1 -0
  249. package/dist/plugin/write-guard.js +18 -0
  250. package/package.json +108 -3
@@ -0,0 +1,346 @@
1
+ import { createRegistryMeta, p, s, } from '@next-buildr/core';
2
+ import { handleEndpoints } from 'payload';
3
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4
+ import { buildrPlugin } from '../index.js';
5
+ import { BUILDR_WRITE } from '../write-guard.js';
6
+ import { meta as baseMeta, migrations as baseMigrations, boot, } from './endpoints.test-kit.js';
7
+ const field = (type, label, props, formField) => ({
8
+ type,
9
+ version: 1,
10
+ label,
11
+ category: 'forms',
12
+ props,
13
+ contentCategories: ['flow', 'form-control'],
14
+ styles: { groups: [] },
15
+ runtime: 'shared',
16
+ formField,
17
+ });
18
+ const common = {
19
+ name: p.text({ label: 'Name', default: '' }),
20
+ required: p.boolean({ label: 'Required', default: false }),
21
+ };
22
+ const components = [
23
+ {
24
+ type: 'buildr/form',
25
+ version: 1,
26
+ label: 'Form',
27
+ category: 'forms',
28
+ props: {},
29
+ contentCategories: ['flow'],
30
+ styles: { groups: [] },
31
+ runtime: 'shared',
32
+ slots: { default: {} },
33
+ },
34
+ field('buildr/text-field', 'Text', { ...common, maxLength: p.number({ label: 'Max length', default: 100 }) }, { valueType: 'string', nameProp: 'name', requiredProp: 'required', maxLengthProp: 'maxLength' }),
35
+ field('buildr/email-field', 'Email', common, {
36
+ valueType: 'email',
37
+ nameProp: 'name',
38
+ requiredProp: 'required',
39
+ }),
40
+ field('buildr/choice', 'Choice', {
41
+ ...common,
42
+ options: p.list(p.object({
43
+ label: p.text({ label: 'Label', default: 'Option' }),
44
+ value: p.text({ label: 'Value', default: '' }),
45
+ }), { label: 'Options' }),
46
+ }, { valueType: 'enum', nameProp: 'name', requiredProp: 'required', optionsProp: 'options' }),
47
+ field('buildr/check', 'Check', common, {
48
+ valueType: 'boolean',
49
+ nameProp: 'name',
50
+ requiredProp: 'required',
51
+ }),
52
+ ];
53
+ const meta = createRegistryMeta({ components: [...baseMeta.list(), ...components] });
54
+ const migrations = {
55
+ ...baseMigrations,
56
+ ...Object.fromEntries(components.map((component) => [component.type, { currentVersion: 1, steps: [] }])),
57
+ };
58
+ const FORM = 'form000001';
59
+ const fieldNodes = (extra = []) => [
60
+ {
61
+ id: 'field00001',
62
+ type: 'buildr/text-field',
63
+ props: { name: s('subject'), required: s(true), maxLength: s(10) },
64
+ },
65
+ { id: 'field00002', type: 'buildr/email-field', props: { name: s('email'), required: s(true) } },
66
+ {
67
+ id: 'field00003',
68
+ type: 'buildr/choice',
69
+ props: {
70
+ name: s('topic'),
71
+ options: s([
72
+ { label: 'Sales', value: 'sales' },
73
+ { label: 'Help', value: 'help' },
74
+ ]),
75
+ },
76
+ },
77
+ { id: 'field00004', type: 'buildr/check', props: { name: s('consent') } },
78
+ ...extra,
79
+ ];
80
+ /** A page holding one form with `fields` inside it. */
81
+ const formLayout = (fields = fieldNodes()) => {
82
+ const types = new Set(['buildr/page', 'buildr/form', ...fields.map((node) => node.type)]);
83
+ return {
84
+ schemaVersion: 1,
85
+ root: 'root',
86
+ nodes: {
87
+ root: { id: 'root', type: 'buildr/page', slots: { default: [FORM] } },
88
+ [FORM]: { id: FORM, type: 'buildr/form', slots: { default: fields.map((node) => node.id) } },
89
+ ...Object.fromEntries(fields.map((node) => [node.id, node])),
90
+ },
91
+ components: Object.fromEntries([...types].map((type) => [type, 1])),
92
+ };
93
+ };
94
+ const valid = { subject: 'Hello', email: 'a@b.co', topic: 'help', consent: 'on' };
95
+ let h;
96
+ let counter = 0;
97
+ const mails = [];
98
+ let mailDown = false;
99
+ beforeAll(async () => {
100
+ h = await boot('forms', {
101
+ registry: { meta, migrations },
102
+ collections: { pages: { context: 'page', templates: true } },
103
+ forms: {
104
+ enabled: true,
105
+ notifyAllowlist: ['@example.com'],
106
+ notifyTo: ['ops@example.com'],
107
+ rateLimit: { limit: 4, windowMs: 60_000 },
108
+ },
109
+ }, {
110
+ email: () => ({
111
+ name: 'test',
112
+ defaultFromAddress: 'noreply@example.com',
113
+ defaultFromName: 'Site',
114
+ sendEmail: async (message) => {
115
+ if (mailDown)
116
+ throw new Error('smtp down');
117
+ mails.push(message);
118
+ },
119
+ }),
120
+ });
121
+ });
122
+ afterAll(() => h.close());
123
+ const publish = (layout, data = {}) => h.payload.create({
124
+ collection: 'pages',
125
+ data: {
126
+ title: 'Contact',
127
+ slug: `p${++counter}`,
128
+ layout,
129
+ _status: 'published',
130
+ ...data,
131
+ },
132
+ });
133
+ /** POSTs to the form endpoint from a fresh address (the limiter is per address). */
134
+ async function submit(path, values, options = {}) {
135
+ const urlencoded = options.as === 'urlencoded';
136
+ const response = await handleEndpoints({
137
+ config: h.payload.config,
138
+ request: new Request(`http://localhost/api/buildr/forms/${path}`, {
139
+ method: 'POST',
140
+ headers: {
141
+ 'content-type': urlencoded ? 'application/x-www-form-urlencoded' : 'application/json',
142
+ 'x-forwarded-for': options.ip ?? `10.0.0.${++counter}`,
143
+ ...options.headers,
144
+ },
145
+ body: urlencoded
146
+ ? new URLSearchParams(values).toString()
147
+ : JSON.stringify(values),
148
+ }),
149
+ });
150
+ const text = await response.text();
151
+ return {
152
+ status: response.status,
153
+ body: text === '' ? undefined : JSON.parse(text),
154
+ headers: response.headers,
155
+ };
156
+ }
157
+ const submissions = async () => (await h.payload.find({ collection: 'buildr-form-submissions', limit: 100, depth: 0 }))
158
+ .docs;
159
+ describe('POST /buildr/forms/:collection/:id/:nodeId', () => {
160
+ it('stores a valid submission and notifies the allowlisted recipient', async () => {
161
+ const page = await publish(formLayout());
162
+ const before = (await submissions()).length;
163
+ const sent = await submit(`pages/${page.id}/${FORM}`, valid, {
164
+ headers: { 'user-agent': 'vitest' },
165
+ });
166
+ expect(sent).toMatchObject({ status: 200, body: { ok: true } });
167
+ const stored = (await submissions()).find((doc) => doc['form'].documentId === String(page.id));
168
+ expect((await submissions()).length).toBe(before + 1);
169
+ expect(stored).toMatchObject({
170
+ form: { collection: 'pages', documentId: String(page.id), nodeId: FORM },
171
+ data: { subject: 'Hello', email: 'a@b.co', topic: 'help', consent: true },
172
+ meta: { userAgent: 'vitest' },
173
+ });
174
+ expect(stored?.['meta'].ipHash).toMatch(/^[0-9a-f]{32}$/);
175
+ expect(mails).toEqual([
176
+ expect.objectContaining({
177
+ to: 'ops@example.com',
178
+ text: expect.stringContaining('subject: Hello'),
179
+ }),
180
+ ]);
181
+ });
182
+ it('does not lose the submission when the notification fails', async () => {
183
+ const page = await publish(formLayout());
184
+ mailDown = true;
185
+ const answer = await submit(`pages/${page.id}/${FORM}`, valid);
186
+ mailDown = false;
187
+ expect(answer.status).toBe(200);
188
+ expect((await submissions()).some((doc) => doc['form'].documentId === String(page.id))).toBe(true);
189
+ });
190
+ it('rejects invalid values, field by field', async () => {
191
+ const page = await publish(formLayout());
192
+ const sent = await submit(`pages/${page.id}/${FORM}`, {
193
+ subject: 'much too long for this field',
194
+ email: 'not-an-email',
195
+ topic: 'refunds',
196
+ });
197
+ expect(sent.status).toBe(422);
198
+ expect(sent.body.codes).toEqual({
199
+ subject: 'too-long',
200
+ email: 'invalid',
201
+ topic: 'invalid',
202
+ });
203
+ expect(Object.keys(sent.body.errors)).toEqual(['subject', 'email', 'topic']);
204
+ const missing = await submit(`pages/${page.id}/${FORM}`, {});
205
+ expect(missing.body.codes).toEqual({ subject: 'required', email: 'required' });
206
+ });
207
+ it('never accepts a field the published document does not have', async () => {
208
+ const page = await publish(formLayout());
209
+ const sent = await submit(`pages/${page.id}/${FORM}`, {
210
+ ...valid,
211
+ isAdmin: 'true',
212
+ _redirect: 'https://evil.example',
213
+ });
214
+ expect(sent.status).toBe(422);
215
+ expect(sent.body.codes['isAdmin']).toBe('unknown');
216
+ expect(sent.body.codes['_redirect']).toBe('unknown');
217
+ });
218
+ it('derives the schema from the published layout, not from a newer draft', async () => {
219
+ const page = await publish(formLayout());
220
+ const extended = formLayout(fieldNodes([
221
+ {
222
+ id: 'field00005',
223
+ type: 'buildr/text-field',
224
+ props: { name: s('phone'), maxLength: s(20) },
225
+ },
226
+ ]));
227
+ await h.payload.update({
228
+ collection: 'pages',
229
+ id: page.id,
230
+ data: { layout: extended },
231
+ draft: true,
232
+ context: { [BUILDR_WRITE]: true },
233
+ });
234
+ const sent = await submit(`pages/${page.id}/${FORM}`, { ...valid, phone: '123' });
235
+ expect(sent.status).toBe(422);
236
+ expect(sent.body.codes).toEqual({ phone: 'unknown' });
237
+ });
238
+ it('is unreachable for a document that was never published', async () => {
239
+ const draft = await h.payload.create({
240
+ collection: 'pages',
241
+ data: { title: 'Wip', slug: `d${++counter}`, layout: formLayout() },
242
+ draft: true,
243
+ });
244
+ const sent = await submit(`pages/${draft.id}/${FORM}`, valid);
245
+ expect(sent.status).toBe(404);
246
+ });
247
+ it('answers 404 for a node that is not a form, a missing node and a collection it does not serve', async () => {
248
+ const page = await publish(formLayout());
249
+ expect((await submit(`pages/${page.id}/field00001`, valid)).status).toBe(404);
250
+ expect((await submit(`pages/${page.id}/nothing0001`, valid)).status).toBe(404);
251
+ expect((await submit(`pages/99999/${FORM}`, valid)).status).toBe(404);
252
+ expect((await submit(`users/${page.id}/${FORM}`, valid)).status).toBe(404);
253
+ });
254
+ it('turns a bot away quietly: the honeypot is answered as a success and stores nothing', async () => {
255
+ const page = await publish(formLayout());
256
+ const before = (await submissions()).length;
257
+ const sent = await submit(`pages/${page.id}/${FORM}`, { ...valid, _hp: 'buy now' });
258
+ expect(sent).toMatchObject({ status: 200, body: { ok: true } });
259
+ expect((await submissions()).length).toBe(before);
260
+ // An empty honeypot is what a person sends.
261
+ expect((await submit(`pages/${page.id}/${FORM}`, { ...valid, _hp: '' })).status).toBe(200);
262
+ });
263
+ it('limits the submissions of one visitor to one form, with a 429 and a retry-after', async () => {
264
+ const page = await publish(formLayout());
265
+ const ip = '203.0.113.9';
266
+ for (let attempt = 0; attempt < 4; attempt += 1) {
267
+ expect((await submit(`pages/${page.id}/${FORM}`, valid, { ip })).status).toBe(200);
268
+ }
269
+ const limited = await submit(`pages/${page.id}/${FORM}`, valid, { ip });
270
+ expect(limited.status).toBe(429);
271
+ expect(Number(limited.headers.get('retry-after'))).toBeGreaterThan(0);
272
+ // Another visitor is not affected.
273
+ expect((await submit(`pages/${page.id}/${FORM}`, valid)).status).toBe(200);
274
+ });
275
+ it('refuses a body that is not a form or JSON, and an oversized one', async () => {
276
+ const page = await publish(formLayout());
277
+ const url = `http://localhost/api/buildr/forms/pages/${page.id}/${FORM}`;
278
+ const text = await handleEndpoints({
279
+ config: h.payload.config,
280
+ request: new Request(url, {
281
+ method: 'POST',
282
+ headers: { 'content-type': 'text/plain' },
283
+ body: 'x',
284
+ }),
285
+ });
286
+ expect(text.status).toBe(415);
287
+ const big = await handleEndpoints({
288
+ config: h.payload.config,
289
+ request: new Request(url, {
290
+ method: 'POST',
291
+ headers: { 'content-type': 'application/json', 'content-length': '1000000' },
292
+ body: '{}',
293
+ }),
294
+ });
295
+ expect(big.status).toBe(413);
296
+ });
297
+ it('serves a plain HTML form with a 303 back to the page it came from', async () => {
298
+ const page = await publish(formLayout());
299
+ const referer = 'http://localhost/contact?utm=1';
300
+ const sent = await submit(`pages/${page.id}/${FORM}`, valid, {
301
+ as: 'urlencoded',
302
+ headers: { referer },
303
+ });
304
+ expect(sent.status).toBe(303);
305
+ expect(sent.headers.get('location')).toBe('/contact?utm=1&buildr-form=sent');
306
+ const invalid = await submit(`pages/${page.id}/${FORM}`, { subject: 'x' }, {
307
+ as: 'urlencoded',
308
+ headers: { referer },
309
+ });
310
+ expect(invalid.status).toBe(303);
311
+ expect(invalid.headers.get('location')).toBe('/contact?utm=1&buildr-form=invalid');
312
+ });
313
+ it('never redirects off the site', async () => {
314
+ const page = await publish(formLayout());
315
+ const sent = await submit(`pages/${page.id}/${FORM}`, valid, {
316
+ as: 'urlencoded',
317
+ headers: { referer: 'https://evil.example/phish' },
318
+ });
319
+ expect(sent.status).toBe(303);
320
+ expect(sent.headers.get('location')).toBe('/');
321
+ });
322
+ it('finds the form of an inherited layout in its template', async () => {
323
+ const template = await h.payload.create({
324
+ collection: 'buildr-templates',
325
+ data: {
326
+ title: 'With form',
327
+ targetCollection: 'pages',
328
+ layout: formLayout(),
329
+ _status: 'published',
330
+ },
331
+ });
332
+ const page = await publish(undefined, { template: template.id });
333
+ expect((await submit(`pages/${page.id}/${FORM}`, valid)).status).toBe(200);
334
+ expect((await submit(`buildr-templates/${template.id}/${FORM}`, valid)).status).toBe(200);
335
+ });
336
+ });
337
+ describe('forms configuration', () => {
338
+ it('needs a registry, and notifies only allowlisted recipients', () => {
339
+ expect(() => buildrPlugin({ collections: {}, forms: { enabled: true } })).toThrow(/registry/);
340
+ expect(() => buildrPlugin({
341
+ collections: {},
342
+ registry: { meta },
343
+ forms: { enabled: true, notifyAllowlist: ['@example.com'], notifyTo: ['x@evil.example'] },
344
+ })).toThrow(/notifyAllowlist/);
345
+ });
346
+ });
@@ -0,0 +1,9 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ /**
3
+ * The CSRF guard of a mutating request: the body must be declared JSON (a cross-site form cannot
4
+ * send that without a preflight), and a browser-supplied `Origin` must be this server's own origin
5
+ * or one of Payload's `csrf` allowlist. Requests without an `Origin` (scripts, server-to-server)
6
+ * pass: browsers always send it on `PUT`/`POST`. Returns the response to send, or `undefined`.
7
+ */
8
+ export declare function mutationGuard(req: PayloadRequest, kind?: 'json' | 'multipart'): Response | undefined;
9
+ //# sourceMappingURL=guards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/guards.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAY9C;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,cAAc,EACnB,IAAI,GAAE,MAAM,GAAG,WAAoB,GAClC,QAAQ,GAAG,SAAS,CAmBtB"}
@@ -0,0 +1,35 @@
1
+ import { fail } from './respond.js';
2
+ const originOf = (value) => {
3
+ if (value === null || value === undefined || value === '')
4
+ return undefined;
5
+ try {
6
+ return new URL(value).origin;
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ };
12
+ /**
13
+ * The CSRF guard of a mutating request: the body must be declared JSON (a cross-site form cannot
14
+ * send that without a preflight), and a browser-supplied `Origin` must be this server's own origin
15
+ * or one of Payload's `csrf` allowlist. Requests without an `Origin` (scripts, server-to-server)
16
+ * pass: browsers always send it on `PUT`/`POST`. Returns the response to send, or `undefined`.
17
+ */
18
+ export function mutationGuard(req, kind = 'json') {
19
+ const contentType = req.headers.get('content-type') ?? '';
20
+ // An upload is multipart, which a cross-site form can send: only the Origin check protects it.
21
+ const expected = kind === 'json' ? /^application\/json\s*(;|$)/i : /^multipart\/form-data\s*;/i;
22
+ if (!expected.test(contentType)) {
23
+ return fail(415, `The content type must be ${kind === 'json' ? 'application/json' : 'multipart/form-data'}.`);
24
+ }
25
+ const origin = req.headers.get('origin');
26
+ if (origin === null)
27
+ return undefined;
28
+ const requested = originOf(origin);
29
+ const own = originOf(req.url);
30
+ const allowlist = (req.payload.config.csrf ?? []).map(originOf);
31
+ if (requested === undefined || (requested !== own && !allowlist.includes(requested))) {
32
+ return fail(403, 'The origin of the request is not allowed.');
33
+ }
34
+ return undefined;
35
+ }
@@ -0,0 +1,6 @@
1
+ import type { Endpoint } from 'payload';
2
+ import type { EndpointEnv } from './context.ts';
3
+ /** The builder API (docs/payload.md#endpoint-contract) as Payload endpoints, served under `/api`. */
4
+ export declare const buildrEndpoints: (env: EndpointEnv) => Endpoint[];
5
+ export type { EndpointEnv } from './context.ts';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACxC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAehD,qGAAqG;AACrG,eAAO,MAAM,eAAe,QAAS,WAAW,KAAG,QAAQ,EAiB1D,CAAC;AAEF,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,32 @@
1
+ import { dataMediaEndpoint, dataQueryEndpoint } from './data.js';
2
+ import { dataContextEndpoint } from './data-context.js';
3
+ import { dataSchemaEndpoint } from './data-schema.js';
4
+ import { getDocumentEndpoint, revisionEndpoint } from './document.js';
5
+ import { createDocumentEndpoint, listDocumentsEndpoint } from './documents.js';
6
+ import { formsEndpoint } from './forms.js';
7
+ import { manifestEndpoint } from './manifest.js';
8
+ import { mediaListEndpoint, mediaUploadEndpoint } from './media.js';
9
+ import { publishEndpoint } from './publish.js';
10
+ import { renameEndpoint } from './rename.js';
11
+ import { samplesEndpoint } from './samples.js';
12
+ import { saveEndpoint } from './save.js';
13
+ import { sessionEndpoint } from './session.js';
14
+ /** The builder API (docs/payload.md#endpoint-contract) as Payload endpoints, served under `/api`. */
15
+ export const buildrEndpoints = (env) => [
16
+ sessionEndpoint(env),
17
+ manifestEndpoint(env),
18
+ getDocumentEndpoint(env),
19
+ revisionEndpoint(env),
20
+ saveEndpoint(env),
21
+ renameEndpoint(env),
22
+ publishEndpoint(env),
23
+ dataSchemaEndpoint(env),
24
+ dataContextEndpoint(env),
25
+ samplesEndpoint(env),
26
+ dataQueryEndpoint(env),
27
+ dataMediaEndpoint(env),
28
+ mediaListEndpoint(env),
29
+ mediaUploadEndpoint(env),
30
+ ...(env.options.mcp.enabled ? [listDocumentsEndpoint(env), createDocumentEndpoint(env)] : []),
31
+ ...(env.options.forms.enabled ? [formsEndpoint(env)] : []),
32
+ ];
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=locales-no-fallback.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locales-no-fallback.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/locales-no-fallback.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,30 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { boot } from './endpoints.test-kit.js';
3
+ let h;
4
+ beforeAll(async () => {
5
+ h = await boot('locales-no-fallback', {}, {
6
+ localization: {
7
+ locales: [
8
+ { code: 'pl', label: 'Polski' },
9
+ { code: 'en', label: 'English' },
10
+ ],
11
+ defaultLocale: 'pl',
12
+ fallback: false,
13
+ },
14
+ });
15
+ });
16
+ afterAll(() => h.close());
17
+ describe('localization with fallback off', () => {
18
+ it('does not fall back to the default language and says so in the session', async () => {
19
+ const page = await h.payload.create({
20
+ collection: 'pages',
21
+ data: { title: 'Tylko polski', slug: 'pl-only' },
22
+ draft: true,
23
+ locale: 'pl',
24
+ });
25
+ const { body } = await h.call('GET', `/buildr/data/context?collection=pages&id=${page.id}&locale=en`);
26
+ expect(body.scopes.page.title ?? null).toBeNull();
27
+ const session = await h.call('GET', '/buildr/session');
28
+ expect(session.body.locales).toMatchObject({ default: 'pl', fallback: false });
29
+ });
30
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=locales.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locales.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/locales.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,128 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { processLayout } from '../hooks/process-layout.js';
3
+ import { chooseLocale, configuredLocales, localeConfigOf } from '../locales.js';
4
+ import { boot, meta, migrations, withChild } from './endpoints.test-kit.js';
5
+ let h;
6
+ let id;
7
+ beforeAll(async () => {
8
+ h = await boot('locales', {}, {
9
+ localization: {
10
+ locales: [
11
+ { code: 'pl', label: 'Polski' },
12
+ { code: 'en', label: 'English' },
13
+ ],
14
+ defaultLocale: 'pl',
15
+ fallback: true,
16
+ },
17
+ });
18
+ const page = await h.payload.create({
19
+ collection: 'pages',
20
+ data: { title: 'Cześć', slug: 'czesc' },
21
+ draft: true,
22
+ locale: 'pl',
23
+ });
24
+ id = page.id;
25
+ await h.payload.update({
26
+ collection: 'pages',
27
+ id,
28
+ data: { title: 'Hello' },
29
+ draft: true,
30
+ locale: 'en',
31
+ });
32
+ });
33
+ afterAll(() => h.close());
34
+ const titleIn = async (locale) => {
35
+ const query = `collection=pages&id=${id}${locale === undefined ? '' : `&locale=${locale}`}`;
36
+ const { status, body } = await h.call('GET', `/buildr/data/context?${query}`);
37
+ expect(status).toBe(200);
38
+ return { title: body.scopes.page.title, route: body.scopes.route };
39
+ };
40
+ describe('locales', () => {
41
+ it('derives the LocaleConfig from the Payload localization', () => {
42
+ expect(configuredLocales(h.payload.config)).toEqual({
43
+ locales: ['pl', 'en'],
44
+ default: 'pl',
45
+ fallback: true,
46
+ intl: { pl: 'Polski', en: 'English' },
47
+ });
48
+ });
49
+ it('builds the data context of a post in both languages', async () => {
50
+ expect(await titleIn('pl')).toMatchObject({ title: 'Cześć', route: { locale: 'pl' } });
51
+ expect(await titleIn('en')).toMatchObject({ title: 'Hello', route: { locale: 'en' } });
52
+ // No locale means the default language.
53
+ expect(await titleIn()).toMatchObject({ title: 'Cześć', route: { locale: 'pl' } });
54
+ });
55
+ it('falls back to the default language for a missing translation', async () => {
56
+ const page = await h.payload.create({
57
+ collection: 'pages',
58
+ data: { title: 'Tylko polski', slug: 'pl-only' },
59
+ draft: true,
60
+ locale: 'pl',
61
+ });
62
+ const { body } = await h.call('GET', `/buildr/data/context?collection=pages&id=${page.id}&locale=en`);
63
+ expect(body.scopes.page.title).toBe('Tylko polski');
64
+ });
65
+ it('opens a document in the language asked for', async () => {
66
+ const opened = await h.call('GET', `/buildr/documents/pages/${id}?locale=en`);
67
+ expect(opened.status).toBe(200);
68
+ expect(opened.body.title).toBe('Hello');
69
+ expect((await h.call('GET', `/buildr/documents/pages/${id}`)).body.title).toBe('Cześć');
70
+ });
71
+ it('lists samples with the titles of the language', async () => {
72
+ const en = await h.call('GET', '/buildr/samples/pages?locale=en');
73
+ expect(en.body.items.map((item) => item.title)).toContain('Hello');
74
+ const pl = await h.call('GET', '/buildr/samples/pages');
75
+ expect(pl.body.items.map((item) => item.title)).toContain('Cześć');
76
+ });
77
+ it('answers 400 for a locale that is not configured, on every endpoint that takes one', async () => {
78
+ const calls = [
79
+ h.call('GET', `/buildr/documents/pages/${id}?locale=de`),
80
+ h.call('GET', `/buildr/data/context?collection=pages&id=${id}&locale=de`),
81
+ h.call('GET', '/buildr/samples/pages?locale=de'),
82
+ h.call('GET', '/buildr/samples/pages?locale=all'),
83
+ h.call('POST', '/buildr/data/query', {
84
+ body: {
85
+ spec: { source: 'pages', limit: 1, page: 1 },
86
+ locale: 'de',
87
+ },
88
+ }),
89
+ h.call('POST', '/buildr/data/media', { body: { ids: [], locale: 'de' } }),
90
+ ];
91
+ for (const { status, body } of await Promise.all(calls)) {
92
+ expect(status).toBe(400);
93
+ expect(body.error).toMatch(/locale/);
94
+ }
95
+ });
96
+ it('reports an l10n key outside the configured languages as a warning', () => {
97
+ const doc = withChild({
98
+ title: { kind: 'static', value: 'Hi', l10n: { pl: 'Cześć', de: 'Hallo' } },
99
+ });
100
+ const options = {
101
+ registry: { meta, migrations },
102
+ limits: { maxNodes: 5000, maxBytes: 2_000_000 },
103
+ };
104
+ const withLocales = processLayout(doc, {
105
+ ...options,
106
+ locales: configuredLocales(h.payload.config),
107
+ });
108
+ expect(withLocales.ok).toBe(true);
109
+ if (withLocales.ok) {
110
+ expect(withLocales.warnings.some((warning) => JSON.stringify(warning).includes('de'))).toBe(true);
111
+ }
112
+ const without = processLayout(doc, options);
113
+ expect(without.ok && without.warnings.every((warning) => !JSON.stringify(warning).includes('"de"'))).toBe(true);
114
+ });
115
+ });
116
+ describe('without localization', () => {
117
+ const plain = { payload: { config: { localization: false } } };
118
+ it('behaves as before: one locale, the parameter is ignored', () => {
119
+ expect(chooseLocale(plain, 'de')).toEqual({ ok: true, locale: undefined, args: {} });
120
+ expect(chooseLocale(plain, undefined)).toEqual({ ok: true, locale: undefined, args: {} });
121
+ expect(localeConfigOf({ localization: false })).toEqual({
122
+ locales: ['en'],
123
+ default: 'en',
124
+ fallback: false,
125
+ intl: { en: 'English' },
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,5 @@
1
+ import type { Endpoint } from 'payload';
2
+ import { type EndpointEnv } from './context.ts';
3
+ /** `GET /api/buildr/manifest`: the registry the editor builds with (only present when the plugin has one). */
4
+ export declare const manifestEndpoint: (env: EndpointEnv) => Endpoint;
5
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/manifest.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACxC,OAAO,EAAW,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAGzD,8GAA8G;AAC9G,eAAO,MAAM,gBAAgB,QAAS,WAAW,KAAG,QAUlD,CAAC"}
@@ -0,0 +1,18 @@
1
+ import { toManifest } from '@next-buildr/core';
2
+ import { allowed } from './context.js';
3
+ import { fail, json, unauthorized } from './respond.js';
4
+ /** `GET /api/buildr/manifest`: the registry the editor builds with (only present when the plugin has one). */
5
+ export const manifestEndpoint = (env) => ({
6
+ path: '/buildr/manifest',
7
+ method: 'get',
8
+ handler: async (req) => {
9
+ if (req.user === null || req.user === undefined)
10
+ return unauthorized();
11
+ if (!(await allowed(env, 'edit', req)))
12
+ return fail(403, 'You may not edit with the builder.');
13
+ const registry = env.options.registry;
14
+ if (registry === undefined)
15
+ return fail(404, 'The plugin was configured without a registry.');
16
+ return json(toManifest(registry.meta));
17
+ },
18
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mcp-http-backend.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-http-backend.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/mcp-http-backend.test.ts"],"names":[],"mappings":""}