@cosmicdrift/kumiko-bundled-features 0.278.0 → 0.280.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 (48) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
  3. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
  4. package/src/auth-mfa-user-data/hooks.ts +4 -6
  5. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
  6. package/src/custom-fields/wire-user-data-rights.ts +40 -24
  7. package/src/data-retention/resolve-for-tenant.ts +18 -6
  8. package/src/delivery/db/queries/preferences.ts +1 -0
  9. package/src/files-tenant-data/hooks.ts +3 -5
  10. package/src/folders/__tests__/folders.integration.test.ts +4 -3
  11. package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
  12. package/src/folders-user-data/hooks.ts +5 -10
  13. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
  14. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
  15. package/src/form-draft-user-data/hooks.ts +3 -6
  16. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
  17. package/src/notes-history-user-data/hooks.ts +3 -4
  18. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  19. package/src/template-resolver-user-data/hooks.ts +1 -2
  20. package/src/tenant-lifecycle/feature.ts +2 -0
  21. package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
  22. package/src/tenant-lifecycle/stages.ts +26 -2
  23. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
  24. package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
  25. package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
  26. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
  27. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
  28. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
  29. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
  30. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
  31. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
  32. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
  33. package/src/user-data-rights/feature.ts +4 -0
  34. package/src/user-data-rights/run-export-jobs.ts +16 -1
  35. package/src/user-data-rights/run-forget-cleanup.ts +44 -2
  36. package/src/user-data-rights/run-user-export.ts +43 -3
  37. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
  38. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
  39. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
  40. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
  41. package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
  42. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
  43. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
  44. package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
  45. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
  46. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
  47. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
  48. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
@@ -20,7 +20,11 @@ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth
20
20
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
21
21
  import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
22
22
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
23
- import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
23
+ import {
24
+ createSystemUser,
25
+ type JobContext,
26
+ SYSTEM_TENANT_ID,
27
+ } from "@cosmicdrift/kumiko-framework/engine";
24
28
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
25
29
  import {
26
30
  setupTestStack,
@@ -50,7 +54,7 @@ let stack: TestStack;
50
54
 
51
55
  const TENANT_A = "00000000-0000-4000-8000-00000000000a";
52
56
  const TENANT_B = "00000000-0000-4000-8000-00000000000b";
53
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
57
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
54
58
 
55
59
  // Deterministische UUIDs fuer Tests — gleiche Helper wie in
56
60
  // user-data-rights-defaults.
@@ -318,6 +322,7 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
318
322
  }),
319
323
  registry: stack.registry,
320
324
  log,
325
+ systemUser: createSystemUser(TENANT_A),
321
326
  };
322
327
  await job?.handler({}, jobCtx as unknown as JobContext);
323
328
 
@@ -359,6 +364,7 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
359
364
  unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
360
365
  }),
361
366
  registry: stack.registry,
367
+ systemUser: createSystemUser(TENANT_A),
362
368
  log,
363
369
  } as unknown as JobContext);
