@cosmicdrift/kumiko-framework 0.260.0 → 0.261.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 (31) hide show
  1. package/package.json +3 -3
  2. package/src/changes.json +13 -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/access-declarations.ts +141 -0
  17. package/src/engine/boot-validator/entity-handler.ts +1 -1
  18. package/src/engine/boot-validator/index.ts +2 -0
  19. package/src/engine/define-handler.ts +1 -0
  20. package/src/engine/entity-handlers.ts +29 -19
  21. package/src/engine/factories.ts +5 -3
  22. package/src/engine/feature-ast/extractors/hooks.ts +5 -1
  23. package/src/engine/feature-entity-handlers.ts +40 -27
  24. package/src/engine/index.ts +4 -0
  25. package/src/engine/types/handlers.ts +1 -0
  26. package/src/engine/types/index.ts +4 -0
  27. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +93 -0
  28. package/src/pipeline/dispatch-shared.ts +3 -0
  29. package/src/secrets/__tests__/contains-secret.test.ts +5 -0
  30. package/src/testing/__tests__/e2e-generator.test.ts +11 -5
  31. package/src/ui-types/index.ts +6 -1
@@ -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";
@@ -187,6 +188,7 @@ export function validateBoot(
187
188
  validateExtendSchemaCollisions(feature);
188
189
  validateDerivedFieldCollisions(feature);
189
190
  validateHandlerAccess(feature);
191
+ validateAccessDeclarations(feature);
190
192
  validateLocatedTimestamps(feature);
191
193
  validateEntityIndexes(feature);
192
194
  validateConfigKeyBounds(feature);
@@ -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
  }
