@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.265.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.265.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.265.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 type { AccessRule } from "../../types";
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 a personal-data field needs publicIntake.
146
- test("openToAll write handler accepting a personal-data field without publicIntake throws", () => {
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: "public signup" } },
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("openToAll write handler accepting a personal-data field WITH publicIntake boots fine", () => {
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: { openToAll: { reason: "public signup" }, publicIntake: true },
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
- // 4. publicIntake is only meaningful on a write handler.
185
- test("publicIntake on a query handler throws", () => {
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: "public listing" }, publicIntake: true },
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 publicIntake boots fine", () => {
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: "public listing" } },
247
+ access: { openToAll: { reason: "member listing" } },
201
248
  });
202
249
  });
203
250
  expect(() => validateAccessDeclarations(feature)).not.toThrow();
204
251
  });
205
252
 
206
- test("publicIntake on a stream handler throws", () => {
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: "public stream" }, publicIntake: true },
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 publicIntake boots fine", () => {
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: "public stream" } },
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 { AccessRule, FeatureDefinition, QueryHandlerDef, WriteHandlerDef } from "../types";
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 { getZodObjectShape } from "./zod-shape";
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+publicIntake is guarding against.
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 personalFieldNames(entity: EntityDefinition): ReadonlySet<string> {
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
- if (isPersonalDataField(field)) names.add(fieldName);
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
- return entity ? personalFieldNames(entity) : new Set();
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
- function hasPublicIntake(access: AccessRule): boolean {
56
- return "publicIntake" in access && access.publicIntake === true;
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 validatePublicIntakeOnlyOnWrite(
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 publicIntake, or it wasn't declared here
96
- if (kind === "write" || !hasPublicIntake(access)) return;
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
- "{ publicIntake: true } — publicIntake is only meaningful on a write handler " +
100
- "(it silences the personal-data + openToAll boot check for the fields a write " +
101
- "handler's input accepts).",
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 publicIntake already covers personal data
112
- if (!hasOpenToAll(access) || hasPublicIntake(access)) return;
113
- const shape = getZodObjectShape(handler.schema);
114
- // skip: handler schema isn't a zod object — nothing to inspect
115
- if (!shape) return;
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(", ")} without ` +
123
- "{ publicIntake: true }. Restrict access to roles, or declare " +
124
- "{ publicIntake: true } if any authenticated user may submit this personal data.",
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
- validatePublicIntakeOnlyOnWrite(feature, "query", handlerName, handler.access);
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
- validatePublicIntakeOnlyOnWrite(feature, "stream", handlerName, handler.access);
215
+ validatePersonalDataOnlyOnWrite(feature, "stream", handlerName, handler.access);
142
216
  }
143
217
  }
@@ -1,4 +1,23 @@
1
- import { ZodArray, ZodDefault, ZodNullable, ZodObject, ZodOptional, type ZodType } from "zod";
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, ...publicIntake };
46
+ return { openToAll: true };
48
47
  }
49
- if (isPlainObject(value["openToAll"]) && typeof value["openToAll"]["reason"] === "string") {
50
- return { openToAll: { reason: value["openToAll"]["reason"] }, ...publicIntake };
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
  }
@@ -432,6 +432,7 @@ export type {
432
432
  OnDeleteStrategy,
433
433
  OpenToAllAccessRule,
434
434
  OpenToAllDeclaration,
435
+ OpenToAllPersonalData,
435
436
  ParentRefDef,
436
437
  PersonalAnnotations,
437
438
  PersonalAnnotationsLongText,
@@ -178,6 +178,7 @@ export type {
178
178
  NotifyPriority,
179
179
  OpenToAllAccessRule,
180
180
  OpenToAllDeclaration,
181
+ OpenToAllPersonalData,
181
182
  QualifiedEventName,
182
183
  QueryEvent,
183
184
  QueryHandlerDef,
@@ -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";