@cosmicdrift/kumiko-bundled-features 0.79.3 → 0.81.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.
@@ -10,10 +10,12 @@
10
10
  //
11
11
  // Rueckgabe: Stats fuer Operator-Monitoring (processed-count, error-list).
12
12
 
13
- import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
13
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
+ import { access, defineWriteHandler, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
14
15
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
15
16
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
16
17
  import { z } from "zod";
18
+ import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
17
19
  import { runForgetCleanup, type SendDeletionExecutedEmailFn } from "../run-forget-cleanup";
18
20
 
19
21
  export type RunForgetCleanupOptions = {
@@ -36,12 +38,25 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
36
38
 
37
39
  // ctx.db.raw ist DbRunner. runForgetCleanup oeffnet pro User eine
38
40
  // Sub-Tx (SAVEPOINT wenn Outer-Dispatcher-Tx aktiv) — siehe
39
- // run-forget-cleanup.ts Header. Kein DbConnection-Cast noetig.
41
+ // run-forget-cleanup.ts Header.
40
42
  const T = getTemporal();
43
+ // Operator-triggered forget must also erase binaries, not just rows —
44
+ // it flips users to Deleted, after which the cron never re-processes
45
+ // them, so a row-only delete here would permanently leak the binaries.
46
+ // Resolve through the same file-foundation path the cron uses.
47
+ const forgetDb = ctx.db.raw as DbConnection; // @cast-boundary db-operator: config reads tolerate the outer tx
41
48
  const result = await runForgetCleanup({
42
49
  db: ctx.db.raw,
43
50
  registry: ctx.registry,
44
51
  now: T.Now.instant(),
52
+ buildStorageProvider: makeTenantStorageProviderResolver({
53
+ registry: ctx.registry,
54
+ configResolver: ctx.configResolver,
55
+ secrets: ctx.secrets,
56
+ db: forgetDb,
57
+ userId: ctx._userId ?? SYSTEM_USER_ID,
58
+ handlerName: "user-data-rights:run-forget-cleanup",
59
+ }),
45
60
  ...(opts.sendDeletionExecutedEmail && {
46
61
  sendDeletionExecutedEmail: opts.sendDeletionExecutedEmail,
47
62
  }),
@@ -0,0 +1,48 @@
1
+ // Builds a per-tenant file-storage-provider resolver from a job/handler
2
+ // context, so the export pipeline and the forget pipeline resolve binaries
3
+ // through the SAME mounted file-foundation (delete-target == upload-target by
4
+ // construction). Extracted from the export cron so both crons + the manual
5
+ // forget handler share one construction site instead of inlining it three times.
6
+
7
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
+ import type { ConfigResolver, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
10
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
11
+ import { createConfigAccessor } from "../../config";
12
+ import { createFileProviderForTenant } from "../../file-foundation";
13
+
14
+ export interface TenantStorageResolverCtx {
15
+ readonly registry: Registry;
16
+ // Job-context carries configResolver (per-request ConfigAccessor exists only
17
+ // in the HTTP dispatcher); the resolver builds a per-tenant accessor from it.
18
+ // Undefined → the returned resolver throws (callers decide fail-loud vs skip).
19
+ readonly configResolver: ConfigResolver | undefined;
20
+ readonly secrets: SecretsContext | undefined;
21
+ readonly db: DbConnection;
22
+ readonly userId: string;
23
+ readonly handlerName: string;
24
+ }
25
+
26
+ export function makeTenantStorageProviderResolver(
27
+ ctx: TenantStorageResolverCtx,
28
+ ): (tenantId: TenantId) => Promise<FileStorageProvider> {
29
+ return async (tenantId) => {
30
+ if (!ctx.configResolver) {
31
+ throw new Error(
32
+ `${ctx.handlerName}: ctx.configResolver missing — cannot resolve the file provider for tenant ${tenantId}`,
33
+ );
34
+ }
35
+ const config = createConfigAccessor(
36
+ ctx.registry,
37
+ ctx.configResolver,
38
+ tenantId as Parameters<typeof createConfigAccessor>[2], // @cast-boundary engine-payload: TenantId brand
39
+ ctx.userId,
40
+ ctx.db,
41
+ );
42
+ return createFileProviderForTenant(
43
+ { config, registry: ctx.registry, secrets: ctx.secrets, _userId: ctx.userId },
44
+ tenantId,
45
+ ctx.handlerName,
46
+ );
47
+ };
48
+ }
@@ -41,6 +41,7 @@ import {
41
41
  type TenantId,
42
42
  type UserDataDeleteHook,
43
43
  type UserDataDeleteStrategy,
44
+ type UserDataStorageProvider,
44
45
  } from "@cosmicdrift/kumiko-framework/engine";
45
46
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
46
47
  import { resolveRetentionPolicyForTenant } from "../data-retention";
@@ -85,6 +86,16 @@ export interface RunForgetCleanupArgs {
85
86
  * ohne Callback laeuft Worker still (User hatte schon
86
87
  * request-deletion-Email + grace-period-Erinnerung). */
87
88
  readonly sendDeletionExecutedEmail?: SendDeletionExecutedEmailFn;
89
+
90
+ /**
91
+ * Per-tenant file-storage-provider resolver (the forget cron builds it from
92
+ * the mounted file-foundation, mirroring the export cron). Threaded into
93
+ * every delete-hook's ctx so file-aware hooks erase binaries from the same
94
+ * store the upload/export path uses. Omitted → hooks skip binary cleanup.
95
+ */
96
+ readonly buildStorageProvider?: (
97
+ tenantId: TenantId,
98
+ ) => Promise<UserDataStorageProvider | undefined>;
88
99
  }
89
100
 
90
101
  export interface ForgetCleanupError {
@@ -119,7 +130,7 @@ const HOOK_ORDER_DEFAULT = EXT_USER_DATA_ORDER.DEFAULT;
119
130
  export async function runForgetCleanup(
120
131
  args: RunForgetCleanupArgs,
121
132
  ): Promise<RunForgetCleanupResult> {
122
- const { db, registry, now, sendDeletionExecutedEmail } = args;
133
+ const { db, registry, now, sendDeletionExecutedEmail, buildStorageProvider } = args;
123
134
 
124
135
  // Step 1: Find users with expired grace period.
125
136
  const dueUsers = await selectUsersDueForForgetCleanup(
@@ -161,6 +172,7 @@ export async function runForgetCleanup(
161
172
  registry,
162
173
  userId: user.id,
163
174
  hookEntries,
175
+ buildStorageProvider,
164
176
  });
165
177
  hookCallsAttempted += userResult.hookCallsAttempted;
166
178
  errors.push(...userResult.errors);
@@ -216,8 +228,9 @@ async function processUser(args: {
216
228
  registry: Registry;
217
229
  userId: string;
218
230
  hookEntries: readonly HookEntry[];
231
+ buildStorageProvider?: (tenantId: TenantId) => Promise<UserDataStorageProvider | undefined>;
219
232
  }): Promise<ProcessUserResult> {
220
- const { db, registry, userId, hookEntries } = args;
233
+ const { db, registry, userId, hookEntries, buildStorageProvider } = args;
221
234
  const errors: ForgetCleanupError[] = [];
222
235
  let hookCallsAttempted = 0;
223
236
 
@@ -274,7 +287,7 @@ async function processUser(args: {
274
287
  const strategy = policyToStrategy(policy.policy?.strategy ?? null);
275
288
 
276
289
  hookCallsAttempted++;
277
- await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
290
+ await entry.deleteHook({ db: tx, tenantId, userId, buildStorageProvider }, strategy);
278
291
  }
279
292
  }
280
293
 
@@ -27,7 +27,7 @@ export function ConfirmAccountDeletionScreen({
27
27
  }: ConfirmAccountDeletionScreenProps): ReactNode {
28
28
  const t = useTranslation();
29
29
  const dispatcher = useDispatcher();
30
- const { Button, Banner } = usePrimitives();
30
+ const { Button, Banner, Card } = usePrimitives();
31
31
  const [token] = useState(readToken);
32
32
  const [phase, setPhase] = useState<Phase>(token.length > 0 ? "idle" : "missing");
33
33
 
@@ -42,12 +42,19 @@ export function ConfirmAccountDeletionScreen({
42
42
  };
43
43
 
44
44
  return (
45
- <div className="w-full max-w-sm mx-auto rounded-lg border bg-card text-card-foreground shadow-sm">
46
- <div className="flex flex-col space-y-1.5 p-6 pb-4">
47
- <h1 className="text-xl font-semibold tracking-tight">
48
- {title ?? t("userDataRights.deletion.confirm.title")}
49
- </h1>
50
- </div>
45
+ <Card
46
+ className="w-full max-w-sm mx-auto"
47
+ options={{ padded: false }}
48
+ slots={{
49
+ header: (
50
+ <div className="flex flex-col space-y-1.5 p-6 pb-4">
51
+ <h1 className="text-xl font-semibold tracking-tight">
52
+ {title ?? t("userDataRights.deletion.confirm.title")}
53
+ </h1>
54
+ </div>
55
+ ),
56
+ }}
57
+ >
51
58
  <div className="p-6 pt-0 flex flex-col gap-4">
52
59
  {phase === "success" ? (
53
60
  <Banner variant="info">
@@ -83,6 +90,6 @@ export function ConfirmAccountDeletionScreen({
83
90
  </>
84
91
  )}
85
92
  </div>
86
- </div>
93
+ </Card>
87
94
  );
88
95
  }
@@ -130,7 +130,26 @@ function ExportSection(): ReactNode {
130
130
  }, [inProgress, refetch]);
131
131
 
132
132
  return (
133
- <Section title={t("userDataRights.privacyCenter.export.title")} testId="privacy-export">
133
+ <Section
134
+ title={t("userDataRights.privacyCenter.export.title")}
135
+ testId="privacy-export"
136
+ actions={
137
+ !inProgress ? (
138
+ <Button
139
+ onClick={() => void request()}
140
+ disabled={submitting}
141
+ loading={submitting}
142
+ testId="privacy-export-request"
143
+ >
144
+ {done
145
+ ? t("userDataRights.privacyCenter.export.requestNew")
146
+ : submitting
147
+ ? t("userDataRights.privacyCenter.export.requesting")
148
+ : t("userDataRights.privacyCenter.export.request")}
149
+ </Button>
150
+ ) : undefined
151
+ }
152
+ >
134
153
  <p className="text-sm text-muted-foreground">
135
154
  {t("userDataRights.privacyCenter.export.intro")}
136
155
  </p>
@@ -171,20 +190,6 @@ function ExportSection(): ReactNode {
171
190
  </Banner>
172
191
  )}
173
192
  <StatusBanner status={status} />
174
- {!inProgress && (
175
- <Button
176
- onClick={() => void request()}
177
- disabled={submitting}
178
- loading={submitting}
179
- testId="privacy-export-request"
180
- >
181
- {done
182
- ? t("userDataRights.privacyCenter.export.requestNew")
183
- : submitting
184
- ? t("userDataRights.privacyCenter.export.requesting")
185
- : t("userDataRights.privacyCenter.export.request")}
186
- </Button>
187
- )}
188
193
  </Section>
189
194
  );
190
195
  }
@@ -219,6 +224,17 @@ function RestrictionSection({
219
224
  <Section
220
225
  title={t("userDataRights.privacyCenter.restriction.title")}
221
226
  testId="privacy-restriction"
227
+ actions={
228
+ restricted ? undefined : (
229
+ <Button
230
+ variant="danger"
231
+ onClick={() => setDialogOpen(true)}
232
+ testId="privacy-restriction-restrict"
233
+ >
234
+ {t("userDataRights.privacyCenter.restriction.restrict")}
235
+ </Button>
236
+ )
237
+ }
222
238
  >
223
239
  {restricted ? (
224
240
  <Banner variant="error" testId="privacy-restriction-active">
@@ -230,13 +246,6 @@ function RestrictionSection({
230
246
  {t("userDataRights.privacyCenter.restriction.explainer")}
231
247
  </p>
232
248
  <StatusBanner status={status} />
233
- <Button
234
- variant="danger"
235
- onClick={() => setDialogOpen(true)}
236
- testId="privacy-restriction-restrict"
237
- >
238
- {t("userDataRights.privacyCenter.restriction.restrict")}
239
- </Button>
240
249
  <Dialog
241
250
  open={dialogOpen}
242
251
  onOpenChange={setDialogOpen}
@@ -291,7 +300,29 @@ function DeletionSection({
291
300
  };
292
301
 
293
302
  return (
294
- <Section title={t("userDataRights.privacyCenter.deletion.title")} testId="privacy-deletion">
303
+ <Section
304
+ title={t("userDataRights.privacyCenter.deletion.title")}
305
+ testId="privacy-deletion"
306
+ actions={
307
+ deletionRequested ? (
308
+ <Button
309
+ variant="secondary"
310
+ onClick={() => void cancelDeletion()}
311
+ testId="privacy-deletion-cancel"
312
+ >
313
+ {t("userDataRights.privacyCenter.deletion.cancel")}
314
+ </Button>
315
+ ) : (
316
+ <Button
317
+ variant="danger"
318
+ onClick={() => setDialogOpen(true)}
319
+ testId="privacy-deletion-delete"
320
+ >
321
+ {t("userDataRights.privacyCenter.deletion.delete")}
322
+ </Button>
323
+ )
324
+ }
325
+ >
295
326
  {deletionRequested ? (
296
327
  <>
297
328
  <Banner variant="error" testId="privacy-deletion-requested">
@@ -300,13 +331,6 @@ function DeletionSection({
300
331
  })}
301
332
  </Banner>
302
333
  <StatusBanner status={status} />
303
- <Button
304
- variant="secondary"
305
- onClick={() => void cancelDeletion()}
306
- testId="privacy-deletion-cancel"
307
- >
308
- {t("userDataRights.privacyCenter.deletion.cancel")}
309
- </Button>
310
334
  </>
311
335
  ) : (
312
336
  <>
@@ -314,13 +338,6 @@ function DeletionSection({
314
338
  {t("userDataRights.privacyCenter.deletion.explainer")}
315
339
  </p>
316
340
  <StatusBanner status={status} />
317
- <Button
318
- variant="danger"
319
- onClick={() => setDialogOpen(true)}
320
- testId="privacy-deletion-delete"
321
- >
322
- {t("userDataRights.privacyCenter.deletion.delete")}
323
- </Button>
324
341
  <Dialog
325
342
  open={dialogOpen}
326
343
  onOpenChange={setDialogOpen}
@@ -21,7 +21,7 @@ export function RequestAccountDeletionScreen({
21
21
  }: RequestAccountDeletionScreenProps): ReactNode {
22
22
  const t = useTranslation();
23
23
  const dispatcher = useDispatcher();
24
- const { Form, Field, Input, Button, Banner } = usePrimitives();
24
+ const { Form, Field, Input, Button, Banner, Card } = usePrimitives();
25
25
  const [email, setEmail] = useState("");
26
26
  const [submitting, setSubmitting] = useState(false);
27
27
  const [done, setDone] = useState(false);
@@ -50,12 +50,19 @@ export function RequestAccountDeletionScreen({
50
50
  };
51
51
 
52
52
  return (
53
- <div className="w-full max-w-sm mx-auto rounded-lg border bg-card text-card-foreground shadow-sm">
54
- <div className="flex flex-col space-y-1.5 p-6 pb-4">
55
- <h1 className="text-xl font-semibold tracking-tight">
56
- {title ?? t("userDataRights.deletion.request.title")}
57
- </h1>
58
- </div>
53
+ <Card
54
+ className="w-full max-w-sm mx-auto"
55
+ options={{ padded: false }}
56
+ slots={{
57
+ header: (
58
+ <div className="flex flex-col space-y-1.5 p-6 pb-4">
59
+ <h1 className="text-xl font-semibold tracking-tight">
60
+ {title ?? t("userDataRights.deletion.request.title")}
61
+ </h1>
62
+ </div>
63
+ ),
64
+ }}
65
+ >
59
66
  {done ? (
60
67
  <div className="p-6 pt-0">
61
68
  <Banner variant="info">
@@ -91,6 +98,6 @@ export function RequestAccountDeletionScreen({
91
98
  </Form>
92
99
  </div>
93
100
  )}
94
- </div>
101
+ </Card>
95
102
  );
96
103
  }
@@ -3,20 +3,9 @@ import {
3
3
  EXT_USER_DATA,
4
4
  type FeatureDefinition,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
- import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
7
- import { createFileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
6
+ import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
8
7
  import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
9
8
 
10
- export interface UserDataRightsDefaultsOptions {
11
- /**
12
- * Wired into the fileRef delete-hook: on strategy="delete" the hook
13
- * calls `storageProvider.delete(key)` per row before hard-deleting
14
- * the row. Without it, file binaries leak on forget (Art. 17) — the
15
- * hook logs a one-shot warning so misconfiguration stays visible.
16
- */
17
- readonly storageProvider?: FileStorageProvider;
18
- }
19
-
20
9
  // user-data-rights-defaults — Default-Hooks für die Core-Entities
21
10
  // `user` (S2.H1) und `fileRef` (S2.H2).
22
11
  //
@@ -34,10 +23,12 @@ export interface UserDataRightsDefaultsOptions {
34
23
  // Pattern matched file-foundation + file-provider-s3 (separate Plugin-
35
24
  // Feature), nicht user/files schreiben ihre eigenen Hooks selbst weil
36
25
  // das circular-requires waere.
37
- export function createUserDataRightsDefaultsFeature(
38
- options: UserDataRightsDefaultsOptions = {},
39
- ): FeatureDefinition {
40
- const fileRefDeleteHook = createFileRefDeleteHook(options.storageProvider);
26
+ // Binary storage for the fileRef delete-hook is resolved at run time from the
27
+ // mounted file-foundation via ctx.buildStorageProvider (injected by the forget
28
+ // orchestrator) no provider is captured here, so a single app-wide store and
29
+ // per-tenant stores both work, and forget deletes from the same store upload +
30
+ // export use. See hooks/file-ref.userdata-hook.ts.
31
+ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
41
32
  return defineFeature("user-data-rights-defaults", (r) => {
42
33
  r.describe(
43
34
  "Registers ready-made `EXT_USER_DATA` export and delete hooks for the two core entities: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`) and `fileRef` (delete removes both the DB row and the storage binary). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
@@ -1,6 +1,11 @@
1
1
  import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
- import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
2
+ import type {
3
+ UserDataDeleteHook,
4
+ UserDataExportHook,
5
+ UserDataHookCtx,
6
+ UserDataStorageProvider,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
4
9
 
5
10
  // userData-Hook fuer fileRef-entity (S2.H2).
6
11
  //
@@ -9,29 +14,32 @@ import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-fra
9
14
  // NICHT direkt — sie werden via signed-Download-URLs separat ins ZIP
10
15
  // gepackt (S2.U3 Export-Job-Pipeline orchestriert das).
11
16
  //
12
- // Delete-Hook entfernt FileRef-Zeile via factory
13
- // `createFileRefDeleteHook(storageProvider)`:
17
+ // Delete-Hook entfernt FileRef-Zeile + Binary:
14
18
  // "delete": storageProvider.delete() pro File + Row hard-delete
15
19
  // "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
16
20
  // koennen weiter zeigen; Personenbezug raus)
17
21
  //
18
- // Delete-Pfad ist FAIL-CLOSED, sobald ein Provider gewired ist: schlaegt ein
19
- // binary-delete fehl, wirft der Hook
20
- // NACH dem Loop die per-User-Sub-Tx von runForgetCleanup rollt zurueck, der
21
- // User bleibt DeletionRequested, der naechste Run retried (storageProvider.delete
22
- // ist idempotent, schon-geloeschte Keys sind no-op). Den Fehler zu schlucken und
23
- // die Row trotzdem hard-zu-loeschen wuerde Art.-17-Erasure als "done" markieren
24
- // waehrend die Bytes auf Disk bleiben — eine falsche Compliance-Aussage. Das
25
- // "KEIN globaler Rollback" der Sprint-2-Atomicity-Decision bleibt gewahrt: nur
26
- // DIESE User-Sub-Tx rollt zurueck (= der Retry-Mechanismus), andere User des
27
- // Laufs committen. Der anonymize-Pfad behaelt Row+binary bewusst, hat also
28
- // nichts zu schlucken.
22
+ // **Provider-Resolution:** der Provider kommt zur Lauf-Zeit aus
23
+ // `ctx.buildStorageProvider(ctx.tenantId)` der Forget-Orchestrator
24
+ // (run-forget-cleanup) baut ihn aus dem gemounteten file-foundation, also aus
25
+ // DEMSELBEN Store den Upload + Export nutzen (delete-target == upload-target by
26
+ // construction). Kein bei-Mount captured Provider mehr.
29
27
  //
30
- // `storageProvider` ist optional. App-Author wired es beim
31
- // Feature-Mount rein (`createUserDataRightsDefaultsFeature({
32
- // storageProvider })`). Ohne Provider macht der Hook row-only-delete,
33
- // die Bytes leaken der Caller bekommt EINEN Warn beim ersten Lauf
34
- // pro Process, damit die Konfiguration sichtbar fehlerhaft ist.
28
+ // **Zwei Fehlerklassen, bewusst verschieden behandelt:**
29
+ // 1. Resolution schlaegt fehl (kein Provider konfiguriert / configResolver
30
+ // fehlt) NICHT fail-closed: EIN Warn pro Process + row-only-delete. Ein
31
+ // fehlkonfigurierter Store darf die Art.-17-Loeschung nicht DAUERHAFT
32
+ // blockieren (sonst haengt jeder User fuer immer in DeletionRequested);
33
+ // der Boot-Guard macht die Fehlkonfiguration sichtbar, Binaries werden
34
+ // nachgeholt sobald ein Provider existiert.
35
+ // 2. Binary-DELETE schlaegt fehl, OBWOHL ein Provider da ist → FAIL-CLOSED:
36
+ // der Hook wirft NACH dem Loop, die per-User-Sub-Tx von runForgetCleanup
37
+ // rollt zurueck, der User bleibt DeletionRequested, der naechste Run
38
+ // retried (delete ist idempotent → konvergiert). Den Fehler zu schlucken +
39
+ // die Row trotzdem zu loeschen wuerde Erasure als "done" markieren waehrend
40
+ // die Bytes liegen bleiben — falsche Compliance-Aussage. Das "KEIN globaler
41
+ // Rollback" der Sprint-2-Atomicity bleibt gewahrt: nur DIESE Sub-Tx rollt
42
+ // zurueck. Der anonymize-Pfad behaelt Row+binary, hat nichts zu schlucken.
35
43
  //
36
44
  // Caveat: hard-delete via deleteMany emittiert KEIN fileRef.deleted —
37
45
  // die storage-tracking-MSP dekrementiert nicht. Wenn die zu loeschenden
@@ -88,61 +96,69 @@ export const fileRefExportHook: UserDataExportHook = async (ctx) => {
88
96
 
89
97
  let missingStorageWarned = false;
90
98
 
91
- export function createFileRefDeleteHook(
92
- storageProvider: FileStorageProvider | undefined,
93
- ): UserDataDeleteHook {
94
- return async (ctx, strategy) => {
95
- if (strategy === "delete") {
96
- if (storageProvider) {
97
- const rows = await selectMany(ctx.db, fileRefsTable, {
98
- tenantId: ctx.tenantId,
99
- insertedById: ctx.userId,
100
- });
101
- const failedKeys: string[] = [];
102
- for (const row of rows) {
103
- const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
104
- if (typeof key !== "string" || key.length === 0) continue;
105
- try {
106
- await storageProvider.delete(key);
107
- } catch (err) {
108
- // biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
109
- console.warn(
110
- `[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
111
- );
112
- failedKeys.push(key);
113
- }
114
- }
115
- // Fail-closed: abort before the row hard-delete so the sub-tx rolls back
116
- // and the next forget run retries (delete is idempotent converges).
117
- if (failedKeys.length > 0) {
118
- throw new Error(
119
- `[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
99
+ // Resolve the per-tenant provider the forget orchestrator injected. A
100
+ // resolution failure (no provider configured / configResolver absent) collapses
101
+ // to `undefined` so the hook degrades to a row-only delete instead of throwing —
102
+ // see error-class 1 in the header. A working-provider binary-delete failure is
103
+ // handled separately (fail-closed) below.
104
+ async function resolveProvider(ctx: UserDataHookCtx): Promise<UserDataStorageProvider | undefined> {
105
+ if (!ctx.buildStorageProvider) return undefined;
106
+ try {
107
+ return await ctx.buildStorageProvider(ctx.tenantId);
108
+ } catch {
109
+ // skip: provider unresolvable (not configured) → fall through to row-only
110
+ // delete; warn-once below gives operator visibility, boot guard catches it.
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
116
+ if (strategy === "delete") {
117
+ const storageProvider = await resolveProvider(ctx);
118
+ if (storageProvider) {
119
+ const rows = await selectMany(ctx.db, fileRefsTable, {
120
+ tenantId: ctx.tenantId,
121
+ insertedById: ctx.userId,
122
+ });
123
+ const failedKeys: string[] = [];
124
+ for (const row of rows) {
125
+ const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
126
+ if (typeof key !== "string" || key.length === 0) continue;
127
+ try {
128
+ await storageProvider.delete(key);
129
+ } catch (err) {
130
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
131
+ console.warn(
132
+ `[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
120
133
  );
134
+ failedKeys.push(key);
121
135
  }
122
- } else if (!missingStorageWarned) {
123
- missingStorageWarned = true;
124
- // biome-ignore lint/suspicious/noConsole: misconfiguration visibility disk-leak in forget-flow
125
- console.warn(
126
- "[user-data-rights-defaults:fileRef] no storageProvider configured — file binaries are NOT deleted on forget. Pass createUserDataRightsDefaultsFeature({ storageProvider }) to fix.",
136
+ }
137
+ // Fail-closed: abort before the row hard-delete so the sub-tx rolls back
138
+ // and the next forget run retries (delete is idempotent → converges).
139
+ if (failedKeys.length > 0) {
140
+ throw new Error(
141
+ `[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
127
142
  );
128
143
  }
129
- await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
130
- } else {
131
- // anonymize: insertedById=null, FileRef + binary bleiben.
132
- // Use-case: shared chat-Attachment in einem Multi-User-Channel —
133
- // Author-Identifikation raus, Datei bleibt fuer andere User
134
- // sichtbar.
135
- await updateMany(
136
- ctx.db,
137
- fileRefsTable,
138
- { insertedById: null },
139
- { tenantId: ctx.tenantId, insertedById: ctx.userId },
144
+ } else if (!missingStorageWarned) {
145
+ missingStorageWarned = true;
146
+ // biome-ignore lint/suspicious/noConsole: misconfiguration visibility disk-leak in forget-flow
147
+ console.warn(
148
+ "[user-data-rights-defaults:fileRef] no file provider resolvable from ctx.buildStorageProvider — file binaries are NOT deleted on forget (row-only delete). Mount file-foundation + a file-provider-* feature and set the provider config so erasure can reach the binaries.",
140
149
  );
141
150
  }
142
- };
143
- }
144
-
145
- // Legacy export: storage-less hook for callers that haven't migrated.
146
- // Binaries are NOT cleaned up disk leak. Migrate to
147
- // createUserDataRightsDefaultsFeature({ storageProvider }).
148
- export const fileRefDeleteHook: UserDataDeleteHook = createFileRefDeleteHook(undefined);
151
+ await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
152
+ } else {
153
+ // anonymize: insertedById=null, FileRef + binary bleiben.
154
+ // Use-case: shared chat-Attachment in einem Multi-User-Channel
155
+ // Author-Identifikation raus, Datei bleibt fuer andere User
156
+ // sichtbar.
157
+ await updateMany(
158
+ ctx.db,
159
+ fileRefsTable,
160
+ { insertedById: null },
161
+ { tenantId: ctx.tenantId, insertedById: ctx.userId },
162
+ );
163
+ }
164
+ };