@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.
- package/package.json +6 -6
- package/src/data-retention/__tests__/cleanup-cron-registration.test.ts +23 -0
- package/src/data-retention/__tests__/resolve-tenant-preset.test.ts +57 -0
- package/src/data-retention/__tests__/resolver.test.ts +3 -3
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +188 -0
- package/src/data-retention/feature.ts +58 -7
- package/src/data-retention/presets.ts +5 -5
- package/src/data-retention/resolve-for-tenant.ts +9 -4
- package/src/data-retention/resolve-tenant-preset.ts +51 -0
- package/src/data-retention/run-retention-cleanup.ts +151 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
- package/src/legal-pages/feature.ts +22 -10
- package/src/mail-foundation/feature.ts +51 -6
- package/src/mail-foundation/index.ts +2 -0
- package/src/mail-transport-inmemory/feature.ts +6 -3
- package/src/mail-transport-smtp/feature.ts +11 -10
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
- package/src/managed-pages/feature.ts +17 -9
- package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
- package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +65 -0
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
- package/src/user-data-rights/email-templates.ts +211 -0
- package/src/user-data-rights/feature.ts +110 -21
- package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +11 -0
- package/src/user-data-rights/handlers/export-job-detail.query.ts +7 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +8 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
- package/src/user-data-rights/lib/default-mailers.ts +116 -0
- package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
- package/src/user-data-rights/run-export-jobs.ts +19 -8
- package/src/user-data-rights/run-forget-cleanup.ts +11 -1
- 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=
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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:
|
|
141
|
-
extra
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
}
|
|
@@ -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 {
|
|
30
|
-
|
|
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:
|
|
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 {
|
|
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:
|
|
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(
|
|
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:
|
|
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=
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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:
|
|
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
|
+
});
|