@cosmicdrift/kumiko-samples 0.404.0 → 0.405.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +25 -25
  3. package/packages/bundled-features/src/account-security/README.md +7 -5
  4. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  5. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
  6. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
  7. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
  8. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  9. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
  10. package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
  11. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
  12. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
  13. package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
  14. package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
  15. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  16. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
  17. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
  18. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
  19. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
  20. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
  23. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
  24. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
  26. package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
  27. package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
  28. package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
  29. package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
  30. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
  31. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  32. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
  33. package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
  34. package/packages/bundled-features/src/documents/changes.json +8 -0
  35. package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
  36. package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
  37. package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
  38. package/packages/bundled-features/src/folders/feature.ts +2 -8
  39. package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
  40. package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
  41. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
  42. package/packages/bundled-features/src/host-routing/README.md +10 -10
  43. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
  44. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
  46. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
  47. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
  48. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
  49. package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
  50. package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
  51. package/packages/bundled-features/src/ledger/feature.ts +2 -7
  52. package/packages/bundled-features/src/legal-pages/README.md +21 -13
  53. package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
  54. package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
  55. package/packages/bundled-features/src/notes-history/feature.ts +7 -8
  56. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
  57. package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
  58. package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
  59. package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
  60. package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
  61. package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
  62. package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
  63. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
  64. package/packages/bundled-features/src/shared/changes.json +6 -0
  65. package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
  66. package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
  67. package/packages/bundled-features/src/shared/index.ts +6 -0
  68. package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
  69. package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
  70. package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
  71. package/packages/bundled-features/src/shared/signed-token.ts +4 -14
  72. package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
  73. package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
  74. package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
  75. package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
  76. package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
  77. package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
  78. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
  79. package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
  80. package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
  81. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
  82. package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
  83. package/packages/bundled-features/src/tags/feature.ts +2 -8
  84. package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
  85. package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
  86. package/packages/bundled-features/src/template-resolver/README.md +29 -29
  87. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
  88. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
  89. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
  90. package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
  91. package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
  92. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
  93. package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
  94. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  95. package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
  96. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
  97. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  98. package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
  99. package/packages/bundled-features/src/user-data-rights/README.md +33 -33
  100. package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
  101. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
  102. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
  103. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
  104. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
  105. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
  106. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
  107. package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
  108. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
  109. package/samples/apps/admin-console/README.md +5 -5
  110. package/samples/apps/admin-console/package.json +3 -3
  111. package/samples/apps/cap-billing-demo/README.md +15 -15
  112. package/samples/apps/config-demo/README.md +7 -7
  113. package/samples/apps/config-demo/package.json +2 -2
  114. package/samples/apps/marketing-demo/README.md +2 -2
  115. package/samples/apps/marketing-demo/package.json +2 -2
  116. package/samples/apps/showcase/package.json +3 -3
  117. package/samples/apps/styleguide/package.json +2 -2
  118. package/samples/apps/ui-walkthrough/README.md +8 -9
  119. package/samples/apps/ui-walkthrough/package.json +2 -2
  120. package/samples/apps/use-all-bundled/README.md +2 -2
  121. package/samples/apps/use-all-bundled/package.json +2 -2
  122. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
  123. package/samples/apps/user-data-rights-demo/README.md +12 -12
  124. package/samples/apps/workspaces/package.json +2 -2
  125. package/samples/recipes/apex-landing/README.md +3 -3
  126. package/samples/recipes/apex-landing/package.json +1 -1
  127. package/samples/recipes/apex-surface-auth/README.md +13 -9
  128. package/samples/recipes/auth-mfa/README.md +2 -4
  129. package/samples/recipes/auth-mfa/package.json +2 -2
  130. package/samples/recipes/boot-check/README.md +2 -2
  131. package/samples/recipes/compliance-profiles-demo/README.md +5 -5
  132. package/samples/recipes/compose-stacks/README.md +8 -8
  133. package/samples/recipes/cross-feature-events/README.md +1 -0
  134. package/samples/recipes/crypto-shredding-hr/README.md +3 -3
  135. package/samples/recipes/custom-fields-basic/README.md +1 -1
  136. package/samples/recipes/documents/README.md +25 -0
  137. package/samples/recipes/embedded-entity-form/package.json +2 -2
  138. package/samples/recipes/encrypted-tenant-config/README.md +8 -8
  139. package/samples/recipes/env-composition/README.md +10 -10
  140. package/samples/recipes/error-contract/README.md +9 -9
  141. package/samples/recipes/event-sourcing/README.md +9 -9
  142. package/samples/recipes/ledger-invoicing/README.md +6 -6
  143. package/samples/recipes/legal-pages/README.md +16 -16
  144. package/samples/recipes/managed-config/README.md +15 -11
  145. package/samples/recipes/managed-pages/README.md +7 -7
  146. package/samples/recipes/pdf-audit-export/package.json +1 -1
  147. package/samples/recipes/pipeline-basics/README.md +13 -13
  148. package/samples/recipes/public-share-token/README.md +4 -4
  149. package/samples/recipes/record-detail-layout/package.json +3 -3
  150. package/samples/recipes/security-baseline/README.md +4 -1
  151. package/samples/recipes/session-revocation/README.md +1 -1
  152. package/samples/recipes/tenant-settings/README.md +4 -4
  153. package/samples/recipes/tier-admin/README.md +2 -2
  154. package/samples/recipes/user-data-rights/README.md +21 -19
  155. package/samples/recipes/wizard-form/package.json +3 -3
  156. package/samples/recipes/writeform-section/package.json +3 -3
@@ -14,8 +14,11 @@ import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.ts";
14
14
  import { userTable } from "../../user/index.ts";
15
15
  import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table.ts";
16
16
  import { tenantMembershipsTable } from "../membership-table.ts";
