@cosmicdrift/kumiko-framework 0.250.0 → 0.252.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.250.0",
3
+ "version": "0.252.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.250.0",
201
+ "@cosmicdrift/kumiko-types": "0.252.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.250.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.252.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine/factories";
3
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
4
+ import {
5
+ type KmsContext,
6
+ subjectIdFromKey,
7
+ subjectIdToKey,
8
+ subjectKeyForRecord,
9
+ } from "../kms-adapter";
10
+ import {
11
+ decryptPiiFieldValues,
12
+ encryptPiiFieldValues,
13
+ isPiiCiphertext,
14
+ PII_CIPHERTEXT_PREFIX,
15
+ } from "../pii-field-encryption";
16
+ import {
17
+ collectPiiSubjectFields,
18
+ resolveSubjectForField,
19
+ SubjectResolutionError,
20
+ } from "../subject-resolver";
21
+
22
+ const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
23
+ const KMS_CTX: KmsContext = { requestId: "test" };
24
+
25
+ const recordEntity = createEntity({
26
+ fields: {
27
+ body: createTextField({ required: true, personal: { of: "id" }, find: "none" }),
28
+ },
29
+ table: "subject_key_record_probe",
30
+ });
31
+
32
+ describe("record subject key round-trip", () => {
33
+ test("subjectIdToKey serializes record:<entity>:<id>", () => {
34
+ expect(subjectIdToKey({ kind: "record", entity: "notesHistory", id: UUID_A })).toBe(
35
+ `record:notesHistory:${UUID_A}`,
36
+ );
37
+ });
38
+
39
+ test("subjectIdFromKey round-trips all three kinds", () => {
40
+ const cases = [
41
+ { kind: "user" as const, userId: UUID_A },
42
+ { kind: "tenant" as const, tenantId: UUID_A },
43
+ { kind: "record" as const, entity: "notesHistory", id: UUID_A },
44
+ ];
45
+ for (const subject of cases) {
46
+ expect(subjectIdFromKey(subjectIdToKey(subject))).toEqual(subject);
47
+ }
48
+ });
49
+
50
+ test("a record key never collides with a user key and vice versa", () => {
51
+ expect(subjectIdFromKey("record:a:b")).toEqual({ kind: "record", entity: "a", id: "b" });
52
+ expect(subjectIdFromKey("user:record:a:b")).toEqual({ kind: "user", userId: "record:a:b" });
53
+ });
54
+
55
+ test('subjectKeyForRecord rejects an entity name containing ":"', () => {
56
+ expect(() => subjectKeyForRecord("bad:name", UUID_A)).toThrow();
57
+ });
58
+
59
+ test("ciphertext round-trip for a record-owned field", async () => {
60
+ const kms = new InMemoryKmsAdapter();
61
+ const fields = collectPiiSubjectFields(recordEntity);
62
+ const row = { id: UUID_A, body: "free-text support note" };
63
+
64
+ const stored = await encryptPiiFieldValues(row, recordEntity, fields, kms, KMS_CTX, {
65
+ entityName: "recordProbe",
66
+ });
67
+ expect(isPiiCiphertext(stored["body"])).toBe(true);
68
+ expect(String(stored["body"])).toStartWith(
69
+ `${PII_CIPHERTEXT_PREFIX}record:recordProbe:${UUID_A}:`,
70
+ );
71
+
72
+ const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
73
+ expect(read["body"]).toBe("free-text support note");
74
+ });
75
+ });
76
+
77
+ describe("resolveSubjectForField: recordOwned fail-closed", () => {
78
+ test("recordOwned field without opts.entityName throws", () => {
79
+ expect(() => resolveSubjectForField(recordEntity, "body", { id: UUID_A })).toThrow(
80
+ SubjectResolutionError,
81
+ );
82
+ });
83
+
84
+ test("recordOwned field with row missing id throws", () => {
85
+ expect(() =>
86
+ resolveSubjectForField(recordEntity, "body", {}, { entityName: "recordProbe" }),
87
+ ).toThrow(SubjectResolutionError);
88
+ });
89
+
90
+ test("recordOwned field resolves to the record subject", () => {
91
+ const subject = resolveSubjectForField(
92
+ recordEntity,
93
+ "body",
94
+ { id: UUID_A },
95
+ { entityName: "recordProbe" },
96
+ );
97
+ expect(subject).toEqual({ kind: "record", entity: "recordProbe", id: UUID_A });
98
+ });
99
+ });
@@ -159,6 +159,8 @@ export interface EncryptPiiOptions {
159
159
  // Row to resolve subjects from when `row` is a partial (update changes may
160
160
  // carry a pii field without its ownerField — the merged row still has it).
161
161
  readonly subjectSource?: Record<string, unknown>;
162
+ // Canonical registry entity name — required to resolve recordOwned fields.
163
+ readonly entityName?: string;
162
164
  }
163
165
 
