@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.
- package/package.json +9 -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/folders/__tests__/drift.test.ts +43 -0
- package/src/folders/__tests__/feature.test.ts +168 -0
- package/src/folders/__tests__/folders.integration.test.ts +290 -0
- package/src/folders/aggregate-id.ts +23 -0
- package/src/folders/constants.ts +40 -0
- package/src/folders/entity.ts +42 -0
- package/src/folders/executor.ts +11 -0
- package/src/folders/feature.ts +106 -0
- package/src/folders/handlers/clear-folder.write.ts +35 -0
- package/src/folders/handlers/set-folder.write.ts +82 -0
- package/src/folders/index.ts +23 -0
- package/src/folders/schemas.ts +18 -0
- package/src/folders/web/__tests__/folder-section.test.tsx +181 -0
- package/src/folders/web/__tests__/tree.test.ts +58 -0
- package/src/folders/web/client-plugin.tsx +16 -0
- package/src/folders/web/folder-manager.tsx +323 -0
- package/src/folders/web/folder-section.tsx +198 -0
- package/src/folders/web/i18n.ts +55 -0
- package/src/folders/web/index.ts +6 -0
- package/src/folders/web/tree.ts +54 -0
- package/src/folders-user-data/hooks.ts +58 -0
- package/src/folders-user-data/index.ts +33 -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__/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 +96 -21
- package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
- 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
|
@@ -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=
|
|
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
|
+
});
|
|
@@ -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 & — no raw unescaped attribute break.
|
|
40
|
+
expect(r.html).toContain("token=a&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
|
+
});
|