@cosmicdrift/kumiko-bundled-features 0.23.1 → 0.24.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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>",
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Forget-Hook binary-Cleanup Integration-Test.
|
|
2
|
+
//
|
|
3
|
+
// Beweist, dass der `fileRef`-Forget-Hook bei strategy="delete" die
|
|
4
|
+
// Storage-Binaries via `storageProvider.delete()` entfernt, BEVOR die
|
|
5
|
+
// row hard-gelöscht wird — ohne provider leakt sonst jede gelöschte
|
|
6
|
+
// Datei ihre Bytes dauerhaft auf Disk (Issue gefunden im Review zu #177).
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import {
|
|
12
|
+
createInMemoryFileProvider,
|
|
13
|
+
fileRefsTable,
|
|
14
|
+
type InMemoryFileProvider,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
16
|
+
import {
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
unsafeCreateEntityTable,
|
|
20
|
+
unsafePushTables,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
24
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
25
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
26
|
+
import { createFilesFeature } from "../../files";
|
|
27
|
+
import { createSessionsFeature } from "../../sessions";
|
|
28
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
29
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
30
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
31
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
32
|
+
|
|
33
|
+
let stack: TestStack;
|
|
34
|
+
let db: DbConnection;
|
|
35
|
+
let provider: InMemoryFileProvider;
|
|
36
|
+
|
|
37
|
+
const TENANT = "00000000-0000-4000-8000-00000000000c";
|
|
38
|
+
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
39
|
+
|
|
40
|
+
function uuid(suffix: number): string {
|
|
41
|
+
return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
45
|
+
const NOW = (): Instant => getTemporal().Now.instant();
|
|
46
|
+
const pastInstant = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
provider = createInMemoryFileProvider();
|
|
50
|
+
stack = await setupTestStack({
|
|
51
|
+
features: [
|
|
52
|
+
createUserFeature(),
|
|
53
|
+
createFilesFeature(),
|
|
54
|
+
createDataRetentionFeature(),
|
|
55
|
+
createComplianceProfilesFeature(),
|
|
56
|
+
createSessionsFeature(),
|
|
57
|
+
createUserDataRightsFeature(),
|
|
58
|
+
createUserDataRightsDefaultsFeature({ storageProvider: provider }),
|
|
59
|
+
],
|
|
60
|
+
files: { storageProvider: provider },
|
|
61
|
+
});
|
|
62
|
+
db = stack.db;
|
|
63
|
+
|
|
64
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
65
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
66
|
+
await unsafePushTables(db, { fileRefsTable });
|
|
67
|
+
await asRawClient(db).unsafe(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
69
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
70
|
+
tenant_id UUID NOT NULL,
|
|
71
|
+
user_id TEXT NOT NULL,
|
|
72
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
74
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
75
|
+
inserted_by_id TEXT,
|
|
76
|
+
modified_by_id TEXT,
|
|
77
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
78
|
+
deleted_at TIMESTAMPTZ,
|
|
79
|
+
deleted_by_id TEXT,
|
|
80
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
81
|
+
UNIQUE(user_id, tenant_id)
|
|
82
|
+
)
|
|
83
|
+
`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await stack.cleanup();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
provider.clear();
|
|
92
|
+
await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function seedForgetUser(id: string): Promise<void> {
|
|
96
|
+
await insertOne(db, userTable, {
|
|
97
|
+
id,
|
|
98
|
+
tenantId: TENANT_SYSTEM,
|
|
99
|
+
email: `user-${id}@example.com`,
|
|
100
|
+
passwordHash: "hashed",
|
|
101
|
+
displayName: `User ${id}`,
|
|
102
|
+
locale: "de",
|
|
103
|
+
emailVerified: true,
|
|
104
|
+
roles: '["Member"]',
|
|
105
|
+
status: USER_STATUS.DeletionRequested,
|
|
106
|
+
gracePeriodEnd: pastInstant(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function seedMembership(userId: string, tenantId: string): Promise<void> {
|
|
111
|
+
await asRawClient(db).unsafe(
|
|
112
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
113
|
+
VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
114
|
+
[tenantId, userId],
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function seedFile(id: string, tenantId: string, insertedById: string): Promise<string> {
|
|
119
|
+
const storageKey = `storage/${id}`;
|
|
120
|
+
await provider.write(storageKey, new Uint8Array([1, 2, 3, 4]), "application/pdf");
|
|
121
|
+
await asRawClient(db).unsafe(
|
|
122
|
+
`INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
123
|
+
VALUES ($1, $2, $3, $4, 'application/pdf', 4, $5) ON CONFLICT (id) DO NOTHING`,
|
|
124
|
+
[id, tenantId, storageKey, `${id}.pdf`, insertedById],
|
|
125
|
+
);
|
|
126
|
+
return storageKey;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe("forget-binary-cleanup :: storage.delete fires before row hard-delete", () => {
|
|
130
|
+
test("Forget deletes the binary from the storage provider", async () => {
|
|
131
|
+
const userId = uuid(1);
|
|
132
|
+
await seedForgetUser(userId);
|
|
133
|
+
await seedMembership(userId, TENANT);
|
|
134
|
+
const key = await seedFile(uuid(101), TENANT, userId);
|
|
135
|
+
expect(await provider.exists(key)).toBe(true);
|
|
136
|
+
|
|
137
|
+
const result = await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
138
|
+
|
|
139
|
+
expect(result.processedUserIds).toContain(userId);
|
|
140
|
+
expect(await provider.exists(key)).toBe(false);
|
|
141
|
+
expect(provider.keys()).not.toContain(key);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("Multiple files from the same user — all binaries cleaned up", async () => {
|
|
145
|
+
const userId = uuid(2);
|
|
146
|
+
await seedForgetUser(userId);
|
|
147
|
+
await seedMembership(userId, TENANT);
|
|
148
|
+
const keys = await Promise.all([
|
|
149
|
+
seedFile(uuid(201), TENANT, userId),
|
|
150
|
+
seedFile(uuid(202), TENANT, userId),
|
|
151
|
+
seedFile(uuid(203), TENANT, userId),
|
|
152
|
+
]);
|
|
153
|
+
expect(provider.keys()).toHaveLength(3);
|
|
154
|
+
|
|
155
|
+
await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
156
|
+
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
expect(await provider.exists(key)).toBe(false);
|
|
159
|
+
}
|
|
160
|
+
expect(provider.keys()).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("Other tenants' files stay untouched", async () => {
|
|
164
|
+
const userId = uuid(3);
|
|
165
|
+
const otherTenant = "00000000-0000-4000-8000-00000000000d";
|
|
166
|
+
await seedForgetUser(userId);
|
|
167
|
+
await seedMembership(userId, TENANT);
|
|
168
|
+
const myKey = await seedFile(uuid(301), TENANT, userId);
|
|
169
|
+
const otherKey = await seedFile(uuid(302), otherTenant, "another-user");
|
|
170
|
+
// The other-tenant file is owned by a different user; the forget run for
|
|
171
|
+
// userId must NOT touch it.
|
|
172
|
+
|
|
173
|
+
await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
174
|
+
|
|
175
|
+
expect(await provider.exists(myKey)).toBe(false);
|
|
176
|
+
expect(await provider.exists(otherKey)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -3,9 +3,20 @@ import {
|
|
|
3
3
|
EXT_USER_DATA,
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import {
|
|
6
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
7
|
+
import { createFileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
|
|
7
8
|
import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
8
9
|
|
|
10
|
+
export interface UserDataRightsDefaultsOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Wired into the fileRef delete-hook: on strategy="delete" the hook
|
|
13
|
+
* calls `storageProvider.delete(key)` per row before hard-deleting
|
|
14
|
+
* the row. Without it, file binaries leak on forget (Art. 17) — the
|
|
15
|
+
* hook logs a one-shot warning so misconfiguration stays visible.
|
|
16
|
+
*/
|
|
17
|
+
readonly storageProvider?: FileStorageProvider;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
// user-data-rights-defaults — Default-Hooks für die Core-Entities
|
|
10
21
|
// `user` (S2.H1) und `fileRef` (S2.H2).
|
|
11
22
|
//
|
|
@@ -23,7 +34,10 @@ import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
|
23
34
|
// Pattern matched file-foundation + file-provider-s3 (separate Plugin-
|
|
24
35
|
// Feature), nicht user/files schreiben ihre eigenen Hooks selbst weil
|
|
25
36
|
// das circular-requires waere.
|
|
26
|
-
export function createUserDataRightsDefaultsFeature(
|
|
37
|
+
export function createUserDataRightsDefaultsFeature(
|
|
38
|
+
options: UserDataRightsDefaultsOptions = {},
|
|
39
|
+
): FeatureDefinition {
|
|
40
|
+
const fileRefDeleteHook = createFileRefDeleteHook(options.storageProvider);
|
|
27
41
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
28
42
|
r.requires("user", "files", "user-data-rights");
|
|
29
43
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
3
|
+
import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
4
4
|
|
|
5
5
|
// userData-Hook fuer fileRef-entity (S2.H2).
|
|
6
6
|
//
|
|
@@ -9,10 +9,9 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
|
9
9
|
// NICHT direkt — sie werden via signed-Download-URLs separat ins ZIP
|
|
10
10
|
// gepackt (S2.U3 Export-Job-Pipeline orchestriert das).
|
|
11
11
|
//
|
|
12
|
-
// Delete-Hook entfernt FileRef-Zeile
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// "delete": Row hard-delete + storageProvider.delete() pro File
|
|
12
|
+
// Delete-Hook entfernt FileRef-Zeile via factory
|
|
13
|
+
// `createFileRefDeleteHook(storageProvider)`:
|
|
14
|
+
// "delete": storageProvider.delete() pro File (best-effort) + Row hard-delete
|
|
16
15
|
// "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
|
|
17
16
|
// koennen weiter zeigen; Personenbezug raus)
|
|
18
17
|
//
|
|
@@ -22,12 +21,17 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
|
22
21
|
// idempotent, KEIN globaler Rollback — wenn ein File-Delete failt,
|
|
23
22
|
// bleibt der User-Row trotzdem anonymisiert.
|
|
24
23
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
24
|
+
// `storageProvider` ist optional. App-Author wired es beim
|
|
25
|
+
// Feature-Mount rein (`createUserDataRightsDefaultsFeature({
|
|
26
|
+
// storageProvider })`). Ohne Provider macht der Hook row-only-delete,
|
|
27
|
+
// die Bytes leaken — der Caller bekommt EINEN Warn beim ersten Lauf
|
|
28
|
+
// pro Process, damit die Konfiguration sichtbar fehlerhaft ist.
|
|
29
|
+
//
|
|
30
|
+
// Caveat: hard-delete via deleteMany emittiert KEIN fileRef.deleted —
|
|
31
|
+
// die storage-tracking-MSP dekrementiert nicht. Wenn die zu loeschenden
|
|
32
|
+
// Files vorher nicht soft-deleted waren, bleibt `tenant_storage_usage`
|
|
33
|
+
// inflated. Forget-Flows sind selten (per-User-Art.-17) und damit
|
|
34
|
+
// bounded; ein executor.purge-API folgt mit dem trashed-files-GC.
|
|
31
35
|
|
|
32
36
|
export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
33
37
|
// isDeleted:false — soft-deleted (trashed) Files gehören nicht ins
|
|
@@ -76,22 +80,54 @@ export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
|
76
80
|
};
|
|
77
81
|
};
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
83
|
+
let missingStorageWarned = false;
|
|
84
|
+
|
|
85
|
+
export function createFileRefDeleteHook(
|
|
86
|
+
storageProvider: FileStorageProvider | undefined,
|
|
87
|
+
): UserDataDeleteHook {
|
|
88
|
+
return async (ctx, strategy) => {
|
|
89
|
+
if (strategy === "delete") {
|
|
90
|
+
if (storageProvider) {
|
|
91
|
+
const rows = await selectMany(ctx.db, fileRefsTable, {
|
|
92
|
+
tenantId: ctx.tenantId,
|
|
93
|
+
insertedById: ctx.userId,
|
|
94
|
+
});
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
|
|
97
|
+
if (typeof key !== "string" || key.length === 0) continue;
|
|
98
|
+
try {
|
|
99
|
+
await storageProvider.delete(key);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
102
|
+
console.warn(
|
|
103
|
+
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else if (!missingStorageWarned) {
|
|
108
|
+
missingStorageWarned = true;
|
|
109
|
+
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|
|
110
|
+
console.warn(
|
|
111
|
+
"[user-data-rights-defaults:fileRef] no storageProvider configured — file binaries are NOT deleted on forget. Pass createUserDataRightsDefaultsFeature({ storageProvider }) to fix.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
|
|
115
|
+
} else {
|
|
116
|
+
// anonymize: insertedById=null, FileRef + binary bleiben.
|
|
117
|
+
// Use-case: shared chat-Attachment in einem Multi-User-Channel —
|
|
118
|
+
// Author-Identifikation raus, Datei bleibt fuer andere User
|
|
119
|
+
// sichtbar.
|
|
120
|
+
await updateMany(
|
|
121
|
+
ctx.db,
|
|
122
|
+
fileRefsTable,
|
|
123
|
+
{ insertedById: null },
|
|
124
|
+
{ tenantId: ctx.tenantId, insertedById: ctx.userId },
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Legacy export: storage-less hook for callers that haven't migrated.
|
|
131
|
+
// Binaries are NOT cleaned up — disk leak. Migrate to
|
|
132
|
+
// createUserDataRightsDefaultsFeature({ storageProvider }).
|
|
133
|
+
export const fileRefDeleteHook: UserDataDeleteHook = createFileRefDeleteHook(undefined);
|