@cosmicdrift/kumiko-bundled-features 0.87.3 → 0.89.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 (35) hide show
  1. package/package.json +6 -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/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
  12. package/src/legal-pages/feature.ts +22 -10
  13. package/src/mail-foundation/feature.ts +51 -6
  14. package/src/mail-foundation/index.ts +2 -0
  15. package/src/mail-transport-inmemory/feature.ts +6 -3
  16. package/src/mail-transport-smtp/feature.ts +11 -10
  17. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
  18. package/src/managed-pages/feature.ts +17 -9
  19. package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
  20. package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
  21. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +65 -0
  22. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
  23. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
  24. package/src/user-data-rights/email-templates.ts +211 -0
  25. package/src/user-data-rights/feature.ts +110 -21
  26. package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
  27. package/src/user-data-rights/handlers/download-attempt-list.query.ts +11 -0
  28. package/src/user-data-rights/handlers/export-job-detail.query.ts +7 -0
  29. package/src/user-data-rights/handlers/export-job-list.query.ts +8 -0
  30. package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
  31. package/src/user-data-rights/lib/default-mailers.ts +116 -0
  32. package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
  33. package/src/user-data-rights/run-export-jobs.ts +19 -8
  34. package/src/user-data-rights/run-forget-cleanup.ts +11 -1
  35. package/src/user-data-rights/screens.ts +71 -0
