@cosmicdrift/kumiko-bundled-features 0.89.0 → 0.90.2

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.89.0",
3
+ "version": "0.90.2",
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>",
@@ -32,6 +32,9 @@
32
32
  "./custom-fields/web": "./src/custom-fields/web/index.ts",
33
33
  "./tags": "./src/tags/index.ts",
34
34
  "./tags/web": "./src/tags/web/index.ts",
35
+ "./folders": "./src/folders/index.ts",
36
+ "./folders/web": "./src/folders/web/index.ts",
37
+ "./folders-user-data": "./src/folders-user-data/index.ts",
35
38
  "./billing-foundation": "./src/billing-foundation/index.ts",
36
39
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
37
40
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
@@ -86,11 +89,11 @@
86
89
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
87
90
  },
88
91
  "dependencies": {
89
- "@cosmicdrift/kumiko-dispatcher-live": "0.89.0",
90
- "@cosmicdrift/kumiko-framework": "0.89.0",
91
- "@cosmicdrift/kumiko-headless": "0.89.0",
92
- "@cosmicdrift/kumiko-renderer": "0.89.0",
93
- "@cosmicdrift/kumiko-renderer-web": "0.89.0",
92
+ "@cosmicdrift/kumiko-dispatcher-live": "0.90.2",
93
+ "@cosmicdrift/kumiko-framework": "0.90.2",
94
+ "@cosmicdrift/kumiko-headless": "0.90.2",
95
+ "@cosmicdrift/kumiko-renderer": "0.90.2",
96
+ "@cosmicdrift/kumiko-renderer-web": "0.90.2",
94
97
  "@mollie/api-client": "^4.5.0",
95
98
  "@node-rs/argon2": "^2.0.2",
96
99
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { folderAssignmentAggregateId } from "../aggregate-id";
3
+
4
+ // Drift-Pin-Tests — these values are cross-boot contracts. If they go red:
5
+ // stop, think, revert. aggregate-id.ts names this file.
6
+
7
+ const TENANT = "00000000-0000-0000-0000-000000000001";
8
+
9
+ describe("folders drift pins", () => {
10
+ test("folder-assignment aggregate-id namespace is stable across boots", () => {
11
+ // FOLDER_ASSIGNMENT_NAMESPACE is in stone — changing it re-keys every
12
+ // existing assignment stream and breaks event-replay. If this fails: revert
13
+ // the namespace, do not adjust the expected values.
14
+ const base = folderAssignmentAggregateId(TENANT, "credit", "c-1");
15
+
16
+ expect(base).toBe(folderAssignmentAggregateId(TENANT, "credit", "c-1")); // deterministic
17
+ // folderId is NOT part of the key (single-membership): every OTHER tuple
18
+ // component is, with no collisions across the axes.
19
+ expect(base).not.toBe(
20
+ folderAssignmentAggregateId("11111111-1111-1111-1111-111111111111", "credit", "c-1"),
21
+ );
22
+ expect(base).not.toBe(folderAssignmentAggregateId(TENANT, "invoice", "c-1"));
23
+ expect(base).not.toBe(folderAssignmentAggregateId(TENANT, "credit", "c-2"));
24
+
25
+ // Pinned actual outputs — the drift-detector for the namespace constant.
26
+ expect(base).toBe("a57e2be6-1831-5d08-9e83-2de64578de6d");
27
+ expect(
28
+ folderAssignmentAggregateId("11111111-1111-1111-1111-111111111111", "credit", "c-1"),
29
+ ).toBe("d2153ce9-5ffa-544e-b850-a4a7fefa7d89");
30
+ expect(folderAssignmentAggregateId(TENANT, "invoice", "c-1")).toBe(
31
+ "7c849492-a806-5e73-a824-e90dfc761e3a",
32
+ );
33
+ expect(folderAssignmentAggregateId(TENANT, "credit", "c-2")).toBe(
34
+ "d9ad71bc-78db-5588-9f61-b77f35229ed3",
35
+ );
36
+ });
37
+
38
+ test("aggregate-id format is a valid uuid", () => {
39
+ expect(folderAssignmentAggregateId(TENANT, "credit", "c-1")).toMatch(
40
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
41
+ );
42
+ });
43
+ });
@@ -0,0 +1,168 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DEFAULT_FOLDER_ROLES } from "../constants";
3
+ import { createFoldersFeature } from "../feature";
4
+ import { clearFolderPayloadSchema, setFolderPayloadSchema } from "../schemas";
5
+
6
+ // Unit tests: feature-shape, role-options, schema-validation. The ES-loop
7
+ // behaviour (single-membership set/move/clear, projection, tenant-isolation,
8
+ // read composition) needs a real stack → folders.integration.test.ts.
9
+
10
+ function writeAccess(
11
+ feature: ReturnType<typeof createFoldersFeature>,
12
+ nameMatch: string,
13
+ ): readonly string[] {
14
+ const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
15
+ if (!entry) throw new Error(`handler ${nameMatch} not registered`);
16
+ const access = entry[1].access;
17
+ if (!access || !("roles" in access)) throw new Error(`handler ${nameMatch} has no roles`);
18
+ return access.roles;
19
+ }
20
+
21
+ function queryAccess(
22
+ feature: ReturnType<typeof createFoldersFeature>,
23
+ nameMatch: string,
24
+ ): readonly string[] {
25
+ const entry = Object.entries(feature.queryHandlers).find(([qn]) => qn.includes(nameMatch));
26
+ if (!entry) throw new Error(`query ${nameMatch} not registered`);
27
+ const access = entry[1].access;
28
+ if (!access || !("roles" in access)) throw new Error(`query ${nameMatch} has no roles`);
29
+ return access.roles;
30
+ }
31
+
32
+ function rawWriteAccess(
33
+ feature: ReturnType<typeof createFoldersFeature>,
34
+ nameMatch: string,
35
+ ): unknown {
36
+ const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
37
+ if (!entry) throw new Error(`handler ${nameMatch} not registered`);
38
+ return entry[1].access;
39
+ }
40
+
41
+ describe("createFoldersFeature shape", () => {
42
+ test("registers folder + folder-assignment entities, 5 write-handlers, 3 query-handlers", () => {
43
+ const feature = createFoldersFeature();
44
+
45
+ expect(Object.keys(feature.entities ?? {})).toEqual(
46
+ expect.arrayContaining(["folder", "folder-assignment"]),
47
+ );
48
+
49
+ expect(Object.keys(feature.writeHandlers)).toEqual(
50
+ expect.arrayContaining([
51
+ expect.stringMatching(/folder:create/),
52
+ expect.stringMatching(/folder:update/),
53
+ expect.stringMatching(/folder:delete/),
54
+ expect.stringMatching(/set-folder/),
55
+ expect.stringMatching(/clear-folder/),
56
+ ]),
57
+ );
58
+ expect(Object.keys(feature.writeHandlers)).toHaveLength(5);
59
+
60
+ expect(Object.keys(feature.queryHandlers)).toEqual(
61
+ expect.arrayContaining([
62
+ expect.stringMatching(/folder:list/),
63
+ expect.stringMatching(/folder:detail/),
64
+ expect.stringMatching(/folder-assignment:list/),
65
+ ]),
66
+ );
67
+ expect(Object.keys(feature.queryHandlers)).toHaveLength(3);
68
+ });
69
+ });
70
+
71
+ describe("createFoldersFeature access-options", () => {
72
+ test("without options: singleton with default roles on every path", () => {
73
+ const feature = createFoldersFeature();
74
+ expect(feature).toBe(createFoldersFeature());
75
+ for (const path of [
76
+ "folder:create",
77
+ "folder:update",
78
+ "folder:delete",
79
+ "set-folder",
80
+ "clear-folder",
81
+ ]) {
82
+ expect(writeAccess(feature, path)).toEqual([...DEFAULT_FOLDER_ROLES]);
83
+ }
84
+ expect(queryAccess(feature, "folder:list")).toEqual([...DEFAULT_FOLDER_ROLES]);
85
+ expect(queryAccess(feature, "folder-assignment:list")).toEqual([...DEFAULT_FOLDER_ROLES]);
86
+ });
87
+
88
+ test("roles option overrides every write- and query-path", () => {
89
+ const feature = createFoldersFeature({ roles: ["Admin", "Editor"] });
90
+ expect(writeAccess(feature, "set-folder")).toEqual(["Admin", "Editor"]);
91
+ expect(writeAccess(feature, "folder:create")).toEqual(["Admin", "Editor"]);
92
+ expect(queryAccess(feature, "folder:list")).toEqual(["Admin", "Editor"]);
93
+ });
94
+
95
+ test("access:{openToAll} applies to every write- and query-path", () => {
96
+ const feature = createFoldersFeature({ access: { openToAll: true } });
97
+ for (const path of [
98
+ "folder:create",
99
+ "folder:update",
100
+ "folder:delete",
101
+ "set-folder",
102
+ "clear-folder",
103
+ ]) {
104
+ expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
105
+ }
106
+ });
107
+
108
+ test("access takes precedence over the roles shorthand", () => {
109
+ const feature = createFoldersFeature({ access: { openToAll: true }, roles: ["Admin"] });
110
+ expect(rawWriteAccess(feature, "set-folder")).toEqual({ openToAll: true });
111
+ });
112
+ });
113
+
114
+ describe("createFoldersFeature toggleable-option (tier-gating)", () => {
115
+ test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
116
+ expect(createFoldersFeature().toggleableDefault).toBeUndefined();
117
+ });
118
+
119
+ test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
120
+ const feature = createFoldersFeature({
121
+ access: { openToAll: true },
122
+ toggleable: { default: false },
123
+ });
124
+ expect(feature.toggleableDefault).toBe(false);
125
+ });
126
+
127
+ test("toggleable alone (no access/roles) builds a fresh, non-singleton feature", () => {
128
+ const feature = createFoldersFeature({ toggleable: { default: false } });
129
+ expect(feature).not.toBe(createFoldersFeature());
130
+ expect(writeAccess(feature, "folder:create")).toEqual([...DEFAULT_FOLDER_ROLES]);
131
+ });
132
+ });
133
+
134
+ describe("setFolderPayloadSchema", () => {
135
+ const valid = { folderId: "f-1", entityType: "credit", entityId: "c-1" };
136
+
137
+ test("accepts a full (folder, entity) reference", () => {
138
+ expect(setFolderPayloadSchema.safeParse(valid).success).toBe(true);
139
+ });
140
+
141
+ test("rejects missing folderId", () => {
142
+ expect(
143
+ setFolderPayloadSchema.safeParse({ entityType: "credit", entityId: "c-1" }).success,
144
+ ).toBe(false);
145
+ });
146
+
147
+ test("rejects empty folderId", () => {
148
+ expect(setFolderPayloadSchema.safeParse({ ...valid, folderId: "" }).success).toBe(false);
149
+ });
150
+
151
+ test("rejects entityId over 128 chars", () => {
152
+ expect(setFolderPayloadSchema.safeParse({ ...valid, entityId: "x".repeat(129) }).success).toBe(
153
+ false,
154
+ );
155
+ });
156
+ });
157
+
158
+ describe("clearFolderPayloadSchema", () => {
159
+ test("accepts an entity reference (no folderId)", () => {
160
+ expect(
161
+ clearFolderPayloadSchema.safeParse({ entityType: "credit", entityId: "c-1" }).success,
162
+ ).toBe(true);
163
+ });
164
+
165
+ test("rejects missing entityId", () => {
166
+ expect(clearFolderPayloadSchema.safeParse({ entityType: "credit" }).success).toBe(false);
167
+ });
168
+ });
@@ -0,0 +1,290 @@
1
+ // Full-stack integration for the folders bundle. Drives create → set → list →
2
+ // move → clear through the real dispatcher + entity-projection + DB. Proves the
3
+ // architecture end-to-end WITHOUT any host wiring (folders are host-agnostic —
4
+ // the host is just the entityType/entityId strings on the assignment):
5
+ // - create-folder projects into read_folders (incl. nested parentId)
6
+ // - set-folder projects a SINGLE membership row keyed by (entityType, entityId)
7
+ // - re-setting to a different folder MOVES the entity (still one row) — the
8
+ // defining difference from tags' many-to-many
9
+ // - clear-folder unfiles; set → clear → set resurrects the deterministic stream
10
+ // - referential integrity (unknown folderId rejected) + multi-tenant isolation
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
+ import {
16
+ createTestUser,
17
+ setupTestStack,
18
+ type TestStack,
19
+ unsafeCreateEntityTable,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { FoldersHandlers, FoldersQueries } from "../constants";
22
+ import { folderAssignmentEntity, folderEntity } from "../entity";
23
+ import { createFoldersFeature } from "../feature";
24
+
25
+ const foldersFeature = createFoldersFeature();
26
+
27
+ let stack: TestStack;
28
+
29
+ beforeAll(async () => {
30
+ stack = await setupTestStack({ features: [foldersFeature] });
31
+ await unsafeCreateEntityTable(stack.db, folderEntity);
32
+ await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
33
+ await createEventsTable(stack.db);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await stack.cleanup();
38
+ });
39
+
40
+ beforeEach(async () => {
41
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
42
+ await asRawClient(stack.db).unsafe("DELETE FROM read_folders");
43
+ await asRawClient(stack.db).unsafe("DELETE FROM read_folder_assignments");
44
+ });
45
+
46
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
47
+ const otherTenant = createTestUser({
48
+ roles: ["TenantAdmin"],
49
+ tenantId: "00000000-0000-4000-8000-0000000000aa",
50
+ });
51
+
52
+ async function createFolder(name: string, parentId?: string, user = admin): Promise<string> {
53
+ const payload = parentId === undefined ? { name } : { name, parentId };
54
+ const folder = await stack.http.writeOk<{ id: string }>(
55
+ FoldersHandlers.createFolder,
56
+ payload,
57
+ user,
58
+ );
59
+ return folder.id;
60
+ }
61
+
62
+ async function setFolder(folderId: string, entityId: string, user = admin) {
63
+ return stack.http.writeOk(
64
+ FoldersHandlers.setFolder,
65
+ { folderId, entityType: "credit", entityId },
66
+ user,
67
+ );
68
+ }
69
+
70
+ async function clearFolder(entityId: string, user = admin) {
71
+ return stack.http.writeOk(FoldersHandlers.clearFolder, { entityType: "credit", entityId }, user);
72
+ }
73
+
74
+ async function listFolders(user = admin): Promise<Array<Record<string, unknown>>> {
75
+ const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
76
+ FoldersQueries.folderList,
77
+ {},
78
+ user,
79
+ );
80
+ return res.rows;
81
+ }
82
+
83
+ async function assignmentsOf(
84
+ entityId: string,
85
+ user = admin,
86
+ ): Promise<Array<Record<string, unknown>>> {
87
+ const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
88
+ FoldersQueries.assignmentList,
89
+ { filter: { field: "entityId", op: "eq", value: entityId } },
90
+ user,
91
+ );
92
+ return res.rows;
93
+ }
94
+
95
+ // Active assignments only — clear soft-deletes (the stream is kept so a re-set
96
+ // can restore it), so isDeleted=true rows must not count as filed.
97
+ async function countAssignments(tenantId: string): Promise<number> {
98
+ const rows = await asRawClient(stack.db).unsafe(
99
+ "SELECT count(*)::int AS n FROM read_folder_assignments WHERE tenant_id = $1 AND is_deleted = FALSE",
100
+ [tenantId],
101
+ );
102
+ return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
103
+ }
104
+
105
+ describe("folders integration — catalog (tree)", () => {
106
+ test("create-folder lands in read_folders", async () => {
107
+ const id = await createFolder("Immobilie Berlin");
108
+ const folders = await listFolders();
109
+ expect(folders).toHaveLength(1);
110
+ expect(folders[0]?.["id"]).toBe(id);
111
+ expect(folders[0]?.["name"]).toBe("Immobilie Berlin");
112
+ expect(folders[0]?.["parentId"]).toBeNull();
113
+ });
114
+
115
+ test("create-folder with parentId nests under the parent", async () => {
116
+ const root = await createFolder("Immobilie Berlin");
117
+ const child = await createFolder("Person Müller", root);
118
+ const folders = await listFolders();
119
+ expect(folders).toHaveLength(2);
120
+ const childRow = folders.find((f) => f["id"] === child);
121
+ expect(childRow?.["parentId"]).toBe(root);
122
+ });
123
+ });
124
+
125
+ describe("folders integration — single-membership set / move / clear", () => {
126
+ test("set-folder files an entity; assignment is queryable", async () => {
127
+ const f = await createFolder("Gruppe A");
128
+ await setFolder(f, "credit-1");
129
+
130
+ const rows = await assignmentsOf("credit-1");
131
+ expect(rows).toHaveLength(1);
132
+ expect(rows[0]?.["folderId"]).toBe(f);
133
+ expect(rows[0]?.["entityType"]).toBe("credit");
134
+ });
135
+
136
+ test("re-setting to a different folder MOVES (still exactly one row)", async () => {
137
+ const a = await createFolder("A");
138
+ const b = await createFolder("B");
139
+ await setFolder(a, "credit-1");
140
+ expect(await countAssignments(admin.tenantId)).toBe(1);
141
+
142
+ await setFolder(b, "credit-1"); // MOVE, not a second assignment
143
+ expect(await countAssignments(admin.tenantId)).toBe(1);
144
+ const rows = await assignmentsOf("credit-1");
145
+ expect(rows).toHaveLength(1);
146
+ expect(rows[0]?.["folderId"]).toBe(b);
147
+ });
148
+
149
+ test("set the same folder twice is an idempotent no-op (one row)", async () => {
150
+ const f = await createFolder("dup");
151
+ await setFolder(f, "credit-2");
152
+ await setFolder(f, "credit-2");
153
+ expect(await countAssignments(admin.tenantId)).toBe(1);
154
+ });
155
+
156
+ test("clear-folder unfiles the entity", async () => {
157
+ const f = await createFolder("temp");
158
+ await setFolder(f, "credit-3");
159
+ expect(await countAssignments(admin.tenantId)).toBe(1);
160
+
161
+ await clearFolder("credit-3");
162
+ expect(await countAssignments(admin.tenantId)).toBe(0);
163
+ expect(await assignmentsOf("credit-3")).toHaveLength(0);
164
+ });
165
+
166
+ test("clearing a never-filed entity succeeds (idempotent end-state)", async () => {
167
+ await clearFolder("credit-never");
168
+ expect(await countAssignments(admin.tenantId)).toBe(0);
169
+ });
170
+
171
+ test("set → clear → set resurrects the same deterministic stream", async () => {
172
+ const f = await createFolder("recurring");
173
+ await setFolder(f, "credit-r");
174
+ await clearFolder("credit-r");
175
+ expect(await countAssignments(admin.tenantId)).toBe(0);
176
+
177
+ await setFolder(f, "credit-r"); // restore, not 409
178
+ expect(await countAssignments(admin.tenantId)).toBe(1);
179
+ expect((await assignmentsOf("credit-r"))[0]?.["folderId"]).toBe(f);
180
+ });
181
+
182
+ test("set → clear → set into a DIFFERENT folder lands in the new folder", async () => {
183
+ const a = await createFolder("A");
184
+ const b = await createFolder("B");
185
+ await setFolder(a, "credit-x");
186
+ await clearFolder("credit-x");
187
+ await setFolder(b, "credit-x"); // restore + update folderId to b
188
+ const rows = await assignmentsOf("credit-x");
189
+ expect(rows).toHaveLength(1);
190
+ expect(rows[0]?.["folderId"]).toBe(b);
191
+ });
192
+ });
193
+
194
+ describe("folders integration — rename via folder:update", () => {
195
+ test("update renames, optimistic-locked, bumps version", async () => {
196
+ const id = await createFolder("Alt");
197
+ const before = (await listFolders()).find((f) => f["id"] === id);
198
+ const version = before?.["version"] as number;
199
+ expect(typeof version).toBe("number");
200
+
201
+ await stack.http.writeOk(
202
+ FoldersHandlers.updateFolder,
203
+ { id, version, changes: { name: "Neu" } },
204
+ admin,
205
+ );
206
+ const after = (await listFolders()).find((f) => f["id"] === id);
207
+ expect(after?.["name"]).toBe("Neu");
208
+ expect(after?.["version"]).toBe(version + 1);
209
+ });
210
+ });
211
+
212
+ describe("folders integration — referential integrity", () => {
213
+ test("set-folder with an unknown folderId is rejected (no dangling assignment)", async () => {
214
+ const err = await stack.http.writeErr(
215
+ FoldersHandlers.setFolder,
216
+ { folderId: "00000000-0000-4000-8000-00000000dead", entityType: "credit", entityId: "c-y" },
217
+ admin,
218
+ );
219
+ expect(err.httpStatus).toBe(404);
220
+ expect(await countAssignments(admin.tenantId)).toBe(0);
221
+ });
222
+ });
223
+
224
+ describe("folders integration — multi-tenant isolation", () => {
225
+ test("tenant B sees neither tenant A's folders nor assignments", async () => {
226
+ const f = await createFolder("A-only", undefined, admin);
227
+ await setFolder(f, "credit-8", admin);
228
+
229
+ expect(await listFolders(otherTenant)).toHaveLength(0);
230
+ expect(await assignmentsOf("credit-8", otherTenant)).toHaveLength(0);
231
+
232
+ expect(await listFolders(admin)).toHaveLength(1);
233
+ expect(await countAssignments(admin.tenantId)).toBe(1);
234
+ expect(await countAssignments(otherTenant.tenantId)).toBe(0);
235
+ });
236
+ });
237
+
238
+ // The access option must reach the runtime: a host mounting folders with
239
+ // openToAll lets a user WITHOUT any default folder role file freely (the exact
240
+ // failure that bit money-horse, whose signup users carry "Admin").
241
+ describe("folders integration — openToAll access model", () => {
242
+ let openStack: TestStack;
243
+ const unprivileged = createTestUser({ roles: ["Viewer"] });
244
+
245
+ beforeAll(async () => {
246
+ openStack = await setupTestStack({
247
+ features: [createFoldersFeature({ access: { openToAll: true } })],
248
+ });
249
+ await unsafeCreateEntityTable(openStack.db, folderEntity);
250
+ await unsafeCreateEntityTable(openStack.db, folderAssignmentEntity);
251
+ await createEventsTable(openStack.db);
252
+ });
253
+
254
+ afterAll(async () => {
255
+ await openStack.cleanup();
256
+ });
257
+
258
+ test("a non-folder-role user can create, set, list and clear", async () => {
259
+ const folder = await openStack.http.writeOk<{ id: string }>(
260
+ FoldersHandlers.createFolder,
261
+ { name: "Ordner A" },
262
+ unprivileged,
263
+ );
264
+ await openStack.http.writeOk(
265
+ FoldersHandlers.setFolder,
266
+ { folderId: folder.id, entityType: "credit", entityId: "c-1" },
267
+ unprivileged,
268
+ );
269
+ const folders = await openStack.http.queryOk<{ rows: unknown[] }>(
270
+ FoldersQueries.folderList,
271
+ {},
272
+ unprivileged,
273
+ );
274
+ expect(folders.rows).toHaveLength(1);
275
+ await openStack.http.writeOk(
276
+ FoldersHandlers.clearFolder,
277
+ { entityType: "credit", entityId: "c-1" },
278
+ unprivileged,
279
+ );
280
+ });
281
+
282
+ test("the SAME user is denied on a default-role-mounted feature", async () => {
283
+ const denied = await stack.http.writeErr(
284
+ FoldersHandlers.createFolder,
285
+ { name: "nope" },
286
+ unprivileged,
287
+ );
288
+ expect(denied.httpStatus).toBe(403);
289
+ });
290
+ });
@@ -0,0 +1,23 @@
1
+ import { v5 as uuidv5 } from "uuid";
2
+
3
+ // Fixed UUID namespace for folder-assignment aggregate-id derivation. Frozen:
4
+ // changing it would re-key every existing assignment stream → broken replay.
5
+ // Drift-pinned in __tests__.
6
+ const FOLDER_ASSIGNMENT_NAMESPACE = "b2e1f4a7-3c9d-4e8b-a1f6-5d2c8b3e7a9f";
7
+
8
+ /**
9
+ * Deterministic aggregate-id for a folder-assignment from the tuple
10
+ * (tenantId, entityType, entityId) — note: NO folderId. Exactly one aggregate
11
+ * exists per (tenant, entity), so an entity belongs to at most one folder;
12
+ * re-assigning to a different folder updates the same stream (move) instead of
13
+ * creating a second row. This is the single-membership counterpart to tags'
14
+ * many-to-many aggregate-id (which includes the tagId).
15
+ */
16
+ // @wrapper-known uuid-domain
17
+ export function folderAssignmentAggregateId(
18
+ tenantId: string,
19
+ entityType: string,
20
+ entityId: string,
21
+ ): string {
22
+ return uuidv5(`${tenantId}|${entityType}|${entityId}`, FOLDER_ASSIGNMENT_NAMESPACE);
23
+ }
@@ -0,0 +1,40 @@
1
+ // @runtime client
2
+ // folders bundle constants — feature-name + qualified handler/query names.
3
+ //
4
+ // Spec: kumiko-platform/docs/plans/folders-feature.md
5
+
6
+ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ export const FOLDERS_FEATURE_NAME = "folders";
9
+
10
+ // Registry name for the drop-in <FolderSection> component. Apps reference it in a
11
+ // screen schema via `component: { react: { __component: FOLDER_SECTION_EXTENSION_NAME } }`
12
+ // after mounting foldersClient(); the component is also importable directly for
13
+ // standalone use from `@cosmicdrift/kumiko-bundled-features/folders/web`.
14
+ export const FOLDER_SECTION_EXTENSION_NAME = "FolderSection";
15
+
16
+ // Qualified handler names (QN format: scope:type:name). The folder catalog uses
17
+ // generic defineEntity*Handler (create/update/delete) → "folder:<verb>" qualified
18
+ // to "folders:write:folder:<verb>". update covers rename (and, later, reparent).
19
+ // set-folder/clear-folder are the hand-written single-membership handlers.
20
+ export const FoldersHandlers = {
21
+ createFolder: "folders:write:folder:create",
22
+ updateFolder: "folders:write:folder:update",
23
+ deleteFolder: "folders:write:folder:delete",
24
+ setFolder: "folders:write:set-folder",
25
+ clearFolder: "folders:write:clear-folder",
26
+ } as const;
27
+
28
+ export const FoldersQueries = {
29
+ folderList: "folders:query:folder:list",
30
+ folderDetail: "folders:query:folder:detail",
31
+ assignmentList: "folders:query:folder-assignment:list",
32
+ } as const;
33
+
34
+ // Default RBAC for every folder write/read path. Like tags, folders are a
35
+ // low-sensitivity organisation tool, so both tenant roles may use them. Apps
36
+ // with their own role vocabulary override via createFoldersFeature({ roles }),
37
+ // or adopt the host's access model with createFoldersFeature({ access }).
38
+ export const DEFAULT_FOLDER_ROLES = ["TenantAdmin", "TenantMember"] as const;
39
+
40
+ export const DEFAULT_FOLDER_ACCESS: AccessRule = { roles: DEFAULT_FOLDER_ROLES };
@@ -0,0 +1,42 @@
1
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // folder — per-tenant hierarchical catalog. Event-sourced (create/update/delete
4
+ // via the standard executor); the framework projects `read_folders` from its own
5
+ // CRUD events. `parentId` null → root folder; otherwise it points at another
6
+ // folder's id, forming a tree. tenantId is a base column set by the framework →
7
+ // tenant-scoped.
8
+ export const folderEntity = createEntity({
9
+ table: "read_folders",
10
+ fields: {
11
+ name: createTextField({ required: true, maxLength: 64 }),
12
+ // Parent folder id, or absent for a root folder. No FK (event-sourced); a
13
+ // dangling parentId renders the folder at root — folders-view guards cycles.
14
+ parentId: createTextField({ maxLength: 64 }),
15
+ },
16
+ });
17
+
18
+ // folder-assignment — host-agnostic membership row keyed by (entityType, entityId).
19
+ // Unlike tag-assignment this is SINGLE-membership: the aggregate-id is derived from
20
+ // (tenantId, entityType, entityId) WITHOUT folderId (see aggregate-id.ts), so an
21
+ // entity has exactly one assignment row and "put into folder X" updates folderId
22
+ // (move) instead of creating a second row.
23
+ //
24
+ // softDelete is required, NOT cosmetic: the aggregate-id is deterministic, so
25
+ // clearing an assignment leaves a (created+deleted) event stream under that id.
26
+ // A hard delete would force the next set to create() at version 0 onto that
27
+ // existing stream → version_conflict. With softDelete the set handler resurrects
28
+ // the stream via restore(); the list query filters isDeleted.
29
+ //
30
+ // Cross-entity views compose in the read-layer (no JOIN):
31
+ // - folder of an entity → list assignments filter { field: "entityId", op: "eq" }
32
+ // - entities in a folder → list assignments filter { field: "folderId", op: "eq" }
33
+ export const folderAssignmentEntity = createEntity({
34
+ table: "read_folder_assignments",
35
+ softDelete: true,
36
+ fields: {
37
+ folderId: createTextField({ required: true, maxLength: 64 }),
38
+ entityType: createTextField({ required: true, maxLength: 64 }),
39
+ // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
40
+ entityId: createTextField({ required: true, maxLength: 128 }),
41
+ },
42
+ });
@@ -0,0 +1,11 @@
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { folderAssignmentEntity, folderEntity } from "./entity";
3
+
4
+ // Shared executors for the folder + folder-assignment write-handlers.
5
+ // createEntityExecutor is side-effect-free; instantiating once keeps the
6
+ // table+executor pair in one place instead of rebuilding it per handler module.
7
+ export const { executor: folderExecutor } = createEntityExecutor("folder", folderEntity);
8
+ export const { executor: folderAssignmentExecutor } = createEntityExecutor(
9
+ "folder-assignment",
10
+ folderAssignmentEntity,
11
+ );