includio-cms 0.20.0 → 0.21.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 (32) hide show
  1. package/API.md +3 -2
  2. package/CHANGELOG.md +57 -0
  3. package/DOCS.md +1 -1
  4. package/ROADMAP.md +7 -4
  5. package/dist/admin/api/rest/middleware/apiKey.js +9 -1
  6. package/dist/admin/api/rest/middleware/generateApiKey.d.ts +7 -0
  7. package/dist/admin/api/rest/middleware/generateApiKey.js +10 -0
  8. package/dist/admin/client/users/delete-user-dialog.svelte +2 -2
  9. package/dist/admin/client/users/lang.d.ts +10 -2
  10. package/dist/admin/client/users/lang.js +10 -4
  11. package/dist/core/server/media/operations/uploadFile.js +4 -3
  12. package/dist/core/server/media/styles/sharp/generateImageStyle.js +3 -2
  13. package/dist/core/server/media/utils/generateAdminThumbnail.js +3 -2
  14. package/dist/core/server/media/utils/generateBlurDataUrl.js +2 -1
  15. package/dist/server/security/csp.d.ts +16 -0
  16. package/dist/server/security/csp.js +33 -0
  17. package/dist/server/security/csrf.d.ts +13 -0
  18. package/dist/server/security/csrf.js +49 -0
  19. package/dist/server/security/index.d.ts +3 -0
  20. package/dist/server/security/index.js +3 -0
  21. package/dist/server/security/rate-limit.d.ts +44 -0
  22. package/dist/server/security/rate-limit.js +97 -0
  23. package/dist/server/utils/withTimeout.d.ts +21 -0
  24. package/dist/server/utils/withTimeout.js +37 -0
  25. package/dist/sveltekit/server/handle.js +7 -0
  26. package/dist/sveltekit/server/index.d.ts +1 -0
  27. package/dist/sveltekit/server/index.js +1 -0
  28. package/dist/types/cms.d.ts +4 -0
  29. package/dist/updates/0.21.0/index.d.ts +2 -0
  30. package/dist/updates/0.21.0/index.js +55 -0
  31. package/dist/updates/index.js +2 -1
  32. package/package.json +9 -2
package/API.md CHANGED
@@ -1,8 +1,8 @@
1
- # includio-cms — Public API v0.20.0
1
+ # includio-cms — Public API v0.21.0
2
2
 
3
3
  > Auto-generated by `scripts/generate-api-md.ts`. Do not edit by hand.
4
4
 
5
- Entry points: **15** · Stable: **345** · Experimental: **2**
5
+ Entry points: **15** · Stable: **346** · Experimental: **2**
6
6
 
7
7
  Tags:
8
8
  - `@public` — frozen for v1.0; semver-protected.
@@ -273,6 +273,7 @@ Tags:
273
273
  - `const createConsentLog: <inferred>`
274
274
  - `const createFormSubmission: <inferred>`
275
275
  - `createRestApiHandler(): <inferred>` — REST API handler factory. Returns `{ GET, POST, PUT, DELETE }` `RequestHandler`s authenticated via `x-api-key` header. Mount in `src/routes/admin/api/rest/[...restPath]/+server.ts`.
276
+ - `generateApiKey(): string` — Generate a cryptographically random API key (32 bytes, base64url-encoded).
276
277
  - `getPreviewEntry(event: RequestEvent, options: { language: string }): Promise<Entry | null>` — Resolves the preview entry from a `?preview=<versionId>` query param. Requires an authenticated session — throws `Unauthorized` otherwise.
277
278
  - `includioCMS(cmsConfig: CMSConfig): Handle[]` — SvelteKit `Handle[]` array that initializes the CMS, generates runtime artifacts, wires auth/admin guards, and exposes `event.locals.cmsContext`. Compose with `sequence()` in `hooks.server.ts`.
278
279
  - `parseFormDataForSubmission(formData: FormData, fields: FormField[]): Promise<Record<string, unknown>>` — Parses multipart `FormData` into a typed record of field values, handling file uploads via the configured files adapter.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,63 @@
3
3
  All notable changes to includio-cms are documented here.
4
4
  Generated from `src/lib/updates/` — do not edit manually.
5
5
 
