@cosmicdrift/kumiko-framework 0.265.0 → 0.268.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/api/__tests__/api.test.ts +6 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
- package/src/api/__tests__/jwt.test.ts +8 -0
- package/src/api/jwt.ts +8 -0
- package/src/changes.json +35 -0
- package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
- package/src/db/__tests__/tenant-db-global.test.ts +95 -5
- package/src/db/entity-table-meta.ts +7 -0
- package/src/db/event-store-executor-context.ts +19 -0
- package/src/db/event-store-executor-write.ts +16 -0
- package/src/db/index.ts +3 -1
- package/src/db/table-builder.ts +27 -0
- package/src/db/tenant-db.ts +83 -10
- package/src/engine/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/access-declarations.ts +101 -27
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/zod-shape.ts +92 -1
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
- package/src/engine/feature-ast/extractors/hooks.ts +8 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +4 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/index.ts +3 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +20 -0
- package/src/errors/i18n/en.yaml +19 -0
- package/src/errors/reasons.ts +8 -0
- package/src/jobs/job-runner.ts +14 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
- package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
- package/src/pipeline/active-membership.ts +3 -1
- package/src/pipeline/dispatch-shared.ts +89 -10
- package/src/pipeline/dispatch-stream.ts +9 -5
- package/src/pipeline/dispatch-write.ts +7 -0
- package/src/pipeline/dispatcher-utils.ts +1 -0
- package/src/pipeline/dispatcher.ts +18 -0
- package/src/pipeline/index.ts +1 -1
- package/src/pipeline/member-reader.ts +108 -0
- package/src/pipeline/system-identity-switch.ts +73 -13
- package/src/stack/test-stack.ts +10 -1
- package/src/testing/handler-context.ts +3 -0
- package/src/ui-types/index.ts +1 -0
|
@@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { defineFeature } from "../../define-feature";
|
|
4
4
|
import { createEntity, createTextField } from "../../factories";
|
|
5
|
-
import
|
|
5
|
+
import { from } from "../../ownership";
|
|
6
|
+
import { buildInsertSchema, buildUpdateSchema } from "../../schema-builder";
|
|
7
|
+
import type { AccessRule, EntityDefinition, OwnershipMap, WriteHandlerDef } from "../../types";
|
|
6
8
|
import { validateAccessDeclarations } from "../access-declarations";
|
|
7
9
|
|
|
8
10
|
const noteEntity = createEntity({
|
|
@@ -142,8 +144,8 @@ describe("validateAccessDeclarations", () => {
|
|
|
142
144
|
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
143
145
|
});
|
|
144
146
|
|
|
145
|
-
// 3. openToAll write handler accepting
|
|
146
|
-
test("openToAll write handler accepting a personal-data field without
|
|
147
|
+
// 3. openToAll write handler accepting an unbound personal-data field needs personalData.
|
|
148
|
+
test("openToAll write handler accepting a personal-data field without personalData throws", () => {
|
|
147
149
|
const feature = defineFeature("notes", (r) => {
|
|
148
150
|
r.entity("note", noteEntity);
|
|
149
151
|
r.writeHandler(
|
|
@@ -154,16 +156,17 @@ describe("validateAccessDeclarations", () => {
|
|
|
154
156
|
data: {},
|
|
155
157
|
}),
|
|
156
158
|
{
|
|
157
|
-
access: { openToAll: { reason: "
|
|
159
|
+
access: { openToAll: { reason: "members share contacts" } },
|
|
158
160
|
},
|
|
159
161
|
);
|
|
160
162
|
});
|
|
161
163
|
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
162
164
|
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
|
|
163
165
|
expect(() => validateAccessDeclarations(feature)).toThrow(/"email"/);
|
|
166
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/personalData: "tenant-members"/);
|
|
164
167
|
});
|
|
165
168
|
|
|
166
|
-
test(
|
|
169
|
+
test('openToAll write handler accepting a personal-data field WITH personalData: "tenant-members" boots fine', () => {
|
|
167
170
|
const feature = defineFeature("notes", (r) => {
|
|
168
171
|
r.entity("note", noteEntity);
|
|
169
172
|
r.writeHandler(
|
|
@@ -174,53 +177,366 @@ describe("validateAccessDeclarations", () => {
|
|
|
174
177
|
data: {},
|
|
175
178
|
}),
|
|
176
179
|
{
|
|
177
|
-
access: {
|
|
180
|
+
access: {
|
|
181
|
+
openToAll: { reason: "members share contacts", personalData: "tenant-members" },
|
|
182
|
+
},
|
|
178
183
|
},
|
|
179
184
|
);
|
|
180
185
|
});
|
|
181
186
|
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
182
187
|
});
|
|
183
188
|
|
|
184
|
-
|
|
185
|
-
|
|
189
|
+
test("an unknown personalData value from an untyped source throws", () => {
|
|
190
|
+
const feature = defineFeature("notes", (r) => {
|
|
191
|
+
r.entity("note", noteEntity);
|
|
192
|
+
r.writeHandler(
|
|
193
|
+
"note:create",
|
|
194
|
+
z.object({ title: z.string() }),
|
|
195
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
196
|
+
{
|
|
197
|
+
// @cast-boundary test — simulates JSON/Designer input that doesn't match the static union
|
|
198
|
+
access: {
|
|
199
|
+
openToAll: { reason: "signup", personalData: "public-intake" },
|
|
200
|
+
} as unknown as AccessRule,
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"public-intake"/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("the removed publicIntake flag no longer type-checks and no longer exempts", () => {
|
|
208
|
+
const access: AccessRule = {
|
|
209
|
+
openToAll: { reason: "members share contacts" },
|
|
210
|
+
// @ts-expect-error publicIntake was replaced by openToAll.personalData
|
|
211
|
+
publicIntake: true,
|
|
212
|
+
};
|
|
213
|
+
const feature = defineFeature("notes", (r) => {
|
|
214
|
+
r.entity("note", noteEntity);
|
|
215
|
+
r.writeHandler(
|
|
216
|
+
"note:create",
|
|
217
|
+
z.object({ email: z.string() }),
|
|
218
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
219
|
+
{ access },
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"email"/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("the deprecated openToAll: true form cannot carry personalData", () => {
|
|
226
|
+
// @ts-expect-error personalData lives only on the { reason } object form
|
|
227
|
+
const access: AccessRule = { openToAll: true, personalData: "tenant-members" };
|
|
228
|
+
expect(access).toBeDefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// 4. personalData is only meaningful on a write handler.
|
|
232
|
+
test("personalData on a query handler throws", () => {
|
|
186
233
|
const feature = defineFeature("notes", (r) => {
|
|
187
234
|
r.entity("note", noteEntity);
|
|
188
235
|
r.queryHandler("note:list", z.object({}), async () => [], {
|
|
189
|
-
access: { openToAll: { reason: "
|
|
236
|
+
access: { openToAll: { reason: "member listing", personalData: "tenant-members" } },
|
|
190
237
|
});
|
|
191
238
|
});
|
|
192
239
|
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
193
240
|
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:list"/);
|
|
194
241
|
});
|
|
195
242
|
|
|
196
|
-
test("query handler without
|
|
243
|
+
test("query handler without personalData boots fine", () => {
|
|
197
244
|
const feature = defineFeature("notes", (r) => {
|
|
198
245
|
r.entity("note", noteEntity);
|
|
199
246
|
r.queryHandler("note:list", z.object({}), async () => [], {
|
|
200
|
-
access: { openToAll: { reason: "
|
|
247
|
+
access: { openToAll: { reason: "member listing" } },
|
|
201
248
|
});
|
|
202
249
|
});
|
|
203
250
|
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
204
251
|
});
|
|
205
252
|
|
|
206
|
-
test("
|
|
253
|
+
test("personalData on a stream handler throws", () => {
|
|
207
254
|
const feature = defineFeature("notes", (r) => {
|
|
208
255
|
r.entity("note", noteEntity);
|
|
209
256
|
r.streamHandler("note:watch", z.object({}), async function* () {}, {
|
|
210
|
-
access: { openToAll: { reason: "
|
|
257
|
+
access: { openToAll: { reason: "member stream", personalData: "tenant-members" } },
|
|
211
258
|
});
|
|
212
259
|
});
|
|
213
260
|
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
214
261
|
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:watch"/);
|
|
215
262
|
});
|
|
216
263
|
|
|
217
|
-
test("stream handler without
|
|
264
|
+
test("stream handler without personalData boots fine", () => {
|
|
218
265
|
const feature = defineFeature("notes", (r) => {
|
|
219
266
|
r.entity("note", noteEntity);
|
|
220
267
|
r.streamHandler("note:watch", z.object({}), async function* () {}, {
|
|
221
|
-
access: { openToAll: { reason: "
|
|
268
|
+
access: { openToAll: { reason: "member stream" } },
|
|
222
269
|
});
|
|
223
270
|
});
|
|
224
271
|
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
225
272
|
});
|
|
226
273
|
});
|
|
274
|
+
|
|
275
|
+
const openToAll = { openToAll: { reason: "any member may edit" } } as const;
|
|
276
|
+
|
|
277
|
+
function featureWithWriteHandler(
|
|
278
|
+
schema: z.ZodType,
|
|
279
|
+
options: Pick<WriteHandlerDef, "access" | "escapeHatch"> = { access: openToAll },
|
|
280
|
+
handlerName = "note:update",
|
|
281
|
+
entity: EntityDefinition = noteEntity,
|
|
282
|
+
) {
|
|
283
|
+
return defineFeature("notes", (r) => {
|
|
284
|
+
r.entity("note", entity);
|
|
285
|
+
r.writeHandler(
|
|
286
|
+
handlerName,
|
|
287
|
+
schema,
|
|
288
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
289
|
+
options,
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
describe("validateAccessDeclarations — personal-data fields beyond a top-level object", () => {
|
|
295
|
+
test.each([
|
|
296
|
+
["intersection", z.intersection(z.object({ email: z.string() }), z.object({ id: z.string() }))],
|
|
297
|
+
["union", z.union([z.object({ email: z.string() }), z.object({ title: z.string() })])],
|
|
298
|
+
[
|
|
299
|
+
"discriminated union",
|
|
300
|
+
z.discriminatedUnion("kind", [
|
|
301
|
+
z.object({ kind: z.literal("a"), email: z.string() }),
|
|
302
|
+
z.object({ kind: z.literal("b"), title: z.string() }),
|
|
303
|
+
]),
|
|
304
|
+
],
|
|
305
|
+
["transform", z.object({ email: z.string() }).transform((v) => v)],
|
|
306
|
+
["preprocess", z.preprocess((v) => v, z.object({ email: z.string() }))],
|
|
307
|
+
["pipe", z.unknown().pipe(z.object({ email: z.string() }))],
|
|
308
|
+
["readonly", z.object({ email: z.string() }).readonly()],
|
|
309
|
+
["catch", z.object({ email: z.string() }).catch({ email: "" })],
|
|
310
|
+
[
|
|
311
|
+
"wrapper around intersection",
|
|
312
|
+
z.intersection(z.object({ email: z.string() }), z.object({})).optional(),
|
|
313
|
+
],
|
|
314
|
+
[
|
|
315
|
+
"nested update changes",
|
|
316
|
+
z.object({
|
|
317
|
+
id: z.string(),
|
|
318
|
+
version: z.number(),
|
|
319
|
+
changes: z.object({ email: z.string() }).partial(),
|
|
320
|
+
}),
|
|
321
|
+
],
|
|
322
|
+
["array of objects", z.object({ entries: z.array(z.object({ email: z.string() })) })],
|
|
323
|
+
[
|
|
324
|
+
"record of objects",
|
|
325
|
+
z.object({ byId: z.record(z.string(), z.object({ email: z.string() })) }),
|
|
326
|
+
],
|
|
327
|
+
[
|
|
328
|
+
"lazy",
|
|
329
|
+
z.object({ inner: z.lazy(() => z.object({ email: z.string().nullable().default(null) })) }),
|
|
330
|
+
],
|
|
331
|
+
])("openToAll write handler with a personal-data field inside a %s throws", (_label, schema) => {
|
|
332
|
+
const feature = featureWithWriteHandler(schema);
|
|
333
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:update"/);
|
|
334
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"email"/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("recursive lazy schema terminates and still finds the field", () => {
|
|
338
|
+
type Tree = { email?: string; children: Tree[] };
|
|
339
|
+
const tree: z.ZodType<Tree> = z.lazy(() =>
|
|
340
|
+
z.object({ email: z.string().optional(), children: z.array(tree) }),
|
|
341
|
+
);
|
|
342
|
+
expect(() => validateAccessDeclarations(featureWithWriteHandler(tree))).toThrow(/"email"/);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("a lazy getter that builds a new schema on every call fails loudly instead of looping", () => {
|
|
346
|
+
const endless = (): z.ZodType => z.object({ title: z.string(), next: z.lazy(endless) });
|
|
347
|
+
expect(() => validateAccessDeclarations(featureWithWriteHandler(endless()))).toThrow(
|
|
348
|
+
/more than 10000 nodes/,
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Mirrors kumiko-credit's `update` (intersection of a refined object with { id })
|
|
353
|
+
// and `bauspar:update` ({ id, version, changes }) on a tenant-trust entity.
|
|
354
|
+
test("credit-style update schemas without owner binding throw", () => {
|
|
355
|
+
const credit = createEntity({
|
|
356
|
+
table: "fw_access_pii_credit",
|
|
357
|
+
fields: {
|
|
358
|
+
name: createTextField({ personal: { of: "ownerUserId" }, find: "none" }),
|
|
359
|
+
ownerUserId: createTextField({ required: false, personal: "ref" }),
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
const refined = z.object({ name: z.string() }).refine((c) => c.name.length > 0);
|
|
363
|
+
const intersectionUpdate = z.intersection(refined, z.object({ id: z.string() }));
|
|
364
|
+
const changesUpdate = z.object({
|
|
365
|
+
id: z.uuid(),
|
|
366
|
+
version: z.number(),
|
|
367
|
+
changes: buildUpdateSchema(credit).omit({ ownerUserId: true }),
|
|
368
|
+
});
|
|
369
|
+
for (const schema of [intersectionUpdate, changesUpdate]) {
|
|
370
|
+
const feature = featureWithWriteHandler(schema, { access: openToAll }, "note:update", credit);
|
|
371
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"name"/);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("validateAccessDeclarations — owner-bound personal-data fields", () => {
|
|
377
|
+
function ownedNoteEntity(write: OwnershipMap | undefined) {
|
|
378
|
+
return createEntity({
|
|
379
|
+
table: "fw_access_pii_owned_notes",
|
|
380
|
+
fields: {
|
|
381
|
+
name: createTextField({ personal: { of: "ownerUserId" }, find: "none" }),
|
|
382
|
+
ownerUserId: createTextField({ required: false, personal: "ref" }),
|
|
383
|
+
assigneeId: createTextField({ required: false, personal: "ref" }),
|
|
384
|
+
},
|
|
385
|
+
...(write && { access: { read: write, write } }),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const ownerBound: OwnershipMap = { Member: from("user:id", "ownerUserId") };
|
|
390
|
+
const createSchema = z.object({ name: z.string() });
|
|
391
|
+
const updateSchema = z.object({
|
|
392
|
+
id: z.string(),
|
|
393
|
+
version: z.number(),
|
|
394
|
+
changes: z.object({ name: z.string() }).partial(),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test.each([
|
|
398
|
+
["create", createSchema],
|
|
399
|
+
["update", updateSchema],
|
|
400
|
+
])(
|
|
401
|
+
"%s on an entity whose every write role is bound to the field's owner boots fine",
|
|
402
|
+
(_l, schema) => {
|
|
403
|
+
const multiRole: OwnershipMap = { ...ownerBound, Viewer: from("user:id", "ownerUserId") };
|
|
404
|
+
for (const map of [ownerBound, multiRole]) {
|
|
405
|
+
const feature = featureWithWriteHandler(
|
|
406
|
+
schema,
|
|
407
|
+
{ access: openToAll },
|
|
408
|
+
"note:update",
|
|
409
|
+
ownedNoteEntity(map),
|
|
410
|
+
);
|
|
411
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
test.each<[string, OwnershipMap | undefined]>([
|
|
417
|
+
["no access.write", undefined],
|
|
418
|
+
["empty access.write", {}],
|
|
419
|
+
['an "all" rule', { Member: "all" }],
|
|
420
|
+
['an owner rule plus an "all" role', { ...ownerBound, TenantAdmin: "all" }],
|
|
421
|
+
["an owner rule on a different column", { Member: from("user:id", "assigneeId") }],
|
|
422
|
+
["a user:tenantId rule on the owner column", { Member: from("user:tenantId", "ownerUserId") }],
|
|
423
|
+
["a claim rule on the owner column", { Member: from("claim:crm:ownerUserId", "ownerUserId") }],
|
|
424
|
+
["a where rule", { Member: { kind: "where", where: () => ({ sqlText: "TRUE", params: [] }) } }],
|
|
425
|
+
])("owner-annotated field on an entity with %s still throws", (_label, map) => {
|
|
426
|
+
const feature = featureWithWriteHandler(
|
|
427
|
+
createSchema,
|
|
428
|
+
{ access: openToAll },
|
|
429
|
+
"note:update",
|
|
430
|
+
ownedNoteEntity(map),
|
|
431
|
+
);
|
|
432
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"name"/);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test.each([
|
|
436
|
+
["self", createTextField({ personal: "self", find: "none" })],
|
|
437
|
+
["record-owned", createTextField({ personal: { of: "id" }, find: "none" })],
|
|
438
|
+
])("a %s field is not exempted by an owner-bound write map", (_label, field) => {
|
|
439
|
+
const entity = createEntity({
|
|
440
|
+
table: "fw_access_pii_mixed_notes",
|
|
441
|
+
fields: {
|
|
442
|
+
email: field,
|
|
443
|
+
ownerUserId: createTextField({ required: false, personal: "ref" }),
|
|
444
|
+
},
|
|
445
|
+
access: { write: ownerBound },
|
|
446
|
+
});
|
|
447
|
+
const schema = z.object({ email: z.string() });
|
|
448
|
+
const feature = featureWithWriteHandler(schema, { access: openToAll }, "note:update", entity);
|
|
449
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"email"/);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("a handler with escapeHatch is not exempted — it can write around the executor", () => {
|
|
453
|
+
const feature = featureWithWriteHandler(
|
|
454
|
+
createSchema,
|
|
455
|
+
{ access: openToAll, escapeHatch: { reason: "bulk import" } },
|
|
456
|
+
"note:update",
|
|
457
|
+
ownedNoteEntity(ownerBound),
|
|
458
|
+
);
|
|
459
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"name"/);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("a handler not mapped to a single entity is not exempted (fail-closed)", () => {
|
|
463
|
+
const feature = defineFeature("notes", (r) => {
|
|
464
|
+
r.entity("note", ownedNoteEntity(ownerBound));
|
|
465
|
+
r.entity("memo", noteEntity);
|
|
466
|
+
r.writeHandler("submit", createSchema, async () => ({ isSuccess: true as const, data: {} }), {
|
|
467
|
+
access: openToAll,
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
expect(feature.handlerEntityMappings["submit"]).toBeUndefined();
|
|
471
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"name"/);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("an insert schema built from the owner-bound entity boots fine", () => {
|
|
475
|
+
const entity = ownedNoteEntity(ownerBound);
|
|
476
|
+
const schema = buildInsertSchema(entity).omit({ ownerUserId: true, assigneeId: true });
|
|
477
|
+
const feature = featureWithWriteHandler(schema, { access: openToAll }, "note:create", entity);
|
|
478
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe("validateAccessDeclarations — self-bound personal-data fields", () => {
|
|
483
|
+
const selfBound: OwnershipMap = { Member: from("user:id", "id") };
|
|
484
|
+
|
|
485
|
+
function profileEntity(write: OwnershipMap | undefined) {
|
|
486
|
+
return createEntity({
|
|
487
|
+
table: "fw_access_pii_profiles",
|
|
488
|
+
fields: {
|
|
489
|
+
displayName: createTextField({ personal: "self", find: "none" }),
|
|
490
|
+
bio: createTextField({ personal: { of: "id" }, find: "none" }),
|
|
491
|
+
},
|
|
492
|
+
...(write && { access: { write } }),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const profileUpdate = z.object({
|
|
497
|
+
id: z.string(),
|
|
498
|
+
version: z.number(),
|
|
499
|
+
changes: z.object({ displayName: z.string(), bio: z.string() }).partial(),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('self and record-owned fields on an entity whose every write role is from("user:id", "id") boot fine', () => {
|
|
503
|
+
const multiRole: OwnershipMap = { ...selfBound, Viewer: from("user:id", "id") };
|
|
504
|
+
for (const map of [selfBound, multiRole]) {
|
|
505
|
+
const feature = featureWithWriteHandler(
|
|
506
|
+
profileUpdate,
|
|
507
|
+
{ access: openToAll },
|
|
508
|
+
"note:update",
|
|
509
|
+
profileEntity(map),
|
|
510
|
+
);
|
|
511
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test.each<[string, OwnershipMap | undefined]>([
|
|
516
|
+
["no access.write", undefined],
|
|
517
|
+
["an owner rule on another column", { Member: from("user:id", "ownerUserId") }],
|
|
518
|
+
['a from("user:id", "id") rule plus an "all" role', { ...selfBound, TenantAdmin: "all" }],
|
|
519
|
+
])("a self field on an entity with %s still throws", (_label, map) => {
|
|
520
|
+
const feature = featureWithWriteHandler(
|
|
521
|
+
profileUpdate,
|
|
522
|
+
{ access: openToAll },
|
|
523
|
+
"note:update",
|
|
524
|
+
profileEntity(map),
|
|
525
|
+
);
|
|
526
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"displayName"/);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("a self-bound entity in an r.systemScope() feature is not exempted", () => {
|
|
530
|
+
const feature = defineFeature("notes", (r) => {
|
|
531
|
+
r.systemScope();
|
|
532
|
+
r.entity("note", profileEntity(selfBound));
|
|
533
|
+
r.writeHandler(
|
|
534
|
+
"note:update",
|
|
535
|
+
profileUpdate,
|
|
536
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
537
|
+
{ access: openToAll },
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"displayName"/);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// fw#2858 — a `tenancy: "global"` entity's event stream must live on
|
|
2
|
+
// SYSTEM_TENANT_ID (systemStream: true); otherwise it would silently fork
|
|
3
|
+
// one stream per creating tenant while still being writable/readable
|
|
4
|
+
// cross-tenant via db.global(table).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { defineFeature } from "../../define-feature";
|
|
8
|
+
import { createEntity, createTextField } from "../../factories";
|
|
9
|
+
import { validateGlobalTenancyEntities } from "../global-tenancy";
|
|
10
|
+
|
|
11
|
+
const textField = () =>
|
|
12
|
+
createTextField({
|
|
13
|
+
required: true,
|
|
14
|
+
maxLength: 64,
|
|
15
|
+
personal: false,
|
|
16
|
+
reason: "technical_reference",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("validateGlobalTenancyEntities", () => {
|
|
20
|
+
test("a tenancy: 'global' entity WITHOUT systemStream: true throws", () => {
|
|
21
|
+
const entity = createEntity({
|
|
22
|
+
table: "read_fw2858_global_no_stream",
|
|
23
|
+
tenancy: "global",
|
|
24
|
+
fields: { name: textField() },
|
|
25
|
+
});
|
|
26
|
+
const feature = defineFeature("fw2858-global-no-stream", (r) => {
|
|
27
|
+
r.entity("fw2858GlobalNoStream", entity);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(() => validateGlobalTenancyEntities([feature])).toThrow(/systemStream/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a tenancy: 'global' entity WITH systemStream: true passes", () => {
|
|
34
|
+
const entity = createEntity({
|
|
35
|
+
table: "read_fw2858_global_with_stream",
|
|
36
|
+
tenancy: "global",
|
|
37
|
+
systemStream: true,
|
|
38
|
+
fields: { name: textField() },
|
|
39
|
+
});
|
|
40
|
+
const feature = defineFeature("fw2858-global-with-stream", (r) => {
|
|
41
|
+
r.entity("fw2858GlobalWithStream", entity);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(() => validateGlobalTenancyEntities([feature])).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a tenant-tenancy entity without systemStream is never checked", () => {
|
|
48
|
+
const entity = createEntity({
|
|
49
|
+
table: "read_fw2858_tenant_entity",
|
|
50
|
+
fields: { name: textField() },
|
|
51
|
+
});
|
|
52
|
+
const feature = defineFeature("fw2858-tenant-entity", (r) => {
|
|
53
|
+
r.entity("fw2858TenantEntity", entity);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(() => validateGlobalTenancyEntities([feature])).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|