@cosmicdrift/kumiko-framework 0.264.1 → 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.
Files changed (30) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +4 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +21 -2
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +4 -0
  5. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +4 -0
  6. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +4 -0
  7. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +4 -0
  8. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +4 -0
  9. package/src/api/__tests__/server-boot-guards.test.ts +60 -0
  10. package/src/api/auth-middleware.ts +2 -7
  11. package/src/api/auth-routes.ts +28 -14
  12. package/src/api/server.ts +14 -1
  13. package/src/changes.json +27 -0
  14. package/src/engine/__tests__/hook-identity-switch-grant.test.ts +84 -3
  15. package/src/engine/active-membership.ts +56 -0
  16. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
  17. package/src/engine/boot-validator/access-declarations.ts +101 -27
  18. package/src/engine/boot-validator/zod-shape.ts +92 -1
  19. package/src/engine/extension-names.ts +16 -0
  20. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
  21. package/src/engine/feature-ast/extractors/hooks.ts +8 -4
  22. package/src/engine/index.ts +15 -0
  23. package/src/engine/types/index.ts +4 -0
  24. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +38 -0
  25. package/src/pipeline/active-membership.ts +178 -0
  26. package/src/pipeline/dispatch-shared.ts +14 -1
  27. package/src/pipeline/dispatcher.ts +22 -1
  28. package/src/pipeline/system-identity-switch.ts +44 -13
  29. package/src/testing/handler-context.ts +4 -0
  30. 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 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
  }