17
-
18
- const KMS_POOL_CONCURRENCY = 4;
17
+ import {
18
+ decryptMemberUsers,
19
+ KMS_POOL_CONCURRENCY,
20
+ type MemberUserRow,
21
+ } from "./decrypt-member-users.ts";
19
22
 
20
23
  type TeamStatus = "active" | "pending";
21
24
 
@@ -115,11 +118,7 @@ export const teamListQuery = definePagedQueryHandler({
115
118
  const membershipRows = await selectMany(db, tenantMembershipsTable, { tenantId });
116
119
  const userIds = [...new Set(membershipRows.map((row) => String(row["userId"])))];
117
120
  const users =
118
- userIds.length > 0
119
- ? await selectMany<{ id: unknown; email?: unknown; displayName?: unknown }>(db, userTable, {
120
- id: userIds,
121
- })
122
- : [];
121
+ userIds.length > 0 ? await selectMany<MemberUserRow>(db, userTable, { id: userIds }) : [];
123
122
 
124
123
  const lastSeenByUserId = new Map<string, Temporal.Instant>();
125
124
  if (userIds.length > 0) {
@@ -147,21 +146,10 @@ export const teamListQuery = definePagedQueryHandler({
147
146
  }
148
147
 
149
148
  // Bounded, not Promise.all/sequential-per-item: shares the KMS
150
- // adapter's small dedicated pool (max 4, see members.query.ts) with the
149
+ // adapter's small dedicated pool (max 4, see decrypt-member-users.ts) with the
151
150
  // invitations decrypt below — the two passes run one after the other
152
151
  // (await, not Promise.all), so combined concurrency never exceeds 4.
153
- const decryptedUsers = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
154
- const email =
155
- typeof user.email === "string"
156
- ? await decryptStoredPii(user.email, "email", "tenant:team-list")
157
- : null;
158
- const displayName =
159
- typeof user.displayName === "string"
160
- ? await decryptStoredPii(user.displayName, "displayName", "tenant:team-list")
161
- : null;
162
- return { userId: String(user.id), email, displayName };
163
- });
164
- const decryptedByUserId = new Map(decryptedUsers.map((d) => [d.userId, d]));
152
+ const decryptedByUserId = await decryptMemberUsers(users, "tenant:team-list");
165
153
 
166
154
  const memberRows: TeamRow[] = membershipRows.map((row) => {
167
155
  const userId = String(row["userId"]);
@@ -0,0 +1,28 @@
1
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
4
+
5
+ export const TENANT_DESTROY_ARCHIVED_BY = "tenant-lifecycle:destroy";
6
+ export const TENANT_DESTROY_ARCHIVE_REASON = "tenant_destroy";
7
+
8
+ // Archiving first means a later projection rebuild cannot resurrect the deleted rows.
9
+ export async function archiveAndDeleteTenantRows(
10
+ ctx: TenantDataHookCtx,
11
+ table: EntityTableMeta,
12
+ aggregateType: string,
13
+ escapeHatchReason: string,
14
+ ): Promise<readonly string[]> {
15
+ declareEscapeHatch({ reason: escapeHatchReason });
16
+ const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
17
+ for (const row of rows) {
18
+ await archiveStream(ctx.db.unsafeRaw(), {
19
+ tenantId: ctx.tenantId,
20
+ aggregateId: row.id,
21
+ aggregateType,
22
+ archivedBy: TENANT_DESTROY_ARCHIVED_BY,
23
+ reason: TENANT_DESTROY_ARCHIVE_REASON,
24
+ });
25
+ }
26
+ await ctx.db.deleteMany(table, { tenantId: ctx.tenantId });
27
+ return rows.map((row) => row.id);
28
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.405.0",
4
+ "type": "improvement",
5
+ "title": "archiveAndDeleteTenantRows for tenant-destroy hooks",
6
+ "detail": "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle exports archiveAndDeleteTenantRows(ctx, table, aggregateType, escapeHatchReason), which archives each row's event stream and then deletes the tenant's rows, plus the TENANT_DESTROY_ARCHIVED_BY and TENANT_DESTROY_ARCHIVE_REASON constants. inbound-mail-foundation uses the helper, billing-foundation the constants."
7
+ },
2
8
  {
3
9
  "version": "0.389.0",
4
10
  "type": "fix",
@@ -1,3 +1,8 @@
1
+ export {
2
+ archiveAndDeleteTenantRows,
3
+ TENANT_DESTROY_ARCHIVE_REASON,
4
+ TENANT_DESTROY_ARCHIVED_BY,
5
+ } from "./archive-and-delete-tenant-rows.ts";
1
6
  export {
2
7
  TENANT_DESTRUCTION_STAGES,
3
8
  TENANT_LIFECYCLE_FEATURE,
@@ -92,6 +92,7 @@ const tenantMembershipCrud = createEventStoreExecutor(
92
92
  // earlier hook that keeps reporting done:false can use up the budget, so later chunking
93
93
  // hooks time out with nothing processed until it finishes.
94
94
  function newOutcomeAccumulator(): {
95
+ // biome-ignore lint/suspicious/noConfusingVoidType: takes the awaited result of a destroy hook, whose public type allows void
95
96
  add: (result: TenantDestroyHookResult | void) => void;
96
97
  outcome: () => StageRunOutcome;
97
98
  } {
@@ -495,7 +495,7 @@ export function createTierEngineFeature<
495
495
  // point AND registers itself as the default plugin. Other features could
496
496
  // register there later (e.g. a future toggle-only resolver), but only one
497
497
  // plugin wins — see the "single-plugin" comment on findTierResolverUsage.
498
- r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
498
+ r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, {});
499
499
 
500
500
  // Plugin-registration: framework's runDevApp/runProdApp picks this up
501
501
  // post-stack-setup, calls build(deps), wires effectiveFeatures.
@@ -16,34 +16,34 @@ Dieses Dokument bündelt die **technischen Fakten** zur DSGVO-Pipeline
16
16
 
17
17
  ## 1. Endpoint → Artikel-Mapping
18
18
 
19
- | Artikel | Endpoint / Runner | Wer ruft auf | Was passiert |
20
- |---------|-------------------|--------------|--------------|
21
- | **Art. 15 (Auskunft, light)** | `user-data-rights:query:my-audit-log` | User | Liest eigene Framework-Events aus `kumiko_events` (account-weit über alle Memberships). Filterbar nach `eventType`, `aggregateType`, `from`/`to`. Domain-Entities ohne `ctx.events.append` erscheinen NICHT — die kommen im Export-Bundle (Art. 20). |
22
- | **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export` | User | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten (cross-tenant) + signed Magic-Link per Email. Idempotent: nur 1 active Job pro User (`ACTIVE_JOB_CONSTRAINT`). |
23
- | **Art. 15 + 20** | `GET /user-export/by-token` (interstitial) + `POST /user-export/by-token` (exchange) | Anonym (Magic-Link-Pfad) | Email-Link trägt den Token als URL-Fragment (`#token=`, nie an den Server gesendet, issue #1271); Interstitial-Script liest das Fragment client-seitig und tauscht es per POST-Body gegen die signed Storage-URL. Kein `?token=`-Query-Fallback (#1512) — Query-Param landet unveränderlich in Proxy-/Access-Logs. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
24
- | **Art. 15 + 20** | `GET /user-export/by-job/:jobId` | User (Session-Auth) | UI-Klick-Pfad. Cross-tenant-same-user: User in Tenant B kann Job aus Tenant A laden wenn er der Owner ist. |
25
- | **Art. 17 (Löschung)** | `user-data-rights:write:request-deletion` | User | Soft-Delete: `status=DeletionRequested` + `gracePeriodEnd = now + profile.gracePeriod`. Cron `run-forget-cleanup` führt nach Grace die Anonymisierung durch. |
26
- | **Art. 17** | `user-data-rights:write:cancel-deletion` | User | Während Grace: status zurück auf `Active`. |
27
- | **Art. 17** | `run-forget-cleanup` (Cron) | System | Findet User mit `status=DeletionRequested AND gracePeriodEnd < now`. Pro User Sub-TX über alle EXT_USER_DATA-Provider mit Strategy aus `retention.policyFor`. user-Hook anonymisiert (PII raus, Sentinel-Email). |
28
- | **Art. 18 (Restriction)** | `user-data-rights:write:restrict-account` | Admin / SystemAdmin | Status-Flip → Auth-Middleware-Guard blockt Logins. `sessions.revokeAllForUser` killt aktive Sessions. |
29
- | **Art. 18** | `user-data-rights:write:lift-restriction` | Admin / SystemAdmin | Restriction aufheben. |
30
- | **Operator (DPO)** | `user-data-rights:query:list-download-attempts` | Admin / SystemAdmin | Brute-Force-Detection: zeigt invalid Download-Versuche (notFound / expired / failed / signedUrlNotSupported) gefiltert nach Result, IP, Zeitraum. |
19
+ | Artikel | Endpoint / Runner | Wer ruft auf | Was passiert |
20
+ | ------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | **Art. 15 (Auskunft, light)** | `user-data-rights:query:my-audit-log` | User | Liest eigene Framework-Events aus `kumiko_events` (account-weit über alle Memberships). Filterbar nach `eventType`, `aggregateType`, `from`/`to`. Domain-Entities ohne `ctx.events.append` erscheinen NICHT — die kommen im Export-Bundle (Art. 20). |
22
+ | **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export` | User | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten (cross-tenant) + signed Magic-Link per Email. Idempotent: nur 1 active Job pro User (`ACTIVE_JOB_CONSTRAINT`). |
23
+ | **Art. 15 + 20** | `GET /user-export/by-token` (interstitial) + `POST /user-export/by-token` (exchange) | Anonym (Magic-Link-Pfad) | Email-Link trägt den Token als URL-Fragment (`#token=`, nie an den Server gesendet, issue #1271); Interstitial-Script liest das Fragment client-seitig und tauscht es per POST-Body gegen die signed Storage-URL. Kein `?token=`-Query-Fallback (#1512) — Query-Param landet unveränderlich in Proxy-/Access-Logs. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
24
+ | **Art. 15 + 20** | `GET /user-export/by-job/:jobId` | User (Session-Auth) | UI-Klick-Pfad. Cross-tenant-same-user: User in Tenant B kann Job aus Tenant A laden wenn er der Owner ist. |
25
+ | **Art. 17 (Löschung)** | `user-data-rights:write:request-deletion` | User | Soft-Delete: `status=DeletionRequested` + `gracePeriodEnd = now + profile.gracePeriod`. Cron `run-forget-cleanup` führt nach Grace die Anonymisierung durch. |
26
+ | **Art. 17** | `user-data-rights:write:cancel-deletion` | User | Während Grace: status zurück auf `Active`. |
27
+ | **Art. 17** | `run-forget-cleanup` (Cron) | System | Findet User mit `status=DeletionRequested AND gracePeriodEnd < now`. Pro User Sub-TX über alle EXT_USER_DATA-Provider mit Strategy aus `retention.policyFor`. user-Hook anonymisiert (PII raus, Sentinel-Email). |
28
+ | **Art. 18 (Restriction)** | `user-data-rights:write:restrict-account` | Admin / SystemAdmin | Status-Flip → Auth-Middleware-Guard blockt Logins. `sessions.revokeAllForUser` killt aktive Sessions. |
29
+ | **Art. 18** | `user-data-rights:write:lift-restriction` | Admin / SystemAdmin | Restriction aufheben. |
30
+ | **Operator (DPO)** | `user-data-rights:query:list-download-attempts` | Admin / SystemAdmin | Brute-Force-Detection: zeigt invalid Download-Versuche (notFound / expired / failed / signedUrlNotSupported) gefiltert nach Result, IP, Zeitraum. |
31
31
 
32
32
  ---
33
33
 
34
34
  ## 2. Speicherorte (Verarbeitungsverzeichnis Art. 30)
35
35
 
36
- | Tabelle | Inhalt | Personenbezug | Retention | Zweck |
37
- |---------|--------|---------------|-----------|-------|
38
- | `read_users` | User-Profil (email, displayName, passwordHash, locale, status, gracePeriodEnd, roles) | direkt | per Domain (typ. blockDelete bei Aufbewahrungspflicht, sonst hardDelete via Forget-Pipeline) | Authentifizierung, Nutzer-Profil |
39
- | `read_tenant_memberships` | User↔Tenant-Verknüpfung + Rollen | direkt (via userId) | per Tenant-Lifecycle | Mehrmandant-Zuordnung |
40
- | `kumiko_events` | Event-Store (alle write-Events) | direkt (`createdBy = userId`) | per Domain-Policy via `data-retention` | Audit-Trail (Art. 15-Selbstauskunft Quelle), Event-Sourcing |
41
- | `read_export_jobs` | Async Export-Status (queued/running/done/failed), userId, requestedAt, doneAt, storageKey | direkt (userId) | per `compliance-profiles` Profil-Default | Idempotenz + Status-Polling |
42
- | `read_export_download_tokens` | Magic-Link-Hash (SHA-256), TTL, useCount, lastUsedAt, lastUsedFromIp, lastUsedUserAgent | indirekt (Token→Job→User) | `compliance-profiles.userRights.exportDownloadTtl` (default 7d) | Magic-Link-Auth + Multi-Use-Audit |
43
- | `read_download_attempts` | Invalid Download-Versuche: result, via, tokenHash, ip, userAgent, attemptedAt | indirekt (IP) | **90d hardDelete** (Entity-Default, Disk-Bomb-Schutz) | DPO-Brute-Force-Detection |
44
- | `read_tenant_compliance_profiles` | Per-Tenant Profile-Wahl + Override | nein | unbounded (Konfiguration) | Region-/Branchen-Defaults |
45
- | `read_tenant_retention_overrides` | Per-Tenant Retention-Override pro Entity | nein | unbounded (Konfiguration) | Aufbewahrungspflicht-Edge-Cases |
46
- | Storage-Provider (Local / S3) | Export-ZIPs + File-Binaries | indirekt (Inhalt) | Local: per `exportDownloadTtl` Cleanup. S3: lifecycle-Policy (App-Author-Verantwortung) | Daten-Export-Auslieferung |
36
+ | Tabelle | Inhalt | Personenbezug | Retention | Zweck |
37
+ | --------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
38
+ | `read_users` | User-Profil (email, displayName, passwordHash, locale, status, gracePeriodEnd, roles) | direkt | per Domain (typ. blockDelete bei Aufbewahrungspflicht, sonst hardDelete via Forget-Pipeline) | Authentifizierung, Nutzer-Profil |
39
+ | `read_tenant_memberships` | User↔Tenant-Verknüpfung + Rollen | direkt (via userId) | per Tenant-Lifecycle | Mehrmandant-Zuordnung |
40
+ | `kumiko_events` | Event-Store (alle write-Events) | direkt (`createdBy = userId`) | per Domain-Policy via `data-retention` | Audit-Trail (Art. 15-Selbstauskunft Quelle), Event-Sourcing |
41
+ | `read_export_jobs` | Async Export-Status (queued/running/done/failed), userId, requestedAt, doneAt, storageKey | direkt (userId) | per `compliance-profiles` Profil-Default | Idempotenz + Status-Polling |
42
+ | `read_export_download_tokens` | Magic-Link-Hash (SHA-256), TTL, useCount, lastUsedAt, lastUsedFromIp, lastUsedUserAgent | indirekt (Token→Job→User) | `compliance-profiles.userRights.exportDownloadTtl` (default 7d) | Magic-Link-Auth + Multi-Use-Audit |
43
+ | `read_download_attempts` | Invalid Download-Versuche: result, via, tokenHash, ip, userAgent, attemptedAt | indirekt (IP) | **90d hardDelete** (Entity-Default, Disk-Bomb-Schutz) | DPO-Brute-Force-Detection |
44
+ | `read_tenant_compliance_profiles` | Per-Tenant Profile-Wahl + Override | nein | unbounded (Konfiguration) | Region-/Branchen-Defaults |
45
+ | `read_tenant_retention_overrides` | Per-Tenant Retention-Override pro Entity | nein | unbounded (Konfiguration) | Aufbewahrungspflicht-Edge-Cases |
46
+ | Storage-Provider (Local / S3) | Export-ZIPs + File-Binaries | indirekt (Inhalt) | Local: per `exportDownloadTtl` Cleanup. S3: lifecycle-Policy (App-Author-Verantwortung) | Daten-Export-Auslieferung |
47
47
 
48
48
  ### Bekanntes Residuum: `createdBy` / `metadata.userId` in `kumiko_events`
49
49
 
@@ -69,12 +69,12 @@ Retention-Policy irgendwann das Event selbst löscht.
69
69
 
70
70
  ## 3. Compliance-Profile
71
71
 
72
- | Profil | gracePeriod | exportDownloadTtl | Stale-After | Sub-Processors |
73
- |--------|-------------|-------------------|-------------|----------------|
74
- | `eu-dsgvo` | 30d | 7d | 30d | per Tenant konfigurierbar |
75
- | `swiss-dsg` | 30d | 7d | 30d | + EDÖB-Meldepfad |
76
- | `de-hr-dsgvo-hgb` | 30d | 7d | 30d | + HR-Aufbewahrung 10y für HR-Entities (anonymize statt delete) |
77
- | `minimal-no-region` | 30d | 7d | 30d | Migration-Edge-Case ohne Region |
72
+ | Profil | gracePeriod | exportDownloadTtl | Stale-After | Sub-Processors |
73
+ | ------------------- | ----------- | ----------------- | ----------- | -------------------------------------------------------------- |
74
+ | `eu-dsgvo` | 30d | 7d | 30d | per Tenant konfigurierbar |
75
+ | `swiss-dsg` | 30d | 7d | 30d | + EDÖB-Meldepfad |
76
+ | `de-hr-dsgvo-hgb` | 30d | 7d | 30d | + HR-Aufbewahrung 10y für HR-Entities (anonymize statt delete) |
77
+ | `minimal-no-region` | 30d | 7d | 30d | Migration-Edge-Case ohne Region |
78
78
 
79
79
  Tenant kann via `compliance-profiles:write:set-profile` + Override (`override.userRights.gracePeriod={ days: N }` etc.) eigene Werte setzen.
80
80
 
@@ -128,13 +128,13 @@ und Organisatorische Maßnahmen" übernommen werden:
128
128
 
129
129
  ## 5. Cron-Jobs / Operationale Trigger
130
130
 
131
- | Job | Trigger | Was passiert | Operator-Sichtbarkeit |
132
- |-----|---------|--------------|------------------------|
133
- | `run-forget-cleanup` | Cron (per App-Author konfigurierbar, typisch täglich) | Findet User mit abgelaufener Grace, ruft `EXT_USER_DATA.delete` pro Provider, anonymisiert User, sendet `sendDeletionExecutedEmail`-Callback | `read_job_runs` (success/fail), Hook-Errors als `errors[]` im Result |
134
- | `run-export-jobs` | Cron + Event-getriggert | Findet pending Export-Jobs, ruft `runUserExport` → ZIP-Bau → Storage-Upload → Magic-Link-Token → `sendExportReadyEmail` | `read_job_runs`, `read_export_jobs.status` |
135
- | `data-retention-cleanup` | Cron (in `data-retention`) | Cleant abgelaufene Rows pro Entity per `retention.keepFor` | `read_job_runs` |
136
- | Token-Cleanup (implizit) | Per `compliance-profiles.userRights.exportDownloadTtl` | Worker-Job entfernt expired Magic-Link-Tokens + Storage-Binaries | `read_export_download_tokens` |
137
- | Download-Attempt-Cleanup | Per Entity-Default 90d | Cleant `read_download_attempts` | — |
131
+ | Job | Trigger | Was passiert | Operator-Sichtbarkeit |
132
+ | ------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
133
+ | `run-forget-cleanup` | Cron (per App-Author konfigurierbar, typisch täglich) | Findet User mit abgelaufener Grace, ruft `EXT_USER_DATA.delete` pro Provider, anonymisiert User, sendet `sendDeletionExecutedEmail`-Callback | `read_job_runs` (success/fail), Hook-Errors als `errors[]` im Result |
134
+ | `run-export-jobs` | Cron + Event-getriggert | Findet pending Export-Jobs, ruft `runUserExport` → ZIP-Bau → Storage-Upload → Magic-Link-Token → `sendExportReadyEmail` | `read_job_runs`, `read_export_jobs.status` |
135
+ | `data-retention-cleanup` | Cron (in `data-retention`) | Cleant abgelaufene Rows pro Entity per `retention.keepFor` | `read_job_runs` |
136
+ | Token-Cleanup (implizit) | Per `compliance-profiles.userRights.exportDownloadTtl` | Worker-Job entfernt expired Magic-Link-Tokens + Storage-Binaries | `read_export_download_tokens` |
137
+ | Download-Attempt-Cleanup | Per Entity-Default 90d | Cleant `read_download_attempts` | — |
138
138
 
139
139
  App-Author registriert die Cron-Trigger im run-config; `jobs`-Feature
140
140
  persistiert Run-Tracking + Retry-Pfad.
@@ -18,16 +18,16 @@ defineFeature("tasks", (r) => {
18
18
  r.useExtension(EXT_USER_DATA, "task", {
19
19
  export: async (ctx) => ({
20
20
  entity: "task",
21
- rows: await ctx.db.select().from(tasksTable)
22
- .where(eq(tasksTable.authorId, ctx.userId)),
21
+ rows: await ctx.db.select().from(tasksTable).where(eq(tasksTable.authorId, ctx.userId)),
23
22
  }),
24
23
  delete: async (ctx, strategy) => {
25
24
  if (strategy === "anonymize") {
26
- await ctx.db.update(tasksTable).set({ authorId: null })
25
+ await ctx.db
26
+ .update(tasksTable)
27
+ .set({ authorId: null })
27
28
  .where(eq(tasksTable.authorId, ctx.userId));
28
29
  } else {
29
- await ctx.db.delete(tasksTable)
30
- .where(eq(tasksTable.authorId, ctx.userId));
30
+ await ctx.db.delete(tasksTable).where(eq(tasksTable.authorId, ctx.userId));
31
31
  }
32
32
  },
33
33
  });
@@ -42,15 +42,15 @@ Hook-Signaturen in `framework/src/engine/extensions/user-data.ts`:
42
42
 
43
43
  ## Endpoints
44
44
 
45
- | Article | Handler | Zweck |
46
- |---------|---------|-------|
47
- | Art. 15 | `user-data-rights:query:my-audit-log` | User sieht eigene Framework-Events (account-weit über alle Memberships). Domain-Entities ohne `ctx.events.append` erscheinen NICHT — nur im Export-Bundle. |
48
- | Art. 15+20 | `user-data-rights:write:request-export` | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten + signed Magic-Link |
49
- | Art. 17 | `user-data-rights:write:request-deletion` | Soft-Delete mit Grace-Period, anschließend Cron anonymisiert User + cleant Domain-Entities |
50
- | Art. 17 | `user-data-rights:write:cancel-deletion` | User widerruft seinen Forget-Request während der Grace |
51
- | Art. 18 | `user-data-rights:write:restrict-account` | Auth-Middleware blockt Logins bis Lift |
52
- | Art. 18 | `user-data-rights:write:lift-restriction` | Admin/SystemAdmin hebt Restriction auf |
53
- | Operator | `user-data-rights:query:list-download-attempts` | DPO-Sicht auf invalid Download-Versuche (Brute-Force-Detection, Admin/SystemAdmin only) |
45
+ | Article | Handler | Zweck |
46
+ | ---------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
47
+ | Art. 15 | `user-data-rights:query:my-audit-log` | User sieht eigene Framework-Events (account-weit über alle Memberships). Domain-Entities ohne `ctx.events.append` erscheinen NICHT — nur im Export-Bundle. |
48
+ | Art. 15+20 | `user-data-rights:write:request-export` | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten + signed Magic-Link |
49
+ | Art. 17 | `user-data-rights:write:request-deletion` | Soft-Delete mit Grace-Period, anschließend Cron anonymisiert User + cleant Domain-Entities |
50
+ | Art. 17 | `user-data-rights:write:cancel-deletion` | User widerruft seinen Forget-Request während der Grace |
51
+ | Art. 18 | `user-data-rights:write:restrict-account` | Auth-Middleware blockt Logins bis Lift |
52
+ | Art. 18 | `user-data-rights:write:lift-restriction` | Admin/SystemAdmin hebt Restriction auf |
53
+ | Operator | `user-data-rights:query:list-download-attempts` | DPO-Sicht auf invalid Download-Versuche (Brute-Force-Detection, Admin/SystemAdmin only) |
54
54
 
55
55
  Plus 2 anonyme HTTP-Routes für Export-Download (Magic-Link-Pfad +
56
56
  session-auth-Pfad), siehe `handlers/download-by-{token,job}.query.ts`.
@@ -77,30 +77,30 @@ weglassen wenn er Custom-Hooks registrieren will.
77
77
 
78
78
  ## Audit-Trail
79
79
 
80
- | Tabelle | Zweck | Retention |
81
- |---------|-------|-----------|
82
- | `kumiko_events` | Framework-Event-Store, Quelle für `my-audit-log` | per Domain-Policy |
83
- | `read_export_jobs` | Async Export-Status (queued / done / failed) | per `compliance-profiles` |
84
- | `read_export_download_tokens` | Magic-Link-Hash + TTL + lastUsed-Audit | per `compliance-profiles` (default `exportDownloadTtl`) |
85
- | `read_download_attempts` | Invalid-Download-Versuche für DPO-Brute-Force-Detection | **90d hardDelete** (Entity-Default — schützt vor Disk-Bomb bei aktiven Angriffen) |
80
+ | Tabelle | Zweck | Retention |
81
+ | ----------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------- |
82
+ | `kumiko_events` | Framework-Event-Store, Quelle für `my-audit-log` | per Domain-Policy |
83
+ | `read_export_jobs` | Async Export-Status (queued / done / failed) | per `compliance-profiles` |
84
+ | `read_export_download_tokens` | Magic-Link-Hash + TTL + lastUsed-Audit | per `compliance-profiles` (default `exportDownloadTtl`) |
85
+ | `read_download_attempts` | Invalid-Download-Versuche für DPO-Brute-Force-Detection | **90d hardDelete** (Entity-Default — schützt vor Disk-Bomb bei aktiven Angriffen) |
86
86
 
87
87
  ## Tests
88
88
 
89
89
  18 Testdateien, 188 Tests, alle grün:
90
90
 
91
- | Datei | Pinst |
92
- |-------|-------|
93
- | `audit-log.integration.test.ts` | Cross-User-Isolation, Account-weite Sicht, eventType-Filter, Admin-only operator-query, download-attempt 90d-retention |
94
- | `cross-data-matrix.integration.test.ts` | 3-Provider-Pipeline (user + fileRef + custom-domain), Cross-Tenant Forget mit user-anonymize, Other-User-Isolation |
95
- | `download.integration.test.ts` | HTTP-e2e via `r.httpRoute`: Magic-Link, multi-use, expired, failed-job, storage-cleared, cross-tenant-same-user, malicious-filename |
96
- | `request-export.integration.test.ts` | Idempotency, active-job-constraint, cross-tenant-anyMember-userId-pattern |
97
- | `request-deletion-callback.integration.test.ts` + `request-cancel-deletion.integration.test.ts` | Grace + Cancel-Pfad + Email-Callback best-effort |
98
- | `restriction-flow.integration.test.ts` | Status-Flip + Auth-Middleware-Block + Lift |
99
- | `run-{export-jobs,forget-cleanup,user-export}.integration.test.ts` | Worker-Logic + Idempotency + Email-Callbacks |
100
- | `policy-to-strategy.test.ts` | Retention.strategy → UserDataDeleteStrategy mapping |
101
- | `user-data-rights.integration.test.ts` | Boot-Smoke + Feature-Meta |
102
- | `token-helpers.test.ts` + `zip-path.test.ts` | Token-Hashing + Path-Traversal-Schutz |
103
- | `export-job-{idempotency,schema}.test.ts` | Active-job-uniqueness + Schema-Constraints |
91
+ | Datei | Pinst |
92
+ | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `audit-log.integration.test.ts` | Cross-User-Isolation, Account-weite Sicht, eventType-Filter, Admin-only operator-query, download-attempt 90d-retention |
94
+ | `cross-data-matrix.integration.test.ts` | 3-Provider-Pipeline (user + fileRef + custom-domain), Cross-Tenant Forget mit user-anonymize, Other-User-Isolation |
95
+ | `download.integration.test.ts` | HTTP-e2e via `r.httpRoute`: Magic-Link, multi-use, expired, failed-job, storage-cleared, cross-tenant-same-user, malicious-filename |
96
+ | `request-export.integration.test.ts` | Idempotency, active-job-constraint, cross-tenant-anyMember-userId-pattern |
97
+ | `request-deletion-callback.integration.test.ts` + `request-cancel-deletion.integration.test.ts` | Grace + Cancel-Pfad + Email-Callback best-effort |
98
+ | `restriction-flow.integration.test.ts` | Status-Flip + Auth-Middleware-Block + Lift |
99
+ | `run-{export-jobs,forget-cleanup,user-export}.integration.test.ts` | Worker-Logic + Idempotency + Email-Callbacks |
100
+ | `policy-to-strategy.test.ts` | Retention.strategy → UserDataDeleteStrategy mapping |
101
+ | `user-data-rights.integration.test.ts` | Boot-Smoke + Feature-Meta |
102
+ | `token-helpers.test.ts` + `zip-path.test.ts` | Token-Hashing + Path-Traversal-Schutz |
103
+ | `export-job-{idempotency,schema}.test.ts` | Active-job-uniqueness + Schema-Constraints |
104
104
 
105
105
  ## Sample
106
106
 
@@ -0,0 +1,40 @@
1
+ import { createTenantDb, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ type EscapeHatchAuditSink,
4
+ EXT_USER_DATA,
5
+ type TenantId,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
8
+ import {
9
+ createEscapeHatchReporter,
10
+ UNATTRIBUTED_ACTOR,
11
+ } from "@cosmicdrift/kumiko-framework/pipeline";
12
+
13
+ export function buildHookDb(
14
+ db: DbRunner,
15
+ tenantId: TenantId,
16
+ entry: { readonly entityName: string; readonly escapeHatchReason: string | undefined },
17
+ args: {
18
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
19
+ readonly escapeHatchAuditLog?: Logger;
20
+ readonly actor?: string;
21
+ },
22
+ ) {
23
+ const report = createEscapeHatchReporter({
24
+ handler: `${EXT_USER_DATA}:${entry.entityName}`,
25
+ tenantId,
26
+ actor: args.actor ?? UNATTRIBUTED_ACTOR,
27
+ sink: args.escapeHatchAuditSink,
28
+ log: args.escapeHatchAuditLog ?? createFallbackLogger("user-data-rights"),
29
+ });
30
+ return createTenantDb({
31
+ db,
32
+ tenantId,
33
+ mode: "tenant",
34
+ grants: {
35
+ unsafeRaw:
36
+ entry.escapeHatchReason !== undefined ? { reason: entry.escapeHatchReason } : undefined,
37
+ report,
38
+ },
39
+ });
40
+ }
@@ -23,9 +23,9 @@
23
23
 
24
24
  import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
25
25
  import { defineWriteHandler, type SaveContext } from "@cosmicdrift/kumiko-framework/engine";
26
- import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
27
26
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
28
27
  import * as z from "zod";
28
+ import { isUniqueConstraintFailure } from "../../shared/index.ts";
29
29
  import {
30
30
  ACTIVE_JOB_CONSTRAINT,
31
31
  EXPORT_JOB_STATUS,
@@ -40,23 +40,6 @@ const crud = createEventStoreExecutor(exportJobsTable, exportJobEntity, {
40
40
  const REQUEST_EXPORT_REASON =
41
41
  "export jobs are keyed by userId across all of the user's tenant memberships, not the caller's current tenant";
42
42
 
43
- /**
44
- * `createEventStoreExecutor.create` catches 23505 internally and returns
45
- * `WriteFailure(UniqueViolationError)` rather than throwing. Check the
46
- * failure code and constraintName to distinguish the app-side and race
47
- * paths; propagate other failures (validation, version conflicts, ...)
48
- * unchanged.
49
- */
50
- function isActiveJobConflict(failure: WriteFailure): boolean {
51
- const error = failure.error as {
52
- code?: string;
53
- details?: { constraintName?: string };
54
- }; // @cast-boundary error-details
55
- return (
56
- error.code === "unique_violation" && error.details?.constraintName === ACTIVE_JOB_CONSTRAINT
57
- );
58
- }
59
-
60
43
  export const requestExportWrite = defineWriteHandler({
61
44
  name: "request-export",
62
45
  schema: z.object({}),
@@ -115,7 +98,7 @@ export const requestExportWrite = defineWriteHandler({
115
98
  // crud.create, causing UniqueViolationError for our constraint. Re-fetch
116
99
  // and return the existing job with isExisting=true. Propagate other
117
100
  // failures (validation, version conflicts, ...) unchanged.
118
- if (isActiveJobConflict(result)) {
101
+ if (isUniqueConstraintFailure(result, ACTIVE_JOB_CONSTRAINT)) {
119
102
  const winner = await findActiveJob(exportJobRunner, userId);
120
103
  if (!winner) {
121
104
  // This should be impossible: a constraint violation without an existing
@@ -1,19 +1,13 @@
1
1
  import {
2
2
  type FeatureRegistrar,
3
- type NotificationLocalizedTemplate,
4
3
  QnTypes,
5
4
  qn,
6
5
  type TemplateVariables,
7
6
  templateVariable,
8
7
  } from "@cosmicdrift/kumiko-framework/engine";
9
- import {
10
- hasMailTranslationKey,
11
- listMailLocales,
12
- mailT,
13
- resolveMailLocale,
14
- } from "@cosmicdrift/kumiko-framework/i18n";
8
+ import { mailT } from "@cosmicdrift/kumiko-framework/i18n";
15
9
  import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
16
- import { mailParagraph } from "../shared/index.ts";
10
+ import { createMailLocaleTemplates, mailParagraph } from "../shared/index.ts";
17
11
  import { USER_DATA_RIGHTS_FEATURE } from "./web/constants.ts";
18
12
 
19
13
  const ALL_GDPR_MAIL_KINDS = [
@@ -49,18 +43,14 @@ function subjectKey(kind: GdprMailKind): string {
49
43
  return `gdpr.mail.${GDPR_MAIL_KINDS[kind].translationKey}.subject`;
50
44
  }
51
45
 
52
- const SUBJECT_KEY_BY_TYPE: ReadonlyMap<string, string> = new Map(
53
- ALL_GDPR_MAIL_KINDS.map((kind) => [GdprMailNotificationTypes[kind], subjectKey(kind)]),
54
- );
55
-
56
- // A registered locale whose bundle lacks the subject key has no template (see
57
- // gdprMailLocalizedTemplate); the renderer has no locale fallback, so the
58
- // sender must pick a locale that has one or the mail would not go out at all.
59
- export function gdprMailTemplateLocale(notificationType: string, requestedLocale: string): string {
60
- const locale = resolveMailLocale(requestedLocale);
61
- const key = SUBJECT_KEY_BY_TYPE.get(notificationType);
62
- return key !== undefined && hasMailTranslationKey(locale, key) ? locale : "en";
63
- }
46
+ const mailLocaleTemplates = createMailLocaleTemplates({
47
+ kinds: ALL_GDPR_MAIL_KINDS,
48
+ notificationTypes: GdprMailNotificationTypes,
49
+ subjectKey,
50
+ bodyFor: (kind, locale) => mailBody(kind, locale),
51
+ });
52
+
53
+ export const gdprMailTemplateLocale = mailLocaleTemplates.templateLocale;
64
54
 
65
55
  const appName = () => templateVariable.text({ required: true });
66
56
 
@@ -119,33 +109,7 @@ function mailBody(kind: GdprMailKind, locale: string): string {
119
109
  }
120
110
  }
121
111
 
122
- // Evaluated when the registry is built, after every locale feature has
123
- // registered its mail translations. English always ships; another locale only
124
- // gets a template when it has its own subject, so a missing translation never
125
- // silently becomes an English mail in that locale's slot.
126
- export function gdprMailLocalizedTemplate(kind: GdprMailKind): () => NotificationLocalizedTemplate {
127
- const kindSubjectKey = subjectKey(kind);
128
- return () => {
129
- const locales = [
130
- "en",
131
- ...listMailLocales().filter(
132
- (locale) => locale !== "en" && hasMailTranslationKey(locale, kindSubjectKey),
133
- ),
134
- ];
135
- return Object.fromEntries(
136
- locales.map((locale) => [
137
- locale,
138
- {
139
- body: mailBody(kind, locale),
140
- meta: {
141
- subject: mailT(locale, kindSubjectKey, { app: "{{appName}}" }),
142
- category: "transactional" as const,
143
- },
144
- },
145
- ]),
146
- );
147
- };
148
- }
112
+ export const gdprMailLocalizedTemplate = mailLocaleTemplates.localizedTemplate;
149
113
 
150
114
  export function registerGdprMails(r: FeatureRegistrar): void {
151
115
  for (const kind of ALL_GDPR_MAIL_KINDS) {
@@ -1,4 +1,4 @@
1
- import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
1
+ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
2
2
  // Forget-cleanup runner — pure-function pipeline.
3
3
  //
4
4
  // After the grace period, this runner processes users in DeletionRequested
@@ -34,7 +34,6 @@ import {
34
34
  subjectIdToKey,
35
35
  } from "@cosmicdrift/kumiko-framework/crypto";
36
36
  import {
37
- createTenantDb,
38
37
  type DbRunner,
39
38
  entityEventName,
40
39
  executeRawQuery,
@@ -57,10 +56,6 @@ import {
57
56
  type UserDataStorageProvider,
58
57
  } from "@cosmicdrift/kumiko-framework/engine";
59
58
  import { runInSystemRowLevelSecurityScope } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
60
- import {
61
- createEscapeHatchReporter,
62
- UNATTRIBUTED_ACTOR,
63
- } from "@cosmicdrift/kumiko-framework/pipeline";
64
59
  import {
65
60
  purgeSearchDocumentsForSubject,
66
61
  type SearchAdapter,
@@ -78,6 +73,7 @@ import {
78
73
  } from "../shared/index.ts";
79
74
  import { tenantMembershipsTable } from "../tenant/index.ts";
80
75
  import { USER_STATUS, userTable } from "../user/index.ts";
76
+ import { buildHookDb } from "./build-hook-db.ts";
81
77
  import { selectUsersDueForForgetCleanup } from "./db/queries/forget-cleanup.ts";
82
78
  import { updateUserLifecycle } from "./lib/update-user-lifecycle.ts";
83
79
 
@@ -205,35 +201,6 @@ interface HookEntry {
205
201
  readonly escapeHatchReason: string | undefined;
206
202
  }
207
203
 
208
- function buildHookDb(
209
- db: DbRunner,
210
- tenantId: TenantId,
211
- entry: HookEntry,
212
- args: {
213
- readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
214
- readonly escapeHatchAuditLog?: Logger;
215
- readonly actor?: string;
216
- },
217
- ) {
218
- const report = createEscapeHatchReporter({
219
- handler: `${EXT_USER_DATA}:${entry.entityName}`,
220
- tenantId,
221
- actor: args.actor ?? UNATTRIBUTED_ACTOR,
222
- sink: args.escapeHatchAuditSink,
223
- log: args.escapeHatchAuditLog ?? createFallbackLogger("user-data-rights"),
224
- });
225
- return createTenantDb({
226
- db,
227
- tenantId,
228
- mode: "tenant",
229
- grants: {
230
- unsafeRaw:
231
- entry.escapeHatchReason !== undefined ? { reason: entry.escapeHatchReason } : undefined,
232
- report,
233
- },
234
- });
235
- }
236
-
237
204
  // EXT_USER_DATA delete-hooks default here; a hook that redacts data keyed on an
238
205
  // owner column it doesn't own must register a lower order so it runs BEFORE any
239
206
  // hook that nulls that column. See custom-fields wire-user-data-rights.ts.