@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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/adapter/adapter.test.d.ts +2 -0
- package/dist/adapter/adapter.test.d.ts.map +1 -0
- package/dist/adapter/adapter.test.js +282 -0
- package/dist/adapter/canvas-data-source.d.ts +9 -0
- package/dist/adapter/canvas-data-source.d.ts.map +1 -0
- package/dist/adapter/canvas-data-source.js +24 -0
- package/dist/adapter/document-adapter.d.ts +20 -0
- package/dist/adapter/document-adapter.d.ts.map +1 -0
- package/dist/adapter/document-adapter.js +183 -0
- package/dist/adapter/http.d.ts +34 -0
- package/dist/adapter/http.d.ts.map +1 -0
- package/dist/adapter/http.js +72 -0
- package/dist/adapter/index.d.ts +4 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +5 -0
- package/dist/admin/index.d.ts +4 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +2 -0
- package/dist/admin/layout-field-connected.d.ts +6 -0
- package/dist/admin/layout-field-connected.d.ts.map +1 -0
- package/dist/admin/layout-field-connected.js +16 -0
- package/dist/admin/layout-field-view.d.ts +16 -0
- package/dist/admin/layout-field-view.d.ts.map +1 -0
- package/dist/admin/layout-field-view.js +14 -0
- package/dist/admin/layout-field.d.ts +6 -0
- package/dist/admin/layout-field.d.ts.map +1 -0
- package/dist/admin/layout-field.js +13 -0
- package/dist/admin/layout-field.test.d.ts +2 -0
- package/dist/admin/layout-field.test.d.ts.map +1 -0
- package/dist/admin/layout-field.test.js +41 -0
- package/dist/contract.d.ts +289 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +176 -0
- package/dist/data/build-context.d.ts +20 -0
- package/dist/data/build-context.d.ts.map +1 -0
- package/dist/data/build-context.js +25 -0
- package/dist/data/data.test.d.ts +2 -0
- package/dist/data/data.test.d.ts.map +1 -0
- package/dist/data/data.test.js +261 -0
- package/dist/data/index.d.ts +10 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +5 -0
- package/dist/data/normalize.d.ts +14 -0
- package/dist/data/normalize.d.ts.map +1 -0
- package/dist/data/normalize.js +128 -0
- package/dist/data/payload-data-source.d.ts +42 -0
- package/dist/data/payload-data-source.d.ts.map +1 -0
- package/dist/data/payload-data-source.js +179 -0
- package/dist/data/payload-data-source.test.d.ts +2 -0
- package/dist/data/payload-data-source.test.d.ts.map +1 -0
- package/dist/data/payload-data-source.test.js +160 -0
- package/dist/data/resolve-layout.d.ts +45 -0
- package/dist/data/resolve-layout.d.ts.map +1 -0
- package/dist/data/resolve-layout.js +111 -0
- package/dist/data/schema-from-fields.d.ts +63 -0
- package/dist/data/schema-from-fields.d.ts.map +1 -0
- package/dist/data/schema-from-fields.js +178 -0
- package/dist/data/where.d.ts +53 -0
- package/dist/data/where.d.ts.map +1 -0
- package/dist/data/where.js +210 -0
- package/dist/mcp/http-backend.d.ts +35 -0
- package/dist/mcp/http-backend.d.ts.map +1 -0
- package/dist/mcp/http-backend.js +337 -0
- package/dist/mcp/http-backend.test.d.ts +2 -0
- package/dist/mcp/http-backend.test.d.ts.map +1 -0
- package/dist/mcp/http-backend.test.js +178 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/route.d.ts +63 -0
- package/dist/mcp/route.d.ts.map +1 -0
- package/dist/mcp/route.js +230 -0
- package/dist/next/get-document.d.ts +64 -0
- package/dist/next/get-document.d.ts.map +1 -0
- package/dist/next/get-document.js +208 -0
- package/dist/next/get-document.test.d.ts +2 -0
- package/dist/next/get-document.test.d.ts.map +1 -0
- package/dist/next/get-document.test.js +155 -0
- package/dist/next/index.d.ts +9 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +4 -0
- package/dist/next/next-helpers.test.d.ts +2 -0
- package/dist/next/next-helpers.test.d.ts.map +1 -0
- package/dist/next/next-helpers.test.js +122 -0
- package/dist/next/revalidate.d.ts +44 -0
- package/dist/next/revalidate.d.ts.map +1 -0
- package/dist/next/revalidate.js +45 -0
- package/dist/next/seo.d.ts +43 -0
- package/dist/next/seo.d.ts.map +1 -0
- package/dist/next/seo.js +53 -0
- package/dist/next/tags.d.ts +23 -0
- package/dist/next/tags.d.ts.map +1 -0
- package/dist/next/tags.js +31 -0
- package/dist/plugin/access.d.ts +19 -0
- package/dist/plugin/access.d.ts.map +1 -0
- package/dist/plugin/access.js +32 -0
- package/dist/plugin/collections/form-submissions.d.ts +10 -0
- package/dist/plugin/collections/form-submissions.d.ts.map +1 -0
- package/dist/plugin/collections/form-submissions.js +37 -0
- package/dist/plugin/collections/templates.d.ts +13 -0
- package/dist/plugin/collections/templates.d.ts.map +1 -0
- package/dist/plugin/collections/templates.js +118 -0
- package/dist/plugin/endpoints/access.test.d.ts +2 -0
- package/dist/plugin/endpoints/access.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/access.test.js +91 -0
- package/dist/plugin/endpoints/adapter-contract.test.d.ts +2 -0
- package/dist/plugin/endpoints/adapter-contract.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/adapter-contract.test.js +45 -0
- package/dist/plugin/endpoints/context.d.ts +65 -0
- package/dist/plugin/endpoints/context.d.ts.map +1 -0
- package/dist/plugin/endpoints/context.js +113 -0
- package/dist/plugin/endpoints/data-context.d.ts +8 -0
- package/dist/plugin/endpoints/data-context.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-context.js +63 -0
- package/dist/plugin/endpoints/data-endpoints.test.d.ts +2 -0
- package/dist/plugin/endpoints/data-endpoints.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-endpoints.test.js +125 -0
- package/dist/plugin/endpoints/data-query.test.d.ts +2 -0
- package/dist/plugin/endpoints/data-query.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-query.test.js +96 -0
- package/dist/plugin/endpoints/data-schema.d.ts +5 -0
- package/dist/plugin/endpoints/data-schema.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-schema.js +21 -0
- package/dist/plugin/endpoints/data.d.ts +10 -0
- package/dist/plugin/endpoints/data.d.ts.map +1 -0
- package/dist/plugin/endpoints/data.js +96 -0
- package/dist/plugin/endpoints/document.d.ts +11 -0
- package/dist/plugin/endpoints/document.d.ts.map +1 -0
- package/dist/plugin/endpoints/document.js +119 -0
- package/dist/plugin/endpoints/documents.d.ts +10 -0
- package/dist/plugin/endpoints/documents.d.ts.map +1 -0
- package/dist/plugin/endpoints/documents.js +145 -0
- package/dist/plugin/endpoints/endpoints.test-kit.d.ts +42 -0
- package/dist/plugin/endpoints/endpoints.test-kit.d.ts.map +1 -0
- package/dist/plugin/endpoints/endpoints.test-kit.js +141 -0
- package/dist/plugin/endpoints/endpoints.test.d.ts +2 -0
- package/dist/plugin/endpoints/endpoints.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/endpoints.test.js +209 -0
- package/dist/plugin/endpoints/forms.d.ts +16 -0
- package/dist/plugin/endpoints/forms.d.ts.map +1 -0
- package/dist/plugin/endpoints/forms.js +199 -0
- package/dist/plugin/endpoints/forms.test.d.ts +2 -0
- package/dist/plugin/endpoints/forms.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/forms.test.js +346 -0
- package/dist/plugin/endpoints/guards.d.ts +9 -0
- package/dist/plugin/endpoints/guards.d.ts.map +1 -0
- package/dist/plugin/endpoints/guards.js +35 -0
- package/dist/plugin/endpoints/index.d.ts +6 -0
- package/dist/plugin/endpoints/index.d.ts.map +1 -0
- package/dist/plugin/endpoints/index.js +32 -0
- package/dist/plugin/endpoints/locales-no-fallback.test.d.ts +2 -0
- package/dist/plugin/endpoints/locales-no-fallback.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/locales-no-fallback.test.js +30 -0
- package/dist/plugin/endpoints/locales.test.d.ts +2 -0
- package/dist/plugin/endpoints/locales.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/locales.test.js +128 -0
- package/dist/plugin/endpoints/manifest.d.ts +5 -0
- package/dist/plugin/endpoints/manifest.d.ts.map +1 -0
- package/dist/plugin/endpoints/manifest.js +18 -0
- package/dist/plugin/endpoints/mcp-http-backend.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-http-backend.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-http-backend.test.js +84 -0
- package/dist/plugin/endpoints/mcp-off.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-off.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-off.test.js +33 -0
- package/dist/plugin/endpoints/mcp-publish.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-publish.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-publish.test.js +42 -0
- package/dist/plugin/endpoints/mcp-route.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-route.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-route.test.js +132 -0
- package/dist/plugin/endpoints/mcp.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp.test.js +231 -0
- package/dist/plugin/endpoints/media.d.ts +7 -0
- package/dist/plugin/endpoints/media.d.ts.map +1 -0
- package/dist/plugin/endpoints/media.js +115 -0
- package/dist/plugin/endpoints/media.test.d.ts +2 -0
- package/dist/plugin/endpoints/media.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/media.test.js +95 -0
- package/dist/plugin/endpoints/publish-a11y.test.d.ts +2 -0
- package/dist/plugin/endpoints/publish-a11y.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/publish-a11y.test.js +27 -0
- package/dist/plugin/endpoints/publish.d.ts +9 -0
- package/dist/plugin/endpoints/publish.d.ts.map +1 -0
- package/dist/plugin/endpoints/publish.js +81 -0
- package/dist/plugin/endpoints/rename.d.ts +9 -0
- package/dist/plugin/endpoints/rename.d.ts.map +1 -0
- package/dist/plugin/endpoints/rename.js +47 -0
- package/dist/plugin/endpoints/respond.d.ts +9 -0
- package/dist/plugin/endpoints/respond.d.ts.map +1 -0
- package/dist/plugin/endpoints/respond.js +10 -0
- package/dist/plugin/endpoints/samples.d.ts +5 -0
- package/dist/plugin/endpoints/samples.d.ts.map +1 -0
- package/dist/plugin/endpoints/samples.js +42 -0
- package/dist/plugin/endpoints/save.d.ts +9 -0
- package/dist/plugin/endpoints/save.d.ts.map +1 -0
- package/dist/plugin/endpoints/save.js +58 -0
- package/dist/plugin/endpoints/session.d.ts +5 -0
- package/dist/plugin/endpoints/session.d.ts.map +1 -0
- package/dist/plugin/endpoints/session.js +30 -0
- package/dist/plugin/endpoints/templates.test.d.ts +2 -0
- package/dist/plugin/endpoints/templates.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/templates.test.js +191 -0
- package/dist/plugin/fields.d.ts +28 -0
- package/dist/plugin/fields.d.ts.map +1 -0
- package/dist/plugin/fields.js +62 -0
- package/dist/plugin/forms/forms.test.d.ts +2 -0
- package/dist/plugin/forms/forms.test.d.ts.map +1 -0
- package/dist/plugin/forms/forms.test.js +88 -0
- package/dist/plugin/forms/notify.d.ts +6 -0
- package/dist/plugin/forms/notify.d.ts.map +1 -0
- package/dist/plugin/forms/notify.js +13 -0
- package/dist/plugin/forms/rate-limit.d.ts +24 -0
- package/dist/plugin/forms/rate-limit.d.ts.map +1 -0
- package/dist/plugin/forms/rate-limit.js +36 -0
- package/dist/plugin/forms/validate.d.ts +22 -0
- package/dist/plugin/forms/validate.d.ts.map +1 -0
- package/dist/plugin/forms/validate.js +79 -0
- package/dist/plugin/hooks/layout-hook.d.ts +9 -0
- package/dist/plugin/hooks/layout-hook.d.ts.map +1 -0
- package/dist/plugin/hooks/layout-hook.js +33 -0
- package/dist/plugin/hooks/layout-hook.test.d.ts +2 -0
- package/dist/plugin/hooks/layout-hook.test.d.ts.map +1 -0
- package/dist/plugin/hooks/layout-hook.test.js +191 -0
- package/dist/plugin/hooks/process-layout.d.ts +32 -0
- package/dist/plugin/hooks/process-layout.d.ts.map +1 -0
- package/dist/plugin/hooks/process-layout.js +68 -0
- package/dist/plugin/hooks/read-layout.d.ts +17 -0
- package/dist/plugin/hooks/read-layout.d.ts.map +1 -0
- package/dist/plugin/hooks/read-layout.js +39 -0
- package/dist/plugin/index.d.ts +18 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +105 -0
- package/dist/plugin/locales.d.ts +32 -0
- package/dist/plugin/locales.d.ts.map +1 -0
- package/dist/plugin/locales.js +56 -0
- package/dist/plugin/options.d.ts +77 -0
- package/dist/plugin/options.d.ts.map +1 -0
- package/dist/plugin/options.js +123 -0
- package/dist/plugin/plugin.test.d.ts +2 -0
- package/dist/plugin/plugin.test.d.ts.map +1 -0
- package/dist/plugin/plugin.test.js +186 -0
- package/dist/plugin/write-guard.d.ts +14 -0
- package/dist/plugin/write-guard.d.ts.map +1 -0
- package/dist/plugin/write-guard.js +18 -0
- package/package.json +108 -3
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { createEmptyDocument, s } from '@next-buildr/core';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { documentResponseSchema, publishResponseSchema, saveResponseSchema, sessionResponseSchema, } from '../../contract.js';
|
|
4
|
+
import { boot, title, withChild } from './endpoints.test-kit.js';
|
|
5
|
+
let h;
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
h = await boot('endpoints');
|
|
8
|
+
});
|
|
9
|
+
afterAll(() => h.close());
|
|
10
|
+
const create = async (data = {}) => {
|
|
11
|
+
const doc = await h.payload.create({
|
|
12
|
+
collection: 'pages',
|
|
13
|
+
data: { title: 'Home', slug: 'home', ...data },
|
|
14
|
+
draft: true,
|
|
15
|
+
});
|
|
16
|
+
return Number(doc.id);
|
|
17
|
+
};
|
|
18
|
+
const path = (docId, suffix = '') => `/buildr/documents/pages/${docId}${suffix}`;
|
|
19
|
+
const save = (docId, revision, document, autosave = false) => h.call('PUT', path(docId), { body: { document, baseRevision: revision, autosave } });
|
|
20
|
+
const nodesOf = (document) => document.nodes;
|
|
21
|
+
describe('authentication', () => {
|
|
22
|
+
it('answers 401 to every endpoint without a user', async () => {
|
|
23
|
+
const id = await create();
|
|
24
|
+
const anonymous = { token: null };
|
|
25
|
+
const calls = await Promise.all([
|
|
26
|
+
h.call('GET', '/buildr/session', anonymous),
|
|
27
|
+
h.call('GET', '/buildr/manifest', anonymous),
|
|
28
|
+
h.call('GET', path(id), anonymous),
|
|
29
|
+
h.call('PUT', path(id), { ...anonymous, body: {} }),
|
|
30
|
+
h.call('POST', path(id, '/publish'), { ...anonymous, body: {} }),
|
|
31
|
+
]);
|
|
32
|
+
expect(calls.map((call) => call.status)).toEqual([401, 401, 401, 401, 401]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('session and manifest', () => {
|
|
36
|
+
it('describes the user, what they may do and the limits', async () => {
|
|
37
|
+
const { status, body } = await h.call('GET', '/buildr/session');
|
|
38
|
+
expect(status).toBe(200);
|
|
39
|
+
const session = sessionResponseSchema.parse(body);
|
|
40
|
+
expect(session.user.email).toBe('editor@example.com');
|
|
41
|
+
expect(session.permissions).toEqual({
|
|
42
|
+
canEdit: true,
|
|
43
|
+
canPublish: true,
|
|
44
|
+
canUnlockTemplates: true,
|
|
45
|
+
});
|
|
46
|
+
expect(session.limits).toEqual({ maxNodes: 5000, maxBytes: 2_000_000 });
|
|
47
|
+
expect(session.publishPolicy).toBe('warn');
|
|
48
|
+
});
|
|
49
|
+
it('serves the manifest of the registry', async () => {
|
|
50
|
+
const { status, body } = await h.call('GET', '/buildr/manifest');
|
|
51
|
+
expect(status).toBe(200);
|
|
52
|
+
expect(typeof body.hash).toBe('string');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('GET a document', () => {
|
|
56
|
+
it('opens a new document as an empty one at revision 0', async () => {
|
|
57
|
+
const docId = await create();
|
|
58
|
+
const { status, body } = await h.call('GET', path(docId));
|
|
59
|
+
expect(status).toBe(200);
|
|
60
|
+
const doc = documentResponseSchema.parse(body);
|
|
61
|
+
expect(doc).toMatchObject({
|
|
62
|
+
ref: { collection: 'pages', id: String(docId) },
|
|
63
|
+
title: 'Home',
|
|
64
|
+
slug: 'home',
|
|
65
|
+
status: 'draft',
|
|
66
|
+
revision: 0,
|
|
67
|
+
contextRef: `pages:${docId}`,
|
|
68
|
+
previewPath: '/home',
|
|
69
|
+
});
|
|
70
|
+
expect(doc.document).toEqual(createEmptyDocument());
|
|
71
|
+
expect(doc.readOnly).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
it('answers 404 for a collection or a document that does not exist', async () => {
|
|
74
|
+
expect((await h.call('GET', '/buildr/documents/nope/1')).status).toBe(404);
|
|
75
|
+
expect((await h.call('GET', path(99999))).status).toBe(404);
|
|
76
|
+
});
|
|
77
|
+
it('marks a document that newer code wrote as read-only, and leaves it untouched', async () => {
|
|
78
|
+
const docId = await create();
|
|
79
|
+
// Straight into the database: the hooks would (rightly) refuse to store this.
|
|
80
|
+
await h.payload.db.createVersion({
|
|
81
|
+
collectionSlug: 'pages',
|
|
82
|
+
parent: docId,
|
|
83
|
+
versionData: {
|
|
84
|
+
title: 'Newer',
|
|
85
|
+
layout: withChild(title('x'), { version: 9 }),
|
|
86
|
+
buildrRevision: 4,
|
|
87
|
+
_status: 'draft',
|
|
88
|
+
},
|
|
89
|
+
createdAt: new Date().toISOString(),
|
|
90
|
+
updatedAt: new Date(Date.now() + 1000).toISOString(),
|
|
91
|
+
autosave: false,
|
|
92
|
+
returning: false,
|
|
93
|
+
});
|
|
94
|
+
const { body } = await h.call('GET', path(docId));
|
|
95
|
+
expect(body.readOnly).toBe(true);
|
|
96
|
+
expect(body.revision).toBe(4);
|
|
97
|
+
expect(body.document.components['buildr/widget']).toBe(9);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('PATCH a document (rename)', () => {
|
|
101
|
+
it('renames without touching the layout or the revision', async () => {
|
|
102
|
+
const docId = await create();
|
|
103
|
+
const { status, body } = await h.call('PATCH', path(docId), { body: { title: ' About us ' } });
|
|
104
|
+
expect(status).toBe(200);
|
|
105
|
+
expect(body).toEqual({ title: 'About us' });
|
|
106
|
+
const loaded = documentResponseSchema.parse((await h.call('GET', path(docId), {})).body);
|
|
107
|
+
expect(loaded.title).toBe('About us');
|
|
108
|
+
expect(loaded.revision).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
it('refuses an empty name and a request without a user', async () => {
|
|
111
|
+
const docId = await create();
|
|
112
|
+
expect((await h.call('PATCH', path(docId), { body: { title: ' ' } })).status).toBe(400);
|
|
113
|
+
expect((await h.call('PATCH', path(docId), { token: null, body: { title: 'x' } })).status).toBe(401);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe('PUT a document (save)', () => {
|
|
117
|
+
it('saves, bumps the revision and stores the layout', async () => {
|
|
118
|
+
const docId = await create();
|
|
119
|
+
const { status, body } = await save(docId, 0, withChild(title('One')));
|
|
120
|
+
expect(status).toBe(200);
|
|
121
|
+
expect(saveResponseSchema.parse(body).revision).toBe(1);
|
|
122
|
+
const opened = documentResponseSchema.parse((await h.call('GET', path(docId))).body);
|
|
123
|
+
expect(opened.revision).toBe(1);
|
|
124
|
+
expect(nodesOf(opened.document)['child00001']?.props).toEqual(title('One'));
|
|
125
|
+
});
|
|
126
|
+
it('answers 409 with the current revision when the base is stale', async () => {
|
|
127
|
+
const docId = await create();
|
|
128
|
+
expect((await save(docId, 0, withChild(title('One')))).status).toBe(200);
|
|
129
|
+
const stale = await save(docId, 0, withChild(title('Two')));
|
|
130
|
+
expect(stale).toEqual({ status: 409, body: { currentRevision: 1 } });
|
|
131
|
+
});
|
|
132
|
+
it('answers 422 with diagnostics for an invalid document, and stores nothing', async () => {
|
|
133
|
+
const docId = await create();
|
|
134
|
+
const invalid = await save(docId, 0, withChild({ title: { kind: 'static', value: 42 } }));
|
|
135
|
+
expect(invalid.status).toBe(422);
|
|
136
|
+
expect(invalid.body.diagnostics.length).toBeGreaterThan(0);
|
|
137
|
+
expect((await save(docId, 0, { schemaVersion: 1 })).status).toBe(422);
|
|
138
|
+
expect(documentResponseSchema.parse((await h.call('GET', path(docId))).body).revision).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
it('answers 400 to a body that is not the contract', async () => {
|
|
141
|
+
const docId = await create();
|
|
142
|
+
expect((await h.call('PUT', path(docId), { body: { document: {} } })).status).toBe(400);
|
|
143
|
+
});
|
|
144
|
+
it('keeps one autosave version, but every explicit save is a version', async () => {
|
|
145
|
+
const docId = await create();
|
|
146
|
+
const versions = async () => (await h.payload.countVersions({ collection: 'pages', where: { parent: { equals: docId } } }))
|
|
147
|
+
.totalDocs;
|
|
148
|
+
const before = await versions();
|
|
149
|
+
await save(docId, 0, withChild(title('a')), true);
|
|
150
|
+
await save(docId, 1, withChild(title('b')), true);
|
|
151
|
+
await save(docId, 2, withChild(title('c')), true);
|
|
152
|
+
const afterAutosaves = await versions();
|
|
153
|
+
expect(afterAutosaves - before).toBeLessThanOrEqual(1);
|
|
154
|
+
await save(docId, 3, withChild(title('d')), false);
|
|
155
|
+
await save(docId, 4, withChild(title('e')), false);
|
|
156
|
+
expect((await versions()) - afterAutosaves).toBe(2);
|
|
157
|
+
});
|
|
158
|
+
it('never loses either side of concurrent admin and builder edits', async () => {
|
|
159
|
+
const docId = await create({ title: 'Original' });
|
|
160
|
+
await save(docId, 0, withChild(title('Built')), true);
|
|
161
|
+
// The admin form still holds the layout it loaded, and saves a new title.
|
|
162
|
+
await h.payload.update({
|
|
163
|
+
collection: 'pages',
|
|
164
|
+
id: docId,
|
|
165
|
+
data: { title: 'Renamed', layout: createEmptyDocument(), buildrRevision: 0 },
|
|
166
|
+
draft: true,
|
|
167
|
+
autosave: true,
|
|
168
|
+
});
|
|
169
|
+
const opened = documentResponseSchema.parse((await h.call('GET', path(docId))).body);
|
|
170
|
+
expect(opened.title).toBe('Renamed');
|
|
171
|
+
expect(opened.revision).toBe(1);
|
|
172
|
+
expect(nodesOf(opened.document)['child00001']?.props).toEqual({ title: s('Built') });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
describe('POST publish', () => {
|
|
176
|
+
it('publishes the latest draft, all of it', async () => {
|
|
177
|
+
const docId = await create({ title: 'Draft title' });
|
|
178
|
+
await save(docId, 0, withChild(title('Live')));
|
|
179
|
+
await h.payload.update({
|
|
180
|
+
collection: 'pages',
|
|
181
|
+
id: docId,
|
|
182
|
+
data: { title: 'Admin edit' },
|
|
183
|
+
draft: true,
|
|
184
|
+
});
|
|
185
|
+
const { status, body } = await h.call('POST', path(docId, '/publish'), {
|
|
186
|
+
body: { baseRevision: 1 },
|
|
187
|
+
});
|
|
188
|
+
expect(status).toBe(200);
|
|
189
|
+
expect(publishResponseSchema.parse(body)).toMatchObject({ status: 'published', revision: 1 });
|
|
190
|
+
const published = (await h.payload.findByID({
|
|
191
|
+
collection: 'pages',
|
|
192
|
+
id: docId,
|
|
193
|
+
}));
|
|
194
|
+
expect(published._status).toBe('published');
|
|
195
|
+
expect(published.title).toBe('Admin edit');
|
|
196
|
+
expect(published.layout.nodes['child00001']?.props).toEqual(title('Live'));
|
|
197
|
+
const opened = documentResponseSchema.parse((await h.call('GET', path(docId))).body);
|
|
198
|
+
expect(opened.status).toBe('published');
|
|
199
|
+
expect(opened.revision).toBe(1);
|
|
200
|
+
});
|
|
201
|
+
it('answers 409 for a stale revision, and 404 for a missing document', async () => {
|
|
202
|
+
const docId = await create();
|
|
203
|
+
await save(docId, 0, withChild(title('One')));
|
|
204
|
+
const stale = await h.call('POST', path(docId, '/publish'), { body: { baseRevision: 0 } });
|
|
205
|
+
expect(stale.status).toBe(409);
|
|
206
|
+
const missing = await h.call('POST', path(99999, '/publish'), { body: { baseRevision: 0 } });
|
|
207
|
+
expect(missing.status).toBe(404);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload';
|
|
2
|
+
import type { EndpointEnv } from './context.ts';
|
|
3
|
+
/** The component a form is: its node type in the document. */
|
|
4
|
+
export declare const FORM_TYPE = "buildr/form";
|
|
5
|
+
/** The name of the honeypot input the form renders (`buildr/form`). */
|
|
6
|
+
export declare const HONEYPOT_FIELD = "_hp";
|
|
7
|
+
/** A submission is small; anything larger is refused before it is read. */
|
|
8
|
+
export declare const MAX_BODY_BYTES: number;
|
|
9
|
+
/**
|
|
10
|
+
* `POST /api/buildr/forms/:collection/:id/:nodeId`: a public form submission (docs/security.md).
|
|
11
|
+
* What the form accepts is never taken from the request: the schema is derived from the published
|
|
12
|
+
* document (or, for an inherited layout, its template) by `deriveFormSchema`. Bots are turned away
|
|
13
|
+
* by the honeypot and the rate limiter; a JSON caller gets JSON, a plain HTML form a `303` back.
|
|
14
|
+
*/
|
|
15
|
+
export declare const formsEndpoint: (env: EndpointEnv) => Endpoint;
|
|
16
|
+
//# sourceMappingURL=forms.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"forms.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/forms.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAkB,MAAM,SAAS,CAAC;AAOxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,8DAA8D;AAC9D,eAAO,MAAM,SAAS,gBAAgB,CAAC;AACvC,uEAAuE;AACvE,eAAO,MAAM,cAAc,QAAQ,CAAC;AACpC,2EAA2E;AAC3E,eAAO,MAAM,cAAc,QAAY,CAAC;AA2ExC;;;;;GAKG;AACH,eAAO,MAAM,aAAa,QAAS,WAAW,KAAG,QAyH/C,CAAC"}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { deriveFormSchema, MAX_FORM_FIELDS } from '@next-buildr/core';
|
|
3
|
+
import { resolveLayout, TEMPLATES_COLLECTION } from '../../data/resolve-layout.js';
|
|
4
|
+
import { FORM_SUBMISSIONS_COLLECTION } from '../collections/form-submissions.js';
|
|
5
|
+
import { isAllowedRecipient } from '../forms/notify.js';
|
|
6
|
+
import { validateSubmission } from '../forms/validate.js';
|
|
7
|
+
import { readLayout } from '../hooks/read-layout.js';
|
|
8
|
+
import { chooseLocale } from '../locales.js';
|
|
9
|
+
import { fail, json } from './respond.js';
|
|
10
|
+
/** The component a form is: its node type in the document. */
|
|
11
|
+
export const FORM_TYPE = 'buildr/form';
|
|
12
|
+
/** The name of the honeypot input the form renders (`buildr/form`). */
|
|
13
|
+
export const HONEYPOT_FIELD = '_hp';
|
|
14
|
+
/** A submission is small; anything larger is refused before it is read. */
|
|
15
|
+
export const MAX_BODY_BYTES = 64 * 1024;
|
|
16
|
+
const MESSAGES = {
|
|
17
|
+
unknown: 'This field is not part of the form.',
|
|
18
|
+
required: 'This field is required.',
|
|
19
|
+
invalid: 'This value is not valid.',
|
|
20
|
+
'too-long': 'This value is too long.',
|
|
21
|
+
};
|
|
22
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
/** The submitted values of a JSON, urlencoded or multipart body; a file or a repeated name is kept as a value that fails validation. */
|
|
24
|
+
async function readBody(req) {
|
|
25
|
+
const type = (req.headers.get('content-type') ?? '').toLowerCase();
|
|
26
|
+
const declared = Number(req.headers.get('content-length') ?? 0);
|
|
27
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
28
|
+
return { ok: false, response: fail(413, 'The submission is too large.') };
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
if (type.startsWith('application/json')) {
|
|
32
|
+
const body = await req.json?.();
|
|
33
|
+
if (!isRecord(body))
|
|
34
|
+
return { ok: false, response: fail(400, 'The body must be an object.') };
|
|
35
|
+
return { ok: true, values: body, json: true };
|
|
36
|
+
}
|
|
37
|
+
if (type.startsWith('application/x-www-form-urlencoded') ||
|
|
38
|
+
type.startsWith('multipart/form-data')) {
|
|
39
|
+
const form = await req.formData?.();
|
|
40
|
+
if (form === undefined)
|
|
41
|
+
return { ok: false, response: fail(400, 'The body is not a form.') };
|
|
42
|
+
const values = {};
|
|
43
|
+
for (const name of new Set(form.keys())) {
|
|
44
|
+
const all = form.getAll(name);
|
|
45
|
+
values[name] = all.length === 1 && typeof all[0] === 'string' ? all[0] : all;
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, values, json: false };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { ok: false, response: fail(400, 'The body could not be read.') };
|
|
52
|
+
}
|
|
53
|
+
return { ok: false, response: fail(415, 'The content type must be a form or JSON.') };
|
|
54
|
+
}
|
|
55
|
+
/** The visitor's address, hashed with the server secret: enough to rate-limit, not to identify. */
|
|
56
|
+
function ipHashOf(req) {
|
|
57
|
+
const forwarded = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
58
|
+
const ip = forwarded || req.headers.get('x-real-ip') || 'unknown';
|
|
59
|
+
return createHash('sha256').update(`${req.payload.secret}:${ip}`).digest('hex').slice(0, 32);
|
|
60
|
+
}
|
|
61
|
+
/** Back to the page the form was on (same origin only, so the redirect can never leave the site). */
|
|
62
|
+
function backTo(req, result) {
|
|
63
|
+
let location = '/';
|
|
64
|
+
const referer = req.headers.get('referer');
|
|
65
|
+
if (referer !== null) {
|
|
66
|
+
try {
|
|
67
|
+
const target = new URL(referer);
|
|
68
|
+
if (target.origin === new URL(req.url ?? 'http://localhost').origin) {
|
|
69
|
+
target.searchParams.set('buildr-form', result);
|
|
70
|
+
location = `${target.pathname}${target.search}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// An unparseable referer goes to the home page.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return new Response(null, { status: 303, headers: { location } });
|
|
78
|
+
}
|
|
79
|
+
const singleLine = (value) => String(value).replace(/\s*[\r\n]+\s*/g, ' / ');
|
|
80
|
+
/**
|
|
81
|
+
* `POST /api/buildr/forms/:collection/:id/:nodeId`: a public form submission (docs/security.md).
|
|
82
|
+
* What the form accepts is never taken from the request: the schema is derived from the published
|
|
83
|
+
* document (or, for an inherited layout, its template) by `deriveFormSchema`. Bots are turned away
|
|
84
|
+
* by the honeypot and the rate limiter; a JSON caller gets JSON, a plain HTML form a `303` back.
|
|
85
|
+
*/
|
|
86
|
+
export const formsEndpoint = (env) => ({
|
|
87
|
+
path: '/buildr/forms/:collection/:id/:nodeId',
|
|
88
|
+
method: 'post',
|
|
89
|
+
handler: async (req) => {
|
|
90
|
+
const collection = req.routeParams?.['collection'];
|
|
91
|
+
const id = req.routeParams?.['id'];
|
|
92
|
+
const nodeId = req.routeParams?.['nodeId'];
|
|
93
|
+
if (typeof collection !== 'string' || typeof id !== 'string' || typeof nodeId !== 'string') {
|
|
94
|
+
return fail(400, 'A collection, an id and a node are required.');
|
|
95
|
+
}
|
|
96
|
+
const own = env.options.collections[collection];
|
|
97
|
+
const isTemplate = collection === TEMPLATES_COLLECTION;
|
|
98
|
+
if ((own === undefined && !isTemplate) || req.payload.collections[collection] === undefined) {
|
|
99
|
+
return fail(404, 'The form was not found.');
|
|
100
|
+
}
|
|
101
|
+
const locale = chooseLocale(req, req.searchParams?.get('locale'));
|
|
102
|
+
if (!locale.ok)
|
|
103
|
+
return locale.response;
|
|
104
|
+
const ipHash = ipHashOf(req);
|
|
105
|
+
const limited = await env.rateLimiter?.hit(`${ipHash}:${collection}:${id}:${nodeId}`);
|
|
106
|
+
if (limited !== undefined && !limited.allowed) {
|
|
107
|
+
const response = fail(429, 'Too many submissions. Try again later.');
|
|
108
|
+
response.headers.set('retry-after', String(limited.retryAfterSeconds));
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
const body = await readBody(req);
|
|
112
|
+
if (!body.ok)
|
|
113
|
+
return body.response;
|
|
114
|
+
const wantsJson = body.json || (req.headers.get('accept') ?? '').includes('application/json');
|
|
115
|
+
const done = (result) => (wantsJson ? undefined : backTo(req, result));
|
|
116
|
+
// A person never sees the honeypot: a bot that filled it in is answered as if it had succeeded.
|
|
117
|
+
const trap = body.values[HONEYPOT_FIELD];
|
|
118
|
+
const { [HONEYPOT_FIELD]: _trap, ...values } = body.values;
|
|
119
|
+
if (trap !== undefined && !(typeof trap === 'string' && trap === '')) {
|
|
120
|
+
return done('sent') ?? json({ ok: true });
|
|
121
|
+
}
|
|
122
|
+
if (Object.keys(values).length > MAX_FORM_FIELDS)
|
|
123
|
+
return fail(422, 'Too many fields.');
|
|
124
|
+
// Only the published state is public: a draft, or a document never published, has no form.
|
|
125
|
+
let stored;
|
|
126
|
+
try {
|
|
127
|
+
stored = (await req.payload.findByID({
|
|
128
|
+
collection,
|
|
129
|
+
id,
|
|
130
|
+
draft: false,
|
|
131
|
+
depth: 0,
|
|
132
|
+
overrideAccess: true,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return fail(404, 'The form was not found.');
|
|
137
|
+
}
|
|
138
|
+
if (stored['_status'] !== 'published')
|
|
139
|
+
return fail(404, 'The form was not found.');
|
|
140
|
+
const layout = isTemplate
|
|
141
|
+
? stored['layout']
|
|
142
|
+
: (await resolveLayout({
|
|
143
|
+
payload: req.payload,
|
|
144
|
+
collection,
|
|
145
|
+
doc: stored,
|
|
146
|
+
contextName: own?.context ?? collection,
|
|
147
|
+
draft: false,
|
|
148
|
+
overrideAccess: true,
|
|
149
|
+
})).layout;
|
|
150
|
+
const read = readLayout(layout, env.options);
|
|
151
|
+
const form = read.ok && Object.hasOwn(read.doc.nodes, nodeId) ? read.doc.nodes[nodeId] : undefined;
|
|
152
|
+
if (!read.ok ||
|
|
153
|
+
form === undefined ||
|
|
154
|
+
form.type !== FORM_TYPE ||
|
|
155
|
+
env.options.registry === undefined) {
|
|
156
|
+
return fail(404, 'The form was not found.');
|
|
157
|
+
}
|
|
158
|
+
const { schema } = deriveFormSchema(read.doc, env.options.registry.meta, nodeId);
|
|
159
|
+
const result = validateSubmission(schema, values);
|
|
160
|
+
if (!result.ok) {
|
|
161
|
+
if (!wantsJson)
|
|
162
|
+
return backTo(req, 'invalid');
|
|
163
|
+
const errors = {};
|
|
164
|
+
const codes = {};
|
|
165
|
+
for (const error of result.errors) {
|
|
166
|
+
errors[error.field] = MESSAGES[error.code];
|
|
167
|
+
codes[error.field] = error.code;
|
|
168
|
+
}
|
|
169
|
+
return json({ errors, codes }, 422);
|
|
170
|
+
}
|
|
171
|
+
await req.payload.create({
|
|
172
|
+
collection: FORM_SUBMISSIONS_COLLECTION,
|
|
173
|
+
data: {
|
|
174
|
+
form: { collection, documentId: id, nodeId },
|
|
175
|
+
data: result.data,
|
|
176
|
+
locale: locale.locale ?? null,
|
|
177
|
+
meta: { userAgent: (req.headers.get('user-agent') ?? '').slice(0, 300), ipHash },
|
|
178
|
+
},
|
|
179
|
+
overrideAccess: true,
|
|
180
|
+
});
|
|
181
|
+
const recipients = env.options.forms.notifyTo.filter((address) => isAllowedRecipient(address, env.options.forms.notifyAllowlist));
|
|
182
|
+
if (recipients.length > 0) {
|
|
183
|
+
try {
|
|
184
|
+
await req.payload.sendEmail({
|
|
185
|
+
to: recipients.join(', '),
|
|
186
|
+
subject: `New form submission (${collection}/${id})`,
|
|
187
|
+
text: Object.entries(result.data)
|
|
188
|
+
.map(([name, value]) => `${name}: ${singleLine(value)}`)
|
|
189
|
+
.join('\n'),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
// The submission is stored; a mail server that is down does not lose it or fail the visitor.
|
|
194
|
+
req.payload.logger.error({ err: error, msg: 'buildr: the form notification was not sent' });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return done('sent') ?? json({ ok: true });
|
|
198
|
+
},
|
|
199
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"forms.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/forms.test.ts"],"names":[],"mappings":""}
|