@cosmicdrift/kumiko-bundled-features 0.81.1 → 0.83.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.81.1",
3
+ "version": "0.83.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.81.1",
89
- "@cosmicdrift/kumiko-framework": "0.81.1",
90
- "@cosmicdrift/kumiko-headless": "0.81.1",
91
- "@cosmicdrift/kumiko-renderer": "0.81.1",
92
- "@cosmicdrift/kumiko-renderer-web": "0.81.1",
88
+ "@cosmicdrift/kumiko-dispatcher-live": "0.83.0",
89
+ "@cosmicdrift/kumiko-framework": "0.83.0",
90
+ "@cosmicdrift/kumiko-headless": "0.83.0",
91
+ "@cosmicdrift/kumiko-renderer": "0.83.0",
92
+ "@cosmicdrift/kumiko-renderer-web": "0.83.0",
93
93
  "@mollie/api-client": "^4.5.0",
94
94
  "@node-rs/argon2": "^2.0.2",
95
95
  "@types/nodemailer": "^8.0.0",
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { DEFAULT_TAG_ROLES } from "../constants";
3
3
  import { createTagsFeature } from "../feature";
4
- import { assignTagPayloadSchema, createTagPayloadSchema, removeTagPayloadSchema } from "../schemas";
4
+ import {
5
+ assignTagPayloadSchema,
6
+ createTagPayloadSchema,
7
+ removeTagPayloadSchema,
8
+ renameTagPayloadSchema,
9
+ } from "../schemas";
5
10
 
6
11
  // Unit tests: feature-shape, role-options, schema-validation. The ES-loop
7
12
  // behaviour (idempotent assign/remove, projection, tenant-isolation, read
@@ -42,7 +47,7 @@ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch
42
47
  }
43
48
 
