@cosmicdrift/kumiko-bundled-features 0.88.0 → 0.90.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 (52) hide show
  1. package/package.json +9 -6
  2. package/src/data-retention/__tests__/cleanup-cron-registration.test.ts +23 -0
  3. package/src/data-retention/__tests__/resolve-tenant-preset.test.ts +57 -0
  4. package/src/data-retention/__tests__/resolver.test.ts +3 -3
  5. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +188 -0
  6. package/src/data-retention/feature.ts +58 -7
  7. package/src/data-retention/presets.ts +5 -5
  8. package/src/data-retention/resolve-for-tenant.ts +9 -4
  9. package/src/data-retention/resolve-tenant-preset.ts +51 -0
  10. package/src/data-retention/run-retention-cleanup.ts +151 -0
  11. package/src/folders/__tests__/drift.test.ts +43 -0
  12. package/src/folders/__tests__/feature.test.ts +168 -0
  13. package/src/folders/__tests__/folders.integration.test.ts +290 -0
  14. package/src/folders/aggregate-id.ts +23 -0
  15. package/src/folders/constants.ts +40 -0
  16. package/src/folders/entity.ts +42 -0
  17. package/src/folders/executor.ts +11 -0
  18. package/src/folders/feature.ts +106 -0
  19. package/src/folders/handlers/clear-folder.write.ts +35 -0
  20. package/src/folders/handlers/set-folder.write.ts +82 -0
  21. package/src/folders/index.ts +23 -0
  22. package/src/folders/schemas.ts +18 -0
  23. package/src/folders/web/__tests__/folder-section.test.tsx +181 -0
  24. package/src/folders/web/__tests__/tree.test.ts +58 -0
  25. package/src/folders/web/client-plugin.tsx +16 -0
  26. package/src/folders/web/folder-manager.tsx +323 -0
  27. package/src/folders/web/folder-section.tsx +198 -0
  28. package/src/folders/web/i18n.ts +55 -0
  29. package/src/folders/web/index.ts +6 -0
  30. package/src/folders/web/tree.ts +54 -0
  31. package/src/folders-user-data/hooks.ts +58 -0
  32. package/src/folders-user-data/index.ts +33 -0
  33. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
  34. package/src/legal-pages/feature.ts +22 -10
  35. package/src/mail-foundation/feature.ts +51 -6
  36. package/src/mail-foundation/index.ts +2 -0
  37. package/src/mail-transport-inmemory/feature.ts +6 -3
  38. package/src/mail-transport-smtp/feature.ts +11 -10
  39. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
  40. package/src/managed-pages/feature.ts +17 -9
  41. package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
  42. package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
  43. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
  44. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
  45. package/src/user-data-rights/email-templates.ts +211 -0
  46. package/src/user-data-rights/feature.ts +96 -21
  47. package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
  48. package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
  49. package/src/user-data-rights/lib/default-mailers.ts +116 -0
  50. package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
  51. package/src/user-data-rights/run-export-jobs.ts +19 -8
  52. package/src/user-data-rights/run-forget-cleanup.ts +11 -1
