@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.
- package/CHANGELOG.md +91 -0
- package/package.json +22 -13
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
- package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
- package/src/auth-email-password/handlers/login.write.ts +32 -2
- package/src/auth-email-password/handlers/logout.write.ts +2 -2
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/auth-client.ts +1 -1
- package/src/billing-foundation/events.ts +1 -1
- package/src/billing-foundation/feature.ts +44 -47
- package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +3 -3
- package/src/billing-foundation/projection.ts +1 -1
- package/src/billing-foundation/webhook-handler.ts +1 -1
- package/src/cap-counter/constants.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +1 -1
- package/src/cap-counter/feature.ts +3 -7
- package/src/cap-counter/handlers/get-counter.query.ts +1 -1
- package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
- package/src/cap-counter/handlers/increment.write.ts +3 -3
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-email/email-channel.ts +1 -1
- package/src/channel-email/types.ts +1 -1
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/config/resolver.ts +1 -1
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +34 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/testing.ts +1 -2
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +9 -2
- package/src/feature-toggles/handlers/set.write.ts +3 -3
- package/src/file-foundation/feature.ts +44 -4
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +10 -12
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +90 -1
- package/src/jobs/handlers/list.query.ts +3 -3
- package/src/jobs/handlers/trigger.write.ts +1 -1
- package/src/legal-pages/constants.ts +1 -0
- package/src/legal-pages/web/client-plugin.ts +42 -0
- package/src/legal-pages/web/index.ts +4 -0
- package/src/mail-foundation/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +2 -2
- package/src/renderer-simple/simple-renderer.ts +1 -1
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/secrets/handlers/rotate.job.ts +2 -2
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/cleanup.job.ts +2 -2
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/step-dispatcher/feature.ts +62 -0
- package/src/step-dispatcher/index.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +32 -0
- package/src/step-dispatcher/webhook-runner.ts +67 -0
- package/src/subscription-mollie/plugin-methods.ts +1 -1
- package/src/subscription-mollie/verify-webhook.ts +9 -5
- package/src/subscription-stripe/verify-webhook.ts +3 -3
- package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
- package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
- package/src/tenant/handlers/remove-member.write.ts +1 -1
- package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
- package/src/tenant/handlers/update-member-roles.write.ts +3 -3
- package/src/text-content/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-tenant.query.ts +56 -0
- package/src/text-content/handlers/set.write.ts +1 -1
- package/src/text-content/web/client-plugin.ts +113 -0
- package/src/text-content/web/index.ts +8 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +23 -13
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user/seeding.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +310 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +333 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,255 @@
|
|
|
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; // @cast-boundary db-row
|
|
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; // @cast-boundary db-row
|
|
124
|
+
if (jobForAudit) {
|
|
125
|
+
const auditDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
|
|
126
|
+
await recordInvalidAttempt({
|
|
127
|
+
db: auditDb,
|
|
128
|
+
tenantId: jobForAudit.requestedFromTenantId as Parameters<
|
|
129
|
+
typeof recordInvalidAttempt
|
|
130
|
+
>[0]["tenantId"], // @cast-boundary engine-bridge
|
|
131
|
+
now,
|
|
132
|
+
result: "expired",
|
|
133
|
+
via: "token",
|
|
134
|
+
tokenHash: hash,
|
|
135
|
+
jobId: tokenRow.jobId,
|
|
136
|
+
attemptedByUserId: null,
|
|
137
|
+
ip: auditIp,
|
|
138
|
+
userAgent: auditUa,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
throw new NotFoundError("export-download", undefined, {
|
|
142
|
+
i18nKey: "userDataRights.errors.download.expired",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Step 3-4: job-checks
|
|
147
|
+
const jobRow = (await fetchOne(
|
|
148
|
+
ctx.db.raw,
|
|
149
|
+
exportJobsTable,
|
|
150
|
+
eq(exportJobsTable["id"], tokenRow.jobId),
|
|
151
|
+
)) as JobRow | null; // @cast-boundary db-row
|
|
152
|
+
|
|
153
|
+
if (!jobRow) {
|
|
154
|
+
throw new NotFoundError("export-download", undefined, {
|
|
155
|
+
i18nKey: "userDataRights.errors.download.notFound",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
|
|
159
|
+
await recordInvalidAttempt({
|
|
160
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
161
|
+
tenantId: jobRow.requestedFromTenantId as Parameters<
|
|
162
|
+
typeof recordInvalidAttempt
|
|
163
|
+
>[0]["tenantId"], // @cast-boundary engine-bridge
|
|
164
|
+
now,
|
|
165
|
+
result: "failed",
|
|
166
|
+
via: "token",
|
|
167
|
+
tokenHash: hash,
|
|
168
|
+
jobId: jobRow.id,
|
|
169
|
+
attemptedByUserId: null,
|
|
170
|
+
ip: auditIp,
|
|
171
|
+
userAgent: auditUa,
|
|
172
|
+
});
|
|
173
|
+
throw new NotFoundError("export-download", undefined, {
|
|
174
|
+
i18nKey: "userDataRights.errors.download.unavailable",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (!jobRow.downloadStorageKey) {
|
|
178
|
+
await recordInvalidAttempt({
|
|
179
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
180
|
+
tenantId: jobRow.requestedFromTenantId as Parameters<
|
|
181
|
+
typeof recordInvalidAttempt
|
|
182
|
+
>[0]["tenantId"], // @cast-boundary engine-bridge
|
|
183
|
+
now,
|
|
184
|
+
result: "expired",
|
|
185
|
+
via: "token",
|
|
186
|
+
tokenHash: hash,
|
|
187
|
+
jobId: jobRow.id,
|
|
188
|
+
attemptedByUserId: null,
|
|
189
|
+
ip: auditIp,
|
|
190
|
+
userAgent: auditUa,
|
|
191
|
+
});
|
|
192
|
+
throw new NotFoundError("export-download", undefined, {
|
|
193
|
+
i18nKey: "userDataRights.errors.download.expired",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Step 5: signed-URL via provider. createFileProviderForTenant nutzt
|
|
198
|
+
// requestedFromTenantId (gleicher Tenant wie beim Worker-Storage-Write).
|
|
199
|
+
const provider = await createFileProviderForTenant(
|
|
200
|
+
ctx,
|
|
201
|
+
jobRow.requestedFromTenantId,
|
|
202
|
+
"user-data-rights:query:download-by-token",
|
|
203
|
+
);
|
|
204
|
+
if (!provider.getSignedUrl) {
|
|
205
|
+
await recordInvalidAttempt({
|
|
206
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
207
|
+
tenantId: jobRow.requestedFromTenantId as Parameters<
|
|
208
|
+
typeof recordInvalidAttempt
|
|
209
|
+
>[0]["tenantId"], // @cast-boundary engine-bridge
|
|
210
|
+
now,
|
|
211
|
+
result: "signedUrlNotSupported",
|
|
212
|
+
via: "token",
|
|
213
|
+
tokenHash: hash,
|
|
214
|
+
jobId: jobRow.id,
|
|
215
|
+
attemptedByUserId: null,
|
|
216
|
+
ip: auditIp,
|
|
217
|
+
userAgent: auditUa,
|
|
218
|
+
});
|
|
219
|
+
throw new UnprocessableError("storage_provider_signed_url_not_supported", {
|
|
220
|
+
i18nKey: "userDataRights.errors.download.signedUrlNotSupported",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const signedUrl = await provider.getSignedUrl(
|
|
225
|
+
jobRow.downloadStorageKey,
|
|
226
|
+
SIGNED_URL_TTL_SECONDS,
|
|
227
|
+
{
|
|
228
|
+
contentDisposition: `attachment; filename="user-data-export-${jobRow.id}.zip"`,
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
const signedUrlExpiresAt = T.Instant.fromEpochMilliseconds(
|
|
232
|
+
now.epochMilliseconds + SIGNED_URL_TTL_SECONDS * 1000,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Step 6: Audit-Update best-effort. auditMeta kommt vom httpRoute-
|
|
236
|
+
// Wrapper (trusted-source). Direct-API-caller koennen luegen, aber
|
|
237
|
+
// Audit ist nicht security-relevant.
|
|
238
|
+
await recordDownloadUse({
|
|
239
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
240
|
+
tokenId: tokenRow.id,
|
|
241
|
+
tokenVersion: tokenRow.version,
|
|
242
|
+
tokenUseCount: tokenRow.useCount ?? 0,
|
|
243
|
+
tenantId: jobRow.requestedFromTenantId as Parameters<typeof recordDownloadUse>[0]["tenantId"], // @cast-boundary engine-bridge
|
|
244
|
+
now,
|
|
245
|
+
ip: query.payload.auditMeta?.ip ?? null,
|
|
246
|
+
userAgent: query.payload.auditMeta?.userAgent ?? null,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
url: signedUrl,
|
|
251
|
+
expiresAt: signedUrlExpiresAt.toString(),
|
|
252
|
+
bytesWritten: jobRow.bytesWritten,
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
});
|
|
@@ -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[]; // @cast-boundary db-row
|
|
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 } } }; // @cast-boundary engine-payload
|
|
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
|
+
}
|