@cosmicdrift/kumiko-framework 0.251.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.251.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.251.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.251.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
@@ -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(
@@ -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,