@cosmicdrift/kumiko-framework 0.259.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 (34) 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 +41 -16
  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__/projection-detail-tabs.test.ts +43 -0
  14. package/src/engine/__tests__/registry.test.ts +16 -5
  15. package/src/engine/__tests__/screen.test.ts +20 -0
  16. package/src/engine/access.ts +2 -1
  17. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +200 -0
  18. package/src/engine/boot-validator/access-declarations.ts +141 -0
  19. package/src/engine/boot-validator/entity-handler.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +2 -0
  21. package/src/engine/boot-validator/screens.ts +53 -38
  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 +4 -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
@@ -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
+ });
@@ -206,6 +206,8 @@ export async function buildHandlerContext(
206
206
  // but at this point we're the root of the pipeline — cast is safe.
207
207
  const dbSource = resolveDbSource(ctx, tx);
208
208
  const reqCtx = requestContext.get();
209
+ // db.global()'s write-gate — undefined for query/stream handlers, which can't declare escapeHatch.
210
+ const escapeHatch = registry.getWriteHandler(type)?.escapeHatch;
209
211
  const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
210
212
  createTenantDb(
211
213
  source,
@@ -214,6 +216,7 @@ export async function buildHandlerContext(
214
216
  context.tracer,
215
217
  context.meter,
216
218
  signal,
219
+ escapeHatch,
217
220
  );
218
221
  // Propagate the request's AbortSignal so every TenantDb query throws when
219
222
  // the client has disconnected — handlers with many sequential queries skip
@@ -54,6 +54,7 @@ declare const aSecret: Secret<string>;
54
54
  defineQueryHandler({
55
55
  name: "t:query:leak",
56
56
  schema,
57
+ access: { openToAll: true },
57
58
  handler: async () => ({ apiKey: aSecret }),
58
59
  });
59
60
 
@@ -64,6 +65,7 @@ function genericResponseHandler<K extends string>(kind: K) {
64
65
  return defineQueryHandler({
65
66
  name: "t:query:generic",
66
67
  schema,
68
+ access: { openToAll: true },
67
69
  handler: async () => ({ kind, ok: true }) as { kind: K; ok: boolean },
68
70
  });
69
71
  }
@@ -81,6 +83,7 @@ declare const leakingUnion: { ok: true } | { apiKey: Secret<string> };
81
83
  defineQueryHandler({
82
84
  name: "t:query:union-leak",
83
85
  schema,
86
+ access: { openToAll: true },
84
87
  handler: async () => leakingUnion,
85
88
  });
86
89
 
@@ -88,6 +91,7 @@ declare const cleanUnion: { ok: true } | { id: string };
88
91
  defineQueryHandler({
89
92
  name: "t:query:union-clean",
90
93
  schema,
94
+ access: { openToAll: true },
91
95
  handler: async () => cleanUnion,
92
96
  });
93
97
 
@@ -96,6 +100,7 @@ describe("R6 ContainsSecret", () => {
96
100
  const def = defineQueryHandler({
97
101
  name: "t:query:clean",
98
102
  schema,
103
+ access: { openToAll: true },
99
104
  handler: async () => ({ ok: true, value: createSecret("x").reveal() }),
100
105
  });
101
106
  expect(def.name).toBe("t:query:clean");
@@ -34,7 +34,7 @@ function createTasksFeature() {
34
34
  return defineFeature("tasks", (r) => {
35
35
  r.systemScope();
36
36
  r.entity("task", taskEntity);
37
- r.writeHandler(defineEntityCreateHandler("task", taskEntity));
37
+ r.writeHandler(defineEntityCreateHandler("task", taskEntity, { access: { openToAll: true } }));
38
38
  r.screen({
39
39
  id: "task-list",
40
40
  type: "entityList",
@@ -105,7 +105,7 @@ describe("generateE2ESpec", () => {
105
105
  const feature = defineFeature("events", (r) => {
106
106
  r.systemScope();
107
107
  r.entity("event", entity);
108
- r.writeHandler(defineEntityCreateHandler("event", entity));
108
+ r.writeHandler(defineEntityCreateHandler("event", entity, { access: { openToAll: true } }));
109
109
  r.screen({
110
110
  id: "event-edit",
111
111
  type: "entityEdit",
@@ -164,7 +164,9 @@ describe("generateE2ESpec", () => {
164
164
  const editOnly = defineFeature("edit-only", (r) => {
165
165
  r.systemScope();
166
166
  r.entity("task", taskEntity);
167
- r.writeHandler(defineEntityCreateHandler("task", taskEntity));
167
+ r.writeHandler(
168
+ defineEntityCreateHandler("task", taskEntity, { access: { openToAll: true } }),
169
+ );
168
170
  r.screen({
169
171
  id: "edit",
170
172
  type: "entityEdit",
@@ -198,7 +200,9 @@ describe("generateE2ESpec", () => {
198
200
  const feature = defineFeature("contacts", (r) => {
199
201
  r.systemScope();
200
202
  r.entity("contact", contactEntity);
201
- r.writeHandler(defineEntityCreateHandler("contact", contactEntity));
203
+ r.writeHandler(
204
+ defineEntityCreateHandler("contact", contactEntity, { access: { openToAll: true } }),
205
+ );
202
206
  r.screen({
203
207
  id: "list",
204
208
  type: "entityList",
@@ -220,7 +224,9 @@ describe("generateE2ESpec", () => {
220
224
  const mixed = defineFeature("mixed", (r) => {
221
225
  r.systemScope();
222
226
  r.entity("task", taskEntity);
223
- r.writeHandler(defineEntityCreateHandler("task", taskEntity));
227
+ r.writeHandler(
228
+ defineEntityCreateHandler("task", taskEntity, { access: { openToAll: true } }),
229
+ );
224
230
  r.screen({ id: "list", type: "entityList", entity: "task", columns: ["title"] });
225
231
  r.screen({
226
232
  id: "edit",
@@ -52,7 +52,12 @@ export type {
52
52
  SelectFieldDef,
53
53
  TextFieldDef,
54
54
  } from "../engine/types/fields";
55
- export type { AccessRule } from "../engine/types/handlers";
55
+ export type {
56
+ AccessRule,
57
+ OpenToAllAccessRule,
58
+ OpenToAllDeclaration,
59
+ } from "../engine/types/handlers";
60
+ export { isOpenToAllGranted } from "../engine/types/handlers";
56
61
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav";
57
62
  export type {
58
63
  ActionFormRedirect,