@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 +6 -6
- package/src/tags/__tests__/feature.test.ts +34 -4
- package/src/tags/__tests__/tags.integration.test.ts +62 -0
- package/src/tags/constants.ts +1 -0
- package/src/tags/feature.ts +5 -3
- package/src/tags/handlers/create-tag.write.ts +1 -2
- package/src/tags/handlers/rename-tag.write.ts +26 -0
- package/src/tags/index.ts +6 -0
- package/src/tags/schemas.ts +9 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +201 -0
- package/src/user-data-rights/constants.ts +7 -0
- package/src/user-data-rights/feature.ts +24 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +8 -0
- package/src/user-data-rights/lib/resolve-tenant-model.ts +34 -0
- package/src/user-data-rights/run-forget-cleanup.ts +37 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
89
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
92
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
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 {
|
|
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,
|
|
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(
|
|
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");
|
package/src/tags/constants.ts
CHANGED
|
@@ -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;
|
package/src/tags/feature.ts
CHANGED
|
@@ -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
|
-
//
|
|
16
|
-
// Deferred:
|
|
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
|
|
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";
|
package/src/tags/schemas.ts
CHANGED
|
@@ -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(
|
|
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)
|