@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.
- package/LICENSE +21 -0
- 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 +107 -3
- package/README.md +0 -3
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { parseDocument } from '@next-buildr/core';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { builtinLayout, resolveLayout } from '../../data/index.js';
|
|
4
|
+
import { boot, title, withChild } from './endpoints.test-kit.js';
|
|
5
|
+
let h;
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
h = await boot('templates', {
|
|
8
|
+
collections: {
|
|
9
|
+
pages: { context: 'page', templates: true },
|
|
10
|
+
articles: { context: 'article', templates: true },
|
|
11
|
+
},
|
|
12
|
+
}, {
|
|
13
|
+
collections: [
|
|
14
|
+
{
|
|
15
|
+
slug: 'articles',
|
|
16
|
+
admin: { useAsTitle: 'title' },
|
|
17
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
18
|
+
versions: { drafts: true },
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
afterAll(() => h.close());
|
|
24
|
+
const layout = (text) => withChild(title(text));
|
|
25
|
+
const template = (data, status = 'published') => h.payload.create({
|
|
26
|
+
collection: 'buildr-templates',
|
|
27
|
+
data: {
|
|
28
|
+
title: 'T',
|
|
29
|
+
targetCollection: 'pages',
|
|
30
|
+
layout: layout('T'),
|
|
31
|
+
...data,
|
|
32
|
+
_status: status,
|
|
33
|
+
},
|
|
34
|
+
draft: status === 'draft',
|
|
35
|
+
});
|
|
36
|
+
const page = (data = {}) => h.payload.create({
|
|
37
|
+
collection: 'pages',
|
|
38
|
+
data: { title: 'P', slug: 'p', ...data },
|
|
39
|
+
draft: true,
|
|
40
|
+
});
|
|
41
|
+
const resolve = async (doc, options = {}) => {
|
|
42
|
+
const collection = options.collection ?? 'pages';
|
|
43
|
+
const stored = (await h.payload.findByID({
|
|
44
|
+
collection: collection,
|
|
45
|
+
id: doc.id,
|
|
46
|
+
draft: true,
|
|
47
|
+
depth: 0,
|
|
48
|
+
}));
|
|
49
|
+
return resolveLayout({
|
|
50
|
+
payload: h.payload,
|
|
51
|
+
collection,
|
|
52
|
+
doc: stored,
|
|
53
|
+
contextName: 'page',
|
|
54
|
+
...options,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const nameOf = (resolved) => resolved.layout.nodes['child00001']?.props?.title?.value;
|
|
58
|
+
describe('resolveLayout', () => {
|
|
59
|
+
it('prefers the own layout, then the chosen template, then the default, then the built-in one', async () => {
|
|
60
|
+
const chosen = await template({ title: 'Chosen', layout: layout('chosen') });
|
|
61
|
+
const isDefault = await template({
|
|
62
|
+
title: 'Default',
|
|
63
|
+
isDefault: true,
|
|
64
|
+
layout: layout('default'),
|
|
65
|
+
});
|
|
66
|
+
const own = await page({ layout: layout('own'), template: chosen.id });
|
|
67
|
+
expect(await resolve(own)).toMatchObject({ source: 'document', layoutRef: `pages:${own.id}` });
|
|
68
|
+
expect(nameOf(await resolve(own))).toBe('own');
|
|
69
|
+
const inherits = await page({ template: chosen.id });
|
|
70
|
+
const viaTemplate = await resolve(inherits);
|
|
71
|
+
expect(viaTemplate).toMatchObject({
|
|
72
|
+
source: 'template',
|
|
73
|
+
layoutRef: `buildr-templates:${chosen.id}`,
|
|
74
|
+
});
|
|
75
|
+
expect(nameOf(viaTemplate)).toBe('chosen');
|
|
76
|
+
const plain = await page();
|
|
77
|
+
const viaDefault = await resolve(plain);
|
|
78
|
+
expect(viaDefault).toMatchObject({
|
|
79
|
+
source: 'default-template',
|
|
80
|
+
layoutRef: `buildr-templates:${isDefault.id}`,
|
|
81
|
+
});
|
|
82
|
+
expect(nameOf(viaDefault)).toBe('default');
|
|
83
|
+
await h.payload.delete({ collection: 'buildr-templates', id: isDefault.id });
|
|
84
|
+
expect(await resolve(plain)).toMatchObject({ source: 'builtin', layoutRef: null });
|
|
85
|
+
});
|
|
86
|
+
it('treats an empty layout as none', async () => {
|
|
87
|
+
const chosen = await template({ title: 'Chosen', layout: layout('chosen') });
|
|
88
|
+
const empty = await page({
|
|
89
|
+
layout: {
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
root: 'root',
|
|
92
|
+
nodes: { root: { id: 'root', type: 'buildr/page' } },
|
|
93
|
+
components: { 'buildr/page': 1 },
|
|
94
|
+
},
|
|
95
|
+
template: chosen.id,
|
|
96
|
+
});
|
|
97
|
+
expect((await resolve(empty)).source).toBe('template');
|
|
98
|
+
});
|
|
99
|
+
it('uses published templates only, unless it reads drafts', async () => {
|
|
100
|
+
const draft = await template({ title: 'Wip', layout: layout('wip') }, 'draft');
|
|
101
|
+
const doc = await page({ template: draft.id });
|
|
102
|
+
expect((await resolve(doc)).source).not.toBe('template');
|
|
103
|
+
expect((await resolve(doc, { draft: true })).source).not.toBe('template');
|
|
104
|
+
expect(await resolve(doc, { draft: true, overrideAccess: true })).toMatchObject({
|
|
105
|
+
source: 'template',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
it('never uses a template of another collection', async () => {
|
|
109
|
+
const other = await template({
|
|
110
|
+
title: 'For articles',
|
|
111
|
+
targetCollection: 'articles',
|
|
112
|
+
layout: layout('other'),
|
|
113
|
+
});
|
|
114
|
+
const doc = await page({ template: other.id });
|
|
115
|
+
expect((await resolve(doc)).source).not.toBe('template');
|
|
116
|
+
});
|
|
117
|
+
it('answers with a built-in layout that is a valid document', () => {
|
|
118
|
+
const parsed = parseDocument(builtinLayout('post'));
|
|
119
|
+
expect(parsed.ok).toBe(true);
|
|
120
|
+
expect(JSON.stringify(builtinLayout('post'))).toContain('post.title');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('buildr-templates', () => {
|
|
124
|
+
it('allows one default per target collection', async () => {
|
|
125
|
+
const first = await template({ title: 'First', targetCollection: 'articles', isDefault: true });
|
|
126
|
+
await expect(template({ title: 'Second', targetCollection: 'articles', isDefault: true })).rejects.toThrow();
|
|
127
|
+
// Another collection may have its own default, and the default may be saved again.
|
|
128
|
+
await template({ title: 'Pages default', targetCollection: 'pages', isDefault: true });
|
|
129
|
+
await h.payload.update({
|
|
130
|
+
collection: 'buildr-templates',
|
|
131
|
+
id: first.id,
|
|
132
|
+
data: { title: 'First, renamed' },
|
|
133
|
+
});
|
|
134
|
+
// Unmarking the first makes room for another.
|
|
135
|
+
await h.payload.update({
|
|
136
|
+
collection: 'buildr-templates',
|
|
137
|
+
id: first.id,
|
|
138
|
+
data: { isDefault: false },
|
|
139
|
+
});
|
|
140
|
+
const second = await template({
|
|
141
|
+
title: 'Second',
|
|
142
|
+
targetCollection: 'articles',
|
|
143
|
+
isDefault: true,
|
|
144
|
+
});
|
|
145
|
+
expect(second.id).toBeDefined();
|
|
146
|
+
});
|
|
147
|
+
it('rejects an invalid layout and a target that takes no templates', async () => {
|
|
148
|
+
await expect(template({ layout: { nope: true } })).rejects.toThrow();
|
|
149
|
+
await expect(template({ targetCollection: 'users' })).rejects.toThrow();
|
|
150
|
+
});
|
|
151
|
+
it('is readable by visitors only when published, and writable only by the builder', async () => {
|
|
152
|
+
const live = await template({ title: 'Live' });
|
|
153
|
+
const wip = await template({ title: 'Wip' }, 'draft');
|
|
154
|
+
const visible = await h.payload.find({
|
|
155
|
+
collection: 'buildr-templates',
|
|
156
|
+
overrideAccess: false,
|
|
157
|
+
});
|
|
158
|
+
const ids = visible.docs.map((doc) => doc.id);
|
|
159
|
+
expect(ids).toContain(live.id);
|
|
160
|
+
expect(ids).not.toContain(wip.id);
|
|
161
|
+
await expect(h.payload.create({
|
|
162
|
+
collection: 'buildr-templates',
|
|
163
|
+
data: { title: 'x', targetCollection: 'pages' },
|
|
164
|
+
overrideAccess: false,
|
|
165
|
+
})).rejects.toThrow();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('GET /buildr/documents: the source of the layout', () => {
|
|
169
|
+
it('opens an inheriting document with the template, and says where it came from', async () => {
|
|
170
|
+
const chosen = await template({ title: 'Shared', layout: layout('shared') });
|
|
171
|
+
const doc = await page({ template: chosen.id });
|
|
172
|
+
const { status, body } = await h.call('GET', `/buildr/documents/pages/${doc.id}`);
|
|
173
|
+
expect(status).toBe(200);
|
|
174
|
+
expect(body.layoutSource).toBe('template');
|
|
175
|
+
expect(body.layoutRef).toBe(`buildr-templates:${chosen.id}`);
|
|
176
|
+
expect(nameOf({ layout: body.document })).toBe('shared');
|
|
177
|
+
});
|
|
178
|
+
it('opens an own layout as such, and a bare document blank with the built-in source', async () => {
|
|
179
|
+
const own = await page({ layout: layout('mine') });
|
|
180
|
+
const opened = await h.call('GET', `/buildr/documents/pages/${own.id}`);
|
|
181
|
+
expect(opened.body).toMatchObject({ layoutSource: 'document', layoutRef: `pages:${own.id}` });
|
|
182
|
+
await h.payload.delete({
|
|
183
|
+
collection: 'buildr-templates',
|
|
184
|
+
where: { isDefault: { equals: true } },
|
|
185
|
+
});
|
|
186
|
+
const bare = await page();
|
|
187
|
+
const blank = await h.call('GET', `/buildr/documents/pages/${bare.id}`);
|
|
188
|
+
expect(blank.body).toMatchObject({ layoutSource: 'builtin', layoutRef: null });
|
|
189
|
+
expect(Object.keys(blank.body.document.nodes)).toEqual(['root']);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Field, FieldHook } from 'payload';
|
|
2
|
+
import { TEMPLATES_COLLECTION } from '../data/resolve-layout.ts';
|
|
3
|
+
/** The Payload import-map path of the admin field (docs/payload.md). */
|
|
4
|
+
export declare const LAYOUT_FIELD_COMPONENT = "@next-buildr/payload/admin#LayoutField";
|
|
5
|
+
/** The collection the `template` relationship points at. */
|
|
6
|
+
export { TEMPLATES_COLLECTION };
|
|
7
|
+
export declare const LAYOUT = "layout";
|
|
8
|
+
export declare const REVISION = "buildrRevision";
|
|
9
|
+
export declare const TEMPLATE = "template";
|
|
10
|
+
/** Who made the last builder write (added with `mcp.enabled`); versions keep it, so history names the agent. */
|
|
11
|
+
export declare const UPDATED_BY = "buildrUpdatedBy";
|
|
12
|
+
/** The names the plugin owns; a collection that already has one of them is a configuration error. */
|
|
13
|
+
export declare const RESERVED_FIELDS: readonly string[];
|
|
14
|
+
/** Reserved too, but only when `mcp.enabled` adds it. */
|
|
15
|
+
export declare const RESERVED_MCP_FIELDS: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* The fields the plugin adds to a collection: the document (`layout`), its concurrency counter
|
|
18
|
+
* (`buildrRevision`, hidden and read-only) and, when the collection takes templates, `template`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildrFields(options: {
|
|
21
|
+
readonly templates: boolean;
|
|
22
|
+
readonly editorRoute: string;
|
|
23
|
+
readonly guard: (name: string) => FieldHook;
|
|
24
|
+
readonly layoutHook: FieldHook;
|
|
25
|
+
/** The auth collections `buildrUpdatedBy` may point at; absent unless agents are enabled. */
|
|
26
|
+
readonly updatedBy?: readonly string[] | undefined;
|
|
27
|
+
}): Field[];
|
|
28
|
+
//# sourceMappingURL=fields.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields.d.ts","sourceRoot":"","sources":["../../src/plugin/fields.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAChD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAEjE,wEAAwE;AACxE,eAAO,MAAM,sBAAsB,2CAA2C,CAAC;AAC/E,4DAA4D;AAC5D,OAAO,EAAE,oBAAoB,EAAE,CAAC;AAEhC,eAAO,MAAM,MAAM,WAAW,CAAC;AAC/B,eAAO,MAAM,QAAQ,mBAAmB,CAAC;AACzC,eAAO,MAAM,QAAQ,aAAa,CAAC;AACnC,gHAAgH;AAChH,eAAO,MAAM,UAAU,oBAAoB,CAAC;AAE5C,qGAAqG;AACrG,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAAiC,CAAC;AAC/E,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,EAAE,SAAS,MAAM,EAAiB,CAAC;AAEnE;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE;IACpC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,SAAS,CAAC;IAC5C,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,6FAA6F;IAC7F,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;CACpD,GAAG,KAAK,EAAE,CA2CV"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { TEMPLATES_COLLECTION } from '../data/resolve-layout.js';
|
|
2
|
+
/** The Payload import-map path of the admin field (docs/payload.md). */
|
|
3
|
+
export const LAYOUT_FIELD_COMPONENT = '@next-buildr/payload/admin#LayoutField';
|
|
4
|
+
/** The collection the `template` relationship points at. */
|
|
5
|
+
export { TEMPLATES_COLLECTION };
|
|
6
|
+
export const LAYOUT = 'layout';
|
|
7
|
+
export const REVISION = 'buildrRevision';
|
|
8
|
+
export const TEMPLATE = 'template';
|
|
9
|
+
/** Who made the last builder write (added with `mcp.enabled`); versions keep it, so history names the agent. */
|
|
10
|
+
export const UPDATED_BY = 'buildrUpdatedBy';
|
|
11
|
+
/** The names the plugin owns; a collection that already has one of them is a configuration error. */
|
|
12
|
+
export const RESERVED_FIELDS = [LAYOUT, REVISION, TEMPLATE];
|
|
13
|
+
/** Reserved too, but only when `mcp.enabled` adds it. */
|
|
14
|
+
export const RESERVED_MCP_FIELDS = [UPDATED_BY];
|
|
15
|
+
/**
|
|
16
|
+
* The fields the plugin adds to a collection: the document (`layout`), its concurrency counter
|
|
17
|
+
* (`buildrRevision`, hidden and read-only) and, when the collection takes templates, `template`.
|
|
18
|
+
*/
|
|
19
|
+
export function buildrFields(options) {
|
|
20
|
+
const fields = [
|
|
21
|
+
{
|
|
22
|
+
name: LAYOUT,
|
|
23
|
+
type: 'json',
|
|
24
|
+
label: 'Layout',
|
|
25
|
+
hooks: { beforeChange: [options.layoutHook] },
|
|
26
|
+
admin: {
|
|
27
|
+
components: {
|
|
28
|
+
Field: {
|
|
29
|
+
path: LAYOUT_FIELD_COMPONENT,
|
|
30
|
+
clientProps: { editorRoute: options.editorRoute },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: REVISION,
|
|
37
|
+
type: 'number',
|
|
38
|
+
defaultValue: 0,
|
|
39
|
+
hooks: { beforeChange: [options.guard(REVISION)] },
|
|
40
|
+
admin: { hidden: true, readOnly: true },
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
if (options.updatedBy !== undefined) {
|
|
44
|
+
fields.push({
|
|
45
|
+
name: UPDATED_BY,
|
|
46
|
+
type: 'relationship',
|
|
47
|
+
relationTo: [...options.updatedBy],
|
|
48
|
+
hasMany: false,
|
|
49
|
+
hooks: { beforeChange: [options.guard(UPDATED_BY)] },
|
|
50
|
+
admin: { hidden: true, readOnly: true },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (options.templates) {
|
|
54
|
+
fields.push({
|
|
55
|
+
name: TEMPLATE,
|
|
56
|
+
type: 'relationship',
|
|
57
|
+
relationTo: TEMPLATES_COLLECTION,
|
|
58
|
+
hasMany: false,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return fields;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"forms.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/forms.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isAllowedRecipient } from './notify.js';
|
|
3
|
+
import { createMemoryRateLimiter } from './rate-limit.js';
|
|
4
|
+
import { validateSubmission } from './validate.js';
|
|
5
|
+
const schema = (fields) => ({ formId: 'form000001', fields });
|
|
6
|
+
const field = (name, valueType, extra = {}) => ({ nodeId: 'field00001', name, valueType, required: false, ...extra });
|
|
7
|
+
describe('validateSubmission', () => {
|
|
8
|
+
it('checks every value type', () => {
|
|
9
|
+
const result = validateSubmission(schema([
|
|
10
|
+
field('n', 'number'),
|
|
11
|
+
field('u', 'url'),
|
|
12
|
+
field('t', 'tel'),
|
|
13
|
+
field('b', 'boolean'),
|
|
14
|
+
field('e', 'enum', { options: ['a', 'b'] }),
|
|
15
|
+
]), { n: '4.5', u: 'https://example.com/x', t: '+48 600 100 200', b: 'off', e: 'a' });
|
|
16
|
+
expect(result).toEqual({
|
|
17
|
+
ok: true,
|
|
18
|
+
data: { n: 4.5, u: 'https://example.com/x', t: '+48 600 100 200', b: false, e: 'a' },
|
|
19
|
+
});
|
|
20
|
+
const bad = validateSubmission(schema([
|
|
21
|
+
field('n', 'number'),
|
|
22
|
+
field('u', 'url'),
|
|
23
|
+
field('t', 'tel'),
|
|
24
|
+
field('e', 'enum', { options: ['a'] }),
|
|
25
|
+
]), { n: 'abc', u: 'javascript:alert(1)', t: 'call me', e: ['a'] });
|
|
26
|
+
expect(bad).toEqual({
|
|
27
|
+
ok: false,
|
|
28
|
+
errors: [
|
|
29
|
+
{ field: 'n', code: 'invalid' },
|
|
30
|
+
{ field: 'u', code: 'invalid' },
|
|
31
|
+
{ field: 't', code: 'invalid' },
|
|
32
|
+
{ field: 'e', code: 'invalid' },
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
it('omits an empty optional field and requires a required one', () => {
|
|
37
|
+
const fields = [field('a', 'string'), field('b', 'string', { required: true })];
|
|
38
|
+
expect(validateSubmission(schema(fields), { a: ' ', b: ' x ' })).toEqual({
|
|
39
|
+
ok: true,
|
|
40
|
+
data: { b: 'x' },
|
|
41
|
+
});
|
|
42
|
+
expect(validateSubmission(schema(fields), { a: 'x' })).toEqual({
|
|
43
|
+
ok: false,
|
|
44
|
+
errors: [{ field: 'b', code: 'required' }],
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
it('requires a required checkbox to be ticked', () => {
|
|
48
|
+
const fields = [field('c', 'boolean', { required: true })];
|
|
49
|
+
expect(validateSubmission(schema(fields), {}).ok).toBe(false);
|
|
50
|
+
expect(validateSubmission(schema(fields), { c: 'on' }).ok).toBe(true);
|
|
51
|
+
expect(validateSubmission(schema(fields), { c: 'maybe' }).ok).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('does not read inherited properties as values', () => {
|
|
54
|
+
const result = validateSubmission(schema([field('toString', 'string', { required: true })]), {});
|
|
55
|
+
expect(result.ok).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
it('limits an unlimited text field', () => {
|
|
58
|
+
expect(validateSubmission(schema([field('a', 'string')]), { a: 'x'.repeat(5001) }).ok).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('createMemoryRateLimiter', () => {
|
|
62
|
+
it('allows the limit per window, then refuses until the window ends', () => {
|
|
63
|
+
let now = 0;
|
|
64
|
+
const limiter = createMemoryRateLimiter({ limit: 2, windowMs: 1000, now: () => now });
|
|
65
|
+
expect(limiter.hit('a')).toEqual({ allowed: true });
|
|
66
|
+
expect(limiter.hit('a')).toEqual({ allowed: true });
|
|
67
|
+
expect(limiter.hit('a')).toEqual({ allowed: false, retryAfterSeconds: 1 });
|
|
68
|
+
expect(limiter.hit('b')).toEqual({ allowed: true });
|
|
69
|
+
now = 1000;
|
|
70
|
+
expect(limiter.hit('a')).toEqual({ allowed: true });
|
|
71
|
+
});
|
|
72
|
+
it('keeps no state between limiters', () => {
|
|
73
|
+
const one = createMemoryRateLimiter({ limit: 1, windowMs: 1000 });
|
|
74
|
+
const two = createMemoryRateLimiter({ limit: 1, windowMs: 1000 });
|
|
75
|
+
one.hit('k');
|
|
76
|
+
expect(two.hit('k')).toEqual({ allowed: true });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('isAllowedRecipient', () => {
|
|
80
|
+
it('matches exact addresses and domains, ignoring case', () => {
|
|
81
|
+
const list = ['ops@example.com', '@team.example'];
|
|
82
|
+
expect(isAllowedRecipient('OPS@example.com', list)).toBe(true);
|
|
83
|
+
expect(isAllowedRecipient('anyone@team.example', list)).toBe(true);
|
|
84
|
+
expect(isAllowedRecipient('other@example.com', list)).toBe(false);
|
|
85
|
+
expect(isAllowedRecipient('x@evil-team.example', list)).toBe(false);
|
|
86
|
+
expect(isAllowedRecipient('a@team.example, b@evil.example', list)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `address` is one the site may send notifications to: an allowlist entry is an exact
|
|
3
|
+
* address (`ops@example.com`) or a domain (`@example.com`). Comparison ignores case.
|
|
4
|
+
*/
|
|
5
|
+
export declare function isAllowedRecipient(address: string, allowlist: readonly string[]): boolean;
|
|
6
|
+
//# sourceMappingURL=notify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/notify.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAOzF"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `address` is one the site may send notifications to: an allowlist entry is an exact
|
|
3
|
+
* address (`ops@example.com`) or a domain (`@example.com`). Comparison ignores case.
|
|
4
|
+
*/
|
|
5
|
+
export function isAllowedRecipient(address, allowlist) {
|
|
6
|
+
const value = address.trim().toLowerCase();
|
|
7
|
+
if (!/^[^\s@,;<>]+@[^\s@,;<>]+$/.test(value))
|
|
8
|
+
return false;
|
|
9
|
+
return allowlist.some((entry) => {
|
|
10
|
+
const allowed = entry.trim().toLowerCase();
|
|
11
|
+
return allowed.startsWith('@') ? value.endsWith(allowed) : value === allowed;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type RateLimitResult = {
|
|
2
|
+
readonly allowed: true;
|
|
3
|
+
} | {
|
|
4
|
+
readonly allowed: false;
|
|
5
|
+
readonly retryAfterSeconds: number;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Counts attempts per key. `hit` records one attempt and says whether it may go on. The bundled
|
|
9
|
+
* implementation lives in one process's memory; behind several serverless instances supply one
|
|
10
|
+
* backed by a shared store (Redis, ...) through the `forms.rateLimiter` option.
|
|
11
|
+
*/
|
|
12
|
+
export interface RateLimiter {
|
|
13
|
+
hit(key: string): RateLimitResult | Promise<RateLimitResult>;
|
|
14
|
+
}
|
|
15
|
+
export interface MemoryRateLimiterOptions {
|
|
16
|
+
/** Attempts allowed per window. */
|
|
17
|
+
readonly limit: number;
|
|
18
|
+
readonly windowMs: number;
|
|
19
|
+
/** Replaceable clock, for tests. */
|
|
20
|
+
readonly now?: () => number;
|
|
21
|
+
}
|
|
22
|
+
/** A fixed-window counter in memory. Each call creates an independent limiter (no shared state). */
|
|
23
|
+
export declare function createMemoryRateLimiter(options: MemoryRateLimiterOptions): RateLimiter;
|
|
24
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/rate-limit.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAA;CAAE,GAC1B;IAAE,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpE;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;CAC9D;AAED,MAAM,WAAW,wBAAwB;IACvC,mCAAmC;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAKD,oGAAoG;AACpG,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,WAAW,CA6BtF"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Entries kept at most: a flood of distinct keys cannot grow the map without bound. */
|
|
2
|
+
const MAX_KEYS = 10_000;
|
|
3
|
+
/** A fixed-window counter in memory. Each call creates an independent limiter (no shared state). */
|
|
4
|
+
export function createMemoryRateLimiter(options) {
|
|
5
|
+
const now = options.now ?? Date.now;
|
|
6
|
+
const windows = new Map();
|
|
7
|
+
return {
|
|
8
|
+
hit(key) {
|
|
9
|
+
const at = now();
|
|
10
|
+
if (windows.size >= MAX_KEYS) {
|
|
11
|
+
for (const [candidate, window] of windows) {
|
|
12
|
+
if (window.resetAt <= at)
|
|
13
|
+
windows.delete(candidate);
|
|
14
|
+
}
|
|
15
|
+
// Still full of live windows: drop the oldest ones.
|
|
16
|
+
for (const candidate of windows.keys()) {
|
|
17
|
+
if (windows.size < MAX_KEYS)
|
|
18
|
+
break;
|
|
19
|
+
windows.delete(candidate);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const current = windows.get(key);
|
|
23
|
+
if (current === undefined || current.resetAt <= at) {
|
|
24
|
+
windows.set(key, { count: 1, resetAt: at + options.windowMs });
|
|
25
|
+
return { allowed: true };
|
|
26
|
+
}
|
|
27
|
+
current.count += 1;
|
|
28
|
+
if (current.count <= options.limit)
|
|
29
|
+
return { allowed: true };
|
|
30
|
+
return {
|
|
31
|
+
allowed: false,
|
|
32
|
+
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - at) / 1000)),
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FormSchema } from '@next-buildr/core';
|
|
2
|
+
export type SubmissionErrorCode = 'unknown' | 'required' | 'invalid' | 'too-long';
|
|
3
|
+
export interface SubmissionError {
|
|
4
|
+
readonly field: string;
|
|
5
|
+
readonly code: SubmissionErrorCode;
|
|
6
|
+
}
|
|
7
|
+
export type Submission = {
|
|
8
|
+
readonly ok: true;
|
|
9
|
+
readonly data: Record<string, string | number | boolean>;
|
|
10
|
+
} | {
|
|
11
|
+
readonly ok: false;
|
|
12
|
+
readonly errors: readonly SubmissionError[];
|
|
13
|
+
};
|
|
14
|
+
/** A field without an author-set limit still has one: nobody stores megabytes in a text field. */
|
|
15
|
+
export declare const DEFAULT_MAX_LENGTH = 5000;
|
|
16
|
+
/**
|
|
17
|
+
* Checks a submission against the schema derived from the published document. The schema is the
|
|
18
|
+
* only thing that decides: a name it does not contain is an error (`unknown`), never ignored, and
|
|
19
|
+
* every value is checked against the type, length and options of its field. Never throws.
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateSubmission(schema: FormSchema, input: Readonly<Record<string, unknown>>): Submission;
|
|
22
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAmB,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAErE,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,CAAC;AAElF,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;CACpC;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAA;CAAE,GAC/E;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,eAAe,EAAE,CAAA;CAAE,CAAC;AAExE,kGAAkG;AAClG,eAAO,MAAM,kBAAkB,OAAO,CAAC;AA2DvC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACvC,UAAU,CAcZ"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** A field without an author-set limit still has one: nobody stores megabytes in a text field. */
|
|
2
|
+
export const DEFAULT_MAX_LENGTH = 5000;
|
|
3
|
+
const EMAIL_MAX = 254;
|
|
4
|
+
const EMAIL = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
|
|
5
|
+
const TEL = /^\+?[0-9()\s.-]{3,32}$/;
|
|
6
|
+
const TRUE = new Set(['true', 'on', '1', 'yes']);
|
|
7
|
+
const FALSE = new Set(['false', 'off', '0', 'no']);
|
|
8
|
+
const isEmpty = (value) => value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
|
|
9
|
+
const bad = (code) => ({ ok: false, code });
|
|
10
|
+
function checkField(field, raw) {
|
|
11
|
+
if (field.valueType === 'boolean') {
|
|
12
|
+
let on = false;
|
|
13
|
+
if (typeof raw === 'boolean')
|
|
14
|
+
on = raw;
|
|
15
|
+
else if (typeof raw === 'string' && TRUE.has(raw.toLowerCase()))
|
|
16
|
+
on = true;
|
|
17
|
+
else if (raw !== undefined && !(typeof raw === 'string' && FALSE.has(raw.toLowerCase()))) {
|
|
18
|
+
return bad('invalid');
|
|
19
|
+
}
|
|
20
|
+
if (field.required && !on)
|
|
21
|
+
return bad('required');
|
|
22
|
+
return { ok: true, value: on };
|
|
23
|
+
}
|
|
24
|
+
if (isEmpty(raw))
|
|
25
|
+
return field.required ? bad('required') : { ok: true, value: undefined };
|
|
26
|
+
if (field.valueType === 'number') {
|
|
27
|
+
const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw.trim()) : Number.NaN;
|
|
28
|
+
return Number.isFinite(parsed) ? { ok: true, value: parsed } : bad('invalid');
|
|
29
|
+
}
|
|
30
|
+
if (typeof raw !== 'string')
|
|
31
|
+
return bad('invalid');
|
|
32
|
+
const value = raw.trim();
|
|
33
|
+
if (value.length > (field.maxLength ?? DEFAULT_MAX_LENGTH))
|
|
34
|
+
return bad('too-long');
|
|
35
|
+
switch (field.valueType) {
|
|
36
|
+
case 'email':
|
|
37
|
+
return value.length <= EMAIL_MAX && EMAIL.test(value) ? { ok: true, value } : bad('invalid');
|
|
38
|
+
case 'tel':
|
|
39
|
+
return TEL.test(value) ? { ok: true, value } : bad('invalid');
|
|
40
|
+
case 'url': {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(value);
|
|
43
|
+
return url.protocol === 'http:' || url.protocol === 'https:'
|
|
44
|
+
? { ok: true, value }
|
|
45
|
+
: bad('invalid');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return bad('invalid');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
case 'enum':
|
|
52
|
+
return field.options?.includes(value) === true ? { ok: true, value } : bad('invalid');
|
|
53
|
+
default:
|
|
54
|
+
return { ok: true, value };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Checks a submission against the schema derived from the published document. The schema is the
|
|
59
|
+
* only thing that decides: a name it does not contain is an error (`unknown`), never ignored, and
|
|
60
|
+
* every value is checked against the type, length and options of its field. Never throws.
|
|
61
|
+
*/
|
|
62
|
+
export function validateSubmission(schema, input) {
|
|
63
|
+
const errors = [];
|
|
64
|
+
const known = new Set(schema.fields.map((field) => field.name));
|
|
65
|
+
for (const name of Object.keys(input)) {
|
|
66
|
+
if (!known.has(name))
|
|
67
|
+
errors.push({ field: name, code: 'unknown' });
|
|
68
|
+
}
|
|
69
|
+
const data = {};
|
|
70
|
+
for (const field of schema.fields) {
|
|
71
|
+
const raw = Object.hasOwn(input, field.name) ? input[field.name] : undefined;
|
|
72
|
+
const checked = checkField(field, raw);
|
|
73
|
+
if (!checked.ok)
|
|
74
|
+
errors.push({ field: field.name, code: checked.code });
|
|
75
|
+
else if (checked.value !== undefined)
|
|
76
|
+
data[field.name] = checked.value;
|
|
77
|
+
}
|
|
78
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true, data };
|
|
79
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type FieldHook } from 'payload';
|
|
2
|
+
import type { ResolvedOptions } from '../options.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The `beforeChange` hook of the `layout` field: the write-guard first (a write that is not the
|
|
5
|
+
* builder keeps the stored value, which is not re-validated), then `processLayout` for the writes
|
|
6
|
+
* that are allowed to change it. Invalid documents are rejected with a field error the admin shows.
|
|
7
|
+
*/
|
|
8
|
+
export declare const layoutHook: (options: Pick<ResolvedOptions, 'registry' | 'limits'>) => FieldHook;
|
|
9
|
+
//# sourceMappingURL=layout-hook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"layout-hook.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/layout-hook.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAmB,MAAM,SAAS,CAAC;AAE1D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAIrD;;;;GAIG;AACH,eAAO,MAAM,UAAU,YACX,IAAI,CAAC,eAAe,EAAE,UAAU,GAAG,QAAQ,CAAC,KAAG,SAsBxD,CAAC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ValidationError } from 'payload';
|
|
2
|
+
import { configuredLocales } from '../locales.js';
|
|
3
|
+
import { BUILDR_WRITE, writeGuard } from '../write-guard.js';
|
|
4
|
+
import { describeDiagnostics, processLayout } from './process-layout.js';
|
|
5
|
+
/**
|
|
6
|
+
* The `beforeChange` hook of the `layout` field: the write-guard first (a write that is not the
|
|
7
|
+
* builder keeps the stored value, which is not re-validated), then `processLayout` for the writes
|
|
8
|
+
* that are allowed to change it. Invalid documents are rejected with a field error the admin shows.
|
|
9
|
+
*/
|
|
10
|
+
export const layoutHook = (options) => (args) => {
|
|
11
|
+
const guarded = writeGuard('layout')(args);
|
|
12
|
+
const accepted = args.operation === 'create' || args.req.context[BUILDR_WRITE] === true;
|
|
13
|
+
if (!accepted)
|
|
14
|
+
return guarded;
|
|
15
|
+
// An update that does not carry a layout (a publish, a title edit) leaves it as it is.
|
|
16
|
+
if (args.operation === 'update' && guarded === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
const result = processLayout(guarded, {
|
|
19
|
+
...options,
|
|
20
|
+
locales: configuredLocales(args.req.payload.config),
|
|
21
|
+
});
|
|
22
|
+
if (!result.ok) {
|
|
23
|
+
throw new ValidationError({
|
|
24
|
+
...(args.collection == null ? {} : { collection: args.collection.slug }),
|
|
25
|
+
errors: [{ path: 'layout', message: describeDiagnostics(result.diagnostics) }],
|
|
26
|
+
req: args.req,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
for (const warning of result.warnings) {
|
|
30
|
+
args.req.payload.logger.warn({ code: warning.code, path: warning.path }, warning.message);
|
|
31
|
+
}
|
|
32
|
+
return result.doc;
|
|
33
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"layout-hook.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/layout-hook.test.ts"],"names":[],"mappings":""}
|