@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.
- package/package.json +6 -6
- package/src/config/index.ts +1 -1
- package/src/config/table.ts +1 -1
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +145 -8
- package/src/data-retention/feature.ts +3 -3
- package/src/data-retention/run-retention-cleanup.ts +102 -19
- package/src/delivery/index.ts +5 -1
- package/src/delivery/tables.ts +1 -1
- package/src/personal-access-tokens/schema/api-token.ts +6 -1
- package/src/sessions/schema/user-session.ts +2 -0
- package/src/tenant/invitation-table.ts +1 -1
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +392 -0
- package/src/user-data-rights-defaults/feature.ts +58 -1
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +37 -0
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +51 -0
- package/src/user-data-rights-defaults/hooks/feature-mounted.ts +10 -0
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +38 -0
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +56 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +107 -0
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +42 -0
- 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";
|