6
+ ## 0.21.0 — 2026-04-30
7
+
8
+ 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/`).
9
+
10
+ ### Added
11
+ - `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.
12
+ - `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).
13
+ - `ApiKeyConfig.rotatedAt?: string` — info-only audit-trail, nie enforced.
14
+ - `generateApiKey()` (`includio-cms/sveltekit/server`) — crypto-random 32B base64url. Pełna rotacja = update `cms.config.ts` + redeploy (statyczny model, patrz KNOWN-RISKS §2).
15
+ - `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`.
16
+ - 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`.
17
+ - 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`.
18
+ - Vitest coverage config (info-only, bez threshold) — `pnpm test:coverage` generuje `coverage/`. Reporter: text/json/html. Devdep: `@vitest/coverage-v8`.
19
+
20
+ ### Fixed
21
+ - `{@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.
22
+
23
+ ### Breaking
24
+ - Brak hard breakages. `ApiKeyConfig.expiresAt` / `rotatedAt` są opcjonalne — istniejące configi działają bez zmian.
25
+ - `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.
26
+
27
+ ### Notes
28
+
29
+ ## Setup integration tests (opt-in)
30
+
31
+ ```bash
32
+ docker compose --profile test up -d db_test
33
+ pnpm prepack # buduje dist/ wymagany przez drizzle-kit push schema
34
+ pnpm db:test:migrate # drizzle-kit push do test DB
35
+ pnpm test:integration # uruchamia tests/integration/**
36
+ docker compose --profile test down
37
+ ```
38
+
39
+ ## API key expiry (opt-in)
40
+
41
+ ```ts
42
+ // cms.config.ts
43
+ apiKeys: [
44
+ { key: 'sk-...', name: 'ci', role: 'admin', expiresAt: '2027-01-01T00:00:00Z' }
45
+ ]
46
+ ```
47
+
48
+ Generowanie nowego klucza:
49
+
50
+ ```ts
51
+ import { generateApiKey } from 'includio-cms/sveltekit/server';
52
+ console.log(generateApiKey()); // → 43-char base64url, 32B entropii
53
+ ```
54
+
55
+ Pełna rotacja wymaga update'u `cms.config.ts` + redeploy. Pole `rotatedAt` jest info-only (audit trail) — admin ustawia ręcznie przy rotacji.
56
+
57
+ ## Known risks
58
+
59
+ 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).
60
+
61
+ Brak SQL migration.
62
+
6
63
  ## 0.20.0 — 2026-04-29
7
64
 
8
65
  API Surface Lock — exports trim 26→15, JSDoc tagi (`@public`/`@experimental`/`@internal`), autogenerowany `API.md`. Powierzchnia publiczna zamrożona w v1.0 — w 1.x tylko `@experimental` może się zmienić bez breaking semver.
package/DOCS.md CHANGED
@@ -1,4 +1,4 @@
1
- # Includio CMS Documentation (v0.20.0)
1
+ # Includio CMS Documentation (v0.21.0)
2
2
 
3
3
  > This file is auto-generated from the docs site. For the latest version, update the package.
4
4
 
package/ROADMAP.md CHANGED
@@ -354,10 +354,13 @@
354
354
  ## Security hardening
355
355
 
356
356
  - [ ] `[feature]` `[P1]` `sanitizeHTML` utility — general HTML sanitization outside richtext (text fields, SEO fields)
357
- - [ ] `[feature]` `[P1]` CSP headers — Content-Security-Policy middleware
358
- - [ ] `[feature]` `[P1]` CSRF protection — tokens for mutating operations
359
- - [x] `[feature]` `[P1]` Rate limiting — form submit endpoints (done in 0.13.3; admin/auth endpoints remaining)
360
- - [x] `[chore]` `[P1]` Security audit — timing attacks, MIME validation, demo endpoint, rate limiting (done in 0.13.3; `{@html}` review remaining)
357
+ - [x] `[feature]` `[P1]` CSP headers — Content-Security-Policy middleware (0.20.0/0.21.0; `unsafe-inline` zaakceptowany — KNOWN-RISKS §1)
358
+ - [x] `[feature]` `[P1]` CSRF protection — origin/referer guard na `/admin/api/*` (0.20.0/0.21.0)
359
+ - [x] `[feature]` `[P1]` Rate limiting — form submit + `/admin/api/*` (form 0.13.3, admin 0.20.0, DRY refactor 0.21.0)
360
+ - [x] `[chore]` `[P1]` Security audit — timing attacks, MIME, demo endpoint, rate limiting, `{@html}` w admin UI, ffmpeg/sharp shell args (0.13.3 + 0.21.0)
361
+ - [x] `[feature]` `[P1]` API key expiry (opt-in `expiresAt`) + `generateApiKey()` helper (0.21.0; pełna rotacja = manual config + redeploy, KNOWN-RISKS §2)
362
+ - [x] `[feature]` `[P1]` Sharp timeout 30s (`withTimeout` + `INCLUDIO_SHARP_TIMEOUT_MS`, 0.21.0)
363
+ - [x] `[chore]` `[P0]` `KNOWN-RISKS.md` — single source of truth dla zaakceptowanych ryzyk v1.0 (0.21.0)
361
364
  - [ ] `[chore]` `[P1]` Input sanitization audit — review all fields for XSS
362
365
 
363
366
  ## Backlog
@@ -14,7 +14,15 @@ function safeEqual(a, b) {
14
14
  }
15
15
  export function validateApiKey(key) {
16
16
  const cms = getCMS();
17
- return cms.apiKeys.find((k) => safeEqual(k.key, key)) ?? null;
17
+ const found = cms.apiKeys.find((k) => safeEqual(k.key, key));
18
+ if (!found)
19
+ return null;
20
+ if (found.expiresAt) {
21
+ const exp = Date.parse(found.expiresAt);
22
+ if (Number.isFinite(exp) && exp < Date.now())
23
+ return null;
24
+ }
25
+ return found;
18
26
  }
19
27
  export function setSyntheticUser(event, apiKey) {
20
28
  const name = apiKey.name || 'api';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Generate a cryptographically random API key (32 bytes, base64url-encoded).
3
+ * Use the result for `ApiKeyConfig.key`. Full rotation requires updating
4
+ * `cms.config.ts` and redeploying — see `KNOWN-RISKS.md` §2.
5
+ * @public
6
+ */
7
+ export declare function generateApiKey(): string;
@@ -0,0 +1,10 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ /**
3
+ * Generate a cryptographically random API key (32 bytes, base64url-encoded).
4
+ * Use the result for `ApiKeyConfig.key`. Full rotation requires updating
5
+ * `cms.config.ts` and redeploying — see `KNOWN-RISKS.md` §2.
6
+ * @public
7
+ */
8
+ export function generateApiKey() {
9
+ return randomBytes(32).toString('base64url');
10
+ }
@@ -82,7 +82,7 @@
82
82
  {lang.deleteWarningTitle}
83
83
  </p>
84
84
  <p class="mt-1 text-sm" style="color: var(--muted-foreground);">
85
- {@html lang.deleteWarningDesc(user.name)}
85
+ {lang.deleteWarningDesc.before}<strong>{user.name}</strong>{lang.deleteWarningDesc.after}
86
86
  </p>
87
87
  </div>
88
88
  </div>
@@ -90,7 +90,7 @@
90
90
  <!-- Confirm input -->
91
91
  <div class="space-y-2">
92
92
  <Label for="delete-confirm">
93
- {@html lang.deleteConfirmType}
93
+ {lang.deleteConfirmType.before}<strong>{lang.deleteConfirmWord}</strong>{lang.deleteConfirmType.after}
94
94
  </Label>
95
95
  <Input
96
96
  id="delete-confirm"
@@ -24,8 +24,16 @@ export declare const usersLang: Record<InterfaceLanguage, {
24
24
  deleteConfirmTitle: string;
25
25
  deleteConfirmDescription: string;
26
26
  deleteWarningTitle: string;
27
- deleteWarningDesc: (name: string) => string;
28
- deleteConfirmType: string;
27
+ /** Split into `before` / `after`; the user name is rendered between them in template (avoids `{@html}`). */
28
+ deleteWarningDesc: {
29
+ before: string;
30
+ after: string;
31
+ };
32
+ /** Split into `before` / `after`; `deleteConfirmWord` is rendered between them. */
33
+ deleteConfirmType: {
34
+ before: string;
35
+ after: string;
36
+ };
29
37
  deleteConfirmWord: string;
30
38
  userCreated: string;
31
39
  userUpdated: string;
@@ -24,8 +24,11 @@ export const usersLang = {
24
24
  deleteConfirmTitle: 'Delete user?',
25
25
  deleteConfirmDescription: 'This action cannot be undone. The user will be permanently deleted.',
26
26
  deleteWarningTitle: 'This action cannot be undone',
27
- deleteWarningDesc: (name) => `User <strong>${name}</strong> will be permanently deleted along with all their data.`,
28
- deleteConfirmType: 'Type <strong>DELETE</strong> to confirm',
27
+ deleteWarningDesc: {
28
+ before: 'User ',
29
+ after: ' will be permanently deleted along with all their data.'
30
+ },
31
+ deleteConfirmType: { before: 'Type ', after: ' to confirm' },
29
32
  deleteConfirmWord: 'DELETE',
30
33
  userCreated: 'User created',
31
34
  userUpdated: 'User updated',
@@ -107,8 +110,11 @@ export const usersLang = {
107
110
  deleteConfirmTitle: 'Usunąć użytkownika?',
108
111
  deleteConfirmDescription: 'Ta akcja jest nieodwracalna. Użytkownik zostanie trwale usunięty.',
109
112
  deleteWarningTitle: 'Tej operacji nie można cofnąć',
110
- deleteWarningDesc: (name) => `Użytkownik <strong>${name}</strong> zostanie trwale usunięty wraz ze wszystkimi danymi.`,
111
- deleteConfirmType: 'Wpisz <strong>USUŃ</strong>, żeby potwierdzić',
113
+ deleteWarningDesc: {
114
+ before: 'Użytkownik ',
115
+ after: ' zostanie trwale usunięty wraz ze wszystkimi danymi.'
116
+ },
117
+ deleteConfirmType: { before: 'Wpisz ', after: ', żeby potwierdzić' },
112
118
  deleteConfirmWord: 'USUŃ',
113
119
  userCreated: 'Użytkownik utworzony',
114
120
  userUpdated: 'Użytkownik zaktualizowany',
@@ -4,6 +4,7 @@ import { generateAdminThumbnail } from '../utils/generateAdminThumbnail.js';
4
4
  import { generateDefaultStylesInBackground } from '../styles/operations/generateDefaultStyles.js';
5
5
  import { generateDefaultVideoStylesInBackground } from '../styles/operations/generateDefaultVideoStyles.js';
6
6
  import sharp from 'sharp';
7
+ import { withTimeout, sharpTimeoutMs } from '../../../../server/utils/withTimeout.js';
7
8
  async function maybeDownscale(file) {
8
9
  const cms = getCMS();
9
10
  const { maxOriginalWidth, maxOriginalHeight } = cms.mediaConfig;
@@ -14,19 +15,19 @@ async function maybeDownscale(file) {
14
15
  return file;
15
16
  try {
16
17
  const buffer = Buffer.from(await file.arrayBuffer());
17
- const metadata = await sharp(buffer).metadata();
18
+ const metadata = await withTimeout(sharp(buffer).metadata(), sharpTimeoutMs(), 'sharp.metadata');
18
19
  const w = metadata.width || 0;
19
20
  const h = metadata.height || 0;
20
21
  const exceedsWidth = maxOriginalWidth && w > maxOriginalWidth;
21
22
  const exceedsHeight = maxOriginalHeight && h > maxOriginalHeight;
22
23
  if (!exceedsWidth && !exceedsHeight)
23
24
  return file;
24
- const resized = await sharp(buffer)
25
+ const resized = await withTimeout(sharp(buffer)
25
26
  .resize(maxOriginalWidth, maxOriginalHeight, {
26
27
  fit: 'inside',
27
28
  withoutEnlargement: true
28
29
  })
29
- .toBuffer();
30
+ .toBuffer(), sharpTimeoutMs(), 'sharp.resize');
30
31
  return new File([new Uint8Array(resized)], file.name, { type: file.type });
31
32
  }
32
33
  catch (e) {
@@ -1,6 +1,7 @@
1
1
  import { getCMS } from '../../../../cms.js';
2
2
  import sharp from 'sharp';
3
3
  import { calculateFocalCropRegion } from '../../utils/calculateFocalCropRegion.js';
4
+ import { withTimeout, sharpTimeoutMs } from '../../../../../server/utils/withTimeout.js';
4
5
  export async function generateImageStyle(mediaFileId, style) {
5
6
  const mediaFile = await getCMS().databaseAdapter.getMediaFile({
6
7
  data: {
@@ -19,7 +20,7 @@ export async function generateImageStyle(mediaFileId, style) {
19
20
  }
20
21
  export async function generateImageStyleFromBuffer(buf, mediaFile, style) {
21
22
  // Read EXIF orientation before processing
22
- const metadata = await sharp(buf).metadata();
23
+ const metadata = await withTimeout(sharp(buf).metadata(), sharpTimeoutMs(), 'sharp.metadata');
23
24
  // .rotate() applies EXIF orientation to pixels AND strips the tag from output.
24
25
  // Prevents double-rotation in WebP/JPEG where EXIF orientation tag may persist.
25
26
  let sharpInstance = sharp(buf).rotate();
@@ -79,7 +80,7 @@ export async function generateImageStyleFromBuffer(buf, mediaFile, style) {
79
80
  const originalExt = mediaFile.mimeType?.split('/').pop() ?? mediaFile.url.split('.').pop();
80
81
  const format = style.format ?? originalExt ?? 'jpeg';
81
82
  sharpInstance = sharpInstance.toFormat(format, style.quality != null ? { quality: Math.max(1, Math.min(100, style.quality)) } : undefined);
82
- const outputBuffer = await sharpInstance.toBuffer();
83
+ const outputBuffer = await withTimeout(sharpInstance.toBuffer(), sharpTimeoutMs(), 'sharp.toBuffer');
83
84
  return getCMS().filesAdapter.uploadFile(new File([new Uint8Array(outputBuffer)], `${mediaFile.id}_${style.name}_${Date.now().toString(36)}.${format}`, {
84
85
  type: `image/${format}`
85
86
  }));
@@ -1,14 +1,15 @@
1
1
  import { getCMS } from '../../../cms.js';
2
2
  import { isProcessableImage } from '../../fields/utils/imageStyles.js';
3
3
  import sharp from 'sharp';
4
+ import { withTimeout, sharpTimeoutMs } from '../../../../server/utils/withTimeout.js';
4
5
  const THUMB_WIDTH = 400;
5
6
  const THUMB_QUALITY = 70;
6
7
  export async function generateAdminThumbnail(buffer, mediaFile) {
7
- const output = await sharp(buffer)
8
+ const output = await withTimeout(sharp(buffer)
8
9
  .rotate()
9
10
  .resize(THUMB_WIDTH, undefined, { withoutEnlargement: true })
10
11
  .toFormat('webp', { quality: THUMB_QUALITY })
11
- .toBuffer();
12
+ .toBuffer(), sharpTimeoutMs(), 'sharp.adminThumbnail');
12
13
  const filename = `${mediaFile.id}_admin_thumb_${Date.now().toString(36)}.webp`;
13
14
  const uploaded = await getCMS().filesAdapter.uploadFile(new File([new Uint8Array(output)], filename, { type: 'image/webp' }));
14
15
  return uploaded.url;
@@ -1,5 +1,6 @@
1
1
  import sharp from 'sharp';
2
+ import { withTimeout, sharpTimeoutMs } from '../../../../server/utils/withTimeout.js';
2
3
  export async function generateBlurDataUrl(buffer) {
3
- const blurBuffer = await sharp(buffer).resize(20).blur(10).toFormat('webp').toBuffer();
4
+ const blurBuffer = await withTimeout(sharp(buffer).resize(20).blur(10).toFormat('webp').toBuffer(), sharpTimeoutMs(), 'sharp.blurDataUrl');
4
5
  return `data:image/webp;base64,${blurBuffer.toString('base64')}`;
5
6
  }
@@ -0,0 +1,16 @@
1
+ export interface CspOptions {
2
+ scriptSrc?: string[];
3
+ styleSrc?: string[];
4
+ imgSrc?: string[];
5
+ mediaSrc?: string[];
6
+ fontSrc?: string[];
7
+ connectSrc?: string[];
8
+ frameAncestors?: string[];
9
+ }
10
+ /**
11
+ * Build a Content-Security-Policy header value with v1.0 defaults.
12
+ * `'unsafe-inline'` is allowed on `script-src`/`style-src` because TipTap and
13
+ * paraglide emit inline code; documented in `KNOWN-RISKS.md`.
14
+ * @internal
15
+ */
16
+ export declare function buildCspHeader(opts?: CspOptions): string;
@@ -0,0 +1,33 @@
1
+ const DEFAULTS = {
2
+ scriptSrc: ["'self'", "'unsafe-inline'"],
3
+ styleSrc: ["'self'", "'unsafe-inline'"],
4
+ imgSrc: ["'self'", 'data:', 'blob:'],
5
+ mediaSrc: ["'self'", 'blob:'],
6
+ fontSrc: ["'self'", 'data:'],
7
+ connectSrc: ["'self'"],
8
+ frameAncestors: ["'self'"]
9
+ };
10
+ /**
11
+ * Build a Content-Security-Policy header value with v1.0 defaults.
12
+ * `'unsafe-inline'` is allowed on `script-src`/`style-src` because TipTap and
13
+ * paraglide emit inline code; documented in `KNOWN-RISKS.md`.
14
+ * @internal
15
+ */
16
+ export function buildCspHeader(opts = {}) {
17
+ const merge = (key, extra) => {
18
+ const base = DEFAULTS[key];
19
+ return extra && extra.length ? Array.from(new Set([...base, ...extra])) : [...base];
20
+ };
21
+ return [
22
+ `default-src 'self'`,
23
+ `script-src ${merge('scriptSrc', opts.scriptSrc).join(' ')}`,
24
+ `style-src ${merge('styleSrc', opts.styleSrc).join(' ')}`,
25
+ `img-src ${merge('imgSrc', opts.imgSrc).join(' ')}`,
26
+ `media-src ${merge('mediaSrc', opts.mediaSrc).join(' ')}`,
27
+ `font-src ${merge('fontSrc', opts.fontSrc).join(' ')}`,
28
+ `connect-src ${merge('connectSrc', opts.connectSrc).join(' ')}`,
29
+ `object-src 'none'`,
30
+ `base-uri 'self'`,
31
+ `frame-ancestors ${merge('frameAncestors', opts.frameAncestors).join(' ')}`
32
+ ].join('; ');
33
+ }
@@ -0,0 +1,13 @@
1
+ import type { Handle, RequestEvent } from '@sveltejs/kit';
2
+ /**
3
+ * Returns true when a request is CSRF-safe: a non-mutating method, or a mutating
4
+ * method whose Origin/Referer matches the request URL origin (or the env allowlist).
5
+ * @internal
6
+ */
7
+ export declare function isCsrfSafe(event: RequestEvent): boolean;
8
+ /**
9
+ * SvelteKit handle that rejects mutating requests under `/admin/api/*` lacking a
10
+ * matching Origin/Referer header. Other paths and safe methods pass through.
11
+ * @internal
12
+ */
13
+ export declare const csrfGuard: Handle;
@@ -0,0 +1,49 @@
1
+ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
2
+ function getAllowedOrigins() {
3
+ const env = process.env.INCLUDIO_CSRF_ALLOWED_ORIGINS ?? '';
4
+ return new Set(env
5
+ .split(',')
6
+ .map((s) => s.trim())
7
+ .filter(Boolean));
8
+ }
9
+ /**
10
+ * Returns true when a request is CSRF-safe: a non-mutating method, or a mutating
11
+ * method whose Origin/Referer matches the request URL origin (or the env allowlist).
12
+ * @internal
13
+ */
14
+ export function isCsrfSafe(event) {
15
+ const method = event.request.method.toUpperCase();
16
+ if (!MUTATING_METHODS.has(method))
17
+ return true;
18
+ const expected = event.url.origin;
19
+ const allowed = getAllowedOrigins();
20
+ const origin = event.request.headers.get('origin');
21
+ if (origin)
22
+ return origin === expected || allowed.has(origin);
23
+ const referer = event.request.headers.get('referer');
24
+ if (referer) {
25
+ try {
26
+ const refOrigin = new URL(referer).origin;
27
+ return refOrigin === expected || allowed.has(refOrigin);
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+ /**
36
+ * SvelteKit handle that rejects mutating requests under `/admin/api/*` lacking a
37
+ * matching Origin/Referer header. Other paths and safe methods pass through.
38
+ * @internal
39
+ */
40
+ export const csrfGuard = async ({ event, resolve }) => {
41
+ if (!event.url.pathname.startsWith('/admin/api/'))
42
+ return resolve(event);
43
+ if (isCsrfSafe(event))
44
+ return resolve(event);
45
+ return new Response(JSON.stringify({ error: 'csrf_rejected' }), {
46
+ status: 403,
47
+ headers: { 'content-type': 'application/json' }
48
+ });
49
+ };
@@ -0,0 +1,3 @@
1
+ export { csrfGuard, isCsrfSafe } from './csrf.js';
2
+ export { rateLimitGuard, MemoryRateLimitStore, type RateLimitStore, type RateLimitResult, type RateLimitGuardOptions } from './rate-limit.js';
3
+ export { buildCspHeader, type CspOptions } from './csp.js';
@@ -0,0 +1,3 @@
1
+ export { csrfGuard, isCsrfSafe } from './csrf.js';
2
+ export { rateLimitGuard, MemoryRateLimitStore } from './rate-limit.js';
3
+ export { buildCspHeader } from './csp.js';
@@ -0,0 +1,44 @@
1
+ import type { Handle, RequestEvent } from '@sveltejs/kit';
2
+ export interface RateLimitResult {
3
+ allowed: boolean;
4
+ remaining: number;
5
+ resetAt: number;
6
+ }
7
+ /**
8
+ * Pluggable storage for {@link rateLimitGuard}. The default implementation is
9
+ * in-memory; multi-node deploys should provide a Redis-backed store.
10
+ * @internal
11
+ */
12
+ export interface RateLimitStore {
13
+ hit(key: string, windowMs: number, limit: number): Promise<RateLimitResult>;
14
+ }
15
+ /**
16
+ * In-memory {@link RateLimitStore}. Per-process; not safe across multiple nodes.
17
+ * @internal
18
+ */
19
+ export declare class MemoryRateLimitStore implements RateLimitStore {
20
+ private buckets;
21
+ private cleanupInterval;
22
+ constructor(opts?: {
23
+ cleanupMs?: number;
24
+ });
25
+ hit(key: string, windowMs: number, limit: number): Promise<RateLimitResult>;
26
+ private cleanup;
27
+ reset(key?: string): void;
28
+ dispose(): void;
29
+ }
30
+ export interface RateLimitGuardOptions {
31
+ store?: RateLimitStore;
32
+ limit?: number;
33
+ windowMs?: number;
34
+ pathPrefix?: string;
35
+ key?: (event: RequestEvent) => string;
36
+ }
37
+ /**
38
+ * SvelteKit handle that limits requests under `pathPrefix` (default `/admin/api/`)
39
+ * per key (default: authenticated user id, falling back to client IP). Returns
40
+ * 429 with `Retry-After` when exceeded. Defaults: 200 req / 60s, override via env
41
+ * `INCLUDIO_RATE_LIMIT_MAX` / `INCLUDIO_RATE_LIMIT_WINDOW_MS`.
42
+ * @internal
43
+ */
44
+ export declare function rateLimitGuard(opts?: RateLimitGuardOptions): Handle;
@@ -0,0 +1,97 @@
1
+ import { json } from '@sveltejs/kit';
2
+ /**
3
+ * In-memory {@link RateLimitStore}. Per-process; not safe across multiple nodes.
4
+ * @internal
5
+ */
6
+ export class MemoryRateLimitStore {
7
+ buckets = new Map();
8
+ cleanupInterval;
9
+ constructor(opts = {}) {
10
+ const cleanupMs = opts.cleanupMs ?? 60_000;
11
+ if (cleanupMs > 0 && typeof setInterval === 'function') {
12
+ this.cleanupInterval = setInterval(() => this.cleanup(), cleanupMs);
13
+ const i = this.cleanupInterval;
14
+ if (typeof i.unref === 'function')
15
+ i.unref();
16
+ }
17
+ }
18
+ async hit(key, windowMs, limit) {
19
+ const now = Date.now();
20
+ const bucket = this.buckets.get(key);
21
+ if (!bucket || bucket.resetAt <= now) {
22
+ const fresh = { count: 1, resetAt: now + windowMs };
23
+ this.buckets.set(key, fresh);
24
+ return { allowed: true, remaining: Math.max(0, limit - 1), resetAt: fresh.resetAt };
25
+ }
26
+ if (bucket.count >= limit) {
27
+ return { allowed: false, remaining: 0, resetAt: bucket.resetAt };
28
+ }
29
+ bucket.count += 1;
30
+ return { allowed: true, remaining: limit - bucket.count, resetAt: bucket.resetAt };
31
+ }
32
+ cleanup() {
33
+ const now = Date.now();
34
+ for (const [k, b] of this.buckets) {
35
+ if (b.resetAt <= now)
36
+ this.buckets.delete(k);
37
+ }
38
+ }
39
+ reset(key) {
40
+ if (key)
41
+ this.buckets.delete(key);
42
+ else
43
+ this.buckets.clear();
44
+ }
45
+ dispose() {
46
+ if (this.cleanupInterval)
47
+ clearInterval(this.cleanupInterval);
48
+ this.cleanupInterval = undefined;
49
+ }
50
+ }
51
+ function num(env, fallback) {
52
+ const n = env ? Number(env) : NaN;
53
+ return Number.isFinite(n) && n > 0 ? n : fallback;
54
+ }
55
+ /**
56
+ * SvelteKit handle that limits requests under `pathPrefix` (default `/admin/api/`)
57
+ * per key (default: authenticated user id, falling back to client IP). Returns
58
+ * 429 with `Retry-After` when exceeded. Defaults: 200 req / 60s, override via env
59
+ * `INCLUDIO_RATE_LIMIT_MAX` / `INCLUDIO_RATE_LIMIT_WINDOW_MS`.
60
+ * @internal
61
+ */
62
+ export function rateLimitGuard(opts = {}) {
63
+ const store = opts.store ?? new MemoryRateLimitStore();
64
+ const limit = opts.limit ?? num(process.env.INCLUDIO_RATE_LIMIT_MAX, 200);
65
+ const windowMs = opts.windowMs ?? num(process.env.INCLUDIO_RATE_LIMIT_WINDOW_MS, 60_000);
66
+ const pathPrefix = opts.pathPrefix ?? '/admin/api/';
67
+ const keyFn = opts.key ??
68
+ ((event) => {
69
+ const userId = event.locals?.user?.id;
70
+ if (userId)
71
+ return `u:${userId}`;
72
+ try {
73
+ return `ip:${event.getClientAddress()}`;
74
+ }
75
+ catch {
76
+ return 'ip:unknown';
77
+ }
78
+ });
79
+ return async ({ event, resolve }) => {
80
+ if (!event.url.pathname.startsWith(pathPrefix))
81
+ return resolve(event);
82
+ const result = await store.hit(keyFn(event), windowMs, limit);
83
+ if (!result.allowed) {
84
+ const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
85
+ return json({ error: 'rate_limited', resetAt: result.resetAt }, {
86
+ status: 429,
87
+ headers: {
88
+ 'retry-after': String(retryAfter),
89
+ 'x-ratelimit-limit': String(limit),
90
+ 'x-ratelimit-remaining': '0',
91
+ 'x-ratelimit-reset': String(Math.ceil(result.resetAt / 1000))
92
+ }
93
+ });
94
+ }
95
+ return resolve(event);
96
+ };
97
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Race a promise against a deadline. Resolves with the original promise's value
3
+ * if it settles before `ms`; otherwise rejects with {@link TimeoutError}.
4
+ * Cleans up the timer in both success and failure paths so it never holds the
5
+ * event loop.
6
+ * @internal
7
+ */
8
+ export declare class TimeoutError extends Error {
9
+ readonly label: string;
10
+ readonly ms: number;
11
+ readonly name = "TimeoutError";
12
+ constructor(label: string, ms: number);
13
+ }
14
+ /** @internal */
15
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>;
16
+ /**
17
+ * Resolved timeout (ms) for sharp operations. Override via `INCLUDIO_SHARP_TIMEOUT_MS`.
18
+ * Documented in `KNOWN-RISKS.md` §4.
19
+ * @internal
20
+ */
21
+ export declare function sharpTimeoutMs(): number;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Race a promise against a deadline. Resolves with the original promise's value
3
+ * if it settles before `ms`; otherwise rejects with {@link TimeoutError}.
4
+ * Cleans up the timer in both success and failure paths so it never holds the
5
+ * event loop.
6
+ * @internal
7
+ */
8
+ export class TimeoutError extends Error {
9
+ label;
10
+ ms;
11
+ name = 'TimeoutError';
12
+ constructor(label, ms) {
13
+ super(`${label} timed out after ${ms}ms`);
14
+ this.label = label;
15
+ this.ms = ms;
16
+ }
17
+ }
18
+ /** @internal */
19
+ export function withTimeout(promise, ms, label) {
20
+ let timer;
21
+ const timeout = new Promise((_, reject) => {
22
+ timer = setTimeout(() => reject(new TimeoutError(label, ms)), ms);
23
+ });
24
+ return Promise.race([promise, timeout]).finally(() => {
25
+ if (timer)
26
+ clearTimeout(timer);
27
+ });
28
+ }
29
+ /**
30
+ * Resolved timeout (ms) for sharp operations. Override via `INCLUDIO_SHARP_TIMEOUT_MS`.
31
+ * Documented in `KNOWN-RISKS.md` §4.
32
+ * @internal
33
+ */
34
+ export function sharpTimeoutMs() {
35
+ const n = Number(process.env.INCLUDIO_SHARP_TIMEOUT_MS);
36
+ return Number.isFinite(n) && n > 0 ? n : 30_000;
37
+ }
@@ -4,6 +4,9 @@ import { getCMS, initCMS } from '../../core/cms.js';
4
4
  import { generateRuntime } from '../../core/server/generator/generator.js';
5
5
  import { svelteKitHandler } from 'better-auth/svelte-kit';
6
6
  import { building } from '$app/environment';
7
+ import { csrfGuard } from '../../server/security/csrf.js';
8
+ import { rateLimitGuard } from '../../server/security/rate-limit.js';
9
+ import { buildCspHeader } from '../../server/security/csp.js';
7
10
  const adminGuard = async ({ event, resolve }) => {
8
11
  const { user, session } = event.locals;
9
12
  // Secure the admin routes
@@ -74,17 +77,21 @@ export function includioCMS(cmsConfig) {
74
77
  initCMS(cmsConfig);
75
78
  const handles = [];
76
79
  handles.push(detectBrowserContext);
80
+ handles.push(csrfGuard);
77
81
  if (cmsConfig.auth) {
78
82
  handles.push(handleAuth);
79
83
  }
84
+ handles.push(rateLimitGuard());
80
85
  handles.push(adminGuard);
81
86
  handles.push(securityHeaders);
82
87
  return handles;
83
88
  }
89
+ const CSP_VALUE = buildCspHeader();
84
90
  const securityHeaders = async ({ event, resolve }) => {
85
91
  const response = await resolve(event);
86
92
  response.headers.set('X-Content-Type-Options', 'nosniff');
87
93
  response.headers.set('X-Frame-Options', 'SAMEORIGIN');
88
94
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
95
+ response.headers.set('Content-Security-Policy', CSP_VALUE);
89
96
  return response;
90
97
  };
@@ -6,3 +6,4 @@ export { parseFormDataForSubmission } from '../../core/server/forms/submissions/
6
6
  export { createConsentLog } from '../../core/server/consentLogs/operations/create.js';
7
7
  export { getPreviewEntry } from './preview.js';
8
8
  export { createRestApiHandler } from '../../admin/api/rest/handler.js';
9
+ export { generateApiKey } from '../../admin/api/rest/middleware/generateApiKey.js';
@@ -7,3 +7,4 @@ export { createConsentLog } from '../../core/server/consentLogs/operations/creat
7
7
  export { getPreviewEntry } from './preview.js';
8
8
  // Folded from `./admin/api/rest/handler` (dropped as separate export in 0.20.0)
9
9
  export { createRestApiHandler } from '../../admin/api/rest/handler.js';
10
+ export { generateApiKey } from '../../admin/api/rest/middleware/generateApiKey.js';
@@ -60,6 +60,10 @@ export interface ApiKeyConfig {
60
60
  key: string;
61
61
  name?: string;
62
62
  role?: 'admin' | 'editor';
63
+ /** Opt-in expiry. ISO-8601 timestamp; past dates → 401 on use. */
64
+ expiresAt?: string;
65
+ /** Audit-trail only; not enforced. ISO-8601 timestamp of last manual rotation. */
66
+ rotatedAt?: string;
63
67
  }
64
68
  export interface TypographyConfig {
65
69
  fixOrphans?: boolean;
@@ -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
+ };
@@ -54,7 +54,8 @@ import { update as update0160 } from './0.16.0/index.js';
54
54
  import { update as update0180 } from './0.18.0/index.js';
55
55
  import { update as update0190 } from './0.19.0/index.js';
56
56
  import { update as update0200 } from './0.20.0/index.js';
57
- export const updates = [update0065, update0066, update0067, update0068, update0069, update010, update011, update012, update013, update014, update015, update020, update022, update050, update051, update052, update053, update054, update055, update056, update057, update058, update060, update061, update062, update070, update071, update072, update073, update080, update090, update0100, update0110, update0120, update0130, update0131, update0132, update0133, update0134, update0140, update0141, update0142, update0143, update0144, update0145, update0146, update0150, update0151, update0152, update0153, update0154, update0155, update0160, update0180, update0190, update0200];
57
+ import { update as update0210 } from './0.21.0/index.js';
58
+ export const updates = [update0065, update0066, update0067, update0068, update0069, update010, update011, update012, update013, update014, update015, update020, update022, update050, update051, update052, update053, update054, update055, update056, update057, update058, update060, update061, update062, update070, update071, update072, update073, update080, update090, update0100, update0110, update0120, update0130, update0131, update0132, update0133, update0134, update0140, update0141, update0142, update0143, update0144, update0145, update0146, update0150, update0151, update0152, update0153, update0154, update0155, update0160, update0180, update0190, update0200, update0210];
58
59
  export const getUpdatesFrom = (fromVersion) => {
59
60
  const fromParts = fromVersion.split('.').map(Number);
60
61
  return updates.filter((update) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "includio-cms",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",
@@ -14,10 +14,16 @@
14
14
  "test:unit": "vitest",
15
15
  "test": "npm run test:unit -- --run && npm run test:e2e",
16
16
  "test:e2e": "playwright test",
17
+ "test:coverage": "vitest run --coverage",
18
+ "pretest:integration": "pnpm prepack",
19
+ "test:integration": "TEST_DATABASE_URL=postgres://root:mysecretpassword@localhost:5434/test vitest run --project=integration",
17
20
  "db:start": "docker compose up",
18
21
  "db:push": "drizzle-kit push",
19
22
  "db:migrate": "drizzle-kit migrate",
20
23
  "db:studio": "drizzle-kit studio",
24
+ "db:test:up": "docker compose --profile test up -d db_test",
25
+ "db:test:down": "docker compose --profile test down db_test",
26
+ "db:test:migrate": "DATABASE_URL=postgres://root:mysecretpassword@localhost:5434/test drizzle-kit push --force",
21
27
  "storybook": "storybook dev -p 6006",
22
28
  "build-storybook": "storybook build",
23
29
  "create:user": "tsx cli/addUser/index.ts",
@@ -229,7 +235,8 @@
229
235
  "typescript": "^5.0.0",
230
236
  "typescript-eslint": "^8.20.0",
231
237
  "vite": "^7.2.2",
232
- "vitest": "^3.2.3"
238
+ "vitest": "^3.2.3",
239
+ "@vitest/coverage-v8": "^3.2.3"
233
240
  },
234
241
  "keywords": [
235
242
  "svelte"