@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
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
import { PRIVACY_CENTER_SCREEN_ID } from "./constants";
|
|
9
9
|
import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
|
|
10
10
|
import { createConfirmDeletionByTokenHandler } from "./handlers/confirm-deletion-by-token.write";
|
|
11
|
+
import { downloadAttemptListQuery } from "./handlers/download-attempt-list.query";
|
|
11
12
|
import { downloadByJobQuery } from "./handlers/download-by-job.query";
|
|
12
13
|
import { downloadByTokenQuery } from "./handlers/download-by-token.query";
|
|
14
|
+
import { exportJobDetailQuery } from "./handlers/export-job-detail.query";
|
|
15
|
+
import { exportJobListQuery } from "./handlers/export-job-list.query";
|
|
13
16
|
import { exportStatusQuery } from "./handlers/export-status.query";
|
|
14
17
|
import { liftRestrictionWrite } from "./handlers/lift-restriction.write";
|
|
15
18
|
import { listDownloadAttemptsQuery } from "./handlers/list-download-attempts.query";
|
|
@@ -25,6 +28,14 @@ import {
|
|
|
25
28
|
import { requestExportWrite } from "./handlers/request-export.write";
|
|
26
29
|
import { restrictAccountWrite } from "./handlers/restrict-account.write";
|
|
27
30
|
import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
|
|
31
|
+
import {
|
|
32
|
+
type GdprMailDefaults,
|
|
33
|
+
isMailTransportAvailable,
|
|
34
|
+
makeDefaultDeletionExecutedEmail,
|
|
35
|
+
makeDefaultExportFailedEmail,
|
|
36
|
+
makeDefaultExportReadyEmail,
|
|
37
|
+
} from "./lib/default-mailers";
|
|
38
|
+
import { makeTenantMailTransportResolver } from "./lib/mail-transport-resolver";
|
|
28
39
|
import { resolveAppTenantModel } from "./lib/resolve-tenant-model";
|
|
29
40
|
import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver";
|
|
30
41
|
import {
|
|
@@ -36,6 +47,7 @@ import { runForgetCleanup, type SendDeletionExecutedEmailFn } from "./run-forget
|
|
|
36
47
|
import { downloadAttemptEntity } from "./schema/download-attempt";
|
|
37
48
|
import { exportDownloadTokenEntity } from "./schema/download-token";
|
|
38
49
|
import { exportJobEntity } from "./schema/export-job";
|
|
50
|
+
import { downloadAttemptListScreen, exportJobDetailScreen, exportJobListScreen } from "./screens";
|
|
39
51
|
|
|
40
52
|
// user-data-rights — DSGVO Art. 15 (Auskunft) + Art. 17 (Löschung) +
|
|
41
53
|
// Art. 18 (Restriction) + Art. 20 (Portabilität) als Core-Feature.
|
|
@@ -100,11 +112,25 @@ export type UserDataRightsOptions = {
|
|
|
100
112
|
/** Versand des Verify-Magic-Links (Schritt 1 des anonymen Flows).
|
|
101
113
|
* Best-effort, app-author-wired. MUSS non-blocking sein (enqueue, z.B.
|
|
102
114
|
* delivery.notify) — ein synchroner Send reintroduziert ein Timing-Oracle
|
|
103
|
-
* für Account-Enumeration (siehe SendDeletionVerificationEmailFn-Doc).
|
|
115
|
+
* für Account-Enumeration (siehe SendDeletionVerificationEmailFn-Doc).
|
|
116
|
+
* Bewusst KEINE mail-foundation-Default: ein synchroner Default-Send
|
|
117
|
+
* brächte genau dieses Enumeration-Oracle zurück, deshalb app-wired. */
|
|
104
118
|
readonly sendDeletionVerificationEmail?: SendDeletionVerificationEmailFn;
|
|
119
|
+
/** C6 — Zero-Callback-GDPR-Mails: ist mail-foundation + ein mail-transport-*
|
|
120
|
+
* gemountet, versendet user-data-rights die Export-/Loesch-Notifications
|
|
121
|
+
* selbst (Export-ready/-failed, Deletion-requested/-executed) ueber die
|
|
122
|
+
* Default-Templates (email-templates.ts) — die App schreibt keinen Callback.
|
|
123
|
+
* `mailDefaults` brandet diese Default-Mails (Locale + App-Name). Greift NUR
|
|
124
|
+
* wenn der jeweilige send*Email-Opt NICHT gesetzt ist; Export-ready braucht
|
|
125
|
+
* zusaetzlich appExportDownloadUrl. */
|
|
126
|
+
readonly mailDefaults?: GdprMailDefaults;
|
|
105
127
|
};
|
|
106
128
|
|
|
107
129
|
export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): FeatureDefinition {
|
|
130
|
+
// One-shot operator warning (the export cron fires every minute — warn once
|
|
131
|
+
// per process, not every run). Lives in the factory scope so the cron closure
|
|
132
|
+
// shares it across runs.
|
|
133
|
+
let warnedMissingExportUrl = false;
|
|
108
134
|
return defineFeature("user-data-rights", (r) => {
|
|
109
135
|
r.describe(
|
|
110
136
|
'Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion`, plus the anonymous email-verified `request-deletion-by-email` + `confirm-deletion-by-token` flow for lockout-safe self-service, + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` \u2192 ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })` \u2014 the feature then orchestrates the export and forget pipelines across all registered hooks automatically. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.',
|
|
@@ -162,11 +188,12 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
162
188
|
|
|
163
189
|
// S2.U5a — Endpoints fuer DSGVO Art. 17 Forget-Pfad mit Grace.
|
|
164
190
|
r.writeHandler(
|
|
165
|
-
createRequestDeletionHandler(
|
|
166
|
-
opts.sendDeletionRequestedEmail
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
191
|
+
createRequestDeletionHandler({
|
|
192
|
+
...(opts.sendDeletionRequestedEmail && {
|
|
193
|
+
sendDeletionRequestedEmail: opts.sendDeletionRequestedEmail,
|
|
194
|
+
}),
|
|
195
|
+
...(opts.mailDefaults && { mailDefaults: opts.mailDefaults }),
|
|
196
|
+
}),
|
|
170
197
|
);
|
|
171
198
|
r.writeHandler(cancelDeletionWrite);
|
|
172
199
|
|
|
@@ -222,6 +249,16 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
222
249
|
r.queryHandler(myAuditLogQuery);
|
|
223
250
|
r.queryHandler(listDownloadAttemptsQuery);
|
|
224
251
|
|
|
252
|
+
// Read-only operator inspector over the GDPR read-models (SystemAdmin).
|
|
253
|
+
// Convention list/detail handlers so entityList/entityEdit resolve by QN;
|
|
254
|
+
// the screens stay inert until an app navs them (opt-in at wire time).
|
|
255
|
+
r.queryHandler(exportJobListQuery);
|
|
256
|
+
r.queryHandler(exportJobDetailQuery);
|
|
257
|
+
r.queryHandler(downloadAttemptListQuery);
|
|
258
|
+
r.screen(exportJobListScreen);
|
|
259
|
+
r.screen(exportJobDetailScreen);
|
|
260
|
+
r.screen(downloadAttemptListScreen);
|
|
261
|
+
|
|
225
262
|
// Dormant Self-Service-Screen (Art. 15/17/18/20): Export, Aktivitäts-
|
|
226
263
|
// protokoll, Einschränkung, Löschung in einem Screen. Kein r.nav — die
|
|
227
264
|
// App platziert ihn im eingeloggten Bereich. Die React-Component kommt
|
|
@@ -289,6 +326,44 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
289
326
|
const exportUserId = ctx._userId ?? SYSTEM_USER_ID;
|
|
290
327
|
const exportDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
|
|
291
328
|
const exportRegistry = ctx.registry;
|
|
329
|
+
|
|
330
|
+
// C6 — ohne eigene send*Email-Opts aber mit gemountetem mail-transport
|
|
331
|
+
// versendet der Cron die Export-Notifications selbst (Default-Templates).
|
|
332
|
+
// Der Resolver baut den per-Tenant-Transport aus configResolver — gleiche
|
|
333
|
+
// Bruecke wie buildStorageProvider.
|
|
334
|
+
const exportMailResolver = isMailTransportAvailable(exportRegistry)
|
|
335
|
+
? makeTenantMailTransportResolver({
|
|
336
|
+
registry: exportRegistry,
|
|
337
|
+
configResolver: ctx.configResolver,
|
|
338
|
+
secrets: ctx.secrets,
|
|
339
|
+
db: exportDb,
|
|
340
|
+
userId: exportUserId,
|
|
341
|
+
handlerName: "user-data-rights:run-export-jobs",
|
|
342
|
+
})
|
|
343
|
+
: undefined;
|
|
344
|
+
const sendExportReadyEmail =
|
|
345
|
+
opts.sendExportReadyEmail ??
|
|
346
|
+
(exportMailResolver && opts.appExportDownloadUrl !== undefined
|
|
347
|
+
? makeDefaultExportReadyEmail(exportMailResolver, opts.mailDefaults)
|
|
348
|
+
: undefined);
|
|
349
|
+
const sendExportFailedEmail =
|
|
350
|
+
opts.sendExportFailedEmail ??
|
|
351
|
+
(exportMailResolver
|
|
352
|
+
? makeDefaultExportFailedEmail(exportMailResolver, opts.mailDefaults)
|
|
353
|
+
: undefined);
|
|
354
|
+
if (
|
|
355
|
+
exportMailResolver &&
|
|
356
|
+
!opts.sendExportReadyEmail &&
|
|
357
|
+
opts.appExportDownloadUrl === undefined &&
|
|
358
|
+
!warnedMissingExportUrl
|
|
359
|
+
) {
|
|
360
|
+
warnedMissingExportUrl = true;
|
|
361
|
+
// biome-ignore lint/suspicious/noConsole: one-shot operator visibility for misconfig
|
|
362
|
+
console.warn(
|
|
363
|
+
"[user-data-rights:run-export-jobs] mail transport mounted but appExportDownloadUrl unset — default export-ready emails disabled (export-failed + deletion mails still send)",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
292
367
|
await runExportJobs({
|
|
293
368
|
db: exportDb,
|
|
294
369
|
registry: exportRegistry,
|
|
@@ -305,15 +380,11 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
305
380
|
handlerName: "user-data-rights:run-export-jobs",
|
|
306
381
|
}),
|
|
307
382
|
now: T.Now.instant(),
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
// export-status.query
|
|
311
|
-
...(
|
|
312
|
-
|
|
313
|
-
}),
|
|
314
|
-
...(opts.sendExportFailedEmail && {
|
|
315
|
-
sendExportFailedEmail: opts.sendExportFailedEmail,
|
|
316
|
-
}),
|
|
383
|
+
// App-Author-Callbacks haben Vorrang; sonst greift die mail-foundation-
|
|
384
|
+
// Default oben. Beide optional: ohne mail-transport + ohne Callback
|
|
385
|
+
// bleibt es bei Polling via export-status.query.
|
|
386
|
+
...(sendExportReadyEmail && { sendExportReadyEmail }),
|
|
387
|
+
...(sendExportFailedEmail && { sendExportFailedEmail }),
|
|
317
388
|
...(opts.appExportDownloadUrl !== undefined && {
|
|
318
389
|
appExportDownloadUrl: opts.appExportDownloadUrl,
|
|
319
390
|
}),
|
|
@@ -338,30 +409,48 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
338
409
|
const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
|
|
339
410
|
const forgetUserId = ctx._userId ?? SYSTEM_USER_ID;
|
|
340
411
|
const forgetDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
|
|
412
|
+
const forgetRegistry = ctx.registry;
|
|
341
413
|
const tenantModel = await resolveAppTenantModel({
|
|
342
|
-
registry:
|
|
414
|
+
registry: forgetRegistry,
|
|
343
415
|
configResolver: ctx.configResolver,
|
|
344
416
|
db: forgetDb,
|
|
345
417
|
userId: forgetUserId,
|
|
346
418
|
});
|
|
419
|
+
|
|
420
|
+
// C6 — Default-Mail beim delete-flip wenn kein Callback gesetzt + ein
|
|
421
|
+
// mail-transport gemountet ist (gleiche per-Tenant-Bruecke wie Export).
|
|
422
|
+
const forgetMailResolver = isMailTransportAvailable(forgetRegistry)
|
|
423
|
+
? makeTenantMailTransportResolver({
|
|
424
|
+
registry: forgetRegistry,
|
|
425
|
+
configResolver: ctx.configResolver,
|
|
426
|
+
secrets: ctx.secrets,
|
|
427
|
+
db: forgetDb,
|
|
428
|
+
userId: forgetUserId,
|
|
429
|
+
handlerName: "user-data-rights:run-forget-cleanup",
|
|
430
|
+
})
|
|
431
|
+
: undefined;
|
|
432
|
+
const sendDeletionExecutedEmail =
|
|
433
|
+
opts.sendDeletionExecutedEmail ??
|
|
434
|
+
(forgetMailResolver
|
|
435
|
+
? makeDefaultDeletionExecutedEmail(forgetMailResolver, opts.mailDefaults)
|
|
436
|
+
: undefined);
|
|
437
|
+
|
|
347
438
|
await runForgetCleanup({
|
|
348
439
|
db: forgetDb,
|
|
349
|
-
registry:
|
|
440
|
+
registry: forgetRegistry,
|
|
350
441
|
now: T.Now.instant(),
|
|
351
442
|
tenantModel,
|
|
352
443
|
// Same per-tenant provider resolution as the export cron — forget
|
|
353
444
|
// deletes binaries from the store upload + export use.
|
|
354
445
|
buildStorageProvider: makeTenantStorageProviderResolver({
|
|
355
|
-
registry:
|
|
446
|
+
registry: forgetRegistry,
|
|
356
447
|
configResolver: ctx.configResolver,
|
|
357
448
|
secrets: ctx.secrets,
|
|
358
449
|
db: forgetDb,
|
|
359
450
|
userId: forgetUserId,
|
|
360
451
|
handlerName: "user-data-rights:run-forget-cleanup",
|
|
361
452
|
}),
|
|
362
|
-
...(
|
|
363
|
-
sendDeletionExecutedEmail: opts.sendDeletionExecutedEmail,
|
|
364
|
-
}),
|
|
453
|
+
...(sendDeletionExecutedEmail && { sendDeletionExecutedEmail }),
|
|
365
454
|
});
|
|
366
455
|
},
|
|
367
456
|
);
|
|
@@ -9,7 +9,12 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
|
9
9
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
10
10
|
|
|
11
11
|
export type StartGracePeriodResult =
|
|
12
|
-
| {
|
|
12
|
+
| {
|
|
13
|
+
readonly ok: true;
|
|
14
|
+
readonly gracePeriodEnd: Instant;
|
|
15
|
+
readonly userEmail: string;
|
|
16
|
+
readonly userLocale: string | null;
|
|
17
|
+
}
|
|
13
18
|
| { readonly ok: false; readonly error: UnprocessableError };
|
|
14
19
|
|
|
15
20
|
// Flippt einen aktiven User auf DeletionRequested + setzt gracePeriodEnd aus
|
|
@@ -26,9 +31,11 @@ export async function startDeletionGracePeriod(
|
|
|
26
31
|
userId: string,
|
|
27
32
|
complianceTenantId: string,
|
|
28
33
|
): Promise<StartGracePeriodResult> {
|
|
29
|
-
const userRow = await fetchOne<{ status: string; email: string }>(
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
const userRow = await fetchOne<{ status: string; email: string; locale: string | null }>(
|
|
35
|
+
ctx.db.raw,
|
|
36
|
+
userTable,
|
|
37
|
+
{ id: userId },
|
|
38
|
+
);
|
|
32
39
|
if (!userRow) {
|
|
33
40
|
return {
|
|
34
41
|
ok: false,
|
|
@@ -63,5 +70,10 @@ export async function startDeletionGracePeriod(
|
|
|
63
70
|
gracePeriodEnd,
|
|
64
71
|
});
|
|
65
72
|
|
|
66
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
gracePeriodEnd,
|
|
76
|
+
userEmail: userRow["email"] ?? "",
|
|
77
|
+
userLocale: userRow["locale"] ?? null,
|
|
78
|
+
};
|
|
67
79
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { access, defineEntityListHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { downloadAttemptEntity } from "../schema/download-attempt";
|
|
3
|
+
|
|
4
|
+
// SystemAdmin operator view of invalid download attempts (DPO brute-force
|
|
5
|
+
// triage). A bespoke list-download-attempts query already exists but sits on a
|
|
6
|
+
// non-convention QN; entityList needs the convention `download-attempt:list`.
|
|
7
|
+
export const downloadAttemptListQuery = defineEntityListHandler(
|
|
8
|
+
"download-attempt",
|
|
9
|
+
downloadAttemptEntity,
|
|
10
|
+
{ access: { roles: access.systemAdmin } },
|
|
11
|
+
);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { access, defineEntityDetailHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { exportJobEntity } from "../schema/export-job";
|
|
3
|
+
|
|
4
|
+
// Detail fetch backing the read-only export-job inspector screen.
|
|
5
|
+
export const exportJobDetailQuery = defineEntityDetailHandler("export-job", exportJobEntity, {
|
|
6
|
+
access: { roles: access.systemAdmin },
|
|
7
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { access, defineEntityListHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { exportJobEntity } from "../schema/export-job";
|
|
3
|
+
|
|
4
|
+
// SystemAdmin operator view of GDPR Art. 20 export jobs. Read-only inspector —
|
|
5
|
+
// rows are created by the user's request-export flow, never through this handler.
|
|
6
|
+
export const exportJobListQuery = defineEntityListHandler("export-job", exportJobEntity, {
|
|
7
|
+
access: { roles: access.systemAdmin },
|
|
8
|
+
});
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
1
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { USER_STATUS } from "../../user";
|
|
6
|
+
import {
|
|
7
|
+
type GdprMailDefaults,
|
|
8
|
+
isMailTransportAvailable,
|
|
9
|
+
makeDefaultDeletionRequestedEmail,
|
|
10
|
+
} from "../lib/default-mailers";
|
|
5
11
|
import { startDeletionGracePeriod } from "./deletion-grace-period";
|
|
6
12
|
|
|
7
13
|
// Atom 5b — Email-Notification beim deletion-requested-flip. Pattern:
|
|
@@ -14,12 +20,18 @@ import { startDeletionGracePeriod } from "./deletion-grace-period";
|
|
|
14
20
|
export type SendDeletionRequestedEmailFn = (args: {
|
|
15
21
|
readonly userId: string;
|
|
16
22
|
readonly userEmail: string;
|
|
23
|
+
/** Stored user.locale (free-form) — lets the default mailer render in the
|
|
24
|
+
* recipient's language. */
|
|
25
|
+
readonly userLocale: string | null;
|
|
17
26
|
readonly tenantId: string;
|
|
18
27
|
readonly gracePeriodEnd: string;
|
|
19
28
|
}) => Promise<void>;
|
|
20
29
|
|
|
21
30
|
export type RequestDeletionOptions = {
|
|
22
31
|
readonly sendDeletionRequestedEmail?: SendDeletionRequestedEmailFn;
|
|
32
|
+
/** Branding fuer die Default-Mail wenn kein sendDeletionRequestedEmail
|
|
33
|
+
* gesetzt ist + mail-foundation gemountet ist. */
|
|
34
|
+
readonly mailDefaults?: GdprMailDefaults;
|
|
23
35
|
};
|
|
24
36
|
|
|
25
37
|
// POST /api/user/request-deletion (S2.U5a) — DSGVO Art. 17 Forget-Antrag.
|
|
@@ -35,15 +47,29 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
|
|
|
35
47
|
handler: async (event, ctx) => {
|
|
36
48
|
const res = await startDeletionGracePeriod(ctx, event.user.id, event.user.tenantId);
|
|
37
49
|
if (!res.ok) return writeFailure(res.error);
|
|
38
|
-
const { gracePeriodEnd, userEmail } = res;
|
|
50
|
+
const { gracePeriodEnd, userEmail, userLocale } = res;
|
|
51
|
+
|
|
52
|
+
// App-Callback hat Vorrang; sonst greift die mail-foundation-Default-Mail
|
|
53
|
+
// (Request-Lane → ctx.config vorhanden, createTransportForTenant direkt).
|
|
54
|
+
// Ohne gemounteten mail-transport bleibt send undefined → keine Mail.
|
|
55
|
+
const send =
|
|
56
|
+
opts.sendDeletionRequestedEmail ??
|
|
57
|
+
(isMailTransportAvailable(ctx.registry)
|
|
58
|
+
? makeDefaultDeletionRequestedEmail(
|
|
59
|
+
(tenantId) =>
|
|
60
|
+
createTransportForTenant(ctx, tenantId, "user-data-rights:request-deletion"),
|
|
61
|
+
opts.mailDefaults,
|
|
62
|
+
)
|
|
63
|
+
: undefined);
|
|
39
64
|
|
|
40
65
|
// Best-effort Email-Notification. Send-Failure darf das Write nicht
|
|
41
66
|
// killen — siehe Type-Doc oben.
|
|
42
|
-
if (
|
|
67
|
+
if (send && userEmail.length > 0) {
|
|
43
68
|
try {
|
|
44
|
-
await
|
|
69
|
+
await send({
|
|
45
70
|
userId: event.user.id,
|
|
46
71
|
userEmail,
|
|
72
|
+
userLocale,
|
|
47
73
|
tenantId: event.user.tenantId,
|
|
48
74
|
gracePeriodEnd: gracePeriodEnd.toString(),
|
|
49
75
|
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Default send*Email-Callbacks fuer die GDPR-Notifications, backed by einem
|
|
2
|
+
// mail-transport (mail-foundation). Damit muss eine App keinen Callback-Code
|
|
3
|
+
// schreiben: sie mountet mail-foundation + einen mail-transport-* und setzt
|
|
4
|
+
// fuer Export-Mails appExportDownloadUrl — fertig. Uebergibt sie eigene
|
|
5
|
+
// send*Email-Opts, greifen diese Defaults nicht (Opt-Override im feature.ts).
|
|
6
|
+
//
|
|
7
|
+
// Jeder Callback rendert das Template (email-templates.ts) und versendet ueber
|
|
8
|
+
// den per-Tenant aufgeloesten EmailTransport. `resolveTransport` kommt im Job-
|
|
9
|
+
// Lane aus makeTenantMailTransportResolver, im Request-Lane direkt aus
|
|
10
|
+
// createTransportForTenant(ctx, ...).
|
|
11
|
+
|
|
12
|
+
import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
13
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import {
|
|
15
|
+
type GdprMailLocale,
|
|
16
|
+
normalizeGdprMailLocale,
|
|
17
|
+
renderDeletionExecutedEmail,
|
|
18
|
+
renderDeletionRequestedEmail,
|
|
19
|
+
renderExportFailedEmail,
|
|
20
|
+
renderExportReadyEmail,
|
|
21
|
+
} from "../email-templates";
|
|
22
|
+
import type { SendDeletionRequestedEmailFn } from "../handlers/request-deletion.write";
|
|
23
|
+
import type { SendExportFailedEmailFn, SendExportReadyEmailFn } from "../run-export-jobs";
|
|
24
|
+
import type { SendDeletionExecutedEmailFn } from "../run-forget-cleanup";
|
|
25
|
+
|
|
26
|
+
export type GdprMailDefaults = {
|
|
27
|
+
readonly locale?: GdprMailLocale;
|
|
28
|
+
readonly appName?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// mail-foundation ist eine Soft-Dep von user-data-rights. Die Default-Mailer
|
|
32
|
+
// greifen nur wenn mindestens ein mail-transport-* registriert ist — sonst
|
|
33
|
+
// kann ohnehin nichts versendet werden und die App bleibt beim bisherigen
|
|
34
|
+
// "kein Callback → keine Email"-Verhalten.
|
|
35
|
+
export function isMailTransportAvailable(registry: Registry): boolean {
|
|
36
|
+
return registry.getExtensionUsages("mailTransport").length > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type TransportResolver = (tenantId: string) => Promise<EmailTransport>;
|
|
40
|
+
|
|
41
|
+
// Per-recipient locale wins (user.locale); mailDefaults.locale is the fallback
|
|
42
|
+
// for unknown/unsupported user.locale values; the template itself defaults to en.
|
|
43
|
+
function localeFor(
|
|
44
|
+
userLocale: string | null,
|
|
45
|
+
defaults: GdprMailDefaults,
|
|
46
|
+
): GdprMailLocale | undefined {
|
|
47
|
+
return normalizeGdprMailLocale(userLocale) ?? defaults.locale;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function makeDefaultExportReadyEmail(
|
|
51
|
+
resolveTransport: TransportResolver,
|
|
52
|
+
defaults: GdprMailDefaults = {},
|
|
53
|
+
): SendExportReadyEmailFn {
|
|
54
|
+
return async (args) => {
|
|
55
|
+
const transport = await resolveTransport(args.tenantId);
|
|
56
|
+
const { subject, html } = renderExportReadyEmail({
|
|
57
|
+
downloadUrl: args.downloadUrl,
|
|
58
|
+
expiresAt: args.expiresAt,
|
|
59
|
+
locale: localeFor(args.userLocale, defaults),
|
|
60
|
+
appName: defaults.appName,
|
|
61
|
+
});
|
|
62
|
+
await transport.send({ to: args.userEmail, subject, html });
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function makeDefaultExportFailedEmail(
|
|
67
|
+
resolveTransport: TransportResolver,
|
|
68
|
+
defaults: GdprMailDefaults = {},
|
|
69
|
+
): SendExportFailedEmailFn {
|
|
70
|
+
return async (args) => {
|
|
71
|
+
const transport = await resolveTransport(args.tenantId);
|
|
72
|
+
const { subject, html } = renderExportFailedEmail({
|
|
73
|
+
locale: localeFor(args.userLocale, defaults),
|
|
74
|
+
appName: defaults.appName,
|
|
75
|
+
});
|
|
76
|
+
await transport.send({ to: args.userEmail, subject, html });
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function makeDefaultDeletionRequestedEmail(
|
|
81
|
+
resolveTransport: TransportResolver,
|
|
82
|
+
defaults: GdprMailDefaults = {},
|
|
83
|
+
): SendDeletionRequestedEmailFn {
|
|
84
|
+
return async (args) => {
|
|
85
|
+
const transport = await resolveTransport(args.tenantId);
|
|
86
|
+
const { subject, html } = renderDeletionRequestedEmail({
|
|
87
|
+
gracePeriodEnd: args.gracePeriodEnd,
|
|
88
|
+
locale: localeFor(args.userLocale, defaults),
|
|
89
|
+
appName: defaults.appName,
|
|
90
|
+
});
|
|
91
|
+
await transport.send({ to: args.userEmail, subject, html });
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function makeDefaultDeletionExecutedEmail(
|
|
96
|
+
resolveTransport: TransportResolver,
|
|
97
|
+
defaults: GdprMailDefaults = {},
|
|
98
|
+
): SendDeletionExecutedEmailFn {
|
|
99
|
+
return async (args) => {
|
|
100
|
+
// Der User ist global, der Mail-Transport per-Tenant — wir senden ueber den
|
|
101
|
+
// Transport des ersten Memberships. Orphan-User (0 Memberships) liefert
|
|
102
|
+
// keine Tenant-Identitaet → kein Transport aufloesbar, Mail entfaellt.
|
|
103
|
+
const tenantId = args.tenantIds[0];
|
|
104
|
+
if (tenantId === undefined) {
|
|
105
|
+
// skip: orphan user has no tenant whose transport could send the mail
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const transport = await resolveTransport(tenantId);
|
|
109
|
+
const { subject, html } = renderDeletionExecutedEmail({
|
|
110
|
+
executedAt: args.executedAt,
|
|
111
|
+
locale: localeFor(args.userLocale, defaults),
|
|
112
|
+
appName: defaults.appName,
|
|
113
|
+
});
|
|
114
|
+
await transport.send({ to: args.userEmail, subject, html });
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Builds a per-tenant mail-transport resolver from a job/handler context, so
|
|
2
|
+
// the GDPR notification crons reach the SAME mounted mail-foundation transport
|
|
3
|
+
// the request path uses. Mirrors makeTenantStorageProviderResolver — the cron
|
|
4
|
+
// ctx carries `configResolver` (the per-request ConfigAccessor exists only in
|
|
5
|
+
// the HTTP dispatcher), so the resolver builds a per-tenant accessor from it.
|
|
6
|
+
// Without that bridge createTransportForTenant throws "ctx.config is missing"
|
|
7
|
+
// in the worker lane (the prod-bug class the file-provider resolver already fixed).
|
|
8
|
+
|
|
9
|
+
import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
10
|
+
import { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
11
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import type { ConfigResolver, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
14
|
+
import { createConfigAccessor } from "../../config";
|
|
15
|
+
|
|
16
|
+
export interface TenantMailResolverCtx {
|
|
17
|
+
readonly registry: Registry;
|
|
18
|
+
// Job-context carries configResolver (per-request ConfigAccessor exists only
|
|
19
|
+
// in the HTTP dispatcher); the resolver builds a per-tenant accessor from it.
|
|
20
|
+
// Undefined → the returned resolver throws (callers gate on mail-availability).
|
|
21
|
+
readonly configResolver: ConfigResolver | undefined;
|
|
22
|
+
readonly secrets: SecretsContext | undefined;
|
|
23
|
+
readonly db: DbConnection;
|
|
24
|
+
readonly userId: string;
|
|
25
|
+
readonly handlerName: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function makeTenantMailTransportResolver(
|
|
29
|
+
ctx: TenantMailResolverCtx,
|
|
30
|
+
): (tenantId: string) => Promise<EmailTransport> {
|
|
31
|
+
return async (tenantId) => {
|
|
32
|
+
if (!ctx.configResolver) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`${ctx.handlerName}: ctx.configResolver missing — cannot resolve the mail transport for tenant ${tenantId}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const config = createConfigAccessor(
|
|
38
|
+
ctx.registry,
|
|
39
|
+
ctx.configResolver,
|
|
40
|
+
tenantId as Parameters<typeof createConfigAccessor>[2], // @cast-boundary engine-payload: TenantId brand
|
|
41
|
+
ctx.userId,
|
|
42
|
+
ctx.db,
|
|
43
|
+
);
|
|
44
|
+
return createTransportForTenant(
|
|
45
|
+
{ config, registry: ctx.registry, secrets: ctx.secrets, _userId: ctx.userId },
|
|
46
|
+
tenantId,
|
|
47
|
+
ctx.handlerName,
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -110,6 +110,9 @@ export const tokenCrud = createEventStoreExecutor(
|
|
|
110
110
|
export type SendExportReadyEmailFn = (args: {
|
|
111
111
|
readonly userId: string;
|
|
112
112
|
readonly userEmail: string;
|
|
113
|
+
/** Stored user.locale (free-form, e.g. "de"/"en"/"de-DE"/null) — lets the
|
|
114
|
+
* default mailer render in the recipient's language. */
|
|
115
|
+
readonly userLocale: string | null;
|
|
113
116
|
readonly tenantId: TenantId;
|
|
114
117
|
readonly jobId: string;
|
|
115
118
|
readonly downloadUrl: string;
|
|
@@ -120,6 +123,7 @@ export type SendExportReadyEmailFn = (args: {
|
|
|
120
123
|
export type SendExportFailedEmailFn = (args: {
|
|
121
124
|
readonly userId: string;
|
|
122
125
|
readonly userEmail: string;
|
|
126
|
+
readonly userLocale: string | null;
|
|
123
127
|
readonly tenantId: TenantId;
|
|
124
128
|
readonly jobId: string;
|
|
125
129
|
readonly errorMessage: string;
|
|
@@ -753,12 +757,17 @@ function countingStream(source: AsyncIterable<Uint8Array>): {
|
|
|
753
757
|
// (Alice in Tenant A+B) hat trotzdem nur 1 user-Row mit ihrer
|
|
754
758
|
// Heim-Tenant als tenantId. Lookup ohne tenantId-filter findet sie
|
|
755
759
|
// aus jedem Worker-Tenant-Context.
|
|
756
|
-
async function
|
|
760
|
+
async function lookupUserContact(
|
|
761
|
+
db: DbConnection,
|
|
762
|
+
userId: string,
|
|
763
|
+
): Promise<{ email: string; locale: string | null } | null> {
|
|
757
764
|
// @cast-boundary db-row.
|
|
758
765
|
const row = (await fetchOne(db, userTable, { id: userId })) as {
|
|
759
766
|
email: string | null;
|
|
767
|
+
locale: string | null;
|
|
760
768
|
} | null;
|
|
761
|
-
|
|
769
|
+
if (!row?.email) return null;
|
|
770
|
+
return { email: row.email, locale: row.locale ?? null };
|
|
762
771
|
}
|
|
763
772
|
|
|
764
773
|
// Atom 5 — Email-Notification beim done-flip. Best-effort:
|
|
@@ -776,8 +785,8 @@ async function fireExportReadyCallback(args: {
|
|
|
776
785
|
readonly appExportDownloadUrl: string | undefined;
|
|
777
786
|
readonly send: SendExportReadyEmailFn;
|
|
778
787
|
}): Promise<void> {
|
|
779
|
-
const
|
|
780
|
-
if (!
|
|
788
|
+
const contact = await lookupUserContact(args.db, args.job.userId);
|
|
789
|
+
if (!contact) {
|
|
781
790
|
// User-Row fehlt (z.B. forget-Pfad mid-export). Skip-Notification mit
|
|
782
791
|
// Operator-Alert via job-run-Log statt Throw — Job bleibt done, User
|
|
783
792
|
// hat ja seinen Token via export-status.query erreichbar (UI-Pfad).
|
|
@@ -802,7 +811,8 @@ async function fireExportReadyCallback(args: {
|
|
|
802
811
|
const downloadUrl = `${baseUrl}?token=${encodeURIComponent(args.plainToken)}`;
|
|
803
812
|
await args.send({
|
|
804
813
|
userId: args.job.userId,
|
|
805
|
-
userEmail,
|
|
814
|
+
userEmail: contact.email,
|
|
815
|
+
userLocale: contact.locale,
|
|
806
816
|
tenantId: args.job.requestedFromTenantId,
|
|
807
817
|
jobId: args.job.id,
|
|
808
818
|
downloadUrl,
|
|
@@ -818,8 +828,8 @@ async function fireExportFailedCallback(args: {
|
|
|
818
828
|
readonly errorMessage: string;
|
|
819
829
|
readonly send: SendExportFailedEmailFn;
|
|
820
830
|
}): Promise<void> {
|
|
821
|
-
const
|
|
822
|
-
if (!
|
|
831
|
+
const contact = await lookupUserContact(args.db, args.job.userId);
|
|
832
|
+
if (!contact) {
|
|
823
833
|
// biome-ignore lint/suspicious/noConsole: operator-visibility
|
|
824
834
|
console.warn(
|
|
825
835
|
`[user-data-rights:run-export-jobs] userId=${args.job.userId} hat kein userEmail — sendExportFailedEmail skipped`,
|
|
@@ -829,7 +839,8 @@ async function fireExportFailedCallback(args: {
|
|
|
829
839
|
}
|
|
830
840
|
await args.send({
|
|
831
841
|
userId: args.job.userId,
|
|
832
|
-
userEmail,
|
|
842
|
+
userEmail: contact.email,
|
|
843
|
+
userLocale: contact.locale,
|
|
833
844
|
tenantId: args.job.requestedFromTenantId,
|
|
834
845
|
jobId: args.job.id,
|
|
835
846
|
errorMessage: args.errorMessage,
|
|
@@ -70,6 +70,9 @@ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
|
70
70
|
export type SendDeletionExecutedEmailFn = (args: {
|
|
71
71
|
readonly userId: string;
|
|
72
72
|
readonly userEmail: string;
|
|
73
|
+
/** Stored user.locale (free-form, cached PRE-tx alongside the email) — lets
|
|
74
|
+
* the default mailer render in the recipient's language. */
|
|
75
|
+
readonly userLocale: string | null;
|
|
73
76
|
readonly tenantIds: readonly TenantId[];
|
|
74
77
|
readonly executedAt: string;
|
|
75
78
|
}) => Promise<void>;
|
|
@@ -207,6 +210,7 @@ export async function runForgetCleanup(
|
|
|
207
210
|
await sendDeletionExecutedEmail({
|
|
208
211
|
userId: user.id,
|
|
209
212
|
userEmail: userResult.userEmailBeforeDelete,
|
|
213
|
+
userLocale: userResult.userLocaleBeforeDelete,
|
|
210
214
|
tenantIds: userResult.tenantIdsBeforeDelete,
|
|
211
215
|
executedAt: now.toString(),
|
|
212
216
|
});
|
|
@@ -231,6 +235,8 @@ interface ProcessUserResult {
|
|
|
231
235
|
* null wenn user-Row beim Pre-Tx-Lookup nicht (mehr) existiert oder
|
|
232
236
|
* email leer ist. */
|
|
233
237
|
readonly userEmailBeforeDelete: string | null;
|
|
238
|
+
/** user.locale VOR Tx gecacht — Default-Mailer rendert in Empfaenger-Sprache. */
|
|
239
|
+
readonly userLocaleBeforeDelete: string | null;
|
|
234
240
|
/** Tenant-Memberships VOR Tx — Email-Template kann das nutzen. */
|
|
235
241
|
readonly tenantIdsBeforeDelete: readonly TenantId[];
|
|
236
242
|
}
|
|
@@ -252,9 +258,12 @@ async function processUser(args: {
|
|
|
252
258
|
// Nach der Tx ist email = "deleted-{id}@{tenant}.example" oder NULL.
|
|
253
259
|
// Memory-cache laesst Atom-5b-Callback nach success-flip den
|
|
254
260
|
// ORIGINAL-email an App-Author-Callback geben.
|
|
255
|
-
const userPreTx = await fetchOne<{ email: string | null }>(db, userTable, {
|
|
261
|
+
const userPreTx = await fetchOne<{ email: string | null; locale: string | null }>(db, userTable, {
|
|
262
|
+
id: userId,
|
|
263
|
+
});
|
|
256
264
|
const userEmailBeforeDelete =
|
|
257
265
|
userPreTx?.email && userPreTx.email.length > 0 ? userPreTx.email : null;
|
|
266
|
+
const userLocaleBeforeDelete = userPreTx?.locale ?? null;
|
|
258
267
|
|
|
259
268
|
// Memberships fuer diesen User holen — alle Tenants in denen er Mitglied ist.
|
|
260
269
|
const memberships = await selectMany<{ tenantId: TenantId }>(db, tenantMembershipsTable, {
|
|
@@ -335,6 +344,7 @@ async function processUser(args: {
|
|
|
335
344
|
hookCallsAttempted,
|
|
336
345
|
errors,
|
|
337
346
|
userEmailBeforeDelete,
|
|
347
|
+
userLocaleBeforeDelete,
|
|
338
348
|
tenantIdsBeforeDelete,
|
|
339
349
|
};
|
|
340
350
|
}
|