@@ -0,0 +1,58 @@
1
+ // EXT_USER_DATA hooks for the folder + folder-assignment entities (GDPR Art. 20
2
+ // export / Art. 17 erasure). Lives apart from the folders feature so folders
3
+ // consumers without the user-data-rights pipeline don't pull a hard dependency.
4
+ // Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
5
+ // (a folder name is tenant data, not per-user PII).
6
+
7
+ import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import {
9
+ createEntityExecutor,
10
+ type UserDataDeleteHook,
11
+ type UserDataExportHook,
12
+ } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { folderAssignmentEntity, folderEntity } from "../folders";
14
+
15
+ const { table: folderTable } = createEntityExecutor("folder", folderEntity);
16
+ const { table: folderAssignmentTable } = createEntityExecutor(
17
+ "folder-assignment",
18
+ folderAssignmentEntity,
19
+ );
20
+
21
+ // Folders are tenant-scoped (no per-user owner column). Every tenant user reads
22
+ // all tenant folders in-app, so bundling them into the user's export is no new
23
+ // exposure — it gives the data subject the organisation of the loans they work with.
24
+ export const folderExportHook: UserDataExportHook = async (ctx) => {
25
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, folderTable, {
26
+ tenantId: ctx.tenantId,
27
+ });
28
+ if (rows.length === 0) return null;
29
+ return { entity: "folder", rows };
30
+ };
31
+
32
+ export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
33
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, folderAssignmentTable, {
34
+ tenantId: ctx.tenantId,
35
+ });
36
+ if (rows.length === 0) return null;
37
+ return { entity: "folder-assignment", rows };
38
+ };
39
+
40
+ // Tenant-scoped erasure is only safe when the tenant is effectively single-user
41
+ // (set by the forget orchestrator from the app's tenantModel + a runtime
42
+ // sole-member check). In a multi-user tenant this stays a no-op: deleting by
43
+ // tenant would destroy co-members' folders. anonymize is also a no-op — folder
44
+ // rows carry no person-link to strip (name is tenant data, not PII), so a
45
+ // retention hold simply keeps them.
46
+ function tenantScopedDelete(table: typeof folderTable): UserDataDeleteHook {
47
+ return async (ctx, strategy) => {
48
+ // skip: multi-user tenant — a tenant-wide delete would destroy co-members' folders
49
+ if (ctx.tenantModel !== "single-user") return;
50
+ // skip: anonymize is a no-op — folder rows carry no per-user PII to strip
51
+ if (strategy === "anonymize") return;
52
+ await deleteMany(ctx.db, table, { tenantId: ctx.tenantId });
53
+ };
54
+ }
55
+
56
+ export const folderDeleteHook: UserDataDeleteHook = tenantScopedDelete(folderTable);
57
+ export const folderAssignmentDeleteHook: UserDataDeleteHook =
58
+ tenantScopedDelete(folderAssignmentTable);
@@ -0,0 +1,33 @@
1
+ // Provides the EXT_USER_DATA export/delete hooks for the folder + folder-assignment
2
+ // entities as a standalone feature — mount it alongside the folders feature +
3
+ // user-data-rights when an app needs folders in its GDPR export/forget pipeline.
4
+ // Kept separate from the folders feature (which only requires "tenant") so
5
+ // folders stays usable without the user-data-rights stack. Mirrors credit-user-data.
6
+
7
+ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ folderAssignmentDeleteHook,
10
+ folderAssignmentExportHook,
11
+ folderDeleteHook,
12
+ folderExportHook,
13
+ } from "./hooks";
14
+
15
+ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) => {
16
+ r.describe(
17
+ "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `folders` feature's `folder` + `folder-assignment` entities. Mounts the EXT_USER_DATA export + delete hooks so a tenant's folder tree and its entity-to-folder assignments are included in the user-data export bundle and erased on a tenant-scoped forget (single-user tenants only; multi-user + anonymize are no-ops since folder rows carry no per-user PII). Kept separate from `folders` so folder consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `folders`.",
18
+ );
19
+ // user-data-rights ist die harte Abhängigkeit (EXT_USER_DATA-Host). `folders` ist
20
+ // OPTIONAL: ist es toggleable(default=false) gemountet (z.B. per-Tenant via Tier),
21
+ // würde ein hartes r.requires eine „effectively disabled"-Boot-Warnung werfen,
22
+ // obwohl die folder-Entities existieren und die Hooks korrekt greifen.
23
+ r.requires("user-data-rights");
24
+ r.optionalRequires("folders");
25
+ r.useExtension(EXT_USER_DATA, "folder", {
26
+ export: folderExportHook,
27
+ delete: folderDeleteHook,
28
+ });
29
+ r.useExtension(EXT_USER_DATA, "folder-assignment", {
30
+ export: folderAssignmentExportHook,
31
+ delete: folderAssignmentDeleteHook,
32
+ });
33
+ });
@@ -163,7 +163,7 @@ describe("legal-pages :: edge-cases", () => {
163
163
  describe("legal-pages :: cache-control", () => {
164
164
  test("sets revalidate cache header + etag", async () => {
165
165
  const res = await stack.app.request("/legal/impressum");
166
- expect(res.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
166
+ expect(res.headers.get("cache-control")).toBe("public, max-age=60, must-revalidate");
167
167
  expect(res.headers.get("etag")).toBeTruthy();
168
168
  });
169
169
 
@@ -176,6 +176,13 @@ describe("legal-pages :: cache-control", () => {
176
176
  });
177
177
  expect(second.status).toBe(304);
178
178
  });
179
+
180
+ test("HEAD → 200 without body, etag present", async () => {
181
+ const res = await stack.app.request("/legal/impressum", { method: "HEAD" });
182
+ expect(res.status).toBe(200);
183
+ expect(await res.text()).toBe("");
184
+ expect(res.headers.get("etag")).toBeTruthy();
185
+ });
179
186
  });
180
187
 
181
188
  describe("legal-pages :: security headers", () => {
@@ -2,7 +2,7 @@ import {
2
2
  requireTextContent,
3
3
  type TextContentApi,
4
4
  } from "@cosmicdrift/kumiko-bundled-features/text-content";
5
- import { computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
5
+ import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
6
6
  import {
7
7
  defineFeature,
8
8
  type FeatureDefinition,
@@ -26,6 +26,11 @@ type ByslugQueryBody = {
26
26
  data: { title: string; body: string | null; updatedAt: string } | null;
27
27
  };
28
28
 
29
+ // Legal-Content ändert sich selten — ein 60s-Shared-Cache-Fenster spart den
30
+ // Origin-Revalidate-Roundtrip (jeder 304 re-runt sonst die Content-Query),
31
+ // ohne dass Edits spürbar stale wirken.
32
+ const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
33
+
29
34
  // legal-pages — Opt-in-Wrapper um text-content für DACH-Compliance.
30
35
  // Liefert vier feste Public-HTML-Routes (/legal/impressum,
31
36
  // /legal/datenschutz, /legal/imprint, /legal/privacy) mit
@@ -120,13 +125,20 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
120
125
  route.lang,
121
126
  data.updatedAt,
122
127
  ]);
123
- const notModified = cachedSecurePageResponse(c.req.raw, {
124
- body: null,
125
- etag,
126
- cache: { kind: "revalidate" },
127
- extra: { "content-type": "text/html; charset=utf-8" },
128
- });
129
- if (notModified.status === 304) return notModified;
128
+ const extra = { "content-type": "text/html; charset=utf-8" };
129
+ // 304 (Revision unverändert) und HEAD überspringen beide das
130
+ // Markdown-Rendern — der Body wird ohnehin verworfen.
131
+ if (
132
+ etagMatches(c.req.raw.headers.get("if-none-match"), etag) ||
133
+ c.req.method === "HEAD"
134
+ ) {
135
+ return cachedSecurePageResponse(c.req.raw, {
136
+ body: null,
137
+ etag,
138
+ cache: PUBLIC_PAGE_CACHE,
139
+ extra,
140
+ });
141
+ }
130
142
 
131
143
  const html = wrapLayout({
132
144
  title: data.title || route.titleFallback,
@@ -137,8 +149,8 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
137
149
  return cachedSecurePageResponse(c.req.raw, {
138
150
  body: html,
139
151
  etag,
140
- cache: { kind: "revalidate" },
141
- extra: { "content-type": "text/html; charset=utf-8" },
152
+ cache: PUBLIC_PAGE_CACHE,
153
+ extra,
142
154
  });
143
155
  },
144
156
  });
@@ -35,9 +35,10 @@ import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channe
35
35
  import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
36
36
  import {
37
37
  access,
38
+ type ConfigAccessor,
38
39
  createTenantConfig,
39
40
  defineFeature,
40
- type HandlerContext,
41
+ type Registry,
41
42
  } from "@cosmicdrift/kumiko-framework/engine";
42
43
 
43
44
  const FEATURE_NAME = "mail-foundation";
@@ -46,6 +47,37 @@ const FEATURE_NAME = "mail-foundation";
46
47
  // Plugin-Interface — what a Provider-Plugin must implement
47
48
  // =============================================================================
48
49
 
50
+ /**
51
+ * Schmaler Surface-Type fuer Transport-Plugins — gespiegelt von
52
+ * file-foundation's FileProviderContext. HandlerContext ist zu fett
53
+ * (haelt tx, actor, signal etc.); Plugins beschraenken sich auf die
54
+ * read-Felder die fuer Tenant-Config + Secret-Lookup noetig sind.
55
+ *
56
+ * **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job-getriggerte
57
+ * Transport-Builds, z.B. der user-data-rights forget/export-Cron) gibt es
58
+ * keinen per-request `config`/`tx`/`actor` — der Wrapper baut den per-Tenant-
59
+ * ConfigAccessor aus `ctx.configResolver`. Ein Plugin das `ctx.tx` oder
60
+ * andere request-only-Felder liest, wuerde den Worker-Pfad zur Runtime
61
+ * brechen — und das fiele NUR mit echtem SMTP und nur in production auf.
62
+ * Cast `ctx as unknown as HandlerContext` macht den Compiler happy, fliegt
63
+ * aber zur Runtime im Worker. Plugin das mehr braucht: MailTransportContext
64
+ * explizit erweitern (sichtbarer breaking change) statt ctx-cast.
65
+ *
66
+ * **Felder:**
67
+ * config — tenant-config-reads (host/port/from/... der Plugins)
68
+ * registry — extension-Lookup in der Factory (nicht plugin-intern)
69
+ * secrets — tenant-secret-reads (smtp.password)
70
+ * _userId — Audit-Identity fuer secret-reads. Handler-Pfad: dispatcher
71
+ * setzt Caller-User-ID; Worker-Pfad: r.job-Wrap setzt eine
72
+ * System-Identity.
73
+ */
74
+ export type MailTransportContext = {
75
+ readonly config?: ConfigAccessor;
76
+ readonly registry?: Registry;
77
+ readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
78
+ readonly _userId?: string | undefined;
79
+ };
80
+
49
81
  /**
50
82
  * Mail-Transport-Plugin contract. Each provider-feature (mail-transport-
51
83
  * smtp, mail-transport-brevo-api, ...) registers an implementation via
@@ -55,11 +87,20 @@ const FEATURE_NAME = "mail-foundation";
55
87
  * (the plugin owns its provider-specific config schema) and constructs
56
88
  * an EmailTransport. Per-call construction so a tenant editing config
57
89
  * sees the change on the next mail.
90
+ *
91
+ * **Plugin-Author-Warnung:** `ctx` ist EXPLIZIT ein MailTransportContext,
92
+ * nicht ein voller HandlerContext (siehe MailTransportContext-Doc).
58
93
  */
59
94
  export type MailTransportPlugin = {
60
- readonly build: (ctx: HandlerContext, tenantId: string) => Promise<EmailTransport>;
95
+ readonly build: (ctx: MailTransportContext, tenantId: string) => Promise<EmailTransport>;
61
96
  };
62
97
 
98
+ // extension-usage `options` is engine-payload (unknown) — structurally validate
99
+ // instead of casting blind. Mirrors file-foundation's isFileProviderPlugin.
100
+ export function isMailTransportPlugin(o: unknown): o is MailTransportPlugin {
101
+ return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
102
+ }
103
+
63
104
  // =============================================================================
64
105
  // Feature-definition
65
106
  // =============================================================================
@@ -129,7 +170,7 @@ export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
129
170
  * await transport.send({ to, subject, html });
130
171
  */
131
172
  export async function createTransportForTenant(
132
- ctx: HandlerContext,
173
+ ctx: MailTransportContext,
133
174
  tenantId: string,
134
175
  handlerName = "mail-foundation:transport-factory",
135
176
  ): Promise<EmailTransport> {
@@ -169,7 +210,11 @@ export async function createTransportForTenant(
169
210
  );
170
211
  }
171
212
 
172
- // @cast-boundary engine-payload — extension-usage carries unknown options
173
- const plugin = usage.options as MailTransportPlugin;
174
- return plugin.build(ctx, tenantId);
213
+ if (!isMailTransportPlugin(usage.options)) {
214
+ throw new Error(
215
+ `${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
216
+ `extension options must be a MailTransportPlugin.`,
217
+ );
218
+ }
219
+ return usage.options.build(ctx, tenantId);
175
220
  }
@@ -9,6 +9,8 @@
9
9
 
10
10
  export {
11
11
  createTransportForTenant,
12
+ isMailTransportPlugin,
13
+ type MailTransportContext,
12
14
  type MailTransportPlugin,
13
15
  mailFoundationFeature,
14
16
  } from "./feature";
@@ -26,8 +26,11 @@ import type {
26
26
  EmailTransport,
27
27
  } from "@cosmicdrift/kumiko-bundled-features/channel-email";
28
28
  import { createInMemoryTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
29
- import type { MailTransportPlugin } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
30
- import { defineFeature, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
29
+ import type {
30
+ MailTransportContext,
31
+ MailTransportPlugin,
32
+ } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
33
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
31
34
 
32
35
  const FEATURE_NAME = "mail-transport-inmemory";
33
36
 
@@ -83,7 +86,7 @@ export const mailTransportInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
83
86
  r.requires("mail-foundation");
84
87
 
85
88
  const plugin: MailTransportPlugin = {
86
- build: async (_ctx: HandlerContext, tenantId: string): Promise<EmailTransport> => {
89
+ build: async (_ctx: MailTransportContext, tenantId: string): Promise<EmailTransport> => {
87
90
  // Returnt den per-tenant Buffer. Identitätsstabil zwischen calls
88
91
  // damit die Demo-Inbox accumulated bleibt.
89
92
  return getOrCreateTransportForTenant(tenantId);
@@ -34,14 +34,12 @@ import {
34
34
  requireNonEmpty,
35
35
  requireSecretSet,
36
36
  } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
37
- import type { MailTransportPlugin } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
37
+ import type {
38
+ MailTransportContext,
39
+ MailTransportPlugin,
40
+ } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
38
41
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
39
- import {
40
- access,
41
- createTenantConfig,
42
- defineFeature,
43
- type HandlerContext,
44
- } from "@cosmicdrift/kumiko-framework/engine";
42
+ import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
45
43
 
46
44
  const FEATURE_NAME = "mail-transport-smtp";
47
45
 
@@ -115,7 +113,7 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
115
113
  // `entityName` "smtp" is what tenants set in mail-foundation's
116
114
  // `provider` config-key to pick this transport.
117
115
  const plugin: MailTransportPlugin = {
118
- build: async (ctx: HandlerContext, tenantId: string) => buildSmtpTransport(ctx, tenantId), // @wrapper-known semantic-alias
116
+ build: async (ctx: MailTransportContext, tenantId: string) => buildSmtpTransport(ctx, tenantId), // @wrapper-known semantic-alias
119
117
  };
120
118
  r.useExtension("mailTransport", "smtp", plugin);
121
119
 
@@ -136,7 +134,10 @@ export const SMTP_PASSWORD = mailTransportSmtpFeature.exports.password;
136
134
  // Internal: build the EmailTransport from tenant config + secret
137
135
  // =============================================================================
138
136
 
139
- async function buildSmtpTransport(ctx: HandlerContext, tenantId: string): Promise<EmailTransport> {
137
+ async function buildSmtpTransport(
138
+ ctx: MailTransportContext,
139
+ tenantId: string,
140
+ ): Promise<EmailTransport> {
140
141
  const ctxConfig = ctx.config;
141
142
  if (!ctxConfig) {
142
143
  throw new Error(
@@ -185,7 +186,7 @@ async function buildSmtpTransport(ctx: HandlerContext, tenantId: string): Promis
185
186
  });
186
187
  }
187
188
 
188
- async function readPassword(ctx: HandlerContext, tenantId: string): Promise<string> {
189
+ async function readPassword(ctx: MailTransportContext, tenantId: string): Promise<string> {
189
190
  const secrets = requireSecretsContext(ctx, FEATURE_NAME);
190
191
  const branded = await secrets.get(tenantId, SMTP_PASSWORD);
191
192
  return requireSecretSet(branded, FEATURE_NAME, SMTP_PASSWORD.name).reveal();
@@ -145,7 +145,7 @@ describe("managed-pages :: Cache + Security-Header", () => {
145
145
  test("Vary: Host + CSP/Hardening-Header + revalidate cache", async () => {
146
146
  const res = await stack.app.request("http://a.example.com/p/about");
147
147
  expect(res.headers.get("vary")).toBe("Host");
148
- expect(res.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
148
+ expect(res.headers.get("cache-control")).toBe("public, max-age=60, must-revalidate");
149
149
  expect(res.headers.get("etag")).toBeTruthy();
150
150
  expect(res.headers.get("content-security-policy")).toContain("script-src 'none'");
151
151
  expect(res.headers.get("x-content-type-options")).toBe("nosniff");
@@ -1,4 +1,4 @@
1
- import { computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
1
+ import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
2
2
  import {
3
3
  defineEntityCreateHandler,
4
4
  defineEntityDeleteHandler,
@@ -28,6 +28,11 @@ import { pageEntity } from "./table";
28
28
  // Alias (publicstatus = "Admin") müssen TenantAdmin granten/mappen.
29
29
  const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
30
30
 
31
+ // Published CMS-Content ändert sich selten — ein 60s-Shared-Cache-Fenster
32
+ // spart den Origin-Revalidate-Roundtrip (jeder 304 re-runt sonst Page- +
33
+ // Branding-Query), Edits sind nach spätestens 60s live.
34
+ const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
35
+
31
36
  // QN-Konstante als dokumentierter Public-Contract — der Render-Pfad ruft
32
37
  // die by-slug-Query via internem app.fetch (kein Code-Import des Handlers,
33
38
  // symmetrisch zum legal-pages-Muster).
@@ -248,13 +253,16 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
248
253
  "content-type": "text/html; charset=utf-8",
249
254
  vary: "Host",
250
255
  } as const;
251
- const notModified = cachedSecurePageResponse(c.req.raw, {
252
- body: null,
253
- etag,
254
- cache: { kind: "revalidate" },
255
- extra: pageHeaders,
256
- });
257
- if (notModified.status === 304) return notModified;
256
+ // 304 (Revision unverändert) und HEAD überspringen beide das
257
+ // Markdown-Rendern — der Body wird ohnehin verworfen.
258
+ if (etagMatches(c.req.raw.headers.get("if-none-match"), etag) || c.req.method === "HEAD") {
259
+ return cachedSecurePageResponse(c.req.raw, {
260
+ body: null,
261
+ etag,
262
+ cache: PUBLIC_PAGE_CACHE,
263
+ extra: pageHeaders,
264
+ });
265
+ }
258
266
 
259
267
  const html = wrapLayout({
260
268
  title: data.title,
@@ -269,7 +277,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
269
277
  return cachedSecurePageResponse(c.req.raw, {
270
278
  body: html,
271
279
  etag,
272
- cache: { kind: "revalidate" },
280
+ cache: PUBLIC_PAGE_CACHE,
273
281
  extra: pageHeaders,
274
282
  });
275
283
  },
@@ -0,0 +1,135 @@
1
+ // Unit-Tests fuer die Dispatch-Logik der Default-Mailer: rendert das richtige
2
+ // Template + sendet ueber den aufgeloesten Transport an die User-Email. Die
3
+ // echte Job-Lane-Bruecke (configResolver → Transport) wird NICHT hier, sondern
4
+ // in mail-default-bridge.integration.test.ts gegen den echten Cron bewiesen —
5
+ // ein Fake-Resolver wuerde genau diese Naht ueberspringen.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ EmailMessage,
10
+ EmailTransport,
11
+ } from "@cosmicdrift/kumiko-bundled-features/channel-email";
12
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
+ import {
14
+ makeDefaultDeletionExecutedEmail,
15
+ makeDefaultExportReadyEmail,
16
+ } from "../lib/default-mailers";
17
+
18
+ function capturingTransport(): { transport: EmailTransport; sent: EmailMessage[] } {
19
+ const sent: EmailMessage[] = [];
20
+ const transport: EmailTransport = {
21
+ send: async (m) => {
22
+ sent.push(m);
23
+ },
24
+ };
25
+ return { transport, sent };
26
+ }
27
+
28
+ const TENANT_A = "00000000-0000-4000-8000-00000000000a" as TenantId;
29
+
30
+ describe("default-mailers dispatch", () => {
31
+ test("export-ready: rendert Template + sendet an userEmail ueber tenant-transport", async () => {
32
+ const cap = capturingTransport();
33
+ const resolved: string[] = [];
34
+ // Kein defaults.locale → die per-User-Locale (user.locale="de") muss die
35
+ // Sprache bestimmen. Das ist der Advisor-Befund: en-Default an de-User.
36
+ const send = makeDefaultExportReadyEmail(
37
+ async (tenantId) => {
38
+ resolved.push(tenantId);
39
+ return cap.transport;
40
+ },
41
+ { appName: "Acme" },
42
+ );
43
+
44
+ await send({
45
+ userId: "u1",
46
+ userEmail: "u1@example.com",
47
+ userLocale: "de",
48
+ tenantId: TENANT_A,
49
+ jobId: "j1",
50
+ downloadUrl: "https://app.test/x?token=tok",
51
+ expiresAt: "2026-07-01T13:45:00Z",
52
+ bytesWritten: 1234,
53
+ });
54
+
55
+ expect(resolved).toEqual([TENANT_A]);
56
+ expect(cap.sent).toHaveLength(1);
57
+ expect(cap.sent[0]?.to).toBe("u1@example.com");
58
+ // user.locale="de" → deutsches Subject, obwohl kein defaults.locale gesetzt.
59
+ expect(cap.sent[0]?.subject).toBe("Acme — Dein Datenexport ist bereit");
60
+ expect(cap.sent[0]?.html).toContain("https://app.test/x?token=tok");
61
+ });
62
+
63
+ test("locale precedence: user.locale wins, mailDefaults is fallback for unknown", async () => {
64
+ const cap = capturingTransport();
65
+ const resolve = async () => cap.transport;
66
+
67
+ // null user.locale + defaults.locale "en" → English fallback.
68
+ await makeDefaultExportReadyEmail(resolve, { locale: "en", appName: "Acme" })({
69
+ userId: "u1",
70
+ userEmail: "u1@example.com",
71
+ userLocale: null,
72
+ tenantId: TENANT_A,
73
+ jobId: "j1",
74
+ downloadUrl: "u",
75
+ expiresAt: "x",
76
+ bytesWritten: null,
77
+ });
78
+ expect(cap.sent[0]?.subject).toBe("Acme — Your data export is ready");
79
+
80
+ // unsupported user.locale "fr" + defaults.locale "de" → falls back to de.
81
+ await makeDefaultExportReadyEmail(resolve, { locale: "de", appName: "Acme" })({
82
+ userId: "u2",
83
+ userEmail: "u2@example.com",
84
+ userLocale: "fr",
85
+ tenantId: TENANT_A,
86
+ jobId: "j2",
87
+ downloadUrl: "u",
88
+ expiresAt: "x",
89
+ bytesWritten: null,
90
+ });
91
+ expect(cap.sent[1]?.subject).toBe("Acme — Dein Datenexport ist bereit");
92
+ });
93
+
94
+ test("deletion-executed: sendet ueber den ersten Membership-Tenant", async () => {
95
+ const cap = capturingTransport();
96
+ const resolved: string[] = [];
97
+ const send = makeDefaultDeletionExecutedEmail(async (tenantId) => {
98
+ resolved.push(tenantId);
99
+ return cap.transport;
100
+ });
101
+
102
+ await send({
103
+ userId: "u1",
104
+ userEmail: "u1@example.com",
105
+ userLocale: "de",
106
+ tenantIds: [TENANT_A, "00000000-0000-4000-8000-00000000000b" as TenantId],
107
+ executedAt: "2026-07-30T09:05:00Z",
108
+ });
109
+
110
+ expect(resolved).toEqual([TENANT_A]);
111
+ expect(cap.sent).toHaveLength(1);
112
+ expect(cap.sent[0]?.to).toBe("u1@example.com");
113
+ expect(cap.sent[0]?.subject).toBe("Konto — Dein Konto wurde geloescht");
114
+ });
115
+
116
+ test("deletion-executed orphan (0 Memberships): kein Transport aufgeloest, keine Mail", async () => {
117
+ const cap = capturingTransport();
118
+ let resolverCalled = false;
119
+ const send = makeDefaultDeletionExecutedEmail(async () => {
120
+ resolverCalled = true;
121
+ return cap.transport;
122
+ });
123
+
124
+ await send({
125
+ userId: "u1",
126
+ userEmail: "u1@example.com",
127
+ userLocale: null,
128
+ tenantIds: [],
129
+ executedAt: "2026-07-30T09:05:00Z",
130
+ });
131
+
132
+ expect(resolverCalled).toBe(false);
133
+ expect(cap.sent).toHaveLength(0);
134
+ });
135
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ renderDeletionExecutedEmail,
4
+ renderDeletionRequestedEmail,
5
+ renderExportFailedEmail,
6
+ renderExportReadyEmail,
7
+ } from "../email-templates";
8
+
9
+ describe("gdpr email-templates", () => {
10
+ test("export-ready: subject + download-button + formatted expiry, de/en differ", () => {
11
+ const en = renderExportReadyEmail({
12
+ downloadUrl: "https://app.test/export/by-token?token=abc123",
13
+ expiresAt: "2026-07-01T13:45:00Z",
14
+ locale: "en",
15
+ appName: "Acme",
16
+ });
17
+ expect(en.subject).toBe("Acme — Your data export is ready");
18
+ expect(en.html).toContain("https://app.test/export/by-token?token=abc123");
19
+ // Button label present.
20
+ expect(en.html).toContain("Download data export");
21
+ // Instant formatted to UTC, not the raw ISO.
22
+ expect(en.html).toContain("2026-07-01 13:45 UTC");
23
+
24
+ const de = renderExportReadyEmail({
25
+ downloadUrl: "https://app.test/x?token=abc",
26
+ expiresAt: "2026-07-01T13:45:00Z",
27
+ locale: "de",
28
+ appName: "Acme",
29
+ });
30
+ expect(de.subject).toBe("Acme — Dein Datenexport ist bereit");
31
+ expect(de.subject).not.toBe(en.subject);
32
+ });
33
+
34
+ test("export-ready: ampersand in download url is escaped in the href attr", () => {
35
+ const r = renderExportReadyEmail({
36
+ downloadUrl: "https://app.test/x?token=a&next=b",
37
+ expiresAt: "2026-07-01T13:45:00Z",
38
+ });
39
+ // escapeHtmlAttr turns & into &amp; — no raw unescaped attribute break.
40
+ expect(r.html).toContain("token=a&amp;next=b");
41
+ expect(r.html).not.toContain('token=a&next="');
42
+ });
43
+
44
+ test("export-ready: default appName when omitted (Account/Konto)", () => {
45
+ expect(
46
+ renderExportReadyEmail({ downloadUrl: "u", expiresAt: "x", locale: "en" }).subject,
47
+ ).toContain("Account");
48
+ expect(
49
+ renderExportReadyEmail({ downloadUrl: "u", expiresAt: "x", locale: "de" }).subject,
50
+ ).toContain("Konto");
51
+ });
52
+
53
+ test("export-failed: informational, no download button", () => {
54
+ const r = renderExportFailedEmail({ locale: "en", appName: "Acme" });
55
+ expect(r.subject).toBe("Acme — Your data export failed");
56
+ expect(r.html).not.toContain("<a ");
57
+ expect(r.html).toContain("request the export again");
58
+ });
59
+
60
+ test("deletion-requested: grace deadline formatted + cancel hint", () => {
61
+ const r = renderDeletionRequestedEmail({
62
+ gracePeriodEnd: "2026-07-30T09:00:00Z",
63
+ locale: "en",
64
+ appName: "Acme",
65
+ });
66
+ expect(r.subject).toBe("Acme — Account deletion requested");
67
+ expect(r.html).toContain("2026-07-30 09:00 UTC");
68
+ expect(r.html).toContain("cancel the deletion");
69
+ });
70
+
71
+ test("deletion-executed: execution timestamp formatted", () => {
72
+ const r = renderDeletionExecutedEmail({
73
+ executedAt: "2026-07-30T09:05:00Z",
74
+ locale: "de",
75
+ appName: "Acme",
76
+ });
77
+ expect(r.subject).toBe("Acme — Dein Konto wurde geloescht");
78
+ expect(r.html).toContain("2026-07-30 09:05 UTC");
79
+ });
80
+
81
+ test("un-parsable timestamp falls back to the raw string", () => {
82
+ const r = renderDeletionExecutedEmail({ executedAt: "not-a-date" });
83
+ expect(r.html).toContain("not-a-date");
84
+ });
85
+ });