@next-buildr/payload 2.0.1 → 3.0.0-rc.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/README.md +2 -0
- package/UPGRADING.md +322 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/plugin/access.d.ts +7 -0
- package/dist/plugin/access.d.ts.map +1 -1
- package/dist/plugin/access.js +19 -0
- package/dist/plugin/collections/templates.d.ts.map +1 -1
- package/dist/plugin/collections/templates.js +9 -3
- package/dist/plugin/endpoints/context.d.ts +8 -0
- package/dist/plugin/endpoints/context.d.ts.map +1 -1
- package/dist/plugin/endpoints/forms-captcha.test.d.ts +2 -0
- package/dist/plugin/endpoints/forms-captcha.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/forms-captcha.test.js +676 -0
- package/dist/plugin/endpoints/forms.d.ts.map +1 -1
- package/dist/plugin/endpoints/forms.js +110 -12
- package/dist/plugin/endpoints/publish.d.ts.map +1 -1
- package/dist/plugin/endpoints/publish.js +4 -0
- package/dist/plugin/endpoints/save.d.ts.map +1 -1
- package/dist/plugin/endpoints/save.js +3 -0
- package/dist/plugin/forms/captcha.d.ts +45 -0
- package/dist/plugin/forms/captcha.d.ts.map +1 -0
- package/dist/plugin/forms/captcha.js +195 -0
- package/dist/plugin/forms/captcha.test.d.ts +2 -0
- package/dist/plugin/forms/captcha.test.d.ts.map +1 -0
- package/dist/plugin/forms/captcha.test.js +262 -0
- package/dist/plugin/forms/client-ip.d.ts +8 -0
- package/dist/plugin/forms/client-ip.d.ts.map +1 -0
- package/dist/plugin/forms/client-ip.js +41 -0
- package/dist/plugin/forms/client-ip.test.d.ts +2 -0
- package/dist/plugin/forms/client-ip.test.d.ts.map +1 -0
- package/dist/plugin/forms/client-ip.test.js +45 -0
- package/dist/plugin/forms/rate-limit.d.ts +2 -0
- package/dist/plugin/forms/rate-limit.d.ts.map +1 -1
- package/dist/plugin/forms/rate-limit.js +10 -1
- package/dist/plugin/hooks/author-code.test.d.ts +2 -0
- package/dist/plugin/hooks/author-code.test.d.ts.map +1 -0
- package/dist/plugin/hooks/author-code.test.js +285 -0
- package/dist/plugin/hooks/layout-hook.d.ts +1 -1
- package/dist/plugin/hooks/layout-hook.d.ts.map +1 -1
- package/dist/plugin/hooks/layout-hook.js +12 -2
- package/dist/plugin/hooks/process-layout.d.ts +10 -0
- package/dist/plugin/hooks/process-layout.d.ts.map +1 -1
- package/dist/plugin/hooks/process-layout.js +94 -1
- package/dist/plugin/hooks/read-layout.d.ts +1 -1
- package/dist/plugin/hooks/read-layout.d.ts.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +20 -1
- package/dist/plugin/options.d.ts +11 -0
- package/dist/plugin/options.d.ts.map +1 -1
- package/dist/plugin/options.js +49 -1
- package/dist/plugin/plugin.test.js +81 -2
- package/package.json +12 -11
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createRegistryMeta, p, s, } from '@next-buildr/core';
|
|
5
|
+
import { sqliteAdapter } from '@payloadcms/db-sqlite';
|
|
6
|
+
import { buildConfig, getPayload } from 'payload';
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
8
|
+
import { authorCodePermitted } from '../access.js';
|
|
9
|
+
import { buildrPlugin } from '../index.js';
|
|
10
|
+
import { resolveOptions } from '../options.js';
|
|
11
|
+
import { BUILDR_WRITE } from '../write-guard.js';
|
|
12
|
+
import { processLayout } from './process-layout.js';
|
|
13
|
+
// Author code (ADR-026) is a server-side capability: the plugin `authorCode` option, resolved per
|
|
14
|
+
// request, enforced on every write of a layout (the hook, the save endpoint, templates).
|
|
15
|
+
const widget = {
|
|
16
|
+
type: 'buildr/widget',
|
|
17
|
+
version: 1,
|
|
18
|
+
label: 'Widget',
|
|
19
|
+
category: 'content',
|
|
20
|
+
props: {
|
|
21
|
+
title: p.text({ label: 'Title', default: 'Hello' }),
|
|
22
|
+
html: p.textarea({ format: 'html' }),
|
|
23
|
+
},
|
|
24
|
+
contentCategories: ['flow'],
|
|
25
|
+
styles: { groups: [] },
|
|
26
|
+
runtime: 'shared',
|
|
27
|
+
slots: {},
|
|
28
|
+
};
|
|
29
|
+
const page = {
|
|
30
|
+
...widget,
|
|
31
|
+
type: 'buildr/page',
|
|
32
|
+
label: 'Page',
|
|
33
|
+
props: {},
|
|
34
|
+
capabilities: { root: true },
|
|
35
|
+
slots: { default: {} },
|
|
36
|
+
};
|
|
37
|
+
const meta = createRegistryMeta({ components: [page, widget] });
|
|
38
|
+
const withAuthor = (extra, props = {}) => ({
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
root: 'root',
|
|
41
|
+
nodes: {
|
|
42
|
+
root: { id: 'root', type: 'buildr/page', slots: { default: ['widget0001'] } },
|
|
43
|
+
widget0001: {
|
|
44
|
+
id: 'widget0001',
|
|
45
|
+
type: 'buildr/widget',
|
|
46
|
+
props: { title: s('a'), ...props },
|
|
47
|
+
...extra,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
components: { 'buildr/page': 1, 'buildr/widget': 1 },
|
|
51
|
+
});
|
|
52
|
+
const RED = { customCss: '& { color: red }' };
|
|
53
|
+
const BLUE = { customCss: '& { color: blue }' };
|
|
54
|
+
const HTML = { html: s('<p>x</p>') };
|
|
55
|
+
const codes = (result) => result.ok ? [] : result.diagnostics.map((diagnostic) => diagnostic.code);
|
|
56
|
+
describe('processLayout and author code', () => {
|
|
57
|
+
const limits = { maxNodes: 5000, maxBytes: 2_000_000 };
|
|
58
|
+
const registry = { meta };
|
|
59
|
+
it.each([
|
|
60
|
+
['custom CSS', withAuthor(RED)],
|
|
61
|
+
['classes', withAuthor({ classes: ['x'] })],
|
|
62
|
+
['attributes', withAuthor({ attributes: { 'data-x': '1' } })],
|
|
63
|
+
['an HTML prop', withAuthor({}, HTML)],
|
|
64
|
+
])('rejects %s unless the request may write author code', (_name, doc) => {
|
|
65
|
+
expect(codes(processLayout(doc, { limits, registry }))).toEqual(['author.not-permitted']);
|
|
66
|
+
expect(codes(processLayout(doc, { limits, registry, allowAuthorCode: false }))).toEqual([
|
|
67
|
+
'author.not-permitted',
|
|
68
|
+
]);
|
|
69
|
+
expect(processLayout(doc, { limits, registry, allowAuthorCode: true }).ok).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('accepts a document without author code', () => {
|
|
72
|
+
expect(processLayout(withAuthor({}), { limits, registry }).ok).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
it('still checks the code itself when it is permitted', () => {
|
|
75
|
+
const bad = withAuthor({ customCss: '@import "x";' });
|
|
76
|
+
expect(codes(processLayout(bad, { limits, registry, allowAuthorCode: true }))).toEqual([
|
|
77
|
+
'author.invalid',
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
it('lets stored author code be saved unchanged and removed, never added or changed', () => {
|
|
81
|
+
const stored = withAuthor(RED, HTML);
|
|
82
|
+
const options = { limits, registry, previous: stored };
|
|
83
|
+
expect(processLayout(stored, options).ok).toBe(true);
|
|
84
|
+
// removed
|
|
85
|
+
expect(processLayout(withAuthor({}), options).ok).toBe(true);
|
|
86
|
+
// changed or added
|
|
87
|
+
expect(codes(processLayout(withAuthor(BLUE, HTML), options))).toEqual(['author.not-permitted']);
|
|
88
|
+
expect(codes(processLayout(withAuthor(RED, { html: s('<p>y</p>') }), options))).toEqual([
|
|
89
|
+
'author.not-permitted',
|
|
90
|
+
]);
|
|
91
|
+
expect(codes(processLayout(withAuthor({ ...RED, classes: ['c'] }, HTML), options))).toEqual([
|
|
92
|
+
'author.not-permitted',
|
|
93
|
+
]);
|
|
94
|
+
// nothing stored, or a stored value that is not a document, excuses nothing
|
|
95
|
+
expect(codes(processLayout(stored, { limits, registry, previous: withAuthor({}, {}) }))).toEqual(['author.not-permitted']);
|
|
96
|
+
expect(codes(processLayout(stored, { limits, registry, previous: 'nonsense' }))).toEqual([
|
|
97
|
+
'author.not-permitted',
|
|
98
|
+
]);
|
|
99
|
+
expect(codes(processLayout(stored, { limits, registry, previous: undefined }))).toEqual([
|
|
100
|
+
'author.not-permitted',
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('processLayout without a registry', () => {
|
|
105
|
+
// With no registry the html props of a component are unknown; the names Text uses are still guarded.
|
|
106
|
+
const limits = { maxNodes: 5000, maxBytes: 2_000_000 };
|
|
107
|
+
it.each([
|
|
108
|
+
['an html prop', withAuthor({}, HTML)],
|
|
109
|
+
['mode html', withAuthor({}, { mode: s('html') })],
|
|
110
|
+
['html and mode html', withAuthor({}, { mode: s('html'), ...HTML })],
|
|
111
|
+
['a bound html prop', withAuthor({}, { html: { kind: 'binding', path: 'page.title' } })],
|
|
112
|
+
])('rejects %s unless the request may write author code', (_name, doc) => {
|
|
113
|
+
expect(codes(processLayout(doc, { limits }))).toEqual(['author.not-permitted']);
|
|
114
|
+
expect(codes(processLayout(doc, { limits, allowAuthorCode: false }))).toEqual([
|
|
115
|
+
'author.not-permitted',
|
|
116
|
+
]);
|
|
117
|
+
expect(processLayout(doc, { limits, allowAuthorCode: true }).ok).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
it('still rejects node fields, and accepts a plain document or a plain Text mode', () => {
|
|
120
|
+
expect(codes(processLayout(withAuthor(RED), { limits }))).toEqual(['author.not-permitted']);
|
|
121
|
+
expect(processLayout(withAuthor({}), { limits }).ok).toBe(true);
|
|
122
|
+
expect(processLayout(withAuthor({}, { mode: s('plain') }), { limits }).ok).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
it('lets stored html be saved unchanged and removed, never added or changed', () => {
|
|
125
|
+
const stored = withAuthor({}, { mode: s('html'), ...HTML });
|
|
126
|
+
const options = { limits, previous: stored };
|
|
127
|
+
expect(processLayout(stored, options).ok).toBe(true);
|
|
128
|
+
expect(processLayout(withAuthor({}, { mode: s('plain') }), options).ok).toBe(true);
|
|
129
|
+
expect(codes(processLayout(withAuthor({}, { mode: s('html'), html: s('<p>y</p>') }), options))).toEqual(['author.not-permitted']);
|
|
130
|
+
expect(codes(processLayout(withAuthor({}, { mode: s('html') }), { limits }))).toEqual([
|
|
131
|
+
'author.not-permitted',
|
|
132
|
+
]);
|
|
133
|
+
expect(codes(processLayout(stored, { limits, previous: withAuthor({}, { title: s('x') }) }))).toEqual(['author.not-permitted']);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('the authorCode option needs a registry', () => {
|
|
137
|
+
it('refuses anything but false at startup, like forms', () => {
|
|
138
|
+
expect(() => buildrPlugin({ collections: {}, authorCode: true })).toThrow(/registry/);
|
|
139
|
+
expect(() => buildrPlugin({ collections: {}, authorCode: () => true })).toThrow(/registry/);
|
|
140
|
+
expect(() => buildrPlugin({ collections: {}, authorCode: false })).not.toThrow();
|
|
141
|
+
expect(() => buildrPlugin({ collections: {} })).not.toThrow();
|
|
142
|
+
expect(() => buildrPlugin({ collections: {}, registry: { meta }, authorCode: true })).not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('authorCodePermitted', () => {
|
|
146
|
+
const req = (user) => ({ user });
|
|
147
|
+
const apiKey = req({ _strategy: 'api-key' });
|
|
148
|
+
it('is off by default and for anything but exactly true', async () => {
|
|
149
|
+
expect(resolveOptions({ collections: {} }).authorCode).toBe(false);
|
|
150
|
+
const off = { authorCode: false };
|
|
151
|
+
expect(await authorCodePermitted(off, req({ id: 1 }))).toBe(false);
|
|
152
|
+
const truthy = { authorCode: (() => 'yes') };
|
|
153
|
+
expect(await authorCodePermitted(truthy, req({ id: 1 }))).toBe(false);
|
|
154
|
+
expect(await authorCodePermitted({ authorCode: () => Promise.reject(new Error('x')) }, req())).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
it('follows a boolean or a function of the request, and never grants an API key', async () => {
|
|
157
|
+
expect(await authorCodePermitted({ authorCode: true }, req({ id: 1 }))).toBe(true);
|
|
158
|
+
const admin = {
|
|
159
|
+
authorCode: ({ req }) => req.user?.role === 'admin',
|
|
160
|
+
};
|
|
161
|
+
expect(await authorCodePermitted(admin, req({ role: 'admin' }))).toBe(true);
|
|
162
|
+
expect(await authorCodePermitted(admin, req({ role: 'editor' }))).toBe(false);
|
|
163
|
+
expect(await authorCodePermitted(admin, req())).toBe(false);
|
|
164
|
+
expect(await authorCodePermitted({ authorCode: true }, apiKey)).toBe(false);
|
|
165
|
+
expect(await authorCodePermitted({ authorCode: async () => true }, apiKey)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('the layout hook and author code', () => {
|
|
169
|
+
let dir;
|
|
170
|
+
let payload;
|
|
171
|
+
beforeAll(async () => {
|
|
172
|
+
dir = mkdtempSync(join(tmpdir(), 'buildr-payload-author-'));
|
|
173
|
+
const config = buildConfig({
|
|
174
|
+
secret: 'test-secret',
|
|
175
|
+
collections: [
|
|
176
|
+
{
|
|
177
|
+
slug: 'pages',
|
|
178
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
179
|
+
versions: { drafts: { autosave: true } },
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
db: sqliteAdapter({ client: { url: `file:${join(dir, 'db.sqlite')}` } }),
|
|
183
|
+
plugins: [
|
|
184
|
+
buildrPlugin({
|
|
185
|
+
collections: { pages: { context: 'page' } },
|
|
186
|
+
registry: { meta },
|
|
187
|
+
// Trusted only for requests that say so, as a host would decide from `req.user`.
|
|
188
|
+
authorCode: ({ req }) => req.context['trusted'] === true,
|
|
189
|
+
}),
|
|
190
|
+
],
|
|
191
|
+
typescript: { autoGenerate: false },
|
|
192
|
+
});
|
|
193
|
+
payload = await getPayload({ config, key: 'author-code' });
|
|
194
|
+
});
|
|
195
|
+
afterAll(() => {
|
|
196
|
+
try {
|
|
197
|
+
rmSync(dir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Windows keeps the database file open a moment longer.
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
const write = (payload, data, id, trusted = false) => id === undefined
|
|
204
|
+
? payload.create({ collection: 'pages', data, draft: true, context: { trusted } })
|
|
205
|
+
: payload.update({
|
|
206
|
+
collection: 'pages',
|
|
207
|
+
id,
|
|
208
|
+
data,
|
|
209
|
+
draft: true,
|
|
210
|
+
context: { [BUILDR_WRITE]: true, trusted },
|
|
211
|
+
});
|
|
212
|
+
// The message of the field error a rejected write carries ("layout" is all the top-level message says).
|
|
213
|
+
const fieldError = async (write) => {
|
|
214
|
+
const error = (await write.then(() => undefined, (caught) => caught));
|
|
215
|
+
return error?.data?.errors?.[0]?.message ?? '(the write was accepted)';
|
|
216
|
+
};
|
|
217
|
+
const nodeOf = async (payload, id) => (await payload.findByID({ collection: 'pages', id, draft: true })).layout.nodes['widget0001'];
|
|
218
|
+
it('rejects author code on create and on update for a request the option does not allow', async () => {
|
|
219
|
+
const off = payload;
|
|
220
|
+
await expect(fieldError(write(off, { title: 'A', layout: withAuthor(RED) }))).resolves.toMatch(/author code/i);
|
|
221
|
+
const doc = await write(off, { title: 'B' });
|
|
222
|
+
await expect(fieldError(write(off, { layout: withAuthor({}, HTML) }, doc.id))).resolves.toMatch(/author code/i);
|
|
223
|
+
expect(await nodeOf(off, doc.id)).toBeUndefined();
|
|
224
|
+
// Without author code the same save goes through.
|
|
225
|
+
await write(off, { layout: withAuthor({}) }, doc.id);
|
|
226
|
+
expect(await nodeOf(off, doc.id)).toBeDefined();
|
|
227
|
+
});
|
|
228
|
+
it('resolves the option per request and keeps stored author code saveable', async () => {
|
|
229
|
+
const trusting = payload;
|
|
230
|
+
await expect(fieldError(write(trusting, { title: 'C', layout: withAuthor(RED) }))).resolves.toMatch(/author code/i);
|
|
231
|
+
const doc = await write(trusting, { title: 'D', layout: withAuthor(RED) }, undefined, true);
|
|
232
|
+
expect((await nodeOf(trusting, doc.id))?.customCss).toBe(RED.customCss);
|
|
233
|
+
// Unchanged author code is saved without the capability (an edit elsewhere in the page).
|
|
234
|
+
await write(trusting, { layout: withAuthor(RED, { title: s('renamed') }) }, doc.id);
|
|
235
|
+
expect((await nodeOf(trusting, doc.id))?.customCss).toBe(RED.customCss);
|
|
236
|
+
// A change is not.
|
|
237
|
+
await expect(fieldError(write(trusting, { layout: withAuthor(BLUE) }, doc.id))).resolves.toMatch(/author code/i);
|
|
238
|
+
// A trusted request may change it, and anyone may remove it.
|
|
239
|
+
await write(trusting, { layout: withAuthor(BLUE) }, doc.id, true);
|
|
240
|
+
expect((await nodeOf(trusting, doc.id))?.customCss).toBe(BLUE.customCss);
|
|
241
|
+
await write(trusting, { layout: withAuthor({}) }, doc.id);
|
|
242
|
+
expect((await nodeOf(trusting, doc.id))?.customCss).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
it('does not copy author code through duplicate, and does not restore it through restoreVersion', async () => {
|
|
245
|
+
const source = await write(payload, { title: 'E', layout: withAuthor(RED, HTML) }, undefined, true);
|
|
246
|
+
// A duplicate is a new document: the source's author code is not "already stored" for it.
|
|
247
|
+
await expect(fieldError(payload.duplicate({
|
|
248
|
+
collection: 'pages',
|
|
249
|
+
id: source.id,
|
|
250
|
+
draft: true,
|
|
251
|
+
context: { trusted: false },
|
|
252
|
+
}))).resolves.toMatch(/author code/i);
|
|
253
|
+
const all = await payload.find({
|
|
254
|
+
collection: 'pages',
|
|
255
|
+
draft: true,
|
|
256
|
+
limit: 100,
|
|
257
|
+
pagination: false,
|
|
258
|
+
});
|
|
259
|
+
expect(all.docs.filter((doc) => doc['title'] === 'E')).toHaveLength(1);
|
|
260
|
+
// A trusted request may duplicate it.
|
|
261
|
+
const trusted = await payload.duplicate({
|
|
262
|
+
collection: 'pages',
|
|
263
|
+
id: source.id,
|
|
264
|
+
draft: true,
|
|
265
|
+
context: { trusted: true },
|
|
266
|
+
});
|
|
267
|
+
expect((await nodeOf(payload, trusted.id))?.customCss).toBe(RED.customCss);
|
|
268
|
+
// restoreVersion by an untrusted request keeps the stored layout (the write guard), so code
|
|
269
|
+
// that a later trusted save removed does not come back.
|
|
270
|
+
await write(payload, { layout: withAuthor({}) }, source.id, true);
|
|
271
|
+
const versions = await payload.findVersions({
|
|
272
|
+
collection: 'pages',
|
|
273
|
+
where: { parent: { equals: source.id } },
|
|
274
|
+
sort: 'createdAt',
|
|
275
|
+
limit: 20,
|
|
276
|
+
});
|
|
277
|
+
const withCode = versions.docs.find((version) => version.version.layout?.nodes['widget0001']?.customCss !==
|
|
278
|
+
undefined);
|
|
279
|
+
expect(withCode).toBeDefined();
|
|
280
|
+
await payload
|
|
281
|
+
.restoreVersion({ collection: 'pages', id: String(withCode?.id), draft: true })
|
|
282
|
+
.catch(() => undefined);
|
|
283
|
+
expect((await nodeOf(payload, source.id))?.customCss).toBeUndefined();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -5,5 +5,5 @@ import type { ResolvedOptions } from '../options.ts';
|
|
|
5
5
|
* builder keeps the stored value, which is not re-validated), then `processLayout` for the writes
|
|
6
6
|
* that are allowed to change it. Invalid documents are rejected with a field error the admin shows.
|
|
7
7
|
*/
|
|
8
|
-
export declare const layoutHook: (options: Pick<ResolvedOptions, 'registry' | 'limits'>) => FieldHook;
|
|
8
|
+
export declare const layoutHook: (options: Pick<ResolvedOptions, 'registry' | 'limits' | 'authorCode'>) => FieldHook;
|
|
9
9
|
//# sourceMappingURL=layout-hook.d.ts.map
|
|
@@ -1 +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;
|
|
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;AAG1D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAIrD;;;;GAIG;AACH,eAAO,MAAM,UAAU,YACX,IAAI,CAAC,eAAe,EAAE,UAAU,GAAG,QAAQ,GAAG,YAAY,CAAC,KAAG,SAgCvE,CAAC"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ValidationError } from 'payload';
|
|
2
|
+
import { authorCodePermitted } from '../access.js';
|
|
2
3
|
import { configuredLocales } from '../locales.js';
|
|
3
4
|
import { BUILDR_WRITE, writeGuard } from '../write-guard.js';
|
|
4
5
|
import { describeDiagnostics, processLayout } from './process-layout.js';
|
|
@@ -7,7 +8,7 @@ import { describeDiagnostics, processLayout } from './process-layout.js';
|
|
|
7
8
|
* builder keeps the stored value, which is not re-validated), then `processLayout` for the writes
|
|
8
9
|
* that are allowed to change it. Invalid documents are rejected with a field error the admin shows.
|
|
9
10
|
*/
|
|
10
|
-
export const layoutHook = (options) => (args) => {
|
|
11
|
+
export const layoutHook = (options) => async (args) => {
|
|
11
12
|
const guarded = writeGuard('layout')(args);
|
|
12
13
|
const accepted = args.operation === 'create' || args.req.context[BUILDR_WRITE] === true;
|
|
13
14
|
if (!accepted)
|
|
@@ -16,8 +17,17 @@ export const layoutHook = (options) => (args) => {
|
|
|
16
17
|
if (args.operation === 'update' && guarded === undefined)
|
|
17
18
|
return undefined;
|
|
18
19
|
const result = processLayout(guarded, {
|
|
19
|
-
|
|
20
|
+
registry: options.registry,
|
|
21
|
+
limits: options.limits,
|
|
20
22
|
locales: configuredLocales(args.req.payload.config),
|
|
23
|
+
// Author code (ADR-026) needs the host's permission for this request; what is already
|
|
24
|
+
// stored can be saved unchanged without it.
|
|
25
|
+
allowAuthorCode: await authorCodePermitted(options, args.req),
|
|
26
|
+
// A new document has no stored layout. On a duplicate Payload hands over the source document
|
|
27
|
+
// as `originalDoc`; trusting it would copy another document's author code without the permission.
|
|
28
|
+
previous: args.operation === 'create'
|
|
29
|
+
? undefined
|
|
30
|
+
: args.originalDoc?.['layout'],
|
|
21
31
|
});
|
|
22
32
|
if (!result.ok) {
|
|
23
33
|
throw new ValidationError({
|
|
@@ -9,6 +9,16 @@ export interface ProcessLayoutOptions {
|
|
|
9
9
|
};
|
|
10
10
|
/** The languages of the site: an `l10n` key outside them is reported (a warning, kept). */
|
|
11
11
|
readonly locales?: LocaleConfig | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Whether this request may write author code (ADR-026), as `authorCodePermitted` resolved it (not the `authorCode` option itself).
|
|
14
|
+
* Absent means no.
|
|
15
|
+
*/
|
|
16
|
+
readonly allowAuthorCode?: boolean | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* The layout as it is stored now. Without `allowAuthorCode`, author code on a node is accepted only
|
|
19
|
+
* when this document's node with the same id carries exactly the same.
|
|
20
|
+
*/
|
|
21
|
+
readonly previous?: unknown;
|
|
12
22
|
}
|
|
13
23
|
export type ProcessedLayout = {
|
|
14
24
|
readonly ok: true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-layout.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/process-layout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EACL,KAAK,eAAe,
|
|
1
|
+
{"version":3,"file":"process-layout.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/process-layout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EACL,KAAK,eAAe,EAIpB,KAAK,UAAU,EACf,KAAK,cAAc,EASpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1E,2FAA2F;IAC3F,QAAQ,CAAC,OAAO,CAAC,EAAE,YAAY,GAAG,SAAS,CAAC;IAC5C;;;OAGG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/C;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,GAC9F;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAAC;AAExE,2EAA2E;AAC3E,eAAO,MAAM,cAAc,WAAY,oBAAoB,CAAC,QAAQ,CAAC,KAAG,cAItE,CAAC;AAOH;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,oBAAoB,GAAG,eAAe,CAoE5F;AAoFD,gGAAgG;AAChG,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAO9E"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createEmptyDocument, DEFAULT_DOCUMENT_LIMITS, migrateComponents, migrateDocument, parseDocument, validateDocument, } from '@next-buildr/core';
|
|
1
|
+
import { checkAuthorCode, createEmptyDocument, DEFAULT_DOCUMENT_LIMITS, htmlPropNames, migrateComponents, migrateDocument, parseDocument, validateDocument, } from '@next-buildr/core';
|
|
2
2
|
/** The limits of the plugin can only tighten the defaults of the model. */
|
|
3
3
|
export const documentLimits = (limits) => ({
|
|
4
4
|
...DEFAULT_DOCUMENT_LIMITS,
|
|
@@ -55,8 +55,101 @@ export function processLayout(value, options) {
|
|
|
55
55
|
return { ok: false, diagnostics: errors };
|
|
56
56
|
warnings.push(...result.issues);
|
|
57
57
|
}
|
|
58
|
+
// Author code (ADR-026) is checked on every write, with the capability the host resolved for
|
|
59
|
+
// this request. Without it, a node may carry only the author code it already carried in the
|
|
60
|
+
// stored document: adding or changing any is rejected, so data written while the capability was
|
|
61
|
+
// on can still be saved unchanged (and removed), but nothing new gets in.
|
|
62
|
+
const author = checkAuthorCode(doc, { authorCode: options.allowAuthorCode === true }, registry?.meta);
|
|
63
|
+
const before = previousNodes(options.previous);
|
|
64
|
+
const authorErrors = author.filter((issue) => issue.severity === 'error' &&
|
|
65
|
+
!(issue.code === 'author.not-permitted' &&
|
|
66
|
+
unchangedAuthorCode(doc, before, issue.details?.['nodeId'], registry?.meta)));
|
|
67
|
+
// Without a registry the html props of a component are unknown, so `checkAuthorCode` cannot see
|
|
68
|
+
// them. Fail closed on the names Text uses: an `html` prop, or `mode: 'html'`, may not be added or
|
|
69
|
+
// changed by a request that may not write author code.
|
|
70
|
+
if (registry === undefined && options.allowAuthorCode !== true) {
|
|
71
|
+
authorErrors.push(...unregisteredHtmlErrors(doc, before));
|
|
72
|
+
}
|
|
73
|
+
if (authorErrors.length > 0)
|
|
74
|
+
return { ok: false, diagnostics: authorErrors };
|
|
75
|
+
warnings.push(...author.filter((issue) => issue.severity !== 'error'));
|
|
58
76
|
return { ok: true, doc, warnings };
|
|
59
77
|
}
|
|
78
|
+
/** The nodes of a stored layout, or none when it is absent or not shaped like a document. */
|
|
79
|
+
function previousNodes(previous) {
|
|
80
|
+
if (typeof previous !== 'object' || previous === null)
|
|
81
|
+
return {};
|
|
82
|
+
const nodes = previous.nodes;
|
|
83
|
+
return typeof nodes === 'object' && nodes !== null && !Array.isArray(nodes)
|
|
84
|
+
? nodes
|
|
85
|
+
: {};
|
|
86
|
+
}
|
|
87
|
+
/** Key-order independent text form of a JSON value, for comparing. */
|
|
88
|
+
function canonical(value) {
|
|
89
|
+
if (Array.isArray(value))
|
|
90
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
91
|
+
if (typeof value === 'object' && value !== null) {
|
|
92
|
+
return `{${Object.entries(value)
|
|
93
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
94
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
|
|
95
|
+
.join(',')}}`;
|
|
96
|
+
}
|
|
97
|
+
return JSON.stringify(value) ?? 'null';
|
|
98
|
+
}
|
|
99
|
+
/** A node's author code only: its three node fields and the values of its HTML props. */
|
|
100
|
+
function authorPart(node, meta) {
|
|
101
|
+
const props = Object.fromEntries(htmlPropNames(meta?.get(node.type))
|
|
102
|
+
.filter((name) => node.props !== undefined && Object.hasOwn(node.props, name))
|
|
103
|
+
.map((name) => [name, node.props?.[name]]));
|
|
104
|
+
return canonical({
|
|
105
|
+
customCss: node.customCss,
|
|
106
|
+
classes: node.classes,
|
|
107
|
+
attributes: node.attributes,
|
|
108
|
+
props,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** The props that could carry author HTML when no registry says which ones do: `html`, and `mode` when it is `'html'`. */
|
|
112
|
+
function unregisteredHtmlPart(node) {
|
|
113
|
+
const props = node.props;
|
|
114
|
+
if (props === undefined)
|
|
115
|
+
return undefined;
|
|
116
|
+
const html = Object.hasOwn(props, 'html') ? props['html'] : undefined;
|
|
117
|
+
const mode = Object.hasOwn(props, 'mode') ? props['mode'] : undefined;
|
|
118
|
+
const htmlMode = mode !== undefined && canonical(mode).includes('"html"');
|
|
119
|
+
if (html === undefined && !htmlMode)
|
|
120
|
+
return undefined;
|
|
121
|
+
return canonical({ html, mode: htmlMode ? mode : undefined });
|
|
122
|
+
}
|
|
123
|
+
/** One `author.not-permitted` error per node whose possible HTML differs from the stored node of the same id and type. */
|
|
124
|
+
function unregisteredHtmlErrors(doc, before) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const [id, node] of Object.entries(doc.nodes)) {
|
|
127
|
+
const now = unregisteredHtmlPart(node);
|
|
128
|
+
if (now === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
const was = Object.hasOwn(before, id) ? before[id] : undefined;
|
|
131
|
+
if (was !== undefined && was.type === node.type && unregisteredHtmlPart(was) === now)
|
|
132
|
+
continue;
|
|
133
|
+
out.push({
|
|
134
|
+
code: 'author.not-permitted',
|
|
135
|
+
message: `Node "${id}" carries HTML (an "html" prop or mode "html"), which this host does not allow.`,
|
|
136
|
+
severity: 'error',
|
|
137
|
+
path: ['nodes', id],
|
|
138
|
+
details: { nodeId: id },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/** Whether `nodeId` carries exactly the author code it carried in the stored document. */
|
|
144
|
+
function unchangedAuthorCode(doc, before, nodeId, meta) {
|
|
145
|
+
if (typeof nodeId !== 'string')
|
|
146
|
+
return false;
|
|
147
|
+
const now = Object.hasOwn(doc.nodes, nodeId) ? doc.nodes[nodeId] : undefined;
|
|
148
|
+
const was = Object.hasOwn(before, nodeId) ? before[nodeId] : undefined;
|
|
149
|
+
if (now === undefined || was === undefined || was.type !== now.type)
|
|
150
|
+
return false;
|
|
151
|
+
return authorPart(now, meta) === authorPart(was, meta);
|
|
152
|
+
}
|
|
60
153
|
/** A readable one-line-per-problem message for the admin UI (`code` and, when known, where). */
|
|
61
154
|
export function describeDiagnostics(diagnostics) {
|
|
62
155
|
return diagnostics
|
|
@@ -13,5 +13,5 @@ export type ReadLayout = {
|
|
|
13
13
|
* read-only when a component was written by newer code than this build knows (ADR-014).
|
|
14
14
|
* Validation is not repeated here: a stored document was validated when it was written.
|
|
15
15
|
*/
|
|
16
|
-
export declare function readLayout(value: unknown, options: ProcessLayoutOptions): ReadLayout;
|
|
16
|
+
export declare function readLayout(value: unknown, options: Pick<ProcessLayoutOptions, 'registry' | 'limits'>): ReadLayout;
|
|
17
17
|
//# sourceMappingURL=read-layout.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"read-layout.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/read-layout.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,eAAe,EAEpB,KAAK,UAAU,EAKhB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAEhF,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,GAChF;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAAC;AAExE;;;;GAIG;AACH,wBAAgB,UAAU,
|
|
1
|
+
{"version":3,"file":"read-layout.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/read-layout.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,eAAe,EAEpB,KAAK,UAAU,EAKhB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAEhF,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,GAChF;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAAC;AAExE;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,IAAI,CAAC,oBAAoB,EAAE,UAAU,GAAG,QAAQ,CAAC,GACzD,UAAU,CA6BZ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugin/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA4B,MAAM,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugin/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA4B,MAAM,EAAE,MAAM,SAAS,CAAC;AAchE,OAAO,EAAE,KAAK,mBAAmB,EAAkB,MAAM,cAAc,CAAC;AAGxE,OAAO,EAAE,2BAA2B,EAAE,MAAM,mCAAmC,CAAC;AAChF,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAC3E,YAAY,EAAE,wBAAwB,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACpG,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,YAAY,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACvF,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/F,YAAY,EACV,UAAU,EACV,QAAQ,EACR,mBAAmB,EACnB,cAAc,EACd,eAAe,GAChB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAOhD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,mBAAmB,GAAG,MAAM,CAyH/D"}
|
package/dist/plugin/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { formSubmissionsCollection } from './collections/form-submissions.js';
|
|
|
2
2
|
import { templatesCollection } from './collections/templates.js';
|
|
3
3
|
import { buildrEndpoints } from './endpoints/index.js';
|
|
4
4
|
import { buildrFields, RESERVED_FIELDS, RESERVED_MCP_FIELDS, TEMPLATES_COLLECTION, } from './fields.js';
|
|
5
|
+
import { createCaptchaGate, createTurnstileVerifier } from './forms/captcha.js';
|
|
5
6
|
import { isAllowedRecipient } from './forms/notify.js';
|
|
6
7
|
import { createMemoryRateLimiter } from './forms/rate-limit.js';
|
|
7
8
|
import { layoutHook } from './hooks/layout-hook.js';
|
|
@@ -28,6 +29,11 @@ export function buildrPlugin(input) {
|
|
|
28
29
|
if (forms.enabled && options.registry === undefined) {
|
|
29
30
|
throw new Error('buildrPlugin: forms need the "registry" option (the form schema is derived from it)');
|
|
30
31
|
}
|
|
32
|
+
// Author code is checked against the registry's html props (ADR-026); without one only a coarse
|
|
33
|
+
// name check runs, so anything but `false` is refused rather than half enforced.
|
|
34
|
+
if (options.authorCode !== false && options.registry === undefined) {
|
|
35
|
+
throw new Error('buildrPlugin: authorCode needs the "registry" option (the HTML props it checks are read from it)');
|
|
36
|
+
}
|
|
31
37
|
const stray = forms.notifyTo.find((address) => !isAllowedRecipient(address, forms.notifyAllowlist));
|
|
32
38
|
if (stray !== undefined) {
|
|
33
39
|
throw new Error(`buildrPlugin: forms.notifyTo "${stray}" is not in forms.notifyAllowlist`);
|
|
@@ -38,6 +44,19 @@ export function buildrPlugin(input) {
|
|
|
38
44
|
const writeLimiter = options.mcp.enabled
|
|
39
45
|
? (options.mcp.rateLimiter ?? createMemoryRateLimiter(options.mcp.rateLimit))
|
|
40
46
|
: undefined;
|
|
47
|
+
const hostRateLimiter = forms.rateLimiter;
|
|
48
|
+
const captcha = forms.captcha === undefined
|
|
49
|
+
? undefined
|
|
50
|
+
: {
|
|
51
|
+
verifier: createTurnstileVerifier({
|
|
52
|
+
secretKey: forms.captcha.secretKey,
|
|
53
|
+
}),
|
|
54
|
+
rateLimiter: hostRateLimiter
|
|
55
|
+
? { hit: (key) => hostRateLimiter.hit(`captcha:${key}`) }
|
|
56
|
+
: createMemoryRateLimiter({ ...forms.rateLimit, failClosed: true }),
|
|
57
|
+
gate: createCaptchaGate(forms.captcha.maxInFlight),
|
|
58
|
+
hostnames: forms.captcha.hostnames,
|
|
59
|
+
};
|
|
41
60
|
return (config) => {
|
|
42
61
|
const collections = config.collections ?? [];
|
|
43
62
|
const authSlugs = collections.filter((c) => Boolean(c.auth)).map((c) => c.slug);
|
|
@@ -76,7 +95,7 @@ export function buildrPlugin(input) {
|
|
|
76
95
|
...config,
|
|
77
96
|
endpoints: [
|
|
78
97
|
...(config.endpoints ?? []),
|
|
79
|
-
...buildrEndpoints({ options, rateLimiter, writeLimiter }),
|
|
98
|
+
...buildrEndpoints({ options, rateLimiter, writeLimiter, captcha }),
|
|
80
99
|
],
|
|
81
100
|
collections: [
|
|
82
101
|
...collections.map((collection) => {
|
package/dist/plugin/options.d.ts
CHANGED
|
@@ -36,6 +36,16 @@ export declare const optionsSchema: z.ZodObject<{
|
|
|
36
36
|
}, z.core.$strip>>;
|
|
37
37
|
forms: z.ZodDefault<z.ZodObject<{
|
|
38
38
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
39
|
+
captcha: z.ZodOptional<z.ZodObject<{
|
|
40
|
+
provider: z.ZodDefault<z.ZodLiteral<"turnstile">>;
|
|
41
|
+
secretKey: z.ZodString;
|
|
42
|
+
hostnames: z.ZodArray<z.ZodString>;
|
|
43
|
+
maxInFlight: z.ZodDefault<z.ZodNumber>;
|
|
44
|
+
}, z.core.$strict>>;
|
|
45
|
+
clientIp: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
46
|
+
header: z.ZodString;
|
|
47
|
+
hops: z.ZodDefault<z.ZodNumber>;
|
|
48
|
+
}, z.core.$strict>, z.ZodCustom<(req: PayloadRequest) => string | undefined, (req: PayloadRequest) => string | undefined>]>>;
|
|
39
49
|
notifyAllowlist: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
40
50
|
notifyTo: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
41
51
|
rateLimit: z.ZodDefault<z.ZodObject<{
|
|
@@ -44,6 +54,7 @@ export declare const optionsSchema: z.ZodObject<{
|
|
|
44
54
|
}, z.core.$strip>>;
|
|
45
55
|
rateLimiter: z.ZodOptional<z.ZodCustom<RateLimiter, RateLimiter>>;
|
|
46
56
|
}, z.core.$strip>>;
|
|
57
|
+
authorCode: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodCustom<AccessFn, AccessFn>]>>;
|
|
47
58
|
access: z.ZodDefault<z.ZodObject<{
|
|
48
59
|
edit: z.ZodOptional<z.ZodCustom<AccessFn, AccessFn>>;
|
|
49
60
|
publish: z.ZodOptional<z.ZodCustom<AccessFn, AccessFn>>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../src/plugin/options.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAKzD,uDAAuD;AACvD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,GAAG,EAAE,cAAc,CAAC;CAC9B;AACD,MAAM,MAAM,QAAQ,GAAG,CAAC,IAAI,EAAE,UAAU,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAExE,mGAAmG;AACnG,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,QAAQ,CAAC,UAAU,CAAC,EAAE,mBAAmB,GAAG,SAAS,CAAC;CACvD;AAoBD,eAAO,MAAM,aAAa;;;;;;;;;8CAdT,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM
|
|
1
|
+
{"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../src/plugin/options.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAKzD,uDAAuD;AACvD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,GAAG,EAAE,cAAc,CAAC;CAC9B;AACD,MAAM,MAAM,QAAQ,GAAG,CAAC,IAAI,EAAE,UAAU,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAExE,mGAAmG;AACnG,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,QAAQ,CAAC,UAAU,CAAC,EAAE,mBAAmB,GAAG,SAAS,CAAC;CACvD;AAoBD,eAAO,MAAM,aAAa;;;;;;;;;8CAdT,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM;;;;;;;;;;;;;;;;;;;;;;;;8CAqEhC,cAAc,KAAK,MAAM,GAAG,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAmGtD,CAAC;AAEH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAChE,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE7D,wFAAwF;AACxF,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,GAAG,eAAe,CAY1E"}
|
package/dist/plugin/options.js
CHANGED
|
@@ -39,6 +39,35 @@ export const optionsSchema = z.object({
|
|
|
39
39
|
.object({
|
|
40
40
|
/** Adds `buildr-form-submissions` and the public submission endpoint (needs `registry`). */
|
|
41
41
|
enabled: z.boolean().default(false),
|
|
42
|
+
captcha: z
|
|
43
|
+
.object({
|
|
44
|
+
provider: z.literal('turnstile').default('turnstile'),
|
|
45
|
+
secretKey: z.string().min(1),
|
|
46
|
+
hostnames: z
|
|
47
|
+
.array(z
|
|
48
|
+
.string()
|
|
49
|
+
.max(253)
|
|
50
|
+
.regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/)
|
|
51
|
+
.refine((value) => value === value.trim(), 'must be a hostname'))
|
|
52
|
+
.min(1),
|
|
53
|
+
maxInFlight: z.number().int().min(1).max(1024).default(16),
|
|
54
|
+
})
|
|
55
|
+
.strict()
|
|
56
|
+
.optional(),
|
|
57
|
+
clientIp: z
|
|
58
|
+
.union([
|
|
59
|
+
z
|
|
60
|
+
.object({
|
|
61
|
+
header: z
|
|
62
|
+
.string()
|
|
63
|
+
.regex(/^[a-z0-9-]{1,64}$/)
|
|
64
|
+
.refine((value) => value === value.trim(), 'must be a header name'),
|
|
65
|
+
hops: z.number().int().positive().default(1),
|
|
66
|
+
})
|
|
67
|
+
.strict(),
|
|
68
|
+
fn(),
|
|
69
|
+
])
|
|
70
|
+
.optional(),
|
|
42
71
|
/** Who a notification may go to: exact addresses or domains (`@example.com`). */
|
|
43
72
|
notifyAllowlist: z.array(z.string()).default([]),
|
|
44
73
|
/** Notified about every submission; each must match `notifyAllowlist`. */
|
|
@@ -56,6 +85,18 @@ export const optionsSchema = z.object({
|
|
|
56
85
|
value !== null &&
|
|
57
86
|
typeof value.hit === 'function', 'must be a rate limiter')
|
|
58
87
|
.optional(),
|
|
88
|
+
})
|
|
89
|
+
.superRefine((forms, ctx) => {
|
|
90
|
+
if (forms.captcha === undefined)
|
|
91
|
+
return;
|
|
92
|
+
if (!forms.enabled)
|
|
93
|
+
ctx.addIssue({ code: 'custom', path: ['captcha'], message: 'requires forms.enabled' });
|
|
94
|
+
if (forms.clientIp === undefined)
|
|
95
|
+
ctx.addIssue({
|
|
96
|
+
code: 'custom',
|
|
97
|
+
path: ['clientIp'],
|
|
98
|
+
message: 'required with forms.captcha',
|
|
99
|
+
});
|
|
59
100
|
})
|
|
60
101
|
.default({
|
|
61
102
|
enabled: false,
|
|
@@ -63,6 +104,13 @@ export const optionsSchema = z.object({
|
|
|
63
104
|
notifyTo: [],
|
|
64
105
|
rateLimit: { limit: 5, windowMs: 60_000 },
|
|
65
106
|
}),
|
|
107
|
+
/**
|
|
108
|
+
* Whether author code (ADR-026: custom CSS, classes, attributes and Text HTML) may be saved:
|
|
109
|
+
* `true`, `false` (the default) or a function that decides per request, like `access.*`. It is
|
|
110
|
+
* never granted to API-key requests. Without it a save that adds or changes author code is
|
|
111
|
+
* rejected; author code already stored can still be saved unchanged.
|
|
112
|
+
*/
|
|
113
|
+
authorCode: z.union([z.boolean(), fn()]).default(false),
|
|
66
114
|
access: z
|
|
67
115
|
.object({
|
|
68
116
|
edit: fn().optional(),
|
|
@@ -115,7 +163,7 @@ export function resolveOptions(input) {
|
|
|
115
163
|
const parsed = optionsSchema.safeParse(input);
|
|
116
164
|
if (!parsed.success) {
|
|
117
165
|
const problems = parsed.error.issues
|
|
118
|
-
.map((issue) => ` - ${issue.path.join('.') || '(options)'}: ${issue.message}`)
|
|
166
|
+
.map((issue) => ` - ${issue.path.join('.') || '(options)'}: ${issue.code === 'unrecognized_keys' && issue.path[0] === 'forms' && (issue.path[1] === 'captcha' || issue.path[1] === 'clientIp') ? 'unrecognized option' : issue.message}`)
|
|
119
167
|
.join('\n');
|
|
120
168
|
throw new Error(`buildrPlugin: invalid options\n${problems}`);
|
|
121
169
|
}
|