@cosmicdrift/kumiko-bundled-features 0.115.0 → 0.116.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 (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
@@ -0,0 +1,56 @@
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 { notificationPreferenceEntity, notificationPreferencesTable } from "../../delivery";
9
+ import { featureMounted } from "./feature-mounted";
10
+
11
+ // userData-Hooks for delivery's notification-preference rows. Event-sourced
12
+ // entity → forget goes through the executor so a rebuild replays the erasure.
13
+ // A preference without its user is meaningless, so both strategies purge via
14
+ // the forget verb.
15
+ //
16
+ // The delivery ATTEMPTS log (read_delivery_attempts, recipientAddress) is NOT
17
+ // covered here: it is an events-only aggregate whose payload lives in the
18
+ // append-only event store — per-user redaction there needs the event-store
19
+ // redaction epic; a read-side UPDATE would be wiped on rebuild.
20
+
21
+ const crud = createEventStoreExecutor(notificationPreferencesTable, notificationPreferenceEntity, {
22
+ entityName: "notification-preference",
23
+ });
24
+
25
+ export const notificationPreferenceExportHook: UserDataExportHook = async (ctx) => {
26
+ if (!featureMounted(ctx, "delivery")) return null;
27
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, notificationPreferencesTable, {
28
+ tenantId: ctx.tenantId,
29
+ userId: ctx.userId,
30
+ });
31
+ if (rows.length === 0) return null;
32
+ return {
33
+ entity: "notification-preference",
34
+ rows: rows.map((r) => ({
35
+ notificationType: r["notificationType"],
36
+ channel: r["channel"],
37
+ enabled: r["enabled"],
38
+ })),
39
+ };
40
+ };
41
+
42
+ export const notificationPreferenceDeleteHook: UserDataDeleteHook = async (ctx) => {
43
+ // skip: delivery not mounted — its table doesn't exist, nothing to erase.
44
+ if (!featureMounted(ctx, "delivery")) return;
45
+ const systemUser = createSystemUser(ctx.tenantId);
46
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
47
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, notificationPreferencesTable, {
48
+ tenantId: ctx.tenantId,
49
+ userId: ctx.userId,
50
+ });
51
+ for (const row of rows) {
52
+ const id = row["id"]; // @cast-boundary db-row
53
+ if (typeof id !== "string") continue;
54
+ await crud.forget({ id }, systemUser, tdb);
55
+ }
56
+ };
@@ -0,0 +1,107 @@
1
+ import { fetchOne, 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
+ type UserDataHookCtx,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant";
10
+ import { userTable } from "../../user";
11
+ import { featureMounted } from "./feature-mounted";
12
+
13
+ // userData-Hooks for tenant-invitation rows. Event-sourced entity → all
14
+ // forget writes go through the executor so a projection rebuild replays the
15
+ // erasure (a direct write would be wiped — the Art.17 hole the user/fileRef
16
+ // hooks already close).
17
+ //
18
+ // Two subject paths in one hook:
19
+ // invitee — rows whose `email` is the user's email. delete → executor
20
+ // forget (row purged); anonymize → email pseudonymized.
21
+ // inviter — rows whose `invitedBy` is the user. The row belongs to the
22
+ // invitee, so both strategies only sever the person-link
23
+ // (invitedBy → pseudonym; the field is required, null not
24
+ // allowed).
25
+ //
26
+ // Email source: on forget the user hook may already have anonymized the
27
+ // user row, so ctx.userEmailBeforeDelete (captured by runForgetCleanup
28
+ // before the transaction) takes precedence over a live lookup.
29
+
30
+ const crud = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
31
+ entityName: "tenant-invitation",
32
+ });
33
+
34
+ const INVITED_BY_ANONYMIZED = "anonymized";
35
+
36
+ async function resolveUserEmail(ctx: UserDataHookCtx): Promise<string | null> {
37
+ if (ctx.userEmailBeforeDelete) return ctx.userEmailBeforeDelete.toLowerCase();
38
+ const row = (await fetchOne(ctx.db, userTable, { id: ctx.userId })) as {
39
+ email: string;
40
+ } | null; // @cast-boundary db-runner
41
+ return row ? row.email.toLowerCase() : null;
42
+ }
43
+
44
+ export const tenantInvitationExportHook: UserDataExportHook = async (ctx) => {
45
+ if (!featureMounted(ctx, "tenant")) return null;
46
+ const email = await resolveUserEmail(ctx);
47
+ if (!email) return null;
48
+ // Invitation emails are lowercase-normalized on insert, so an exact match
49
+ // is a case-insensitive match.
50
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, tenantInvitationsTable, {
51
+ tenantId: ctx.tenantId,
52
+ email,
53
+ });
54
+ if (rows.length === 0) return null;
55
+ return {
56
+ entity: "tenant-invitation",
57
+ rows: rows.map((r) => ({
58
+ email: r["email"],
59
+ role: r["role"],
60
+ status: r["status"],
61
+ expiresAt: String(r["expiresAt"] ?? ""),
62
+ })),
63
+ };
64
+ };
65
+
66
+ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
67
+ // skip: tenant not mounted — its table doesn't exist, nothing to erase.
68
+ if (!featureMounted(ctx, "tenant")) return;
69
+ const systemUser = createSystemUser(ctx.tenantId);
70
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
71
+
72
+ const email = await resolveUserEmail(ctx);
73
+ if (email) {
74
+ const inviteeRows = await selectMany<Record<string, unknown>>(ctx.db, tenantInvitationsTable, {
75
+ tenantId: ctx.tenantId,
76
+ email,
77
+ });
78
+ for (const row of inviteeRows) {
79
+ const id = row["id"]; // @cast-boundary db-row
80
+ if (typeof id !== "string") continue;
81
+ if (strategy === "delete") {
82
+ await crud.forget({ id }, systemUser, tdb);
83
+ } else {
84
+ // Row-id in the pseudonym keeps the (tenantId, email) unique index
85
+ // collision-free when a user has invitations in several states.
86
+ await crud.update(
87
+ { id, changes: { email: `forgotten-${id}@anonymized.invalid` } },
88
+ systemUser,
89
+ tdb,
90
+ { skipOptimisticLock: true },
91
+ );
92
+ }
93
+ }
94
+ }
95
+
96
+ const inviterRows = await selectMany<Record<string, unknown>>(ctx.db, tenantInvitationsTable, {
97
+ tenantId: ctx.tenantId,
98
+ invitedBy: ctx.userId,
99
+ });
100
+ for (const row of inviterRows) {
101
+ const id = row["id"]; // @cast-boundary db-row
102
+ if (typeof id !== "string") continue;
103
+ await crud.update({ id, changes: { invitedBy: INVITED_BY_ANONYMIZED } }, systemUser, tdb, {
104
+ skipOptimisticLock: true,
105
+ });
106
+ }
107
+ };
@@ -0,0 +1,42 @@
1
+ import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { userSessionTable } from "../../sessions";
4
+ import { featureMounted } from "./feature-mounted";
5
+
6
+ // userData-Hooks for the sessions feature's user-session rows (ip, userAgent).
7
+ //
8
+ // user-session is an unmanaged direct-write store (no event stream, excluded
9
+ // from projection rebuilds — see sessions/feature.ts #494/#498), so a direct
10
+ // DELETE is legal here and survives rebuilds; the executor path the ES
11
+ // entities use does not apply.
12
+ //
13
+ // Both forget strategies hard-delete: a session row without its user is
14
+ // worthless (revocation audit trail loses its subject), and ip/userAgent are
15
+ // pure PII with no shared-data value to preserve via anonymization.
16
+
17
+ export const userSessionExportHook: UserDataExportHook = async (ctx) => {
18
+ if (!featureMounted(ctx, "sessions")) return null;
19
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, userSessionTable, {
20
+ userId: ctx.userId,
21
+ tenantId: ctx.tenantId,
22
+ });
23
+ if (rows.length === 0) return null;
24
+ return {
25
+ entity: "user-session",
26
+ // sid (row id) stays out of the bundle — it is the JWT jti and
27
+ // token-shaped, not portable personal data.
28
+ rows: rows.map((r) => ({
29
+ createdAt: String(r["createdAt"] ?? ""),
30
+ expiresAt: String(r["expiresAt"] ?? ""),
31
+ revokedAt: r["revokedAt"] ? String(r["revokedAt"]) : null,
32
+ ip: r["ip"] ?? null,
33
+ userAgent: r["userAgent"] ?? null,
34
+ })),
35
+ };
36
+ };
37
+
38
+ export const userSessionDeleteHook: UserDataDeleteHook = async (ctx) => {
39
+ // skip: sessions not mounted — its table doesn't exist, nothing to erase.
40
+ if (!featureMounted(ctx, "sessions")) return;
41
+ await deleteMany(ctx.db, userSessionTable, { userId: ctx.userId, tenantId: ctx.tenantId });
42
+ };
@@ -1,6 +1,27 @@
1
1
  export { createUserDataRightsDefaultsFeature } from "./feature";
2
+ export { apiTokenDeleteHook, apiTokenExportHook } from "./hooks/api-token.userdata-hook";
3
+ export {
4
+ configValueDeleteHook,
5
+ configValueExportHook,
6
+ } from "./hooks/config-value.userdata-hook";
2
7
  export {
3
8
  fileRefDeleteHook,
4
9
  fileRefExportHook,
5
10
  } from "./hooks/file-ref.userdata-hook";
11
+ export {
12
+ inAppMessageDeleteHook,
13
+ inAppMessageExportHook,
14
+ } from "./hooks/in-app-message.userdata-hook";
15
+ export {
16
+ notificationPreferenceDeleteHook,
17
+ notificationPreferenceExportHook,
18
+ } from "./hooks/notification-preference.userdata-hook";
19
+ export {
20
+ tenantInvitationDeleteHook,
21
+ tenantInvitationExportHook,
22
+ } from "./hooks/tenant-invitation.userdata-hook";
6
23
  export { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
24
+ export {
25
+ userSessionDeleteHook,
26
+ userSessionExportHook,
27
+ } from "./hooks/user-session.userdata-hook";