@@ -0,0 +1,151 @@
1
+ // Retention-Cleanup-Runner (S2.D2b) — pure Function, vom retention-cleanup-Cron
2
+ // pro fan-out-Tenant aufgerufen.
3
+ //
4
+ // Iteriert alle implicit-Entity-Projektionen, loest pro Entity die effektive
5
+ // Retention-Policy (3-Schicht-Resolver, siehe resolver.ts) und wendet die
6
+ // Strategy auf Rows an deren reference-Timestamp aelter als der keepFor-Cutoff
7
+ // ist:
8
+ //
9
+ // - hardDelete → deleteManyBatched (selbst-begrenzt, kein Full-Table-Scan)
10
+ // - softDelete → isDeleted=true/deletedAt=now, nur auf noch-nicht-geloeschte
11
+ // - anonymize → DEFERRED (siehe unten)
12
+ // - blockDelete → ignoriert (Aufbewahrungs-Pflicht; user-forget loest anonymize)
13
+ //
14
+ // **Schaerfer als soft-delete-cleanup:** dieser Cron hardDeleted LIVE Rows
15
+ // (keyed auf reference, Default createdAt), nicht bereits-soft-geloeschte.
16
+ // Darum die Spalten-Existenz-Pruefung vor jedem WHERE — eine fehlende/vertippte
17
+ // reference-Spalte wuerde sonst ein malformed/all-matching WHERE ergeben und
18
+ // pauschal loeschen.
19
+ //
20
+ // **anonymize deferred:** anonymize behaelt die Row. Ohne Idempotenz-Marker
21
+ // wuerde der taegliche UPDATE jede past-cutoff Row endlos neu treffen — genau
22
+ // der Full-Table-Scan den die Strategie vermeiden soll (hardDelete begrenzt
23
+ // sich selbst, softDelete via isDeleted:false). Kein bundled-Entity nutzt
24
+ // zeitgesteuertes anonymize; der user-forget-Flow (run-forget-cleanup) deckt
25
+ // anonymize keyed auf userId ab. Follow-up fuer den Marker.
26
+
27
+ import { deleteManyBatched, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
28
+ import type { DbRunner, WhereObject } from "@cosmicdrift/kumiko-framework/db";
29
+ import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
30
+ import { computeCutoff, type Instant } from "./keep-for";
31
+ import type { RetentionPresetKey } from "./presets";
32
+ import { resolveRetentionPolicyForTenant } from "./resolve-for-tenant";
33
+
34
+ const DEFAULT_BATCH_LIMIT = 1000;
35
+ const DEFAULT_REFERENCE_FIELD = "createdAt";
36
+
37
+ // Der Boot-Validator (boot-validator/pii-retention.ts FRAMEWORK_TIMESTAMP_FIELDS)
38
+ // erlaubt diese Aliase als retention.reference, auch wenn sie nicht in
39
+ // entity.fields deklariert sind — die physischen Spalten heissen aber anders
40
+ // (table-builder.ts: inserted_at/modified_at). Hier zur Cleanup-Zeit auf das
41
+ // echte Entity-Feld mappen, sonst trifft die Spalten-Existenz-Pruefung unten
42
+ // und der Cron wuerde lautlos nichts tun. deletedAt/lastSeenAt sind echte
43
+ // Felder (softDelete bzw. session) und brauchen keine Uebersetzung.
44
+ const FRAMEWORK_REFERENCE_ALIAS: Readonly<Record<string, string>> = {
45
+ createdAt: "insertedAt",
46
+ updatedAt: "modifiedAt",
47
+ };
48
+
49
+ export interface RunRetentionCleanupArgs {
50
+ readonly db: DbRunner;
51
+ readonly registry: Registry;
52
+ readonly tenantId: TenantId;
53
+ /** Layer-2 Preset (aus resolveTenantRetentionPreset). null = nur Layer 1/3. */
54
+ readonly tenantPreset: RetentionPresetKey | null;
55
+ /** Now-Injection — Tests pinnen den Wert ohne Date-Mock (Pattern keep-for.ts). */
56
+ readonly now: Instant;
57
+ readonly batchLimit?: number;
58
+ }
59
+
60
+ export interface RetentionCleanupSkip {
61
+ readonly entityName: string;
62
+ readonly reason: "missing_reference_column" | "missing_softdelete_columns";
63
+ }
64
+
65
+ export interface RunRetentionCleanupResult {
66
+ readonly hardDeleted: number;
67
+ readonly softDeleted: number;
68
+ /** Entities mit anonymize-Strategy — deferred (Header). Cron logt sie. */
69
+ readonly anonymizeDeferred: readonly string[];
70
+ /** Anomalien: Policy referenziert eine Spalte die die Tabelle nicht hat. */
71
+ readonly skipped: readonly RetentionCleanupSkip[];
72
+ }
73
+
74
+ export async function runRetentionCleanup(
75
+ args: RunRetentionCleanupArgs,
76
+ ): Promise<RunRetentionCleanupResult> {
77
+ const { db, registry, tenantId, tenantPreset, now } = args;
78
+ const batchLimit = args.batchLimit ?? DEFAULT_BATCH_LIMIT;
79
+
80
+ let hardDeleted = 0;
81
+ let softDeleted = 0;
82
+ const anonymizeDeferred: string[] = [];
83
+ const skipped: RetentionCleanupSkip[] = [];
84
+
85
+ for (const proj of registry.getAllProjections().values()) {
86
+ // Nur implicit-Entity-Projektionen mit Tabelle — wie soft-delete-cleanup.
87
+ // Custom-Projektionen + unmanaged-Tables (z.B. sessions) sind kein Target.
88
+ if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table) continue;
89
+ const entityName = proj.source;
90
+
91
+ const resolved = await resolveRetentionPolicyForTenant({
92
+ db,
93
+ registry,
94
+ tenantId,
95
+ entityName,
96
+ tenantPreset,
97
+ });
98
+ const policy = resolved.policy;
99
+ if (!policy) continue;
100
+
101
+ const table = proj.table as Record<string, unknown>; // @cast-boundary column-presence probe
102
+ const declaredReference = policy.reference ?? DEFAULT_REFERENCE_FIELD;
103
+ const referenceField = FRAMEWORK_REFERENCE_ALIAS[declaredReference] ?? declaredReference;
104
+
105
+ if (table[referenceField] === undefined) {
106
+ skipped.push({ entityName, reason: "missing_reference_column" });
107
+ continue;
108
+ }
109
+
110
+ const cutoff = computeCutoff(policy.keepFor, now);
111
+ const where: WhereObject = { [referenceField]: { lt: cutoff } };
112
+ // Tenant-Scope nur wenn die Tabelle eine tenantId-Spalte hat — identisch zu
113
+ // soft-delete-cleanup. Ohne diesen Filter wuerde ein Tenant die Rows eines
114
+ // anderen treffen.
115
+ if (table["tenantId"] !== undefined) {
116
+ where["tenantId"] = tenantId;
117
+ }
118
+
119
+ switch (policy.strategy) {
120
+ case "hardDelete": {
121
+ const res = await deleteManyBatched(db, proj.table, where, { limit: batchLimit });
122
+ hardDeleted += res.deleted;
123
+ break;
124
+ }
125
+ case "softDelete": {
126
+ if (table["isDeleted"] === undefined || table["deletedAt"] === undefined) {
127
+ skipped.push({ entityName, reason: "missing_softdelete_columns" });
128
+ break;
129
+ }
130
+ const updated = await updateMany(
131
+ db,
132
+ proj.table,
133
+ { isDeleted: true, deletedAt: now },
134
+ { ...where, isDeleted: false },
135
+ );
136
+ softDeleted += updated.length;
137
+ break;
138
+ }
139
+ case "anonymize": {
140
+ anonymizeDeferred.push(entityName);
141
+ break;
142
+ }
143
+ case "blockDelete": {
144
+ // skip: Aufbewahrungs-Pflicht — Cleanup ignoriert, user-forget anonymisiert
145
+ break;
146
+ }
147
+ }
148
+ }
149
+
150
+ return { hardDeleted, softDeleted, anonymizeDeferred, skipped };
151
+ }
@@ -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
+ });