@cosmicdrift/kumiko-bundled-features 0.82.0 → 0.84.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.82.0",
3
+ "version": "0.84.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>",
@@ -85,11 +85,11 @@
85
85
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
86
86
  },
87
87
  "dependencies": {
88
- "@cosmicdrift/kumiko-dispatcher-live": "0.82.0",
89
- "@cosmicdrift/kumiko-framework": "0.82.0",
90
- "@cosmicdrift/kumiko-headless": "0.82.0",
91
- "@cosmicdrift/kumiko-renderer": "0.82.0",
92
- "@cosmicdrift/kumiko-renderer-web": "0.82.0",
88
+ "@cosmicdrift/kumiko-dispatcher-live": "0.84.0",
89
+ "@cosmicdrift/kumiko-framework": "0.84.0",
90
+ "@cosmicdrift/kumiko-headless": "0.84.0",
91
+ "@cosmicdrift/kumiko-renderer": "0.84.0",
92
+ "@cosmicdrift/kumiko-renderer-web": "0.84.0",
93
93
  "@mollie/api-client": "^4.5.0",
94
94
  "@node-rs/argon2": "^2.0.2",
95
95
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,201 @@
1
+ // Configurable tenant-occupancy model for GDPR forget (Art. 17).
2
+ //
3
+ // A tenant-scoped contributor (e.g. credit) has no per-user column to anonymize,
4
+ // so per-user erasure of tenant data is only safe when the tenant has exactly
5
+ // one user. The app declares that via the `tenantModel` config; the forget
6
+ // pipeline refines it per-tenant with a runtime sole-member check before handing
7
+ // `ctx.tenantModel` to each delete-hook.
8
+ //
9
+ // This drives the REAL config resolution (appOverride → resolveAppTenantModel)
10
+ // and the REAL forget pipeline (sole-member refinement → ctx.tenantModel →
11
+ // contributor delete) — NOT a hand-set ctx, which would prove the hook's `if`
12
+ // but not that the config string + system-scope resolution actually carry the
13
+ // value (the failure mode that shipped the ctx.config export bug).
14
+
15
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
17
+ import {
18
+ createEntity,
19
+ createTextField,
20
+ defineFeature,
21
+ EXT_USER_DATA,
22
+ SYSTEM_USER_ID,
23
+ type UserDataDeleteHook,
24
+ } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
26
+ import {
27
+ resetEventStore,
28
+ setupTestStack,
29
+ type TestStack,
30
+ unsafeCreateEntityTable,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
33
+ import { createConfigFeature } from "../../config";
34
+ import { createConfigResolver } from "../../config/resolver";
35
+ import { configValueEntity } from "../../config/table";
36
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
37
+ import { createSessionsFeature } from "../../sessions";
38
+ import { createUserFeature, userEntity } from "../../user";
39
+ import { TENANT_MODEL_CONFIG_KEY } from "../constants";
40
+ import { createUserDataRightsFeature } from "../feature";
41
+ import { resolveAppTenantModel } from "../lib/resolve-tenant-model";
42
+ import { runForgetCleanup } from "../run-forget-cleanup";
43
+ import {
44
+ createForgetSeeders,
45
+ nowInstant,
46
+ READ_TENANT_MEMBERSHIPS_DDL,
47
+ } from "./forget-test-helpers";
48
+
49
+ const TENANT = "00000000-0000-4000-8000-0000000000c1";
50
+ const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000c1";
51
+ const CO_MEMBER = "cccccccc-cccc-4ccc-8ccc-0000000000c2";
52
+ const TABLE = "read_dsgvo_tenant_scoped";
53
+
54
+ // Tenant-scoped contributor with NO per-user column — deletes by tenant only,
55
+ // and ONLY when this tenant is effectively single-user (mirrors credit).
56
+ const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
57
+ if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
58
+ await asRawClient(ctx.db).unsafe(`DELETE FROM ${TABLE} WHERE tenant_id = $1`, [ctx.tenantId]);
59
+ };
60
+
61
+ const scopedEntity = createEntity({
62
+ table: TABLE,
63
+ fields: { name: createTextField({ required: true }) },
64
+ });
65
+
66
+ const contributorFeature = defineFeature("dsgvo-tenant-scoped", (r) => {
67
+ r.entity("tenant-scoped", scopedEntity);
68
+ r.useExtension(EXT_USER_DATA, "tenant-scoped", {
69
+ export: async () => null,
70
+ delete: tenantScopedDeleteHook,
71
+ });
72
+ });
73
+
74
+ let stack: TestStack;
75
+ const seed = (db: unknown) =>
76
+ // biome-ignore lint/suspicious/noExplicitAny: dummy writer; this contributor has no binaries.
77
+ createForgetSeeders(db as any, { write: async () => {} });
78
+
79
+ beforeAll(async () => {
80
+ stack = await setupTestStack({
81
+ features: [
82
+ createUserFeature(),
83
+ createSessionsFeature(),
84
+ createDataRetentionFeature(),
85
+ createComplianceProfilesFeature(),
86
+ createConfigFeature(),
87
+ createUserDataRightsFeature(),
88
+ contributorFeature,
89
+ ],
90
+ });
91
+ await unsafeCreateEntityTable(stack.db, userEntity);
92
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
93
+ await unsafeCreateEntityTable(stack.db, configValueEntity);
94
+ await unsafeCreateEntityTable(stack.db, scopedEntity);
95
+ await createEventsTable(stack.db);
96
+ await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
97
+ });
98
+
99
+ afterAll(async () => {
100
+ await stack.cleanup();
101
+ });
102
+
103
+ beforeEach(async () => {
104
+ await resetEventStore(stack);
105
+ await asRawClient(stack.db).unsafe(`DELETE FROM ${TABLE}`);
106
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
107
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_users`);
108
+ });
109
+
110
+ async function seedScopedRow(rowId: string): Promise<void> {
111
+ await asRawClient(stack.db).unsafe(
112
+ `INSERT INTO ${TABLE} (id, tenant_id, name) VALUES ($1, $2, 'loan')`,
113
+ [rowId, TENANT],
114
+ );
115
+ }
116
+
117
+ async function rowCount(): Promise<number> {
118
+ const rows = await asRawClient(stack.db).unsafe(`SELECT id FROM ${TABLE} WHERE tenant_id = $1`, [
119
+ TENANT,
120
+ ]);
121
+ return (rows as ReadonlyArray<unknown>).length;
122
+ }
123
+
124
+ describe("tenant-model config resolution (seam)", () => {
125
+ test("appOverride single-user resolves through the real config resolver", async () => {
126
+ const model = await resolveAppTenantModel({
127
+ registry: stack.registry,
128
+ configResolver: createConfigResolver({
129
+ appOverrides: new Map([[TENANT_MODEL_CONFIG_KEY, "single-user"]]),
130
+ }),
131
+ db: stack.db,
132
+ userId: SYSTEM_USER_ID,
133
+ });
134
+ expect(model).toBe("single-user");
135
+ });
136
+
137
+ test("no override falls back to the feature default (multi-user)", async () => {
138
+ const model = await resolveAppTenantModel({
139
+ registry: stack.registry,
140
+ configResolver: createConfigResolver({ appOverrides: new Map() }),
141
+ db: stack.db,
142
+ userId: SYSTEM_USER_ID,
143
+ });
144
+ expect(model).toBe("multi-user");
145
+ });
146
+ });
147
+
148
+ describe("forget pipeline honours the effective tenant model", () => {
149
+ test("single-user + sole member → tenant-scoped rows erased", async () => {
150
+ await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c1");
151
+ await seed(stack.db).seedForgetUser(FORGET_USER);
152
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT);
153
+
154
+ const result = await runForgetCleanup({
155
+ db: stack.db,
156
+ registry: stack.registry,
157
+ now: nowInstant(),
158
+ tenantModel: "single-user",
159
+ });
160
+
161
+ expect(result.errors).toHaveLength(0);
162
+ expect(result.processedUserIds).toContain(FORGET_USER);
163
+ expect(await rowCount()).toBe(0);
164
+ });
165
+
166
+ test("single-user but a co-member exists → rows preserved (sole-member guard)", async () => {
167
+ await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c2");
168
+ await seed(stack.db).seedForgetUser(FORGET_USER);
169
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT);
170
+ await seed(stack.db).seedMembership(CO_MEMBER, TENANT);
171
+
172
+ const result = await runForgetCleanup({
173
+ db: stack.db,
174
+ registry: stack.registry,
175
+ now: nowInstant(),
176
+ tenantModel: "single-user",
177
+ });
178
+
179
+ expect(result.errors).toHaveLength(0);
180
+ expect(result.processedUserIds).toContain(FORGET_USER);
181
+ // A stray invite made the config's claim false at runtime — the co-member's
182
+ // loan book must survive even though the user was forgotten.
183
+ expect(await rowCount()).toBe(1);
184
+ });
185
+
186
+ test("multi-user → tenant-scoped rows preserved", async () => {
187
+ await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c3");
188
+ await seed(stack.db).seedForgetUser(FORGET_USER);
189
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT);
190
+
191
+ const result = await runForgetCleanup({
192
+ db: stack.db,
193
+ registry: stack.registry,
194
+ now: nowInstant(),
195
+ tenantModel: "multi-user",
196
+ });
197
+
198
+ expect(result.errors).toHaveLength(0);
199
+ expect(await rowCount()).toBe(1);
200
+ });
201
+ });
@@ -16,6 +16,13 @@ export const UserDataRightsQueries = {
16
16
  downloadByJob: "user-data-rights:query:download-by-job",
17
17
  } as const;
18
18
 
19
+ // App-level config: tenant-occupancy model. "single-user" tells the forget
20
+ // pipeline that each tenant has exactly one user, so tenant-scoped contributors
21
+ // (e.g. credit) may erase the tenant's data as that user's personal data.
22
+ // Default "multi-user" (declared in feature.ts). Apps set it via appOverrides:
23
+ // `overrides.set(TENANT_MODEL_CONFIG_KEY, "single-user")`.
24
+ export const TENANT_MODEL_CONFIG_KEY = "user-data-rights:config:tenant-model" as const;
25
+
19
26
  export const UserDataRightsHandlers = {
20
27
  requestExport: "user-data-rights:write:request-export",
21
28
  requestDeletion: "user-data-rights:write:request-deletion",
@@ -1,4 +1,5 @@
1
1
  import {
2
+ createSystemConfig,
2
3
  defineFeature,
3
4
  EXT_USER_DATA,
4
5
  type FeatureDefinition,
@@ -24,6 +25,7 @@ import {
24
25
  import { requestExportWrite } from "./handlers/request-export.write";
25
26
  import { restrictAccountWrite } from "./handlers/restrict-account.write";
26
27
  import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
28
+ import { resolveAppTenantModel } from "./lib/resolve-tenant-model";
27
29
  import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver";
28
30
  import {
29
31
  runExportJobs,
@@ -126,6 +128,21 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
126
128
 
127
129
  r.extendsRegistrar(EXT_USER_DATA, {});
128
130
 
131
+ // App-level tenant-occupancy model. Default "multi-user" → tenant-scoped
132
+ // contributors (e.g. credit) NEVER erase tenant data on a per-user forget
133
+ // (would harm co-members). An app with one user per tenant sets this to
134
+ // "single-user" via appOverrides (TENANT_MODEL_CONFIG_KEY) so the forget
135
+ // pipeline may erase the tenant's data as that user's personal data — still
136
+ // gated by a runtime sole-member check in run-forget-cleanup.
137
+ r.config({
138
+ keys: {
139
+ tenantModel: createSystemConfig("select", {
140
+ default: "multi-user",
141
+ options: ["single-user", "multi-user"],
142
+ }),
143
+ },
144
+ });
145
+
129
146
  // S2.U3 Atom 1b — ExportJob-Lifecycle-Entity.
130
147
  r.entity("export-job", exportJobEntity);
131
148
 
@@ -321,10 +338,17 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
321
338
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
322
339
  const forgetUserId = ctx._userId ?? SYSTEM_USER_ID;
323
340
  const forgetDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
341
+ const tenantModel = await resolveAppTenantModel({
342
+ registry: ctx.registry,
343
+ configResolver: ctx.configResolver,
344
+ db: forgetDb,
345
+ userId: forgetUserId,
346
+ });
324
347
  await runForgetCleanup({
325
348
  db: forgetDb,
326
349
  registry: ctx.registry,
327
350
  now: T.Now.instant(),
351
+ tenantModel,
328
352
  // Same per-tenant provider resolution as the export cron — forget
329
353
  // deletes binaries from the store upload + export use.
330
354
  buildStorageProvider: makeTenantStorageProviderResolver({
@@ -15,6 +15,7 @@ import { access, defineWriteHandler, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-
15
15
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
16
16
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
17
17
  import { z } from "zod";
18
+ import { resolveAppTenantModel } from "../lib/resolve-tenant-model";
18
19
  import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
19
20
  import { runForgetCleanup, type SendDeletionExecutedEmailFn } from "../run-forget-cleanup";
20
21
 
@@ -45,10 +46,17 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
45
46
  // them, so a row-only delete here would permanently leak the binaries.
46
47
  // Resolve through the same file-foundation path the cron uses.
47
48
  const forgetDb = ctx.db.raw as DbConnection; // @cast-boundary db-operator: config reads tolerate the outer tx
49
+ const tenantModel = await resolveAppTenantModel({
50
+ registry: ctx.registry,
51
+ configResolver: ctx.configResolver,
52
+ db: forgetDb,
53
+ userId: ctx._userId ?? SYSTEM_USER_ID,
54
+ });
48
55
  const result = await runForgetCleanup({
49
56
  db: ctx.db.raw,
50
57
  registry: ctx.registry,
51
58
  now: T.Now.instant(),
59
+ tenantModel,
52
60
  buildStorageProvider: makeTenantStorageProviderResolver({
53
61
  registry: ctx.registry,
54
62
  configResolver: ctx.configResolver,
@@ -0,0 +1,34 @@
1
+ // Resolves the app-level `tenantModel` config once for a forget run. The
2
+ // forget cron + manual handler both call this and pass the scalar into the
3
+ // pure pipeline (which refines it per-tenant with a sole-member check). The key
4
+ // is system-scoped, so the tenantId used for resolution is irrelevant —
5
+ // SYSTEM_TENANT_ID reads the system/appOverride value.
6
+
7
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
+ import {
9
+ type ConfigResolver,
10
+ type Registry,
11
+ SYSTEM_TENANT_ID,
12
+ type TenantUserModel,
13
+ } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { createConfigAccessor } from "../../config";
15
+ import { TENANT_MODEL_CONFIG_KEY } from "../constants";
16
+
17
+ export async function resolveAppTenantModel(args: {
18
+ readonly registry: Registry;
19
+ readonly configResolver: ConfigResolver | undefined;
20
+ readonly db: DbConnection;
21
+ readonly userId: string;
22
+ }): Promise<TenantUserModel> {
23
+ // No resolver (e.g. a unit context) → safe default: never erase tenant-scoped data.
24
+ if (!args.configResolver) return "multi-user";
25
+ const config = createConfigAccessor(
26
+ args.registry,
27
+ args.configResolver,
28
+ SYSTEM_TENANT_ID,
29
+ args.userId,
30
+ args.db,
31
+ );
32
+ const raw = await config(TENANT_MODEL_CONFIG_KEY);
33
+ return raw === "single-user" ? "single-user" : "multi-user";
34
+ }
@@ -39,6 +39,7 @@ import {
39
39
  EXT_USER_DATA_ORDER,
40
40
  type Registry,
41
41
  type TenantId,
42
+ type TenantUserModel,
42
43
  type UserDataDeleteHook,
43
44
  type UserDataDeleteStrategy,
44
45
  type UserDataStorageProvider,
@@ -96,6 +97,15 @@ export interface RunForgetCleanupArgs {
96
97
  readonly buildStorageProvider?: (
97
98
  tenantId: TenantId,
98
99
  ) => Promise<UserDataStorageProvider | undefined>;
100
+
101
+ /**
102
+ * App-level tenant-occupancy model (resolved from the `tenantModel` config by
103
+ * the cron/handler). The pipeline refines it PER TENANT with a sole-member
104
+ * check before handing `tenantModel` to each delete-hook, so tenant-scoped
105
+ * erasure only happens where it's actually safe. Omitted → `"multi-user"`
106
+ * (no tenant-scoped erasure).
107
+ */
108
+ readonly tenantModel?: TenantUserModel;
99
109
  }
100
110
 
101
111
  export interface ForgetCleanupError {
@@ -131,6 +141,7 @@ export async function runForgetCleanup(
131
141
  args: RunForgetCleanupArgs,
132
142
  ): Promise<RunForgetCleanupResult> {
133
143
  const { db, registry, now, sendDeletionExecutedEmail, buildStorageProvider } = args;
144
+ const appTenantModel: TenantUserModel = args.tenantModel ?? "multi-user";
134
145
 
135
146
  // Step 1: Find users with expired grace period.
136
147
  const dueUsers = await selectUsersDueForForgetCleanup(
@@ -173,6 +184,7 @@ export async function runForgetCleanup(
173
184
  userId: user.id,
174
185
  hookEntries,
175
186
  buildStorageProvider,
187
+ appTenantModel,
176
188
  });
177
189
  hookCallsAttempted += userResult.hookCallsAttempted;
178
190
  errors.push(...userResult.errors);
@@ -229,8 +241,9 @@ async function processUser(args: {
229
241
  userId: string;
230
242
  hookEntries: readonly HookEntry[];
231
243
  buildStorageProvider?: (tenantId: TenantId) => Promise<UserDataStorageProvider | undefined>;
244
+ appTenantModel: TenantUserModel;
232
245
  }): Promise<ProcessUserResult> {
233
- const { db, registry, userId, hookEntries, buildStorageProvider } = args;
246
+ const { db, registry, userId, hookEntries, buildStorageProvider, appTenantModel } = args;
234
247
  const errors: ForgetCleanupError[] = [];
235
248
  let hookCallsAttempted = 0;
236
249
 
@@ -276,6 +289,10 @@ async function processUser(args: {
276
289
  await runInSubTransaction(db, async (tx) => {
277
290
  for (const tenantId of tenantList) {
278
291
  currentTenantId = tenantId;
292
+ // Refine the app-level model to THIS tenant: "single-user" only if the
293
+ // tenant truly has one member, so a stray invite can't let a per-user
294
+ // forget erase a co-member's tenant-scoped data (money-path safety).
295
+ const tenantModel = await resolveEffectiveTenantModel(tx, tenantId, appTenantModel);
279
296
  for (const entry of hookEntries) {
280
297
  currentEntityName = entry.entityName;
281
298
  const policy = await resolveRetentionPolicyForTenant({
@@ -287,7 +304,10 @@ async function processUser(args: {
287
304
  const strategy = policyToStrategy(policy.policy?.strategy ?? null);
288
305
 
289
306
  hookCallsAttempted++;
290
- await entry.deleteHook({ db: tx, tenantId, userId, buildStorageProvider }, strategy);
307
+ await entry.deleteHook(
308
+ { db: tx, tenantId, userId, buildStorageProvider, tenantModel },
309
+ strategy,
310
+ );
291
311
  }
292
312
  }
293
313
 
@@ -362,6 +382,21 @@ async function runInSubTransaction(
362
382
  // User-Row und ignorieren tenantId.
363
383
  const SYSTEM_TENANT_ID_FOR_ORPHANS = "00000000-0000-0000-0000-000000000000" as TenantId;
364
384
 
385
+ // "single-user" requires BOTH the app config AND a runtime sole-member check —
386
+ // the config asserts the deployment model, the count guards against a stray
387
+ // invite that would make a per-user forget delete a co-member's tenant-scoped
388
+ // data. Only queried when the app opted into "single-user" (multi-user apps
389
+ // never reach the destructive path, so no extra query for them).
390
+ async function resolveEffectiveTenantModel(
391
+ db: DbRunner,
392
+ tenantId: TenantId,
393
+ appTenantModel: TenantUserModel,
394
+ ): Promise<TenantUserModel> {
395
+ if (appTenantModel !== "single-user") return "multi-user";
396
+ const members = await selectMany<{ userId: string }>(db, tenantMembershipsTable, { tenantId });
397
+ return members.length === 1 ? "single-user" : "multi-user";
398
+ }
399
+
365
400
  // Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
366
401
  // - "anonymize" / "blockDelete" → "anonymize" (Aufbewahrungs-Pflicht
367
402
  // blockDelete: Daten muessen physisch bleiben, nur Personen-Bezug raus)
@@ -114,7 +114,7 @@ function ExportSection(): ReactNode {
114
114
  };
115
115
 
116
116
  const result = statusQuery.data;
117
- const job = result && result.hasJob ? result.job : null;
117
+ const job = result?.hasJob ? result.job : null;
118
118
  const submitting = status.kind === "submitting";
119
119
  const inProgress =
120
120
  job?.status === EXPORT_JOB_STATUS.Pending || job?.status === EXPORT_JOB_STATUS.Running;