@cosmicdrift/kumiko-framework 0.260.0 → 0.262.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.
Files changed (34) hide show
  1. package/package.json +3 -3
  2. package/src/changes.json +20 -0
  3. package/src/db/__tests__/tenant-db-global.integration.test.ts +97 -0
  4. package/src/db/__tests__/tenant-db-global.test.ts +170 -0
  5. package/src/db/entity-table-meta.ts +8 -2
  6. package/src/db/table-builder.ts +37 -2
  7. package/src/db/tenant-db.ts +86 -4
  8. package/src/engine/__tests__/boot-validator.test.ts +16 -15
  9. package/src/engine/__tests__/build-app-schema.test.ts +14 -5
  10. package/src/engine/__tests__/engine.test.ts +10 -0
  11. package/src/engine/__tests__/entity-handlers.test.ts +48 -36
  12. package/src/engine/__tests__/extends-registrar.test.ts +27 -12
  13. package/src/engine/__tests__/registry.test.ts +16 -5
  14. package/src/engine/access.ts +2 -1
  15. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +200 -0
  16. package/src/engine/boot-validator/__tests__/security-baseline.test.ts +107 -0
  17. package/src/engine/boot-validator/access-declarations.ts +141 -0
  18. package/src/engine/boot-validator/entity-handler.ts +1 -1
  19. package/src/engine/boot-validator/index.ts +5 -0
  20. package/src/engine/boot-validator/security-baseline.ts +26 -0
  21. package/src/engine/boot-validator.ts +1 -0
  22. package/src/engine/define-handler.ts +1 -0
  23. package/src/engine/entity-handlers.ts +29 -19
  24. package/src/engine/factories.ts +5 -3
  25. package/src/engine/feature-ast/extractors/hooks.ts +5 -1
  26. package/src/engine/feature-entity-handlers.ts +40 -27
  27. package/src/engine/index.ts +5 -0
  28. package/src/engine/types/handlers.ts +1 -0
  29. package/src/engine/types/index.ts +4 -0
  30. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +93 -0
  31. package/src/pipeline/dispatch-shared.ts +3 -0
  32. package/src/secrets/__tests__/contains-secret.test.ts +5 -0
  33. package/src/testing/__tests__/e2e-generator.test.ts +11 -5
  34. package/src/ui-types/index.ts +6 -1
