@cosmicdrift/kumiko-bundled-features 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/env-schemas.test.ts +53 -11
  3. package/src/auth-email-password/__tests__/auth.integration.test.ts +37 -0
  4. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
  5. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
  6. package/src/auth-email-password/handlers/change-password.write.ts +12 -2
  7. package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
  8. package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
  9. package/src/compliance-profiles/_internal/parse-override.ts +8 -7
  10. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  11. package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
  12. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  13. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  14. package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
  15. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  16. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  17. package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
  18. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  19. package/src/custom-fields/constants.ts +8 -7
  20. package/src/custom-fields/db/queries/projection.ts +19 -7
  21. package/src/custom-fields/db/queries/retention.ts +20 -6
  22. package/src/custom-fields/executor.ts +10 -0
  23. package/src/custom-fields/feature.ts +32 -39
  24. package/src/custom-fields/handlers/clear-custom-field.write.ts +8 -1
  25. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  26. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  27. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  28. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  29. package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
  30. package/src/custom-fields/lib/field-access.ts +9 -4
  31. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  32. package/src/custom-fields/lib/value-schema.ts +14 -2
  33. package/src/custom-fields/run-retention.ts +6 -5
  34. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  35. package/src/custom-fields/web/client-plugin.tsx +2 -0
  36. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  37. package/src/custom-fields/web/i18n.ts +30 -0
  38. package/src/custom-fields/wire-for-entity.ts +9 -2
  39. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  40. package/src/feature-toggles/handlers/set.write.ts +13 -8
  41. package/src/secrets/feature.ts +4 -11
  42. package/src/subscription-stripe/feature.ts +2 -2
  43. package/src/template-resolver/handlers/list.query.ts +12 -10
  44. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  45. package/src/tenant/seeding.ts +3 -3
  46. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
  47. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
  48. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
  49. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
  50. package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
  51. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  52. package/src/user-data-rights/run-forget-cleanup.ts +77 -36
  53. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
