@cosmicdrift/kumiko-bundled-features 0.115.1 → 0.116.1

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 (31) hide show
  1. package/package.json +6 -6
  2. package/src/config/index.ts +1 -1
  3. package/src/config/table.ts +1 -1
  4. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +145 -8
  5. package/src/data-retention/feature.ts +3 -3
  6. package/src/data-retention/run-retention-cleanup.ts +102 -19
  7. package/src/delivery/index.ts +5 -1
  8. package/src/delivery/tables.ts +1 -1
  9. package/src/personal-access-tokens/schema/api-token.ts +6 -1
  10. package/src/sessions/schema/user-session.ts +2 -0
  11. package/src/tenant/invitation-table.ts +1 -1
  12. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -1
  13. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -1
  14. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -1
  15. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -1
  16. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -1
  17. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -1
  18. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -1
  19. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +2 -1
  20. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -1
  21. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -1
  22. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +392 -0
  23. package/src/user-data-rights-defaults/feature.ts +58 -1
  24. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +37 -0
  25. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +51 -0
  26. package/src/user-data-rights-defaults/hooks/feature-mounted.ts +10 -0
  27. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +38 -0
  28. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +56 -0
  29. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +107 -0
  30. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +42 -0
  31. package/src/user-data-rights-defaults/index.ts +21 -0
@@ -30,7 +30,7 @@ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
30
30
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
31
31
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
32
32
  import { createFilesFeature } from "../../files";