@@ -0,0 +1,107 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { defineFeature } from "../../define-feature";
3
+ import type { FeatureDefinition } from "../../types";
4
+ import { validateBoot } from "../index";
5
+ import {
6
+ SECURITY_BASELINE_FEATURE_NAMES,
7
+ warnOnMissingSecurityBaseline,
8
+ } from "../security-baseline";
9
+
10
+ function stubFeature(name: string): FeatureDefinition {
11
+ return defineFeature(name, () => {});
12
+ }
13
+
14
+ describe("warnOnMissingSecurityBaseline", () => {
15
+ let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
16
+
17
+ beforeEach(() => {
18
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
19
+ });
20
+
21
+ afterEach(() => {
22
+ warnSpy.mockRestore();
23
+ });
24
+
25
+ test("production + no baseline features → warns once, naming all four", () => {
26
+ warnOnMissingSecurityBaseline([], "production");
27
+
28
+ expect(warnSpy).toHaveBeenCalledTimes(1);
29
+ const msg = String(warnSpy.mock.calls[0]?.[0]);
30
+ for (const name of SECURITY_BASELINE_FEATURE_NAMES) {
31
+ expect(msg).toContain(name);
32
+ }
33
+ });
34
+
35
+ test("production + only sessions/audit mounted → warns about the missing two only", () => {
36
+ const features = [stubFeature("sessions"), stubFeature("audit")];
37
+
38
+ warnOnMissingSecurityBaseline(features, "production");
39
+
40
+ expect(warnSpy).toHaveBeenCalledTimes(1);
41
+ const msg = String(warnSpy.mock.calls[0]?.[0]);
42
+ expect(msg).toContain("crypto-shredding");
43
+ expect(msg).toContain("rate-limiting");
44
+ expect(msg).not.toContain("missing feature(s): sessions");
45
+ });
46
+
47
+ test("production + all four baseline features mounted → no warning", () => {
48
+ const features = SECURITY_BASELINE_FEATURE_NAMES.map((name) => stubFeature(name));
49
+
50
+ warnOnMissingSecurityBaseline(features, "production");
51
+
52
+ expect(warnSpy).not.toHaveBeenCalled();
53
+ });
54
+
55
+ test.each(["development", "test", undefined])(
56
+ "nodeEnv=%p + no baseline features → no warning",
57
+ (nodeEnv) => {
58
+ warnOnMissingSecurityBaseline([], nodeEnv);
59
+
60
+ expect(warnSpy).not.toHaveBeenCalled();
61
+ },
62
+ );
63
+
64
+ test("never throws, regardless of nodeEnv or mounted features", () => {
65
+ expect(() => warnOnMissingSecurityBaseline([], "production")).not.toThrow();
66
+ expect(() => warnOnMissingSecurityBaseline([], "development")).not.toThrow();
67
+ expect(() =>
68
+ warnOnMissingSecurityBaseline(
69
+ SECURITY_BASELINE_FEATURE_NAMES.map((name) => stubFeature(name)),
70
+ "production",
71
+ ),
72
+ ).not.toThrow();
73
+ });
74
+ });
75
+
76
+ describe("validateBoot + security baseline warning", () => {
77
+ const originalNodeEnv = process.env["NODE_ENV"];
78
+ let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
79
+
80
+ beforeEach(() => {
81
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
82
+ });
83
+
84
+ afterEach(() => {
85
+ warnSpy.mockRestore();
86
+ if (originalNodeEnv === undefined) {
87
+ delete process.env["NODE_ENV"];
88
+ } else {
89
+ process.env["NODE_ENV"] = originalNodeEnv;
90
+ }
91
+ });
92
+
93
+ test("production boot with all four baseline stubs mounted does not throw and does not warn", () => {
94
+ process.env["NODE_ENV"] = "production";
95
+ const features = SECURITY_BASELINE_FEATURE_NAMES.map((name) => stubFeature(name));
96
+
97
+ expect(() => validateBoot(features)).not.toThrow();
98
+ expect(warnSpy).not.toHaveBeenCalled();
99
+ });
100
+
101
+ test("production boot with no baseline features mounted warns but does not throw", () => {
102
+ process.env["NODE_ENV"] = "production";
103
+
104
+ expect(() => validateBoot([])).not.toThrow();
105
+ expect(warnSpy).toHaveBeenCalledTimes(1);
106
+ });
107
+ });
@@ -0,0 +1,141 @@
1
+ import type { AccessRule, FeatureDefinition, WriteHandlerDef } from "../types";
2
+ import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
3
+ import { getZodObjectShape } from "./zod-shape";
4
+
5
+ type HandlerKind = "write" | "query" | "stream";
6
+
7
+ // Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
8
+ // minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
9
+ // sense openToAll+publicIntake is guarding against.
10
+ function isPersonalDataField(field: unknown): boolean {
11
+ const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
12
+ return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
13
+ }
14
+
15
+ function personalFieldNames(entity: EntityDefinition): ReadonlySet<string> {
16
+ const names = new Set<string>();
17
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
18
+ if (isPersonalDataField(field)) names.add(fieldName);
19
+ }
20
+ return names;
21
+ }
22
+
23
+ function candidatePersonalFieldNames(
24
+ feature: FeatureDefinition,
25
+ handlerName: string,
26
+ ): ReadonlySet<string> {
27
+ const mappedEntityName = feature.handlerEntityMappings?.[handlerName];
28
+ const entities = feature.entities ?? {};
29
+ if (mappedEntityName) {
30
+ const entity = entities[mappedEntityName];
31
+ return entity ? personalFieldNames(entity) : new Set();
32
+ }
33
+ const names = new Set<string>();
34
+ for (const entity of Object.values(entities)) {
35
+ for (const name of personalFieldNames(entity)) names.add(name);
36
+ }
37
+ return names;
38
+ }
39
+
40
+ // A value that is neither `true` nor an object with a non-empty string `reason`
41
+ // counts as "empty" — catches malformed openToAll from untyped sources too.
42
+ function openToAllReasonIsEmpty(access: AccessRule): boolean {
43
+ if (!("openToAll" in access)) return false;
44
+ const openToAll: unknown = access.openToAll;
45
+ if (openToAll === true) return false;
46
+ if (typeof openToAll !== "object" || openToAll === null) return true;
47
+ if (!("reason" in openToAll) || typeof openToAll.reason !== "string") return true;
48
+ return openToAll.reason.trim().length === 0;
49
+ }
50
+
51
+ function hasOpenToAll(access: AccessRule): boolean {
52
+ return "openToAll" in access;
53
+ }
54
+
55
+ function hasPublicIntake(access: AccessRule): boolean {
56
+ return "publicIntake" in access && access.publicIntake === true;
57
+ }
58
+
59
+ function validateOpenToAllReason(
60
+ feature: FeatureDefinition,
61
+ kind: HandlerKind,
62
+ handlerName: string,
63
+ access: AccessRule,
64
+ ): void {
65
+ // skip: reason is well-formed, nothing to validate
66
+ if (!openToAllReasonIsEmpty(access)) return;
67
+ throw new Error(
68
+ `[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid ` +
69
+ '{ openToAll: ... } — must be `true` or { reason: "<non-empty explanation>" } ' +
70
+ "for why any authenticated user may call this handler.",
71
+ );
72
+ }
73
+
74
+ function validateEscapeHatchReason(
75
+ feature: FeatureDefinition,
76
+ handlerName: string,
77
+ escapeHatch: WriteHandlerDef["escapeHatch"],
78
+ ): void {
79
+ // skip: no escapeHatch declared, or its reason is already non-empty
80
+ if (!escapeHatch || escapeHatch.reason.trim().length > 0) return;
81
+ throw new Error(
82
+ `[Feature ${feature.name}] write handler "${handlerName}" declares ` +
83
+ `{ escapeHatch: { reason: "" } } — the reason must be a non-empty string ` +
84
+ "explaining why this handler needs db.global() write access.",
85
+ );
86
+ }
87
+
88
+ function validatePublicIntakeOnlyOnWrite(
89
+ feature: FeatureDefinition,
90
+ kind: HandlerKind,
91
+ handlerName: string,
92
+ access: AccessRule,
93
+ ): void {
94
+ // skip: write handlers may declare publicIntake, or it wasn't declared here
95
+ if (kind === "write" || !hasPublicIntake(access)) return;
96
+ throw new Error(
97
+ `[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
98
+ "{ publicIntake: true } — publicIntake is only meaningful on a write handler " +
99
+ "(it silences the personal-data + openToAll boot check for the fields a write " +
100
+ "handler's input accepts).",
101
+ );
102
+ }
103
+
104
+ function validateOpenToAllPersonalData(
105
+ feature: FeatureDefinition,
106
+ handlerName: string,
107
+ handler: WriteHandlerDef,
108
+ ): void {
109
+ const access = handler.access;
110
+ // skip: no openToAll declared, or publicIntake already covers personal data
111
+ if (!hasOpenToAll(access) || hasPublicIntake(access)) return;
112
+ const shape = getZodObjectShape(handler.schema);
113
+ // skip: handler schema isn't a zod object — nothing to inspect
114
+ if (!shape) return;
115
+ const personalNames = candidatePersonalFieldNames(feature, handlerName);
116
+ const offending = Object.keys(shape).filter((key) => personalNames.has(key));
117
+ // skip: no personal-data fields in the handler's input
118
+ if (offending.length === 0) return;
119
+ throw new Error(
120
+ `[Feature ${feature.name}] write handler "${handlerName}" declares openToAll and ` +
121
+ `accepts personal-data field(s) ${offending.map((f) => `"${f}"`).join(", ")} without ` +
122
+ "{ publicIntake: true }. Restrict access to roles, or declare " +
123
+ "{ publicIntake: true } if any authenticated user may submit this personal data.",
124
+ );
125
+ }
126
+
127
+ export function validateAccessDeclarations(feature: FeatureDefinition): void {
128
+ for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
129
+ validateOpenToAllReason(feature, "write", handlerName, handler.access);
130
+ validateEscapeHatchReason(feature, handlerName, handler.escapeHatch);
131
+ validateOpenToAllPersonalData(feature, handlerName, handler);
132
+ }
133
+ for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
134
+ validateOpenToAllReason(feature, "query", handlerName, handler.access);
135
+ validatePublicIntakeOnlyOnWrite(feature, "query", handlerName, handler.access);
136
+ }
137
+ for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
138
+ validateOpenToAllReason(feature, "stream", handlerName, handler.access);
139
+ validatePublicIntakeOnlyOnWrite(feature, "stream", handlerName, handler.access);
140
+ }
141
+ }
@@ -143,7 +143,7 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
143
143
  if (!handler.access) {
144
144
  throw new Error(
145
145
  `${label} handler "${feature.name}:${kind}:${name}" is missing an access rule. ` +
146
- `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
146
+ `Set { roles: [...] } for role-based access, or { openToAll: { reason: "..." } } for any authenticated user.`,
147
147
  );
148
148
  }