@@ -104,8 +104,16 @@ export interface RunForgetCleanupResult {
104
104
  interface HookEntry {
105
105
  readonly entityName: string;
106
106
  readonly deleteHook: UserDataDeleteHook;
107
+ /** Lower runs first. Owner-column-preserving redaction declares a negative
108
+ * order so it precedes owner-nulling hooks on the same entity (see sort below). */
109
+ readonly order: number;
107
110
  }
108
111
 
112
+ // EXT_USER_DATA delete-hooks default here; a hook that redacts data keyed on an
113
+ // owner column it doesn't own must register a lower order so it runs BEFORE any
114
+ // hook that nulls that column. See custom-fields wire-user-data-rights.ts.
115
+ const HOOK_ORDER_DEFAULT = 0;
116
+
109
117
  export async function runForgetCleanup(
110
118
  args: RunForgetCleanupArgs,
111
119
  ): Promise<RunForgetCleanupResult> {
@@ -127,10 +135,18 @@ export async function runForgetCleanup(
127
135
  const usages = registry.getExtensionUsages(EXT_USER_DATA);
128
136
  const hookEntries: HookEntry[] = usages
129
137
  .map((u): HookEntry | null => {
130
- const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook }; // @cast-boundary engine-payload
131
- return opts.delete ? { entityName: u.entityName, deleteHook: opts.delete } : null;
138
+ const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook; order?: number }; // @cast-boundary engine-payload
139
+ if (!opts.delete) return null;
140
+ const order = typeof opts.order === "number" ? opts.order : HOOK_ORDER_DEFAULT;
141
+ return { entityName: u.entityName, deleteHook: opts.delete, order };
132
142
  })
133
- .filter((x): x is HookEntry => x !== null);
143
+ .filter((x): x is HookEntry => x !== null)
144
+ // Order ascending. Array.sort is ES2019-stable, so equal orders keep
145
+ // registration order; correctness here needs only distinct orders, not
146
+ // stability. Guarantees owner-preserving redaction (negative order) runs
147
+ // before owner-nulling hooks on the same entity, independent of feature
148
+ // registration order.
149
+ .sort((a, b) => a.order - b.order);
134
150
 
135
151
  const errors: ForgetCleanupError[] = [];
136
152
  const processedUserIds: string[] = [];
@@ -233,46 +249,40 @@ async function processUser(args: {
233
249
  memberships.length > 0 ? memberships.map((m) => m.tenantId) : [SYSTEM_TENANT_ID_FOR_ORPHANS];
234
250
 
235
251
  // Per-User-Sub-Tx: hooks + status-flip atomar. Bei Hook-Throw rollt
236
- // nur dieser User zurueck, andere User bleiben commit-fest. Drizzle
237
- // mappt das in nested-Tx auf SAVEPOINT, in top-level auf BEGIN die
238
- // `transaction()`-API ist auf DbRunner uniform.
239
- //
240
- // Cast `db as {transaction: ...}` ist eine TS-Limitation: DbRunner ist
241
- // `DbConnection | DbTx`, beide haben `.transaction()`, aber TS kann
242
- // die Signaturen ueber die Union nicht unifizieren (PgDatabase vs
243
- // PgTransaction haben unterschiedliche Generics). Cast macht das
244
- // Strukturelle explizit, kein Hack.
252
+ // nur dieser User zurueck, andere User bleiben commit-fest. Die Sub-Tx
253
+ // nestet korrekt: eine Top-Level-Connection oeffnet sie via `.begin`
254
+ // (BEGIN), eine TransactionSql der Fall im Dispatcher, wo jeder
255
+ // writeHandler bereits IN der Outer-Tx laeuft — via `.savepoint`
256
+ // (SAVEPOINT). Siehe runInSubTransaction.
245
257
  let txSucceeded = false;
246
258
  let currentTenantId: TenantId | null = null;
247
259
  let currentEntityName: string | null = null;
248
260
  try {
249
- await (db as { begin: (fn: (tx: DbRunner) => Promise<void>) => Promise<void> }).begin(
250
- async (tx) => {
251
- for (const tenantId of tenantList) {
252
- currentTenantId = tenantId;
253
- for (const entry of hookEntries) {
254
- currentEntityName = entry.entityName;
255
- const policy = await resolveRetentionPolicyForTenant({
256
- db: tx,
257
- registry,
258
- tenantId,
259
- entityName: entry.entityName,
260
- });
261
- const strategy = policyToStrategy(policy.policy?.strategy ?? null);
261
+ await runInSubTransaction(db, async (tx) => {
262
+ for (const tenantId of tenantList) {
263
+ currentTenantId = tenantId;
264
+ for (const entry of hookEntries) {
265
+ currentEntityName = entry.entityName;
266
+ const policy = await resolveRetentionPolicyForTenant({
267
+ db: tx,
268
+ registry,
269
+ tenantId,
270
+ entityName: entry.entityName,
271
+ });
272
+ const strategy = policyToStrategy(policy.policy?.strategy ?? null);
262
273
 
263
- hookCallsAttempted++;
264
- await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
265
- }
274
+ hookCallsAttempted++;
275
+ await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
266
276
  }
277
+ }
267
278
 
268
- // Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
269
- // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
270
- // alles, der User bleibt im DeletionRequested-Status, naechster
271
- // Run retried.
272
- await updateMany(tx, userTable, { status: USER_STATUS.Deleted }, { id: userId });
273
- txSucceeded = true;
274
- },
275
- );
279
+ // Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
280
+ // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
281
+ // alles, der User bleibt im DeletionRequested-Status, naechster
282
+ // Run retried.
283
+ await updateMany(tx, userTable, { status: USER_STATUS.Deleted }, { id: userId });
284
+ txSucceeded = true;
285
+ });
276
286
  } catch (e) {
277
287
  // currentTenantId/currentEntityName tracken den Failing-Hook —
278
288
  // Operator sieht "Hook fileRef in Tenant A failed for user X" statt
@@ -294,6 +304,37 @@ async function processUser(args: {
294
304
  };
295
305
  }
296
306
 
307
+ // Per-user sub-transaction, nesting-aware across both db shapes:
308
+ // - top-level connection (Bun.SQL / postgres-js Sql) → `.begin` (BEGIN)
309
+ // - TransactionSql (inside the dispatcher's outer tx, where every
310
+ // writeHandler already runs) → `.savepoint` (SAVEPOINT)
311
+ // A TransactionSql has no `.begin`, so the previous unconditional `.begin`
312
+ // threw "is not a function" on every user when invoked through the dispatcher
313
+ // (the cron path) → zero deletions in production, while direct-connection tests
314
+ // stayed green. Selecting the available method makes the sub-tx work in both
315
+ // contexts; on throw the savepoint rolls back just this user (others survive).
316
+ async function runInSubTransaction(
317
+ db: DbRunner,
318
+ fn: (tx: DbRunner) => Promise<void>,
319
+ ): Promise<void> {
320
+ // `db` is already the raw runner (the handler passes ctx.db.raw, the tests a
321
+ // top-level connection) — cast to read the transaction surface directly,
322
+ // without asRawClient (a test-only escape hatch). A top-level connection
323
+ // exposes `.begin`; a TransactionSql only `.savepoint`. They are mutually
324
+ // exclusive, so prefer whichever is present.
325
+ const runner = db as {
326
+ begin?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
327
+ savepoint?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
328
+ };
329
+ const open = runner.begin ?? runner.savepoint;
330
+ if (!open) {
331
+ throw new Error(
332
+ "runForgetCleanup: db exposes neither .begin nor .savepoint — cannot open a per-user sub-transaction",
333
+ );
334
+ }
335
+ await open.call(runner, fn);
336
+ }
337
+
297
338
  // Pseudo-Tenant fuer User ohne aktive Memberships. RFC4122-konforme
298
339
  // Null-UUID. Tenant-scoped Hooks finden hier nichts (no-op),
299
340
  // tenant-agnostische Hooks (z.B. user) operieren auf der globalen
@@ -11,15 +11,21 @@ import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-fra
11
11
  //
12
12
  // Delete-Hook entfernt FileRef-Zeile via factory
13
13
  // `createFileRefDeleteHook(storageProvider)`:
14
- // "delete": storageProvider.delete() pro File (best-effort) + Row hard-delete
14
+ // "delete": storageProvider.delete() pro File + Row hard-delete
15
15
  // "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
16
16
  // koennen weiter zeigen; Personenbezug raus)
17
17
  //
18
- // Storage-Provider-Cleanup ist BEST-EFFORT wenn S3-delete failt,
19
- // log + skip (Cron-Job kann es retry). Memory: Forget-Atomicity-
20
- // Decision aus Sprint-2-Architektur (advisor-pinned): per-Hook
21
- // idempotent, KEIN globaler Rollback wenn ein File-Delete failt,
22
- // bleibt der User-Row trotzdem anonymisiert.
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.
23
29
  //
24
30
  // `storageProvider` ist optional. App-Author wired es beim
25
31
  // Feature-Mount rein (`createUserDataRightsDefaultsFeature({
@@ -92,6 +98,7 @@ export function createFileRefDeleteHook(
92
98
  tenantId: ctx.tenantId,
93
99
  insertedById: ctx.userId,
94
100
  });
101
+ const failedKeys: string[] = [];
95
102
  for (const row of rows) {
96
103
  const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
97
104
  if (typeof key !== "string" || key.length === 0) continue;
@@ -102,8 +109,16 @@ export function createFileRefDeleteHook(
102
109
  console.warn(
103
110
  `[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
104
111
  );
112
+ failedKeys.push(key);
105
113
  }
106
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(", ")})`,
120
+ );
121
+ }
107
122
  } else if (!missingStorageWarned) {
108
123
  missingStorageWarned = true;
109
124
  // biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow