@cosmicdrift/kumiko-framework 0.118.0 → 0.119.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.
@@ -133,19 +133,36 @@ describe("validateBoot — PII annotations", () => {
133
133
  );
134
134
  });
135
135
 
136
- test("userOwned.ownerField pointing to non-reference field throws", () => {
136
+ test("userOwned.ownerField on a text field passes (ES userId-by-convention)", () => {
137
137
  const feature = defineFeature("test", (r) => {
138
138
  r.entity(
139
139
  "comment",
140
140
  createEntity({
141
141
  fields: {
142
- body: createLongTextField({ userOwned: { ownerField: "authorName" } }),
143
- authorName: createTextField(),
142
+ body: createLongTextField({ userOwned: { ownerField: "authorId" } }),
143
+ authorId: createTextField(),
144
+ },
145
+ }),
146
+ );
147
+ });
148
+ expect(() => validateBoot([feature])).not.toThrow();
149
+ });
150
+
151
+ test("userOwned.ownerField pointing to a non-id-capable field throws", () => {
152
+ const feature = defineFeature("test", (r) => {
153
+ r.entity(
154
+ "comment",
155
+ createEntity({
156
+ fields: {
157
+ body: createLongTextField({ userOwned: { ownerField: "isPublic" } }),
158
+ isPublic: { type: "boolean" },
144
159
  },
145
160
  }),
146
161
  );
147
162
  });
148
- expect(() => validateBoot([feature])).toThrow(/must be a reference field, got type "text"/);
163
+ expect(() => validateBoot([feature])).toThrow(
164
+ /must be a reference or text \(userId\) field, got type "boolean"/,
165
+ );
149
166
  });
150
167
 
151
168
  test("userOwned.ownerField referencing non-user entity warns", () => {
@@ -568,3 +585,77 @@ describe("validateBoot — retention", () => {
568
585
  expect(matchingWarn).toBeUndefined();
569
586
  });
570
587
  });
588
+
589
+ describe("validateBoot — lookupable / blind-index (#818)", () => {
590
+ test("lookupable on subject-annotated text field passes", () => {
591
+ const feature = defineFeature("test", (r) => {
592
+ r.entity(
593
+ "user",
594
+ createEntity({
595
+ fields: {
596
+ email: createTextField({ pii: true, lookupable: true }),
597
+ },
598
+ }),
599
+ );
600
+ });
601
+ expect(() => validateBoot([feature])).not.toThrow();
602
+ });
603
+
604
+ test("lookupable without subject annotation throws", () => {
605
+ const feature = defineFeature("test", (r) => {
606
+ r.entity(
607
+ "doc",
608
+ createEntity({
609
+ fields: {
610
+ slug: createTextField({ lookupable: true }),
611
+ },
612
+ }),
613
+ );
614
+ });
615
+ expect(() => validateBoot([feature])).toThrow(/lookupable.*without a subject annotation/);
616
+ });
617
+
618
+ test("lookupable on non-text field throws", () => {
619
+ const feature = defineFeature("test", (r) => {
620
+ r.entity(
621
+ "person",
622
+ createEntity({
623
+ fields: {
624
+ // longText carries PiiAnnotations too — lookupable must still be rejected.
625
+ bio: createLongTextField({ userOwned: { ownerField: "ownerId" }, lookupable: true }),
626
+ ownerId: createTextField({ required: true }),
627
+ },
628
+ }),
629
+ );
630
+ });
631
+ expect(() => validateBoot([feature])).toThrow(/only apply to text fields/);
632
+ });
633
+
634
+ test("searchable combined with a subject annotation throws", () => {
635
+ const feature = defineFeature("test", (r) => {
636
+ r.entity(
637
+ "user",
638
+ createEntity({
639
+ fields: {
640
+ displayName: createTextField({ pii: true, searchable: true }),
641
+ },
642
+ }),
643
+ );
644
+ });
645
+ expect(() => validateBoot([feature])).toThrow(/searchable.*cannot work/);
646
+ });
647
+
648
+ test("sortable combined with a subject annotation throws", () => {
649
+ const feature = defineFeature("test", (r) => {
650
+ r.entity(
651
+ "user",
652
+ createEntity({
653
+ fields: {
654
+ email: createTextField({ pii: true, sortable: true }),
655
+ },
656
+ }),
657
+ );
658
+ });
659
+ expect(() => validateBoot([feature])).toThrow(/sortable.*cannot work/);
660
+ });
661
+ });
@@ -3,7 +3,11 @@
3
3
  // drizzle migrate-runner). See define-feature.ts / DX-4.
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { defineUnmanagedTable, resolveTableName } from "../../db/entity-table-meta";
6
+ import {
7
+ buildEntityTableMeta,
8
+ defineUnmanagedTable,
9
+ resolveTableName,
10
+ } from "../../db/entity-table-meta";
7
11
  import { defineFeature } from "../define-feature";
8
12
  import { createEntity, createTextField } from "../index";
9
13
  import { createRegistry } from "../registry";
@@ -125,3 +129,44 @@ describe("createRegistry — unmanagedTable aggregation", () => {
125
129
  );
126
130
  });
127
131
  });
132
+
133
+ describe("createRegistry — unmanaged tables with PII-annotated fields (#820)", () => {
134
+ const piiEntity = createEntity({
135
+ table: "ut_pii_probe",
136
+ fields: {
137
+ userId: createTextField({ required: true }),
138
+ ip: createTextField({ userOwned: { ownerField: "userId" } }),
139
+ },
140
+ });
141
+ const piiMeta = buildEntityTableMeta("ut-pii-probe", piiEntity);
142
+
143
+ test("buildEntityTableMeta records the subject-annotated field names", () => {
144
+ expect(piiMeta.piiSubjectFields).toEqual(["ip"]);
145
+ });
146
+
147
+ test("rejects a PII-carrying unmanaged table without piiEncryptedOnWrite", () => {
148
+ const feat = defineFeature("probe", (r) => {
149
+ r.unmanagedTable(piiMeta, { reason: "direct-write store" });
150
+ });
151
+ expect(() => createRegistry([feat])).toThrow(
152
+ /has PII-annotated fields \(ip\) but direct writes bypass the executor's PII encryption/,
153
+ );
154
+ });
155
+
156
+ test("accepts it once the feature declares piiEncryptedOnWrite", () => {
157
+ const feat = defineFeature("probe", (r) => {
158
+ r.unmanagedTable(piiMeta, {
159
+ reason: "direct-write store",
160
+ piiEncryptedOnWrite: true,
161
+ });
162
+ });
163
+ expect(() => createRegistry([feat])).not.toThrow();
164
+ });
165
+
166
+ test("a PII-free unmanaged table needs no declaration", () => {
167
+ const feat = defineFeature("probe", (r) => {
168
+ r.unmanagedTable(probeMeta, { reason: "plain store" });
169
+ });
170
+ expect(() => createRegistry([feat])).not.toThrow();
171
+ });
172
+ });
@@ -64,6 +64,35 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
64
64
  );
65
65
  }
66
66
 
67
+ // lookupable (#818): nur text + nur MIT Subject-Annotation. Ohne
68
+ // Subject bleibt der Wert eh Klartext — der Blind-Index wäre eine
69
+ // zweite (deterministische!) Kopie ohne Nutzen.
70
+ if (annot.lookupable === true) {
71
+ if (field.type !== "text") {
72
+ throw new Error(
73
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { lookupable: true } but has type "${field.type}" — blind-index equality lookups only apply to text fields.`,
74
+ );
75
+ }
76
+ if (annotCount === 0) {
77
+ throw new Error(
78
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { lookupable: true } without a subject annotation (pii / userOwned / tenantOwned). Plaintext fields don't need a blind index — add the subject annotation or drop lookupable.`,
79
+ );
80
+ }
81
+ }
82
+
83
+ // Substring-Suche/Sortierung auf Ciphertext ist prinzipbedingt
84
+ // unmöglich — searchable würde Plaintext-Kopien in den Suchindex
85
+ // schieben, sortable sortiert Base64-Blobs. Equality → lookupable.
86
+ if (annotCount > 0) {
87
+ const flags = field as { readonly searchable?: boolean; readonly sortable?: boolean }; // @cast-boundary schema-walk
88
+ if (flags.searchable === true || flags.sortable === true) {
89
+ const offending = flags.searchable === true ? "searchable" : "sortable";
90
+ throw new Error(
91
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation with { ${offending}: true } — ${offending} on encrypted fields cannot work (ciphertext at rest). For equality lookups use { lookupable: true }; for search/sort the field must stay plaintext (allowPlaintext).`,
92
+ );
93
+ }
94
+ }
95
+
67
96
  if (annot.userOwned) {
68
97
  const ownerName = annot.userOwned.ownerField;
69
98
  if (!ownerName || typeof ownerName !== "string") {
@@ -78,21 +107,28 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
78
107
  `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references userOwned.ownerField "${ownerName}" but no such field exists. Known fields: ${known}`,
79
108
  );
80
109
  }
81
- if (ownerField.type !== "reference") {
110
+ // Text is accepted alongside reference: the ES-framework carries
111
+ // user ids as plain text columns throughout the bundled features
112
+ // (user-session.userId, invitation.invitedBy) — there is no
113
+ // relational FK to point a reference at. Self-referencing
114
+ // ownerField (the field's own value IS the owner id) rides on this.
115
+ if (ownerField.type !== "reference" && ownerField.type !== "text") {
82
116
  throw new Error(
83
- `[Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" must be a reference field, got type "${ownerField.type}"`,
117
+ `[Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`,
84
118
  );
85
119
  }
86
120
  // Soft-Warning wenn das reference-target nicht offensichtlich user
87
121
  // ist — custom subject-entities (HR-Mitarbeiter, Patient) sind
88
122
  // erlaubt, müssen aber bewusste Wahl sein.
89
- const refTarget = ownerField.entity;
90
- const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
91
- if (targetEntity !== "user") {
92
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
93
- console.warn(
94
- `[kumiko:boot] [Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
95
- );
123
+ if (ownerField.type === "reference") {
124
+ const refTarget = ownerField.entity;
125
+ const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
126
+ if (targetEntity !== "user") {
127
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
128
+ console.warn(
129
+ `[kumiko:boot] [Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
130
+ );
131
+ }
96
132
  }
97
133
  }
98
134
 
@@ -66,6 +66,7 @@ import type {
66
66
  TreeChildrenSubscribe,
67
67
  UiHints,
68
68
  UnmanagedTableEntry,
69
+ UnmanagedTableOptions,
69
70
  ValidationHookFn,
70
71
  WriteHandlerDef,
71
72
  WriteHandlerFn,
@@ -867,7 +868,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
867
868
  };
868
869
  },
869
870
 
870
- unmanagedTable(meta: EntityTableMeta, options: RawTableOptions): void {
871
+ unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void {
871
872
  // Name comes from the meta itself — apps already give the table a
872
873
  // name when calling defineUnmanagedTable, no need to repeat it.
873
874
  const tableName = meta.tableName;
@@ -896,6 +897,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
896
897
  name: tableName,
897
898
  meta,
898
899
  reason: options.reason,
900
+ ...(options.piiEncryptedOnWrite && { piiEncryptedOnWrite: true }),
899
901
  };
900
902
  },
901
903
 
@@ -678,6 +678,15 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
678
678
  `Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
679
679
  );
680
680
  }
681
+ const piiFields = umDef.meta.piiSubjectFields ?? [];
682
+ if (piiFields.length > 0 && !umDef.piiEncryptedOnWrite) {
683
+ throw new Error(
684
+ `Unmanaged-table "${umName}" (feature "${feature.name}") has PII-annotated fields ` +
685
+ `(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
686
+ `Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
687
+ `{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
688
+ );
689
+ }
681
690
  physicalTableOwners.set(umName, {
682
691
  kind: "unmanaged",
683
692
  owner: umName,
@@ -168,6 +168,15 @@ export type UnmanagedTableEntry = {
168
168
  readonly name: string;
169
169
  readonly meta: EntityTableMeta;
170
170
  readonly reason: string;
171
+ readonly piiEncryptedOnWrite?: true;
172
+ };
173
+
174
+ /** Options for r.unmanagedTable(). Direct-write stores skip the executor,
175
+ * so the executor's PII encryption never runs for them — a feature whose
176
+ * meta carries piiSubjectFields must encrypt those fields itself before
177
+ * every insert/update and declare that here, or boot fails (#820). */
178
+ export type UnmanagedTableOptions = RawTableOptions & {
179
+ readonly piiEncryptedOnWrite?: true;
171
180
  };
172
181
 
173
182
  /** Registry-aggregated unmanaged-table — adds the owning feature name. */
@@ -718,7 +727,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
718
727
  // `PgTable` (legacy), unmanagedTable carries the new `EntityTableMeta`
719
728
  // shape that `migrate-runner` consumes. After the full drizzle-cut they
720
729
  // will likely merge; for now they coexist.
721
- unmanagedTable(meta: EntityTableMeta, options: RawTableOptions): void;
730
+ unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void;
722
731
 
723
732
  // Register the tree-actions schema for this feature — a map of
724
733
  // action-name → action-definition (with optional typed args). At-most-
@@ -62,6 +62,12 @@ export type PiiAnnotations = {
62
62
  readonly tenantOwned?: boolean;
63
63
  readonly anonymize?: () => unknown | Promise<unknown>;
64
64
  readonly allowPlaintext?: string;
65
+ /** Equality-Lookups (fetchOne/filter eq) bleiben trotz Verschluesselung
66
+ * moeglich: generierte `<snake>_bidx`-Spalte traegt einen HMAC-Blind-
67
+ * Index, Query-Compiler matchen `(col = $1 OR col_bidx = $2)`. Nur auf
68
+ * text-Feldern MIT Subject-Annotation erlaubt (Boot-Validator).
69
+ * Siehe docs/plans/datenschutz/blind-index.md (kumiko-framework#818). */
70
+ readonly lookupable?: true;
65
71
  };
66
72
 
67
73
  // --- Retention (DSGVO Art. 5(1)(e) + HGB/AO Aufbewahrungspflichten) ---
@@ -78,6 +78,7 @@ export type {
78
78
  UiHints,
79
79
  UnmanagedTableDef,
80
80
  UnmanagedTableEntry,
81
+ UnmanagedTableOptions,
81
82
  } from "./feature";
82
83
  export type {
83
84
  AnyFileFieldDef,