@@ -19,6 +19,7 @@ import type {
19
19
  AgentHandlerHints,
20
20
  EntityDefinition,
21
21
  EntityRef,
22
+ EscapeHatchDeclaration,
22
23
  HandlerRef,
23
24
  NameOrRef,
24
25
  QueryHandlerFn,
@@ -67,6 +68,18 @@ function agentSlots(source: {
67
68
  };
68
69
  }
69
70
 
71
+ function requireInlineHandlerArgs<TSchema, THandler, TOptions extends object>(
72
+ kind: string,
73
+ schema: TSchema | undefined,
74
+ handler: THandler | undefined,
75
+ options: TOptions | undefined,
76
+ ): { schema: TSchema; handler: THandler; options: TOptions } {
77
+ if (!schema || !handler || !options) {
78
+ throw new Error(`${kind} inline form requires schema + handler + options.access`);
79
+ }
80
+ return { schema, handler, options };
81
+ }
82
+
70
83
  // Builds entity/relation/writeHandler/queryHandler — the registrar
71
84
  // methods that create or reference entities.
72
85
  export function buildEntityHandlerMethods<TName extends string>(
@@ -110,10 +123,11 @@ export function buildEntityHandlerMethods<TName extends string>(
110
123
  schema?: TSchema,
111
124
  handler?: WriteHandlerFn<z.infer<TSchema>>,
112
125
  options?: {
113
- access?: AccessRule;
126
+ access: AccessRule;
114
127
  rateLimit?: RateLimitOption;
115
128
  description?: string;
116
129
  agent?: AgentHandlerHints;
130
+ escapeHatch?: EscapeHatchDeclaration;
117
131
  },
118
132
  ): HandlerRef {
119
133
  if (typeof nameOrDef === "object") {
@@ -130,10 +144,11 @@ export function buildEntityHandlerMethods<TName extends string>(
130
144
  // `satisfies` does not work here (it asserts assignability, which
131
145
  // is what fails). Explicit cast is the right tool.
132
146
  handler: def.handler as WriteHandlerFn,
133
- ...(def.access && { access: def.access }),
147
+ access: def.access,
134
148
  ...agentSlots(def),
135
149
  ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
136
150
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
151
+ ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
137
152
  // Forward the pipeline-build closure so boot-validators and
138
153
  // Designer/AI tooling can inspect the step list. Absent on
139
154
  // free-form handlers — defineWriteHandler only sets `perform`
@@ -147,15 +162,15 @@ export function buildEntityHandlerMethods<TName extends string>(
147
162
  tryMapEntity(state, name, def.name);
148
163
  return { name: def.name };
149
164
  }
150
- if (!schema || !handler)
151
- throw new Error("writeHandler inline form requires schema + handler");
165
+ const inline = requireInlineHandlerArgs("writeHandler", schema, handler, options);
152
166
  state.writeHandlers[nameOrDef] = {
153
167
  name: nameOrDef,
154
- schema,
155
- handler: handler as WriteHandlerFn, // @cast-boundary engine-bridge
156
- ...(options?.access && { access: options.access }),
157
- ...agentSlots(options ?? {}),
158
- ...(options?.rateLimit && { rateLimit: options.rateLimit }),
168
+ schema: inline.schema,
169
+ handler: inline.handler as WriteHandlerFn, // @cast-boundary engine-bridge
170
+ access: inline.options.access,
171
+ ...agentSlots(inline.options),
172
+ ...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
173
+ ...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
159
174
  };
160
175
  tryMapEntity(state, name, nameOrDef);
161
176
  return { name: nameOrDef };
@@ -165,7 +180,7 @@ export function buildEntityHandlerMethods<TName extends string>(
165
180
  schema?: TSchema,
166
181
  handler?: QueryHandlerFn<z.infer<TSchema>>,
167
182
  options?: {
168
- access?: AccessRule;
183
+ access: AccessRule;
169
184
  rateLimit?: RateLimitOption;
170
185
  outputSchema?: ZodType;
171
186
  description?: string;
@@ -179,7 +194,7 @@ export function buildEntityHandlerMethods<TName extends string>(
179
194
  schema: def.schema,
180
195
  // @cast-boundary engine-bridge — typed Dev-API → erased internal storage
181
196
  handler: def.handler as QueryHandlerFn, // @cast-boundary engine-bridge
182
- ...(def.access && { access: def.access }),
197
+ access: def.access,
183
198
  ...agentSlots(def),
184
199
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
185
200
  ...(def.outputSchema && { outputSchema: def.outputSchema }),
@@ -193,16 +208,15 @@ export function buildEntityHandlerMethods<TName extends string>(
193
208
  tryMapEntity(state, name, def.name);
194
209
  return { name: def.name };
195
210
  }
196
- if (!schema || !handler)
197
- throw new Error("queryHandler inline form requires schema + handler");
211
+ const inline = requireInlineHandlerArgs("queryHandler", schema, handler, options);
198
212
  state.queryHandlers[nameOrDef] = {
199
213
  name: nameOrDef,
200
- schema,
201
- handler: handler as QueryHandlerFn, // @cast-boundary engine-bridge
202
- ...(options?.access && { access: options.access }),
203
- ...agentSlots(options ?? {}),
204
- ...(options?.rateLimit && { rateLimit: options.rateLimit }),
205
- ...(options?.outputSchema && { outputSchema: options.outputSchema }),
214
+ schema: inline.schema,
215
+ handler: inline.handler as QueryHandlerFn, // @cast-boundary engine-bridge
216
+ access: inline.options.access,
217
+ ...agentSlots(inline.options),
218
+ ...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
219
+ ...(inline.options.outputSchema && { outputSchema: inline.options.outputSchema }),
206
220
  };
207
221
  tryMapEntity(state, name, nameOrDef);
208
222
  return { name: nameOrDef };
@@ -211,7 +225,7 @@ export function buildEntityHandlerMethods<TName extends string>(
211
225
  nameOrDef: string | StreamHandlerDefinition<TName, TSchema>,
212
226
  schema?: TSchema,
213
227
  handler?: StreamHandlerFn<z.infer<TSchema>>,
214
- options?: { access?: AccessRule; rateLimit?: RateLimitOption },
228
+ options?: { access: AccessRule; rateLimit?: RateLimitOption },
215
229
  ): HandlerRef {
216
230
  if (typeof nameOrDef === "object") {
217
231
  const def = nameOrDef;
@@ -220,19 +234,18 @@ export function buildEntityHandlerMethods<TName extends string>(
220
234
  schema: def.schema,
221
235
  // @cast-boundary engine-bridge — typed Dev-API → erased internal storage
222
236
  handler: def.handler as StreamHandlerFn, // @cast-boundary engine-bridge
223
- ...(def.access && { access: def.access }),
237
+ access: def.access,
224
238
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
225
239
  };
226
240
  return { name: def.name };
227
241
  }
228
- if (!schema || !handler)
229
- throw new Error("streamHandler inline form requires schema + handler");
242
+ const inline = requireInlineHandlerArgs("streamHandler", schema, handler, options);
230
243
  state.streamHandlers[nameOrDef] = {
231
244
  name: nameOrDef,
232
- schema,
233
- handler: handler as StreamHandlerFn, // @cast-boundary engine-bridge
234
- ...(options?.access && { access: options.access }),
235
- ...(options?.rateLimit && { rateLimit: options.rateLimit }),
245
+ schema: inline.schema,
246
+ handler: inline.handler as StreamHandlerFn, // @cast-boundary engine-bridge
247
+ access: inline.options.access,
248
+ ...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
236
249
  };
237
250
  return { name: nameOrDef };
238
251
  },
@@ -358,6 +358,7 @@ export type {
358
358
  EntityListScreenDefinition,
359
359
  EntityRef,
360
360
  EntityRelations,
361
+ EscapeHatchDeclaration,
361
362
  EventDef,
362
363
  FeatureDefinition,
363
364
  FeatureRegistrar,
@@ -414,6 +415,8 @@ export type {
414
415
  NotifyPriority,
415
416
  NumberFieldDef,
416
417
  OnDeleteStrategy,
418
+ OpenToAllAccessRule,
419
+ OpenToAllDeclaration,
417
420
  ParentRefDef,
418
421
  PersonalAnnotations,
419
422
  PersonalAnnotationsLongText,
@@ -489,6 +492,7 @@ export {
489
492
  DEFAULT_LOCALES,
490
493
  HookPhases,
491
494
  isAgentVisibleScreen,
495
+ isOpenToAllGranted,
492
496
  resolveAgentExposure,
493
497
  } from "./types";
494
498
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
@@ -1,2 +1,3 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
2
  export type * from "@cosmicdrift/kumiko-types/handlers";
3
+ export { isOpenToAllGranted } from "@cosmicdrift/kumiko-types/handlers";
@@ -156,6 +156,7 @@ export type {
156
156
  DeclarativeEventMigration,
157
157
  DispatchWriteRef,
158
158
  EntityRef,
159
+ EscapeHatchDeclaration,
159
160
  EventDef,
160
161
  EventMigrationDef,
161
162
  EventPiiFields,
@@ -172,6 +173,8 @@ export type {
172
173
  NotifyFn,
173
174
  NotifyOptions,
174
175
  NotifyPriority,
176
+ OpenToAllAccessRule,
177
+ OpenToAllDeclaration,
175
178
  QualifiedEventName,
176
179
  QueryEvent,
177
180
  QueryHandlerDef,
@@ -189,6 +192,7 @@ export type {
189
192
  WriteHandlerFn,
190
193
  WriteResult,
191
194
  } from "@cosmicdrift/kumiko-types/handlers";
195
+ export { isOpenToAllGranted } from "@cosmicdrift/kumiko-types/handlers";
192
196
  export type {
193
197
  DeleteContext,
194
198
  EntityHookMap,
@@ -0,0 +1,93 @@
1
+ // fw#2855 — a write handler's `escapeHatch` gates writes through ctx.db.global(table):
2
+ // WITH it the write succeeds, WITHOUT it the same handler rejects with access_denied.
3
+ // Real HTTP calls + setupTestStack — never createTestDispatcher.
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { z } from "zod";
7
+ import { defineUnmanagedTable } from "../../db/entity-table-meta";
8
+ import { defineFeature } from "../../engine";
9
+ import { createTestUser, setupTestStack, type TestStack } from "../../stack";
10
+
11
+ const globalStoreTable = defineUnmanagedTable({
12
+ tableName: "store_fw2855_escape_hatch_items",
13
+ tenancy: "global",
14
+ columns: [
15
+ {
16
+ name: "id",
17
+ pgType: "uuid",
18
+ notNull: true,
19
+ primaryKey: true,
20
+ defaultSql: "gen_random_uuid()",
21
+ },
22
+ { name: "tenant_id", pgType: "uuid", notNull: true },
23
+ { name: "note", pgType: "text", notNull: true },
24
+ ],
25
+ });
26
+
27
+ const escapeHatchFeature = defineFeature("escape-hatch-probe", (r) => {
28
+ r.storeTable(globalStoreTable, {
29
+ reason: "fw#2855 integration test — cross-tenant store table for db.global()",
30
+ });
31
+
32
+ r.writeHandler({
33
+ name: "write-with-hatch",
34
+ schema: z.object({ note: z.string() }),
35
+ access: { roles: ["User"] },
36
+ escapeHatch: { reason: "fw#2855 integration test — declared cross-tenant write" },
37
+ handler: async (event, ctx) => {
38
+ const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
39
+ tenantId: event.user.tenantId,
40
+ note: event.payload.note,
41
+ });
42
+ return { isSuccess: true as const, data: row };
43
+ },
44
+ });
45
+
46
+ r.writeHandler({
47
+ name: "write-without-hatch",
48
+ schema: z.object({ note: z.string() }),
49
+ access: { roles: ["User"] },
50
+ handler: async (event, ctx) => {
51
+ const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
52
+ tenantId: event.user.tenantId,
53
+ note: event.payload.note,
54
+ });
55
+ return { isSuccess: true as const, data: row };
56
+ },
57
+ });
58
+ });
59
+
60
+ let stack: TestStack;
61
+ const user = createTestUser({
62
+ id: 91,
63
+ tenantId: "11111111-0000-4000-8000-000000000091",
64
+ roles: ["User"],
65
+ });
66
+
67
+ beforeAll(async () => {
68
+ stack = await setupTestStack({ features: [escapeHatchFeature] });
69
+ });
70
+
71
+ afterAll(async () => {
72
+ await stack.cleanup();
73
+ });
74
+
75
+ describe("write handler escapeHatch gates ctx.db.global() writes", () => {
76
+ test("WITH escapeHatch: the write succeeds", async () => {
77
+ const result = await stack.http.writeOk<{ id: string }>(
78
+ "escape-hatch-probe:write:write-with-hatch",
79
+ { note: "hi" },
80
+ user,
81
+ );
82
+ expect(result.id).toBeDefined();
83
+ });
84
+
85
+ test("WITHOUT escapeHatch: the same write shape fails with access_denied", async () => {
86
+ const err = await stack.http.writeErr(
87
+ "escape-hatch-probe:write:write-without-hatch",
88
+ { note: "hi" },
89
+ user,
90
+ );
91
+ expect(err.code).toBe("access_denied");
92
+ });
93
+ });