@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
@@ -891,6 +891,7 @@ describe("buildAppSchema", () => {
891
891
  cursor: z.string().optional(),
892
892
  }),
893
893
  async () => ({ rows: [], nextCursor: null }),
894
+ { access: { openToAll: true } },
894
895
  );
895
896
  r.screen({
896
897
  id: "schedule-list",
@@ -909,7 +910,9 @@ describe("buildAppSchema", () => {
909
910
 
910
911
  test("projectionList: a query schema without search/sort/cursor derives no capability", () => {
911
912
  const f = defineFeature("ledger", (r) => {
912
- r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }));
913
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
914
+ access: { openToAll: true },
915
+ });
913
916
  r.screen({
914
917
  id: "schedule-list",
915
918
  type: "projectionList",
@@ -931,6 +934,7 @@ describe("buildAppSchema", () => {
931
934
  "schedule:list",
932
935
  z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]),
933
936
  async () => ({ rows: [], nextCursor: null }),
937
+ { access: { openToAll: true } },
934
938
  );
935
939
  r.screen({
936
940
  id: "schedule-list",
@@ -952,10 +956,15 @@ describe("buildAppSchema", () => {
952
956
 
953
957
  test("projectionList: author-written searchable:false survives even when the schema accepts search", () => {
954
958
  const f = defineFeature("ledger", (r) => {
955
- r.queryHandler("schedule:list", z.object({ search: z.string().optional() }), async () => ({
956
- rows: [],
957
- nextCursor: null,
958
- }));
959
+ r.queryHandler(
960
+ "schedule:list",
961
+ z.object({ search: z.string().optional() }),
962
+ async () => ({
963
+ rows: [],
964
+ nextCursor: null,
965
+ }),
966
+ { access: { openToAll: true } },
967
+ );
959
968
  r.screen({
960
969
  id: "schedule-list",
961
970
  type: "projectionList",
@@ -15,6 +15,7 @@ import {
15
15
  createTextField,
16
16
  } from "../factories";
17
17
  import { createApp, createRegistry, defineFeature } from "../index";
18
+ import type { AccessRule } from "../types";
18
19
  import { buildMinimalCtx } from "./_pipeline-test-utils";
19
20
 
20
21
  // --- defineFeature ---
@@ -930,6 +931,15 @@ describe("hasAccess", () => {
930
931
  const user = createTestUser({ roles: [] });
931
932
  expect(hasAccess(user, { openToAll: true })).toBe(true);
932
933
  });
934
+
935
+ test.each([{ openToAll: false }, { openToAll: {} }])(
936
+ "malformed openToAll %p denies access instead of crashing",
937
+ (access) => {
938
+ const user = createTestUser({ roles: ["Employee"] });
939
+ // @cast-boundary test — simulates JSON/Designer input that doesn't match the static union
940
+ expect(hasAccess(user, access as unknown as AccessRule)).toBe(false);
941
+ },
942
+ );
933
943
  });
934
944
 
935
945
  // --- Entity with softDelete ---
@@ -37,29 +37,35 @@ const noteEntitySoftDelete = createEntity({
37
37
  softDelete: true,
38
38
  });
39
39
 
40
+ const adminAccess = { access: { roles: ["Admin"] } } as const;
41
+
40
42
  describe("defineEntityWriteHandler", () => {
41
43
  test("throws when name has no colon", () => {
42
- expect(() => defineEntityWriteHandler("note", noteEntity)).toThrow(/<entity>:<verb>/);
44
+ expect(() => defineEntityWriteHandler("note", noteEntity, adminAccess)).toThrow(
45
+ /<entity>:<verb>/,
46
+ );
43
47
  });
44
48
 
45
49
  test("throws when entity part is empty", () => {
46
- expect(() => defineEntityWriteHandler(":create", noteEntity)).toThrow(
50
+ expect(() => defineEntityWriteHandler(":create", noteEntity, adminAccess)).toThrow(
47
51
  /missing the entity part/,
48
52
  );
49
53
  });
50
54
 
51
55
  test("throws when verb is unknown", () => {
52
- expect(() => defineEntityWriteHandler("note:archive", noteEntity)).toThrow(
56
+ expect(() => defineEntityWriteHandler("note:archive", noteEntity, adminAccess)).toThrow(
53
57
  /Unknown verb "archive"/,
54
58
  );
55
59
  });
56
60
 
57
61
  test("throws when restore is requested on an entity without softDelete", () => {
58
- expect(() => defineEntityRestoreHandler("note", noteEntity)).toThrow(/restore is only valid/);
62
+ expect(() => defineEntityRestoreHandler("note", noteEntity, adminAccess)).toThrow(
63
+ /restore is only valid/,
64
+ );
59
65
  });
60
66
 
61
67
  test("create: handler def carries name, schema, handler", () => {
62
- const def = defineEntityCreateHandler("note", noteEntity);
68
+ const def = defineEntityCreateHandler("note", noteEntity, adminAccess);
63
69
  expect(def.name).toBe("note:create");
64
70
  expect(typeof def.handler).toBe("function");
65
71
  expect(def.schema.safeParse({ title: "x" }).success).toBe(true);
@@ -67,7 +73,7 @@ describe("defineEntityWriteHandler", () => {
67
73
  });
68
74
 
69
75
  test("update: schema requires id + version + changes", () => {
70
- const def = defineEntityUpdateHandler("note", noteEntity);
76
+ const def = defineEntityUpdateHandler("note", noteEntity, adminAccess);
71
77
  expect(
72
78
  def.schema.safeParse({ id: VALID_UUID, version: 1, changes: { title: "x" } }).success,
73
79
  ).toBe(true);
@@ -76,13 +82,13 @@ describe("defineEntityWriteHandler", () => {
76
82
  });
77
83
 
78
84
  test("delete: schema requires only id", () => {
79
- const def = defineEntityDeleteHandler("note", noteEntity);
85
+ const def = defineEntityDeleteHandler("note", noteEntity, adminAccess);
80
86
  expect(def.schema.safeParse({ id: VALID_UUID }).success).toBe(true);
81
87
  expect(def.schema.safeParse({}).success).toBe(false);
82
88
  });
83
89
 
84
90
  test("restore: schema requires only id (with softDelete)", () => {
85
- const def = defineEntityRestoreHandler("note", noteEntitySoftDelete);
91
+ const def = defineEntityRestoreHandler("note", noteEntitySoftDelete, adminAccess);
86
92
  expect(def.schema.safeParse({ id: VALID_UUID }).success).toBe(true);
87
93
  expect(def.schema.safeParse({}).success).toBe(false);
88
94
  });
@@ -94,21 +100,21 @@ describe("defineEntityWriteHandler", () => {
94
100
  expect(def.access).toEqual({ roles: ["Admin"] });
95
101
  });
96
102
 
97
- test("omitting access leaves the handler def's access unset", () => {
98
- const def = defineEntityCreateHandler("note", noteEntity);
99
- expect(def.access).toBeUndefined();
103
+ test("access is now a required option — omitting it is a type error", () => {
104
+ // @ts-expect-error access is required since fw#2855 — no implicit openToAll.
105
+ expect(() => defineEntityCreateHandler("note", noteEntity)).toThrow();
100
106
  });
101
107
  });
102
108
 
103
109
  describe("defineEntityQueryHandler", () => {
104
110
  test("throws when verb is unknown (write verbs are not allowed here)", () => {
105
- expect(() => defineEntityQueryHandler("note:create", noteEntity)).toThrow(
111
+ expect(() => defineEntityQueryHandler("note:create", noteEntity, adminAccess)).toThrow(
106
112
  /Unknown verb "create"/,
107
113
  );
108
114
  });
109
115
 
110
116
  test("list: schema accepts the standard pagination/search/sort params", () => {
111
- const def = defineEntityListHandler("note", noteEntity);
117
+ const def = defineEntityListHandler("note", noteEntity, adminAccess);
112
118
  expect(def.schema.safeParse({}).success).toBe(true);
113
119
  expect(
114
120
  def.schema.safeParse({
@@ -123,7 +129,7 @@ describe("defineEntityQueryHandler", () => {
123
129
  });
124
130
 
125
131
  test("list: schema is the exported entityListSchema, not a private copy", () => {
126
- const def = defineEntityListHandler("note", noteEntity);
132
+ const def = defineEntityListHandler("note", noteEntity, adminAccess);
127
133
  expect(def.schema).toBe(entityListSchema);
128
134
  // money-horse#293: a consumer copy silently dropped these fields when it
129
135
  // drifted from the handler's actual schema — assert they still round-trip.
@@ -136,7 +142,7 @@ describe("defineEntityQueryHandler", () => {
136
142
  });
137
143
 
138
144
  test("detail: schema requires id", () => {
139
- const def = defineEntityDetailHandler("note", noteEntity);
145
+ const def = defineEntityDetailHandler("note", noteEntity, adminAccess);
140
146
  expect(def.schema.safeParse({ id: VALID_UUID }).success).toBe(true);
141
147
  expect(def.schema.safeParse({}).success).toBe(false);
142
148
  });
@@ -186,6 +192,7 @@ describe("defineProjectionQueryHandler", () => {
186
192
  const def = defineProjectionQueryHandler(
187
193
  "revenue:list",
188
194
  "showcase:projection:customer-revenue",
195
+ { access: { openToAll: true } },
189
196
  );
190
197
  const fakeRows = [{ customer: "a", totalCents: 100 }];
191
198
  const ctx = {
@@ -208,7 +215,7 @@ describe("defineProjectionQueryHandler", () => {
208
215
  const def = defineProjectionQueryHandler(
209
216
  "revenue:list",
210
217
  "showcase:projection:customer-revenue",
211
- { unsafeAllTenants: true },
218
+ { access: { openToAll: true }, unsafeAllTenants: true },
212
219
  );
213
220
  const ctx = { queryProjection: mock().mockResolvedValue([]) };
214
221
  await def.handler(
@@ -222,12 +229,11 @@ describe("defineProjectionQueryHandler", () => {
222
229
  });
223
230
  });
224
231
 
225
- test("omitting access leaves the handler def's access unset", () => {
226
- const def = defineProjectionQueryHandler(
227
- "revenue:list",
228
- "showcase:projection:customer-revenue",
229
- );
230
- expect(def.access).toBeUndefined();
232
+ test("access is now a required option — omitting it is a type error", () => {
233
+ expect(() =>
234
+ // @ts-expect-error access is required since fw#2855 — no implicit openToAll.
235
+ defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue"),
236
+ ).toThrow();
231
237
  });
232
238
  });
233
239
 
@@ -247,22 +253,24 @@ describe("Verb-specific entity-handler factories", () => {
247
253
  });
248
254
 
249
255
  test("defineEntityUpdateHandler produziert <entity>:update", () => {
250
- const def = defineEntityUpdateHandler("note", noteEntity);
256
+ const def = defineEntityUpdateHandler("note", noteEntity, adminAccess);
251
257
  expect(def.name).toBe("note:update");
252
258
  });
253
259
 
254
260
  test("defineEntityDeleteHandler produziert <entity>:delete", () => {
255
- const def = defineEntityDeleteHandler("note", noteEntity);
261
+ const def = defineEntityDeleteHandler("note", noteEntity, adminAccess);
256
262
  expect(def.name).toBe("note:delete");
257
263
  });
258
264
 
259
265
  test("defineEntityRestoreHandler produziert <entity>:restore (auf softDelete-Entity)", () => {
260
- const def = defineEntityRestoreHandler("note", noteEntitySoftDelete);
266
+ const def = defineEntityRestoreHandler("note", noteEntitySoftDelete, adminAccess);
261
267
  expect(def.name).toBe("note:restore");
262
268
  });
263
269
 
264
270
  test("defineEntityRestoreHandler ohne softDelete → throw (Runtime-Guard bleibt)", () => {
265
- expect(() => defineEntityRestoreHandler("note", noteEntity)).toThrow(/softDelete: true/);
271
+ expect(() => defineEntityRestoreHandler("note", noteEntity, adminAccess)).toThrow(
272
+ /softDelete: true/,
273
+ );
266
274
  });
267
275
 
268
276
  test("defineEntityListHandler produziert <entity>:list", () => {
@@ -274,7 +282,7 @@ describe("Verb-specific entity-handler factories", () => {
274
282
  });
275
283
 
276
284
  test("defineEntityDetailHandler produziert <entity>:detail", () => {
277
- const def = defineEntityDetailHandler("note", noteEntity);
285
+ const def = defineEntityDetailHandler("note", noteEntity, adminAccess);
278
286
  expect(def.name).toBe("note:detail");
279
287
  });
280
288
 
@@ -284,8 +292,8 @@ describe("Verb-specific entity-handler factories", () => {
284
292
  // defineEntityCreateHandler("note", ...) sein. Wir vergleichen
285
293
  // hier die Schema-Identitäten + Handler-Namen; Behavior-Tests des
286
294
  // Executors leben in event-store-executor.integration.ts.
287
- const newApi = defineEntityCreateHandler("note", noteEntity);
288
- const legacyApi = defineEntityCreateHandler("note", noteEntity);
295
+ const newApi = defineEntityCreateHandler("note", noteEntity, adminAccess);
296
+ const legacyApi = defineEntityCreateHandler("note", noteEntity, adminAccess);
289
297
  expect(newApi.name).toBe(legacyApi.name);
290
298
  expect(typeof newApi.handler).toBe("function");
291
299
  expect(typeof legacyApi.handler).toBe("function");
@@ -337,17 +345,16 @@ describe("registerEntityCrud", () => {
337
345
  test("verbs.delete: false skips delete handler", () => {
338
346
  const { r, writes } = createCrudRegistrarMock();
339
347
  registerEntityCrud(r, "note", noteEntity, {
348
+ write: adminAccess,
349
+ read: adminAccess,
340
350
  verbs: { delete: false },
341
351
  });
342
352
  expect(writes.map((w) => w.name)).toEqual(["note:create", "note:update"]);
343
353
  });
344
354
 
345
- test("no options → handlers have no access (no implicit openToAll)", () => {
346
- const { r, writes, queries } = createCrudRegistrarMock();
347
- registerEntityCrud(r, "note", noteEntity);
348
- for (const def of [...writes, ...queries]) {
349
- expect(def.access).toBeUndefined();
350
- }
355
+ test("no options → registration throws (no implicit openToAll)", () => {
356
+ const { r } = createCrudRegistrarMock();
357
+ expect(() => registerEntityCrud(r, "note", noteEntity)).toThrow(/no access rule resolved/);
351
358
  });
352
359
 
353
360
  test("restore verb on entity without softDelete throws", () => {
@@ -361,6 +368,7 @@ describe("registerEntityCrud", () => {
361
368
  const { r, entities, writes } = createCrudRegistrarMock();
362
369
  r.entity("note", noteEntity);
363
370
  registerEntityCrud(r, "note", noteEntity, {
371
+ write: adminAccess,
364
372
  registerEntity: false,
365
373
  verbs: { update: false, delete: false, restore: false, list: false, detail: false },
366
374
  });
@@ -370,6 +378,8 @@ describe("registerEntityCrud", () => {
370
378
  test("descriptions: per-verb text lands on that verb only, and drives agent exposure", () => {
371
379
  const { r, writes, queries } = createCrudRegistrarMock();
372
380
  registerEntityCrud(r, "note", noteEntity, {
381
+ write: adminAccess,
382
+ read: adminAccess,
373
383
  descriptions: {
374
384
  create: "Files a new note.",
375
385
  list: "Lists notes newest first.",
@@ -398,7 +408,7 @@ describe("registerEntityCrud", () => {
398
408
  test("descriptions: per-verb entry wins over the write/read fallback", () => {
399
409
  const { r, writes } = createCrudRegistrarMock();
400
410
  registerEntityCrud(r, "note", noteEntity, {
401
- write: { description: "Shared write text." },
411
+ write: { access: { roles: ["Admin"] }, description: "Shared write text." },
402
412
  descriptions: { create: "Files a new note." },
403
413
  verbs: { list: false, detail: false },
404
414
  });
@@ -409,10 +419,12 @@ describe("registerEntityCrud", () => {
409
419
 
410
420
  test("agent hints on the per-handler options raise the resolved risk", () => {
411
421
  const del = defineEntityDeleteHandler("note", noteEntity, {
422
+ access: { roles: ["Admin"] },
412
423
  description: "Deletes a note permanently.",
413
424
  agent: { risk: "high" },
414
425
  });
415
426
  const create = defineEntityCreateHandler("note", noteEntity, {
427
+ access: { roles: ["Admin"] },
416
428
  description: "Files a new note.",
417
429
  });
418
430
 
@@ -115,10 +115,15 @@ describe("extendsRegistrar", () => {
115
115
  fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
116
116
  }),
117
117
  );
118
- r.writeHandler("create", z.object({ name: z.string() }), async () => ({
119
- isSuccess: true as const,
120
- data: { id: "c1" },
121
- }));
118
+ r.writeHandler(
119
+ "create",
120
+ z.object({ name: z.string() }),
121
+ async () => ({
122
+ isSuccess: true as const,
123
+ data: { id: "c1" },
124
+ }),
125
+ { access: { openToAll: true } },
126
+ );
122
127
  r.useExtension("audited", "credit");
123
128
  });
124
129
 
@@ -149,14 +154,24 @@ describe("extendsRegistrar", () => {
149
154
  // Explicit handlers — the entity mapping is inferred from the
150
155
  // "vehicle:" prefix via tryMapEntity, so the extension's preSave
151
156
  // wires onto every entity-scoped handler automatically.
152
- r.writeHandler("vehicle:create", z.object({ name: z.string() }), async () => ({
153
- isSuccess: true as const,
154
- data: { id: "v1" },
155
- }));
156
- r.writeHandler("vehicle:update", z.object({ id: z.string() }), async () => ({
157
- isSuccess: true as const,
158
- data: { id: "v1" },
159
- }));
157
+ r.writeHandler(
158
+ "vehicle:create",
159
+ z.object({ name: z.string() }),
160
+ async () => ({
161
+ isSuccess: true as const,
162
+ data: { id: "v1" },
163
+ }),
164
+ { access: { openToAll: true } },
165
+ );
166
+ r.writeHandler(
167
+ "vehicle:update",
168
+ z.object({ id: z.string() }),
169
+ async () => ({
170
+ isSuccess: true as const,
171
+ data: { id: "v1" },
172
+ }),
173
+ { access: { openToAll: true } },
174
+ );
160
175
  r.useExtension("audited", "vehicle");
161
176
  });
162
177
 
@@ -136,10 +136,14 @@ describe("getAllStreamHandlers", () => {
136
136
 
137
137
  test("duplicate stream-handler short-name across features qualifies independently, no collision", () => {
138
138
  const aiFeature = defineFeature("registry-test-dup-a", (r) => {
139
- r.streamHandler("chat:complete", z.object({}), async function* () {});
139
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
140
+ access: { openToAll: true },
141
+ });
140
142
  });
141
143
  const otherFeature = defineFeature("registry-test-dup-b", (r) => {
142
- r.streamHandler("chat:complete", z.object({}), async function* () {});
144
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
145
+ access: { openToAll: true },
146
+ });
143
147
  });
144
148
  expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
145
149
  });
@@ -149,10 +153,14 @@ describe("getAllStreamHandlers", () => {
149
153
  // feature.name values (so the earlier Duplicate-feature guard doesn't
150
154
  // fire) but toKebab() collapses both to the same qualified name.
151
155
  const featureA = defineFeature("registry-test-kebab-dup", (r) => {
152
- r.streamHandler("chat:complete", z.object({}), async function* () {});
156
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
157
+ access: { openToAll: true },
158
+ });
153
159
  });
154
160
  const featureB = defineFeature("registryTestKebabDup", (r) => {
155
- r.streamHandler("chat:complete", z.object({}), async function* () {});
161
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
162
+ access: { openToAll: true },
163
+ });
156
164
  });
157
165
  expect(() => createRegistry([featureA, featureB])).toThrow(/Duplicate stream handler/);
158
166
  });
@@ -188,6 +196,7 @@ describe("double-qualified handler names (#1991)", () => {
188
196
  "registry-test-ai-orch:query:duplicate-candidates",
189
197
  z.object({}),
190
198
  async () => ({}),
199
+ { access: { openToAll: true } },
191
200
  );
192
201
  });
193
202
 
@@ -196,7 +205,9 @@ describe("double-qualified handler names (#1991)", () => {
196
205
 
197
206
  test("createRegistry allows a sub-structured short name whose entity prefix merely resembles the feature name", () => {
198
207
  const feature = defineFeature("registry-test-invoices", (r) => {
199
- r.queryHandler("invoice:mark-paid", z.object({}), async () => ({}));
208
+ r.queryHandler("invoice:mark-paid", z.object({}), async () => ({}), {
209
+ access: { openToAll: true },
210
+ });
200
211
  });
201
212
 
202
213
  expect(() => createRegistry([feature])).not.toThrow();
@@ -1,5 +1,6 @@
1
1
  import { ANONYMOUS_ROLE } from "./system-user";
2
2
  import type { AccessRule, SessionUser } from "./types";
3
+ import { isOpenToAllGranted } from "./types";
3
4
 
4
5
  // Default-deny: a handler without an explicit AccessRule is unreachable. To
5
6
  // grant access a handler must either list allowed roles or opt into
@@ -18,7 +19,7 @@ export function hasAccess(
18
19
  ): boolean {
19
20
  if (!access) return false;
20
21
  if ("openToAll" in access) {
21
- if (access.openToAll !== true) return false;
22
+ if (!isOpenToAllGranted(access)) return false;
22
23
  return !user.roles.includes(ANONYMOUS_ROLE);
23
24
  }
24
25
  if (access.roles.length === 0) return false;
@@ -0,0 +1,200 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../define-feature";
4
+ import { createEntity, createTextField } from "../../factories";
5
+ import type { AccessRule } from "../../types";
6
+ import { validateAccessDeclarations } from "../access-declarations";
7
+
8
+ const noteEntity = createEntity({
9
+ table: "fw2855_guard_notes",
10
+ fields: {
11
+ email: createTextField({ personal: "self", find: "none" }),
12
+ title: createTextField({ personal: false, reason: "test_fixture" }),
13
+ },
14
+ });
15
+
16
+ describe("validateAccessDeclarations", () => {
17
+ // 1. openToAll reason must be non-empty.
18
+ test("openToAll with an empty reason throws, naming the handler", () => {
19
+ const feature = defineFeature("notes", (r) => {
20
+ r.entity("note", noteEntity);
21
+ r.writeHandler(
22
+ "note:create",
23
+ z.object({ title: z.string() }),
24
+ async () => ({
25
+ isSuccess: true as const,
26
+ data: {},
27
+ }),
28
+ {
29
+ access: { openToAll: { reason: "" } },
30
+ },
31
+ );
32
+ });
33
+ expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
34
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
35
+ });
36
+
37
+ test("openToAll with a non-empty reason boots fine", () => {
38
+ const feature = defineFeature("notes", (r) => {
39
+ r.entity("note", noteEntity);
40
+ r.writeHandler(
41
+ "note:create",
42
+ z.object({ title: z.string() }),
43
+ async () => ({
44
+ isSuccess: true as const,
45
+ data: {},
46
+ }),
47
+ {
48
+ access: { openToAll: { reason: "internal signup form" } },
49
+ },
50
+ );
51
+ });
52
+ expect(() => validateAccessDeclarations(feature)).not.toThrow();
53
+ });
54
+
55
+ // 1b. malformed openToAll (from untyped sources) counts as invalid, not a crash.
56
+ test.each([{ openToAll: false }, { openToAll: {} }])(
57
+ "malformed openToAll %p throws, naming the handler",
58
+ (access) => {
59
+ const feature = defineFeature("notes", (r) => {
60
+ r.entity("note", noteEntity);
61
+ r.writeHandler(
62
+ "note:create",
63
+ z.object({ title: z.string() }),
64
+ async () => ({
65
+ isSuccess: true as const,
66
+ data: {},
67
+ }),
68
+ {
69
+ // @cast-boundary test — simulates JSON/Designer input that doesn't match the static union
70
+ access: access as unknown as AccessRule,
71
+ },
72
+ );
73
+ });
74
+ expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
75
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
76
+ },
77
+ );
78
+
79
+ // 2. escapeHatch reason must be non-empty.
80
+ test("escapeHatch with an empty reason throws, naming the handler", () => {
81
+ const feature = defineFeature("notes", (r) => {
82
+ r.entity("note", noteEntity);
83
+ r.writeHandler(
84
+ "note:create",
85
+ z.object({ title: z.string() }),
86
+ async () => ({
87
+ isSuccess: true as const,
88
+ data: {},
89
+ }),
90
+ {
91
+ access: { roles: ["Admin"] },
92
+ escapeHatch: { reason: "" },
93
+ },
94
+ );
95
+ });
96
+ expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
97
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
98
+ });
99
+
100
+ test("escapeHatch with a non-empty reason boots fine", () => {
101
+ const feature = defineFeature("notes", (r) => {
102
+ r.entity("note", noteEntity);
103
+ r.writeHandler(
104
+ "note:create",
105
+ z.object({ title: z.string() }),
106
+ async () => ({
107
+ isSuccess: true as const,
108
+ data: {},
109
+ }),
110
+ {
111
+ access: { roles: ["Admin"] },
112
+ escapeHatch: { reason: "cross-tenant note import job" },
113
+ },
114
+ );
115
+ });
116
+ expect(() => validateAccessDeclarations(feature)).not.toThrow();
117
+ });
118
+
119
+ // 3. openToAll write handler accepting a personal-data field needs publicIntake.
120
+ test("openToAll write handler accepting a personal-data field without publicIntake throws", () => {
121
+ const feature = defineFeature("notes", (r) => {
122
+ r.entity("note", noteEntity);
123
+ r.writeHandler(
124
+ "note:create",
125
+ z.object({ email: z.string() }),
126
+ async () => ({
127
+ isSuccess: true as const,
128
+ data: {},
129
+ }),
130
+ {
131
+ access: { openToAll: { reason: "public signup" } },
132
+ },
133
+ );
134
+ });
135
+ expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
136
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
137
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"email"/);
138
+ });
139
+
140
+ test("openToAll write handler accepting a personal-data field WITH publicIntake boots fine", () => {
141
+ const feature = defineFeature("notes", (r) => {
142
+ r.entity("note", noteEntity);
143
+ r.writeHandler(
144
+ "note:create",
145
+ z.object({ email: z.string() }),
146
+ async () => ({
147
+ isSuccess: true as const,
148
+ data: {},
149
+ }),
150
+ {
151
+ access: { openToAll: { reason: "public signup" }, publicIntake: true },
152
+ },
153
+ );
154
+ });
155
+ expect(() => validateAccessDeclarations(feature)).not.toThrow();
156
+ });
157
+
158
+ // 4. publicIntake is only meaningful on a write handler.
159
+ test("publicIntake on a query handler throws", () => {
160
+ const feature = defineFeature("notes", (r) => {
161
+ r.entity("note", noteEntity);
162
+ r.queryHandler("note:list", z.object({}), async () => [], {
163
+ access: { openToAll: { reason: "public listing" }, publicIntake: true },
164
+ });
165
+ });
166
+ expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
167
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:list"/);
168
+ });
169
+
170
+ test("query handler without publicIntake boots fine", () => {
171
+ const feature = defineFeature("notes", (r) => {
172
+ r.entity("note", noteEntity);
173
+ r.queryHandler("note:list", z.object({}), async () => [], {
174
+ access: { openToAll: { reason: "public listing" } },
175
+ });
176
+ });
177
+ expect(() => validateAccessDeclarations(feature)).not.toThrow();
178
+ });
179
+
180
+ test("publicIntake on a stream handler throws", () => {
181
+ const feature = defineFeature("notes", (r) => {
182
+ r.entity("note", noteEntity);
183
+ r.streamHandler("note:watch", z.object({}), async function* () {}, {
184
+ access: { openToAll: { reason: "public stream" }, publicIntake: true },
185
+ });
186
+ });
187
+ expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
188
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:watch"/);
189
+ });
190
+
191
+ test("stream handler without publicIntake boots fine", () => {
192
+ const feature = defineFeature("notes", (r) => {
193
+ r.entity("note", noteEntity);
194
+ r.streamHandler("note:watch", z.object({}), async function* () {}, {
195
+ access: { openToAll: { reason: "public stream" } },
196
+ });
197
+ });
198
+ expect(() => validateAccessDeclarations(feature)).not.toThrow();
199
+ });
200
+ });