@cosmicdrift/kumiko-bundled-features 0.40.1 → 0.41.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/custom-fields/__tests__/feature.test.ts +44 -0
- package/src/custom-fields/constants.ts +9 -0
- package/src/custom-fields/feature.ts +69 -27
- package/src/custom-fields/handlers/clear-custom-field.write.ts +10 -1
- package/src/custom-fields/handlers/set-custom-field.write.ts +11 -1
- package/src/custom-fields/index.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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>",
|
|
@@ -76,11 +76,11 @@
|
|
|
76
76
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
77
77
|
},
|
|
78
78
|
"dependencies": {
|
|
79
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
81
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
82
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
83
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
79
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.40.1",
|
|
80
|
+
"@cosmicdrift/kumiko-framework": "0.40.1",
|
|
81
|
+
"@cosmicdrift/kumiko-headless": "0.40.1",
|
|
82
|
+
"@cosmicdrift/kumiko-renderer": "0.40.1",
|
|
83
|
+
"@cosmicdrift/kumiko-renderer-web": "0.40.1",
|
|
84
84
|
"@mollie/api-client": "^4.5.0",
|
|
85
85
|
"@node-rs/argon2": "^2.0.2",
|
|
86
86
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -123,3 +123,47 @@ describe("fieldDefinitionAggregateId determinism", () => {
|
|
|
123
123
|
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
// Role-Naming-Drift (Wave J): die CustomFieldsFormSection dispatcht die
|
|
128
|
+
// Bundle-QNs hart — Apps mit eigenem Rollen-Vokabular (publicstatus:
|
|
129
|
+
// "Admin"/"Editor") müssen die Access-Rollen der Value-Writes + des
|
|
130
|
+
// fieldDefinition-List-Queries überschreiben können, sonst ist jeder
|
|
131
|
+
// Save/Load für App-User access_denied.
|
|
132
|
+
describe("createCustomFieldsFeature access-options", () => {
|
|
133
|
+
function writeAccess(
|
|
134
|
+
feature: ReturnType<typeof createCustomFieldsFeature>,
|
|
135
|
+
nameMatch: string,
|
|
136
|
+
): readonly string[] {
|
|
137
|
+
const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
|
|
138
|
+
if (!entry) throw new Error(`handler ${nameMatch} not registered`);
|
|
139
|
+
const access = entry[1].access;
|
|
140
|
+
if (!access || !("roles" in access)) throw new Error(`handler ${nameMatch} has no roles`);
|
|
141
|
+
return access.roles;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
test("ohne Optionen: Singleton mit Default-Rollen", () => {
|
|
145
|
+
const feature = createCustomFieldsFeature();
|
|
146
|
+
expect(feature).toBe(createCustomFieldsFeature());
|
|
147
|
+
expect(writeAccess(feature, "set-custom-field")).toEqual(["TenantAdmin", "TenantMember"]);
|
|
148
|
+
expect(writeAccess(feature, "clear-custom-field")).toEqual(["TenantAdmin", "TenantMember"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("valueWriteRoles überschreibt set- UND clear-custom-field", () => {
|
|
152
|
+
const feature = createCustomFieldsFeature({ valueWriteRoles: ["Admin", "Editor"] });
|
|
153
|
+
expect(writeAccess(feature, "set-custom-field")).toEqual(["Admin", "Editor"]);
|
|
154
|
+
expect(writeAccess(feature, "clear-custom-field")).toEqual(["Admin", "Editor"]);
|
|
155
|
+
// Definition-CRUD bleibt unberührt — dafür existieren App-Wrapper.
|
|
156
|
+
expect(writeAccess(feature, "define-tenant-field")).toEqual(["TenantAdmin"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("fieldDefinitionListRoles überschreibt den List-Query (FormSection-Lade-Pfad)", () => {
|
|
160
|
+
const feature = createCustomFieldsFeature({ fieldDefinitionListRoles: ["Admin", "Editor"] });
|
|
161
|
+
const entry = Object.entries(feature.queryHandlers).find(([qn]) =>
|
|
162
|
+
qn.includes("field-definition:list"),
|
|
163
|
+
);
|
|
164
|
+
if (!entry) throw new Error("field-definition:list not registered");
|
|
165
|
+
const access = entry[1].access;
|
|
166
|
+
if (!access || !("roles" in access)) throw new Error("list-query has no roles");
|
|
167
|
+
expect(access.roles).toEqual(["Admin", "Editor"]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -50,6 +50,15 @@ export const CUSTOM_FIELD_CLEARED_EVENT = "custom-field-cleared";
|
|
|
50
50
|
// dass eine host-entity Custom-Fields haben darf.
|
|
51
51
|
export const CUSTOM_FIELDS_EXTENSION = "customFields";
|
|
52
52
|
|
|
53
|
+
// Default-RBAC der Value-Write- und List-Pfade. Apps mit eigenem Rollen-
|
|
54
|
+
// Vokabular (publicstatus: "Admin"/"Editor" statt "TenantAdmin"/
|
|
55
|
+
// "TenantMember") überschreiben via createCustomFieldsFeature({
|
|
56
|
+
// valueWriteRoles, fieldDefinitionListRoles }) — sonst sind die hart
|
|
57
|
+
// verdrahteten Bundle-QNs, die die CustomFieldsFormSection dispatcht,
|
|
58
|
+
// für jeden App-User access_denied.
|
|
59
|
+
export const DEFAULT_VALUE_WRITE_ROLES = ["TenantAdmin", "TenantMember"] as const;
|
|
60
|
+
export const DEFAULT_FIELD_DEFINITION_LIST_ROLES = ["TenantAdmin"] as const;
|
|
61
|
+
|
|
53
62
|
// Field-type union — identisch zu Stammfeld-Field-Type-System (Spec Z.59-73:
|
|
54
63
|
// `Identisch zu Entity-Feld-Typen`). Builder-Reuse-Promise: was `r.field.X()`
|
|
55
64
|
// kann, kann eine Custom-Field-Definition auch.
|
|
@@ -50,11 +50,15 @@ import {
|
|
|
50
50
|
CUSTOM_FIELD_SET_EVENT,
|
|
51
51
|
CUSTOM_FIELDS_EXTENSION,
|
|
52
52
|
CUSTOM_FIELDS_FEATURE_NAME,
|
|
53
|
+
DEFAULT_FIELD_DEFINITION_LIST_ROLES,
|
|
53
54
|
FIELD_DEFINITION_DELETED_EVENT,
|
|
54
55
|
} from "./constants";
|
|
55
56
|
import { fieldDefinitionEntity } from "./entity";
|
|
56
57
|
import { customFieldClearedSchema, customFieldSetSchema } from "./events";
|
|
57
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
clearCustomFieldHandler,
|
|
60
|
+
createClearCustomFieldHandler,
|
|
61
|
+
} from "./handlers/clear-custom-field.write";
|
|
58
62
|
import { defineSystemFieldHandler } from "./handlers/define-system-field.write";
|
|
59
63
|
import {
|
|
60
64
|
createDefineTenantFieldHandler,
|
|
@@ -62,11 +66,12 @@ import {
|
|
|
62
66
|
} from "./handlers/define-tenant-field.write";
|
|
63
67
|
import { deleteSystemFieldHandler } from "./handlers/delete-system-field.write";
|
|
64
68
|
import { deleteTenantFieldHandler } from "./handlers/delete-tenant-field.write";
|
|
65
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
createSetCustomFieldHandler,
|
|
71
|
+
setCustomFieldHandler,
|
|
72
|
+
} from "./handlers/set-custom-field.write";
|
|
66
73
|
import { updateTenantFieldHandler } from "./handlers/update-tenant-field.write";
|
|
67
74
|
|
|
68
|
-
const tenantAdminAccess = { access: { roles: ["TenantAdmin"] } } as const;
|
|
69
|
-
|
|
70
75
|
const fieldDefinitionDeletedSchema = z.object({
|
|
71
76
|
entityName: z.string(),
|
|
72
77
|
fieldKey: z.string(),
|
|
@@ -78,13 +83,21 @@ const fieldDefinitionDeletedSchema = z.object({
|
|
|
78
83
|
tenantId: z.string().optional(),
|
|
79
84
|
});
|
|
80
85
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
86
|
+
// Handler-/Access-Varianten die zwischen Singleton und Options-Variante
|
|
87
|
+
// differieren können. Alles andere registriert registerCustomFields für
|
|
88
|
+
// beide identisch, damit ein neues Event/Handler nie still die
|
|
89
|
+
// Options-Variante verfehlt.
|
|
90
|
+
type RegisterVariant = {
|
|
91
|
+
readonly defineTenantHandler: WriteHandlerDef;
|
|
92
|
+
readonly setHandler: WriteHandlerDef;
|
|
93
|
+
readonly clearHandler: WriteHandlerDef;
|
|
94
|
+
readonly fieldDefinitionListRoles: readonly string[];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Shared registration body for both the singleton and the options-variant.
|
|
85
98
|
function registerCustomFields(
|
|
86
99
|
r: FeatureRegistrar<typeof CUSTOM_FIELDS_FEATURE_NAME>,
|
|
87
|
-
|
|
100
|
+
variant: RegisterVariant,
|
|
88
101
|
) {
|
|
89
102
|
r.describe(
|
|
90
103
|
"Tenant- and system-scoped custom field definitions with generic value storage on any host entity. Registers the `field-definition` entity (event-sourced CRUD via `define-tenant-field`, `define-system-field`, `update-tenant-field`, `delete-tenant-field`, `delete-system-field`) and two value write-handlers (`set-custom-field`, `clear-custom-field`) that emit `custom-fields:event:custom-field-set` / `custom-fields:event:custom-field-cleared` events on the host aggregate's stream. To attach custom fields to your own entity, call `wireCustomFieldsFor(r, entityName, entityTable)` in the host feature — this wires the JSONB projection, `postQuery` flattening hook, and search-payload extension. The host entity must declare a `customFieldsField()` JSONB column.",
|
|
@@ -113,19 +126,23 @@ function registerCustomFields(
|
|
|
113
126
|
r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
|
|
114
127
|
|
|
115
128
|
// Definition-CRUD handlers (B1; update kam mit Bug-Bash D2 2026-06-08).
|
|
116
|
-
r.writeHandler(defineTenantHandler);
|
|
129
|
+
r.writeHandler(variant.defineTenantHandler);
|
|
117
130
|
r.writeHandler(defineSystemFieldHandler);
|
|
118
131
|
r.writeHandler(updateTenantFieldHandler);
|
|
119
132
|
r.writeHandler(deleteTenantFieldHandler);
|
|
120
133
|
r.writeHandler(deleteSystemFieldHandler);
|
|
121
134
|
|
|
122
135
|
// Value-write handlers (B2). Emittieren events auf host-aggregate-stream.
|
|
123
|
-
r.writeHandler(
|
|
124
|
-
r.writeHandler(
|
|
136
|
+
r.writeHandler(variant.setHandler);
|
|
137
|
+
r.writeHandler(variant.clearHandler);
|
|
125
138
|
|
|
126
|
-
// List-Query — tenant-scoped (B1-limit).
|
|
139
|
+
// List-Query — tenant-scoped (B1-limit). Die CustomFieldsFormSection
|
|
140
|
+
// dispatcht diesen QN hart — Apps mit eigenem Rollen-Vokabular
|
|
141
|
+
// überschreiben die Rollen via fieldDefinitionListRoles.
|
|
127
142
|
r.queryHandler(
|
|
128
|
-
defineEntityListHandler("field-definition", fieldDefinitionEntity,
|
|
143
|
+
defineEntityListHandler("field-definition", fieldDefinitionEntity, {
|
|
144
|
+
access: { roles: variant.fieldDefinitionListRoles },
|
|
145
|
+
}),
|
|
129
146
|
);
|
|
130
147
|
|
|
131
148
|
return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
|
|
@@ -137,29 +154,54 @@ function registerCustomFields(
|
|
|
137
154
|
// (feature.ts -> handlers/*.write.ts -> feature.ts) löst sich auf weil
|
|
138
155
|
// kein top-level-access stattfindet.
|
|
139
156
|
export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
140
|
-
registerCustomFields(r,
|
|
157
|
+
registerCustomFields(r, {
|
|
158
|
+
defineTenantHandler: defineTenantFieldHandler,
|
|
159
|
+
setHandler: setCustomFieldHandler,
|
|
160
|
+
clearHandler: clearCustomFieldHandler,
|
|
161
|
+
fieldDefinitionListRoles: DEFAULT_FIELD_DEFINITION_LIST_ROLES,
|
|
162
|
+
}),
|
|
141
163
|
);
|
|
142
164
|
|
|
165
|
+
export type CustomFieldsFeatureOptions = {
|
|
166
|
+
/** T1.5e: Quota für define-tenant-field. */
|
|
167
|
+
readonly fieldDefinitionLimitPerTenant?: number;
|
|
168
|
+
/** Rollen für set-/clear-custom-field — die Save-Pfade der
|
|
169
|
+
* CustomFieldsFormSection. Default ["TenantAdmin","TenantMember"];
|
|
170
|
+
* Apps mit eigenem Rollen-Vokabular (z.B. ["Admin","Editor"]) MÜSSEN
|
|
171
|
+
* das setzen, sonst ist der Value-Save für jeden App-User
|
|
172
|
+
* access_denied (Role-Naming-Drift). */
|
|
173
|
+
readonly valueWriteRoles?: readonly string[];
|
|
174
|
+
/** Rollen für custom-fields:query:field-definition:list — der
|
|
175
|
+
* Lade-Pfad der CustomFieldsFormSection. Default ["TenantAdmin"]. */
|
|
176
|
+
readonly fieldDefinitionListRoles?: readonly string[];
|
|
177
|
+
};
|
|
178
|
+
|
|
143
179
|
// Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
|
|
144
180
|
// host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
|
|
145
181
|
// module-level-Singleton — kein neuer build pro Aufruf, was für consumer
|
|
146
|
-
// nicht erkennbar ist (read-only inspection).
|
|
147
|
-
//
|
|
148
|
-
// **T1.5e options**: when `fieldDefinitionLimitPerTenant` is set, the
|
|
149
|
-
// returned feature carries a fresh `define-tenant-field` handler with the
|
|
150
|
-
// quota baked in. Callers that don't pass options get the singleton — no
|
|
151
|
-
// change in behavior.
|
|
182
|
+
// nicht erkennbar ist (read-only inspection). Jede gesetzte Option baut
|
|
183
|
+
// eine frische Feature-Definition mit den Varianten-Handlern.
|
|
152
184
|
export function createCustomFieldsFeature(
|
|
153
|
-
opts:
|
|
185
|
+
opts: CustomFieldsFeatureOptions = {},
|
|
154
186
|
): typeof customFieldsFeature {
|
|
155
|
-
|
|
187
|
+
const hasOptions =
|
|
188
|
+
opts.fieldDefinitionLimitPerTenant !== undefined ||
|
|
189
|
+
opts.valueWriteRoles !== undefined ||
|
|
190
|
+
opts.fieldDefinitionListRoles !== undefined;
|
|
191
|
+
if (!hasOptions) {
|
|
156
192
|
return customFieldsFeature;
|
|
157
193
|
}
|
|
158
194
|
const limit = opts.fieldDefinitionLimitPerTenant;
|
|
159
195
|
return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
160
|
-
registerCustomFields(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
196
|
+
registerCustomFields(r, {
|
|
197
|
+
defineTenantHandler:
|
|
198
|
+
limit !== undefined
|
|
199
|
+
? createDefineTenantFieldHandler({ fieldDefinitionLimitPerTenant: limit })
|
|
200
|
+
: defineTenantFieldHandler,
|
|
201
|
+
setHandler: createSetCustomFieldHandler(opts.valueWriteRoles),
|
|
202
|
+
clearHandler: createClearCustomFieldHandler(opts.valueWriteRoles),
|
|
203
|
+
fieldDefinitionListRoles:
|
|
204
|
+
opts.fieldDefinitionListRoles ?? DEFAULT_FIELD_DEFINITION_LIST_ROLES,
|
|
205
|
+
}),
|
|
164
206
|
);
|
|
165
207
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { DEFAULT_VALUE_WRITE_ROLES } from "../constants";
|
|
4
5
|
import { customFieldsFeature } from "../feature";
|
|
5
6
|
import { checkFieldAccessForWrite } from "../lib/field-access";
|
|
6
7
|
|
|
@@ -25,7 +26,7 @@ export type ClearCustomFieldPayload = z.infer<typeof clearCustomFieldPayloadSche
|
|
|
25
26
|
export const clearCustomFieldHandler: WriteHandlerDef = {
|
|
26
27
|
name: "clear-custom-field",
|
|
27
28
|
schema: clearCustomFieldPayloadSchema,
|
|
28
|
-
access: { roles:
|
|
29
|
+
access: { roles: DEFAULT_VALUE_WRITE_ROLES },
|
|
29
30
|
handler: async (event, ctx) => {
|
|
30
31
|
const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
|
|
31
32
|
|
|
@@ -62,3 +63,11 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
|
|
|
62
63
|
};
|
|
63
64
|
},
|
|
64
65
|
};
|
|
66
|
+
|
|
67
|
+
/** Pendant zu createSetCustomFieldHandler — gleiche Rollen für set + clear,
|
|
68
|
+
* ein Wert ohne Clear-Recht wäre nur halb beherrschbar. */
|
|
69
|
+
export function createClearCustomFieldHandler(
|
|
70
|
+
roles: readonly string[] = DEFAULT_VALUE_WRITE_ROLES,
|
|
71
|
+
): WriteHandlerDef {
|
|
72
|
+
return { ...clearCustomFieldHandler, access: { roles } };
|
|
73
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { DEFAULT_VALUE_WRITE_ROLES } from "../constants";
|
|
4
5
|
import { customFieldsFeature } from "../feature";
|
|
5
6
|
import { fieldWriteAccessDeniedRoles, loadFieldDefinition } from "../lib/field-access";
|
|
6
7
|
import { buildCustomFieldValueSchema } from "../lib/value-schema";
|
|
@@ -48,7 +49,7 @@ export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
|
|
|
48
49
|
export const setCustomFieldHandler: WriteHandlerDef = {
|
|
49
50
|
name: "set-custom-field",
|
|
50
51
|
schema: setCustomFieldPayloadSchema,
|
|
51
|
-
access: { roles:
|
|
52
|
+
access: { roles: DEFAULT_VALUE_WRITE_ROLES },
|
|
52
53
|
handler: async (event, ctx) => {
|
|
53
54
|
const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
|
|
54
55
|
|
|
@@ -102,3 +103,12 @@ export const setCustomFieldHandler: WriteHandlerDef = {
|
|
|
102
103
|
};
|
|
103
104
|
},
|
|
104
105
|
};
|
|
106
|
+
|
|
107
|
+
/** Value-Write mit App-Rollen statt der Bundle-Defaults — Apps mit eigenem
|
|
108
|
+
* Rollen-Vokabular (z.B. "Admin"/"Editor") reichen ihre Rollen über
|
|
109
|
+
* createCustomFieldsFeature({ valueWriteRoles }) hierher durch. */
|
|
110
|
+
export function createSetCustomFieldHandler(
|
|
111
|
+
roles: readonly string[] = DEFAULT_VALUE_WRITE_ROLES,
|
|
112
|
+
): WriteHandlerDef {
|
|
113
|
+
return { ...setCustomFieldHandler, access: { roles } };
|
|
114
|
+
}
|
|
@@ -14,7 +14,11 @@ export {
|
|
|
14
14
|
customFieldClearedSchema,
|
|
15
15
|
customFieldSetSchema,
|
|
16
16
|
} from "./events";
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
type CustomFieldsFeatureOptions,
|
|
19
|
+
createCustomFieldsFeature,
|
|
20
|
+
customFieldsFeature,
|
|
21
|
+
} from "./feature";
|
|
18
22
|
export {
|
|
19
23
|
type ClearCustomFieldPayload,
|
|
20
24
|
clearCustomFieldPayloadSchema,
|