@cosmicdrift/kumiko-framework 0.56.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.56.1",
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>",
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
2
2
  import { asRawClient } from "../../db/query";
3
3
  import { createBooleanField, createEntity, createTextField } from "../../engine";
4
4
  import { append, createEventsTable } from "../../event-store";
5
+ import type { EntityCache } from "../../pipeline/entity-cache";
5
6
  import {
6
7
  createTestDb,
7
8
  type TestDb,
@@ -285,3 +286,48 @@ describe("event-store-executor — detail liefert die Stream-Version", () => {
285
286
  expect(updated.isSuccess).toBe(true);
286
287
  });
287
288
  });
289
+
290
+ describe("event-store-executor — entity cache read-through", () => {
291
+ // In-memory stand-in for EntityCache — no Redis needed.
292
+ const store = new Map<string, Record<string, unknown>>();
293
+ const entityCache: EntityCache = {
294
+ get: async (tenantId, name, id) => store.get(`${tenantId}:${name}:${id}`) ?? null,
295
+ mget: async () => new Map(),
296
+ set: async (tenantId, name, id, data) => {
297
+ store.set(`${tenantId}:${name}:${id}`, data);
298
+ },
299
+ mset: async (tenantId, name, entries) => {
300
+ for (const { id, data } of entries) store.set(`${tenantId}:${name}:${id}`, data);
301
+ },
302
+ del: async (tenantId, name, id) => {
303
+ store.delete(`${tenantId}:${name}:${id}`);
304
+ },
305
+ };
306
+ const cachedCrud = createEventStoreExecutor(table, entity, {
307
+ entityName: "esExecUser",
308
+ entityCache,
309
+ });
310
+
311
+ beforeEach(() => store.clear());
312
+
313
+ test("first detail populates cache; second detail is served from cache", async () => {
314
+ const created = await cachedCrud.create({ email: "cache@test.de" }, adminUser, tdb);
315
+ if (!created.isSuccess) throw new Error("create failed");
316
+ const id = created.data.id;
317
+ const storeKey = `${adminUser.tenantId}:esExecUser:${id}`;
318
+
319
+ // create() calls del() — cache must be empty after create.
320
+ expect(store.has(storeKey)).toBe(false);
321
+
322
+ // First detail: miss → DB → populates cache.
323
+ const first = await cachedCrud.detail({ id }, adminUser, tdb);
324
+ expect(first).not.toBeNull();
325
+ expect(store.has(storeKey)).toBe(true);
326
+
327
+ // Poison the cache entry — proves the second detail reads from cache, not DB.
328
+ store.set(storeKey, { ...store.get(storeKey)!, email: "from-cache@test.de" });
329
+
330
+ const second = await cachedCrud.detail({ id }, adminUser, tdb);
331
+ expect(second?.["email"]).toBe("from-cache@test.de");
332
+ });
333
+ });
@@ -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:action/i);
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)");
@@ -1,4 +1,5 @@
1
1
  import type { ConfigScope } from "./constants";
2
+ import { SYSTEM_ROLE } from "./system-user";
2
3
  import type {
3
4
  ConfigBacking,
4
5
  ConfigBounds,
@@ -27,6 +28,13 @@ export const access = {
27
28
  authenticated: ["User", "Admin", "SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
28
29
  anonymous: ["anonymous"] as readonly string[], // @cast-boundary schema-walk
29
30
  roles: (...roles: string[]): readonly string[] => roles,
31
+ // Tenant self-service roles PLUS the system actor — for tenant-scope keys a
32
+ // tenant edits itself (configEdit) but that provisioning/migration/jobs must
33
+ // also set via ctx.systemWriteAs (roles = [SYSTEM_ROLE]). Stays human-writable
34
+ // because checkWriteAccess only collapses to system-only when system is the
35
+ // SOLE writer (humanWriters empty). Composes any preset, so apps with custom
36
+ // roles get the same provisioning path (issue #396).
37
+ withSystem: (roles: readonly string[]): readonly string[] => [SYSTEM_ROLE, ...roles],
30
38
  } as const;
31
39
 
32
40
  // --- Config Key Options ---
@@ -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". No colon standalone handler, no mapping.
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
- // skip: handler name is not entity-scoped (no colon), nothing to map
177
- if (colonIdx < 0) return;
178
- const candidate = handlerName.slice(0, colonIdx);
179
- if (entities[candidate]) {
180
- handlerEntityMappings[handlerName] = candidate;
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
 
@@ -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 {
@@ -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:action" convention (e.g. "user:create") so field-access checks apply.`,
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 dash must resolve (typo protection).
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)) {