44
49
  describe("createTagsFeature shape", () => {
45
- test("registers tag + tag-assignment entities, 3 write-handlers, 2 query-handlers", () => {
50
+ test("registers tag + tag-assignment entities, 4 write-handlers, 2 query-handlers", () => {
46
51
  const feature = createTagsFeature();
47
52
 
48
53
  expect(Object.keys(feature.entities ?? {})).toEqual(
@@ -52,11 +57,12 @@ describe("createTagsFeature shape", () => {
52
57
  expect(Object.keys(feature.writeHandlers)).toEqual(
53
58
  expect.arrayContaining([
54
59
  expect.stringMatching(/create-tag/),
60
+ expect.stringMatching(/rename-tag/),
55
61
  expect.stringMatching(/assign-tag/),
56
62
  expect.stringMatching(/remove-tag/),
57
63
  ]),
58
64
  );
59
- expect(Object.keys(feature.writeHandlers)).toHaveLength(3);
65
+ expect(Object.keys(feature.writeHandlers)).toHaveLength(4);
60
66
 
61
67
  expect(Object.keys(feature.queryHandlers)).toEqual(
62
68
  expect.arrayContaining([
@@ -73,6 +79,7 @@ describe("createTagsFeature access-options", () => {
73
79
  const feature = createTagsFeature();
74
80
  expect(feature).toBe(createTagsFeature());
75
81
  expect(writeAccess(feature, "create-tag")).toEqual([...DEFAULT_TAG_ROLES]);
82
+ expect(writeAccess(feature, "rename-tag")).toEqual([...DEFAULT_TAG_ROLES]);
76
83
  expect(writeAccess(feature, "assign-tag")).toEqual([...DEFAULT_TAG_ROLES]);
77
84
  expect(writeAccess(feature, "remove-tag")).toEqual([...DEFAULT_TAG_ROLES]);
78
85
  expect(queryAccess(feature, "tag:list")).toEqual([...DEFAULT_TAG_ROLES]);
@@ -82,6 +89,7 @@ describe("createTagsFeature access-options", () => {
82
89
  test("roles option overrides every write- and query-path", () => {
83
90
  const feature = createTagsFeature({ roles: ["Admin", "Editor"] });
84
91
  expect(writeAccess(feature, "create-tag")).toEqual(["Admin", "Editor"]);
92
+ expect(writeAccess(feature, "rename-tag")).toEqual(["Admin", "Editor"]);
85
93
  expect(writeAccess(feature, "assign-tag")).toEqual(["Admin", "Editor"]);
86
94
  expect(writeAccess(feature, "remove-tag")).toEqual(["Admin", "Editor"]);
87
95
  expect(queryAccess(feature, "tag:list")).toEqual(["Admin", "Editor"]);
@@ -90,7 +98,7 @@ describe("createTagsFeature access-options", () => {
90
98
 
91
99
  test("access:{openToAll} applies to every write- and query-path", () => {
92
100
  const feature = createTagsFeature({ access: { openToAll: true } });
93
- for (const path of ["create-tag", "assign-tag", "remove-tag"]) {
101
+ for (const path of ["create-tag", "rename-tag", "assign-tag", "remove-tag"]) {
94
102
  expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
95
103
  }
96
104
  for (const query of ["tag:list", "tag-assignment:list"]) {
@@ -155,6 +163,28 @@ describe("createTagPayloadSchema", () => {
155
163
  });
156
164
  });
157
165
 
166
+ describe("renameTagPayloadSchema", () => {
167
+ const valid = { id: "tag-1", version: 0, name: "Neu" };
168
+
169
+ test("accepts id + version + name", () => {
170
+ expect(renameTagPayloadSchema.safeParse(valid).success).toBe(true);
171
+ });
172
+
173
+ test("rejects a missing version (optimistic lock is mandatory)", () => {
174
+ expect(renameTagPayloadSchema.safeParse({ id: "tag-1", name: "Neu" }).success).toBe(false);
175
+ });
176
+
177
+ test("rejects an empty name", () => {
178
+ expect(renameTagPayloadSchema.safeParse({ ...valid, name: "" }).success).toBe(false);
179
+ });
180
+
181
+ test("rejects a name over 64 chars", () => {
182
+ expect(renameTagPayloadSchema.safeParse({ ...valid, name: "x".repeat(65) }).success).toBe(
183
+ false,
184
+ );
185
+ });
186
+ });
187
+
158
188
  describe("assign/remove payload schemas", () => {
159
189
  const valid = { tagId: "tag-1", entityType: "credit", entityId: "c-1" };
160
190
 
@@ -61,6 +61,10 @@ async function remove(tagId: string, entityType: string, entityId: string, user
61
61
  return stack.http.writeOk(TagsHandlers.removeTag, { tagId, entityType, entityId }, user);
62
62
  }
63
63
 
64
+ async function tagById(id: string, user = admin): Promise<Record<string, unknown> | undefined> {
65
+ return (await listTags(user)).find((t) => t["id"] === id);
66
+ }
67
+
64
68
  async function listTags(user = admin): Promise<Array<Record<string, unknown>>> {
65
69
  const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
66
70
  TagsQueries.tagList,
@@ -129,6 +133,64 @@ describe("tags integration — catalog + assignment roundtrip", () => {
129
133
  });
130
134
  });
131
135
 
136
+ describe("tags integration — rename", () => {
137
+ test("rename-tag updates name, preserves color, bumps version", async () => {
138
+ const { id } = await stack.http.writeOk<{ id: string }>(
139
+ TagsHandlers.createTag,
140
+ { name: "Mandant Alt", color: "#abc" },
141
+ admin,
142
+ );
143
+ const before = await tagById(id);
144
+ expect(before?.["name"]).toBe("Mandant Alt");
145
+ // The rename UI passes the list-row version as the optimistic-lock base —
146
+ // assert it's actually there (tags are CRUD-only, so it's authoritative).
147
+ expect(typeof before?.["version"]).toBe("number");
148
+ const version = before?.["version"] as number;
149
+
150
+ await stack.http.writeOk(TagsHandlers.renameTag, { id, version, name: "Mandant Neu" }, admin);
151
+
152
+ const after = await tagById(id);
153
+ expect(after?.["name"]).toBe("Mandant Neu");
154
+ expect(after?.["color"]).toBe("#abc"); // shallow merge keeps non-renamed fields
155
+ expect(after?.["version"]).toBe(version + 1);
156
+ });
157
+
158
+ test("rename-tag with a stale version is rejected (409), name unchanged", async () => {
159
+ const { id } = await stack.http.writeOk<{ id: string }>(
160
+ TagsHandlers.createTag,
161
+ { name: "Konflikt" },
162
+ admin,
163
+ );
164
+ const stale = (await tagById(id))?.["version"] as number;
165
+ await stack.http.writeOk(TagsHandlers.renameTag, { id, version: stale, name: "Erster" }, admin);
166
+
167
+ const err = await stack.http.writeErr(
168
+ TagsHandlers.renameTag,
169
+ { id, version: stale, name: "Zweiter" }, // stale: the row already moved to stale+1
170
+ admin,
171
+ );
172
+ expect(err.httpStatus).toBe(409);
173
+ expect((await tagById(id))?.["name"]).toBe("Erster");
174
+ });
175
+
176
+ test("tenant B cannot rename tenant A's tag (404, A untouched)", async () => {
177
+ const { id } = await stack.http.writeOk<{ id: string }>(
178
+ TagsHandlers.createTag,
179
+ { name: "A privat" },
180
+ admin,
181
+ );
182
+ const version = (await tagById(id, admin))?.["version"] as number;
183
+
184
+ const err = await stack.http.writeErr(
185
+ TagsHandlers.renameTag,
186
+ { id, version, name: "B-Übernahme" },
187
+ otherTenant,
188
+ );
189
+ expect(err.httpStatus).toBe(404);
190
+ expect((await tagById(id, admin))?.["name"]).toBe("A privat");
191
+ });
192
+ });
193
+
132
194
  describe("tags integration — many-to-many composition", () => {
133
195
  test("one entity carries multiple tags", async () => {
134
196
  const a = await createTag("rot");
@@ -18,6 +18,7 @@ export const TAGS_SECTION_EXTENSION_NAME = "TagSection";
18
18
  // object instead of magic strings (mirror custom-fields' Handlers/Queries).
19
19
  export const TagsHandlers = {
20
20
  createTag: "tags:write:create-tag",
21
+ renameTag: "tags:write:rename-tag",
21
22
  assignTag: "tags:write:assign-tag",
22
23
  removeTag: "tags:write:remove-tag",
23
24
  } as const;
@@ -12,8 +12,8 @@
12
12
  // tag-assignments filtered on entityId (tags of an entity) or tagId (entities
13
13
  // with a tag). See entity.ts.
14
14
  //
15
- // v1 scope: create-tag, assign-tag, remove-tag, list tags, list assignments.
16
- // Deferred: rename/delete-tag, optional host-projection decoration
15
+ // Scope: create-tag, rename-tag, assign-tag, remove-tag, list tags, list assignments.
16
+ // Deferred: delete-tag, optional host-projection decoration
17
17
  // (`wireTagsFor`), search indexing, user-data-rights anonymization.
18
18
 
19
19
  import {
@@ -27,6 +27,7 @@ import { tagAssignmentEntity, tagEntity } from "./entity";
27
27
  import { createAssignTagHandler } from "./handlers/assign-tag.write";
28
28
  import { createCreateTagHandler } from "./handlers/create-tag.write";
29
29
  import { createRemoveTagHandler } from "./handlers/remove-tag.write";
30
+ import { createRenameTagHandler } from "./handlers/rename-tag.write";
30
31
 
31
32
  // Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
32
33
  // dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
@@ -42,7 +43,7 @@ function registerTags(
42
43
  toggleable: TagsToggleable | undefined,
43
44
  ): void {
44
45
  r.describe(
45
- "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
46
+ "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `rename-tag` (optimistic-locked), `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
46
47
  );
47
48
  r.uiHints({
48
49
  displayLabel: "Tags",
@@ -58,6 +59,7 @@ function registerTags(
58
59
  r.entity("tag-assignment", tagAssignmentEntity);
59
60
 
60
61
  r.writeHandler(createCreateTagHandler(access));
62
+ r.writeHandler(createRenameTagHandler(access));
61
63
  r.writeHandler(createAssignTagHandler(access));
62
64
  r.writeHandler(createRemoveTagHandler(access));
63
65
 
@@ -6,8 +6,7 @@ import { type CreateTagPayload, createTagPayloadSchema } from "../schemas";
6
6
  // create-tag — adds a tag to the tenant's catalog. The framework mints a fresh
7
7
  // UUIDv7 id (no explicit id passed). Tag names are not unique by design: the
8
8
  // catalog is a free list and dedup is a UI concern (autocomplete from existing
9
- // tags). Rename/delete are deferred to a later iteration (v1 scope: create,
10
- // assign, remove, list).
9
+ // tags). Rename is rename-tag.write.ts; delete is still deferred.
11
10
  export function createCreateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
12
11
  return {
13
12
  name: "create-tag",
@@ -0,0 +1,26 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { DEFAULT_TAG_ACCESS } from "../constants";
3
+ import { tagExecutor } from "../executor";
4
+ import { type RenameTagPayload, renameTagPayloadSchema } from "../schemas";
5
+
6
+ // rename-tag — renames a tag in the tenant's catalog. Optimistic-locked: the
7
+ // client sends the `version` it read (mirrors tenant:update). The executor
8
+ // merges shallowly so only `name` changes — `color` is preserved. A stale
9
+ // version returns version_conflict; the UI refetches and retries.
10
+ export function createRenameTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
11
+ return {
12
+ name: "rename-tag",
13
+ schema: renameTagPayloadSchema,
14
+ access,
15
+ handler: async (event, ctx) => {
16
+ const payload = event.payload as RenameTagPayload; // @cast-boundary engine-payload
17
+ return tagExecutor.update(
18
+ { id: payload.id, version: payload.version, changes: { name: payload.name } },
19
+ event.user,
20
+ ctx.db,
21
+ );
22
+ },
23
+ };
24
+ }
25
+
26
+ export const renameTagHandler: WriteHandlerDef = createRenameTagHandler();
package/src/tags/index.ts CHANGED
@@ -20,11 +20,17 @@ export {
20
20
  createRemoveTagHandler,
21
21
  removeTagHandler,
22
22
  } from "./handlers/remove-tag.write";
23
+ export {
24
+ createRenameTagHandler,
25
+ renameTagHandler,
26
+ } from "./handlers/rename-tag.write";
23
27
  export {
24
28
  type AssignTagPayload,
25
29
  assignTagPayloadSchema,
26
30
  type CreateTagPayload,
27
31
  createTagPayloadSchema,
28
32
  type RemoveTagPayload,
33
+ type RenameTagPayload,
29
34
  removeTagPayloadSchema,
35
+ renameTagPayloadSchema,
30
36
  } from "./schemas";
@@ -6,6 +6,15 @@ export const createTagPayloadSchema = z.object({
6
6
  });
7
7
  export type CreateTagPayload = z.infer<typeof createTagPayloadSchema>;
8
8
 
9
+ // rename-tag — id + the version the client read (optimistic lock, mirrors
10
+ // tenant:update) + the new name. The executor merges shallowly, so color stays.
11
+ export const renameTagPayloadSchema = z.object({
12
+ id: z.string().min(1),
13
+ version: z.number().int().nonnegative(),
14
+ name: z.string().min(1).max(64),
15
+ });
16
+ export type RenameTagPayload = z.infer<typeof renameTagPayloadSchema>;
17
+
9
18
  // assign + remove share the (tag, entity) reference shape.
10
19
  const entityTagRef = {
11
20
  tagId: z.string().min(1).max(64),
@@ -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)