@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,333 @@
|
|
|
1
|
+
// Forget-Cleanup-Runner (S2.U5b) — pure-Function Pipeline.
|
|
2
|
+
//
|
|
3
|
+
// Nach abgelaufener Grace-Period (S2.U5a setzt gracePeriodEnd) iteriert
|
|
4
|
+
// dieser Runner ueber alle User in DeletionRequested-State und triggert
|
|
5
|
+
// die EXT_USER_DATA-delete-Hooks pro Membership-Tenant.
|
|
6
|
+
//
|
|
7
|
+
// **Cross-Tenant-Iteration:** Ein User-Forget-Antrag in Tenant A muss
|
|
8
|
+
// die Daten des Users in ALLEN seinen Tenants entfernen — siehe
|
|
9
|
+
// docs/plans/architecture/user-data-rights.md "Cross-Tenant-Semantik".
|
|
10
|
+
//
|
|
11
|
+
// **Strategy-Dispatch:** Pro Entity entscheidet die data-retention-
|
|
12
|
+
// policy (per-Tenant Override moeglich), ob "delete" oder "anonymize"
|
|
13
|
+
// gefahren wird. blockDelete (Aufbewahrungs-Pflicht) ergibt zwingend
|
|
14
|
+
// "anonymize" — Daten-Objekt bleibt physisch da, Personen-Bezug raus.
|
|
15
|
+
//
|
|
16
|
+
// **Per-User-Atomicity (advisor-pinned):** Jeder User wird in einer
|
|
17
|
+
// eigenen Sub-Transaction abgewickelt (db.transaction → SAVEPOINT wenn
|
|
18
|
+
// Outer-Tx aktiv, BEGIN sonst). Folge: ein failing Hook bei User A
|
|
19
|
+
// rollt nur dessen Sub-Tx zurueck, User B + bisherige User-Status-Flips
|
|
20
|
+
// bleiben commit-able. Ohne diese Sub-Tx wuerde der Outer-Dispatcher-Tx
|
|
21
|
+
// (alle writeHandler laufen in `db.transaction(...)`) den ganzen
|
|
22
|
+
// Cleanup-Run beim ersten Hook-Throw zurueckrollen.
|
|
23
|
+
//
|
|
24
|
+
// **Idempotenz:** Hooks sind idempotent designed (siehe
|
|
25
|
+
// engine/extensions/user-data.ts). Doppellauf nach Crash-Recovery muss
|
|
26
|
+
// safe sein. Status-Flip auf "Deleted" am Ende sorgt dafuer, dass next
|
|
27
|
+
// Lauf den User nicht mehr findet.
|
|
28
|
+
//
|
|
29
|
+
// **Error-Handling:** Ein hook der wirft soll den Lauf NICHT stoppen —
|
|
30
|
+
// andere User sollen weiter abgearbeitet werden. Errors werden
|
|
31
|
+
// gesammelt + zurueckgegeben fuer Operator-Visibility. Ein User mit
|
|
32
|
+
// gefailten Hooks bleibt im DeletionRequested-Status (next Lauf
|
|
33
|
+
// retried automatisch).
|
|
34
|
+
|
|
35
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
36
|
+
import {
|
|
37
|
+
EXT_USER_DATA,
|
|
38
|
+
type Registry,
|
|
39
|
+
type TenantId,
|
|
40
|
+
type UserDataDeleteHook,
|
|
41
|
+
type UserDataDeleteStrategy,
|
|
42
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
43
|
+
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
44
|
+
import { and, eq, lte } from "drizzle-orm";
|
|
45
|
+
import { resolveRetentionPolicyForTenant } from "../data-retention";
|
|
46
|
+
import { tenantMembershipsTable } from "../tenant";
|
|
47
|
+
import { USER_STATUS, userTable } from "../user";
|
|
48
|
+
|
|
49
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Notification-Callback fuer den Forget-Cleanup-Pfad (Atom 5b). Pattern
|
|
53
|
+
* matched Atom 5 (Export). Throw bubbelt zum r.job-Wrap; jobs-feature
|
|
54
|
+
* persistiert den failed-Run in jobRunsTable (siehe
|
|
55
|
+
* jobs/__tests__/jobs-feature.integration.ts Scenario 2 — der throw-
|
|
56
|
+
* Pfad eines r.job-handlers wird dort gepinnt).
|
|
57
|
+
*
|
|
58
|
+
* **executedAt:** Zeitpunkt des delete-Flips. Wird als ISO-String
|
|
59
|
+
* uebergeben damit App-Author den frei in Email-Template einbauen kann.
|
|
60
|
+
*
|
|
61
|
+
* **tenantIds:** alle Memberships die der User vor dem Delete hatte.
|
|
62
|
+
* Email-Template kann das nutzen ("dein Account in Tenant X+Y wurde
|
|
63
|
+
* geloescht"). Bei orphan-User (0 Memberships) ist die Liste leer.
|
|
64
|
+
*/
|
|
65
|
+
export type SendDeletionExecutedEmailFn = (args: {
|
|
66
|
+
readonly userId: string;
|
|
67
|
+
readonly userEmail: string;
|
|
68
|
+
readonly tenantIds: readonly TenantId[];
|
|
69
|
+
readonly executedAt: string;
|
|
70
|
+
}) => Promise<void>;
|
|
71
|
+
|
|
72
|
+
export interface RunForgetCleanupArgs {
|
|
73
|
+
readonly db: DbRunner;
|
|
74
|
+
readonly registry: Registry;
|
|
75
|
+
/**
|
|
76
|
+
* Now-Injection — Tests koennen den Wert pinnen ohne Date-Mock.
|
|
77
|
+
* Pattern aus data-retention/keep-for.ts (advisor-pinned).
|
|
78
|
+
*/
|
|
79
|
+
readonly now: Instant;
|
|
80
|
+
|
|
81
|
+
/** Atom 5b — Email-Notification beim delete-flip. Optional;
|
|
82
|
+
* ohne Callback laeuft Worker still (User hatte schon
|
|
83
|
+
* request-deletion-Email + grace-period-Erinnerung). */
|
|
84
|
+
readonly sendDeletionExecutedEmail?: SendDeletionExecutedEmailFn;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ForgetCleanupError {
|
|
88
|
+
readonly userId: string;
|
|
89
|
+
readonly tenantId: TenantId;
|
|
90
|
+
readonly entityName: string;
|
|
91
|
+
readonly message: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface RunForgetCleanupResult {
|
|
95
|
+
/** User die in diesem Lauf von DeletionRequested → Deleted geflippt wurden. */
|
|
96
|
+
readonly processedUserIds: readonly string[];
|
|
97
|
+
/** Anzahl entity-hook-calls die wirklich gelaufen sind (success oder fail). */
|
|
98
|
+
readonly hookCallsAttempted: number;
|
|
99
|
+
/** Hook-Errors fuer Operator-Visibility. Lauf bricht nicht ab — siehe Header. */
|
|
100
|
+
readonly errors: readonly ForgetCleanupError[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface HookEntry {
|
|
104
|
+
readonly entityName: string;
|
|
105
|
+
readonly deleteHook: UserDataDeleteHook;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function runForgetCleanup(
|
|
109
|
+
args: RunForgetCleanupArgs,
|
|
110
|
+
): Promise<RunForgetCleanupResult> {
|
|
111
|
+
const { db, registry, now, sendDeletionExecutedEmail } = args;
|
|
112
|
+
|
|
113
|
+
// Step 1: Find users with expired grace period.
|
|
114
|
+
// @cast-boundary db-row — drizzle-select gibt Record-Shape zurueck.
|
|
115
|
+
const dueUsers = (await db
|
|
116
|
+
.select({ id: userTable["id"] })
|
|
117
|
+
.from(userTable)
|
|
118
|
+
.where(
|
|
119
|
+
and(
|
|
120
|
+
eq(userTable["status"], USER_STATUS.DeletionRequested),
|
|
121
|
+
lte(userTable["gracePeriodEnd"], now),
|
|
122
|
+
),
|
|
123
|
+
)) as Array<{ id: string }>;
|
|
124
|
+
|
|
125
|
+
if (dueUsers.length === 0) {
|
|
126
|
+
return { processedUserIds: [], hookCallsAttempted: 0, errors: [] };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Step 2: Sammle alle EXT_USER_DATA-Usages einmalig — Liste der
|
|
130
|
+
// (entityName, deleteHook)-Pairs aller registrierten Provider-Features.
|
|
131
|
+
const usages = registry.getExtensionUsages(EXT_USER_DATA);
|
|
132
|
+
const hookEntries: HookEntry[] = usages
|
|
133
|
+
.map((u): HookEntry | null => {
|
|
134
|
+
const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook }; // @cast-boundary engine-payload
|
|
135
|
+
return opts.delete ? { entityName: u.entityName, deleteHook: opts.delete } : null;
|
|
136
|
+
})
|
|
137
|
+
.filter((x): x is HookEntry => x !== null);
|
|
138
|
+
|
|
139
|
+
const errors: ForgetCleanupError[] = [];
|
|
140
|
+
const processedUserIds: string[] = [];
|
|
141
|
+
let hookCallsAttempted = 0;
|
|
142
|
+
|
|
143
|
+
// Step 3: Pro User iterieren — eigene Sub-Tx pro User (siehe Header).
|
|
144
|
+
for (const user of dueUsers) {
|
|
145
|
+
const userResult = await processUser({
|
|
146
|
+
db,
|
|
147
|
+
registry,
|
|
148
|
+
userId: user.id,
|
|
149
|
+
hookEntries,
|
|
150
|
+
});
|
|
151
|
+
hookCallsAttempted += userResult.hookCallsAttempted;
|
|
152
|
+
errors.push(...userResult.errors);
|
|
153
|
+
if (userResult.success) {
|
|
154
|
+
processedUserIds.push(user.id);
|
|
155
|
+
|
|
156
|
+
// Atom 5b — Email-Notification nach success-flip. userEmail wurde
|
|
157
|
+
// VOR der Tx gecacht (user-Hook anonymisiert in der Tx).
|
|
158
|
+
//
|
|
159
|
+
// Best-effort: ein Email-Throw fuer User A darf nicht den Batch
|
|
160
|
+
// killen — User A ist bereits geloescht (Sub-Tx committed), und
|
|
161
|
+
// die Users B, C, ... muessen noch verarbeitet werden. Throw waere
|
|
162
|
+
// hier ein Bug: r.job-Wrap markiert den Run failed, retry findet
|
|
163
|
+
// keine User mehr im DeletionRequested+grace-expired-Status (alle
|
|
164
|
+
// schon Deleted) → silent miss. console.warn ist die einzige
|
|
165
|
+
// Operator-Sichtbarkeit — runForgetCleanup-args fuehren AppContext.
|
|
166
|
+
// log aktuell nicht durch (pure-function-Pattern).
|
|
167
|
+
if (sendDeletionExecutedEmail && userResult.userEmailBeforeDelete) {
|
|
168
|
+
try {
|
|
169
|
+
await sendDeletionExecutedEmail({
|
|
170
|
+
userId: user.id,
|
|
171
|
+
userEmail: userResult.userEmailBeforeDelete,
|
|
172
|
+
tenantIds: userResult.tenantIdsBeforeDelete,
|
|
173
|
+
executedAt: now.toString(),
|
|
174
|
+
});
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for email-send-failure
|
|
177
|
+
console.warn(
|
|
178
|
+
`[user-data-rights:run-forget-cleanup] sendDeletionExecutedEmail failed userId=${user.id} err=${err instanceof Error ? err.message : String(err)}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { processedUserIds, hookCallsAttempted, errors };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface ProcessUserResult {
|
|
189
|
+
readonly success: boolean;
|
|
190
|
+
readonly hookCallsAttempted: number;
|
|
191
|
+
readonly errors: readonly ForgetCleanupError[];
|
|
192
|
+
/** Atom 5b: userEmail VOR Tx gecacht (user-Hook anonymisiert in Tx).
|
|
193
|
+
* null wenn user-Row beim Pre-Tx-Lookup nicht (mehr) existiert oder
|
|
194
|
+
* email leer ist. */
|
|
195
|
+
readonly userEmailBeforeDelete: string | null;
|
|
196
|
+
/** Tenant-Memberships VOR Tx — Email-Template kann das nutzen. */
|
|
197
|
+
readonly tenantIdsBeforeDelete: readonly TenantId[];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function processUser(args: {
|
|
201
|
+
db: DbRunner;
|
|
202
|
+
registry: Registry;
|
|
203
|
+
userId: string;
|
|
204
|
+
hookEntries: readonly HookEntry[];
|
|
205
|
+
}): Promise<ProcessUserResult> {
|
|
206
|
+
const { db, registry, userId, hookEntries } = args;
|
|
207
|
+
const errors: ForgetCleanupError[] = [];
|
|
208
|
+
let hookCallsAttempted = 0;
|
|
209
|
+
|
|
210
|
+
// Atom 5b — userEmail VOR der Tx cachen. user-Hook (user-data-rights-
|
|
211
|
+
// defaults) anonymisiert email/displayName/passwordHash IN der Tx.
|
|
212
|
+
// Nach der Tx ist email = "deleted-{id}@{tenant}.example" oder NULL.
|
|
213
|
+
// Memory-cache laesst Atom-5b-Callback nach success-flip den
|
|
214
|
+
// ORIGINAL-email an App-Author-Callback geben.
|
|
215
|
+
// @cast-boundary db-row.
|
|
216
|
+
const userPreTx = (await db
|
|
217
|
+
.select({ email: userTable["email"] })
|
|
218
|
+
.from(userTable)
|
|
219
|
+
.where(eq(userTable["id"], userId))
|
|
220
|
+
.limit(1)) as Array<{ email: string | null }>;
|
|
221
|
+
const userEmailBeforeDelete =
|
|
222
|
+
userPreTx[0]?.email && userPreTx[0].email.length > 0 ? userPreTx[0].email : null;
|
|
223
|
+
|
|
224
|
+
// Memberships fuer diesen User holen — alle Tenants in denen er Mitglied ist.
|
|
225
|
+
// @cast-boundary db-row.
|
|
226
|
+
const memberships = (await db
|
|
227
|
+
.select({ tenantId: tenantMembershipsTable["tenantId"] })
|
|
228
|
+
.from(tenantMembershipsTable)
|
|
229
|
+
.where(eq(tenantMembershipsTable["userId"], userId))) as Array<{
|
|
230
|
+
tenantId: TenantId;
|
|
231
|
+
}>;
|
|
232
|
+
// tenant-Liste fuer Atom 5b Email — Memberships VOR Tx, weil hooks
|
|
233
|
+
// memberships in der Tx loeschen. Orphan-User (0 memberships) liefert
|
|
234
|
+
// [] in Email-args; App-Author-Template kann das case-handlen.
|
|
235
|
+
const tenantIdsBeforeDelete: readonly TenantId[] = memberships.map((m) => m.tenantId);
|
|
236
|
+
|
|
237
|
+
// Edge-Case "0 Memberships": User hat alle Tenants schon verlassen
|
|
238
|
+
// bevor Forget triggerte. Wir laufen den Hook-Loop trotzdem mit einem
|
|
239
|
+
// Pseudo-Tenant — der user-Hook (user-data-rights-defaults) ist
|
|
240
|
+
// tenant-agnostisch und MUSS laufen damit email/displayName/passwordHash
|
|
241
|
+
// anonymisiert werden. Tenant-scoped Hooks (z.B. fileRefDeleteHook)
|
|
242
|
+
// finden im Pseudo-Tenant nichts und sind no-op. Ohne diesen Pfad
|
|
243
|
+
// wuerde status=Deleted gesetzt waehrend Original-PII liegen bleibt
|
|
244
|
+
// — sieht compliant aus, ist es nicht (advisor-Finding S2.U5b.fix1).
|
|
245
|
+
const tenantList: TenantId[] =
|
|
246
|
+
memberships.length > 0 ? memberships.map((m) => m.tenantId) : [SYSTEM_TENANT_ID_FOR_ORPHANS];
|
|
247
|
+
|
|
248
|
+
// Per-User-Sub-Tx: hooks + status-flip atomar. Bei Hook-Throw rollt
|
|
249
|
+
// nur dieser User zurueck, andere User bleiben commit-fest. Drizzle
|
|
250
|
+
// mappt das in nested-Tx auf SAVEPOINT, in top-level auf BEGIN — die
|
|
251
|
+
// `transaction()`-API ist auf DbRunner uniform.
|
|
252
|
+
//
|
|
253
|
+
// Cast `db as {transaction: ...}` ist eine TS-Limitation: DbRunner ist
|
|
254
|
+
// `DbConnection | DbTx`, beide haben `.transaction()`, aber TS kann
|
|
255
|
+
// die Signaturen ueber die Union nicht unifizieren (PgDatabase vs
|
|
256
|
+
// PgTransaction haben unterschiedliche Generics). Cast macht das
|
|
257
|
+
// Strukturelle explizit, kein Hack.
|
|
258
|
+
let txSucceeded = false;
|
|
259
|
+
let currentTenantId: TenantId | null = null;
|
|
260
|
+
let currentEntityName: string | null = null;
|
|
261
|
+
try {
|
|
262
|
+
await (db as { transaction: (fn: (tx: DbRunner) => Promise<void>) => Promise<void> }) // @cast-boundary db-runner
|
|
263
|
+
.transaction(async (tx) => {
|
|
264
|
+
for (const tenantId of tenantList) {
|
|
265
|
+
currentTenantId = tenantId;
|
|
266
|
+
for (const entry of hookEntries) {
|
|
267
|
+
currentEntityName = entry.entityName;
|
|
268
|
+
const policy = await resolveRetentionPolicyForTenant({
|
|
269
|
+
db: tx,
|
|
270
|
+
registry,
|
|
271
|
+
tenantId,
|
|
272
|
+
entityName: entry.entityName,
|
|
273
|
+
});
|
|
274
|
+
const strategy = policyToStrategy(policy.policy?.strategy ?? null);
|
|
275
|
+
|
|
276
|
+
hookCallsAttempted++;
|
|
277
|
+
await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
|
|
282
|
+
// geworfen hat, kommen wir hier nicht an — die Tx rollback'd
|
|
283
|
+
// alles, der User bleibt im DeletionRequested-Status, naechster
|
|
284
|
+
// Run retried.
|
|
285
|
+
await tx
|
|
286
|
+
.update(userTable)
|
|
287
|
+
.set({ status: USER_STATUS.Deleted })
|
|
288
|
+
.where(eq(userTable["id"], userId));
|
|
289
|
+
txSucceeded = true;
|
|
290
|
+
});
|
|
291
|
+
} catch (e) {
|
|
292
|
+
// currentTenantId/currentEntityName tracken den Failing-Hook —
|
|
293
|
+
// Operator sieht "Hook fileRef in Tenant A failed for user X" statt
|
|
294
|
+
// generisches "<sub-transaction>".
|
|
295
|
+
errors.push({
|
|
296
|
+
userId,
|
|
297
|
+
tenantId: currentTenantId ?? ("" as TenantId),
|
|
298
|
+
entityName: currentEntityName ?? "<unknown>",
|
|
299
|
+
message: e instanceof Error ? e.message : String(e),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
success: txSucceeded,
|
|
305
|
+
hookCallsAttempted,
|
|
306
|
+
errors,
|
|
307
|
+
userEmailBeforeDelete,
|
|
308
|
+
tenantIdsBeforeDelete,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Pseudo-Tenant fuer User ohne aktive Memberships. RFC4122-konforme
|
|
313
|
+
// Null-UUID. Tenant-scoped Hooks finden hier nichts (no-op),
|
|
314
|
+
// tenant-agnostische Hooks (z.B. user) operieren auf der globalen
|
|
315
|
+
// User-Row und ignorieren tenantId.
|
|
316
|
+
const SYSTEM_TENANT_ID_FOR_ORPHANS = "00000000-0000-0000-0000-000000000000" as TenantId;
|
|
317
|
+
|
|
318
|
+
// Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
|
|
319
|
+
// - "anonymize" / "blockDelete" → "anonymize" (Aufbewahrungs-Pflicht
|
|
320
|
+
// blockDelete: Daten muessen physisch bleiben, nur Personen-Bezug raus)
|
|
321
|
+
// - "hardDelete" / "softDelete" / null → "delete" (Default)
|
|
322
|
+
//
|
|
323
|
+
// Eigene Funktion damit Strategie-Drift zwischen retention-strategies
|
|
324
|
+
// und user-data-rights-Hooks an EINER Stelle dokumentiert + getestet
|
|
325
|
+
// werden kann (siehe run-forget-cleanup.test.ts).
|
|
326
|
+
export function policyToStrategy(
|
|
327
|
+
retentionStrategy: "hardDelete" | "softDelete" | "anonymize" | "blockDelete" | null,
|
|
328
|
+
): UserDataDeleteStrategy {
|
|
329
|
+
if (retentionStrategy === "anonymize" || retentionStrategy === "blockDelete") {
|
|
330
|
+
return "anonymize";
|
|
331
|
+
}
|
|
332
|
+
return "delete";
|
|
333
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// User-Data-Export-Pipeline (S2.U3) — DSGVO Art. 15 (Auskunft) +
|
|
2
|
+
// Art. 20 (Datenportabilität).
|
|
3
|
+
//
|
|
4
|
+
// Pure Pipeline-Function: ruft alle EXT_USER_DATA-export-Hooks ueber
|
|
5
|
+
// alle Tenant-Memberships eines Users + sammelt das Ergebnis als
|
|
6
|
+
// strukturiertes Bundle.
|
|
7
|
+
//
|
|
8
|
+
// **Async ZIP + Storage (S2.U3-ext) bewusst spaeter:** Diese Foundation
|
|
9
|
+
// gibt das Bundle inline zurueck (JSON-Object). Apps mit grossen
|
|
10
|
+
// File-Mengen brauchen einen Job-Wrap der das Bundle nach S3 / lokalem
|
|
11
|
+
// Storage schreibt + signed-URLs ergibt — kommt drauf wenn ein realer
|
|
12
|
+
// User-Case >10MB Output produziert. Bis dahin reicht inline fuer
|
|
13
|
+
// 99% der Apps (User-Profil + Files-Metadata + Aktivitaeten-History).
|
|
14
|
+
//
|
|
15
|
+
// **Cross-Tenant-Iteration:** Wie beim Forget-Pfad — User-Daten in
|
|
16
|
+
// Tenant A + B kommen in dasselbe Bundle. Plan-Doc:
|
|
17
|
+
// docs/plans/architecture/user-data-rights.md "Cross-Tenant-Semantik".
|
|
18
|
+
//
|
|
19
|
+
// **PII-Surface:** Hooks definieren selbst welche Felder ins Bundle
|
|
20
|
+
// landen. user-data-rights-defaults/hooks/user.userdata-hook expose
|
|
21
|
+
// expliziert KEIN passwordHash + KEINE roles (privileged columns).
|
|
22
|
+
|
|
23
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
24
|
+
import {
|
|
25
|
+
EXT_USER_DATA,
|
|
26
|
+
type Registry,
|
|
27
|
+
type TenantId,
|
|
28
|
+
type UserDataExportHook,
|
|
29
|
+
type UserDataExportSnippet,
|
|
30
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
31
|
+
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
32
|
+
import { eq } from "drizzle-orm";
|
|
33
|
+
import { tenantMembershipsTable } from "../tenant";
|
|
34
|
+
import { buildFileRefZipPath } from "./zip-path";
|
|
35
|
+
|
|
36
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
37
|
+
|
|
38
|
+
export interface RunUserExportArgs {
|
|
39
|
+
readonly db: DbRunner;
|
|
40
|
+
readonly registry: Registry;
|
|
41
|
+
readonly userId: string;
|
|
42
|
+
readonly now: Instant;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UserExportFileRef {
|
|
46
|
+
readonly fileRefId: string;
|
|
47
|
+
readonly storageKey: string;
|
|
48
|
+
readonly fileName: string;
|
|
49
|
+
/** Tenant in dem die Datei haengt — gleicher fileRefId kann nicht ueber Tenants geteilt sein. */
|
|
50
|
+
readonly tenantId: TenantId;
|
|
51
|
+
/**
|
|
52
|
+
* ZIP-internal Pfad unter dem die Datei im Export-ZIP landet. Reader-
|
|
53
|
+
* Tools (Compliance-Audit, Self-Service-Portal) verlinken bundle.json
|
|
54
|
+
* fileRefs[] auf die files/-Pfade ueber dieses Feld. Garantiert
|
|
55
|
+
* path-traversal-frei via sanitizeZipFilename.
|
|
56
|
+
*/
|
|
57
|
+
readonly zipPath: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface UserExportTenantSection {
|
|
61
|
+
readonly tenantId: TenantId;
|
|
62
|
+
/** Pro Entity ein Snippet ({entity, rows[]}). Empty wenn Hook null returned. */
|
|
63
|
+
readonly entities: ReadonlyArray<UserDataExportSnippet>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface UserExportBundle {
|
|
67
|
+
readonly userId: string;
|
|
68
|
+
/** ISO-8601 Generation-Timestamp — fuer Audit-Trail. */
|
|
69
|
+
readonly generatedAt: string;
|
|
70
|
+
/** Pro Tenant in dem User Mitglied ist eine Section. Orphan-User → tenants=[]. */
|
|
71
|
+
readonly tenants: ReadonlyArray<UserExportTenantSection>;
|
|
72
|
+
/**
|
|
73
|
+
* Flat-Liste aller fileRefs aus allen Tenant-Sections — der spaetere
|
|
74
|
+
* ZIP-Bau-Job iteriert hier durch + zieht Binaries aus dem Storage-
|
|
75
|
+
* Provider. Bis dahin ist das die Stueckliste fuer den Operator.
|
|
76
|
+
*/
|
|
77
|
+
readonly fileRefs: ReadonlyArray<UserExportFileRef>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface HookEntry {
|
|
81
|
+
readonly entityName: string;
|
|
82
|
+
readonly exportHook: UserDataExportHook;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure function: iteriert alle EXT_USER_DATA-Hooks pro Tenant (Cross-
|
|
87
|
+
* Tenant-Memberships) + sammelt Snippets in ein UserExportBundle.
|
|
88
|
+
*
|
|
89
|
+
* **Memory-Footprint:**
|
|
90
|
+
*
|
|
91
|
+
* Der gesamte Storage-Pfad ist streaming-bound:
|
|
92
|
+
* - File-Binaries via provider.readStream → chunk-streaming, skaliert
|
|
93
|
+
* auf beliebige File-Sizes ohne Heap-Spike.
|
|
94
|
+
* - ZIP-Schreiben via provider.writeStream — local nutzt fs.createWriteStream,
|
|
95
|
+
* S3 nutzt lib-storage.Upload (multipart, ~20MB Heap-Bound bei 4
|
|
96
|
+
* concurrent parts).
|
|
97
|
+
*
|
|
98
|
+
* **EINZIGER nicht-streaming Pfad:** das Bundle-Object selbst (bundle.json
|
|
99
|
+
* Inhalt). Es wird komplett in-memory gebaut bevor `bundleToZipEntries`
|
|
100
|
+
* es als ZIP-Entry yieldet. Hooks geben Snippets als Plain-Objects
|
|
101
|
+
* zurueck (siehe UserDataExportSnippet).
|
|
102
|
+
*
|
|
103
|
+
* **Threshold:** Web-App mit ~500 Tabellen-Rows pro User ≈ 500 KB JSON.
|
|
104
|
+
* 50k Rows ≈ 50 MB. 100k+ Rows pro User (z.B. langjaehrige Mietportal-
|
|
105
|
+
* Logs) macht Heap-Druck merkbar.
|
|
106
|
+
*
|
|
107
|
+
* **Wenn das knapp wird:** runUserExport auf AsyncIterable-Form refactoren —
|
|
108
|
+
* Hooks yielden snippets statt Object-Returns; Bundle-Schema bekommt
|
|
109
|
+
* JSON-Lines-Format. bundleToZipEntries wuerde line-by-line streamen.
|
|
110
|
+
* Eigener Sprint, nicht-trivialer Schema-Bruch.
|
|
111
|
+
*
|
|
112
|
+
* **Operator-Signal:** wenn bundle.json im ZIP > 100 MB ist, sollte
|
|
113
|
+
* Telemetry triggern + Schema-Refactor evaluieren.
|
|
114
|
+
*/
|
|
115
|
+
export async function runUserExport(args: RunUserExportArgs): Promise<UserExportBundle> {
|
|
116
|
+
const { db, registry, userId, now } = args;
|
|
117
|
+
|
|
118
|
+
// Memberships → Tenant-Liste fuer Hook-Iteration.
|
|
119
|
+
// @cast-boundary db-row.
|
|
120
|
+
const memberships = (await db
|
|
121
|
+
.select({ tenantId: tenantMembershipsTable["tenantId"] })
|
|
122
|
+
.from(tenantMembershipsTable)
|
|
123
|
+
.where(eq(tenantMembershipsTable["userId"], userId))) as Array<{ tenantId: TenantId }>;
|
|
124
|
+
|
|
125
|
+
const tenantList: TenantId[] = memberships.map((m) => m.tenantId);
|
|
126
|
+
|
|
127
|
+
// EXT_USER_DATA-Usages → export-Hook-Liste.
|
|
128
|
+
const usages = registry.getExtensionUsages(EXT_USER_DATA);
|
|
129
|
+
const hookEntries: HookEntry[] = usages
|
|
130
|
+
.map((u): HookEntry | null => {
|
|
131
|
+
const opts = (u.options ?? {}) as { export?: UserDataExportHook }; // @cast-boundary engine-payload
|
|
132
|
+
return opts.export ? { entityName: u.entityName, exportHook: opts.export } : null;
|
|
133
|
+
})
|
|
134
|
+
.filter((x): x is HookEntry => x !== null);
|
|
135
|
+
|
|
136
|
+
const tenants: UserExportTenantSection[] = [];
|
|
137
|
+
const fileRefs: UserExportFileRef[] = [];
|
|
138
|
+
|
|
139
|
+
for (const tenantId of tenantList) {
|
|
140
|
+
const entities: UserDataExportSnippet[] = [];
|
|
141
|
+
for (const entry of hookEntries) {
|
|
142
|
+
const snippet = await entry.exportHook({ db, tenantId, userId });
|
|
143
|
+
if (snippet === null) continue;
|
|
144
|
+
entities.push(snippet);
|
|
145
|
+
if (snippet.fileRefs) {
|
|
146
|
+
for (const ref of snippet.fileRefs) {
|
|
147
|
+
fileRefs.push({
|
|
148
|
+
...ref,
|
|
149
|
+
tenantId,
|
|
150
|
+
zipPath: buildFileRefZipPath({
|
|
151
|
+
tenantId,
|
|
152
|
+
fileRefId: ref.fileRefId,
|
|
153
|
+
fileName: ref.fileName,
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
tenants.push({ tenantId, entities });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Edge-Case "0 Memberships": Tenant-agnostische Hooks (z.B. user-Hook)
|
|
163
|
+
// wuerden bei Cross-Tenant-Iteration mehrfach laufen. Bei orphan-User
|
|
164
|
+
// (kein Membership) wuerden sie gar nicht laufen. Loesung wie beim
|
|
165
|
+
// Forget-Runner: einen Sonder-Lauf mit einem Pseudo-Tenant ergaenzen
|
|
166
|
+
// damit die globale User-Row IM Bundle landet. Tenant-scoped Hooks
|
|
167
|
+
// sind no-op.
|
|
168
|
+
//
|
|
169
|
+
// Das Pattern matched run-forget-cleanup.ts; Memory: Cross-Tenant-
|
|
170
|
+
// Konsistenz darf nicht silent kippen wenn Memberships leer sind.
|
|
171
|
+
if (tenantList.length === 0 && hookEntries.length > 0) {
|
|
172
|
+
const orphanEntities: UserDataExportSnippet[] = [];
|
|
173
|
+
for (const entry of hookEntries) {
|
|
174
|
+
const snippet = await entry.exportHook({
|
|
175
|
+
db,
|
|
176
|
+
tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
|
|
177
|
+
userId,
|
|
178
|
+
});
|
|
179
|
+
if (snippet === null) continue;
|
|
180
|
+
orphanEntities.push(snippet);
|
|
181
|
+
if (snippet.fileRefs) {
|
|
182
|
+
for (const ref of snippet.fileRefs) {
|
|
183
|
+
fileRefs.push({
|
|
184
|
+
...ref,
|
|
185
|
+
tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
|
|
186
|
+
zipPath: buildFileRefZipPath({
|
|
187
|
+
tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
|
|
188
|
+
fileRefId: ref.fileRefId,
|
|
189
|
+
fileName: ref.fileName,
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (orphanEntities.length > 0) {
|
|
196
|
+
tenants.push({ tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS, entities: orphanEntities });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
userId,
|
|
202
|
+
generatedAt: now.toString(),
|
|
203
|
+
tenants,
|
|
204
|
+
fileRefs,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
|
|
209
|
+
// Pattern in run-forget-cleanup.ts — RFC4122-Null-UUID. Tenant-scoped
|
|
210
|
+
// Hooks finden hier nichts (no-op).
|
|
211
|
+
const SYSTEM_TENANT_ID_FOR_ORPHANS = "00000000-0000-0000-0000-000000000000" as TenantId;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createTextField,
|
|
5
|
+
createTimestampField,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// Audit-Trail invalid Download-Attempts (S2.U7).
|
|
9
|
+
// Schreibt eine Row pro 4xx im download-by-{token,job}-Pfad. DPO erkennt
|
|
10
|
+
// damit Brute-Force / Anomalien (gleiche IP, viele invalid-Versuche).
|
|
11
|
+
// Success-Downloads landen in download-token.lastUsedAt — nicht hier.
|
|
12
|
+
export const downloadAttemptEntity = createEntity({
|
|
13
|
+
table: "read_download_attempts",
|
|
14
|
+
idType: "uuid",
|
|
15
|
+
fields: {
|
|
16
|
+
// notFound | expired | failed | signedUrlNotSupported
|
|
17
|
+
result: createTextField({ required: true, maxLength: 32 }),
|
|
18
|
+
// Welcher Pfad: "token" | "job"
|
|
19
|
+
via: createTextField({ required: true, maxLength: 16 }),
|
|
20
|
+
// Token-Hash (token-Pfad) oder NULL (job-Pfad / unbekannter Token).
|
|
21
|
+
tokenHash: createTextField({ maxLength: 64 }),
|
|
22
|
+
// Job-ID wenn der attempt einen kannte. NULL bei unbekanntem Token.
|
|
23
|
+
jobId: createTextField({}),
|
|
24
|
+
// User-ID wenn auth-Pfad (job). NULL bei anonymous (token-Pfad).
|
|
25
|
+
attemptedByUserId: createTextField({}),
|
|
26
|
+
ip: createTextField({ maxLength: 64 }),
|
|
27
|
+
userAgent: createTextField({ maxLength: 256 }),
|
|
28
|
+
attemptedAt: createTimestampField({ required: true }),
|
|
29
|
+
},
|
|
30
|
+
// 90d hardDelete: unbounded growth = disk-bomb genau gegen das System
|
|
31
|
+
// das den Brute-Force erkennen soll. Brute-Force-Patterns sind kurzfristig
|
|
32
|
+
// (Stunden bis Tage) — 90d Window deckt forensik-Reviews + DPO-quartal-
|
|
33
|
+
// Audits. Tenant kann via override verlängern (HR-Compliance).
|
|
34
|
+
retention: { keepFor: "90d", strategy: "hardDelete", reference: "attemptedAt" },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const downloadAttemptsTable = buildDrizzleTable("downloadAttempt", downloadAttemptEntity);
|