@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.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 (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -0,0 +1,310 @@
1
+ import {
2
+ defineFeature,
3
+ EXT_USER_DATA,
4
+ type FeatureDefinition,
5
+ SYSTEM_USER_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { createFileProviderForTenant } from "../file-foundation";
8
+ import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
9
+ import { downloadByJobQuery } from "./handlers/download-by-job.query";
10
+ import { downloadByTokenQuery } from "./handlers/download-by-token.query";
11
+ import { exportStatusQuery } from "./handlers/export-status.query";
12
+ import { liftRestrictionWrite } from "./handlers/lift-restriction.write";
13
+ import { listDownloadAttemptsQuery } from "./handlers/list-download-attempts.query";
14
+ import { myAuditLogQuery } from "./handlers/my-audit-log.query";
15
+ import {
16
+ createRequestDeletionHandler,
17
+ type SendDeletionRequestedEmailFn,
18
+ } from "./handlers/request-deletion.write";
19
+ import { requestExportWrite } from "./handlers/request-export.write";
20
+ import { restrictAccountWrite } from "./handlers/restrict-account.write";
21
+ import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
22
+ import {
23
+ runExportJobs,
24
+ type SendExportFailedEmailFn,
25
+ type SendExportReadyEmailFn,
26
+ } from "./run-export-jobs";
27
+ import type { SendDeletionExecutedEmailFn } from "./run-forget-cleanup";
28
+ import { downloadAttemptEntity } from "./schema/download-attempt";
29
+ import { exportDownloadTokenEntity } from "./schema/download-token";
30
+ import { exportJobEntity } from "./schema/export-job";
31
+
32
+ // user-data-rights — DSGVO Art. 15 (Auskunft) + Art. 17 (Löschung) +
33
+ // Art. 18 (Restriction) + Art. 20 (Portabilität) als Core-Feature.
34
+ //
35
+ // Pattern (Plan-Roadmap docs/plans/datenschutz/user-data-rights.md):
36
+ // Statt jedes Feature seine eigene Forget-/Export-Logik schreibt,
37
+ // haengt es sich via `r.useExtension(EXT_USER_DATA, "<entity>", {
38
+ // export, delete })` an. user-data-rights orchestriert:
39
+ // - Export-Job: iteriert alle Extension-Registrierungen, sammelt JSON
40
+ // - Forget-Job: iteriert alle, ruft delete-Hook mit Strategy aus
41
+ // retention.policyFor (data-retention)
42
+ // - Restriction: status-Flip auf user-Schema, Auth-Middleware-Guard
43
+ /**
44
+ * Options fuer createUserDataRightsFeature. Notification-Callbacks
45
+ * (Atom 5) folgen dem password-reset-Pattern aus auth-routes.ts.
46
+ *
47
+ * Plain-Token landet NIE in DB/event-store/jobRunsTable — Worker reicht
48
+ * ihn ephemeral via Callback-arg an die App-Author-Implementation
49
+ * (typisch: `delivery.notify(...)` mit `r.notification`-Templates,
50
+ * `mailFoundation.send` direkt, oder Custom Resend/SES).
51
+ *
52
+ * Worker-Run-Tracking + Retry kommt automatisch via existing
53
+ * `jobs`-Feature (siehe CLAUDE.md "Bundled-Features by Concern").
54
+ */
55
+ export type UserDataRightsOptions = {
56
+ /** Email-Notification beim Export-done. App-Author wired das an seinen
57
+ * Email-Provider. Best-effort (Atom 5.fix3): send-Throw fuer Job A
58
+ * killt den Batch nicht — restliche pending-Jobs werden weiter
59
+ * verarbeitet, Failure wird via console.warn sichtbar. (Vorher
60
+ * bubbelte der Throw zum r.job-Wrap, was bei mehreren pending Jobs
61
+ * zum silent-miss fuehrte: Job A done committed, B/C/D nie
62
+ * verarbeitet, retry findet niemand.) */
63
+ readonly sendExportReadyEmail?: SendExportReadyEmailFn;
64
+ /** Email-Notification beim Export-failed. Best-effort analog
65
+ * sendExportReadyEmail. */
66
+ readonly sendExportFailedEmail?: SendExportFailedEmailFn;
67
+ /** Base-URL fuer den Magic-Link, z.B.
68
+ * "https://app.example.com/user-export/by-token". Worker bauen
69
+ * `${appExportDownloadUrl}?token=<plain>`. Required wenn
70
+ * sendExportReadyEmail gesetzt. Per-Tenant via reverse-proxy host
71
+ * routing — nicht via per-Tenant-config-key (App-Author-Decision). */
72
+ readonly appExportDownloadUrl?: string;
73
+ /** Atom 5b — Email-Notification beim deletion-requested-flip
74
+ * ("Account-Loeschung in 30 Tagen"). Best-effort: send-failure killt
75
+ * den Status-Flip nicht (siehe handlers/request-deletion.write.ts). */
76
+ readonly sendDeletionRequestedEmail?: SendDeletionRequestedEmailFn;
77
+ /** Atom 5b — Email-Notification beim Cleanup-Runner-done-Pfad
78
+ * ("Account wurde geloescht"). Best-effort. Der Versand passiert NACH
79
+ * dem User-Hook-Anonymisieren, deshalb cached der Worker
80
+ * userEmail+tenantIds PRE-tx und reicht sie ephemeral an die
81
+ * Callback-Implementation (siehe run-forget-cleanup.ts). */
82
+ readonly sendDeletionExecutedEmail?: SendDeletionExecutedEmailFn;
83
+ };
84
+
85
+ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): FeatureDefinition {
86
+ return defineFeature("user-data-rights", (r) => {
87
+ r.requires("user", "data-retention", "compliance-profiles");
88
+ r.usesApi("compliance.forTenant");
89
+ r.usesApi("retention.policyFor");
90
+ // S2.U6 — restrict-account ruft sessions.revokeAllForUser cross-feature.
91
+ // r.usesApi sorgt fuer Boot-Validation: App ohne sessions-feature wirft
92
+ // beim Boot, statt erst beim ersten Restrict-Call ein opaque "handler
93
+ // not found" zu werfen.
94
+ r.usesApi("sessions.revokeAllForUser");
95
+ // file-foundation ist soft-dep: nur der Export-Worker (Atom 3b)
96
+ // braucht ihn fuer Storage-Schreiben. Apps die nur Forget nutzen
97
+ // (kein Export) muessen file-foundation nicht mounten.
98
+
99
+ r.extendsRegistrar(EXT_USER_DATA, {});
100
+
101
+ // S2.U3 Atom 1b — ExportJob-Lifecycle-Entity.
102
+ r.entity("export-job", exportJobEntity);
103
+
104
+ // S2.U3 Atom 4a — Download-Token-Entity. Worker generiert Token beim
105
+ // Flip auf done (siehe run-export-jobs.ts). Hash in DB, plain im
106
+ // RunExportJobsResult fuer Atom 5 (Notification per Email).
107
+ r.entity("export-download-token", exportDownloadTokenEntity);
108
+
109
+ // S2.U7 — Audit-Trail invalid Download-Attempts (DPO Brute-Force-Detection).
110
+ r.entity("download-attempt", downloadAttemptEntity);
111
+
112
+ // S2.U6 — DSGVO Art. 18 Account-Freeze (Verarbeitungs-Pause).
113
+ // Endpoints fuer Restrict + Lift. Login-Block fuer Restricted Users
114
+ // lebt in auth-email-password/login.write.ts.
115
+ r.writeHandler(restrictAccountWrite);
116
+ r.writeHandler(liftRestrictionWrite);
117
+
118
+ // S2.U5a — Endpoints fuer DSGVO Art. 17 Forget-Pfad mit Grace.
119
+ r.writeHandler(
120
+ createRequestDeletionHandler(
121
+ opts.sendDeletionRequestedEmail
122
+ ? { sendDeletionRequestedEmail: opts.sendDeletionRequestedEmail }
123
+ : {},
124
+ ),
125
+ );
126
+ r.writeHandler(cancelDeletionWrite);
127
+
128
+ // S2.U5b — Cleanup-Runner als privileged-Handler. Atom 5b: Wenn
129
+ // sendDeletionExecutedEmail gesetzt, reicht der Handler den Callback
130
+ // an runForgetCleanup weiter (Worker cached userEmail+tenantIds
131
+ // PRE-tx, siehe run-forget-cleanup.ts).
132
+ r.writeHandler(
133
+ createRunForgetCleanupHandler(
134
+ opts.sendDeletionExecutedEmail
135
+ ? { sendDeletionExecutedEmail: opts.sendDeletionExecutedEmail }
136
+ : {},
137
+ ),
138
+ );
139
+ r.exposesApi("userDataRights.runForget");
140
+
141
+ // S2.U3 Atom 2 — User-Touchpoints fuer Async Export-Pipeline.
142
+ r.writeHandler(requestExportWrite);
143
+ r.queryHandler(exportStatusQuery);
144
+ r.exposesApi("userDataRights.runExport");
145
+
146
+ // S2.U3 Atom 4b — Download-Endpoints (Token-Pfad + Session-Pfad).
147
+ // Beide query-handlers verifizieren Token/Session, holen signed-URL
148
+ // vom Storage-Provider, und persistieren Audit-Felder am Token-Row.
149
+ // r.httpRoute-Wrapper unten machen den 302-Redirect zu signedUrl.
150
+ r.queryHandler(downloadByTokenQuery);
151
+ r.queryHandler(downloadByJobQuery);
152
+
153
+ // S2.U7 — User-Selbstauskunft (Art. 15) + Operator-Sicht auf
154
+ // invalid Download-Attempts (DPO).
155
+ r.queryHandler(myAuditLogQuery);
156
+ r.queryHandler(listDownloadAttemptsQuery);
157
+
158
+ // r.httpRoute-Wrapper: Magic-Link-Pfad (anonymous) + UI-Klick-Pfad.
159
+ //
160
+ // Beide rufen via app.fetch /api/query → wenn success: 302-Redirect
161
+ // zur signed-URL → Browser folgt → Download startet beim Object-Store.
162
+ // Bei error: passthrough (404/410/501) als JSON.
163
+ //
164
+ // **Token-Pfad (anonymous):** GET /user-export/by-token?token=<plain>
165
+ //
166
+ // Path liegt AUSSERHALB /api/* weil r.httpRoute den /api-namespace
167
+ // nicht claimen darf (reserved fuer write/query/batch/auth/sse-
168
+ // dispatcher).
169
+ r.httpRoute({
170
+ method: "GET",
171
+ path: "/user-export/by-token",
172
+ anonymous: true,
173
+ handler: async (c, { app }) => {
174
+ const url = new URL(c.req.url);
175
+ const token = url.searchParams.get("token");
176
+ if (!token) {
177
+ return c.json({ error: "missing_token" }, 400);
178
+ }
179
+ const queryRes = await app.fetch(
180
+ new Request(`${url.origin}/api/query`, {
181
+ method: "POST",
182
+ headers: { "content-type": "application/json" },
183
+ body: JSON.stringify({
184
+ type: "user-data-rights:query:download-by-token",
185
+ payload: { token, auditMeta: extractAuditMeta(c.req.raw.headers) },
186
+ }),
187
+ }),
188
+ );
189
+ return mapQueryResponseToRedirect(c, queryRes);
190
+ },
191
+ });
192
+
193
+ // **Session-Pfad (auth):** GET /user-export/by-job/:jobId
194
+ r.httpRoute({
195
+ method: "GET",
196
+ path: "/user-export/by-job/:jobId",
197
+ handler: async (c, { app }) => {
198
+ const url = new URL(c.req.url);
199
+ const jobId = c.req.param("jobId");
200
+ if (!jobId) {
201
+ return c.json({ error: "missing_job_id" }, 400);
202
+ }
203
+ const queryRes = await app.fetch(
204
+ new Request(`${url.origin}/api/query`, {
205
+ method: "POST",
206
+ headers: {
207
+ "content-type": "application/json",
208
+ ...forwardAuthHeaders(c.req.raw.headers),
209
+ },
210
+ body: JSON.stringify({
211
+ type: "user-data-rights:query:download-by-job",
212
+ payload: { jobId, auditMeta: extractAuditMeta(c.req.raw.headers) },
213
+ }),
214
+ }),
215
+ );
216
+ return mapQueryResponseToRedirect(c, queryRes);
217
+ },
218
+ });
219
+
220
+ // S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
221
+ r.job(
222
+ "run-export-jobs",
223
+ { trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
224
+ async (_payload, ctx) => {
225
+ if (!ctx.db || !ctx.registry) {
226
+ throw new Error(
227
+ "run-export-jobs: ctx.db + ctx.registry required (JobContext incomplete)",
228
+ );
229
+ }
230
+ const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
231
+ // SYSTEM_USER_ID ist die framework-weite Konvention. Der job-
232
+ // Discriminator wird via handlerName="user-data-rights:run-export-
233
+ // jobs" im Secret-Read-Audit erfasst.
234
+ const providerCtx = {
235
+ config: ctx.config,
236
+ registry: ctx.registry,
237
+ secrets: ctx.secrets,
238
+ _userId: ctx._userId ?? SYSTEM_USER_ID,
239
+ };
240
+ await runExportJobs({
241
+ db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator
242
+ registry: ctx.registry,
243
+ buildStorageProvider: async (tenantId) =>
244
+ createFileProviderForTenant(providerCtx, tenantId, "user-data-rights:run-export-jobs"),
245
+ now: T.Now.instant(),
246
+ // Atom 5 — App-Author-Callbacks fuer Email-Notification.
247
+ // Optional: wenn nicht gesetzt, kein Email; User pollt
248
+ // export-status.query + UI-Klick.
249
+ ...(opts.sendExportReadyEmail && {
250
+ sendExportReadyEmail: opts.sendExportReadyEmail,
251
+ }),
252
+ ...(opts.sendExportFailedEmail && {
253
+ sendExportFailedEmail: opts.sendExportFailedEmail,
254
+ }),
255
+ ...(opts.appExportDownloadUrl !== undefined && {
256
+ appExportDownloadUrl: opts.appExportDownloadUrl,
257
+ }),
258
+ });
259
+ },
260
+ );
261
+ });
262
+ }
263
+
264
+ // Map /api/query-Response auf 302-Redirect oder Error-Passthrough.
265
+ async function mapQueryResponseToRedirect(
266
+ c: import("hono").Context,
267
+ queryRes: Response,
268
+ ): Promise<Response> {
269
+ if (!queryRes.ok) {
270
+ const errorBody = await queryRes.text();
271
+ const statusCode = queryRes.status as 400 | 401 | 404 | 410 | 500; // @cast-boundary engine-payload
272
+ return c.body(errorBody, statusCode, {
273
+ "content-type": queryRes.headers.get("content-type") ?? "application/json",
274
+ });
275
+ }
276
+ const body = (await queryRes.json()) as { data?: { url?: string } }; // @cast-boundary engine-payload
277
+ if (!body.data?.url) {
278
+ return c.json({ error: "download_resolution_failed" }, 500);
279
+ }
280
+ return c.redirect(body.data.url, 302);
281
+ }
282
+
283
+ function forwardAuthHeaders(headers: Headers): Record<string, string> {
284
+ const out: Record<string, string> = {};
285
+ const auth = headers.get("authorization");
286
+ if (auth) out["authorization"] = auth;
287
+ const cookie = headers.get("cookie");
288
+ if (cookie) out["cookie"] = cookie;
289
+ return out;
290
+ }
291
+
292
+ // Extract Audit-Meta (IP + UA) aus den HTTP-Headers + steck es in die
293
+ // query-payload. Der httpRoute-Wrapper ist trusted-source — er hat den
294
+ // raw-request gesehen, nicht der direkter /api/query-Caller. User der
295
+ // /api/query direkt mit eigenem auditMeta aufruft kann luegen, aber
296
+ // auditMeta ist nicht security-relevant (operator kann mit server-logs
297
+ // crossreferencen wenn forensik gebraucht).
298
+ function extractAuditMeta(headers: Headers): { ip: string | null; userAgent: string | null } {
299
+ const xff = headers.get("x-forwarded-for");
300
+ let ip: string | null = null;
301
+ if (xff) {
302
+ const first = xff.split(",")[0]?.trim();
303
+ if (first && first.length > 0) ip = first;
304
+ }
305
+ if (!ip) {
306
+ const real = headers.get("x-real-ip");
307
+ if (real && real.length > 0) ip = real;
308
+ }
309
+ return { ip, userAgent: headers.get("user-agent") };
310
+ }
@@ -0,0 +1,84 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { eq, sql } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { USER_STATUS, userTable } from "../../user";
6
+
7
+ // POST /api/user/cancel-deletion (S2.U5).
8
+ //
9
+ // Innerhalb der Grace-Period kann User seinen Forget-Antrag zurueck-
10
+ // nehmen. Setzt:
11
+ // - status = "active"
12
+ // - gracePeriodEnd = null
13
+ //
14
+ // Nach Grace-Period: 422 (run-forget-cleanup hat in der Zwischenzeit
15
+ // die Hooks schon getriggert — Reversal nicht moeglich).
16
+ //
17
+ // Sonderfall: Cancel als "active"-User → 422 (kein pending Forget).
18
+ export const cancelDeletionWrite = defineWriteHandler({
19
+ name: "cancel-deletion",
20
+ schema: z.object({}),
21
+ access: { openToAll: true },
22
+ handler: async (event, ctx) => {
23
+ // ctx.db.raw (kein TenantDb-Wrapper) weil User-Entity tenant-agnostisch
24
+ // ist — siehe request-deletion.write.ts fuer die Begruendung. Cancel
25
+ // muss aus jedem Tenant-Mode den User finden + zuruecksetzen koennen.
26
+ //
27
+ // Combined query: status + grace_period_end-vs-now in einem Pass.
28
+ const checkRows = await ctx.db.raw
29
+ .select({
30
+ status: userTable["status"],
31
+ inGrace: sql<boolean>`(${userTable["gracePeriodEnd"]} > now())`,
32
+ })
33
+ .from(userTable)
34
+ .where(eq(userTable["id"], event.user.id))
35
+ .limit(1);
36
+
37
+ if (checkRows.length === 0) {
38
+ return writeFailure(
39
+ new UnprocessableError("user_not_found", {
40
+ details: { reason: "user_not_found", userId: event.user.id },
41
+ }),
42
+ );
43
+ }
44
+
45
+ const row = checkRows[0];
46
+ if (!row || row.status !== USER_STATUS.DeletionRequested) {
47
+ return writeFailure(
48
+ new UnprocessableError("no_pending_deletion", {
49
+ details: {
50
+ reason: "no_pending_deletion",
51
+ currentStatus: row?.status,
52
+ },
53
+ }),
54
+ );
55
+ }
56
+
57
+ if (!row.inGrace) {
58
+ return writeFailure(
59
+ new UnprocessableError("grace_period_expired", {
60
+ details: { reason: "grace_period_expired" },
61
+ }),
62
+ );
63
+ }
64
+
65
+ await ctx.db.raw
66
+ .update(userTable)
67
+ .set({
68
+ status: USER_STATUS.Active,
69
+ gracePeriodEnd: null,
70
+ })
71
+ .where(eq(userTable["id"], event.user.id));
72
+
73
+ // gracePeriodEnd=null im Response symmetrisch zu request-deletion's
74
+ // ISO-Timestamp — Frontend kann beide Endpoints uniform behandeln.
75
+ return {
76
+ isSuccess: true as const,
77
+ data: {
78
+ userId: event.user.id,
79
+ status: USER_STATUS.Active,
80
+ gracePeriodEnd: null as string | null, // @cast-boundary generic-record
81
+ },
82
+ };
83
+ },
84
+ });
@@ -0,0 +1,206 @@
1
+ // GET /api/query → user-data-rights:query:download-by-job (S2.U3 Atom 4b).
2
+ //
3
+ // **UI-Klick-Pfad** (session-auth): User pollt status, sieht "done",
4
+ // klickt "Download" im UI mit jobId. Server checkt session.userId ==
5
+ // job.userId — kein Token noetig (Session IS the auth).
6
+ //
7
+ // **Cross-Tenant-Same-User:** Job ist tenant-agnostisch (1 Job pro
8
+ // userId ueber alle Memberships). Alice triggert Export aus Tenant A,
9
+ // loggt sich spaeter in Tenant B ein, klickt Download. Server akzeptiert
10
+ // — `session.userId == job.userId` reicht, kein Tenant-Match-Check.
11
+ // Plan-Decision (Atom 4b Plan, User-Choice).
12
+ //
13
+ // **Cross-User-Isolation:** Wenn session.userId != job.userId → 404
14
+ // (NICHT 403, kein Existenz-Leak). Selber error wie "Job-ID nicht
15
+ // gefunden".
16
+ //
17
+ // Verify-Pipeline:
18
+ // 1. fetchOne job by jobId
19
+ // 2. job.userId === session.userId (cross-user-isolation)
20
+ // 3. job.status === "done"
21
+ // 4. job.downloadStorageKey != null
22
+ // 5. tokenRow lookup (audit-row update only)
23
+ // 6. Audit-Update: useCount + 1, IP, UA, lastUsedAt (best-effort)
24
+ // 7. Return {url, expiresAt}
25
+
26
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
27
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
28
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
29
+ import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
30
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
31
+ import { eq } from "drizzle-orm";
32
+ import { z } from "zod";
33
+ import { createFileProviderForTenant } from "../../file-foundation";
34
+ import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
35
+ import { exportDownloadTokensTable } from "../schema/download-token";
36
+ import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
37
+
38
+ const SIGNED_URL_TTL_SECONDS = 300; // 5 min — matched download-by-token
39
+
40
+ interface TokenRow {
41
+ readonly id: string;
42
+ readonly version: number;
43
+ readonly useCount: number | null;
44
+ }
45
+
46
+ interface JobRow {
47
+ readonly id: string;
48
+ readonly userId: string;
49
+ readonly requestedFromTenantId: string;
50
+ readonly status: string;
51
+ readonly downloadStorageKey: string | null;
52
+ readonly bytesWritten: number | null;
53
+ }
54
+
55
+ export const downloadByJobQuery = defineQueryHandler({
56
+ name: "download-by-job",
57
+ schema: z.object({
58
+ jobId: z.string().min(1, "jobId required"),
59
+ auditMeta: z
60
+ .object({
61
+ ip: z.string().nullable(),
62
+ userAgent: z.string().nullable(),
63
+ })
64
+ .optional(),
65
+ }),
66
+ access: { openToAll: true }, // openToAll = auth-required, kein anonymous
67
+ handler: async (query, ctx) => {
68
+ const T = getTemporal();
69
+ const now = T.Now.instant();
70
+ const userId = query.user.id;
71
+ const jobId = query.payload.jobId;
72
+ const tenantId = query.user.tenantId;
73
+ const auditIp = query.payload.auditMeta?.ip ?? null;
74
+ const auditUa = query.payload.auditMeta?.userAgent ?? null;
75
+
76
+ // Step 1-2: job-lookup + cross-user-isolation
77
+ // ctx.db.raw weil tenant-agnostisch — Alice in Tenant B sucht den
78
+ // aus Tenant A erstellten Job.
79
+ const jobRow = (await fetchOne(
80
+ ctx.db.raw,
81
+ exportJobsTable,
82
+ eq(exportJobsTable["id"], jobId),
83
+ )) as JobRow | null; // @cast-boundary db-row
84
+
85
+ if (!jobRow || jobRow.userId !== userId) {
86
+ await recordInvalidAttempt({
87
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
88
+ tenantId,
89
+ now,
90
+ result: "notFound",
91
+ via: "job",
92
+ tokenHash: null,
93
+ jobId,
94
+ attemptedByUserId: userId,
95
+ ip: auditIp,
96
+ userAgent: auditUa,
97
+ });
98
+ throw new NotFoundError("export-download", jobId, {
99
+ i18nKey: "userDataRights.errors.download.notFound",
100
+ });
101
+ }
102
+
103
+ if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
104
+ await recordInvalidAttempt({
105
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
106
+ tenantId,
107
+ now,
108
+ result: "failed",
109
+ via: "job",
110
+ tokenHash: null,
111
+ jobId,
112
+ attemptedByUserId: userId,
113
+ ip: auditIp,
114
+ userAgent: auditUa,
115
+ });
116
+ throw new NotFoundError("export-download", jobId, {
117
+ i18nKey: "userDataRights.errors.download.unavailable",
118
+ });
119
+ }
120
+ if (!jobRow.downloadStorageKey) {
121
+ await recordInvalidAttempt({
122
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
123
+ tenantId,
124
+ now,
125
+ result: "expired",
126
+ via: "job",
127
+ tokenHash: null,
128
+ jobId,
129
+ attemptedByUserId: userId,
130
+ ip: auditIp,
131
+ userAgent: auditUa,
132
+ });
133
+ throw new NotFoundError("export-download", jobId, {
134
+ i18nKey: "userDataRights.errors.download.expired",
135
+ });
136
+ }
137
+
138
+ const provider = await createFileProviderForTenant(
139
+ ctx,
140
+ jobRow.requestedFromTenantId,
141
+ "user-data-rights:query:download-by-job",
142
+ );
143
+ if (!provider.getSignedUrl) {
144
+ await recordInvalidAttempt({
145
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
146
+ tenantId,
147
+ now,
148
+ result: "signedUrlNotSupported",
149
+ via: "job",
150
+ tokenHash: null,
151
+ jobId,
152
+ attemptedByUserId: userId,
153
+ ip: auditIp,
154
+ userAgent: auditUa,
155
+ });
156
+ throw new UnprocessableError("storage_provider_signed_url_not_supported", {
157
+ i18nKey: "userDataRights.errors.download.signedUrlNotSupported",
158
+ });
159
+ }
160
+
161
+ const signedUrl = await provider.getSignedUrl(
162
+ jobRow.downloadStorageKey,
163
+ SIGNED_URL_TTL_SECONDS,
164
+ {
165
+ contentDisposition: `attachment; filename="user-data-export-${jobRow.id}.zip"`,
166
+ },
167
+ );
168
+ const signedUrlExpiresAt = T.Instant.fromEpochMilliseconds(
169
+ now.epochMilliseconds + SIGNED_URL_TTL_SECONDS * 1000,
170
+ );
171
+
172
+ // Step 6: Audit-Update via tokenRow-lookup. UI-Pfad benutzt nicht
173
+ // den plain-Token, aber wir wollen den useCount inkrementieren
174
+ // damit die Audit-Felder konsistent sind (UI-clicks zaehlen auch
175
+ // als Use). Lookup via jobId — UNIQUE-Index garantiert max 1 Row.
176
+ const tokenRow = (await fetchOne(
177
+ ctx.db.raw,
178
+ exportDownloadTokensTable,
179
+ eq(exportDownloadTokensTable["jobId"], jobId),
180
+ )) as TokenRow | null; // @cast-boundary db-row
181
+
182
+ if (tokenRow) {
183
+ await recordDownloadUse({
184
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
185
+ tokenId: tokenRow.id,
186
+ tokenVersion: tokenRow.version,
187
+ tokenUseCount: tokenRow.useCount ?? 0,
188
+ tenantId: jobRow.requestedFromTenantId as Parameters<
189
+ typeof recordDownloadUse
190
+ >[0]["tenantId"], // @cast-boundary engine-bridge
191
+ now,
192
+ ip: query.payload.auditMeta?.ip ?? null,
193
+ userAgent: query.payload.auditMeta?.userAgent ?? null,
194
+ });
195
+ }
196
+ // Wenn tokenRow fehlt (sollte nicht passieren wenn Atom 4a sauber
197
+ // lief): Audit-Update skipped, Download laeuft weiter. Niemand wird
198
+ // hier durch Audit-Failure blockiert.
199
+
200
+ return {
201
+ url: signedUrl,
202
+ expiresAt: signedUrlExpiresAt.toString(),
203
+ bytesWritten: jobRow.bytesWritten,
204
+ };
205
+ },
206
+ });