364
370
  } finally {
@@ -25,7 +25,11 @@ import {
25
25
  isPiiCiphertext,
26
26
  } from "@cosmicdrift/kumiko-framework/crypto";
27
27
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
28
- import { createSystemUser, type UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
28
+ import {
29
+ createSystemUser,
30
+ SYSTEM_TENANT_ID,
31
+ type UserDataHookCtx,
32
+ } from "@cosmicdrift/kumiko-framework/engine";
29
33
  import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
30
34
  import {
31
35
  setupTestStack,
@@ -58,7 +62,7 @@ let stack: TestStack;
58
62
 
59
63
  const TENANT_A = "00000000-0000-4000-8000-00000000000a";
60
64
  const TENANT_B = "00000000-0000-4000-8000-00000000000b";
61
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
65
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
62
66
 
63
67
  function uuid(suffix: number): string {
64
68
  return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
@@ -498,7 +502,7 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
498
502
  // in the GDPR bundle. The hook decrypts locally (same as invitations
499
503
  // query) — assert plaintext here so a regression to raw ciphertext fails.
500
504
  const hookCtx: UserDataHookCtx = {
501
- db: stack.db,
505
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
502
506
  registry: stack.registry,
503
507
  tenantId: TENANT_A,
504
508
  userId: ALICE_ID,
@@ -571,7 +575,7 @@ describe("runUserExport :: fileRef PII export via the fileRefs side-channel (#19
571
575
  // assertion would catch it even though the runUserExport() assertions
572
576
  // below would stay green either way.
573
577
  const hookCtx: UserDataHookCtx = {
574
- db: stack.db,
578
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
575
579
  registry: stack.registry,
576
580
  tenantId: TENANT_A,
577
581
  userId: ALICE_ID,
@@ -65,11 +65,6 @@ const TABLE = "read_dsgvo_tenant_scoped";
65
65
 
66
66
  // Tenant-scoped contributor with NO per-user column — deletes by tenant only,
67
67
  // and ONLY when this tenant is effectively single-user (mirrors credit).
68
- const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
69
- if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
70
- await asRawClient(ctx.db).unsafe(`DELETE FROM ${TABLE} WHERE tenant_id = $1`, [ctx.tenantId]);
71
- };
72
-
73
68
  const scopedEntity = createEntity({
74
69
  table: TABLE,
75
70
  fields: {
@@ -77,11 +72,26 @@ const scopedEntity = createEntity({
77
72
  },
78
73
  });
79
74
 
75
+ // fw#2914 — "tenant-scoped" is a managed r.entity table (EXECUTOR_ONLY-branded),
76
+ // so TenantDb's typed write methods reject it; this delete is raw SQL, still
77
+ // filtered by tenant_id.
78
+ const TENANT_SCOPED_DELETE_REASON =
79
+ "fw#2914 test fixture: tenant-scoped erasure against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by tenant_id";
80
+
81
+ const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
82
+ if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
83
+ await asRawClient(ctx.db.unsafeRaw(TENANT_SCOPED_DELETE_REASON)).unsafe(
84
+ `DELETE FROM ${TABLE} WHERE tenant_id = $1`,
85
+ [ctx.tenantId],
86
+ );
87
+ };
88
+
80
89
  const contributorFeature = defineFeature("dsgvo-tenant-scoped", (r) => {
81
90
  r.entity("tenant-scoped", scopedEntity);
82
91
  r.useExtension(EXT_USER_DATA, "tenant-scoped", {
83
92
  export: async () => null,
84
93
  delete: tenantScopedDeleteHook,
94
+ escapeHatch: { reason: TENANT_SCOPED_DELETE_REASON },
85
95
  });
86
96
  });
87
97
 
@@ -610,6 +610,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
610
610
  ...(opts.appExportDownloadUrl !== undefined && {
611
611
  appExportDownloadUrl: opts.appExportDownloadUrl,
612
612
  }),
613
+ escapeHatchAuditSink: ctx._escapeHatchAuditSink,
614
+ actor: ctx.systemUser.id,
613
615
  });
614
616
  },
615
617
  });
@@ -677,6 +679,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
677
679
  handlerName: "user-data-rights:run-forget-cleanup",
678
680
  }),
679
681
  ...(sendDeletionExecutedEmail && { sendDeletionExecutedEmail }),
682
+ escapeHatchAuditSink: ctx._escapeHatchAuditSink,
683
+ actor: ctx.systemUser.id,
680
684
  });
681
685
 
682
686
  // Persist each incomplete report on the job-run log (#1572). The write
@@ -49,7 +49,11 @@ import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
49
49
  import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
50
50
  import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