33
- import { createSessionsFeature } from "../../sessions";
33
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
34
34
  import {
35
35
  createUserFeature,
36
36
  USER_ANONYMIZED_DISPLAY_NAME,
@@ -74,6 +74,9 @@ beforeAll(async () => {
74
74
  });
75
75
 
76
76
  await unsafeCreateEntityTable(stack.db, userEntity);
77
+ // sessions ist gemountet → der user-session-Hook der defaults läuft im Forget
78
+ // und braucht die Tabelle (#797).
79
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
77
80
  await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
78
81
  // tenant-membership-Tabelle (von tenant-feature) manuell anlegen weil
79
82
  // wir ohne tenant-feature im stack arbeiten — minimaler Setup.
@@ -28,7 +28,7 @@ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
28
28
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
29
29
  import { createDataRetentionFeature } from "../../data-retention";
30
30
  import { createFilesFeature } from "../../files";
31
- import { createSessionsFeature } from "../../sessions";
31
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
32
32
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
33
33
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
34
34
  import { createUserDataRightsFeature } from "../feature";
@@ -63,6 +63,7 @@ beforeAll(async () => {
63
63
  });
64
64
 
65
65
  await unsafeCreateEntityTable(stack.db, userEntity);
66
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
66
67
  await asRawClient(stack.db).unsafe(`
67
68
  CREATE TABLE IF NOT EXISTS read_tenant_memberships (
68
69
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -0,0 +1,392 @@
1
+ // userData-Hook Integration-Tests für die Bundled-Entity-Hooks (GDPR-Audit
2
+ // 2026-07): user-session, api-token, in-app-message, tenant-invitation,
3
+ // notification-preference, config-value.
4
+ //
5
+ // Zwei Stacks:
6
+ // FULL — alle Source-Features gemountet; testet Export-Inhalt, Forget-
7
+ // Semantik (hard-delete vs. executor-forget vs. anonymize) und
8
+ // Tenant-Isolation.
9
+ // MINIMAL — nur user/files/udr/defaults; testet das Presence-Gating: jeder
10
+ // Hook muss null/no-op liefern statt gegen fehlende Tabellen zu
11
+ // crashen (der Export-Runner hat kein per-Hook try/catch).
12
+
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import {
16
+ setupTestStack,
17
+ type TestStack,
18
+ unsafeCreateEntityTable,
19
+ unsafePushTables,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
22
+ import { createChannelInAppFeature, inAppMessagesTable } from "../../channel-in-app";
23
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
24
+ import { configValueEntity, createConfigFeature } from "../../config";
25
+ import { createDataRetentionFeature } from "../../data-retention";
26
+ import { createDeliveryFeature, notificationPreferenceEntity } from "../../delivery";
27
+ import { createFilesFeature } from "../../files";
28
+ import {
29
+ apiTokenEntity,
30
+ createPersonalAccessTokensFeature,
31
+ PatQueries,
32
+ type PatScopeConfig,
33
+ } from "../../personal-access-tokens";
34
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
35
+ import { createTenantFeature, tenantInvitationEntity } from "../../tenant";
36
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
37
+ import { createUserDataRightsFeature } from "../../user-data-rights";
38
+ import { createUserDataRightsDefaultsFeature } from "../feature";
39
+ import {
40
+ apiTokenDeleteHook,
41
+ apiTokenExportHook,
42
+ configValueDeleteHook,
43
+ configValueExportHook,
44
+ inAppMessageDeleteHook,
45
+ inAppMessageExportHook,
46
+ notificationPreferenceDeleteHook,
47
+ notificationPreferenceExportHook,
48
+ tenantInvitationDeleteHook,
49
+ tenantInvitationExportHook,
50
+ userSessionDeleteHook,
51
+ userSessionExportHook,
52
+ } from "../index";
53
+
54
+ const PAT_SCOPES: PatScopeConfig = {
55
+ tokens: { label: "Tokens", read: [PatQueries.mine] },
56
+ };
57
+
58
+ let full: TestStack;
59
+ let minimal: TestStack;
60
+
61
+ beforeAll(async () => {
62
+ full = await setupTestStack({
63
+ features: [
64
+ createUserFeature(),
65
+ createFilesFeature(),
66
+ createDataRetentionFeature(),
67
+ createComplianceProfilesFeature(),
68
+ createSessionsFeature(),
69
+ createConfigFeature(),
70
+ createTenantFeature(),
71
+ createPersonalAccessTokensFeature({ scopes: PAT_SCOPES }),
72
+ createDeliveryFeature(),
73
+ createChannelInAppFeature(),
74
+ createUserDataRightsFeature(),
75
+ createUserDataRightsDefaultsFeature(),
76
+ ],
77
+ });
78
+ await unsafeCreateEntityTable(full.db, userEntity);
79
+ await unsafeCreateEntityTable(full.db, userSessionEntity);
80
+ await unsafeCreateEntityTable(full.db, apiTokenEntity);
81
+ await unsafeCreateEntityTable(full.db, tenantInvitationEntity);
82
+ await unsafeCreateEntityTable(full.db, notificationPreferenceEntity);
83
+ await unsafeCreateEntityTable(full.db, configValueEntity);
84
+ await unsafePushTables(full.db, { inAppMessagesTable });
85
+
86
+ minimal = await setupTestStack({
87
+ features: [
88
+ createUserFeature(),
89
+ createFilesFeature(),
90
+ createDataRetentionFeature(),
91
+ createComplianceProfilesFeature(),
92
+ createSessionsFeature(),
93
+ createUserDataRightsFeature(),
94
+ createUserDataRightsDefaultsFeature(),
95
+ ],
96
+ });
97
+ });
98
+
99
+ afterAll(async () => {
100
+ await full.cleanup();
101
+ await minimal.cleanup();
102
+ });
103
+
104
+ const TENANT_A = "00000000-0000-4000-8000-00000000000a";
105
+ const TENANT_B = "00000000-0000-4000-8000-00000000000b";
106
+
107
+ function uuid(suffix: number): string {
108
+ return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
109
+ }
110
+
111
+ function ctx(userId: string, overrides: Record<string, unknown> = {}) {
112
+ return {
113
+ db: full.db,
114
+ registry: full.registry,
115
+ tenantId: TENANT_A,
116
+ userId,
117
+ ...overrides,
118
+ };
119
+ }
120
+
121
+ async function rawSelect(sql: string, params: unknown[]) {
122
+ const result = await asRawClient(full.db).unsafe(sql, params);
123
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
124
+ return ((result as any).rows ?? result) as Array<Record<string, unknown>>;
125
+ }
126
+
127
+ async function seedUser(id: string): Promise<void> {
128
+ const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000001";
129
+ await seedRow(full.db, userTable, {
130
+ id,
131
+ tenantId: SYSTEM_TENANT,
132
+ email: `user-${id}@example.com`,
133
+ passwordHash: "hashed-password",
134
+ displayName: `User ${id}`,
135
+ locale: "de",
136
+ emailVerified: true,
137
+ roles: '["Member"]',
138
+ status: USER_STATUS.Active,
139
+ });
140
+ }
141
+
142
+ describe("user-session userData-hooks", () => {
143
+ async function seedSession(id: string, userId: string, tenantId: string): Promise<void> {
144
+ await asRawClient(full.db).unsafe(
145
+ `INSERT INTO read_user_sessions (id, user_id, tenant_id, created_at, expires_at, ip, user_agent)
146
+ VALUES ($1, $2, $3, now(), now() + interval '1 day', '203.0.113.7', 'TestAgent/1.0')`,
147
+ [id, userId, tenantId],
148
+ );
149
+ }
150
+
151
+ test("export liefert ip/userAgent, aber keine Session-Id (jti)", async () => {
152
+ await seedSession(uuid(1), "sess-user-1", TENANT_A);
153
+
154
+ const result = await userSessionExportHook(ctx("sess-user-1"));
155
+ expect(result?.entity).toBe("user-session");
156
+ expect(result?.rows).toHaveLength(1);
157
+ expect(result?.rows[0]?.["ip"]).toBe("203.0.113.7");
158
+ expect(result?.rows[0]?.["userAgent"]).toBe("TestAgent/1.0");
159
+ expect(result?.rows[0]?.["id"]).toBeUndefined();
160
+ });
161
+
162
+ test("delete entfernt Sessions des Users, Cross-Tenant bleibt", async () => {
163
+ await seedSession(uuid(2), "sess-user-2", TENANT_A);
164
+ await seedSession(uuid(3), "sess-user-2", TENANT_B);
165
+
166
+ await userSessionDeleteHook(ctx("sess-user-2"), "delete");
167
+
168
+ const a = await rawSelect(
169
+ "SELECT * FROM read_user_sessions WHERE user_id = $1 AND tenant_id = $2",
170
+ ["sess-user-2", TENANT_A],
171
+ );
172
+ const b = await rawSelect(
173
+ "SELECT * FROM read_user_sessions WHERE user_id = $1 AND tenant_id = $2",
174
+ ["sess-user-2", TENANT_B],
175
+ );
176
+ expect(a).toHaveLength(0);
177
+ expect(b).toHaveLength(1);
178
+ });
179
+ });
180
+
181
+ describe("api-token userData-hooks", () => {
182
+ async function seedToken(id: string, userId: string): Promise<void> {
183
+ await asRawClient(full.db).unsafe(
184
+ `INSERT INTO read_api_tokens (id, user_id, tenant_id, name, token_hash, prefix, scopes, created_at)
185
+ VALUES ($1, $2, $3, 'My MacBook', $4, 'kum_ab12', '["read"]', now())`,
186
+ [id, userId, TENANT_A, `hash-${id}`],
187
+ );
188
+ }
189
+
190
+ test("export liefert Token-Metadata ohne tokenHash", async () => {
191
+ await seedToken(uuid(10), "pat-user-1");
192
+
193
+ const result = await apiTokenExportHook(ctx("pat-user-1"));
194
+ expect(result?.entity).toBe("api-token");
195
+ expect(result?.rows).toHaveLength(1);
196
+ expect(result?.rows[0]?.["name"]).toBe("My MacBook");
197
+ expect(result?.rows[0]?.["prefix"]).toBe("kum_ab12");
198
+ expect(result?.rows[0]?.["tokenHash"]).toBeUndefined();
199
+ });
200
+
201
+ test("delete entfernt Tokens (= Revoke)", async () => {
202
+ await seedToken(uuid(11), "pat-user-2");
203
+
204
+ await apiTokenDeleteHook(ctx("pat-user-2"), "delete");
205
+
206
+ const rows = await rawSelect("SELECT * FROM read_api_tokens WHERE user_id = $1", [
207
+ "pat-user-2",
208
+ ]);
209
+ expect(rows).toHaveLength(0);
210
+ });
211
+ });
212
+
213
+ describe("in-app-message userData-hooks", () => {
214
+ async function seedMessage(userId: string, title: string): Promise<void> {
215
+ await asRawClient(full.db).unsafe(
216
+ `INSERT INTO in_app_messages (tenant_id, user_id, notification_type, title, body)
217
+ VALUES ($1, $2, 'test:notify', $3, 'Hallo Marc, dein Export ist da')`,
218
+ [TENANT_A, userId, title],
219
+ );
220
+ }
221
+
222
+ test("export liefert Messages, delete entfernt sie", async () => {
223
+ await seedMessage("inapp-user-1", "Export fertig");
224
+
225
+ const result = await inAppMessageExportHook(ctx("inapp-user-1"));
226
+ expect(result?.entity).toBe("in-app-message");
227
+ expect(result?.rows[0]?.["title"]).toBe("Export fertig");
228
+
229
+ await inAppMessageDeleteHook(ctx("inapp-user-1"), "delete");
230
+ const rows = await rawSelect("SELECT * FROM in_app_messages WHERE user_id = $1", [
231
+ "inapp-user-1",
232
+ ]);
233
+ expect(rows).toHaveLength(0);
234
+ });
235
+ });
236
+
237
+ describe("tenant-invitation userData-hooks", () => {
238
+ async function seedInvitation(
239
+ id: string,
240
+ email: string,
241
+ invitedBy: string,
242
+ tenantId = TENANT_A,
243
+ ): Promise<void> {
244
+ await asRawClient(full.db).unsafe(
245
+ `INSERT INTO read_tenant_invitations (id, tenant_id, email, role, status, invited_by, expires_at)
246
+ VALUES ($1, $2, $3, 'Member', 'pending', $4, now() + interval '7 days')`,
247
+ [id, tenantId, email, invitedBy],
248
+ );
249
+ }
250
+
251
+ test("export matcht Invitations über die User-Email", async () => {
252
+ await seedUser(uuid(20));
253
+ await seedInvitation(uuid(21), `user-${uuid(20)}@example.com`, "admin-1");
254
+
255
+ const result = await tenantInvitationExportHook(ctx(uuid(20)));
256
+ expect(result?.entity).toBe("tenant-invitation");
257
+ expect(result?.rows).toHaveLength(1);
258
+ expect(result?.rows[0]?.["role"]).toBe("Member");
259
+ });
260
+
261
+ test("forget delete purged die Invitee-Row (rebuild-sicher via Executor)", async () => {
262
+ await seedUser(uuid(22));
263
+ await seedInvitation(uuid(23), `user-${uuid(22)}@example.com`, "admin-1");
264
+
265
+ await tenantInvitationDeleteHook(ctx(uuid(22)), "delete");
266
+
267
+ const rows = await rawSelect("SELECT * FROM read_tenant_invitations WHERE id = $1", [uuid(23)]);
268
+ expect(rows).toHaveLength(0);
269
+ });
270
+
271
+ test("anonymize pseudonymisiert die Invitee-Email (unique-index-sicher)", async () => {
272
+ await seedUser(uuid(24));
273
+ await seedInvitation(uuid(25), `user-${uuid(24)}@example.com`, "admin-1");
274
+
275
+ await tenantInvitationDeleteHook(ctx(uuid(24)), "anonymize");
276
+
277
+ const rows = await rawSelect("SELECT email FROM read_tenant_invitations WHERE id = $1", [
278
+ uuid(25),
279
+ ]);
280
+ expect(rows).toHaveLength(1);
281
+ expect(String(rows[0]?.["email"])).toBe(`forgotten-${uuid(25)}@anonymized.invalid`);
282
+ });
283
+
284
+ test("userEmailBeforeDelete hat Vorrang (User-Row schon anonymisiert)", async () => {
285
+ await seedInvitation(uuid(26), "vanished@example.com", "admin-1");
286
+
287
+ await tenantInvitationDeleteHook(
288
+ ctx("ghost-user-forgotten", { userEmailBeforeDelete: "Vanished@Example.com" }),
289
+ "delete",
290
+ );
291
+
292
+ const rows = await rawSelect("SELECT * FROM read_tenant_invitations WHERE id = $1", [uuid(26)]);
293
+ expect(rows).toHaveLength(0);
294
+ });
295
+
296
+ test("Inviter-Forget severs invitedBy, Row bleibt (gehört dem Invitee)", async () => {
297
+ // userId als echte UUID — der Email-Lookup geht gegen read_users.id (uuid).
298
+ const inviter = uuid(28);
299
+ await seedInvitation(uuid(27), "someone-else@example.com", inviter);
300
+
301
+ await tenantInvitationDeleteHook(ctx(inviter), "delete");
302
+
303
+ const rows = await rawSelect(
304
+ "SELECT email, invited_by FROM read_tenant_invitations WHERE id = $1",
305
+ [uuid(27)],
306
+ );
307
+ expect(rows).toHaveLength(1);
308
+ expect(rows[0]?.["email"]).toBe("someone-else@example.com");
309
+ expect(rows[0]?.["invited_by"]).toBe("anonymized");
310
+ });
311
+ });
312
+
313
+ describe("notification-preference userData-hooks", () => {
314
+ async function seedPreference(id: string, userId: string): Promise<void> {
315
+ await asRawClient(full.db).unsafe(
316
+ `INSERT INTO read_notification_preferences (id, tenant_id, user_id, notification_type, channel, enabled)
317
+ VALUES ($1, $2, $3, 'test:notify', 'email', false)`,
318
+ [id, TENANT_A, userId],
319
+ );
320
+ }
321
+
322
+ test("export liefert Prefs, forget purged sie via Executor", async () => {
323
+ await seedPreference(uuid(30), "pref-user-1");
324
+
325
+ const result = await notificationPreferenceExportHook(ctx("pref-user-1"));
326
+ expect(result?.entity).toBe("notification-preference");
327
+ expect(result?.rows[0]?.["channel"]).toBe("email");
328
+ expect(result?.rows[0]?.["enabled"]).toBe(false);
329
+
330
+ await notificationPreferenceDeleteHook(ctx("pref-user-1"), "delete");
331
+ const rows = await rawSelect("SELECT * FROM read_notification_preferences WHERE user_id = $1", [
332
+ "pref-user-1",
333
+ ]);
334
+ expect(rows).toHaveLength(0);
335
+ });
336
+ });
337
+
338
+ describe("config-value userData-hooks", () => {
339
+ async function seedConfig(id: string, key: string, userId: string | null): Promise<void> {
340
+ await asRawClient(full.db).unsafe(
341
+ `INSERT INTO read_config_values (id, tenant_id, key, value, user_id)
342
+ VALUES ($1, $2, $3, '"dark"', $4)`,
343
+ [id, TENANT_A, key, userId],
344
+ );
345
+ }
346
+
347
+ test("export + forget treffen NUR user-scoped Rows, tenant-scope bleibt", async () => {
348
+ await seedConfig(uuid(40), "ui.theme", "cfg-user-1");
349
+ await seedConfig(uuid(41), "tenant.brand", null);
350
+
351
+ const result = await configValueExportHook(ctx("cfg-user-1"));
352
+ expect(result?.rows).toHaveLength(1);
353
+ expect(result?.rows[0]?.["key"]).toBe("ui.theme");
354
+
355
+ await configValueDeleteHook(ctx("cfg-user-1"), "delete");
356
+ const userRows = await rawSelect("SELECT * FROM read_config_values WHERE user_id = $1", [
357
+ "cfg-user-1",
358
+ ]);
359
+ const tenantRows = await rawSelect("SELECT * FROM read_config_values WHERE id = $1", [
360
+ uuid(41),
361
+ ]);
362
+ expect(userRows).toHaveLength(0);
363
+ expect(tenantRows).toHaveLength(1);
364
+ });
365
+ });
366
+
367
+ describe("Presence-Gating: Source-Feature nicht gemountet → null/no-op statt Crash", () => {
368
+ test("alle gegateten Hooks no-open auf dem Minimal-Stack", async () => {
369
+ const minCtx = {
370
+ db: minimal.db,
371
+ registry: minimal.registry,
372
+ tenantId: TENANT_A,
373
+ userId: "any-user",
374
+ };
375
+
376
+ // sessions ist im Minimal-Stack GEMOUNTET aber die Tabelle existiert
377
+ // nicht — der Gate-Check greift auf Feature-Ebene, deshalb hier nur die
378
+ // fünf wirklich ungemounteten Features. (user-session wird im FULL-Stack
379
+ // getestet.)
380
+ expect(await apiTokenExportHook(minCtx)).toBeNull();
381
+ expect(await inAppMessageExportHook(minCtx)).toBeNull();
382
+ expect(await tenantInvitationExportHook(minCtx)).toBeNull();
383
+ expect(await notificationPreferenceExportHook(minCtx)).toBeNull();
384
+ expect(await configValueExportHook(minCtx)).toBeNull();
385
+
386
+ await expect(apiTokenDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
387
+ await expect(inAppMessageDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
388
+ await expect(tenantInvitationDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
389
+ await expect(notificationPreferenceDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
390
+ await expect(configValueDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
391
+ });
392
+ });
@@ -3,8 +3,23 @@ import {
3
3
  EXT_USER_DATA,
4
4
  type FeatureDefinition,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { apiTokenDeleteHook, apiTokenExportHook } from "./hooks/api-token.userdata-hook";
7
+ import { configValueDeleteHook, configValueExportHook } from "./hooks/config-value.userdata-hook";
6
8
  import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
9
+ import {
10
+ inAppMessageDeleteHook,
11
+ inAppMessageExportHook,
12
+ } from "./hooks/in-app-message.userdata-hook";
13
+ import {
14
+ notificationPreferenceDeleteHook,
15
+ notificationPreferenceExportHook,
16
+ } from "./hooks/notification-preference.userdata-hook";
17
+ import {
18
+ tenantInvitationDeleteHook,
19
+ tenantInvitationExportHook,
20
+ } from "./hooks/tenant-invitation.userdata-hook";
7
21
  import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
22
+ import { userSessionDeleteHook, userSessionExportHook } from "./hooks/user-session.userdata-hook";
8
23
 
9
24
  // user-data-rights-defaults — Default-Hooks für die Core-Entities
10
25
  // `user` (S2.H1) und `fileRef` (S2.H2).
@@ -31,7 +46,7 @@ import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
31
46
  export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
32
47
  return defineFeature("user-data-rights-defaults", (r) => {
33
48
  r.describe(
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.",
49
+ "Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `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`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
35
50
  );
36
51
  r.uiHints({
37
52
  displayLabel: "User Data Rights · Default Hooks",
@@ -39,6 +54,18 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
39
54
  recommended: false,
40
55
  });
41
56
  r.requires("user", "files", "user-data-rights");
57
+ // Optional sources: hooks are registered unconditionally but each one
58
+ // no-ops at runtime when its source feature isn't mounted (see
59
+ // hooks/feature-mounted.ts) — the export runner has no per-hook
60
+ // try/catch, so a query against a missing table would kill the job.
61
+ r.optionalRequires(
62
+ "sessions",
63
+ "personal-access-tokens",
64
+ "channel-in-app",
65
+ "tenant",
66
+ "delivery",
67
+ "config",
68
+ );
42
69
 
43
70
  r.useExtension(EXT_USER_DATA, "user", {
44
71
  export: userExportHook,
@@ -49,5 +76,35 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
49
76
  export: fileRefExportHook,
50
77
  delete: fileRefDeleteHook,
51
78
  });
79
+
80
+ r.useExtension(EXT_USER_DATA, "user-session", {
81
+ export: userSessionExportHook,
82
+ delete: userSessionDeleteHook,
83
+ });
84
+
85
+ r.useExtension(EXT_USER_DATA, "api-token", {
86
+ export: apiTokenExportHook,
87
+ delete: apiTokenDeleteHook,
88
+ });
89
+
90
+ r.useExtension(EXT_USER_DATA, "in-app-message", {
91
+ export: inAppMessageExportHook,
92
+ delete: inAppMessageDeleteHook,
93
+ });
94
+
95
+ r.useExtension(EXT_USER_DATA, "tenant-invitation", {
96
+ export: tenantInvitationExportHook,
97
+ delete: tenantInvitationDeleteHook,
98
+ });
99
+
100
+ r.useExtension(EXT_USER_DATA, "notification-preference", {
101
+ export: notificationPreferenceExportHook,
102
+ delete: notificationPreferenceDeleteHook,
103
+ });
104
+
105
+ r.useExtension(EXT_USER_DATA, "config-value", {
106
+ export: configValueExportHook,
107
+ delete: configValueDeleteHook,
108
+ });
52
109
  });
53
110
  }
@@ -0,0 +1,37 @@
1
+ import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { apiTokenTable } from "../../personal-access-tokens";
4
+ import { featureMounted } from "./feature-mounted";
5
+
6
+ // userData-Hooks for personal-access-tokens rows. Same shape as user-session:
7
+ // unmanaged direct-write store (no event stream, rebuild-safe DELETE), and a
8
+ // token without its user is worthless — both strategies hard-delete. Deleting
9
+ // the row also revokes the token (the resolver point-reads this table), which
10
+ // is the correct outcome for a forgotten user.
11
+
12
+ export const apiTokenExportHook: UserDataExportHook = async (ctx) => {
13
+ if (!featureMounted(ctx, "personal-access-tokens")) return null;
14
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, apiTokenTable, {
15
+ userId: ctx.userId,
16
+ tenantId: ctx.tenantId,
17
+ });
18
+ if (rows.length === 0) return null;
19
+ return {
20
+ entity: "api-token",
21
+ // tokenHash stays out of the bundle — secret-derived, not personal data.
22
+ rows: rows.map((r) => ({
23
+ name: r["name"] ?? null,
24
+ prefix: r["prefix"] ?? null,
25
+ scopes: r["scopes"] ?? null,
26
+ createdAt: String(r["createdAt"] ?? ""),
27
+ expiresAt: r["expiresAt"] ? String(r["expiresAt"]) : null,
28
+ revokedAt: r["revokedAt"] ? String(r["revokedAt"]) : null,
29
+ })),
30
+ };
31
+ };
32
+
33
+ export const apiTokenDeleteHook: UserDataDeleteHook = async (ctx) => {
34
+ // skip: personal-access-tokens not mounted — its table doesn't exist, nothing to erase.
35
+ if (!featureMounted(ctx, "personal-access-tokens")) return;
36
+ await deleteMany(ctx.db, apiTokenTable, { userId: ctx.userId, tenantId: ctx.tenantId });
37
+ };
@@ -0,0 +1,51 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ createSystemUser,
5
+ type UserDataDeleteHook,
6
+ type UserDataExportHook,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { configValueEntity, configValuesTable } from "../../config";
9
+ import { featureMounted } from "./feature-mounted";
10
+
11
+ // userData-Hooks for config's USER-scoped rows (userId set). Tenant-/system-
12
+ // scope rows carry no per-user subject and stay untouched. Event-sourced
13
+ // entity → forget goes through the executor (rebuild replays the erasure).
14
+ // A user-scoped setting without its user is meaningless — both strategies
15
+ // purge via the forget verb, which also erases encrypted value blobs.
16
+
17
+ const crud = createEventStoreExecutor(configValuesTable, configValueEntity, {
18
+ entityName: "config-value",
19
+ });
20
+
21
+ export const configValueExportHook: UserDataExportHook = async (ctx) => {
22
+ if (!featureMounted(ctx, "config")) return null;
23
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, configValuesTable, {
24
+ tenantId: ctx.tenantId,
25
+ userId: ctx.userId,
26
+ });
27
+ if (rows.length === 0) return null;
28
+ return {
29
+ entity: "config-value",
30
+ // value may be an encrypted blob — exported as stored; decryption is the
31
+ // config resolver's concern and secret-backed values don't belong in the
32
+ // bundle in plaintext.
33
+ rows: rows.map((r) => ({ key: r["key"], value: r["value"] })),
34
+ };
35
+ };
36
+
37
+ export const configValueDeleteHook: UserDataDeleteHook = async (ctx) => {
38
+ // skip: config not mounted — its table doesn't exist, nothing to erase.
39
+ if (!featureMounted(ctx, "config")) return;
40
+ const systemUser = createSystemUser(ctx.tenantId);
41
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
42
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, configValuesTable, {
43
+ tenantId: ctx.tenantId,
44
+ userId: ctx.userId,
45
+ });
46
+ for (const row of rows) {
47
+ const id = row["id"]; // @cast-boundary db-row
48
+ if (typeof id !== "string") continue;
49
+ await crud.forget({ id }, systemUser, tdb);
50
+ }
51
+ };
@@ -0,0 +1,10 @@
1
+ import type { UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // The defaults feature registers hooks for OPTIONAL source features
4
+ // (optionalRequires). When the source isn't mounted its tables don't exist,
5
+ // and the export runner has no try/catch around hooks — a query against a
6
+ // missing table would kill the whole export job. Every gated hook must
7
+ // early-return on this check before touching the source's tables.
8
+ export function featureMounted(ctx: UserDataHookCtx, featureName: string): boolean {
9
+ return ctx.registry.getFeature(featureName) !== undefined;
10
+ }
@@ -0,0 +1,38 @@
1
+ import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { inAppMessagesTable } from "../../channel-in-app";
4
+ import { featureMounted } from "./feature-mounted";
5
+
6
+ // userData-Hooks for channel-in-app's in_app_messages. Plain SQL table (no
7
+ // r.entity, no event stream — see channel-in-app/tables.ts), written by
8
+ // direct INSERT in the channel adapter, so a direct DELETE is rebuild-safe.
9
+ // Messages are addressed TO the user (title/body can quote personal content);
10
+ // both strategies hard-delete.
11
+ //
12
+ // This table has no entity fields, so the V3 pii-annotation boot guard cannot
13
+ // see it — coverage lives solely in these hooks.
14
+
15
+ export const inAppMessageExportHook: UserDataExportHook = async (ctx) => {
16
+ if (!featureMounted(ctx, "channel-in-app")) return null;
17
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, inAppMessagesTable, {
18
+ userId: ctx.userId,
19
+ tenantId: ctx.tenantId,
20
+ });
21
+ if (rows.length === 0) return null;
22
+ return {
23
+ entity: "in-app-message",
24
+ rows: rows.map((r) => ({
25
+ notificationType: r["notificationType"] ?? null,
26
+ title: r["title"] ?? null,
27
+ body: r["body"] ?? null,
28
+ isRead: r["isRead"] ?? false,
29
+ createdAt: String(r["createdAt"] ?? ""),
30
+ })),
31
+ };
32
+ };
33
+
34
+ export const inAppMessageDeleteHook: UserDataDeleteHook = async (ctx) => {
35
+ // skip: channel-in-app not mounted — its table doesn't exist, nothing to erase.
36
+ if (!featureMounted(ctx, "channel-in-app")) return;
37
+ await deleteMany(ctx.db, inAppMessagesTable, { userId: ctx.userId, tenantId: ctx.tenantId });
38
+ };