@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.
- package/package.json +9 -9
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
- package/src/auth-mfa-user-data/hooks.ts +4 -6
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
- package/src/custom-fields/wire-user-data-rights.ts +40 -24
- package/src/data-retention/resolve-for-tenant.ts +18 -6
- package/src/delivery/db/queries/preferences.ts +1 -0
- package/src/files-tenant-data/hooks.ts +3 -5
- package/src/folders/__tests__/folders.integration.test.ts +4 -3
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
- package/src/folders-user-data/hooks.ts +5 -10
- package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
- package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
- package/src/form-draft-user-data/hooks.ts +3 -6
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
- package/src/notes-history-user-data/hooks.ts +3 -4
- package/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/src/template-resolver-user-data/hooks.ts +1 -2
- package/src/tenant-lifecycle/feature.ts +2 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
- package/src/tenant-lifecycle/stages.ts +26 -2
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
- package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
- package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
- package/src/user-data-rights/feature.ts +4 -0
- package/src/user-data-rights/run-export-jobs.ts +16 -1
- package/src/user-data-rights/run-forget-cleanup.ts +44 -2
- package/src/user-data-rights/run-user-export.ts +43 -3
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.280.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -129,12 +129,12 @@
|
|
|
129
129
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
130
|
},
|
|
131
131
|
"dependencies": {
|
|
132
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
132
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.280.0",
|
|
133
|
+
"@cosmicdrift/kumiko-framework": "0.280.0",
|
|
134
|
+
"@cosmicdrift/kumiko-headless": "0.280.0",
|
|
135
|
+
"@cosmicdrift/kumiko-renderer": "0.280.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer-web": "0.280.0",
|
|
137
|
+
"@cosmicdrift/kumiko-types": "0.280.0",
|
|
138
138
|
"@mollie/api-client": "^4.5.0",
|
|
139
139
|
"@node-rs/argon2": "^2.0.2",
|
|
140
140
|
"@types/mailparser": "^3.4.6",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
],
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
166
|
+
"@cosmicdrift/kumiko-locale-de": "0.280.0",
|
|
167
|
+
"@cosmicdrift/kumiko-locale-es": "0.280.0"
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
InMemoryKmsAdapter,
|
|
15
15
|
isPiiCiphertext,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
17
18
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
19
|
import {
|
|
19
20
|
setupTestStack,
|
|
@@ -319,7 +320,7 @@ describe("auth flows with active KMS + blind index", () => {
|
|
|
319
320
|
expect(isPiiCiphertext(rawInvitation["email"])).toBe(true);
|
|
320
321
|
|
|
321
322
|
const result = await tenantInvitationExportHook({
|
|
322
|
-
db: stack.db,
|
|
323
|
+
db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
|
|
323
324
|
registry: stack.registry,
|
|
324
325
|
tenantId: TENANT_A_ID,
|
|
325
326
|
userId: bobId,
|
|
@@ -347,7 +348,12 @@ describe("auth flows with active KMS + blind index", () => {
|
|
|
347
348
|
expect(isPiiCiphertext(rawBefore["invited_by"])).toBe(true);
|
|
348
349
|
|
|
349
350
|
await tenantInvitationDeleteHook(
|
|
350
|
-
{
|
|
351
|
+
{
|
|
352
|
+
db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
|
|
353
|
+
registry: stack.registry,
|
|
354
|
+
tenantId: TENANT_A_ID,
|
|
355
|
+
userId: aliceId,
|
|
356
|
+
},
|
|
351
357
|
"delete",
|
|
352
358
|
);
|
|
353
359
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (not just leave it dangling for a future projection rebuild to resurrect).
|
|
4
4
|
|
|
5
5
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
-
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import { configureEntityFieldEncryption, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
7
7
|
import {
|
|
8
8
|
createTestUser,
|
|
9
9
|
setupTestStack,
|
|
@@ -77,7 +77,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
77
77
|
const user = await enrollUser("export-1");
|
|
78
78
|
|
|
79
79
|
const snippet = await userMfaExportHook({
|
|
80
|
-
db: stack.db,
|
|
80
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
81
81
|
registry: stack.registry,
|
|
82
82
|
tenantId: user.tenantId,
|
|
83
83
|
userId: user.id,
|
|
@@ -94,7 +94,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
94
94
|
test("export returns null for a user who never enrolled", async () => {
|
|
95
95
|
const user = createTestUser({ id: "never-enrolled", roles: ["User"] });
|
|
96
96
|
const snippet = await userMfaExportHook({
|
|
97
|
-
db: stack.db,
|
|
97
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
98
98
|
registry: stack.registry,
|
|
99
99
|
tenantId: user.tenantId,
|
|
100
100
|
userId: user.id,
|
|
@@ -106,7 +106,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
106
106
|
const user = await enrollUser("delete-1");
|
|
107
107
|
|
|
108
108
|
const before = await userMfaExportHook({
|
|
109
|
-
db: stack.db,
|
|
109
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
110
110
|
registry: stack.registry,
|
|
111
111
|
tenantId: user.tenantId,
|
|
112
112
|
userId: user.id,
|
|
@@ -115,7 +115,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
115
115
|
|
|
116
116
|
await userMfaDeleteHook(
|
|
117
117
|
{
|
|
118
|
-
db: stack.db,
|
|
118
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
119
119
|
registry: stack.registry,
|
|
120
120
|
tenantId: user.tenantId,
|
|
121
121
|
userId: user.id,
|
|
@@ -124,7 +124,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
124
124
|
);
|
|
125
125
|
|
|
126
126
|
const after = await userMfaExportHook({
|
|
127
|
-
db: stack.db,
|
|
127
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
128
128
|
registry: stack.registry,
|
|
129
129
|
tenantId: user.tenantId,
|
|
130
130
|
userId: user.id,
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// replays via the event, a raw DELETE would be resurrected by a
|
|
8
8
|
// projection rebuild).
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
12
11
|
import {
|
|
13
12
|
createSystemUser,
|
|
14
13
|
type UserDataDeleteHook,
|
|
@@ -26,7 +25,7 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
|
26
25
|
// tokenHash exclusion in user-data-rights-defaults). Just confirms
|
|
27
26
|
// enrollment + when, which is what a data subject actually needs to see.
|
|
28
27
|
export const userMfaExportHook: UserDataExportHook = async (ctx) => {
|
|
29
|
-
const rows = await selectMany<{ id: string; enabledAt: unknown }>(
|
|
28
|
+
const rows = await ctx.db.selectMany<{ id: string; enabledAt: unknown }>(userMfaTable, {
|
|
30
29
|
userId: ctx.userId,
|
|
31
30
|
tenantId: ctx.tenantId,
|
|
32
31
|
});
|
|
@@ -41,15 +40,14 @@ export const userMfaExportHook: UserDataExportHook = async (ctx) => {
|
|
|
41
40
|
// secret can't be anonymized, and `personal: { of: "userId" }` already
|
|
42
41
|
// means this row belongs to exactly one user (never shared tenant data).
|
|
43
42
|
export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
44
|
-
const rows = await selectMany<{ id: string }>(
|
|
43
|
+
const rows = await ctx.db.selectMany<{ id: string }>(userMfaTable, {
|
|
45
44
|
userId: ctx.userId,
|
|
46
45
|
tenantId: ctx.tenantId,
|
|
47
46
|
});
|
|
48
47
|
// skip: nothing enrolled for this user — no row to erase
|
|
49
48
|
if (rows.length === 0) return;
|
|
50
49
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
51
|
-
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
52
50
|
for (const row of rows) {
|
|
53
|
-
assertErased(await executor.forget({ id: row.id }, systemUser,
|
|
51
|
+
assertErased(await executor.forget({ id: row.id }, systemUser, ctx.db), "user-mfa", row.id);
|
|
54
52
|
}
|
|
55
53
|
};
|
|
@@ -71,8 +71,14 @@ const propertyTable = buildEntityTable("property", propertyEntity);
|
|
|
71
71
|
// Host entity gets its own EXT_USER_DATA-registration too — that's the
|
|
72
72
|
// canonical setup. The host's anonymize hook NULLS inserted_by_id; the
|
|
73
73
|
// custom-fields wiring contributes export only (#972).
|
|
74
|
+
// fw#2914 — "property" is a managed r.entity table (EXECUTOR_ONLY-branded), so
|
|
75
|
+
// TenantDb's typed write methods reject it; these host hooks need raw SQL,
|
|
76
|
+
// still filtered by inserted_by_id + tenant_id.
|
|
77
|
+
const HOST_HOOK_REASON =
|
|
78
|
+
"fw#2914 test fixture: host export/delete against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by inserted_by_id + tenant_id";
|
|
79
|
+
|
|
74
80
|
const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
75
|
-
const rows = await asRawClient(ctx.db).unsafe(
|
|
81
|
+
const rows = await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
|
|
76
82
|
`SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
77
83
|
[ctx.userId, ctx.tenantId],
|
|
78
84
|
);
|
|
@@ -86,13 +92,13 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
|
86
92
|
|
|
87
93
|
const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
88
94
|
if (strategy === "delete") {
|
|
89
|
-
await asRawClient(ctx.db).unsafe(
|
|
95
|
+
await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
|
|
90
96
|
`DELETE FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
91
97
|
[ctx.userId, ctx.tenantId],
|
|
92
98
|
);
|
|
93
99
|
} else {
|
|
94
100
|
// anonymize: clear owner, keep row + customFields.
|
|
95
|
-
await asRawClient(ctx.db).unsafe(
|
|
101
|
+
await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
|
|
96
102
|
`UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
97
103
|
[ctx.userId, ctx.tenantId],
|
|
98
104
|
);
|
|
@@ -111,6 +117,7 @@ const propertyFeature = defineFeature("property-t15c", (r) => {
|
|
|
111
117
|
r.useExtension(EXT_USER_DATA, "property", {
|
|
112
118
|
export: hostExportHook,
|
|
113
119
|
delete: hostDeleteHook,
|
|
120
|
+
escapeHatch: { reason: HOST_HOOK_REASON },
|
|
114
121
|
});
|
|
115
122
|
|
|
116
123
|
const { executor } = createEntityExecutor("property", propertyEntity);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// T1.5c — user-data-rights wiring for custom-fields.
|
|
2
2
|
|
|
3
|
-
import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import type {
|
|
3
|
+
import { type DbRunner, extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import type { UserDataExportSnippet, UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { selectCustomFieldsHostRows } from "./db/queries/user-data-rights";
|
|
7
7
|
|
|
@@ -27,6 +27,41 @@ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
|
|
|
27
27
|
return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// fw#2914 — selectCustomFieldsHostRows runs raw SQL against a dynamic host
|
|
31
|
+
// table name (opts.entityTable varies per call site), so it can't go through
|
|
32
|
+
// TenantDb's typed method API (table param must be a known SchemaTable). The
|
|
33
|
+
// SQL text itself still filters `tenant_id = ctx.tenantId`, so this stays a
|
|
34
|
+
// single-tenant read — the escapeHatch is only for the raw-SQL mechanism, not
|
|
35
|
+
// for cross-tenant access.
|
|
36
|
+
const CUSTOM_FIELDS_EXPORT_REASON =
|
|
37
|
+
"custom-fields export reads a generic customFields column via raw SQL against a dynamic host table name, not expressible through TenantDb's typed method API; the query still filters tenant_id = ctx.tenantId";
|
|
38
|
+
|
|
39
|
+
async function exportCustomFields(
|
|
40
|
+
db: DbRunner,
|
|
41
|
+
ctx: UserDataHookCtx,
|
|
42
|
+
tableName: string,
|
|
43
|
+
opts: WireCustomFieldsUserDataRightsOptions,
|
|
44
|
+
): Promise<UserDataExportSnippet | null> {
|
|
45
|
+
const rows = await selectCustomFieldsHostRows(
|
|
46
|
+
db,
|
|
47
|
+
tableName,
|
|
48
|
+
opts.userIdColumn,
|
|
49
|
+
ctx.userId,
|
|
50
|
+
ctx.tenantId,
|
|
51
|
+
);
|
|
52
|
+
const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
|
|
53
|
+
for (const raw of rows) {
|
|
54
|
+
const row = asCustomFieldsHostRow(raw);
|
|
55
|
+
if (!row) continue;
|
|
56
|
+
const customFields = row.customFields;
|
|
57
|
+
if (customFields && Object.keys(customFields).length > 0) {
|
|
58
|
+
snippetRows.push({ id: row.id, customFields });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (snippetRows.length === 0) return null;
|
|
62
|
+
return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
|
|
63
|
+
}
|
|
64
|
+
|
|
30
65
|
// Export-only wiring: custom fields hold supplemental business data, not PII
|
|
31
66
|
// (#972) — there is nothing to redact on user-forget, only Art. 20 export.
|
|
32
67
|
export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
|
|
@@ -35,29 +70,10 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
|
|
|
35
70
|
): void {
|
|
36
71
|
const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
|
|
37
72
|
|
|
38
|
-
const exportHook: UserDataExportHook = async (ctx) => {
|
|
39
|
-
const rows = await selectCustomFieldsHostRows(
|
|
40
|
-
ctx.db,
|
|
41
|
-
tableName,
|
|
42
|
-
opts.userIdColumn,
|
|
43
|
-
ctx.userId,
|
|
44
|
-
ctx.tenantId,
|
|
45
|
-
);
|
|
46
|
-
const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
|
|
47
|
-
for (const raw of rows) {
|
|
48
|
-
const row = asCustomFieldsHostRow(raw);
|
|
49
|
-
if (!row) continue;
|
|
50
|
-
const customFields = row.customFields;
|
|
51
|
-
if (customFields && Object.keys(customFields).length > 0) {
|
|
52
|
-
snippetRows.push({ id: row.id, customFields });
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (snippetRows.length === 0) return null;
|
|
56
|
-
return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
|
|
57
|
-
};
|
|
58
|
-
|
|
59
73
|
// biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
|
|
60
74
|
r.useExtension(EXT_USER_DATA, opts.entityName, {
|
|
61
|
-
export:
|
|
75
|
+
export: async (ctx: UserDataHookCtx) =>
|
|
76
|
+
exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
|
|
77
|
+
escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
|
|
62
78
|
});
|
|
63
79
|
}
|
|
@@ -7,15 +7,22 @@
|
|
|
7
7
|
// + `resolveRetentionPolicy`, also kein Drift-Risiko.
|
|
8
8
|
|
|
9
9
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
11
11
|
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
|
|
13
13
|
import type { RetentionPresetKey } from "./presets";
|
|
14
14
|
import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
|
|
15
15
|
import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
|
|
16
16
|
|
|
17
|
+
function isTenantDb(db: DbRunner | TenantDb): db is TenantDb {
|
|
18
|
+
return typeof (db as Partial<TenantDb>).unsafeRaw === "function";
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
export interface ResolveForTenantArgs {
|
|
18
|
-
|
|
22
|
+
// fw#2914 — a EXT_USER_DATA hook's ctx.db is a TenantDb (method-form);
|
|
23
|
+
// the cleanup cron's own db is still a raw DbRunner. Both are valid here —
|
|
24
|
+
// this lookup is a plain tenant-scoped read either way.
|
|
25
|
+
readonly db: DbRunner | TenantDb;
|
|
19
26
|
readonly registry: Registry;
|
|
20
27
|
readonly tenantId: TenantId;
|
|
21
28
|
readonly entityName: string;
|
|
@@ -43,10 +50,15 @@ export async function resolveRetentionPolicyForTenant(
|
|
|
43
50
|
const overrideRow =
|
|
44
51
|
args.preloadedOverride !== undefined
|
|
45
52
|
? args.preloadedOverride
|
|
46
|
-
: (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
: isTenantDb(args.db)
|
|
54
|
+
? ((await args.db.fetchOne<{ config: string | null }>(tenantRetentionOverrideTable, {
|
|
55
|
+
tenantId: args.tenantId,
|
|
56
|
+
entityName: args.entityName,
|
|
57
|
+
})) ?? null)
|
|
58
|
+
: ((await fetchOne(args.db, tenantRetentionOverrideTable, {
|
|
59
|
+
tenantId: args.tenantId,
|
|
60
|
+
entityName: args.entityName,
|
|
61
|
+
})) as { config: string | null } | null); // @cast-boundary db-runner
|
|
50
62
|
|
|
51
63
|
const tenantOverride = parseRetentionOverrideOrNull(
|
|
52
64
|
overrideRow?.config ?? null,
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
// cross into another tenant's keys (the provider's own list() prefix
|
|
19
19
|
// already scopes it).
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
23
22
|
import {
|
|
24
23
|
createSystemUser,
|
|
25
24
|
type StorageProviderDestroyTenantHook,
|
|
@@ -34,13 +33,12 @@ import {
|
|
|
34
33
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
35
34
|
|
|
36
35
|
export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
|
|
37
|
-
const rows = await selectMany<{ id: string }>(
|
|
36
|
+
const rows = await ctx.db.selectMany<{ id: string }>(fileRefsTable, {
|
|
38
37
|
tenantId: ctx.tenantId,
|
|
39
38
|
});
|
|
40
39
|
const user = createSystemUser(ctx.tenantId);
|
|
41
|
-
const db = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
42
40
|
for (const row of rows) {
|
|
43
|
-
const result = await crud.forget({ id: row.id }, user, db);
|
|
41
|
+
const result = await crud.forget({ id: row.id }, user, ctx.db);
|
|
44
42
|
// Executor writes return {isSuccess:false} instead of throwing — a
|
|
45
43
|
// discarded result would report this destroy stage "succeeded" while the
|
|
46
44
|
// row (and its PII fileName) survives. Throw so the pipeline's
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
19
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
20
21
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
22
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
22
23
|
import {
|
|
@@ -402,7 +403,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
402
403
|
test("multi-user tenant: no-op, rows survive", async () => {
|
|
403
404
|
await seedOneFolderWithAssignment();
|
|
404
405
|
const ctx = {
|
|
405
|
-
db: stack.db,
|
|
406
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
406
407
|
registry: stack.registry,
|
|
407
408
|
tenantId: admin.tenantId,
|
|
408
409
|
userId: admin.id,
|
|
@@ -417,7 +418,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
417
418
|
test("anonymize strategy: no-op even on a single-user tenant", async () => {
|
|
418
419
|
await seedOneFolderWithAssignment();
|
|
419
420
|
const ctx = {
|
|
420
|
-
db: stack.db,
|
|
421
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
421
422
|
registry: stack.registry,
|
|
422
423
|
tenantId: admin.tenantId,
|
|
423
424
|
userId: admin.id,
|
|
@@ -432,7 +433,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
432
433
|
test("single-user tenant + delete: rows are purged", async () => {
|
|
433
434
|
await seedOneFolderWithAssignment();
|
|
434
435
|
const ctx = {
|
|
435
|
-
db: stack.db,
|
|
436
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
436
437
|
registry: stack.registry,
|
|
437
438
|
tenantId: admin.tenantId,
|
|
438
439
|
userId: admin.id,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
13
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
14
15
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
16
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
17
|
import {
|
|
@@ -87,7 +88,7 @@ describe("folderAssignmentExportHook", () => {
|
|
|
87
88
|
);
|
|
88
89
|
|
|
89
90
|
const snippet = await folderAssignmentExportHook({
|
|
90
|
-
db: stack.db,
|
|
91
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
91
92
|
registry: stack.registry,
|
|
92
93
|
tenantId: admin.tenantId,
|
|
93
94
|
userId: admin.id,
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
|
|
5
5
|
// (a folder name is tenant data, not per-user PII).
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import { createTenantDb, type EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
9
8
|
import {
|
|
10
9
|
createEntityExecutor,
|
|
11
10
|
createSystemUser,
|
|
@@ -27,9 +26,7 @@ const { table: folderAssignmentTable, executor: folderAssignmentExecutor } = cre
|
|
|
27
26
|
// all tenant folders in-app, so bundling them into the user's export is no new
|
|
28
27
|
// exposure — it gives the data subject the organisation of the loans they work with.
|
|
29
28
|
export const folderExportHook: UserDataExportHook = async (ctx) => {
|
|
30
|
-
const rows = await
|
|
31
|
-
tenantId: ctx.tenantId,
|
|
32
|
-
});
|
|
29
|
+
const rows = await ctx.db.selectMany(folderTable, { tenantId: ctx.tenantId });
|
|
33
30
|
if (rows.length === 0) return null;
|
|
34
31
|
return { entity: "folder", rows };
|
|
35
32
|
};
|
|
@@ -38,7 +35,7 @@ export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
|
|
|
38
35
|
// folderAssignmentEntity is softDelete: true — a cleared assignment
|
|
39
36
|
// (isDeleted: true) is a removed folder membership, not something the GDPR
|
|
40
37
|
// export should still surface as current data.
|
|
41
|
-
const rows = await
|
|
38
|
+
const rows = await ctx.db.selectMany(folderAssignmentTable, {
|
|
42
39
|
tenantId: ctx.tenantId,
|
|
43
40
|
isDeleted: false,
|
|
44
41
|
});
|
|
@@ -65,11 +62,9 @@ function tenantScopedDelete(
|
|
|
65
62
|
// eventless, so a projection rebuild resurrects the rows. Bounded — forget
|
|
66
63
|
// only fires for single-user tenants.
|
|
67
64
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
68
|
-
|
|
69
|
-
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
70
|
-
const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
|
|
65
|
+
const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
|
|
71
66
|
for (const row of rows) {
|
|
72
|
-
await executor.delete({ id: row.id }, systemUser,
|
|
67
|
+
await executor.delete({ id: row.id }, systemUser, ctx.db);
|
|
73
68
|
}
|
|
74
69
|
};
|
|
75
70
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
9
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
10
11
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
12
|
import {
|
|
12
13
|
createTestUser,
|
|
@@ -50,7 +51,12 @@ describe("formDraftDeleteHook — delete failure handling (#review-batch-fw-1)",
|
|
|
50
51
|
|
|
51
52
|
await expect(
|
|
52
53
|
formDraftDeleteHook(
|
|
53
|
-
{
|
|
54
|
+
{
|
|
55
|
+
db: createTenantDb(stack.db, bob.tenantId, "tenant"),
|
|
56
|
+
registry: stack.registry,
|
|
57
|
+
tenantId: bob.tenantId,
|
|
58
|
+
userId: bob.id,
|
|
59
|
+
},
|
|
54
60
|
"delete",
|
|
55
61
|
),
|
|
56
62
|
).resolves.toBeUndefined();
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
9
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
9
10
|
import {
|
|
10
11
|
createTestUser,
|
|
@@ -52,7 +53,7 @@ describe("formDraftExportHook", () => {
|
|
|
52
53
|
);
|
|
53
54
|
|
|
54
55
|
const snippet = await formDraftExportHook({
|
|
55
|
-
db: stack.db,
|
|
56
|
+
db: createTenantDb(stack.db, owner.tenantId, "tenant"),
|
|
56
57
|
registry: stack.registry,
|
|
57
58
|
tenantId: owner.tenantId,
|
|
58
59
|
userId: owner.id,
|
|
@@ -88,7 +89,7 @@ describe("formDraftExportHook", () => {
|
|
|
88
89
|
);
|
|
89
90
|
|
|
90
91
|
const snippet = await formDraftExportHook({
|
|
91
|
-
db: stack.db,
|
|
92
|
+
db: createTenantDb(stack.db, tenantATestTenantId, "tenant"),
|
|
92
93
|
registry: stack.registry,
|
|
93
94
|
tenantId: tenantATestTenantId,
|
|
94
95
|
userId: sameIdTenantA.id,
|
|
@@ -101,7 +102,7 @@ describe("formDraftExportHook", () => {
|
|
|
101
102
|
test("returns null when the user saved no drafts", async () => {
|
|
102
103
|
const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
|
|
103
104
|
const snippet = await formDraftExportHook({
|
|
104
|
-
db: stack.db,
|
|
105
|
+
db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
|
|
105
106
|
registry: stack.registry,
|
|
106
107
|
tenantId: lurker.tenantId,
|
|
107
108
|
userId: lurker.id,
|
|
@@ -126,7 +127,12 @@ describe("formDraftDeleteHook", () => {
|
|
|
126
127
|
);
|
|
127
128
|
|
|
128
129
|
await formDraftDeleteHook(
|
|
129
|
-
{
|
|
130
|
+
{
|
|
131
|
+
db: createTenantDb(stack.db, alice.tenantId, "tenant"),
|
|
132
|
+
registry: stack.registry,
|
|
133
|
+
tenantId: alice.tenantId,
|
|
134
|
+
userId: alice.id,
|
|
135
|
+
},
|
|
130
136
|
"delete",
|
|
131
137
|
);
|
|
132
138
|
|
|
@@ -138,7 +144,12 @@ describe("formDraftDeleteHook", () => {
|
|
|
138
144
|
const lurker = createTestUser({ id: 13, roles: ["TenantMember"] });
|
|
139
145
|
await expect(
|
|
140
146
|
formDraftDeleteHook(
|
|
141
|
-
{
|
|
147
|
+
{
|
|
148
|
+
db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
|
|
149
|
+
registry: stack.registry,
|
|
150
|
+
tenantId: lurker.tenantId,
|
|
151
|
+
userId: lurker.id,
|
|
152
|
+
},
|
|
142
153
|
"delete",
|
|
143
154
|
),
|
|
144
155
|
).resolves.toBeUndefined();
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
// consumers without the user-data-rights pipeline don't pull a hard
|
|
4
4
|
// dependency.
|
|
5
5
|
|
|
6
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
|
-
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
6
|
import {
|
|
9
7
|
createSystemUser,
|
|
10
8
|
type UserDataDeleteHook,
|
|
@@ -13,7 +11,7 @@ import {
|
|
|
13
11
|
import { formDraftExecutor, formDraftTable } from "../form-draft";
|
|
14
12
|
|
|
15
13
|
export const formDraftExportHook: UserDataExportHook = async (ctx) => {
|
|
16
|
-
const rows = await
|
|
14
|
+
const rows = await ctx.db.selectMany(formDraftTable, {
|
|
17
15
|
tenantId: ctx.tenantId,
|
|
18
16
|
ownerId: ctx.userId,
|
|
19
17
|
});
|
|
@@ -37,15 +35,14 @@ export const formDraftExportHook: UserDataExportHook = async (ctx) => {
|
|
|
37
35
|
// owners' drafts, tenant model doesn't matter here (unlike folders' single-
|
|
38
36
|
// user gating: a form-draft row always has exactly one owner already).
|
|
39
37
|
export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
40
|
-
const rows = await selectMany<{ id: string }>(
|
|
38
|
+
const rows = await ctx.db.selectMany<{ id: string }>(formDraftTable, {
|
|
41
39
|
tenantId: ctx.tenantId,
|
|
42
40
|
ownerId: ctx.userId,
|
|
43
41
|
});
|
|
44
42
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
45
|
-
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
46
43
|
const failures: string[] = [];
|
|
47
44
|
for (const row of rows) {
|
|
48
|
-
const result = await formDraftExecutor.delete({ id: row.id }, systemUser,
|
|
45
|
+
const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db);
|
|
49
46
|
if (!result.isSuccess) {
|
|
50
47
|
failures.push(`${row.id}: ${result.error.message}`);
|
|
51
48
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
9
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
10
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
11
|
import {
|
|
@@ -78,7 +79,7 @@ describe("noteEntryExportHook", () => {
|
|
|
78
79
|
);
|
|
79
80
|
|
|
80
81
|
const snippet = await noteEntryExportHook({
|
|
81
|
-
db: stack.db,
|
|
82
|
+
db: createTenantDb(stack.db, author.tenantId, "tenant"),
|
|
82
83
|
registry: stack.registry,
|
|
83
84
|
tenantId: author.tenantId,
|
|
84
85
|
userId: author.id,
|
|
@@ -95,7 +96,7 @@ describe("noteEntryExportHook", () => {
|
|
|
95
96
|
test("returns null when the user authored no notes", async () => {
|
|
96
97
|
const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
|
|
97
98
|
const snippet = await noteEntryExportHook({
|
|
98
|
-
db: stack.db,
|
|
99
|
+
db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
|
|
99
100
|
registry: stack.registry,
|
|
100
101
|
tenantId: lurker.tenantId,
|
|
101
102
|
userId: lurker.id,
|
|
@@ -107,7 +108,12 @@ describe("noteEntryExportHook", () => {
|
|
|
107
108
|
describe("noteEntryDeleteHook", () => {
|
|
108
109
|
test("is a no-op — resolves without throwing", async () => {
|
|
109
110
|
const result = await noteEntryDeleteHook(
|
|
110
|
-
{
|
|
111
|
+
{
|
|
112
|
+
db: createTenantDb(stack.db, author.tenantId, "tenant"),
|
|
113
|
+
registry: stack.registry,
|
|
114
|
+
tenantId: author.tenantId,
|
|
115
|
+
userId: author.id,
|
|
116
|
+
},
|
|
111
117
|
"delete",
|
|
112
118
|
);
|
|
113
119
|
expect(result).toBeUndefined();
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
|
|
5
5
|
// export-only, erasure via crypto-shredding.
|
|
6
6
|
|
|
7
|
-
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
7
|
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
9
8
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
9
|
import { resolveRetentionPolicyForTenant } from "../data-retention";
|
|
@@ -14,7 +13,7 @@ import { policyToStrategy } from "../user-data-rights";
|
|
|
14
13
|
// note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
|
|
15
14
|
// per-user content, so the export filters by authorId directly.
|
|
16
15
|
export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
|
|
17
|
-
const rows = await
|
|
16
|
+
const rows = await ctx.db.selectMany(noteEntryTable, {
|
|
18
17
|
authorId: ctx.userId,
|
|
19
18
|
});
|
|
20
19
|
if (rows.length === 0) return null;
|
|
@@ -55,7 +54,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
|
55
54
|
// skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
|
|
56
55
|
if (!kms) return;
|
|
57
56
|
|
|
58
|
-
const mentions = await selectMany<{ noteId: string }>(
|
|
57
|
+
const mentions = await ctx.db.selectMany<{ noteId: string }>(noteMentionTable, {
|
|
59
58
|
subjectId: ctx.userId,
|
|
60
59
|
});
|
|
61
60
|
// skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
|
|
@@ -63,7 +62,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
|
63
62
|
|
|
64
63
|
const noteIds = new Set(mentions.map((m) => m.noteId));
|
|
65
64
|
for (const noteId of noteIds) {
|
|
66
|
-
const note = await fetchOne<{ entityType: string }>(
|
|
65
|
+
const note = await ctx.db.fetchOne<{ entityType: string }>(noteEntryTable, { id: noteId });
|
|
67
66
|
// Defensive: append-only rows are never hard-deleted, so this shouldn't
|
|
68
67
|
// happen — but a missing host means no retention policy to consult.
|
|
69
68
|
if (!note) continue;
|