@cosmicdrift/kumiko-framework 0.57.0 → 0.57.1
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 +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +48 -0
- package/src/engine/__tests__/engine.test.ts +1 -1
- package/src/engine/__tests__/extends-registrar.test.ts +23 -0
- package/src/engine/boot-validator/entity-handler.ts +36 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/define-feature.ts +22 -6
- package/src/engine/index.ts +1 -0
- package/src/engine/registry.ts +23 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.57.
|
|
3
|
+
"version": "0.57.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -361,6 +361,54 @@ describe("boot-validator", () => {
|
|
|
361
361
|
expect(() => validateBoot(features)).not.toThrow();
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
+
// --- Write-handler entity-mapping (smart tryMapEntity + extension preSave) ---
|
|
365
|
+
|
|
366
|
+
test("accepts bare CRUD write handler when feature name matches entity", () => {
|
|
367
|
+
const features = [
|
|
368
|
+
defineFeature("credit", (r) => {
|
|
369
|
+
r.entity("credit", createEntity({ table: "Credits", fields: { name: createTextField() } }));
|
|
370
|
+
r.writeHandler(
|
|
371
|
+
"create",
|
|
372
|
+
z.object({ name: z.string() }),
|
|
373
|
+
async () => ({
|
|
374
|
+
isSuccess: true as const,
|
|
375
|
+
data: { id: "1" },
|
|
376
|
+
}),
|
|
377
|
+
{ access: { openToAll: true } },
|
|
378
|
+
);
|
|
379
|
+
}),
|
|
380
|
+
];
|
|
381
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
382
|
+
expect(features[0]?.handlerEntityMappings["create"]).toBe("credit");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("throws when extension preSave targets entity with no mapped write handlers", () => {
|
|
386
|
+
const ext = defineFeature("cap-ext", (r) => {
|
|
387
|
+
r.extendsRegistrar("credit-cap", {
|
|
388
|
+
hooks: {
|
|
389
|
+
preSave: async (changes) => changes,
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
const consumer = defineFeature("money-horse", (r) => {
|
|
394
|
+
r.requires("cap-ext");
|
|
395
|
+
r.entity("credit", createEntity({ table: "Credits", fields: { name: createTextField() } }));
|
|
396
|
+
r.writeHandler(
|
|
397
|
+
"doSomething",
|
|
398
|
+
z.object({}),
|
|
399
|
+
async () => ({
|
|
400
|
+
isSuccess: true as const,
|
|
401
|
+
data: {},
|
|
402
|
+
}),
|
|
403
|
+
{ access: { openToAll: true } },
|
|
404
|
+
);
|
|
405
|
+
r.useExtension("credit-cap", "credit");
|
|
406
|
+
});
|
|
407
|
+
expect(() => validateBoot([ext, consumer])).toThrow(
|
|
408
|
+
/no write handler is entity-mapped to "credit"/i,
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
364
412
|
// --- Handler access validation (default-deny) ---
|
|
365
413
|
|
|
366
414
|
test("throws when a write handler has no access rule", () => {
|
|
@@ -404,7 +404,7 @@ describe("createRegistry", () => {
|
|
|
404
404
|
});
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
-
expect(() => createRegistry([feature])).toThrow(/hr:write:promote.*not mapped.*entity:
|
|
407
|
+
expect(() => createRegistry([feature])).toThrow(/hr:write:promote.*not mapped.*entity:verb/i);
|
|
408
408
|
});
|
|
409
409
|
|
|
410
410
|
test("allows unmapped write handlers when feature has no field-access rules", () => {
|
|
@@ -98,6 +98,29 @@ describe("extendsRegistrar", () => {
|
|
|
98
98
|
expect(entity?.fields["customData"]?.type).toBe("text");
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
test("extension preSave hooks fire for bare CRUD handler with smart entity map", () => {
|
|
102
|
+
const preSaveFn = mock(async (changes: Record<string, unknown>) => changes);
|
|
103
|
+
|
|
104
|
+
const ext = defineFeature("audit", (r) => {
|
|
105
|
+
r.extendsRegistrar("audited", {
|
|
106
|
+
hooks: { preSave: preSaveFn },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
const consumer = defineFeature("credit", (r) => {
|
|
110
|
+
r.requires("audit");
|
|
111
|
+
r.entity("credit", createEntity({ table: "Credits", fields: { name: createTextField() } }));
|
|
112
|
+
r.writeHandler("create", z.object({ name: z.string() }), async () => ({
|
|
113
|
+
isSuccess: true as const,
|
|
114
|
+
data: { id: "c1" },
|
|
115
|
+
}));
|
|
116
|
+
r.useExtension("audited", "credit");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const registry = createRegistry([ext, consumer]);
|
|
120
|
+
const hooks = registry.getPreSaveHooks("credit:write:create");
|
|
121
|
+
expect(hooks.length).toBeGreaterThan(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
101
124
|
test("extension preSave hooks fire for entity-scoped handlers", () => {
|
|
102
125
|
const preSaveFn = mock(async (changes: Record<string, unknown>) => changes);
|
|
103
126
|
|
|
@@ -53,6 +53,42 @@ export const PII_USER_OWNED_NAME_HINTS: ReadonlySet<string> = new Set([
|
|
|
53
53
|
"notes",
|
|
54
54
|
]);
|
|
55
55
|
|
|
56
|
+
// --- Extension preSave wiring validation ---
|
|
57
|
+
|
|
58
|
+
/** Extensions with preSave must target an entity that has mapped write handlers. */
|
|
59
|
+
export function validateExtensionPreSaveWiring(features: readonly FeatureDefinition[]): void {
|
|
60
|
+
const extensionsWithPreSave = new Set<string>();
|
|
61
|
+
for (const f of features) {
|
|
62
|
+
for (const [extName, def] of Object.entries(f.registrarExtensions ?? {})) {
|
|
63
|
+
if (def.hooks?.preSave) extensionsWithPreSave.add(extName);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// skip: no extensions declare preSave hooks — nothing to validate
|
|
67
|
+
if (extensionsWithPreSave.size === 0) return;
|
|
68
|
+
|
|
69
|
+
const entitiesWithMappedWrites = new Set<string>();
|
|
70
|
+
for (const f of features) {
|
|
71
|
+
for (const [handlerName, entityName] of Object.entries(f.handlerEntityMappings ?? {})) {
|
|
72
|
+
if (handlerName in (f.writeHandlers ?? {})) {
|
|
73
|
+
entitiesWithMappedWrites.add(entityName);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const f of features) {
|
|
79
|
+
for (const usage of f.extensionUsages) {
|
|
80
|
+
if (!extensionsWithPreSave.has(usage.extensionName)) continue;
|
|
81
|
+
if (!entitiesWithMappedWrites.has(usage.entityName)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Feature "${f.name}" uses extension "${usage.extensionName}" with preSave on entity "${usage.entityName}" ` +
|
|
84
|
+
`but no write handler is entity-mapped to "${usage.entityName}". ` +
|
|
85
|
+
`Use create/update/delete on a matching entity or name the handler "entity:verb".`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
56
92
|
// --- Handler access validation ---
|
|
57
93
|
|
|
58
94
|
// Rate-limit modes that bucket per user.id. Anonymous endpoints would put
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
validateEncryptedFields,
|
|
17
17
|
validateEntityIndexes,
|
|
18
18
|
validateExtendSchemaCollisions,
|
|
19
|
+
validateExtensionPreSaveWiring,
|
|
19
20
|
validateFileFields,
|
|
20
21
|
validateHandlerAccess,
|
|
21
22
|
validateLocatedTimestamps,
|
|
@@ -158,6 +159,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
158
159
|
|
|
159
160
|
validateNavCycles(allNavQns);
|
|
160
161
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
162
|
+
validateExtensionPreSaveWiring(features);
|
|
161
163
|
|
|
162
164
|
if (hasEncryptedFields && !process.env["ENCRYPTION_KEY"]) {
|
|
163
165
|
throw new Error("ENCRYPTION_KEY environment variable is required (encrypted fields in use)");
|
|
@@ -170,14 +170,30 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
170
170
|
let envSchema: z.ZodObject<z.ZodRawShape> | undefined;
|
|
171
171
|
|
|
172
172
|
// Map handler name to entity via colon convention.
|
|
173
|
-
// "task:create" → entity "task".
|
|
173
|
+
// "task:create" → entity "task". Bare CRUD verbs (create/update/delete) map
|
|
174
|
+
// when feature name matches an entity or the feature owns exactly one entity.
|
|
175
|
+
const CRUD_VERBS = new Set(["create", "update", "delete"]);
|
|
176
|
+
|
|
174
177
|
function tryMapEntity(handlerName: string): void {
|
|
175
178
|
const colonIdx = handlerName.indexOf(":");
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
if (colonIdx >= 0) {
|
|
180
|
+
const candidate = handlerName.slice(0, colonIdx);
|
|
181
|
+
if (entities[candidate]) {
|
|
182
|
+
handlerEntityMappings[handlerName] = candidate;
|
|
183
|
+
}
|
|
184
|
+
// skip: colon-prefixed handler processed (mapped or not), bare CRUD path not applicable
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (CRUD_VERBS.has(handlerName)) {
|
|
188
|
+
if (entities[name]) {
|
|
189
|
+
handlerEntityMappings[handlerName] = name;
|
|
190
|
+
// skip: feature-name entity match is the preferred mapping
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const entityKeys = Object.keys(entities);
|
|
194
|
+
if (entityKeys.length === 1) {
|
|
195
|
+
handlerEntityMappings[handlerName] = entityKeys[0] as string;
|
|
196
|
+
}
|
|
181
197
|
}
|
|
182
198
|
}
|
|
183
199
|
|
package/src/engine/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
validateAppCustomScreenWriteQns,
|
|
7
7
|
validateBoot,
|
|
8
8
|
} from "./boot-validator";
|
|
9
|
+
export { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
|
|
9
10
|
export { buildAppSchema } from "./build-app-schema";
|
|
10
11
|
export type { ConfigFeatureSchema } from "./build-config-feature-schema";
|
|
11
12
|
export {
|
package/src/engine/registry.ts
CHANGED
|
@@ -980,23 +980,20 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
980
980
|
|
|
981
981
|
// Validate: handlers in features with field-access rules must be entity-mapped.
|
|
982
982
|
// Without entity mapping, field-level access checks are silently skipped (security gap).
|
|
983
|
-
// Convention: "entityName.action" = entity-bound (must resolve), "action" = standalone (no filter).
|
|
984
983
|
for (const feature of features) {
|
|
985
984
|
if (!hasFieldAccessRules(feature)) continue;
|
|
986
985
|
|
|
987
|
-
// Write handlers: ALL must be entity-mapped (security-critical, writes need field-access checks)
|
|
988
986
|
for (const handlerName of Object.keys(feature.writeHandlers ?? {})) {
|
|
989
987
|
const qualified = qualify(feature.name, "write", handlerName);
|
|
990
988
|
if (!handlerEntityMap.has(qualified)) {
|
|
991
989
|
throw new Error(
|
|
992
990
|
`Write handler "${qualified}" is not mapped to any entity, but feature "${feature.name}" has field-level access rules. ` +
|
|
993
|
-
`Name must follow "entity:
|
|
991
|
+
`Name must follow "entity:verb" convention (e.g. "user:create") or use create/update/delete on a matching entity.`,
|
|
994
992
|
);
|
|
995
993
|
}
|
|
996
994
|
}
|
|
997
995
|
|
|
998
|
-
// Query handlers: only those with a
|
|
999
|
-
// No dash = standalone query (dashboard, stats) — intentionally not entity-bound.
|
|
996
|
+
// Query handlers: only those with a colon must resolve (typo protection).
|
|
1000
997
|
for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
|
|
1001
998
|
if (!handlerName.includes(":")) continue;
|
|
1002
999
|
const qualified = qualify(feature.name, "query", handlerName);
|
|
@@ -1009,6 +1006,27 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1009
1006
|
}
|
|
1010
1007
|
}
|
|
1011
1008
|
|
|
1009
|
+
// Extension preSave: useExtension on an entity must have at least one mapped write handler.
|
|
1010
|
+
const extensionsWithPreSave = new Set<string>();
|
|
1011
|
+
for (const f of features) {
|
|
1012
|
+
for (const [extName, def] of Object.entries(f.registrarExtensions ?? {})) {
|
|
1013
|
+
if (def.hooks?.preSave) extensionsWithPreSave.add(extName);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
for (const usage of extensionUsages) {
|
|
1017
|
+
if (!extensionsWithPreSave.has(usage.extensionName)) continue;
|
|
1018
|
+
const hasMapped = [...writeHandlerMap.keys()].some(
|
|
1019
|
+
(qn) => handlerEntityMap.get(qn) === usage.entityName,
|
|
1020
|
+
);
|
|
1021
|
+
if (!hasMapped) {
|
|
1022
|
+
throw new Error(
|
|
1023
|
+
`Feature "${usage.featureName}" uses extension "${usage.extensionName}" with preSave on entity "${usage.entityName}" ` +
|
|
1024
|
+
`but no write handler is entity-mapped to "${usage.entityName}". ` +
|
|
1025
|
+
`Use create/update/delete on a matching entity or name the handler "entity:verb".`,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1012
1030
|
// Validate: all relation targets must reference existing entities
|
|
1013
1031
|
for (const [entityName, rels] of relationMap) {
|
|
1014
1032
|
for (const [relName, rel] of Object.entries(rels)) {
|