@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.
- package/package.json +3 -3
- package/src/changes.json +13 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +97 -0
- package/src/db/__tests__/tenant-db-global.test.ts +170 -0
- package/src/db/entity-table-meta.ts +8 -2
- package/src/db/table-builder.ts +37 -2
- package/src/db/tenant-db.ts +86 -4
- package/src/engine/__tests__/boot-validator.test.ts +16 -15
- package/src/engine/__tests__/build-app-schema.test.ts +14 -5
- package/src/engine/__tests__/engine.test.ts +10 -0
- package/src/engine/__tests__/entity-handlers.test.ts +48 -36
- package/src/engine/__tests__/extends-registrar.test.ts +27 -12
- package/src/engine/__tests__/registry.test.ts +16 -5
- package/src/engine/access.ts +2 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +200 -0
- package/src/engine/boot-validator/access-declarations.ts +141 -0
- package/src/engine/boot-validator/entity-handler.ts +1 -1
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/entity-handlers.ts +29 -19
- package/src/engine/factories.ts +5 -3
- package/src/engine/feature-ast/extractors/hooks.ts +5 -1
- package/src/engine/feature-entity-handlers.ts +40 -27
- package/src/engine/index.ts +4 -0
- package/src/engine/types/handlers.ts +1 -0
- package/src/engine/types/index.ts +4 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +93 -0
- package/src/pipeline/dispatch-shared.ts +3 -0
- package/src/secrets/__tests__/contains-secret.test.ts +5 -0
- package/src/testing/__tests__/e2e-generator.test.ts +11 -5
- 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(
|
|
956
|
-
|
|
957
|
-
|
|
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(
|
|
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(
|
|
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("
|
|
98
|
-
|
|
99
|
-
expect(
|
|
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("
|
|
226
|
-
|
|
227
|
-
|
|
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(
|
|
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 →
|
|
346
|
-
const { r
|
|
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(
|
|
119
|
-
|
|
120
|
-
|
|
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(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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();
|
package/src/engine/access.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|