51
51
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
52
- import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
52
+ import type {
53
+ EscapeHatchAuditSink,
54
+ Registry,
55
+ TenantId,
56
+ } from "@cosmicdrift/kumiko-framework/engine";
53
57
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
54
58
  import {
55
59
  createZipStream,
@@ -155,6 +159,11 @@ export interface RunExportJobsArgs {
155
159
  * a URL fragment (not a query param — see issue #1271) and passes that
156
160
  * Callback durch. Required wenn sendExportReadyEmail gesetzt. */
157
161
  readonly appExportDownloadUrl?: string;
162
+
163
+ // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
164
+ // systemUser.id); threaded into runUserExport's per-hook TenantDb.
165
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
166
+ readonly actor?: string;
158
167
  }
159
168
 
160
169
  export interface ExportJobError {
@@ -219,6 +228,8 @@ export async function runExportJobs(args: RunExportJobsArgs): Promise<RunExportJ
219
228
  buildStorageProvider,
220
229
  now,
221
230
  job,
231
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
232
+ actor: args.actor,
222
233
  });
223
234
  if (outcome.kind === "done") {
224
235
  completedJobIds.push(job.id);
@@ -337,6 +348,8 @@ async function processJob(args: {
337
348
  buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
338
349
  now: Instant;
339
350
  job: JobRow;
351
+ escapeHatchAuditSink?: EscapeHatchAuditSink;
352
+ actor?: string;
340
353
  }): Promise<ProcessOutcome> {
341
354
  const { db, registry, buildStorageProvider, now, job } = args;
342
355
  const executor = createSystemUser(job.requestedFromTenantId);
@@ -381,6 +394,8 @@ async function processJob(args: {
381
394
  registry,
382
395
  userId: job.userId,
383
396
  now,
397
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
398
+ actor: args.actor,
384
399
  });
385
400
 
386
401
  const profile = await resolveProfileForTenant({
@@ -39,14 +39,17 @@ import {
39
39
  subjectIdToKey,
40
40
  } from "@cosmicdrift/kumiko-framework/crypto";
41
41
  import {
42
+ createTenantDb,
42
43
  type DbRunner,
43
44
  entityEventName,
44
45
  executeRawQuery,
45
46
  nullBlindIndexesForSubject,
46
47
  } from "@cosmicdrift/kumiko-framework/db";
47
48
  import {
49
+ type EscapeHatchAuditSink,
48
50
  EXT_USER_DATA,
49
51
  EXT_USER_DATA_ORDER,
52
+ extensionUsageEscapeHatchReason,
50
53
  type Registry,
51
54
  type TenantId,
52
55
  type TenantUserModel,
@@ -54,6 +57,7 @@ import {
54
57
  type UserDataDeleteStrategy,
55
58
  type UserDataStorageProvider,
56
59
  } from "@cosmicdrift/kumiko-framework/engine";
60
+ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
57
61
  import {
58
62
  purgeSearchDocumentsForSubject,
59
63
  type SearchAdapter,
@@ -140,6 +144,12 @@ export interface RunForgetCleanupArgs {
140
144
  * (no tenant-scoped erasure).
141
145
  */
142
146
  readonly tenantModel?: TenantUserModel;
147
+
148
+ // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
149
+ // systemUser.id); attributes+audits any EXT_USER_DATA usage's declared
150
+ // escapeHatch when its delete hook calls ctx.db.unsafeRaw(reason).
151
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
152
+ readonly actor?: string;
143
153
  }
144
154
 
145
155
  export interface ForgetCleanupError {
@@ -184,6 +194,26 @@ interface HookEntry {
184
194
  /** Lower runs first. Owner-column-preserving redaction declares a negative
185
195
  * order so it precedes owner-nulling hooks on the same entity (see sort below). */
186
196
  readonly order: number;
197
+ readonly escapeHatchReason: string | undefined;
198
+ }
199
+
200
+ function buildHookDb(
201
+ db: DbRunner,
202
+ tenantId: TenantId,
203
+ entry: HookEntry,
204
+ args: { readonly escapeHatchAuditSink?: EscapeHatchAuditSink; readonly actor?: string },
205
+ ) {
206
+ const report = createEscapeHatchReporter({
207
+ handler: `${EXT_USER_DATA}:${entry.entityName}`,
208
+ tenantId,
209
+ actor: args.actor ?? "system",
210
+ sink: args.escapeHatchAuditSink,
211
+ });
212
+ return createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, {
213
+ unsafeRaw:
214
+ entry.escapeHatchReason !== undefined ? { reason: entry.escapeHatchReason } : undefined,
215
+ report,
216
+ });
187
217
  }
188
218
 
189
219
  // EXT_USER_DATA delete-hooks default here; a hook that redacts data keyed on an
@@ -226,7 +256,12 @@ export async function runForgetCleanup(
226
256
  const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook; order?: number }; // @cast-boundary engine-payload
227
257
  if (!opts.delete) return null;
228
258
  const order = typeof opts.order === "number" ? opts.order : HOOK_ORDER_DEFAULT;
229
- return { entityName: u.entityName, deleteHook: opts.delete, order };
259
+ return {
260
+ entityName: u.entityName,
261
+ deleteHook: opts.delete,
262
+ order,
263
+ escapeHatchReason: extensionUsageEscapeHatchReason(u),
264
+ };
230
265
  })
231
266
  .filter((x): x is HookEntry => x !== null)
232
267
  // Order ascending. Array.sort is ES2019-stable, so equal orders keep
@@ -252,6 +287,8 @@ export async function runForgetCleanup(
252
287
  appTenantModel,
253
288
  kms,
254
289
  searchAdapter: args.searchAdapter,
290
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
291
+ actor: args.actor,
255
292
  });
256
293
  hookCallsAttempted += userResult.hookCallsAttempted;
257
294
  errors.push(...userResult.errors);
@@ -316,6 +353,8 @@ async function processUser(args: {
316
353
  appTenantModel: TenantUserModel;
317
354
  kms?: KmsAdapter;
318
355
  searchAdapter?: SearchAdapter;
356
+ escapeHatchAuditSink?: EscapeHatchAuditSink;
357
+ actor?: string;
319
358
  }): Promise<ProcessUserResult> {
320
359
  const { db, registry, userId, hookEntries, buildStorageProvider, appTenantModel, kms } = args;
321
360
  const errors: ForgetCleanupError[] = [];
@@ -404,7 +443,10 @@ async function processUser(args: {
404
443
  hookCallsAttempted++;
405
444
  const hookResult = await entry.deleteHook(
406
445
  {
407
- db: tx,
446
+ db: buildHookDb(tx, tenantId, entry, {
447
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
448
+ actor: args.actor,
449
+ }),
408
450
  registry,
409
451
  tenantId,
410
452
  userId,
@@ -30,15 +30,19 @@ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
30
30
  import {
31
31
  collectEncryptedFieldNames,
32
32
  configuredEntityFieldEncryption,
33
+ createTenantDb,
33
34
  decryptEntityFieldValues,
34
35
  } from "@cosmicdrift/kumiko-framework/db";
35
36
  import {
37
+ type EscapeHatchAuditSink,
36
38
  EXT_USER_DATA,
39
+ extensionUsageEscapeHatchReason,
37
40
  type Registry,
38
41
  type TenantId,
39
42
  type UserDataExportHook,
40
43
  type UserDataExportSnippet,
41
44
  } from "@cosmicdrift/kumiko-framework/engine";
45
+ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
42
46
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
43
47
  import { tenantMembershipsTable } from "../tenant";
44
48
  import { buildFileRefZipPath } from "./zip-path";
@@ -50,6 +54,11 @@ export interface RunUserExportArgs {
50
54
  readonly registry: Registry;
51
55
  readonly userId: string;
52
56
  readonly now: Instant;
57
+ // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
58
+ // systemUser.id); attributes+audits any EXT_USER_DATA usage's declared
59
+ // escapeHatch when its export hook calls ctx.db.unsafeRaw(reason).
60
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
61
+ readonly actor?: string;
53
62
  }
54
63
 
55
64
  export interface UserExportFileRef {
@@ -90,6 +99,26 @@ export interface UserExportBundle {
90
99
  interface HookEntry {
91
100
  readonly entityName: string;
92
101
  readonly exportHook: UserDataExportHook;
102
+ readonly escapeHatchReason: string | undefined;
103
+ }
104
+
105
+ function buildHookDb(
106
+ db: DbRunner,
107
+ tenantId: TenantId,
108
+ entry: HookEntry,
109
+ args: Pick<RunUserExportArgs, "escapeHatchAuditSink" | "actor">,
110
+ ) {
111
+ const report = createEscapeHatchReporter({
112
+ handler: `${EXT_USER_DATA}:${entry.entityName}`,
113
+ tenantId,
114
+ actor: args.actor ?? "system",
115
+ sink: args.escapeHatchAuditSink,
116
+ });
117
+ return createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, {
118
+ unsafeRaw:
119
+ entry.escapeHatchReason !== undefined ? { reason: entry.escapeHatchReason } : undefined,
120
+ report,
121
+ });
93
122
  }
94
123
 
95
124
  /**
@@ -137,7 +166,13 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
137
166
  const hookEntries: HookEntry[] = usages
138
167
  .map((u): HookEntry | null => {
139
168
  const opts = (u.options ?? {}) as { export?: UserDataExportHook }; // @cast-boundary engine-payload
140
- return opts.export ? { entityName: u.entityName, exportHook: opts.export } : null;
169
+ return opts.export
170
+ ? {
171
+ entityName: u.entityName,
172
+ exportHook: opts.export,
173
+ escapeHatchReason: extensionUsageEscapeHatchReason(u),
174
+ }
175
+ : null;
141
176
  })
142
177
  .filter((x): x is HookEntry => x !== null);
143
178
 
@@ -147,7 +182,12 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
147
182
  for (const tenantId of tenantList) {
148
183
  const entities: UserDataExportSnippet[] = [];
149
184
  for (const entry of hookEntries) {
150
- const rawSnippet = await entry.exportHook({ db, registry, tenantId, userId });
185
+ const rawSnippet = await entry.exportHook({
186
+ db: buildHookDb(db, tenantId, entry, args),
187
+ registry,
188
+ tenantId,
189
+ userId,
190
+ });
151
191
  if (rawSnippet === null) continue;
152
192
  const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
153
193
  entities.push(snippet);
@@ -181,7 +221,7 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
181
221
  const orphanEntities: UserDataExportSnippet[] = [];
182
222
  for (const entry of hookEntries) {
183
223
  const rawSnippet = await entry.exportHook({
184
- db,
224
+ db: buildHookDb(db, SYSTEM_TENANT_ID_FOR_ORPHANS, entry, args),
185
225
  registry,
186
226
  tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
187
227
  userId,
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
14
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
16
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
15
17
  import {
16
18
  setupTestStack,
17
19
  type TestStack,
@@ -117,7 +119,7 @@ function uuid(suffix: number): string {
117
119
 
118
120
  function ctx(userId: string, overrides: Record<string, unknown> = {}) {
119
121
  return {
120
- db: full.db,
122
+ db: createTenantDb(full.db, TENANT_A, "tenant"),
121
123
  registry: full.registry,
122
124
  tenantId: TENANT_A,
123
125
  userId,
@@ -132,10 +134,9 @@ async function rawSelect(sql: string, params: unknown[]) {
132
134
  }
133
135
 
134
136
  async function seedUser(id: string): Promise<void> {
135
- const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000001";
136
137
  await seedRow(full.db, userTable, {
137
138
  id,
138
- tenantId: SYSTEM_TENANT,
139
+ tenantId: SYSTEM_TENANT_ID,
139
140
  email: `user-${id}@example.com`,
140
141
  passwordHash: "hashed-password",
141
142
  displayName: `User ${id}`,
@@ -404,11 +405,11 @@ describe("job-run userData-hooks (#799)", () => {
404
405
  `INSERT INTO store_job_runs
405
406
  (id, tenant_id, job_name, bull_job_id, status, payload, attempt, started_at, triggered_by_id)
406
407
  VALUES ($1::uuid, $2, 'app:job:export', $3, 'completed', '{"scope":"mine"}', 1, now(), $4)`,
407
- [id, TENANT_A, `bull-${id}`, triggeredById],
408
+ [id, SYSTEM_TENANT_ID, `bull-${id}`, triggeredById],
408
409
  );
409
410
  }
410
411
 
411
- test("export filters by triggeredById only (system-tenant rows)", async () => {
412
+ test("export filters by triggeredById only (rows live on SYSTEM_TENANT_ID, ctx bound to TENANT_A)", async () => {
412
413
  await seedRun(uuid(70), "jr-user-1");
413
414
  await seedRun(uuid(71), "jr-user-2");
414
415
  await seedRun(uuid(72), null);
@@ -428,7 +429,7 @@ describe("job-run userData-hooks (#799)", () => {
428
429
  describe("Presence-Gating: Source-Feature nicht gemountet → null/no-op statt Crash", () => {
429
430
  test("alle gegateten Hooks no-open auf dem Minimal-Stack", async () => {
430
431
  const minCtx = {
431
- db: minimal.db,
432
+ db: createTenantDb(minimal.db, TENANT_A, "tenant"),
432
433
  registry: minimal.registry,
433
434
  tenantId: TENANT_A,
434
435
  userId: "any-user",
@@ -13,7 +13,9 @@
13
13
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
14
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
15
15
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
16
17
  import { variantSuffix } from "@cosmicdrift/kumiko-framework/derivatives";
18
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
17
19
  import {
18
20
  createInMemoryFileProvider,
19
21
  deriveKey,
@@ -87,10 +89,9 @@ async function seedUser(id: string, overrides: Record<string, unknown> = {}): Pr
87
89
  // user-Entity ist tenant-agnostisch im Domain-Sinn, aber das DB-
88
90
  // Schema hat tenant_id-Spalte automatisch (Framework-Default).
89
91
  // Pragmatisch: SYSTEM_TENANT_ID fuer User-Rows in Tests.
90
- const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000001";
91
92
  await seedRow(stack.db, userTable, {
92
93
  id,
93
- tenantId: SYSTEM_TENANT,
94
+ tenantId: SYSTEM_TENANT_ID,
94
95
  email: `user-${id}@example.com`,
95
96
  passwordHash: "hashed-password",
96
97
  displayName: `User ${id}`,
@@ -168,7 +169,7 @@ describe("S2.H1 :: userExportHook", () => {
168
169
  await seedUser(uuid(1001), { displayName: "Marc" });
169
170
 
170
171
  const result = await userExportHook({
171
- db: stack.db,
172
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
172
173
  registry: stack.registry,
173
174
  tenantId: TENANT_A,
174
175
  userId: uuid(1001),
@@ -189,7 +190,7 @@ describe("S2.H1 :: userExportHook", () => {
189
190
 
190
191
  test("returns null wenn User nicht existiert", async () => {
191
192
  const result = await userExportHook({
192
- db: stack.db,
193
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
193
194
  registry: stack.registry,
194
195
  tenantId: TENANT_A,
195
196
  userId: uuid(1002),
@@ -203,7 +204,12 @@ describe("S2.H1 :: userDeleteHook", () => {
203
204
  await seedUser(uuid(1003));
204
205
 
205
206
  await userDeleteHook(
206
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1003) },
207
+ {
208
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
209
+ registry: stack.registry,
210
+ tenantId: TENANT_A,
211
+ userId: uuid(1003),
212
+ },
207
213
  "delete",
208
214
  );
209
215
 
@@ -222,7 +228,12 @@ describe("S2.H1 :: userDeleteHook", () => {
222
228
  await seedUser(uuid(1004));
223
229
 
224
230
  await userDeleteHook(
225
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1004) },
231
+ {
232
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
233
+ registry: stack.registry,
234
+ tenantId: TENANT_A,
235
+ userId: uuid(1004),
236
+ },
226
237
  "anonymize",
227
238
  );
228
239
 
@@ -238,7 +249,12 @@ describe("S2.H1 :: userDeleteHook", () => {
238
249
  await seedUser(uuid(1005));
239
250
 
240
251
  await userDeleteHook(
241
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
252
+ {
253
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
254
+ registry: stack.registry,
255
+ tenantId: TENANT_A,
256
+ userId: uuid(1005),
257
+ },
242
258
  "delete",
243
259
  );
244
260
  const afterFirst = await fetchUser(uuid(1005));
@@ -247,7 +263,12 @@ describe("S2.H1 :: userDeleteHook", () => {
247
263
  // Zweiter Call: kein Crash + State unverändert
248
264
  await expect(
249
265
  userDeleteHook(
250
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
266
+ {
267
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
268
+ registry: stack.registry,
269
+ tenantId: TENANT_A,
270
+ userId: uuid(1005),
271
+ },
251
272
  "delete",
252
273
  ),
253
274
  ).resolves.toBeUndefined();
@@ -269,7 +290,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
269
290
  await seedFileRef(uuid(102), TENANT_A, "user-files-1", "anschreiben.pdf");
270
291
 
271
292
  const result = await fileRefExportHook({
272
- db: stack.db,
293
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
273
294
  registry: stack.registry,
274
295
  tenantId: TENANT_A,
275
296
  userId: "user-files-1",
@@ -284,7 +305,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
284
305
 
285
306
  test("returns null wenn User keine Files hat", async () => {
286
307
  const result = await fileRefExportHook({
287
- db: stack.db,
308
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
288
309
  registry: stack.registry,
289
310
  tenantId: TENANT_A,
290
311
  userId: "ghost-user-no-files",
@@ -299,7 +320,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
299
320
  await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
300
321
 
301
322
  await fileRefDeleteHook(
302
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-delete-files" },
323
+ {
324
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
325
+ registry: stack.registry,
326
+ tenantId: TENANT_A,
327
+ userId: "user-delete-files",
328
+ },
303
329
  "delete",
304
330
  );
305
331
 
@@ -311,7 +337,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
311
337
  await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
312
338
 
313
339
  await fileRefDeleteHook(
314
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-anon-files" },
340
+ {
341
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
342
+ registry: stack.registry,
343
+ tenantId: TENANT_A,
344
+ userId: "user-anon-files",
345
+ },
315
346
  "anonymize",
316
347
  );
317
348
 
@@ -329,7 +360,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
329
360
 
330
361
  // Tenant A loescht alle Files von "shared-user"
331
362
  await fileRefDeleteHook(
332
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "shared-user" },
363
+ {
364
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
365
+ registry: stack.registry,
366
+ tenantId: TENANT_A,
367
+ userId: "shared-user",
368
+ },
333
369
  "delete",
334
370
  );
335
371
 
@@ -345,7 +381,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
345
381
  await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
346
382
 
347
383
  await fileRefDeleteHook(
348
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
384
+ {
385
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
386
+ registry: stack.registry,
387
+ tenantId: TENANT_A,
388
+ userId: "user-idem-files",
389
+ },
349
390
  "delete",
350
391
  );
351
392
  const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
@@ -354,7 +395,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
354
395
  // Zweiter Call: kein Crash + State weiter 0 Files
355
396
  await expect(
356
397
  fileRefDeleteHook(
357
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
398
+ {
399
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
400
+ registry: stack.registry,
401
+ tenantId: TENANT_A,
402
+ userId: "user-idem-files",
403
+ },
358
404
  "delete",
359
405
  ),
360
406
  ).resolves.toBeUndefined();
@@ -398,7 +444,7 @@ describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue
398
444
 
399
445
  await fileRefDeleteHook(
400
446
  {
401
- db: stack.db,
447
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
402
448
  registry: stack.registry,
403
449
  tenantId: TENANT_A,
404
450
  userId,
@@ -428,7 +474,7 @@ describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue
428
474
 
429
475
  await fileRefDeleteHook(
430
476
  {
431
- db: stack.db,
477
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
432
478
  registry: stack.registry,
433
479
  tenantId: TENANT_A,
434
480
  userId,
@@ -459,7 +505,7 @@ describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue
459
505
  await expect(
460
506
  fileRefDeleteHook(
461
507
  {
462
- db: stack.db,
508
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
463
509
  registry: stack.registry,
464
510
  tenantId: TENANT_A,
465
511
  userId,
@@ -1,4 +1,3 @@
1
- import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { apiTokenTable } from "../../personal-access-tokens";
4
3
  import { featureMounted } from "./feature-mounted";
@@ -11,7 +10,7 @@ import { featureMounted } from "./feature-mounted";
11
10
 
12
11
  export const apiTokenExportHook: UserDataExportHook = async (ctx) => {
13
12
  if (!featureMounted(ctx, "personal-access-tokens")) return null;
14
- const rows = await selectMany<Record<string, unknown>>(ctx.db, apiTokenTable, {
13
+ const rows = await ctx.db.selectMany(apiTokenTable, {
15
14
  userId: ctx.userId,
16
15
  tenantId: ctx.tenantId,
17
16
  });
@@ -33,5 +32,5 @@ export const apiTokenExportHook: UserDataExportHook = async (ctx) => {
33
32
  export const apiTokenDeleteHook: UserDataDeleteHook = async (ctx) => {
34
33
  // skip: personal-access-tokens not mounted — its table doesn't exist, nothing to erase.
35
34
  if (!featureMounted(ctx, "personal-access-tokens")) return;
36
- await deleteMany(ctx.db, apiTokenTable, { userId: ctx.userId, tenantId: ctx.tenantId });
35
+ await ctx.db.deleteMany(apiTokenTable, { userId: ctx.userId, tenantId: ctx.tenantId });
37
36
  };