@cosmicdrift/kumiko-framework 0.265.0 → 0.266.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 +20 -0
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
- package/src/engine/boot-validator/access-declarations.ts +101 -27
- 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/index.ts +1 -0
- package/src/engine/types/index.ts +1 -0
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.266.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.266.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.266.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.266.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "`publicIntake` is removed; an openToAll write handler declares unbound personal data with `openToAll: { reason, personalData: \"tenant-members\" }`.",
|
|
6
|
+
"detail": "`OpenToAllAccessRule.publicIntake` (`@cosmicdrift/kumiko-types/handlers`) no longer exists. `OpenToAllDeclaration` gains `personalData?: OpenToAllPersonalData`, a named union (currently only `\"tenant-members\"`, exported from `@cosmicdrift/kumiko-framework/engine` and `/ui-types`): signed-in tenant members may write personal data that no owner rule binds to the caller. The deprecated `openToAll: true` form cannot carry it. `validateAccessDeclarations` requires `personalData: \"tenant-members\"` for a write handler with `openToAll` whose input accepts a personal-data field that is not owner- or self-bound, rejects `personalData` on query and stream handlers, and rejects any other `personalData` value from untyped sources. `openToAll` never admits anonymous callers, so there is no public-intake value; anonymous personal-data intake is tracked separately. The feature-AST extractor reads `openToAll.personalData` and no longer reads `publicIntake`. Bundled `user:update` now declares `openToAll: { reason, personalData: \"tenant-members\" }`: `userEntity` has no entity-level `access.write`, and privileged actors may edit any user.",
|
|
7
|
+
"migration": "Replace `access: { openToAll: { reason: \"...\" }, publicIntake: true }` (or `openToAll: true, publicIntake: true`) with `access: { openToAll: { reason: \"<why any signed-in tenant member may write this personal data>\", personalData: \"tenant-members\" } }`. Handlers whose personal-data fields are owner- or self-bound through the entity's `access.write` need no declaration. A `publicIntake` key left in an untyped access object (pattern JSON, Designer) is ignored, so the handler fails boot until migrated. No known consumer used `publicIntake`."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.266.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "The openToAll personal-data boot check now finds personal-data fields nested anywhere in a write handler's input schema.",
|
|
13
|
+
"detail": "`validateAccessDeclarations` (`engine/boot-validator/access-declarations.ts`) previously compared only the top-level keys of a `z.object` input schema (unwrapping `.optional()`/`.nullable()`/`.default()`) against the handler entity's personal-data fields (pii / userOwned / recordOwned); any other schema shape was skipped. It now collects object keys at every depth via `collectZodObjectKeys` (`engine/boot-validator/zod-shape.ts`): through `z.intersection`, `z.union`/`z.discriminatedUnion`/`z.xor`, `.transform()`/`z.preprocess()`/`.pipe()`/codecs, `.optional()`/`.nullable()`/`.default()`/`.prefault()`/`.nonoptional()`/`.readonly()`/`.catch()`/`z.lazy()`, nested objects (e.g. the update shape `{ id, version, changes: {...} }`), arrays, tuples and records. An update handler that accepted the same personal-data field as its create handler used to pass boot while the create handler failed. A schema with more than 10000 distinct nodes (typically a `z.lazy()` getter that returns a new schema on every call) throws at boot instead of looping. Which entity's fields are compared is unchanged: the handler's mapped entity, or every entity of the feature for an unmapped handler.",
|
|
14
|
+
"migration": "A write handler with `openToAll` whose input accepts a personal-data field of its entity inside an intersection, union, pipe/transform, nested object (update `changes`), array or record now fails boot like a top-level field always did. Known: kumiko-enterprise kumiko-credit `update` (intersection) and `bauspar:update` (`changes.name`) under `trustDomain: \"tenant\"` or with an `access` map that grants any role `\"all\"`. For each affected handler, bind the entity's rows to the caller (owner-binding entry in this release), restrict `access` to roles, or declare `openToAll: { reason: \"...\", personalData: \"tenant-members\" }`."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.266.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "Owner- and self-bound personal-data fields no longer need a declaration on openToAll write handlers.",
|
|
20
|
+
"detail": "A personal-data field is exempt from the openToAll personal-data boot check when it is bound to the caller: the handler maps to exactly one entity, that entity's `access.write` is a non-empty `OwnershipMap` in which every role's rule is `from(\"user:id\", <column>)` on the binding column, the handler declares no `escapeHatch`, and its feature is not `r.systemScope()`. The binding column is the owner field for `personal: { of: \"<ownerField>\" }` (userOwned), and `id` for `personal: \"self\"` and `personal: { of: \"id\" }` — there the row itself is the caller. The event-store executor enforces such a map on every write: create rejects a row whose binding column is not the caller (`ownership_denied`), update checks the old and the new row, delete/restore the existing row. Not exempt: a missing or empty `access.write`, any `\"all\"` rule (also next to binding rules), a rule on another column, `user:tenantId` or claim refs, `where` rules, `self` fields on an entity without `from(\"user:id\", \"id\")`, handlers not mapped to a single entity, handlers with `escapeHatch` and handlers of `r.systemScope()` features (both can write around the executor via `db.global()`, `systemDb` or a SYSTEM identity)."
|
|
21
|
+
},
|
|
2
22
|
{
|
|
3
23
|
"version": "0.265.0",
|
|
4
24
|
"type": "improvement",
|
|
@@ -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
|
+
});
|
|
@@ -1,38 +1,88 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AccessRule,
|
|
3
|
+
FeatureDefinition,
|
|
4
|
+
OwnershipMap,
|
|
5
|
+
OwnershipRule,
|
|
6
|
+
QueryHandlerDef,
|
|
7
|
+
WriteHandlerDef,
|
|
8
|
+
} from "../types";
|
|
2
9
|
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
3
|
-
import {
|
|
10
|
+
import { collectZodObjectKeys } from "./zod-shape";
|
|
4
11
|
|
|
5
12
|
type HandlerKind = "write" | "query" | "stream";
|
|
6
13
|
|
|
7
14
|
// Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
|
|
8
15
|
// minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
|
|
9
|
-
// sense openToAll
|
|
16
|
+
// sense the openToAll personal-data check is guarding against.
|
|
10
17
|
function isPersonalDataField(field: unknown): boolean {
|
|
11
18
|
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
|
|
12
19
|
return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
function
|
|
22
|
+
function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
|
|
23
|
+
if (rule === "all" || rule.kind !== "from") return false;
|
|
24
|
+
return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The executor checks access.write against every created/updated row; one "all" role
|
|
28
|
+
// or an empty map (= public) lets a caller write rows owned by someone else.
|
|
29
|
+
function writeMapBindsRowsToCaller(
|
|
30
|
+
writeMap: OwnershipMap | undefined,
|
|
31
|
+
ownerColumn: string,
|
|
32
|
+
): boolean {
|
|
33
|
+
const rules = Object.values(writeMap ?? {});
|
|
34
|
+
return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ROW_ID_COLUMN = "id";
|
|
38
|
+
|
|
39
|
+
// A self/record-owned field's subject is the row itself, so only from("user:id", "id")
|
|
40
|
+
// makes that row the caller — on any other entity "self" names a third party.
|
|
41
|
+
function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
|
|
42
|
+
if (annot.userOwned) return annot.userOwned.ownerField;
|
|
43
|
+
if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
|
|
48
|
+
const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
|
|
49
|
+
return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function personalFieldNames(
|
|
53
|
+
entity: EntityDefinition,
|
|
54
|
+
honorOwnerBinding: boolean,
|
|
55
|
+
): ReadonlySet<string> {
|
|
16
56
|
const names = new Set<string>();
|
|
17
57
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
18
|
-
|
|
58
|
+
const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
|
|
59
|
+
if (isPersonalDataField(field) && !exempt) names.add(fieldName);
|
|
19
60
|
}
|
|
20
61
|
return names;
|
|
21
62
|
}
|
|
22
63
|
|
|
64
|
+
// escapeHatch and r.systemScope() can write around the entity's write map
|
|
65
|
+
// (db.global(), systemDb, SYSTEM identity), so the map no longer vouches for the row.
|
|
66
|
+
function canWriteAroundExecutor(feature: FeatureDefinition, handler: WriteHandlerDef): boolean {
|
|
67
|
+
return handler.escapeHatch !== undefined || feature.systemScope;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Owner binding counts only for a handler mapped to one entity.
|
|
23
71
|
function candidatePersonalFieldNames(
|
|
24
72
|
feature: FeatureDefinition,
|
|
25
73
|
handlerName: string,
|
|
74
|
+
handler: WriteHandlerDef,
|
|
26
75
|
): ReadonlySet<string> {
|
|
27
76
|
const mappedEntityName = feature.handlerEntityMappings?.[handlerName];
|
|
28
77
|
const entities = feature.entities ?? {};
|
|
29
78
|
if (mappedEntityName) {
|
|
30
79
|
const entity = entities[mappedEntityName];
|
|
31
|
-
|
|
80
|
+
const honorOwnerBinding = !canWriteAroundExecutor(feature, handler);
|
|
81
|
+
return entity ? personalFieldNames(entity, honorOwnerBinding) : new Set();
|
|
32
82
|
}
|
|
33
83
|
const names = new Set<string>();
|
|
34
84
|
for (const entity of Object.values(entities)) {
|
|
35
|
-
for (const name of personalFieldNames(entity)) names.add(name);
|
|
85
|
+
for (const name of personalFieldNames(entity, false)) names.add(name);
|
|
36
86
|
}
|
|
37
87
|
return names;
|
|
38
88
|
}
|
|
@@ -52,8 +102,16 @@ function hasOpenToAll(access: AccessRule): boolean {
|
|
|
52
102
|
return "openToAll" in access;
|
|
53
103
|
}
|
|
54
104
|
|
|
55
|
-
|
|
56
|
-
|
|
105
|
+
// Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
|
|
106
|
+
function declaredPersonalData(access: AccessRule): unknown {
|
|
107
|
+
if (!("openToAll" in access)) return undefined;
|
|
108
|
+
const openToAll: unknown = access.openToAll;
|
|
109
|
+
if (typeof openToAll !== "object" || openToAll === null) return undefined;
|
|
110
|
+
return "personalData" in openToAll ? openToAll.personalData : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function declaresTenantMembersPersonalData(access: AccessRule): boolean {
|
|
114
|
+
return declaredPersonalData(access) === "tenant-members";
|
|
57
115
|
}
|
|
58
116
|
|
|
59
117
|
function validateOpenToAllReason(
|
|
@@ -86,19 +144,32 @@ function validateEscapeHatchReason(
|
|
|
86
144
|
);
|
|
87
145
|
}
|
|
88
146
|
|
|
89
|
-
function
|
|
147
|
+
function validatePersonalDataOnlyOnWrite(
|
|
90
148
|
feature: FeatureDefinition,
|
|
91
149
|
kind: HandlerKind,
|
|
92
150
|
handlerName: string,
|
|
93
151
|
access: AccessRule,
|
|
94
152
|
): void {
|
|
95
|
-
// skip: write handlers may declare
|
|
96
|
-
if (kind === "write" ||
|
|
153
|
+
// skip: write handlers may declare personalData, or it wasn't declared here
|
|
154
|
+
if (kind === "write" || declaredPersonalData(access) === undefined) return;
|
|
97
155
|
throw new Error(
|
|
98
156
|
`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
|
|
157
|
+
"openToAll.personalData — it only applies to write handlers, whose input is " +
|
|
158
|
+
"checked for personal-data fields.",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validatePersonalDataValue(
|
|
163
|
+
feature: FeatureDefinition,
|
|
164
|
+
handlerName: string,
|
|
165
|
+
access: AccessRule,
|
|
166
|
+
): void {
|
|
167
|
+
const declared = declaredPersonalData(access);
|
|
168
|
+
// skip: nothing declared, or the one supported value
|
|
169
|
+
if (declared === undefined || declared === "tenant-members") return;
|
|
170
|
+
throw new Error(
|
|
171
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
|
|
172
|
+
`openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`,
|
|
102
173
|
);
|
|
103
174
|
}
|
|
104
175
|
|
|
@@ -108,20 +179,22 @@ function validateOpenToAllPersonalData(
|
|
|
108
179
|
handler: WriteHandlerDef,
|
|
109
180
|
): void {
|
|
110
181
|
const access = handler.access;
|
|
111
|
-
// skip: no openToAll declared, or
|
|
112
|
-
if (!hasOpenToAll(access) ||
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const personalNames = candidatePersonalFieldNames(feature, handlerName);
|
|
117
|
-
const offending = Object.keys(shape).filter((key) => personalNames.has(key));
|
|
182
|
+
// skip: no openToAll declared, or the handler declares tenant members may write personal data
|
|
183
|
+
if (!hasOpenToAll(access) || declaresTenantMembersPersonalData(access)) return;
|
|
184
|
+
const inputKeys = collectZodObjectKeys(handler.schema);
|
|
185
|
+
const personalNames = candidatePersonalFieldNames(feature, handlerName, handler);
|
|
186
|
+
const offending = [...inputKeys].filter((key) => personalNames.has(key));
|
|
118
187
|
// skip: no personal-data fields in the handler's input
|
|
119
188
|
if (offending.length === 0) return;
|
|
120
189
|
throw new Error(
|
|
121
190
|
`[Feature ${feature.name}] write handler "${handlerName}" declares openToAll and ` +
|
|
122
|
-
`accepts personal-data field(s) ${offending.map((f) => `"${f}"`).join(", ")}
|
|
123
|
-
"
|
|
124
|
-
"
|
|
191
|
+
`accepts personal-data field(s) ${offending.map((f) => `"${f}"`).join(", ")} that are ` +
|
|
192
|
+
"not bound to the caller. Choose one: (1) bind rows to the caller — every role in the " +
|
|
193
|
+
'entity\'s access.write is from("user:id", "<ownerField>") for a personal: { of: "<ownerField>" } ' +
|
|
194
|
+
'field, or from("user:id", "id") for a personal: "self" field, with no escapeHatch on the ' +
|
|
195
|
+
"handler and no r.systemScope() on its feature; (2) restrict access to roles; (3) declare " +
|
|
196
|
+
'{ openToAll: { reason: "<why any signed-in tenant member may write this personal data>", ' +
|
|
197
|
+
'personalData: "tenant-members" } }.',
|
|
125
198
|
);
|
|
126
199
|
}
|
|
127
200
|
|
|
@@ -129,15 +202,16 @@ export function validateAccessDeclarations(feature: FeatureDefinition): void {
|
|
|
129
202
|
for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
|
|
130
203
|
validateOpenToAllReason(feature, "write", handlerName, handler.access);
|
|
131
204
|
validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
|
|
205
|
+
validatePersonalDataValue(feature, handlerName, handler.access);
|
|
132
206
|
validateOpenToAllPersonalData(feature, handlerName, handler);
|
|
133
207
|
}
|
|
134
208
|
for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
|
|
135
209
|
validateOpenToAllReason(feature, "query", handlerName, handler.access);
|
|
136
210
|
validateEscapeHatchReason(feature, "query", handlerName, handler.escapeHatch);
|
|
137
|
-
|
|
211
|
+
validatePersonalDataOnlyOnWrite(feature, "query", handlerName, handler.access);
|
|
138
212
|
}
|
|
139
213
|
for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
|
|
140
214
|
validateOpenToAllReason(feature, "stream", handlerName, handler.access);
|
|
141
|
-
|
|
215
|
+
validatePersonalDataOnlyOnWrite(feature, "stream", handlerName, handler.access);
|
|
142
216
|
}
|
|
143
217
|
}
|
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ZodArray,
|
|
3
|
+
ZodCatch,
|
|
4
|
+
ZodDefault,
|
|
5
|
+
ZodIntersection,
|
|
6
|
+
ZodLazy,
|
|
7
|
+
ZodNonOptional,
|
|
8
|
+
ZodNullable,
|
|
9
|
+
ZodObject,
|
|
10
|
+
ZodOptional,
|
|
11
|
+
ZodPipe,
|
|
12
|
+
ZodPrefault,
|
|
13
|
+
ZodReadonly,
|
|
14
|
+
ZodRecord,
|
|
15
|
+
ZodTuple,
|
|
16
|
+
type ZodType,
|
|
17
|
+
ZodUnion,
|
|
18
|
+
ZodXor,
|
|
19
|
+
} from "zod";
|
|
20
|
+
import type { $ZodType } from "zod/v4/core";
|
|
2
21
|
|
|
3
22
|
// Drills through wrapper types (.nullable(), .optional(), .default()) a
|
|
4
23
|
// handler's schema may use around its actual object/array shape — e.g. a
|
|
@@ -49,3 +68,75 @@ export function getZodRowShape(schema: ZodType | undefined): Record<string, ZodT
|
|
|
49
68
|
? getZodObjectShape(rowsArray.element as ZodType)
|
|
50
69
|
: undefined;
|
|
51
70
|
}
|
|
71
|
+
|
|
72
|
+
type SingleInnerSchema =
|
|
73
|
+
| ZodOptional
|
|
74
|
+
| ZodNullable
|
|
75
|
+
| ZodDefault
|
|
76
|
+
| ZodPrefault
|
|
77
|
+
| ZodNonOptional
|
|
78
|
+
| ZodCatch
|
|
79
|
+
| ZodReadonly
|
|
80
|
+
| ZodLazy;
|
|
81
|
+
|
|
82
|
+
const SINGLE_INNER_SCHEMA_CLASSES = [
|
|
83
|
+
ZodOptional,
|
|
84
|
+
ZodNullable,
|
|
85
|
+
ZodDefault,
|
|
86
|
+
ZodPrefault,
|
|
87
|
+
ZodNonOptional,
|
|
88
|
+
ZodCatch,
|
|
89
|
+
ZodReadonly,
|
|
90
|
+
ZodLazy,
|
|
91
|
+
] as const;
|
|
92
|
+
|
|
93
|
+
function isSingleInnerSchema(schema: $ZodType): schema is SingleInnerSchema {
|
|
94
|
+
return SINGLE_INNER_SCHEMA_CLASSES.some((schemaClass) => schema instanceof schemaClass);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function combinatorChildSchemas(schema: $ZodType): readonly $ZodType[] {
|
|
98
|
+
if (schema instanceof ZodUnion || schema instanceof ZodXor) return schema.options;
|
|
99
|
+
if (schema instanceof ZodIntersection) return [schema.def.left, schema.def.right];
|
|
100
|
+
// z.preprocess() carries the accepted shape on `out`, .transform() on `in`.
|
|
101
|
+
if (schema instanceof ZodPipe) return [schema.in, schema.out];
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function childSchemas(schema: $ZodType): readonly $ZodType[] {
|
|
106
|
+
if (schema instanceof ZodObject) return Object.values(schema.shape);
|
|
107
|
+
if (schema instanceof ZodArray) return [schema.element];
|
|
108
|
+
if (schema instanceof ZodRecord) return [schema.valueType];
|
|
109
|
+
if (schema instanceof ZodTuple) {
|
|
110
|
+
const { items, rest } = schema.def;
|
|
111
|
+
return rest ? [...items, rest] : items;
|
|
112
|
+
}
|
|
113
|
+
if (isSingleInnerSchema(schema)) return [schema.unwrap()];
|
|
114
|
+
return combinatorChildSchemas(schema);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A z.lazy() getter that builds a fresh schema per call never revisits a node.
|
|
118
|
+
const MAX_WALKED_SCHEMAS = 10_000;
|
|
119
|
+
|
|
120
|
+
// Object keys at any depth, so a field nested in an intersection, union or an
|
|
121
|
+
// update's `changes` object is found too.
|
|
122
|
+
export function collectZodObjectKeys(schema: $ZodType | undefined): ReadonlySet<string> {
|
|
123
|
+
const keys = new Set<string>();
|
|
124
|
+
if (schema === undefined) return keys;
|
|
125
|
+
const visited = new Set<$ZodType>();
|
|
126
|
+
const pending: $ZodType[] = [schema];
|
|
127
|
+
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
|
128
|
+
if (visited.has(next)) continue;
|
|
129
|
+
if (visited.size >= MAX_WALKED_SCHEMAS) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`collectZodObjectKeys: schema has more than ${MAX_WALKED_SCHEMAS} nodes — ` +
|
|
132
|
+
"a z.lazy() getter probably returns a new schema on every call; hoist it into a constant.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
visited.add(next);
|
|
136
|
+
if (next instanceof ZodObject) {
|
|
137
|
+
for (const key of Object.keys(next.shape)) keys.add(key);
|
|
138
|
+
}
|
|
139
|
+
pending.push(...childSchemas(next));
|
|
140
|
+
}
|
|
141
|
+
return keys;
|
|
142
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readOptionalAccessRule } from "../extractors/hooks";
|
|
3
|
+
|
|
4
|
+
describe("readOptionalAccessRule — openToAll.personalData", () => {
|
|
5
|
+
test("keeps personalData: tenant-members on the object form", () => {
|
|
6
|
+
expect(
|
|
7
|
+
readOptionalAccessRule({
|
|
8
|
+
openToAll: { reason: "members edit their team roster", personalData: "tenant-members" },
|
|
9
|
+
}),
|
|
10
|
+
).toEqual({
|
|
11
|
+
openToAll: { reason: "members edit their team roster", personalData: "tenant-members" },
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("drops an unknown personalData value", () => {
|
|
16
|
+
expect(
|
|
17
|
+
readOptionalAccessRule({
|
|
18
|
+
openToAll: { reason: "signup", personalData: "public-intake" },
|
|
19
|
+
}),
|
|
20
|
+
).toEqual({ openToAll: { reason: "signup" } });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("the deprecated openToAll: true form carries no personalData", () => {
|
|
24
|
+
expect(readOptionalAccessRule({ openToAll: true, personalData: "tenant-members" })).toEqual({
|
|
25
|
+
openToAll: true,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -42,12 +42,16 @@ 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 } : {};
|
|
46
45
|
if (value["openToAll"] === true) {
|
|
47
|
-
return { openToAll: true
|
|
46
|
+
return { openToAll: true };
|
|
48
47
|
}
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
const openToAll = value["openToAll"];
|
|
49
|
+
if (isPlainObject(openToAll) && typeof openToAll["reason"] === "string") {
|
|
50
|
+
const personalData =
|
|
51
|
+
openToAll["personalData"] === "tenant-members"
|
|
52
|
+
? { personalData: "tenant-members" as const }
|
|
53
|
+
: {};
|
|
54
|
+
return { openToAll: { reason: openToAll["reason"], ...personalData } };
|
|
51
55
|
}
|
|
52
56
|
return undefined;
|
|
53
57
|
}
|
package/src/engine/index.ts
CHANGED
package/src/ui-types/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ export type {
|
|
|
56
56
|
AccessRule,
|
|
57
57
|
OpenToAllAccessRule,
|
|
58
58
|
OpenToAllDeclaration,
|
|
59
|
+
OpenToAllPersonalData,
|
|
59
60
|
} from "../engine/types/handlers";
|
|
60
61
|
export { isOpenToAllGranted } from "../engine/types/handlers";
|
|
61
62
|
export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav";
|