149
149
  validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
@@ -1,6 +1,7 @@
1
1
  import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
2
2
  import { QnTypes, qualifyEntityName } from "../qualified-name";
3
3
  import type { FeatureDefinition } from "../types";
4
+ import { validateAccessDeclarations } from "./access-declarations";
4
5
  import { warnOnUniqueAccessRoles } from "./access-roles";
5
6
  import { validateActionWiring, validateFieldWiring } from "./action-wiring";
6
7
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
@@ -60,6 +61,7 @@ import {
60
61
  validateScreenShortIdCollisions,
61
62
  validateScreens,
62
63
  } from "./screens";
64
+ import { warnOnMissingSecurityBaseline } from "./security-baseline";
63
65
  import {
64
66
  collectWorkspaceQns,
65
67
  validateDefaultWorkspaceUniqueness,
@@ -71,6 +73,7 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
71
73
  // an den Codegen zu übergeben. Nicht Teil von validateBoot, aber
72
74
  // dieselbe Extraktionslogik.
73
75
  export { collectWriteHandlerQns } from "./nav";
76
+ export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline";
74
77
 
75
78
  export type ValidateBootOptions = {
76
79
  /** Warn when an access role is used by exactly one handler/config-key/
@@ -187,6 +190,7 @@ export function validateBoot(
187
190
  validateExtendSchemaCollisions(feature);
188
191
  validateDerivedFieldCollisions(feature);
189
192
  validateHandlerAccess(feature);
193
+ validateAccessDeclarations(feature);
190
194
  validateLocatedTimestamps(feature);
191
195
  validateEntityIndexes(feature);
192
196
  validateConfigKeyBounds(feature);
@@ -252,6 +256,7 @@ export function validateBoot(
252
256
 
253
257
  validateConfigReads(features, allConfigKeys);
254
258
  warnOnToggleableDependencies(features, featureMap);
259
+ warnOnMissingSecurityBaseline(features);
255
260
  if (options?.warnOnUniqueAccessRoles === true) {
256
261
  warnOnUniqueAccessRoles(features);
257
262
  }
@@ -0,0 +1,26 @@
1
+ import type { FeatureDefinition } from "../types";
2
+
3
+ export const SECURITY_BASELINE_FEATURE_NAMES = [
4
+ "sessions",
5
+ "crypto-shredding",
6
+ "rate-limiting",
7
+ "audit",
8
+ ] as const;
9
+
10
+ export function warnOnMissingSecurityBaseline(
11
+ features: readonly FeatureDefinition[],
12
+ nodeEnv: string | undefined = process.env["NODE_ENV"],
13
+ ): void {
14
+ // skip: baseline is only enforced for production boots
15
+ if (nodeEnv !== "production") return;
16
+
17
+ const mountedNames = new Set(features.map((f) => f.name));
18
+ const missing = SECURITY_BASELINE_FEATURE_NAMES.filter((name) => !mountedNames.has(name));
19
+ // skip: every baseline feature is mounted
20
+ if (missing.length === 0) return;
21
+
22
+ // biome-ignore lint/suspicious/noConsole: boot-time hint, no logger available yet
23
+ console.warn(
24
+ `[kumiko:boot] NODE_ENV=production but the security baseline is incomplete — missing feature(s): ${missing.join(", ")}. Mount securityBaselineFeatures() from @cosmicdrift/kumiko-bundled-features/presets (or the missing features individually).`,
25
+ );
26
+ }
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  collectWriteHandlerQns,
3
+ SECURITY_BASELINE_FEATURE_NAMES,
3
4
  type ValidateBootOptions,
4
5
  validateAppCustomScreenWriteQns,
5
6
  validateBoot,
@@ -85,6 +85,7 @@ export function defineWriteHandler<
85
85
  unsafeSkipTransitionGuard: def.unsafeSkipTransitionGuard,
86
86
  }),
87
87
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
88
+ ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
88
89
  };
89
90
 
90
91
  if ("perform" in def && def.perform !== undefined) {
@@ -30,6 +30,7 @@ import type {
30
30
  } from "./types/entity-handlers";
31
31
 
32
32
  export type {
33
+ EntityCrudHandlerDefaults,
33
34
  EntityCrudRegistrar,
34
35
  EntityCrudVerb,
35
36
  EntityHandlerOptions,
@@ -174,7 +175,7 @@ function parseHandlerName<TVerb extends string>(
174
175
  export function defineEntityWriteHandler(
175
176
  name: string,
176
177
  entity: EntityDefinition,
177
- options?: EntityHandlerOptions,
178
+ options: EntityHandlerOptions,
178
179
  ): WriteHandlerDef {
179
180
  const { entityName, verb } = parseHandlerName(name, WRITE_VERBS);
180
181
  if (verb === "restore" && !entity.softDelete) {
@@ -291,9 +292,9 @@ export function defineEntityWriteHandler(
291
292
  name,
292
293
  schema,
293
294
  handler,
294
- ...(options?.access && { access: options.access }),
295
- ...(options?.description !== undefined && { description: options.description }),
296
- ...(options?.agent !== undefined && { agent: options.agent }),
295
+ access: options.access,
296
+ ...(options.description !== undefined && { description: options.description }),
297
+ ...(options.agent !== undefined && { agent: options.agent }),
297
298
  };
298
299
  }
299
300
 
@@ -323,7 +324,7 @@ function augmentDerivedFields(
323
324
  export function defineEntityQueryHandler(
324
325
  name: string,
325
326
  entity: EntityDefinition,
326
- options?: EntityQueryHandlerOptions,
327
+ options: EntityQueryHandlerOptions,
327
328
  ): QueryHandlerDef {
328
329
  const { entityName, verb } = parseHandlerName(name, QUERY_VERBS);
329
330
 
@@ -421,9 +422,9 @@ export function defineEntityQueryHandler(
421
422
  name,
422
423
  schema,
423
424
  handler,
424
- ...(options?.access && { access: options.access }),
425
- ...(options?.description !== undefined && { description: options.description }),
426
- ...(options?.agent !== undefined && { agent: options.agent }),
425
+ access: options.access,
426
+ ...(options.description !== undefined && { description: options.description }),
427
+ ...(options.agent !== undefined && { agent: options.agent }),
427
428
  // The "list" verb's executor.list() always returns { rows, nextCursor,
428
429
  // total? } (see the handler body above) — brand it so the definition
429
430
  // site documents the PagedRows contract without needing
@@ -458,7 +459,7 @@ export function defineEntityQueryHandler(
458
459
  export function defineEntityCreateHandler(
459
460
  entityName: string,
460
461
  entity: EntityDefinition,
461
- options?: EntityHandlerOptions,
462
+ options: EntityHandlerOptions,
462
463
  ): WriteHandlerDef {
463
464
  return defineEntityWriteHandler(`${entityName}:create`, entity, options);
464
465
  }
@@ -467,7 +468,7 @@ export function defineEntityCreateHandler(
467
468
  export function defineEntityUpdateHandler(
468
469
  entityName: string,
469
470
  entity: EntityDefinition,
470
- options?: EntityHandlerOptions,
471
+ options: EntityHandlerOptions,
471
472
  ): WriteHandlerDef {
472
473
  return defineEntityWriteHandler(`${entityName}:update`, entity, options);
473
474
  }
@@ -476,7 +477,7 @@ export function defineEntityUpdateHandler(
476
477
  export function defineEntityDeleteHandler(
477
478
  entityName: string,
478
479
  entity: EntityDefinition,
479
- options?: EntityHandlerOptions,
480
+ options: EntityHandlerOptions,
480
481
  ): WriteHandlerDef {
481
482
  return defineEntityWriteHandler(`${entityName}:delete`, entity, options);
482
483
  }
@@ -485,7 +486,7 @@ export function defineEntityDeleteHandler(
485
486
  export function defineEntityRestoreHandler(
486
487
  entityName: string,
487
488
  entity: EntityDefinition,
488
- options?: EntityHandlerOptions,
489
+ options: EntityHandlerOptions,
489
490
  ): WriteHandlerDef {
490
491
  return defineEntityWriteHandler(`${entityName}:restore`, entity, options);
491
492
  }
@@ -494,7 +495,7 @@ export function defineEntityRestoreHandler(
494
495
  export function defineEntityListHandler(
495
496
  entityName: string,
496
497
  entity: EntityDefinition,
497
- options?: EntityQueryHandlerOptions,
498
+ options: EntityQueryHandlerOptions,
498
499
  ): QueryHandlerDef {
499
500
  return defineEntityQueryHandler(`${entityName}:list`, entity, options);
500
501
  }
@@ -503,7 +504,7 @@ export function defineEntityListHandler(
503
504
  export function defineEntityDetailHandler(
504
505
  entityName: string,
505
506
  entity: EntityDefinition,
506
- options?: EntityQueryHandlerOptions,
507
+ options: EntityQueryHandlerOptions,
507
508
  ): QueryHandlerDef {
508
509
  return defineEntityQueryHandler(`${entityName}:detail`, entity, options);
509
510
  }
@@ -542,7 +543,7 @@ export function createEntityExecutor(
542
543
  export function defineProjectionQueryHandler(
543
544
  name: string,
544
545
  projectionQualifiedName: string,
545
- options?: { access?: AccessRule; unsafeAllTenants?: boolean },
546
+ options: { access: AccessRule; unsafeAllTenants?: boolean },
546
547
  ): QueryHandlerDef {
547
548
  return {
548
549
  name,
@@ -554,9 +555,9 @@ export function defineProjectionQueryHandler(
554
555
  handler: async (_query, ctx) =>
555
556
  ctx.queryProjection(
556
557
  projectionQualifiedName,
557
- options?.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
558
+ options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
558
559
  ), // @wrapper-known semantic-alias
559
- ...(options?.access && { access: options.access }),
560
+ access: options.access,
560
561
  };
561
562
  }
562
563
 
@@ -588,14 +589,23 @@ export function registerEntityCrud(
588
589
  }
589
590
  const writeOpts = options?.write;
590
591
  const readOpts = options?.read;
592
+ const requireAccess = (verb: EntityCrudVerb, access: AccessRule | undefined): AccessRule => {
593
+ if (!access) {
594
+ throw new Error(
595
+ `registerEntityCrud("${entityName}", verb: "${verb}"): no access rule resolved. ` +
596
+ `Set write.access/read.access on registerEntityCrud's options, or verbAccess.${verb}.`,
597
+ );
598
+ }
599
+ return access;
600
+ };
591
601
  const resolveWriteOpts = (verb: EntityCrudVerb): EntityHandlerOptions => ({
592
602
  ...writeOpts,
593
- access: options?.verbAccess?.[verb] ?? writeOpts?.access,
603
+ access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
594
604
  description: options?.descriptions?.[verb] ?? writeOpts?.description,
595
605
  });
596
606
  const resolveReadOpts = (verb: EntityCrudVerb): EntityQueryHandlerOptions => ({
597
607
  ...readOpts,
598
- access: options?.verbAccess?.[verb] ?? readOpts?.access,
608
+ access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
599
609
  description: options?.descriptions?.[verb] ?? readOpts?.description,
600
610
  });
601
611
 
@@ -1,3 +1,4 @@
1
+ import type { EntityTenancy } from "@cosmicdrift/kumiko-types/tenancy-brand";
1
2
  import type {
2
3
  BigIntFieldDef,
3
4
  BooleanFieldDef,
@@ -442,7 +443,7 @@ export function createImagesField(
442
443
  // Issue um Constraint-Inferenz auf strukturellen Records. Stattdessen
443
444
  // validieren wir die Conformance via conditional return type: wenn F
444
445
  // nicht FieldsMap-compat ist, kollabiert der Return zu `never`.
445
- export function createEntity<F>(def: {
446
+ export function createEntity<F, const T extends EntityTenancy = "tenant">(def: {
446
447
  readonly table?: string;
447
448
  readonly fields: F;
448
449
  readonly description?: string;
@@ -460,7 +461,8 @@ export function createEntity<F>(def: {
460
461
  readonly parentRef?: EntityDefinition["parentRef"];
461
462
  readonly retention?: RetentionDef;
462
463
  readonly derivedFields?: EntityDefinition["derivedFields"];
463
- }): F extends FieldsMap ? EntityDefinition<F> : never {
464
+ readonly tenancy?: T;
465
+ }): F extends FieldsMap ? EntityDefinition<F, T> : never {
464
466
  return {
465
467
  softDelete: false,
466
468
  searchWeight: 1,
@@ -468,5 +470,5 @@ export function createEntity<F>(def: {
468
470
  // aggregate-ids are UUID. Opt-out with `idType: "serial"` for pre-ES
469
471
  // legacy tables (should be rare).
470
472
  ...def,
471
- } as F extends FieldsMap ? EntityDefinition<F> : never; // @cast-boundary engine-payload
473
+ } as F extends FieldsMap ? EntityDefinition<F, T> : never; // @cast-boundary engine-payload
472
474
  }
@@ -42,8 +42,12 @@ export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
42
42
  if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
43
43
  return { roles: value["roles"] as readonly string[] };
44
44
  }
45
+ const publicIntake = value["publicIntake"] === true ? { publicIntake: true as const } : {};
45
46
  if (value["openToAll"] === true) {
46
- return { openToAll: true };
47
+ return { openToAll: true, ...publicIntake };
48
+ }
49
+ if (isPlainObject(value["openToAll"]) && typeof value["openToAll"]["reason"] === "string") {
50
+ return { openToAll: { reason: value["openToAll"]["reason"] }, ...publicIntake };
47
51
  }
48
52
  return undefined;
49
53
  }