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.
Files changed (101) hide show
  1. package/API.md +22 -21
  2. package/CHANGELOG.md +147 -0
  3. package/DOCS.md +1 -1
  4. package/README.md +138 -32
  5. package/ROADMAP.md +11 -4
  6. package/dist/admin/api/rest/handler.d.ts +13 -1
  7. package/dist/admin/api/rest/handler.js +13 -1
  8. package/dist/admin/api/rest/middleware/apiKey.js +9 -1
  9. package/dist/admin/api/rest/middleware/generateApiKey.d.ts +16 -0
  10. package/dist/admin/api/rest/middleware/generateApiKey.js +19 -0
  11. package/dist/admin/client/collection/collection-entries.svelte +1 -1
  12. package/dist/admin/client/collection/empty-state.svelte +1 -1
  13. package/dist/admin/client/collection/row-actions.svelte +3 -3
  14. package/dist/admin/client/collection/table-toolbar.svelte +3 -1
  15. package/dist/admin/client/entry/entry-header.svelte +3 -1
  16. package/dist/admin/client/users/create-user-dialog.svelte +4 -4
  17. package/dist/admin/client/users/delete-user-dialog.svelte +4 -2
  18. package/dist/admin/client/users/lang.d.ts +10 -2
  19. package/dist/admin/client/users/lang.js +10 -4
  20. package/dist/admin/client/users/users-page.svelte +3 -2
  21. package/dist/admin/components/media/file-upload.svelte +2 -0
  22. package/dist/ai-claude/index.d.ts +9 -1
  23. package/dist/ai-claude/index.js +9 -1
  24. package/dist/ai-openai/index.d.ts +9 -1
  25. package/dist/ai-openai/index.js +9 -1
  26. package/dist/cli/index.js +115 -13
  27. package/dist/cms/runtime/schema.d.ts +2 -0
  28. package/dist/cms/runtime/schema.js +4 -0
  29. package/dist/cms/runtime/types.d.ts +1 -1
  30. package/dist/core/cms.d.ts +13 -1
  31. package/dist/core/cms.js +13 -1
  32. package/dist/core/errors.d.ts +71 -0
  33. package/dist/core/errors.js +179 -0
  34. package/dist/core/server/consentLogs/operations/create.d.ts +13 -1
  35. package/dist/core/server/consentLogs/operations/create.js +13 -1
  36. package/dist/core/server/entries/operations/create.js +6 -1
  37. package/dist/core/server/entries/operations/get.js +14 -3
  38. package/dist/core/server/entries/operations/resolveEntry.d.ts +32 -1
  39. package/dist/core/server/entries/operations/resolveEntry.js +36 -4
  40. package/dist/core/server/entries/operations/update.js +5 -1
  41. package/dist/core/server/fields/utils/resolveMedia.d.ts +18 -1
  42. package/dist/core/server/fields/utils/resolveMedia.js +13 -1
  43. package/dist/core/server/forms/submissions/operations/create.d.ts +21 -1
  44. package/dist/core/server/forms/submissions/operations/create.js +18 -2
  45. package/dist/core/server/forms/submissions/utils/parseMultipart.d.ts +15 -1
  46. package/dist/core/server/forms/submissions/utils/parseMultipart.js +15 -1
  47. package/dist/core/server/media/operations/uploadFile.js +4 -3
  48. package/dist/core/server/media/styles/sharp/generateImageStyle.js +3 -2
  49. package/dist/core/server/media/utils/generateAdminThumbnail.js +3 -2
  50. package/dist/core/server/media/utils/generateBlurDataUrl.js +2 -1
  51. package/dist/db-postgres/index.d.ts +10 -0
  52. package/dist/db-postgres/index.js +10 -0
  53. package/dist/email-nodemailer/index.d.ts +13 -1
  54. package/dist/email-nodemailer/index.js +13 -1
  55. package/dist/entity/index.d.ts +16 -1
  56. package/dist/entity/index.js +16 -1
  57. package/dist/files-local/index.d.ts +12 -1
  58. package/dist/files-local/index.js +12 -1
  59. package/dist/paraglide/messages/_index.d.ts +3 -36
  60. package/dist/paraglide/messages/_index.js +3 -71
  61. package/dist/paraglide/messages/hello_world.d.ts +5 -0
  62. package/dist/paraglide/messages/hello_world.js +33 -0
  63. package/dist/paraglide/messages/login_hello.d.ts +16 -0
  64. package/dist/paraglide/messages/login_hello.js +34 -0
  65. package/dist/paraglide/messages/login_please_login.d.ts +16 -0
  66. package/dist/paraglide/messages/login_please_login.js +34 -0
  67. package/dist/server/auth.d.ts +11 -0
  68. package/dist/server/auth.js +11 -0
  69. package/dist/server/security/csp.d.ts +16 -0
  70. package/dist/server/security/csp.js +33 -0
  71. package/dist/server/security/csrf.d.ts +13 -0
  72. package/dist/server/security/csrf.js +49 -0
  73. package/dist/server/security/index.d.ts +3 -0
  74. package/dist/server/security/index.js +3 -0
  75. package/dist/server/security/rate-limit.d.ts +44 -0
  76. package/dist/server/security/rate-limit.js +97 -0
  77. package/dist/server/utils/withTimeout.d.ts +21 -0
  78. package/dist/server/utils/withTimeout.js +37 -0
  79. package/dist/sveltekit/config.d.ts +67 -4
  80. package/dist/sveltekit/config.js +73 -4
  81. package/dist/sveltekit/server/handle.d.ts +15 -1
  82. package/dist/sveltekit/server/handle.js +22 -1
  83. package/dist/sveltekit/server/index.d.ts +1 -0
  84. package/dist/sveltekit/server/index.js +1 -0
  85. package/dist/sveltekit/server/layout.d.ts +12 -1
  86. package/dist/sveltekit/server/layout.js +12 -1
  87. package/dist/sveltekit/server/preview.d.ts +21 -1
  88. package/dist/sveltekit/server/preview.js +21 -1
  89. package/dist/types/cms.d.ts +4 -0
  90. package/dist/types/cms.schema.d.ts +452 -0
  91. package/dist/types/cms.schema.js +629 -0
  92. package/dist/updates/0.21.0/index.d.ts +2 -0
  93. package/dist/updates/0.21.0/index.js +55 -0
  94. package/dist/updates/0.22.0/index.d.ts +2 -0
  95. package/dist/updates/0.22.0/index.js +75 -0
  96. package/dist/updates/index.js +3 -1
  97. package/package.json +12 -2
  98. package/dist/paraglide/messages/en.d.ts +0 -5
  99. package/dist/paraglide/messages/en.js +0 -14
  100. package/dist/paraglide/messages/pl.d.ts +0 -5
  101. 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,2 @@
1
+ import type { CmsUpdate } from '../index.js';
2
+ export declare const update: CmsUpdate;
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ import type { CmsUpdate } from '../index.js';
2
+ export declare const update: CmsUpdate;