164
166
  export async function encryptPiiFieldValues(
@@ -186,6 +188,7 @@ export async function encryptPiiFieldValues(
186
188
  }
187
189
  const subject = resolveSubjectForField(entity, name, subjectSource, {
188
190
  ...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
191
+ ...(opts.entityName !== undefined && { entityName: opts.entityName }),
189
192
  });
190
193
  // skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
191
194
  if (subject === null) continue;
@@ -19,18 +19,83 @@ export interface ResolveSubjectOptions {
19
19
  // Write-time tenant scope — consulted for tenantOwned fields when the row
20
20
  // itself carries no tenantId column.
21
21
  readonly tenantId?: TenantId;
22
+ // Canonical source is the registry entity name (executor `entityName` /
23
+ // event `aggregate_type`) — required to resolve recordOwned fields.
24
+ readonly entityName?: string;
22
25
  }
23
26
 
24
27
  function nonEmptyString(value: unknown): string | null {
25
28
  return typeof value === "string" && value.length > 0 ? value : null;
26
29
  }
27
30
 
31
+ function rowIdAsString(row: Record<string, unknown>): string | null {
32
+ const id = row["id"];
33
+ return nonEmptyString(id) ?? (typeof id === "number" ? String(id) : null);
34
+ }
35
+
36
+ function resolveRecordSubject(
37
+ fieldName: string,
38
+ row: Record<string, unknown>,
39
+ opts: ResolveSubjectOptions,
40
+ ): SubjectId {
41
+ const entityName = nonEmptyString(opts.entityName);
42
+ if (entityName === null) {
43
+ throw new SubjectResolutionError(
44
+ fieldName,
45
+ "record subject needs the entity name; caller did not supply one",
46
+ );
47
+ }
48
+ const recordId = rowIdAsString(row);
49
+ if (recordId === null) {
50
+ throw new SubjectResolutionError(fieldName, "row has no id to use as the record subject");
51
+ }
52
+ return { kind: "record", entity: entityName, id: recordId };
53
+ }
54
+
55
+ function resolveUserOwnedSubject(
56
+ ownerField: string,
57
+ fieldName: string,
58
+ row: Record<string, unknown>,
59
+ ): SubjectId {
60
+ const userId = nonEmptyString(row[ownerField]);
61
+ if (userId === null) {
62
+ throw new SubjectResolutionError(fieldName, `owner field "${ownerField}" is empty on the row`);
63
+ }
64
+ return { kind: "user", userId };
65
+ }
66
+
67
+ function resolveTenantSubject(
68
+ fieldName: string,
69
+ row: Record<string, unknown>,
70
+ opts: ResolveSubjectOptions,
71
+ ): SubjectId {
72
+ const tenantId = nonEmptyString(row["tenantId"]) ?? opts.tenantId;
73
+ if (tenantId === undefined) {
74
+ throw new SubjectResolutionError(
75
+ fieldName,
76
+ "row has no tenantId column and no write-time tenantId was provided",
77
+ );
78
+ }
79
+ return { kind: "tenant", tenantId };
80
+ }
81
+
82
+ function resolveSelfPiiSubject(fieldName: string, row: Record<string, unknown>): SubjectId {
83
+ // pii: true = the entity itself is the subject (user.email belongs to
84
+ // that user row). Serial ids are stringified — subject keys are text.
85
+ const userId = rowIdAsString(row);
86
+ if (userId === null) {
87
+ throw new SubjectResolutionError(fieldName, "row has no id to use as the pii self-subject");
88
+ }
89
+ return { kind: "user", userId };
90
+ }
91
+
28
92
  /**
29
93
  * Maps a pii-annotated field to the subject whose key encrypts it.
30
94
  * Returns null for fields without any PII annotation (stored plaintext).
31
95
  *
32
96
  * Precedence for multi-annotated fields mirrors the erase triggers:
33
- * userOwned (user-forget) > tenantOwned (tenant-destroy) > pii (self).
97
+ * recordOwned (record-forget) > userOwned (user-forget) > tenantOwned
98
+ * (tenant-destroy) > pii (self).
34
99
  */
35
100
  export function resolveSubjectForField(
36
101
  entity: EntityDefinition,
@@ -41,53 +106,39 @@ export function resolveSubjectForField(
41
106
  const field = entity.fields[fieldName];
42
107
  if (!field) throw new SubjectResolutionError(fieldName, "field is not defined on the entity");
43
108
 
109
+ if ("recordOwned" in field && field.recordOwned === true) {
110
+ return resolveRecordSubject(fieldName, row, opts);
111
+ }
112
+
44
113
  if ("userOwned" in field && field.userOwned !== undefined) {
45
- const ownerField = field.userOwned.ownerField;
46
- const userId = nonEmptyString(row[ownerField]);
47
- if (userId === null) {
48
- throw new SubjectResolutionError(
49
- fieldName,
50
- `owner field "${ownerField}" is empty on the row`,
51
- );
52
- }
53
- return { kind: "user", userId };
114
+ return resolveUserOwnedSubject(field.userOwned.ownerField, fieldName, row);
54
115
  }
55
116
 
56
117
  if ("tenantOwned" in field && field.tenantOwned === true) {
57
- const tenantId = nonEmptyString(row["tenantId"]) ?? opts.tenantId;
58
- if (tenantId === undefined) {
59
- throw new SubjectResolutionError(
60
- fieldName,
61
- "row has no tenantId column and no write-time tenantId was provided",
62
- );
63
- }
64
- return { kind: "tenant", tenantId };
118
+ return resolveTenantSubject(fieldName, row, opts);
65
119
  }
66
120
 
67
121
  if (isSelfPiiField(field)) {
68
- // pii: true = the entity itself is the subject (user.email belongs to
69
- // that user row). Serial ids are stringified — subject keys are text.
70
- const id = row["id"];
71
- const userId = nonEmptyString(id) ?? (typeof id === "number" ? String(id) : null);
72
- if (userId === null) {
73
- throw new SubjectResolutionError(fieldName, "row has no id to use as the pii self-subject");
74
- }
75
- return { kind: "user", userId };
122
+ return resolveSelfPiiSubject(fieldName, row);
76
123
  }
77
124
 
78
125
  return null;
79
126
  }
80
127
 
128
+ function hasSubjectAnnotation(field: EntityDefinition["fields"][string]): boolean {
129
+ return (
130
+ ("userOwned" in field && field.userOwned !== undefined) ||
131
+ ("tenantOwned" in field && field.tenantOwned === true) ||
132
+ ("recordOwned" in field && field.recordOwned === true) ||
133
+ isSelfPiiField(field)
134
+ );
135
+ }
136
+
81
137
  // The field names an encrypt engine must process for an entity — precomputed
82
138
  // once at executor build time, like the sensitiveFields set.
83
139
  export function collectPiiSubjectFields(entity: EntityDefinition): readonly string[] {
84
140
  return Object.entries(entity.fields)
85
- .filter(
86
- ([, field]) =>
87
- ("userOwned" in field && field.userOwned !== undefined) ||
88
- ("tenantOwned" in field && field.tenantOwned === true) ||
89
- isSelfPiiField(field),
90
- )
141
+ .filter(([, field]) => hasSubjectAnnotation(field))
91
142
  .map(([name]) => name);
92
143
  }
93
144
 
@@ -95,11 +146,7 @@ export function collectPiiSubjectFields(entity: EntityDefinition): readonly stri
95
146
  export function collectSearchableSubjectFields(entity: EntityDefinition): readonly string[] {
96
147
  return Object.entries(entity.fields)
97
148
  .filter(([, field]) => {
98
- const subject =
99
- ("userOwned" in field && field.userOwned !== undefined) ||
100
- ("tenantOwned" in field && field.tenantOwned === true) ||
101
- isSelfPiiField(field);
102
- if (!subject) return false;
149
+ if (!hasSubjectAnnotation(field)) return false;
103
150
  if ("sensitive" in field && field.sensitive === true) return false;
104
151
  return "searchable" in field && field.searchable === true;
105
152
  })
@@ -15,6 +15,7 @@ import { collectLookupableFields } from "../crypto/blind-index";
15
15
  import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
16
16
  import { isSelfPiiField } from "../crypto/is-self-pii-field";
17
17
  import type { FeatureDefinition } from "../engine/types";
18
+ import type { EntityDefinition } from "../engine/types/fields";
18
19
  import { toSnakeCase } from "../utils/case";
19
20
  import type { DbRunner } from "./connection";
20
21
  import { resolveTableName } from "./entity-table-meta";
@@ -105,3 +106,40 @@ export async function subjectRowExistsInTenant(
105
106
  }
106
107
  return false;
107
108
  }
109
+
110
+ function findEntityByExactName(
111
+ features: ReadonlyMap<string, FeatureDefinition>,
112
+ entityName: string,
113
+ ): EntityDefinition | undefined {
114
+ for (const feature of features.values()) {
115
+ const entity = feature.entities?.[entityName];
116
+ if (entity) return entity;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ // Tighter than subjectRowExistsInTenant: the record subject names its own
122
+ // entity, so this is a single-table lookup instead of a self-PII scan
123
+ // across the whole registry.
124
+ //
125
+ // Invariant: sound only because `id` is a single-column PK per entity
126
+ // table (globally unique, unspoofable via a client-supplied explicitId
127
+ // at create) — a composite PK or shared table would break this (#2348).
128
+ export async function recordRowExistsInTenant(
129
+ db: DbRunner,
130
+ features: ReadonlyMap<string, FeatureDefinition>,
131
+ entityName: string,
132
+ recordId: string,
133
+ tenantId: string,
134
+ ): Promise<boolean> {
135
+ const entity = findEntityByExactName(features, entityName);
136
+ if (!entity) return false;
137
+ const tableName = resolveTableName(entityName, entity, undefined);
138
+ if (!(await tableExists(db, tableName))) return false;
139
+ const rows = await executeRawQueryRead(
140
+ db,
141
+ `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
142
+ [recordId, tenantId],
143
+ );
144
+ return rows.length > 0;
145
+ }
@@ -265,6 +265,7 @@ export function buildExecutorContext(
265
265
  if (hasPiiFields && kms) {
266
266
  out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
267
267
  tenantId: user.tenantId,
268
+ entityName,
268
269
  ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
269
270
  ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
270
271
  });
package/src/db/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export { assertExistsIn } from "./assert-exists-in";
2
- export { nullBlindIndexesForSubject, subjectRowExistsInTenant } from "./blind-index-cleanup";
2
+ export {
3
+ nullBlindIndexesForSubject,
4
+ recordRowExistsInTenant,
5
+ subjectRowExistsInTenant,
6
+ } from "./blind-index-cleanup";
3
7
  export { collectTableMetas } from "./collect-table-metas";
4
8
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
5
9
  export { seedConfigValues } from "./config-seed";
@@ -329,6 +329,7 @@ export async function backfillEventPiiEncryption(
329
329
  projectionOwnerRow,
330
330
  // @cast-boundary db-read — tenant_id column is the branded TenantId
331
331
  row.tenant_id as TenantId,
332
+ row.aggregate_type,
332
333
  );
333
334
  if (resolution.kind === "unannotated") continue;
334
335
  if (resolution.kind === "unresolved") {
@@ -408,9 +409,10 @@ function resolveOwnerSubject(
408
409
  subjectSource: Record<string, unknown>,
409
410
  projectionOwnerRow: Record<string, unknown> | undefined,
410
411
  tenantId: TenantId,
412
+ entityName: string,
411
413
  ): OwnerSubjectResolution {
412
414
  try {
413
- const subject = resolveSubjectForField(entity, field, subjectSource, { tenantId });
415
+ const subject = resolveSubjectForField(entity, field, subjectSource, { tenantId, entityName });
414
416
  return subject === null
415
417
  ? { kind: "unannotated" }
416
418
  : { kind: "resolved", subject, viaProjection: false };
@@ -419,7 +421,7 @@ function resolveOwnerSubject(
419
421
  if (projectionOwnerRow) {
420
422
  try {
421
423
  const augmented = { ...subjectSource, ...projectionOwnerRow };
422
- const subject = resolveSubjectForField(entity, field, augmented, { tenantId });
424
+ const subject = resolveSubjectForField(entity, field, augmented, { tenantId, entityName });
423
425
  if (subject !== null) return { kind: "resolved", subject, viaProjection: true };
424
426
  } catch (e2) {
425
427
  if (!(e2 instanceof SubjectResolutionError)) throw e2;
@@ -428,6 +428,7 @@ describe("buildAppSchema", () => {
428
428
  "pii", // PII classification, drives crypto/storage — not client-relevant
429
429
  "userOwned", // same: subject-key annotation, server/crypto-only
430
430
  "tenantOwned", // same: subject-key annotation, server/crypto-only
431
+ "recordOwned", // same: subject-key annotation, server/crypto-only
431
432
  "anonymize", // retention-cleanup callback, never serializable to JSON
432
433
  "allowPlaintext", // PII-audit reason string, not a renderer concern
433
434
  "lookupable", // blind-index equality lookup, server-side query concern
@@ -362,6 +362,190 @@ describe("r.screen() — registration", () => {
362
362
  expect(() => validateBoot(features)).not.toThrow();
363
363
  });
364
364
 
365
+ describe("relatedList search + facets (fw#2740)", () => {
366
+ test("rejects searchable: true when the bound query's schema has no search parameter", () => {
367
+ const features = [
368
+ defineFeature("app", (r) => {
369
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
370
+ access: { openToAll: true },
371
+ });
372
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
373
+ access: { openToAll: true },
374
+ });
375
+ r.screen({
376
+ id: "x",
377
+ type: "projectionDetail",
378
+ query: "app:query:foo:detail",
379
+ layout: {
380
+ sections: [
381
+ {
382
+ kind: "relatedList",
383
+ title: "s",
384
+ query: "app:query:foo:list",
385
+ columns: ["name"],
386
+ searchable: true,
387
+ },
388
+ ],
389
+ },
390
+ });
391
+ }),
392
+ ];
393
+ expect(() => validateBoot(features)).toThrow(/searchable: true.*"search"/);
394
+ });
395
+
396
+ test("accepts searchable: true when the bound query's schema accepts search", () => {
397
+ const features = [
398
+ defineFeature("app", (r) => {
399
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
400
+ access: { openToAll: true },
401
+ });
402
+ r.queryHandler(
403
+ "foo:list",
404
+ z.object({ search: z.string().optional() }),
405
+ async () => ({ rows: [], nextCursor: null }),
406
+ { access: { openToAll: true } },
407
+ );
408
+ r.screen({
409
+ id: "x",
410
+ type: "projectionDetail",
411
+ query: "app:query:foo:detail",
412
+ layout: {
413
+ sections: [
414
+ {
415
+ kind: "relatedList",
416
+ title: "s",
417
+ query: "app:query:foo:list",
418
+ columns: ["name"],
419
+ searchable: true,
420
+ },
421
+ ],
422
+ },
423
+ });
424
+ }),
425
+ ];
426
+ expect(() => validateBoot(features)).not.toThrow();
427
+ });
428
+
429
+ test("rejects facets when the bound query's schema has no filters parameter", () => {
430
+ const features = [
431
+ defineFeature("app", (r) => {
432
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
433
+ access: { openToAll: true },
434
+ });
435
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
436
+ access: { openToAll: true },
437
+ });
438
+ r.screen({
439
+ id: "x",
440
+ type: "projectionDetail",
441
+ query: "app:query:foo:detail",
442
+ layout: {
443
+ sections: [
444
+ {
445
+ kind: "relatedList",
446
+ title: "s",
447
+ query: "app:query:foo:list",
448
+ columns: ["name", "status"],
449
+ facets: [
450
+ {
451
+ field: "status",
452
+ type: "select",
453
+ label: "Status",
454
+ options: [{ value: "active", label: "Active" }],
455
+ },
456
+ ],
457
+ },
458
+ ],
459
+ },
460
+ });
461
+ }),
462
+ ];
463
+ expect(() => validateBoot(features)).toThrow(/declares facets but query.*"filters"/);
464
+ });
465
+
466
+ test("rejects a facet whose field is not a declared column", () => {
467
+ const features = [
468
+ defineFeature("app", (r) => {
469
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
470
+ access: { openToAll: true },
471
+ });
472
+ r.queryHandler(
473
+ "foo:list",
474
+ z.object({ filters: z.array(z.object({})).optional() }),
475
+ async () => ({ rows: [], nextCursor: null }),
476
+ { access: { openToAll: true } },
477
+ );
478
+ r.screen({
479
+ id: "x",
480
+ type: "projectionDetail",
481
+ query: "app:query:foo:detail",
482
+ layout: {
483
+ sections: [
484
+ {
485
+ kind: "relatedList",
486
+ title: "s",
487
+ query: "app:query:foo:list",
488
+ columns: ["name"],
489
+ facets: [
490
+ {
491
+ field: "status",
492
+ type: "select",
493
+ label: "Status",
494
+ options: [{ value: "active", label: "Active" }],
495
+ },
496
+ ],
497
+ },
498
+ ],
499
+ },
500
+ });
501
+ }),
502
+ ];
503
+ expect(() => validateBoot(features)).toThrow(
504
+ /relatedList section "s".*facet references field "status" which is not a declared column/,
505
+ );
506
+ });
507
+
508
+ test("accepts valid facets when the bound query's schema accepts filters", () => {
509
+ const features = [
510
+ defineFeature("app", (r) => {
511
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
512
+ access: { openToAll: true },
513
+ });
514
+ r.queryHandler(
515
+ "foo:list",
516
+ z.object({ filters: z.array(z.object({})).optional() }),
517
+ async () => ({ rows: [], nextCursor: null }),
518
+ { access: { openToAll: true } },
519
+ );
520
+ r.screen({
521
+ id: "x",
522
+ type: "projectionDetail",
523
+ query: "app:query:foo:detail",
524
+ layout: {
525
+ sections: [
526
+ {
527
+ kind: "relatedList",
528
+ title: "s",
529
+ query: "app:query:foo:list",
530
+ columns: ["name", "status"],
531
+ facets: [
532
+ {
533
+ field: "status",
534
+ type: "select",
535
+ label: "Status",
536
+ options: [{ value: "active", label: "Active" }],
537
+ },
538
+ ],
539
+ },
540
+ ],
541
+ },
542
+ });
543
+ }),
544
+ ];
545
+ expect(() => validateBoot(features)).not.toThrow();
546
+ });
547
+ });
548
+
365
549
  test("validateBoot rejects a relatedList section on entityEdit (fw#2166)", () => {
366
550
  const features = [
367
551
  defineFeature("shop", (r) => {
@@ -46,7 +46,10 @@ import {
46
46
  } from "./nav";
47
47
  import { collectClaimKeys, validateOwnershipRules } from "./ownership";
48
48
  import { validatePiiAndRetention } from "./pii-retention";
49
- import { validateProjectionListScreens } from "./projection-list-screens";
49
+ import {
50
+ validateProjectionListScreens,
51
+ validateRelatedListSectionQueries,
52
+ } from "./projection-list-screens";
50
53
  import { validateQueryOutputColumns } from "./query-output-columns";
51
54
  import { validateQueryRefs } from "./query-refs";
52
55
  import {
@@ -224,6 +227,7 @@ export function validateBoot(
224
227
  // assume every `query` string already resolves to a registered handler.
225
228
  validateQueryOutputColumns(features);
226
229
  validateProjectionListScreens(features);
230
+ validateRelatedListSectionQueries(features);
227
231
  validateExtensionPreSaveWiring(features);
228
232
  validateGdprStoragePersistence(features);
229
233
  validateFeatureBootChecks(features);
@@ -24,7 +24,7 @@ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
24
24
  // Excludes subjectRef: its `personal: "ref"` union member structurally
25
25
  // forbids `anonymize` (packages/types/src/fields.ts) — #2336.
26
26
  function hasAnonymizableSubjectField(annot: ResolvedPiiFlags): boolean {
27
- return Boolean(annot.pii || annot.userOwned || annot.tenantOwned);
27
+ return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.recordOwned);
28
28
  }
29
29
 
30
30
  // --- PII / Subject-Key Annotations + Retention validation ---
@@ -66,7 +66,12 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
66
66
  const hasPii = Boolean(annot.pii);
67
67
  const hasUserOwned = Boolean(annot.userOwned);
68
68
  const hasTenantOwned = Boolean(annot.tenantOwned);
69
- const annotCount = (hasPii ? 1 : 0) + (hasUserOwned ? 1 : 0) + (hasTenantOwned ? 1 : 0);
69
+ const hasRecordOwned = Boolean(annot.recordOwned);
70
+ const annotCount =
71
+ (hasPii ? 1 : 0) +
72
+ (hasUserOwned ? 1 : 0) +
73
+ (hasTenantOwned ? 1 : 0) +
74
+ (hasRecordOwned ? 1 : 0);
70
75
 
71
76
  if (annotCount > 1) {
72
77
  throw new Error(
@@ -121,3 +121,45 @@ export function validateProjectionListScreens(features: readonly FeatureDefiniti
121
121
  }
122
122
  }
123
123
  }
124
+
125
+ // relatedList sections declare search/facets the same way a projectionList
126
+ // screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
127
+ // auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
128
+ // undeclared param would fail every query instead of failing at boot.
129
+ // Reachable on projectionDetail only (screens.ts rejects relatedList
130
+ // elsewhere), but the layout walk stays uniform so lifting that restriction
131
+ // doesn't quietly skip these checks.
132
+ export function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void {
133
+ const queryHandlers = buildQueryHandlerMap(features);
134
+ for (const feature of features) {
135
+ for (const screen of Object.values(feature.screens)) {
136
+ if (
137
+ screen.type !== "projectionDetail" &&
138
+ screen.type !== "entityEdit" &&
139
+ screen.type !== "actionForm" &&
140
+ screen.type !== "configEdit"
141
+ ) {
142
+ continue;
143
+ }
144
+ for (const section of screen.layout.sections) {
145
+ if (section.kind !== "relatedList") continue;
146
+ const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
147
+ const schema = queryHandlers.get(section.query)?.schema;
148
+ if (section.searchable === true && !schemaAccepts(schema, "search")) {
149
+ throw new Error(
150
+ `${prefix}: searchable: true but query "${section.query}" has no "search" parameter in its Zod schema`,
151
+ );
152
+ }
153
+ if (
154
+ section.facets !== undefined &&
155
+ section.facets.length > 0 &&
156
+ !schemaAccepts(schema, "filters")
157
+ ) {
158
+ throw new Error(
159
+ `${prefix}: declares facets but query "${section.query}" has no "filters" parameter in its Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
160
+ );
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
@@ -25,6 +25,8 @@ import type {
25
25
  EditFieldSpec,
26
26
  EditLayout,
27
27
  FieldCondition,
28
+ ListColumnSpec,
29
+ ListFacetSpec,
28
30
  RowAction,
29
31
  RowActionNavigateBase,
30
32
  RowFieldExtractor,
@@ -559,37 +561,12 @@ export function validateScreens(
559
561
  `filter.value to be a readonly array.`,
560
562
  );
561
563
  }
562
- // Facets (fw#2224) — unlike filter, a field inventory IS available
563
- // here: the declared columns. A facet on a field with no column is
564
- // almost always a typo (the user never sees the field anywhere), so
565
- // this is hard-checked rather than just documented.
566
564
  if (screen.facets !== undefined) {
567
- const columnFieldNames = new Set(
568
- screen.columns.map((col) => normalizeListColumn(col).field),
565
+ validateListFacets(
566
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList)`,
567
+ screen.facets,
568
+ screen.columns,
569
569
  );
570
- const seenFacetFields = new Set<string>();
571
- for (const facet of screen.facets) {
572
- if (seenFacetFields.has(facet.field)) {
573
- throw new Error(
574
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) declares facet ` +
575
- `"${facet.field}" more than once.`,
576
- );
577
- }
578
- seenFacetFields.add(facet.field);
579
- if (!columnFieldNames.has(facet.field)) {
580
- throw new Error(
581
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet references field ` +
582
- `"${facet.field}" which is not a declared column. Known columns: ` +
583
- `${[...columnFieldNames].sort().join(", ")}`,
584
- );
585
- }
586
- if (facet.type === "select" && facet.options.length === 0) {
587
- throw new Error(
588
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet "${facet.field}" ` +
589
- `(type "select") has an empty options list — declare at least one option.`,
590
- );
591
- }
592
- }
593
570
  }
594
571
  if (screen.rowActions !== undefined) {
595
572
  for (const action of screen.rowActions) {
@@ -863,6 +840,13 @@ export function validateScreens(
863
840
  );
864
841
  }
865
842
  }
843
+ if (section.facets !== undefined) {
844
+ validateListFacets(
845
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`,
846
+ section.facets,
847
+ section.columns,
848
+ );
849
+ }
866
850
  continue;
867
851
  }
868
852
  if (isWriteFormEditSection(section)) {
@@ -1805,6 +1789,38 @@ export function validateColumnRendererForm(
1805
1789
  }
1806
1790
  }
1807
1791
 
1792
+ // Facets (fw#2224) — unlike filter, a field inventory IS available here: the
1793
+ // declared columns. A facet on a field with no column is almost always a
1794
+ // typo (the user never sees the field anywhere), so this is hard-checked
1795
+ // rather than just documented. Shared by projectionList and relatedList
1796
+ // (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
1797
+ function validateListFacets(
1798
+ prefix: string,
1799
+ facets: readonly ListFacetSpec[],
1800
+ columns: readonly ListColumnSpec[],
1801
+ ): void {
1802
+ const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
1803
+ const seenFacetFields = new Set<string>();
1804
+ for (const facet of facets) {
1805
+ if (seenFacetFields.has(facet.field)) {
1806
+ throw new Error(`${prefix} declares facet "${facet.field}" more than once.`);
1807
+ }
1808
+ seenFacetFields.add(facet.field);
1809
+ if (!columnFieldNames.has(facet.field)) {
1810
+ throw new Error(
1811
+ `${prefix} facet references field "${facet.field}" which is not a declared column. ` +
1812
+ `Known columns: ${[...columnFieldNames].sort().join(", ")}`,
1813
+ );
1814
+ }
1815
+ if (facet.type === "select" && facet.options.length === 0) {
1816
+ throw new Error(
1817
+ `${prefix} facet "${facet.field}" (type "select") has an empty options list — ` +
1818
+ `declare at least one option.`,
1819
+ );
1820
+ }
1821
+ }
1822
+ }
1823
+
1808
1824
  export function findEntityFeature(
1809
1825
  entityName: string,
1810
1826
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -38,6 +38,10 @@ type PersonalOverridesInput = {
38
38
  readonly anonymize?: () => unknown | Promise<unknown>;
39
39
  };
40
40
 
41
+ // `personal: { of: "id" }` means the row itself is the subject, not a
42
+ // referenced user — resolves to `recordOwned` instead of `userOwned`.
43
+ const RECORD_OWNER_FIELD = "id";
44
+
41
45
  // Resolves the author-facing `personal`/`find` annotations (kumiko-framework#2250)
42
46
  // into the internal ResolvedPiiFlags every factory below merges into its
43
47
  // return value. `personal`, `find`, `reason` never survive into the field def.
@@ -58,6 +62,8 @@ function expandPersonalAnnotations<T extends PersonalOverridesInput>(
58
62
  personalFlags = { subjectRef: true };
59
63
  } else if (personal === false) {
60
64
  personalFlags = { allowPlaintext: reason };
65
+ } else if (personal && personal.of === RECORD_OWNER_FIELD) {
66
+ personalFlags = { recordOwned: true };
61
67
  } else if (personal) {
62
68
  personalFlags = { userOwned: { ownerField: personal.of } };
63
69
  }
@@ -22,45 +22,121 @@ import type { EntityId, TenantId } from "../engine/types/identifiers";
22
22
  import { toSnakeCase } from "../utils/case";
23
23
  import type { SearchAdapter } from "./types";
24
24
 
25
- /** Build OR predicates for rows owned by `subject` (id / ownerField / tenant_id). */
26
- function ownershipPredicates(
25
+ function finalizePredicateParts(
26
+ parts: readonly string[],
27
+ params: readonly unknown[],
28
+ ): { sql: string; params: unknown[] } | null {
29
+ if (parts.length === 0) return null;
30
+ return { sql: parts.join(" OR "), params: [...params] };
31
+ }
32
+
33
+ function userOwnershipPredicates(
27
34
  entity: EntityDefinition,
28
35
  searchableFields: readonly string[],
29
- subject: SubjectId,
36
+ userId: string,
30
37
  nextParam: () => number,
31
38
  ): { sql: string; params: unknown[] } | null {
32
39
  const parts: string[] = [];
33
40
  const params: unknown[] = [];
34
41
  let selfIdN: number | undefined;
35
- let tenantIdN: number | undefined;
36
42
  const ownerFieldN = new Map<string, number>();
37
43
 
38
44
  for (const fieldName of searchableFields) {
39
45
  const field = entity.fields[fieldName];
40
46
  if (!field) continue;
41
- if (subject.kind === "user") {
42
- if ("userOwned" in field && field.userOwned !== undefined) {
43
- const col = toSnakeCase(field.userOwned.ownerField);
44
- let n = ownerFieldN.get(col);
45
- if (n === undefined) {
46
- n = nextParam();
47
- ownerFieldN.set(col, n);
48
- params.push(subject.userId);
49
- parts.push(`${quoteIdent(col)} = $${n}`);
50
- }
51
- } else if (isSelfPiiField(field) && selfIdN === undefined) {
52
- selfIdN = nextParam();
53
- params.push(subject.userId);
54
- parts.push(`${quoteIdent("id")} = $${selfIdN}`);
47
+ if ("userOwned" in field && field.userOwned !== undefined) {
48
+ const col = toSnakeCase(field.userOwned.ownerField);
49
+ let n = ownerFieldN.get(col);
50
+ if (n === undefined) {
51
+ n = nextParam();
52
+ ownerFieldN.set(col, n);
53
+ params.push(userId);
54
+ parts.push(`${quoteIdent(col)} = $${n}`);
55
55
  }
56
- } else if ("tenantOwned" in field && field.tenantOwned === true && tenantIdN === undefined) {
56
+ } else if (isSelfPiiField(field) && selfIdN === undefined) {
57
+ selfIdN = nextParam();
58
+ params.push(userId);
59
+ parts.push(`${quoteIdent("id")} = $${selfIdN}`);
60
+ }
61
+ }
62
+ return finalizePredicateParts(parts, params);
63
+ }
64
+
65
+ function tenantOwnershipPredicates(
66
+ entity: EntityDefinition,
67
+ searchableFields: readonly string[],
68
+ tenantId: string,
69
+ nextParam: () => number,
70
+ ): { sql: string; params: unknown[] } | null {
71
+ const parts: string[] = [];
72
+ const params: unknown[] = [];
73
+ let tenantIdN: number | undefined;
74
+
75
+ for (const fieldName of searchableFields) {
76
+ const field = entity.fields[fieldName];
77
+ if (!field) continue;
78
+ if ("tenantOwned" in field && field.tenantOwned === true && tenantIdN === undefined) {
57
79
  tenantIdN = nextParam();
58
- params.push(subject.tenantId);
80
+ params.push(tenantId);
59
81
  parts.push(`${quoteIdent("tenant_id")} = $${tenantIdN}`);
60
82
  }
61
83
  }
62
- if (parts.length === 0) return null;
63
- return { sql: parts.join(" OR "), params };
84
+ return finalizePredicateParts(parts, params);
85
+ }
86
+
87
+ function recordOwnershipPredicates(
88
+ entity: EntityDefinition,
89
+ searchableFields: readonly string[],
90
+ entityName: string,
91
+ subjectEntity: string,
92
+ subjectId: string,
93
+ nextParam: () => number,
94
+ ): { sql: string; params: unknown[] } | null {
95
+ const parts: string[] = [];
96
+ const params: unknown[] = [];
97
+ let recordIdN: number | undefined;
98
+
99
+ for (const fieldName of searchableFields) {
100
+ const field = entity.fields[fieldName];
101
+ if (!field) continue;
102
+ const isMatchingRecordField =
103
+ entityName === subjectEntity && "recordOwned" in field && field.recordOwned === true;
104
+ if (isMatchingRecordField && recordIdN === undefined) {
105
+ recordIdN = nextParam();
106
+ params.push(subjectId);
107
+ parts.push(`${quoteIdent("id")} = $${recordIdN}`);
108
+ }
109
+ }
110
+ return finalizePredicateParts(parts, params);
111
+ }
112
+
113
+ /** Build OR predicates for rows owned by `subject` (id / ownerField / tenant_id). */
114
+ function ownershipPredicates(
115
+ entity: EntityDefinition,
116
+ searchableFields: readonly string[],
117
+ entityName: string,
118
+ subject: SubjectId,
119
+ nextParam: () => number,
120
+ ): { sql: string; params: unknown[] } | null {
121
+ switch (subject.kind) {
122
+ case "user":
123
+ return userOwnershipPredicates(entity, searchableFields, subject.userId, nextParam);
124
+ case "tenant":
125
+ return tenantOwnershipPredicates(entity, searchableFields, subject.tenantId, nextParam);
126
+ case "record":
127
+ return recordOwnershipPredicates(
128
+ entity,
129
+ searchableFields,
130
+ entityName,
131
+ subject.entity,
132
+ subject.id,
133
+ nextParam,
134
+ );
135
+ default: {
136
+ const exhaustiveCheck: never = subject;
137
+ throw new Error(`Unhandled subject kind: ${JSON.stringify(exhaustiveCheck)}`);
138
+ }
139
+ }
64
140
  }
65
141
 
66
142
  type MatchedRow = { id: string; tenant_id: string };
@@ -99,6 +175,7 @@ function buildSubjectPredicate(
99
175
  entity: EntityDefinition,
100
176
  fields: readonly string[],
101
177
  likePattern: string,
178
+ entityName: string,
102
179
  subject: SubjectId | undefined,
103
180
  ): { sql: string; params: unknown[] } {
104
181
  let paramIdx = 0;
@@ -113,7 +190,7 @@ function buildSubjectPredicate(
113
190
  );
114
191
 
115
192
  if (subject) {
116
- const owned = ownershipPredicates(entity, fields, subject, nextParam);
193
+ const owned = ownershipPredicates(entity, fields, entityName, subject, nextParam);
117
194
  if (owned) {
118
195
  params.push(...owned.params);
119
196
  orParts.push(`(${owned.sql})`);
@@ -144,7 +221,7 @@ export async function purgeSearchDocumentsForSubject(
144
221
  // a mounted feature without its migration must not abort the purge
145
222
  // (and the audit event after it).
146
223
  if (!(await tableExists(db, tableName))) continue;
147
- const predicate = buildSubjectPredicate(entity, fields, likePattern, subject);
224
+ const predicate = buildSubjectPredicate(entity, fields, likePattern, entityName, subject);
148
225
  const rows = await collectMatchingRowsForEntity(
149
226
  db,
150
227
  tableName,