@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.23.1",
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 { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
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(): FeatureDefinition {
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 + Storage-Binary. Plan-Roadmap
13
- // docs/plans/datenschutz/storage-encryption.md hat das Subject-
14
- // Resolver-Pattern fuer File-Encryption als Sprint 4 — bis dahin:
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
- // Storage-Provider kommt aus dem App-Bootstrap (createBunServer-
26
- // options.files.storageProvider). Wir greifen darauf via ctx — der
27
- // Hook-ctx hat aktuell nur db/tenantId/userId, also fuer Storage-
28
- // Calls braucht es eine Erweiterung. S2.U3 Export-Job-Pipeline regelt
29
- // das (Job-ctx hat ctx.files.ref(key)). Hier lassen wir Storage-
30
- // Cleanup als TODO und faellen das in S2.U5 nochmal an.
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
- export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
80
- if (strategy === "delete") {
81
- // Hard-delete der FileRef-Rows fuer diesen User in diesem Tenant.
82
- // Storage-Binary-Cleanup folgt in S2.U5 wenn der Forget-Job-Ctx
83
- // den Storage-Provider exposed.
84
- await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
85
- } else {
86
- // anonymize: insertedById=null, FileRef + binary bleiben.
87
- // Use-case: shared chat-Attachment in einem Multi-User-Channel —
88
- // Author-Identifikation raus, Datei bleibt fuer andere User
89
- // sichtbar.
90
- await updateMany(
91
- ctx.db,
92
- fileRefsTable,
93
- { insertedById: null },
94
- { tenantId: ctx.tenantId, insertedById: ctx.userId },
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);