@cosmicdrift/kumiko-bundled-features 0.2.1 → 0.2.3

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 (100) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/package.json +12 -6
  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/login.write.ts +31 -1
  6. package/src/auth-email-password/i18n.ts +4 -0
  7. package/src/compliance-profiles/README.md +88 -0
  8. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  9. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  10. package/src/compliance-profiles/feature.ts +51 -0
  11. package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
  12. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  13. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  14. package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
  15. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  16. package/src/compliance-profiles/index.ts +6 -0
  17. package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
  18. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  19. package/src/compliance-profiles/seeding.ts +96 -0
  20. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  21. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  22. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  23. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  24. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  25. package/src/data-retention/_internal/parse-override.ts +33 -0
  26. package/src/data-retention/feature.ts +57 -0
  27. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  28. package/src/data-retention/index.ts +18 -0
  29. package/src/data-retention/keep-for.ts +75 -0
  30. package/src/data-retention/override-schema.ts +37 -0
  31. package/src/data-retention/presets.ts +72 -0
  32. package/src/data-retention/resolve-for-tenant.ts +50 -0
  33. package/src/data-retention/resolver.ts +107 -0
  34. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  35. package/src/file-foundation/feature.ts +43 -3
  36. package/src/file-foundation/index.ts +1 -0
  37. package/src/file-provider-inmemory/feature.ts +6 -3
  38. package/src/file-provider-s3/feature.ts +8 -10
  39. package/src/files/README.md +50 -0
  40. package/src/files/__tests__/files.integration.ts +157 -0
  41. package/src/files/feature.ts +34 -0
  42. package/src/files/index.ts +1 -0
  43. package/src/files/schema/file-ref.ts +58 -0
  44. package/src/files-provider-s3/s3-provider.ts +89 -0
  45. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  46. package/src/secrets/feature.ts +10 -6
  47. package/src/sessions/constants.ts +4 -0
  48. package/src/sessions/feature.ts +3 -0
  49. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  50. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  51. package/src/tier-engine/feature.ts +16 -6
  52. package/src/user/__tests__/user-status.test.ts +39 -0
  53. package/src/user/index.ts +11 -1
  54. package/src/user/schema/user.ts +76 -0
  55. package/src/user-data-rights/COMPLIANCE.md +182 -0
  56. package/src/user-data-rights/README.md +109 -0
  57. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  58. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  59. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  60. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  61. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  62. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  63. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  64. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  65. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  66. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  67. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  68. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  69. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  70. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  71. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  72. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  73. package/src/user-data-rights/audit-download.ts +125 -0
  74. package/src/user-data-rights/feature.ts +309 -0
  75. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  76. package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
  77. package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
  78. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  79. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  80. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  81. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  82. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  83. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  84. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  85. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  86. package/src/user-data-rights/i18n.ts +37 -0
  87. package/src/user-data-rights/index.ts +19 -0
  88. package/src/user-data-rights/run-export-jobs.ts +878 -0
  89. package/src/user-data-rights/run-forget-cleanup.ts +334 -0
  90. package/src/user-data-rights/run-user-export.ts +211 -0
  91. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  92. package/src/user-data-rights/schema/download-token.ts +111 -0
  93. package/src/user-data-rights/schema/export-job.ts +166 -0
  94. package/src/user-data-rights/token-helpers.ts +67 -0
  95. package/src/user-data-rights/zip-path.ts +94 -0
  96. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  97. package/src/user-data-rights-defaults/feature.ts +40 -0
  98. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  99. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  100. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -0,0 +1,257 @@
