includio-cms 0.20.0 → 0.22.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/API.md +22 -21
- package/CHANGELOG.md +147 -0
- package/DOCS.md +1 -1
- package/README.md +138 -32
- package/ROADMAP.md +11 -4
- package/dist/admin/api/rest/handler.d.ts +13 -1
- package/dist/admin/api/rest/handler.js +13 -1
- package/dist/admin/api/rest/middleware/apiKey.js +9 -1
- package/dist/admin/api/rest/middleware/generateApiKey.d.ts +16 -0
- package/dist/admin/api/rest/middleware/generateApiKey.js +19 -0
- package/dist/admin/client/collection/collection-entries.svelte +1 -1
- package/dist/admin/client/collection/empty-state.svelte +1 -1
- package/dist/admin/client/collection/row-actions.svelte +3 -3
- package/dist/admin/client/collection/table-toolbar.svelte +3 -1
- package/dist/admin/client/entry/entry-header.svelte +3 -1
- package/dist/admin/client/users/create-user-dialog.svelte +4 -4
- package/dist/admin/client/users/delete-user-dialog.svelte +4 -2
- package/dist/admin/client/users/lang.d.ts +10 -2
- package/dist/admin/client/users/lang.js +10 -4
- package/dist/admin/client/users/users-page.svelte +3 -2
- package/dist/admin/components/media/file-upload.svelte +2 -0
- package/dist/ai-claude/index.d.ts +9 -1
- package/dist/ai-claude/index.js +9 -1
- package/dist/ai-openai/index.d.ts +9 -1
- package/dist/ai-openai/index.js +9 -1
- package/dist/cli/index.js +115 -13
- package/dist/cms/runtime/schema.d.ts +2 -0
- package/dist/cms/runtime/schema.js +4 -0
- package/dist/cms/runtime/types.d.ts +1 -1
- package/dist/core/cms.d.ts +13 -1
- package/dist/core/cms.js +13 -1
- package/dist/core/errors.d.ts +71 -0
- package/dist/core/errors.js +179 -0
- package/dist/core/server/consentLogs/operations/create.d.ts +13 -1
- package/dist/core/server/consentLogs/operations/create.js +13 -1
- package/dist/core/server/entries/operations/create.js +6 -1
- package/dist/core/server/entries/operations/get.js +14 -3
- package/dist/core/server/entries/operations/resolveEntry.d.ts +32 -1
- package/dist/core/server/entries/operations/resolveEntry.js +36 -4
- package/dist/core/server/entries/operations/update.js +5 -1
- package/dist/core/server/fields/utils/resolveMedia.d.ts +18 -1
- package/dist/core/server/fields/utils/resolveMedia.js +13 -1
- package/dist/core/server/forms/submissions/operations/create.d.ts +21 -1
- package/dist/core/server/forms/submissions/operations/create.js +18 -2
- package/dist/core/server/forms/submissions/utils/parseMultipart.d.ts +15 -1
- package/dist/core/server/forms/submissions/utils/parseMultipart.js +15 -1
- package/dist/core/server/media/operations/uploadFile.js +4 -3
- package/dist/core/server/media/styles/sharp/generateImageStyle.js +3 -2
- package/dist/core/server/media/utils/generateAdminThumbnail.js +3 -2
- package/dist/core/server/media/utils/generateBlurDataUrl.js +2 -1
- package/dist/db-postgres/index.d.ts +10 -0
- package/dist/db-postgres/index.js +10 -0
- package/dist/email-nodemailer/index.d.ts +13 -1
- package/dist/email-nodemailer/index.js +13 -1
- package/dist/entity/index.d.ts +16 -1
- package/dist/entity/index.js +16 -1
- package/dist/files-local/index.d.ts +12 -1
- package/dist/files-local/index.js +12 -1
- package/dist/paraglide/messages/_index.d.ts +3 -36
- package/dist/paraglide/messages/_index.js +3 -71
- package/dist/paraglide/messages/hello_world.d.ts +5 -0
- package/dist/paraglide/messages/hello_world.js +33 -0
- package/dist/paraglide/messages/login_hello.d.ts +16 -0
- package/dist/paraglide/messages/login_hello.js +34 -0
- package/dist/paraglide/messages/login_please_login.d.ts +16 -0
- package/dist/paraglide/messages/login_please_login.js +34 -0
- package/dist/server/auth.d.ts +11 -0
- package/dist/server/auth.js +11 -0
- package/dist/server/security/csp.d.ts +16 -0
- package/dist/server/security/csp.js +33 -0
- package/dist/server/security/csrf.d.ts +13 -0
- package/dist/server/security/csrf.js +49 -0
- package/dist/server/security/index.d.ts +3 -0
- package/dist/server/security/index.js +3 -0
- package/dist/server/security/rate-limit.d.ts +44 -0
- package/dist/server/security/rate-limit.js +97 -0
- package/dist/server/utils/withTimeout.d.ts +21 -0
- package/dist/server/utils/withTimeout.js +37 -0
- package/dist/sveltekit/config.d.ts +67 -4
- package/dist/sveltekit/config.js +73 -4
- package/dist/sveltekit/server/handle.d.ts +15 -1
- package/dist/sveltekit/server/handle.js +22 -1
- package/dist/sveltekit/server/index.d.ts +1 -0
- package/dist/sveltekit/server/index.js +1 -0
- package/dist/sveltekit/server/layout.d.ts +12 -1
- package/dist/sveltekit/server/layout.js +12 -1
- package/dist/sveltekit/server/preview.d.ts +21 -1
- package/dist/sveltekit/server/preview.js +21 -1
- package/dist/types/cms.d.ts +4 -0
- package/dist/types/cms.schema.d.ts +452 -0
- package/dist/types/cms.schema.js +629 -0
- package/dist/updates/0.21.0/index.d.ts +2 -0
- package/dist/updates/0.21.0/index.js +55 -0
- package/dist/updates/0.22.0/index.d.ts +2 -0
- package/dist/updates/0.22.0/index.js +75 -0
- package/dist/updates/index.js +3 -1
- package/package.json +12 -2
- package/dist/paraglide/messages/en.d.ts +0 -5
- package/dist/paraglide/messages/en.js +0 -14
- package/dist/paraglide/messages/pl.d.ts +0 -5
- package/dist/paraglide/messages/pl.js +0 -14
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/* -------------------------------------------------------------------------- */
|
|
3
|
+
/* Localized */
|
|
4
|
+
/* -------------------------------------------------------------------------- */
|
|
5
|
+
const localizedSchema = z.union([z.string(), z.record(z.string(), z.string())]);
|
|
6
|
+
/* -------------------------------------------------------------------------- */
|
|
7
|
+
/* Slug */
|
|
8
|
+
/* -------------------------------------------------------------------------- */
|
|
9
|
+
const SLUG_RE = /^[a-z][a-z0-9-]*$/;
|
|
10
|
+
const slugSchema = z.string().regex(SLUG_RE, {
|
|
11
|
+
message: 'must be lowercase letters/digits/hyphens, starting with a letter'
|
|
12
|
+
});
|
|
13
|
+
/* -------------------------------------------------------------------------- */
|
|
14
|
+
/* Languages */
|
|
15
|
+
/* -------------------------------------------------------------------------- */
|
|
16
|
+
const LANG_CODE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
17
|
+
const languageEntrySchema = z.union([
|
|
18
|
+
z.string().regex(LANG_CODE_RE, {
|
|
19
|
+
message: "must be a 2-letter ISO code (e.g. 'en' or 'pl-PL')"
|
|
20
|
+
}),
|
|
21
|
+
z
|
|
22
|
+
.object({
|
|
23
|
+
code: z.string().regex(LANG_CODE_RE, {
|
|
24
|
+
message: "must be a 2-letter ISO code (e.g. 'en' or 'pl-PL')"
|
|
25
|
+
}),
|
|
26
|
+
label: localizedSchema.optional(),
|
|
27
|
+
default: z.boolean().optional()
|
|
28
|
+
})
|
|
29
|
+
.passthrough()
|
|
30
|
+
]);
|
|
31
|
+
/* -------------------------------------------------------------------------- */
|
|
32
|
+
/* Fields */
|
|
33
|
+
/* -------------------------------------------------------------------------- */
|
|
34
|
+
const FIELD_TYPES = [
|
|
35
|
+
'text',
|
|
36
|
+
'content',
|
|
37
|
+
'number',
|
|
38
|
+
'boolean',
|
|
39
|
+
'date',
|
|
40
|
+
'datetime',
|
|
41
|
+
'file',
|
|
42
|
+
'media',
|
|
43
|
+
'select',
|
|
44
|
+
'radio',
|
|
45
|
+
'checkboxes',
|
|
46
|
+
'relation',
|
|
47
|
+
'object',
|
|
48
|
+
'array',
|
|
49
|
+
'blocks',
|
|
50
|
+
'slug',
|
|
51
|
+
'seo',
|
|
52
|
+
'shop',
|
|
53
|
+
'url',
|
|
54
|
+
'custom'
|
|
55
|
+
];
|
|
56
|
+
const baseFieldShape = {
|
|
57
|
+
slug: slugSchema,
|
|
58
|
+
label: localizedSchema.optional(),
|
|
59
|
+
required: z.boolean().optional(),
|
|
60
|
+
description: localizedSchema.optional(),
|
|
61
|
+
localized: z.boolean().optional(),
|
|
62
|
+
defaultValue: z.unknown().optional(),
|
|
63
|
+
showWhen: z
|
|
64
|
+
.object({
|
|
65
|
+
field: z.string(),
|
|
66
|
+
equals: z.union([z.string(), z.array(z.string())]).optional(),
|
|
67
|
+
notEquals: z.union([z.string(), z.array(z.string())]).optional()
|
|
68
|
+
})
|
|
69
|
+
.optional()
|
|
70
|
+
};
|
|
71
|
+
const optionItemSchema = z.object({
|
|
72
|
+
label: localizedSchema.optional(),
|
|
73
|
+
value: z.string()
|
|
74
|
+
});
|
|
75
|
+
const fieldSchema = z.lazy(() => z.discriminatedUnion('type', [
|
|
76
|
+
// text
|
|
77
|
+
z
|
|
78
|
+
.object({
|
|
79
|
+
...baseFieldShape,
|
|
80
|
+
type: z.literal('text'),
|
|
81
|
+
placeholder: localizedSchema.optional(),
|
|
82
|
+
minLength: z.number().int().nonnegative().optional(),
|
|
83
|
+
maxLength: z.number().int().positive().optional(),
|
|
84
|
+
pattern: z.string().optional(),
|
|
85
|
+
multiline: z.boolean().optional()
|
|
86
|
+
})
|
|
87
|
+
.passthrough(),
|
|
88
|
+
// content
|
|
89
|
+
z
|
|
90
|
+
.object({
|
|
91
|
+
...baseFieldShape,
|
|
92
|
+
type: z.literal('content'),
|
|
93
|
+
inlineBlocks: z.array(fieldSchema).optional()
|
|
94
|
+
})
|
|
95
|
+
.passthrough(),
|
|
96
|
+
// number
|
|
97
|
+
z
|
|
98
|
+
.object({
|
|
99
|
+
...baseFieldShape,
|
|
100
|
+
type: z.literal('number'),
|
|
101
|
+
min: z.number().optional(),
|
|
102
|
+
max: z.number().optional(),
|
|
103
|
+
step: z.number().optional()
|
|
104
|
+
})
|
|
105
|
+
.passthrough(),
|
|
106
|
+
// boolean
|
|
107
|
+
z
|
|
108
|
+
.object({ ...baseFieldShape, type: z.literal('boolean') })
|
|
109
|
+
.passthrough(),
|
|
110
|
+
// date / datetime
|
|
111
|
+
z
|
|
112
|
+
.object({
|
|
113
|
+
...baseFieldShape,
|
|
114
|
+
type: z.literal('date'),
|
|
115
|
+
minDate: z.string().optional(),
|
|
116
|
+
maxDate: z.string().optional()
|
|
117
|
+
})
|
|
118
|
+
.passthrough(),
|
|
119
|
+
z
|
|
120
|
+
.object({
|
|
121
|
+
...baseFieldShape,
|
|
122
|
+
type: z.literal('datetime'),
|
|
123
|
+
minDate: z.string().optional(),
|
|
124
|
+
maxDate: z.string().optional()
|
|
125
|
+
})
|
|
126
|
+
.passthrough(),
|
|
127
|
+
// file
|
|
128
|
+
z
|
|
129
|
+
.object({
|
|
130
|
+
...baseFieldShape,
|
|
131
|
+
type: z.literal('file'),
|
|
132
|
+
accept: z.string().optional(),
|
|
133
|
+
maxSizeMB: z.number().positive().optional(),
|
|
134
|
+
multiple: z.boolean().optional()
|
|
135
|
+
})
|
|
136
|
+
.passthrough(),
|
|
137
|
+
// media
|
|
138
|
+
z
|
|
139
|
+
.object({
|
|
140
|
+
...baseFieldShape,
|
|
141
|
+
type: z.literal('media'),
|
|
142
|
+
accept: z.string().optional(),
|
|
143
|
+
maxSizeMB: z.number().positive().optional(),
|
|
144
|
+
multiple: z.boolean().optional(),
|
|
145
|
+
styles: z
|
|
146
|
+
.array(z
|
|
147
|
+
.object({
|
|
148
|
+
name: z.string()
|
|
149
|
+
})
|
|
150
|
+
.passthrough())
|
|
151
|
+
.optional()
|
|
152
|
+
})
|
|
153
|
+
.passthrough(),
|
|
154
|
+
// select
|
|
155
|
+
z
|
|
156
|
+
.object({
|
|
157
|
+
...baseFieldShape,
|
|
158
|
+
type: z.literal('select'),
|
|
159
|
+
options: z.array(optionItemSchema).min(1, {
|
|
160
|
+
message: 'select must declare at least one option'
|
|
161
|
+
}),
|
|
162
|
+
multiple: z.boolean().optional()
|
|
163
|
+
})
|
|
164
|
+
.passthrough(),
|
|
165
|
+
// radio
|
|
166
|
+
z
|
|
167
|
+
.object({
|
|
168
|
+
...baseFieldShape,
|
|
169
|
+
type: z.literal('radio'),
|
|
170
|
+
options: z.array(optionItemSchema).min(1, {
|
|
171
|
+
message: 'radio must declare at least one option'
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
.passthrough(),
|
|
175
|
+
// checkboxes
|
|
176
|
+
z
|
|
177
|
+
.object({
|
|
178
|
+
...baseFieldShape,
|
|
179
|
+
type: z.literal('checkboxes'),
|
|
180
|
+
options: z.array(optionItemSchema).min(1, {
|
|
181
|
+
message: 'checkboxes must declare at least one option'
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
.passthrough(),
|
|
185
|
+
// relation
|
|
186
|
+
z
|
|
187
|
+
.object({
|
|
188
|
+
...baseFieldShape,
|
|
189
|
+
type: z.literal('relation'),
|
|
190
|
+
collection: z.string().min(1, { message: 'relation.collection is required' }),
|
|
191
|
+
multiple: z.boolean().optional(),
|
|
192
|
+
displayField: z.string().optional()
|
|
193
|
+
})
|
|
194
|
+
.passthrough(),
|
|
195
|
+
// object
|
|
196
|
+
z
|
|
197
|
+
.object({
|
|
198
|
+
...baseFieldShape,
|
|
199
|
+
type: z.literal('object'),
|
|
200
|
+
fields: z.array(fieldSchema).min(1, {
|
|
201
|
+
message: 'object field must declare at least one nested field'
|
|
202
|
+
}),
|
|
203
|
+
accordionLabelField: z.string().optional(),
|
|
204
|
+
thumbnail: z.string().optional()
|
|
205
|
+
})
|
|
206
|
+
.passthrough(),
|
|
207
|
+
// array
|
|
208
|
+
z
|
|
209
|
+
.object({
|
|
210
|
+
...baseFieldShape,
|
|
211
|
+
type: z.literal('array'),
|
|
212
|
+
of: z.enum(['text', 'number', 'url']),
|
|
213
|
+
minItems: z.number().int().nonnegative().optional(),
|
|
214
|
+
maxItems: z.number().int().positive().optional()
|
|
215
|
+
})
|
|
216
|
+
.passthrough(),
|
|
217
|
+
// blocks
|
|
218
|
+
z
|
|
219
|
+
.object({
|
|
220
|
+
...baseFieldShape,
|
|
221
|
+
type: z.literal('blocks'),
|
|
222
|
+
of: z.array(fieldSchema).min(1, {
|
|
223
|
+
message: 'blocks.of must declare at least one block type'
|
|
224
|
+
}),
|
|
225
|
+
minItems: z.number().int().nonnegative().optional(),
|
|
226
|
+
maxItems: z.number().int().positive().optional(),
|
|
227
|
+
displayMode: z.enum(['simple', 'blocks']).optional()
|
|
228
|
+
})
|
|
229
|
+
.passthrough(),
|
|
230
|
+
// slug
|
|
231
|
+
z
|
|
232
|
+
.object({
|
|
233
|
+
...baseFieldShape,
|
|
234
|
+
type: z.literal('slug'),
|
|
235
|
+
pattern: z.string().optional(),
|
|
236
|
+
sourceField: z.string().optional()
|
|
237
|
+
})
|
|
238
|
+
.passthrough(),
|
|
239
|
+
// seo
|
|
240
|
+
z
|
|
241
|
+
.object({
|
|
242
|
+
...baseFieldShape,
|
|
243
|
+
type: z.literal('seo'),
|
|
244
|
+
slugSource: z.string().optional(),
|
|
245
|
+
titleSource: z.string().optional()
|
|
246
|
+
})
|
|
247
|
+
.passthrough(),
|
|
248
|
+
// shop
|
|
249
|
+
z
|
|
250
|
+
.object({ ...baseFieldShape, type: z.literal('shop') })
|
|
251
|
+
.passthrough(),
|
|
252
|
+
// url
|
|
253
|
+
z
|
|
254
|
+
.object({
|
|
255
|
+
...baseFieldShape,
|
|
256
|
+
type: z.literal('url'),
|
|
257
|
+
placeholder: localizedSchema.optional(),
|
|
258
|
+
text: z.boolean().optional(),
|
|
259
|
+
newTab: z.boolean().optional(),
|
|
260
|
+
rel: z.boolean().optional()
|
|
261
|
+
})
|
|
262
|
+
.passthrough(),
|
|
263
|
+
// custom (plugin-provided)
|
|
264
|
+
z
|
|
265
|
+
.object({
|
|
266
|
+
...baseFieldShape,
|
|
267
|
+
type: z.literal('custom'),
|
|
268
|
+
fieldType: z.string().min(1, { message: 'custom.fieldType is required' }),
|
|
269
|
+
config: z.record(z.string(), z.unknown()).optional()
|
|
270
|
+
})
|
|
271
|
+
.passthrough()
|
|
272
|
+
]));
|
|
273
|
+
/* -------------------------------------------------------------------------- */
|
|
274
|
+
/* Form fields */
|
|
275
|
+
/* -------------------------------------------------------------------------- */
|
|
276
|
+
const FORM_FIELD_TYPES = ['text', 'email', 'textarea', 'checkbox', 'select', 'file'];
|
|
277
|
+
const formFieldBaseShape = {
|
|
278
|
+
slug: slugSchema,
|
|
279
|
+
label: localizedSchema.optional(),
|
|
280
|
+
required: z.boolean().optional(),
|
|
281
|
+
description: localizedSchema.optional(),
|
|
282
|
+
errorMessage: localizedSchema.optional(),
|
|
283
|
+
defaultValue: z.unknown().optional(),
|
|
284
|
+
showInDataTable: z.boolean().optional()
|
|
285
|
+
};
|
|
286
|
+
const formFieldSchema = z.discriminatedUnion('type', [
|
|
287
|
+
z
|
|
288
|
+
.object({
|
|
289
|
+
...formFieldBaseShape,
|
|
290
|
+
type: z.literal('text'),
|
|
291
|
+
minLength: z.number().int().nonnegative().optional(),
|
|
292
|
+
maxLength: z.number().int().positive().optional()
|
|
293
|
+
})
|
|
294
|
+
.passthrough(),
|
|
295
|
+
z
|
|
296
|
+
.object({
|
|
297
|
+
...formFieldBaseShape,
|
|
298
|
+
type: z.literal('email')
|
|
299
|
+
})
|
|
300
|
+
.passthrough(),
|
|
301
|
+
z
|
|
302
|
+
.object({
|
|
303
|
+
...formFieldBaseShape,
|
|
304
|
+
type: z.literal('textarea'),
|
|
305
|
+
minLength: z.number().int().nonnegative().optional(),
|
|
306
|
+
maxLength: z.number().int().positive().optional()
|
|
307
|
+
})
|
|
308
|
+
.passthrough(),
|
|
309
|
+
z
|
|
310
|
+
.object({
|
|
311
|
+
...formFieldBaseShape,
|
|
312
|
+
type: z.literal('checkbox')
|
|
313
|
+
})
|
|
314
|
+
.passthrough(),
|
|
315
|
+
z
|
|
316
|
+
.object({
|
|
317
|
+
...formFieldBaseShape,
|
|
318
|
+
type: z.literal('select'),
|
|
319
|
+
options: z.array(optionItemSchema).min(1, {
|
|
320
|
+
message: 'form select must declare at least one option'
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
.passthrough(),
|
|
324
|
+
z
|
|
325
|
+
.object({
|
|
326
|
+
...formFieldBaseShape,
|
|
327
|
+
type: z.literal('file'),
|
|
328
|
+
accept: z.string().optional(),
|
|
329
|
+
maxSize: z.number().positive().optional()
|
|
330
|
+
})
|
|
331
|
+
.passthrough()
|
|
332
|
+
]);
|
|
333
|
+
/* -------------------------------------------------------------------------- */
|
|
334
|
+
/* Collections / Singles / Forms */
|
|
335
|
+
/* -------------------------------------------------------------------------- */
|
|
336
|
+
const configBaseShape = {
|
|
337
|
+
slug: slugSchema,
|
|
338
|
+
sidebarIcon: z.string().optional(),
|
|
339
|
+
previewUrl: z.string().optional(),
|
|
340
|
+
fields: z.array(fieldSchema).min(1, {
|
|
341
|
+
message: 'must declare at least one field'
|
|
342
|
+
}),
|
|
343
|
+
layout: z.unknown().optional(),
|
|
344
|
+
slugField: z.string().optional(),
|
|
345
|
+
pathTemplate: z.string().optional()
|
|
346
|
+
};
|
|
347
|
+
const collectionSchema = z
|
|
348
|
+
.object({
|
|
349
|
+
...configBaseShape,
|
|
350
|
+
entryAdminTitle: z.string().optional(),
|
|
351
|
+
labels: z
|
|
352
|
+
.object({
|
|
353
|
+
singular: localizedSchema.optional(),
|
|
354
|
+
plural: localizedSchema.optional()
|
|
355
|
+
})
|
|
356
|
+
.optional(),
|
|
357
|
+
orderable: z.boolean().optional(),
|
|
358
|
+
listColumns: z.array(z.string()).optional()
|
|
359
|
+
})
|
|
360
|
+
.passthrough();
|
|
361
|
+
const singleSchema = z
|
|
362
|
+
.object({
|
|
363
|
+
...configBaseShape,
|
|
364
|
+
label: localizedSchema.optional()
|
|
365
|
+
})
|
|
366
|
+
.passthrough();
|
|
367
|
+
const formSchema = z
|
|
368
|
+
.object({
|
|
369
|
+
slug: slugSchema,
|
|
370
|
+
sidebarIcon: z.string().optional(),
|
|
371
|
+
fields: z.array(formFieldSchema).min(1, {
|
|
372
|
+
message: 'form must declare at least one field'
|
|
373
|
+
}),
|
|
374
|
+
label: localizedSchema,
|
|
375
|
+
notificationEmailAddresses: z.array(z.string().email()).optional()
|
|
376
|
+
})
|
|
377
|
+
.passthrough();
|
|
378
|
+
/* -------------------------------------------------------------------------- */
|
|
379
|
+
/* Adapters */
|
|
380
|
+
/* -------------------------------------------------------------------------- */
|
|
381
|
+
function adapterMethodsCheck(name, methods) {
|
|
382
|
+
return z.custom((val) => {
|
|
383
|
+
if (val == null || typeof val !== 'object')
|
|
384
|
+
return false;
|
|
385
|
+
for (const m of methods) {
|
|
386
|
+
if (typeof val[m] !== 'function')
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
}, {
|
|
391
|
+
message: `${name} is missing required methods (${methods.join(', ')})`
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const dbAdapterSchema = adapterMethodsCheck('DatabaseAdapter', [
|
|
395
|
+
'createEntry',
|
|
396
|
+
'getEntries',
|
|
397
|
+
'updateEntry',
|
|
398
|
+
'createEntryVersion',
|
|
399
|
+
'getEntryVersions',
|
|
400
|
+
'createFormSubmission'
|
|
401
|
+
]);
|
|
402
|
+
const filesAdapterSchema = adapterMethodsCheck('FilesAdapter', [
|
|
403
|
+
'uploadFile',
|
|
404
|
+
'downloadFile',
|
|
405
|
+
'renameFile',
|
|
406
|
+
'deleteFile',
|
|
407
|
+
'listFiles'
|
|
408
|
+
]);
|
|
409
|
+
const emailAdapterSchema = adapterMethodsCheck('EmailAdapter', ['sendMail']);
|
|
410
|
+
const aiAdapterSchema = z
|
|
411
|
+
.custom((val) => val != null && typeof val === 'object', {
|
|
412
|
+
message: 'AIAdapter must be an object'
|
|
413
|
+
});
|
|
414
|
+
/* -------------------------------------------------------------------------- */
|
|
415
|
+
/* ApiKeys */
|
|
416
|
+
/* -------------------------------------------------------------------------- */
|
|
417
|
+
const apiKeySchema = z
|
|
418
|
+
.object({
|
|
419
|
+
key: z.string().min(8, { message: 'apiKey.key must be at least 8 chars' }),
|
|
420
|
+
name: z.string().optional(),
|
|
421
|
+
role: z.enum(['admin', 'editor']).optional(),
|
|
422
|
+
expiresAt: z.string().optional(),
|
|
423
|
+
rotatedAt: z.string().optional(),
|
|
424
|
+
permissions: z.array(z.string()).optional()
|
|
425
|
+
})
|
|
426
|
+
.passthrough();
|
|
427
|
+
/* -------------------------------------------------------------------------- */
|
|
428
|
+
/* CMSConfig */
|
|
429
|
+
/* -------------------------------------------------------------------------- */
|
|
430
|
+
const cmsConfigBaseSchema = z
|
|
431
|
+
.object({
|
|
432
|
+
languages: z
|
|
433
|
+
.array(languageEntrySchema)
|
|
434
|
+
.min(1, { message: 'CMSConfig.languages must contain at least one language' }),
|
|
435
|
+
collections: z.array(collectionSchema).optional(),
|
|
436
|
+
singles: z.array(singleSchema).optional(),
|
|
437
|
+
forms: z.array(formSchema).optional(),
|
|
438
|
+
db: dbAdapterSchema,
|
|
439
|
+
files: filesAdapterSchema,
|
|
440
|
+
email: emailAdapterSchema.optional(),
|
|
441
|
+
auth: z
|
|
442
|
+
.object({
|
|
443
|
+
secret: z.string().min(1, { message: 'auth.secret is required' }),
|
|
444
|
+
baseURL: z.string().optional()
|
|
445
|
+
})
|
|
446
|
+
.passthrough()
|
|
447
|
+
.optional(),
|
|
448
|
+
plugins: z.array(z.unknown()).optional(),
|
|
449
|
+
ai: aiAdapterSchema.optional(),
|
|
450
|
+
media: z.unknown().optional(),
|
|
451
|
+
apiKeys: z.array(apiKeySchema).optional(),
|
|
452
|
+
typography: z
|
|
453
|
+
.object({ fixOrphans: z.boolean().optional() })
|
|
454
|
+
.passthrough()
|
|
455
|
+
.optional(),
|
|
456
|
+
sidebarHelp: z.boolean().optional(),
|
|
457
|
+
shop: z.unknown().optional(),
|
|
458
|
+
cmp: z.unknown().optional()
|
|
459
|
+
})
|
|
460
|
+
.passthrough();
|
|
461
|
+
/* -------------------------------------------------------------------------- */
|
|
462
|
+
/* Cross-field invariants */
|
|
463
|
+
/* -------------------------------------------------------------------------- */
|
|
464
|
+
function getLangCode(entry) {
|
|
465
|
+
if (typeof entry === 'string')
|
|
466
|
+
return entry;
|
|
467
|
+
if (entry && typeof entry === 'object' && 'code' in entry) {
|
|
468
|
+
const c = entry.code;
|
|
469
|
+
return typeof c === 'string' ? c : null;
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
function isLangDefault(entry) {
|
|
474
|
+
return !!entry && typeof entry === 'object' && entry.default === true;
|
|
475
|
+
}
|
|
476
|
+
function* walkRelationFields(fields, path) {
|
|
477
|
+
if (!fields)
|
|
478
|
+
return;
|
|
479
|
+
for (let i = 0; i < fields.length; i++) {
|
|
480
|
+
const f = fields[i];
|
|
481
|
+
const fieldPath = [...path, i];
|
|
482
|
+
if (f.type === 'relation')
|
|
483
|
+
yield { field: f, path: fieldPath };
|
|
484
|
+
if (f.type === 'object' && Array.isArray(f.fields)) {
|
|
485
|
+
yield* walkRelationFields(f.fields, [...fieldPath, 'fields']);
|
|
486
|
+
}
|
|
487
|
+
if (f.type === 'blocks' && Array.isArray(f.of)) {
|
|
488
|
+
for (let j = 0; j < f.of.length; j++) {
|
|
489
|
+
const blockDef = f.of[j];
|
|
490
|
+
if (blockDef && Array.isArray(blockDef.fields)) {
|
|
491
|
+
yield* walkRelationFields(blockDef.fields, [...fieldPath, 'of', j, 'fields']);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (f.type === 'content' && Array.isArray(f.inlineBlocks)) {
|
|
496
|
+
for (let j = 0; j < f.inlineBlocks.length; j++) {
|
|
497
|
+
const ib = f.inlineBlocks[j];
|
|
498
|
+
if (ib && Array.isArray(ib.fields)) {
|
|
499
|
+
yield* walkRelationFields(ib.fields, [...fieldPath, 'inlineBlocks', j, 'fields']);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Final config schema. Use `cmsConfigSchema.safeParse(input)` and pass any
|
|
507
|
+
* resulting `ZodError` to `formatConfigError` (in `core/errors.ts`) to get a
|
|
508
|
+
* human-readable {@link import('../core/errors.js').ConfigValidationError}.
|
|
509
|
+
*
|
|
510
|
+
* Cross-field invariants enforced via `superRefine`:
|
|
511
|
+
* - `languages` has at most one `default: true`.
|
|
512
|
+
* - `collections[].slug` are unique.
|
|
513
|
+
* - `singles[].slug` are unique.
|
|
514
|
+
* - `forms[].slug` are unique.
|
|
515
|
+
* - Each `relation` field's `collection` references a declared collection.
|
|
516
|
+
* - `apiKeys[].permissions` reference declared collection slugs.
|
|
517
|
+
*/
|
|
518
|
+
export const cmsConfigSchema = cmsConfigBaseSchema.superRefine((data, ctx) => {
|
|
519
|
+
// Default language: at most one (zero is fine — fall back to languages[0]).
|
|
520
|
+
const defaults = (data.languages ?? []).filter(isLangDefault);
|
|
521
|
+
if (defaults.length > 1) {
|
|
522
|
+
ctx.addIssue({
|
|
523
|
+
code: z.ZodIssueCode.custom,
|
|
524
|
+
path: ['languages'],
|
|
525
|
+
message: `more than one language is marked default (${defaults.length})`
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
// Collection slug uniqueness
|
|
529
|
+
const collectionSlugs = new Set();
|
|
530
|
+
(data.collections ?? []).forEach((c, i) => {
|
|
531
|
+
const slug = c.slug;
|
|
532
|
+
if (typeof slug !== 'string')
|
|
533
|
+
return;
|
|
534
|
+
if (collectionSlugs.has(slug)) {
|
|
535
|
+
ctx.addIssue({
|
|
536
|
+
code: z.ZodIssueCode.custom,
|
|
537
|
+
path: ['collections', i, 'slug'],
|
|
538
|
+
message: `duplicate collection slug '${slug}'`
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
collectionSlugs.add(slug);
|
|
542
|
+
});
|
|
543
|
+
// Single slug uniqueness
|
|
544
|
+
const singleSlugs = new Set();
|
|
545
|
+
(data.singles ?? []).forEach((s, i) => {
|
|
546
|
+
const slug = s.slug;
|
|
547
|
+
if (typeof slug !== 'string')
|
|
548
|
+
return;
|
|
549
|
+
if (singleSlugs.has(slug)) {
|
|
550
|
+
ctx.addIssue({
|
|
551
|
+
code: z.ZodIssueCode.custom,
|
|
552
|
+
path: ['singles', i, 'slug'],
|
|
553
|
+
message: `duplicate single slug '${slug}'`
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
singleSlugs.add(slug);
|
|
557
|
+
});
|
|
558
|
+
// Form slug uniqueness
|
|
559
|
+
const formSlugs = new Set();
|
|
560
|
+
(data.forms ?? []).forEach((f, i) => {
|
|
561
|
+
const slug = f.slug;
|
|
562
|
+
if (typeof slug !== 'string')
|
|
563
|
+
return;
|
|
564
|
+
if (formSlugs.has(slug)) {
|
|
565
|
+
ctx.addIssue({
|
|
566
|
+
code: z.ZodIssueCode.custom,
|
|
567
|
+
path: ['forms', i, 'slug'],
|
|
568
|
+
message: `duplicate form slug '${slug}'`
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
formSlugs.add(slug);
|
|
572
|
+
});
|
|
573
|
+
// Relation targets (must reference a declared collection)
|
|
574
|
+
const validTargets = new Set([...collectionSlugs, ...singleSlugs]);
|
|
575
|
+
(data.collections ?? []).forEach((c, i) => {
|
|
576
|
+
const fields = c.fields;
|
|
577
|
+
for (const { field, path } of walkRelationFields(fields, ['collections', i, 'fields'])) {
|
|
578
|
+
const target = field.collection;
|
|
579
|
+
if (typeof target !== 'string' || !validTargets.has(target)) {
|
|
580
|
+
ctx.addIssue({
|
|
581
|
+
code: z.ZodIssueCode.custom,
|
|
582
|
+
path: [...path, 'collection'],
|
|
583
|
+
message: `relation target '${target}' does not reference a declared collection or single`
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
(data.singles ?? []).forEach((s, i) => {
|
|
589
|
+
const fields = s.fields;
|
|
590
|
+
for (const { field, path } of walkRelationFields(fields, ['singles', i, 'fields'])) {
|
|
591
|
+
const target = field.collection;
|
|
592
|
+
if (typeof target !== 'string' || !validTargets.has(target)) {
|
|
593
|
+
ctx.addIssue({
|
|
594
|
+
code: z.ZodIssueCode.custom,
|
|
595
|
+
path: [...path, 'collection'],
|
|
596
|
+
message: `relation target '${target}' does not reference a declared collection or single`
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
// apiKeys permissions
|
|
602
|
+
(data.apiKeys ?? []).forEach((k, i) => {
|
|
603
|
+
const perms = k.permissions;
|
|
604
|
+
if (!perms)
|
|
605
|
+
return;
|
|
606
|
+
for (let j = 0; j < perms.length; j++) {
|
|
607
|
+
const p = perms[j];
|
|
608
|
+
if (!collectionSlugs.has(p)) {
|
|
609
|
+
ctx.addIssue({
|
|
610
|
+
code: z.ZodIssueCode.custom,
|
|
611
|
+
path: ['apiKeys', i, 'permissions', j],
|
|
612
|
+
message: `apiKeys permission '${p}' does not reference a declared collection`
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
/** Exposed for tests only. Not part of the public API. */
|
|
619
|
+
export const _internal = {
|
|
620
|
+
fieldSchema,
|
|
621
|
+
formFieldSchema,
|
|
622
|
+
collectionSchema,
|
|
623
|
+
singleSchema,
|
|
624
|
+
formSchema,
|
|
625
|
+
cmsConfigBaseSchema,
|
|
626
|
+
FIELD_TYPES,
|
|
627
|
+
FORM_FIELD_TYPES,
|
|
628
|
+
getLangCode
|
|
629
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.21.0',
|
|
3
|
+
date: '2026-04-30',
|
|
4
|
+
description: 'Faza 5 część 2 — security finish: form rate-limit DRY refactor, API keys `expiresAt` / `rotatedAt`, sharp timeout 30s, `{@html}` audit close. `KNOWN-RISKS.md` w root jako single source of truth dla zaakceptowanych ryzyk v1.0. Plus: faza 6 setup — vitest coverage (info-only), docker test profile, integration test scaffolding (`tests/`).',
|
|
5
|
+
features: [
|
|
6
|
+
'`KNOWN-RISKS.md` w root paczki — 5 ryzyk udokumentowanych: CSP `\'unsafe-inline\'`, API key rotation opt-in, in-memory rate-limit, sharp 30s timeout, ffmpeg/sharp args audit. Każde z mitygacją i triggerem fix.',
|
|
7
|
+
'`ApiKeyConfig.expiresAt?: string` (ISO-8601) — opt-in expiry, enforced w `validateApiKey()`. Wygasły klucz → 401 generic (no leak). Brak `expiresAt` = klucz nigdy nie wygasa (backward compat).',
|
|
8
|
+
'`ApiKeyConfig.rotatedAt?: string` — info-only audit-trail, nie enforced.',
|
|
9
|
+
'`generateApiKey()` (`includio-cms/sveltekit/server`) — crypto-random 32B base64url. Pełna rotacja = update `cms.config.ts` + redeploy (statyczny model, patrz KNOWN-RISKS §2).',
|
|
10
|
+
'`withTimeout<T>(promise, ms, label)` + `TimeoutError` (`$lib/server/utils/withTimeout`). Wrap dookoła wszystkich sharp calls (metadata, toBuffer, blur, admin thumbnail, downscale resize). Default 30s, env override `INCLUDIO_SHARP_TIMEOUT_MS`.',
|
|
11
|
+
'Form submit rate-limit: refaktor `src/routes/api/forms/[slug]/submit/+server.ts` — używa shared `MemoryRateLimitStore` z `$lib/server/security/rate-limit.js` (DRY z `/admin/api/*` rate-limit). Limity 5/h per IP zachowane. Env: `INCLUDIO_FORM_RATE_LIMIT_MAX`, `INCLUDIO_FORM_RATE_LIMIT_WINDOW_MS`.',
|
|
12
|
+
'Faza 6 setup: docker `test` profile (`db_test` na porcie 5434, tmpfs, `fsync=off`), `tests/helpers/{db,api,auth}.ts`, `tests/setup.ts` z TRUNCATE strategy, vitest project `integration` (sequential, `singleFork`), sanity test `tests/integration/db-sanity.spec.ts`.',
|
|
13
|
+
'Vitest coverage config (info-only, bez threshold) — `pnpm test:coverage` generuje `coverage/`. Reporter: text/json/html. Devdep: `@vitest/coverage-v8`.'
|
|
14
|
+
],
|
|
15
|
+
fixes: [
|
|
16
|
+
'`{@html}` w `src/lib/admin/client/users/delete-user-dialog.svelte` (linie 85, 93) zastąpione safe Svelte template `{...}<strong>{...}</strong>{...}`. Defense-in-depth — content był admin-controlled, ale eliminuje wektor regresji XSS gdyby ktoś kiedyś dodał user-supplied input do tych komunikatów.'
|
|
17
|
+
],
|
|
18
|
+
breakingChanges: [
|
|
19
|
+
'Brak hard breakages. `ApiKeyConfig.expiresAt` / `rotatedAt` są opcjonalne — istniejące configi działają bez zmian.',
|
|
20
|
+
'`usersLang.deleteWarningDesc` zmienia sygnaturę z `(name: string) => string` na statyczny obiekt `{ before: string; after: string }`. `usersLang.deleteConfirmType` analogicznie ze stringa na `{ before: string; after: string }`. Wpływ tylko na fork\'i admin UI z customowym lang — szybki `pnpm check` zwróci błąd typu, fix = przepisz wartości w lang.'
|
|
21
|
+
],
|
|
22
|
+
notes: `## Setup integration tests (opt-in)
|
|
23
|
+
|
|
24
|
+
\`\`\`bash
|
|
25
|
+
docker compose --profile test up -d db_test
|
|
26
|
+
pnpm prepack # buduje dist/ wymagany przez drizzle-kit push schema
|
|
27
|
+
pnpm db:test:migrate # drizzle-kit push do test DB
|
|
28
|
+
pnpm test:integration # uruchamia tests/integration/**
|
|
29
|
+
docker compose --profile test down
|
|
30
|
+
\`\`\`
|
|
31
|
+
|
|
32
|
+
## API key expiry (opt-in)
|
|
33
|
+
|
|
34
|
+
\`\`\`ts
|
|
35
|
+
// cms.config.ts
|
|
36
|
+
apiKeys: [
|
|
37
|
+
{ key: 'sk-...', name: 'ci', role: 'admin', expiresAt: '2027-01-01T00:00:00Z' }
|
|
38
|
+
]
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
Generowanie nowego klucza:
|
|
42
|
+
|
|
43
|
+
\`\`\`ts
|
|
44
|
+
import { generateApiKey } from 'includio-cms/sveltekit/server';
|
|
45
|
+
console.log(generateApiKey()); // → 43-char base64url, 32B entropii
|
|
46
|
+
\`\`\`
|
|
47
|
+
|
|
48
|
+
Pełna rotacja wymaga update'u \`cms.config.ts\` + redeploy. Pole \`rotatedAt\` jest info-only (audit trail) — admin ustawia ręcznie przy rotacji.
|
|
49
|
+
|
|
50
|
+
## Known risks
|
|
51
|
+
|
|
52
|
+
Lista zaakceptowanych ryzyk v1.0 — \`KNOWN-RISKS.md\` w root paczki. 5 sekcji: CSP \`unsafe-inline\`, API key rotation opt-in, in-memory rate-limit (multi-node = wymaga Redis adapter), sharp 30s timeout, ffmpeg/sharp shell audit (SAFE).
|
|
53
|
+
|
|
54
|
+
Brak SQL migration.`
|
|
55
|
+
};
|