1
+ // GET /api/query → user-data-rights:query:download-by-token (S2.U3 Atom 4b).
2
+ //
3
+ // **Magic-Link-Pfad** (anonymous): User klickt Email-Link mit
4
+ // `?token=<plain>`. Worker hat beim done-flip Token-Hash in DB
5
+ // persistiert (Atom 4a). Verify-Pipeline:
6
+ //
7
+ // 1. hashDownloadToken(plain) → fetchOne in download-tokens
8
+ // 2. expiresAt > now (Multi-use within TTL — Plan-Decision)
9
+ // 3. job.status === "done"
10
+ // 4. job.downloadStorageKey != null (storage nicht gecleared)
11
+ // 5. provider.getSignedUrl pflicht (501 wenn Provider local-fs ist)
12
+ // 6. Audit-Update: useCount + 1, lastUsedAt, IP, UA (best-effort, race-tolerant)
13
+ // 7. Return {url, expiresAt}
14
+ //
15
+ // **Sicherheit:**
16
+ // - Token-Hash-Compare via DB-fetchOne (nicht constant-time, aber
17
+ // timing-attacks auf SHA256-bytes brauchen >>10k requests + stable
18
+ // latenz — in Web-App-Kontext nicht ausnutzbar). Plan-Decision: harden
19
+ // wenn Pen-Test es flaggt.
20
+ // - 404 bei invalidem Token (kein Existenz-Leak) — gleicher Code-Pfad
21
+ // wie nicht-gefundenes Token.
22
+ // - tenant-agnostic: Token ist global eindeutig (UUID + SHA256), kein
23
+ // Tenant-Filter nötig.
24
+ //
25
+ // **r.httpRoute-Wrapper** (siehe feature.ts) macht 302-Redirect zu
26
+ // signedUrl — User klickt 1× Email-Link, Browser folgt redirect, Download
27
+ // startet. Dieser query-handler liefert nur das JSON.
28
+
29
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
30
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
31
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
32
+ import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
33
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
34
+ import { eq } from "drizzle-orm";
35
+ import { z } from "zod";
36
+ import { createFileProviderForTenant } from "../../file-foundation";
37
+ import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
38
+ import { exportDownloadTokensTable } from "../schema/download-token";
39
+ import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
40
+ import { hashDownloadToken } from "../token-helpers";
41
+
42
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
43
+
44
+ const SIGNED_URL_TTL_SECONDS = 300; // 5 min — kurz genug fuer Replay-Schutz, lang genug fuer slow connections
45
+
46
+ interface TokenRow {
47
+ readonly id: string;
48
+ readonly version: number;
49
+ readonly jobId: string;
50
+ readonly expiresAt: Instant;
51
+ readonly useCount: number | null;
52
+ }
53
+
54
+ interface JobRow {
55
+ readonly id: string;
56
+ readonly userId: string;
57
+ readonly requestedFromTenantId: string;
58
+ readonly status: string;
59
+ readonly downloadStorageKey: string | null;
60
+ readonly bytesWritten: number | null;
61
+ }
62
+
63
+ export const downloadByTokenQuery = defineQueryHandler({
64
+ name: "download-by-token",
65
+ schema: z.object({
66
+ token: z.string().min(1, "token required"),
67
+ auditMeta: z
68
+ .object({
69
+ ip: z.string().nullable(),
70
+ userAgent: z.string().nullable(),
71
+ })
72
+ .optional(),
73
+ }),
74
+ access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
75
+ // Brute-Force-Schutz fuer Token-Hash-Probing. Anonymous-Endpoint mit
76
+ // 32-byte-Random-Token = 256 Bit Search-Space, aber rate-limit als
77
+ // defense-in-depth + Schutz gegen Storm-Patterns die DB-Last erzeugen.
78
+ // 30 attempts/min/IP reicht fuer legitime User (mehrere Klicks bei
79
+ // Connection-Abbruch); blockiert automatisierte Probing-Loops.
80
+ // Memory `feedback_security_default_on`.
81
+ rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
82
+ handler: async (query, ctx) => {
83
+ const T = getTemporal();
84
+ const now = T.Now.instant();
85
+
86
+ // Step 1: hash + lookup
87
+ const hash = await hashDownloadToken(query.payload.token);
88
+ // ctx.db.raw weil Token+Job tenant-agnostisch — anonymous-pfad hat
89
+ // keinen tenant-context im query.user.
90
+ const tokenRow = (await fetchOne(
91
+ ctx.db.raw,
92
+ exportDownloadTokensTable,
93
+ eq(exportDownloadTokensTable["tokenHash"], hash),
94
+ )) as TokenRow | null;
95
+
96
+ if (!tokenRow) {
97
+ // Invalid token — 404 ohne Existenz-Leak. Generic NotFoundError
98
+ // damit alle Failure-Pfade die gleiche externe Shape haben (kein
99
+ // Probing zwischen "Token existiert nicht" vs "Job ist failed").
100
+ throw new NotFoundError("export-download", undefined, {
101
+ i18nKey: "userDataRights.errors.download.notFound",
102
+ });
103
+ }
104
+
105
+ const auditIp = query.payload.auditMeta?.ip ?? null;
106
+ const auditUa = query.payload.auditMeta?.userAgent ?? null;
107
+
108
+ // Step 2: TTL-check.
109
+ //
110
+ // **Pragma:** semantisch waere 410 Gone richtig (war mal da, jetzt
111
+ // nicht mehr). Framework hat keine GoneError-Class; wir nutzen
112
+ // NotFoundError + i18nKey "expired" als Kompromiss. UI rendert
113
+ // anhand des i18nKeys, nicht des HTTP-Status — also User sieht
114
+ // "Dein Download ist abgelaufen", nicht generic "not found".
115
+ if (tokenRow.expiresAt.epochMilliseconds <= now.epochMilliseconds) {
116
+ // Audit-Skip noch nicht moeglich — jobRow noch nicht geladen,
117
+ // tenantId unbekannt. Wir laden den Job hier noch fuer Audit-Context
118
+ // (best-effort — wenn Job auch fehlt, audit-skip ist akzeptabel).
119
+ const jobForAudit = (await fetchOne(
120
+ ctx.db.raw,
121
+ exportJobsTable,
122
+ eq(exportJobsTable["id"], tokenRow.jobId),
123
+ )) as { requestedFromTenantId: string } | null;
124
+ if (jobForAudit) {
125
+ await recordInvalidAttempt({
126
+ db: ctx.db.raw as DbConnection,
127
+ tenantId: jobForAudit.requestedFromTenantId as Parameters<
128
+ typeof recordInvalidAttempt
129
+ >[0]["tenantId"],
130
+ now,
131
+ result: "expired",
132
+ via: "token",
133
+ tokenHash: hash,
134
+ jobId: tokenRow.jobId,
135
+ attemptedByUserId: null,
136
+ ip: auditIp,
137
+ userAgent: auditUa,
138
+ });
139
+ }
140
+ throw new NotFoundError("export-download", undefined, {
141
+ i18nKey: "userDataRights.errors.download.expired",
142
+ });
143
+ }
144
+
145
+ // Step 3-4: job-checks
146
+ const jobRow = (await fetchOne(
147
+ ctx.db.raw,
148
+ exportJobsTable,
149
+ eq(exportJobsTable["id"], tokenRow.jobId),
150
+ )) as JobRow | null;
151
+
152
+ if (!jobRow) {
153
+ throw new NotFoundError("export-download", undefined, {
154
+ i18nKey: "userDataRights.errors.download.notFound",
155
+ });
156
+ }
157
+ if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
158
+ await recordInvalidAttempt({
159
+ db: ctx.db.raw as DbConnection,
160
+ tenantId: jobRow.requestedFromTenantId as Parameters<
161
+ typeof recordInvalidAttempt
162
+ >[0]["tenantId"],
163
+ now,
164
+ result: "failed",
165
+ via: "token",
166
+ tokenHash: hash,
167
+ jobId: jobRow.id,
168
+ attemptedByUserId: null,
169
+ ip: auditIp,
170
+ userAgent: auditUa,
171
+ });
172
+ throw new NotFoundError("export-download", undefined, {
173
+ i18nKey: "userDataRights.errors.download.unavailable",
174
+ });
175
+ }
176
+ if (!jobRow.downloadStorageKey) {
177
+ await recordInvalidAttempt({
178
+ db: ctx.db.raw as DbConnection,
179
+ tenantId: jobRow.requestedFromTenantId as Parameters<
180
+ typeof recordInvalidAttempt
181
+ >[0]["tenantId"],
182
+ now,
183
+ result: "expired",
184
+ via: "token",
185
+ tokenHash: hash,
186
+ jobId: jobRow.id,
187
+ attemptedByUserId: null,
188
+ ip: auditIp,
189
+ userAgent: auditUa,
190
+ });
191
+ throw new NotFoundError("export-download", undefined, {
192
+ i18nKey: "userDataRights.errors.download.expired",
193
+ });
194
+ }
195
+
196
+ // Step 5: signed-URL via provider. createFileProviderForTenant nutzt
197
+ // requestedFromTenantId (gleicher Tenant wie beim Worker-Storage-Write).
198
+ const provider = await createFileProviderForTenant(
199
+ ctx,
200
+ jobRow.requestedFromTenantId,
201
+ "user-data-rights:query:download-by-token",
202
+ );
203
+ if (!provider.getSignedUrl) {
204
+ await recordInvalidAttempt({
205
+ db: ctx.db.raw as DbConnection,
206
+ tenantId: jobRow.requestedFromTenantId as Parameters<
207
+ typeof recordInvalidAttempt
208
+ >[0]["tenantId"],
209
+ now,
210
+ result: "signedUrlNotSupported",
211
+ via: "token",
212
+ tokenHash: hash,
213
+ jobId: jobRow.id,
214
+ attemptedByUserId: null,
215
+ ip: auditIp,
216
+ userAgent: auditUa,
217
+ });
218
+ throw new UnprocessableError("storage_provider_signed_url_not_supported", {
219
+ i18nKey: "userDataRights.errors.download.signedUrlNotSupported",
220
+ });
221
+ }
222
+
223
+ const signedUrl = await provider.getSignedUrl(
224
+ jobRow.downloadStorageKey,
225
+ SIGNED_URL_TTL_SECONDS,
226
+ {
227
+ contentDisposition: `attachment; filename="user-data-export-${jobRow.id}.zip"`,
228
+ },
229
+ );
230
+ const signedUrlExpiresAt = T.Instant.fromEpochMilliseconds(
231
+ now.epochMilliseconds + SIGNED_URL_TTL_SECONDS * 1000,
232
+ );
233
+
234
+ // Step 6: Audit-Update best-effort. auditMeta kommt vom httpRoute-
235
+ // Wrapper (trusted-source). Direct-API-caller koennen luegen, aber
236
+ // Audit ist nicht security-relevant.
237
+ await recordDownloadUse({
238
+ // @cast-boundary db: ctx.db.raw ist DbRunner (Connection|Tx),
239
+ // im query-handler-Pfad ist es immer Connection. Cast legit weil
240
+ // recordDownloadUse intern createTenantDb braucht (DbConnection).
241
+ db: ctx.db.raw as DbConnection,
242
+ tokenId: tokenRow.id,
243
+ tokenVersion: tokenRow.version,
244
+ tokenUseCount: tokenRow.useCount ?? 0,
245
+ tenantId: jobRow.requestedFromTenantId as Parameters<typeof recordDownloadUse>[0]["tenantId"],
246
+ now,
247
+ ip: query.payload.auditMeta?.ip ?? null,
248
+ userAgent: query.payload.auditMeta?.userAgent ?? null,
249
+ });
250
+
251
+ return {
252
+ url: signedUrl,
253
+ expiresAt: signedUrlExpiresAt.toString(),
254
+ bytesWritten: jobRow.bytesWritten,
255
+ };
256
+ },
257
+ });
@@ -0,0 +1,76 @@
1
+ // GET /api/user/export-status (S2.U3 Atom 2) — User-Polling.
2
+ //
3
+ // Liefert den meist-aktuellen ExportJob des aufrufenden Users (in
4
+ // Reihenfolge: aktiver Job zuerst, sonst neuester done/failed).
5
+ //
6
+ // **Cross-User-Isolation:** Filter ist `userId === query.user.id` — kein
7
+ // User kann fremde Job-Status sehen, auch nicht via ID-Guess. Pre-Check
8
+ // nutzt ctx.db.raw weil ExportJob tenant-agnostisch ist (Plan-Doc-
9
+ // "Cross-Tenant-Semantik").
10
+ //
11
+ // **Read-Only-Endpoint:** Pollt nur, kein State-Flip. Idempotent + cache-
12
+ // fest. UI poll-Intervall typisch 2-5s waehrend running.
13
+
14
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
15
+ import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
16
+ import { desc, eq } from "drizzle-orm";
17
+ import { z } from "zod";
18
+ import { exportJobsTable } from "../schema/export-job";
19
+
20
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
21
+
22
+ // @cast-boundary db-row — drizzle's typed-select gibt korrekte Shapes
23
+ // fuer instant-Spalten zurueck (Temporal.Instant), aber TS-Inference
24
+ // ueber TenantDb-Wrapper kennt das nicht. Cast auf den narrow-Shape
25
+ // macht den Read-Pfad explizit. requestedAt ist `notNull` im Schema
26
+ // → niemals null. Lifecycle-Felder (completedAt/expiresAt) sind
27
+ // nullable bis Worker sie setzt.
28
+ type ExportJobRow = {
29
+ readonly id: string;
30
+ readonly status: string;
31
+ readonly requestedAt: Instant;
32
+ readonly completedAt: Instant | null;
33
+ readonly expiresAt: Instant | null;
34
+ readonly errorMessage: string | null;
35
+ readonly bytesWritten: number | null;
36
+ };
37
+
38
+ export const exportStatusQuery = defineQueryHandler({
39
+ name: "export-status",
40
+ schema: z.object({}),
41
+ access: { openToAll: true },
42
+ handler: async (query, ctx) => {
43
+ // ctx.db.raw weil tenant-agnostisch — ein User der aus Tenant B
44
+ // pollt, sieht den aus Tenant A erstellten Job.
45
+ const rows = (await ctx.db.raw
46
+ .select({
47
+ id: exportJobsTable["id"],
48
+ status: exportJobsTable["status"],
49
+ requestedAt: exportJobsTable["requestedAt"],
50
+ completedAt: exportJobsTable["completedAt"],
51
+ expiresAt: exportJobsTable["expiresAt"],
52
+ errorMessage: exportJobsTable["errorMessage"],
53
+ bytesWritten: exportJobsTable["bytesWritten"],
54
+ })
55
+ .from(exportJobsTable)
56
+ .where(eq(exportJobsTable["userId"], query.user.id))
57
+ .orderBy(desc(exportJobsTable["requestedAt"]))
58
+ .limit(1)) as ExportJobRow[];
59
+
60
+ const latest = rows[0];
61
+ if (!latest) return { hasJob: false as const };
62
+
63
+ return {
64
+ hasJob: true as const,
65
+ job: {
66
+ id: latest.id,
67
+ status: latest.status,
68
+ requestedAt: latest.requestedAt.toString(),
69
+ completedAt: latest.completedAt?.toString() ?? null,
70
+ expiresAt: latest.expiresAt?.toString() ?? null,
71
+ errorMessage: latest.errorMessage,
72
+ bytesWritten: latest.bytesWritten,
73
+ },
74
+ };
75
+ },
76
+ });
@@ -0,0 +1,68 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { USER_STATUS, userTable } from "../../user";
6
+
7
+ // POST /api/user/lift-restriction (S2.U6) — DSGVO Art. 18 Reverse.
8
+ //
9
+ // **Wichtige Eigenheit:** Der User kann diesen Endpoint NICHT selber
10
+ // aufrufen weil sein Login geblockt ist (Restricted-Status, siehe
11
+ // login.write.ts Atom 3). Wer ein Restricted-Konto wieder aktiviert,
12
+ // muss dafuer einen anderen Pfad nutzen — typisch Operator-Tool oder
13
+ // Email-Magic-Link an die User-Email. App-Author entscheidet das per
14
+ // access-Konfig oder Custom-Wrapper.
15
+ //
16
+ // MVP-Default: openToAll mit Self-Service-Semantik. Die Asymmetrie
17
+ // (User koennte sich selbst freischalten WENN er einen Weg ohne Login
18
+ // hat — z.B. valid Magic-Link aus pre-Restriction) ist akzeptabel:
19
+ // Restriction ist *Verarbeitungs-Pause*, nicht *Sperre durch Operator*.
20
+ // User der "ich will doch wieder mitmachen" sagt, soll das koennen.
21
+ //
22
+ // State-Transitions:
23
+ // Restricted → Active ✓
24
+ // Active → ... ✗ 422 not_restricted (Idempotenz-Guard)
25
+ // DeletionRequested → ... ✗ 422 not_restricted
26
+ // Deleted → ... ✗ 422 not_restricted
27
+ export const liftRestrictionWrite = defineWriteHandler({
28
+ name: "lift-restriction",
29
+ schema: z.object({}),
30
+ access: { openToAll: true },
31
+ handler: async (event, ctx) => {
32
+ const userRow = await ctx.db.raw
33
+ .select({ status: userTable["status"] })
34
+ .from(userTable)
35
+ .where(eq(userTable["id"], event.user.id))
36
+ .limit(1);
37
+
38
+ if (userRow.length === 0) {
39
+ return writeFailure(
40
+ new UnprocessableError("user_not_found", {
41
+ details: { reason: "user_not_found", userId: event.user.id },
42
+ }),
43
+ );
44
+ }
45
+
46
+ const currentStatus = userRow[0]?.status;
47
+ if (currentStatus !== USER_STATUS.Restricted) {
48
+ return writeFailure(
49
+ new UnprocessableError("not_restricted", {
50
+ details: { reason: "not_restricted", currentStatus },
51
+ }),
52
+ );
53
+ }
54
+
55
+ await ctx.db.raw
56
+ .update(userTable)
57
+ .set({ status: USER_STATUS.Active })
58
+ .where(eq(userTable["id"], event.user.id));
59
+
60
+ return {
61
+ isSuccess: true as const,
62
+ data: {
63
+ userId: event.user.id,
64
+ status: USER_STATUS.Active,
65
+ },
66
+ };
67
+ },
68
+ });
@@ -0,0 +1,53 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { and, desc, eq, gte, lte } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { downloadAttemptsTable } from "../schema/download-attempt";
5
+
6
+ // Operator-Query: invalid Download-Attempts (S2.U7).
7
+ // DPO-Sicht fuer Brute-Force-Detection. Tenant-isolated via WHERE.
8
+
9
+ const MAX_LIMIT = 100;
10
+
11
+ export const listDownloadAttemptsQuery = defineQueryHandler({
12
+ name: "list-download-attempts",
13
+ schema: z
14
+ .object({
15
+ limit: z.number().int().min(1).max(MAX_LIMIT).default(50),
16
+ result: z.enum(["notFound", "expired", "failed", "signedUrlNotSupported"]).optional(),
17
+ ip: z.string().optional(),
18
+ from: z.iso.datetime().optional(),
19
+ to: z.iso.datetime().optional(),
20
+ })
21
+ .refine((v) => !v.from || !v.to || v.from <= v.to, {
22
+ message: "`from` must be less than or equal to `to`",
23
+ }),
24
+ access: { roles: ["Admin", "SystemAdmin"] },
25
+ handler: async (query, ctx) => {
26
+ const p = query.payload;
27
+ const t = downloadAttemptsTable;
28
+ const conditions = [eq(t["tenantId"], query.user.tenantId)];
29
+ if (p.result) conditions.push(eq(t["result"], p.result));
30
+ if (p.ip) conditions.push(eq(t["ip"], p.ip));
31
+ if (p.from) conditions.push(gte(t["attemptedAt"], Temporal.Instant.from(p.from)));
32
+ if (p.to) conditions.push(lte(t["attemptedAt"], Temporal.Instant.from(p.to)));
33
+
34
+ const rows = await ctx.db
35
+ .select({
36
+ id: t["id"],
37
+ result: t["result"],
38
+ via: t["via"],
39
+ tokenHash: t["tokenHash"],
40
+ jobId: t["jobId"],
41
+ attemptedByUserId: t["attemptedByUserId"],
42
+ ip: t["ip"],
43
+ userAgent: t["userAgent"],
44
+ attemptedAt: t["attemptedAt"],
45
+ })
46
+ .from(t)
47
+ .where(and(...conditions))
48
+ .orderBy(desc(t["attemptedAt"]))
49
+ .limit(p.limit);
50
+
51
+ return { rows };
52
+ },
53
+ });
@@ -0,0 +1,63 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
3
+ import { and, desc, eq, gte, lt, lte } from "drizzle-orm";
4
+ import { z } from "zod";
5
+
6
+ // DSGVO Art. 15 Selbstauskunft — User reads HIS OWN audit-log.
7
+ // WHERE createdBy = ctx.user.id ist hard-coded (kein userId-Param,
8
+ // anti-cross-user-Snooping). KEIN tenantId-Filter: User hat Anspruch
9
+ // auf account-weite Sicht ueber alle Memberships — analog Forget-Pfad.
10
+ const MAX_LIMIT = 100;
11
+
12
+ export const myAuditLogQuery = defineQueryHandler({
13
+ name: "my-audit-log",
14
+ schema: z
15
+ .object({
16
+ before: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
17
+ limit: z.number().int().min(1).max(MAX_LIMIT).default(50),
18
+ aggregateType: z.string().optional(),
19
+ eventType: z.string().optional(),
20
+ from: z.iso.datetime().optional(),
21
+ to: z.iso.datetime().optional(),
22
+ })
23
+ .refine((v) => !v.from || !v.to || v.from <= v.to, {
24
+ message: "`from` must be less than or equal to `to`",
25
+ path: ["from"],
26
+ }),
27
+ access: { openToAll: true },
28
+ handler: async (query, ctx) => {
29
+ const p = query.payload;
30
+
31
+ const conditions = [eq(eventsTable.createdBy, query.user.id)];
32
+ if (p.aggregateType) conditions.push(eq(eventsTable.aggregateType, p.aggregateType));
33
+ if (p.eventType) conditions.push(eq(eventsTable.type, p.eventType));
34
+ if (p.from) conditions.push(gte(eventsTable.createdAt, Temporal.Instant.from(p.from)));
35
+ if (p.to) conditions.push(lte(eventsTable.createdAt, Temporal.Instant.from(p.to)));
36
+ if (p.before) conditions.push(lt(eventsTable.id, BigInt(p.before)));
37
+
38
+ // ctx.db.raw weil events-table tenantId-Spalte hat und TenantDb
39
+ // sonst auto-filtert auf currentTenant. Account-weite Sicht ist
40
+ // hier explizit gewollt; Sicherung erfolgt via createdBy-Filter.
41
+ const rows = await ctx.db.raw
42
+ .select({
43
+ id: eventsTable.id,
44
+ aggregateId: eventsTable.aggregateId,
45
+ aggregateType: eventsTable.aggregateType,
46
+ version: eventsTable.version,
47
+ type: eventsTable.type,
48
+ payload: eventsTable.payload,
49
+ createdAt: eventsTable.createdAt,
50
+ })
51
+ .from(eventsTable)
52
+ .where(and(...conditions))
53
+ .orderBy(desc(eventsTable.id))
54
+ .limit(p.limit);
55
+
56
+ const serialised = rows.map((r) => ({ ...r, id: String(r["id"]) }));
57
+ const last = serialised[serialised.length - 1];
58
+ return {
59
+ rows: serialised,
60
+ nextBefore: serialised.length === p.limit && last ? last["id"] : null,
61
+ };
62
+ },
63
+ });
@@ -0,0 +1,123 @@
1
+ import { addDurationSpec, type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
2
+ import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
5
+ import { eq } from "drizzle-orm";
6
+ import { z } from "zod";
7
+ import { USER_STATUS, userTable } from "../../user";
8
+
9
+ // Atom 5b — Email-Notification beim deletion-requested-flip. Pattern:
10
+ // password-reset-Callback aus auth-routes.ts. Best-effort — Throw beim
11
+ // Send wird gefangen + per console.warn geloggt, der Status-Flip selbst
12
+ // bleibt erfolgreich. Reasoning: user-Aktion ist abgeschlossen sobald
13
+ // die DB-Row geflipt ist; Email-Versand ist Beleg, keine Vorbedingung.
14
+ // Wenn Email broken ist soll der User nicht erneut "Account löschen"
15
+ // klicken muessen.
16
+ export type SendDeletionRequestedEmailFn = (args: {
17
+ readonly userId: string;
18
+ readonly userEmail: string;
19
+ readonly tenantId: string;
20
+ readonly gracePeriodEnd: string;
21
+ }) => Promise<void>;
22
+
23
+ export type RequestDeletionOptions = {
24
+ readonly sendDeletionRequestedEmail?: SendDeletionRequestedEmailFn;
25
+ };
26
+
27
+ // POST /api/user/request-deletion (S2.U5a) — DSGVO Art. 17 Forget-Antrag.
28
+ // Flippt status=Active → deletionRequested, setzt gracePeriodEnd aus
29
+ // Compliance-Profile. Account-weite Semantik (1 User-Row global), siehe
30
+ // docs/plans/architecture/user-data-rights.md "Cross-Tenant-Semantik".
31
+ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {}) {
32
+ return defineWriteHandler({
33
+ name: "request-deletion",
34
+ schema: z.object({}),
35
+ access: { openToAll: true },
36
+ handler: async (event, ctx) => {
37
+ // ctx.db.raw (kein TenantDb-Wrapper) weil User-Entity tenant-agnostisch
38
+ // ist — siehe Plan-Doc Cross-Tenant-Section.
39
+ const userRow = await ctx.db.raw
40
+ .select({ status: userTable["status"], email: userTable["email"] })
41
+ .from(userTable)
42
+ .where(eq(userTable["id"], event.user.id))
43
+ .limit(1);
44
+
45
+ if (userRow.length === 0) {
46
+ return writeFailure(
47
+ new UnprocessableError("user_not_found", {
48
+ details: { reason: "user_not_found", userId: event.user.id },
49
+ }),
50
+ );
51
+ }
52
+
53
+ if (userRow[0]?.status !== USER_STATUS.Active) {
54
+ return writeFailure(
55
+ new UnprocessableError("user_not_in_active_state", {
56
+ details: {
57
+ reason: "user_not_in_active_state",
58
+ currentStatus: userRow[0]?.status,
59
+ },
60
+ }),
61
+ );
62
+ }
63
+
64
+ // Compliance-Profile fuer gracePeriod via Cross-Feature-Query. Pattern:
65
+ // ctx.queryAs(user, qn, payload) — siehe auth-email-password/change-
66
+ // password.write.ts. @cast-boundary engine-bridge — queryAs liefert
67
+ // unknown, narrow auf den effektiven Profile-Shape.
68
+ const profile = (await ctx.queryAs(
69
+ createSystemUser(event.user.tenantId),
70
+ "compliance-profiles:query:for-tenant",
71
+ {},
72
+ )) as { profile: { userRights: { gracePeriod: DurationSpec } } };
73
+
74
+ // addDurationSpec deckt `{days}` und `{hours}` ab. App-Server-Clock
75
+ // ist authoritative — instant() customType nimmt Temporal.Instant
76
+ // direkt, kein SQL-interval-Bypass des Codecs.
77
+ const gracePeriod = profile.profile.userRights.gracePeriod;
78
+ const T = getTemporal();
79
+ const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
80
+
81
+ await ctx.db.raw
82
+ .update(userTable)
83
+ .set({
84
+ status: USER_STATUS.DeletionRequested,
85
+ gracePeriodEnd,
86
+ })
87
+ .where(eq(userTable["id"], event.user.id));
88
+
89
+ // Best-effort Email-Notification. Send-Failure darf das Write nicht
90
+ // killen — siehe Type-Doc oben. console.warn ist die Operator-
91
+ // Sichtbarkeit; defineWriteHandler-Context fuehrt aktuell keinen
92
+ // structured-logger durch, Refactor-Kandidat wenn ctx.log threadet.
93
+ const userEmail = userRow[0]?.email;
94
+ if (opts.sendDeletionRequestedEmail && userEmail && userEmail.length > 0) {
95
+ try {
96
+ await opts.sendDeletionRequestedEmail({
97
+ userId: event.user.id,
98
+ userEmail,
99
+ tenantId: event.user.tenantId,
100
+ gracePeriodEnd: gracePeriodEnd.toString(),
101
+ });
102
+ } catch (err) {
103
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for email-send-failure
104
+ console.warn(
105
+ `[user-data-rights:request-deletion] sendDeletionRequestedEmail failed userId=${event.user.id} tenantId=${event.user.tenantId} err=${err instanceof Error ? err.message : String(err)}`,
106
+ );
107
+ }
108
+ }
109
+
110
+ // Response liefert den absoluten gracePeriodEnd-Timestamp damit
111
+ // Frontend/Audit/Cleanup-Runner alle denselben Wert lesen — nicht
112
+ // den Input-`{days|hours}`, der ist Konfiguration nicht Result.
113
+ return {
114
+ isSuccess: true as const,
115
+ data: {
116
+ userId: event.user.id,
117
+ status: USER_STATUS.DeletionRequested,
118
+ gracePeriodEnd: gracePeriodEnd.toString(),
119
+ },
120
+ };
121
+ },
122
+ });
123
+ }