@cosmicdrift/kumiko-framework 0.238.0 → 0.240.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.238.0",
3
+ "version": "0.240.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.238.0",
201
+ "@cosmicdrift/kumiko-types": "0.240.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.238.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.240.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -0,0 +1,381 @@
1
+ // fw#2660 — a text search on an entity must also match its `searchable`
2
+ // reference fields by the target row's labelField, not just the raw FK
3
+ // column (a GUID never contains the search term a human types). Covers the
4
+ // four cases the issue mandates: reference-only match, union with a native
5
+ // text match (no duplicates), cross-tenant target label match (must yield
6
+ // zero rows — the hard security constraint), and the >200-match cap (drop
7
+ // the reference clause, native search still applies).
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
11
+ import { asRawClient } from "../../db/query";
12
+ import { createEntity, createTextField } from "../../engine";
13
+ import type { EntityDefinition } from "../../engine/types";
14
+ import { createEventsTable } from "../../event-store";
15
+ import { createInMemorySearchAdapter } from "../../search";
16
+ import { TestUsers, unsafeCreateEntityTable } from "../../stack";
17
+ import { seedRows } from "../../testing";
18
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
19
+ import { createEventStoreExecutor } from "../event-store-executor";
20
+ import { buildEntityTable } from "../table-builder";
21
+ import { createTenantDb, type TenantDb } from "../tenant-db";
22
+
23
+ const customerEntity = createEntity({
24
+ table: "read_ref_search_customers",
25
+ fields: {
26
+ name: createTextField({ required: true }),
27
+ },
28
+ });
29
+ const customerTable = buildEntityTable("refSearchCustomer", customerEntity);
30
+
31
+ const orderEntity = createEntity({
32
+ table: "read_ref_search_orders",
33
+ fields: {
34
+ note: createTextField({ searchable: true }),
35
+ customerId: {
36
+ type: "reference",
37
+ entity: "refSearchCustomer",
38
+ labelField: "name",
39
+ searchable: true,
40
+ },
41
+ },
42
+ });
43
+ const orderTable = buildEntityTable("refSearchOrder", orderEntity);
44
+
45
+ // A caller who cannot read `customerId` itself must not be able to infer its
46
+ // value by matching the target's label through the reference clause — same
47
+ // probe surface fw#2629 already closed for filter/sort (fw#2660 parity).
48
+ const restrictedOrderEntity = createEntity({
49
+ table: "read_ref_search_restricted_orders",
50
+ fields: {
51
+ note: createTextField({ searchable: true }),
52
+ customerId: {
53
+ type: "reference",
54
+ entity: "refSearchCustomer",
55
+ labelField: "name",
56
+ searchable: true,
57
+ access: { read: { Admin: "all" } },
58
+ },
59
+ },
60
+ });
61
+ const restrictedOrderTable = buildEntityTable("refSearchRestrictedOrder", restrictedOrderEntity);
62
+
63
+ // Minimal stand-in for the real bundled `tenant` entity (table `read_tenants`)
64
+ // — mirrors the pattern in entity-table-from-registry.test.ts. Only `name`
65
+ // matters here: the implicit tenantId->tenant.name row-meta reference
66
+ // (LIST_ROW_META_REFERENCES) is what's under test, not tenant's full schema.
67
+ const tenantMetaEntity = createEntity({
68
+ table: "read_tenants",
69
+ fields: { name: createTextField({ required: true }) },
70
+ });
71
+ const tenantMetaTable = buildEntityTable("tenant", tenantMetaEntity);
72
+
73
+ function resolveEntity(name: string): EntityDefinition | undefined {
74
+ return name === "refSearchCustomer" ? customerEntity : undefined;
75
+ }
76
+
77
+ const referenceSearch = {
78
+ fields: [{ fieldName: "customerId", targetEntityName: "refSearchCustomer", labelField: "name" }],
79
+ resolveEntity,
80
+ };
81
+
82
+ const restrictedReferenceSearch = {
83
+ fields: referenceSearch.fields,
84
+ resolveEntity,
85
+ };
86
+
87
+ // Same fields as `referenceSearch`, but resolves "tenant" too — kept separate
88
+ // from the module-default `referenceSearch` so the other tests (whose
89
+ // resolveEntity never resolves "tenant") never issue a query against
90
+ // `read_tenants`.
91
+ const referenceSearchWithTenant = {
92
+ fields: referenceSearch.fields,
93
+ resolveEntity: (name: string) => (name === "tenant" ? tenantMetaEntity : resolveEntity(name)),
94
+ };
95
+
96
+ let testDb: BunTestDb;
97
+ let tdbA: TenantDb;
98
+ let tdbB: TenantDb;
99
+ const admin = TestUsers.admin;
100
+ const otherTenantAdmin = TestUsers.otherTenant;
101
+ const orderExec = createEventStoreExecutor(orderTable, orderEntity, {
102
+ entityName: "refSearchOrder",
103
+ });
104
+ const restrictedOrderExec = createEventStoreExecutor(restrictedOrderTable, restrictedOrderEntity, {
105
+ entityName: "refSearchRestrictedOrder",
106
+ });
107
+
108
+ beforeAll(async () => {
109
+ await ensureTemporalPolyfill();
110
+ testDb = await createTestDb();
111
+ await unsafeCreateEntityTable(testDb.db, customerEntity, "refSearchCustomer");
112
+ await unsafeCreateEntityTable(testDb.db, orderEntity, "refSearchOrder");
113
+ await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSearchRestrictedOrder");
114
+ await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
115
+ await createEventsTable(testDb.db);
116
+ tdbA = createTenantDb(testDb.db, admin.tenantId);
117
+ tdbB = createTenantDb(testDb.db, otherTenantAdmin.tenantId);
118
+ });
119
+
120
+ afterAll(async () => {
121
+ await testDb.cleanup();
122
+ });
123
+
124
+ beforeEach(async () => {
125
+ await asRawClient(testDb.db).unsafe(
126
+ "TRUNCATE kumiko_events, read_ref_search_customers, read_ref_search_orders, " +
127
+ "read_ref_search_restricted_orders, read_tenants RESTART IDENTITY CASCADE",
128
+ );
129
+ });
130
+
131
+ describe("event-store-executor.list — searchable reference fields (fw#2660)", () => {
132
+ test("matches via the reference column's label with no text-field match", async () => {
133
+ const [acme] = await seedRows(testDb.db, customerTable, [
134
+ { id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Acme Corp" },
135
+ ]);
136
+ const [order] = await seedRows(testDb.db, orderTable, [
137
+ {
138
+ id: crypto.randomUUID(),
139
+ tenantId: admin.tenantId,
140
+ note: "totally unrelated note",
141
+ customerId: (acme as { id: string }).id,
142
+ },
143
+ ]);
144
+
145
+ const searchAdapter = createInMemorySearchAdapter();
146
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
147
+ await searchAdapter.index(admin.tenantId, {
148
+ entityType: "refSearchOrder",
149
+ entityId: (order as { id: string }).id,
150
+ weight: 1,
151
+ fields: { note: "totally unrelated note" },
152
+ });
153
+
154
+ const res = await orderExec.list({ search: "acme" }, admin, tdbA, {
155
+ searchAdapter,
156
+ referenceSearch,
157
+ });
158
+ expect(res.rows.map((r) => r["id"])).toEqual([(order as { id: string }).id]);
159
+ });
160
+
161
+ test("unions a native text-field match with a reference-label match, no duplicates", async () => {
162
+ const [acme, other] = await seedRows(testDb.db, customerTable, [
163
+ { id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Acme Corp" },
164
+ { id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Some Other Customer" },
165
+ ]);
166
+ const [textMatchOrder, refMatchOrder, decoyOrder] = await seedRows(testDb.db, orderTable, [
167
+ {
168
+ id: crypto.randomUUID(),
169
+ tenantId: admin.tenantId,
170
+ note: "great acme deal closed",
171
+ customerId: (other as { id: string }).id,
172
+ },
173
+ {
174
+ id: crypto.randomUUID(),
175
+ tenantId: admin.tenantId,
176
+ note: "nothing special here",
177
+ customerId: (acme as { id: string }).id,
178
+ },
179
+ {
180
+ id: crypto.randomUUID(),
181
+ tenantId: admin.tenantId,
182
+ note: "nothing special here either",
183
+ customerId: (other as { id: string }).id,
184
+ },
185
+ ]);
186
+
187
+ const searchAdapter = createInMemorySearchAdapter();
188
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
189
+ for (const o of [textMatchOrder, refMatchOrder, decoyOrder]) {
190
+ const row = o as { id: string; note: string };
191
+ await searchAdapter.index(admin.tenantId, {
192
+ entityType: "refSearchOrder",
193
+ entityId: row.id,
194
+ weight: 1,
195
+ fields: { note: row.note },
196
+ });
197
+ }
198
+
199
+ const res = await orderExec.list({ search: "acme" }, admin, tdbA, {
200
+ searchAdapter,
201
+ referenceSearch,
202
+ });
203
+ const ids = res.rows.map((r) => r["id"]);
204
+ expect(ids.sort()).toEqual(
205
+ [(textMatchOrder as { id: string }).id, (refMatchOrder as { id: string }).id].sort(),
206
+ );
207
+ expect(new Set(ids).size).toBe(ids.length);
208
+ });
209
+
210
+ test("a target label match in a foreign tenant never surfaces a row (mandatory)", async () => {
211
+ const [confidential] = await seedRows(testDb.db, customerTable, [
212
+ { id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Confidential Co" },
213
+ ]);
214
+ // Reference UUIDs are freely settable (no FK constraint) — a tenant-B
215
+ // row can point at a tenant-A id. If the target lookup weren't
216
+ // tenant-scoped, this order would surface and leak that a
217
+ // "Confidential Co" row exists in another tenant.
218
+ const [order] = await seedRows(testDb.db, orderTable, [
219
+ {
220
+ id: crypto.randomUUID(),
221
+ tenantId: otherTenantAdmin.tenantId,
222
+ note: "unrelated",
223
+ customerId: (confidential as { id: string }).id,
224
+ },
225
+ ]);
226
+
227
+ const searchAdapter = createInMemorySearchAdapter();
228
+ await searchAdapter.configure(otherTenantAdmin.tenantId, { searchableFields: ["note"] });
229
+ await searchAdapter.index(otherTenantAdmin.tenantId, {
230
+ entityType: "refSearchOrder",
231
+ entityId: (order as { id: string }).id,
232
+ weight: 1,
233
+ fields: { note: "unrelated" },
234
+ });
235
+
236
+ const res = await orderExec.list({ search: "confidential" }, otherTenantAdmin, tdbB, {
237
+ searchAdapter,
238
+ referenceSearch,
239
+ });
240
+ expect(res.rows).toHaveLength(0);
241
+ });
242
+
243
+ test("drops the reference clause above 200 target matches — native search still applies", async () => {
244
+ const fooCustomers = Array.from({ length: 201 }, (_, i) => ({
245
+ id: crypto.randomUUID(),
246
+ tenantId: admin.tenantId,
247
+ name: `Foo Customer ${i}`,
248
+ }));
249
+ await seedRows(testDb.db, customerTable, fooCustomers);
250
+
251
+ const [refOnlyOrder, textMatchOrder] = await seedRows(testDb.db, orderTable, [
252
+ {
253
+ id: crypto.randomUUID(),
254
+ tenantId: admin.tenantId,
255
+ note: "no keyword here",
256
+ customerId: fooCustomers[5]?.id,
257
+ },
258
+ {
259
+ id: crypto.randomUUID(),
260
+ tenantId: admin.tenantId,
261
+ note: "foo appears right here",
262
+ customerId: fooCustomers[10]?.id,
263
+ },
264
+ ]);
265
+
266
+ const searchAdapter = createInMemorySearchAdapter();
267
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
268
+ for (const o of [refOnlyOrder, textMatchOrder]) {
269
+ const row = o as { id: string; note: string };
270
+ await searchAdapter.index(admin.tenantId, {
271
+ entityType: "refSearchOrder",
272
+ entityId: row.id,
273
+ weight: 1,
274
+ fields: { note: row.note },
275
+ });
276
+ }
277
+
278
+ const res = await orderExec.list({ search: "foo" }, admin, tdbA, {
279
+ searchAdapter,
280
+ referenceSearch,
281
+ });
282
+ expect(res.rows.map((r) => r["id"])).toEqual([(textMatchOrder as { id: string }).id]);
283
+ });
284
+
285
+ test("a caller without read access to the reference column gets no reference-match hit (fw#2629 parity)", async () => {
286
+ const [acme] = await seedRows(testDb.db, customerTable, [
287
+ { id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Acme Corp" },
288
+ ]);
289
+ const [order] = await seedRows(testDb.db, restrictedOrderTable, [
290
+ {
291
+ id: crypto.randomUUID(),
292
+ tenantId: admin.tenantId,
293
+ note: "totally unrelated note",
294
+ customerId: (acme as { id: string }).id,
295
+ },
296
+ ]);
297
+
298
+ const searchAdapter = createInMemorySearchAdapter();
299
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
300
+
301
+ const asUser = await restrictedOrderExec.list({ search: "acme" }, TestUsers.user, tdbA, {
302
+ searchAdapter,
303
+ referenceSearch: restrictedReferenceSearch,
304
+ });
305
+ expect(asUser.rows).toHaveLength(0);
306
+
307
+ const asAdmin = await restrictedOrderExec.list({ search: "acme" }, admin, tdbA, {
308
+ searchAdapter,
309
+ referenceSearch: restrictedReferenceSearch,
310
+ });
311
+ expect(asAdmin.rows.map((r) => r["id"])).toEqual([(order as { id: string }).id]);
312
+ });
313
+
314
+ test("matches via the implicit tenantId->tenant.name row-meta reference in a system-scoped cross-tenant search", async () => {
315
+ const [acmeTenant] = await seedRows(testDb.db, tenantMetaTable, [
316
+ { id: admin.tenantId, tenantId: admin.tenantId, name: "Acme Tenant" },
317
+ ]);
318
+ await seedRows(testDb.db, tenantMetaTable, [
319
+ { id: otherTenantAdmin.tenantId, tenantId: otherTenantAdmin.tenantId, name: "Globex Tenant" },
320
+ ]);
321
+ const [someCustomer] = await seedRows(testDb.db, customerTable, [
322
+ { id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Some Customer" },
323
+ ]);
324
+
325
+ const [tenantAOrder] = await seedRows(testDb.db, orderTable, [
326
+ {
327
+ id: crypto.randomUUID(),
328
+ tenantId: admin.tenantId,
329
+ note: "nothing relevant here",
330
+ customerId: (someCustomer as { id: string }).id,
331
+ },
332
+ ]);
333
+ await seedRows(testDb.db, orderTable, [
334
+ {
335
+ id: crypto.randomUUID(),
336
+ tenantId: otherTenantAdmin.tenantId,
337
+ note: "also nothing relevant",
338
+ customerId: (someCustomer as { id: string }).id,
339
+ },
340
+ ]);
341
+
342
+ const searchAdapter = createInMemorySearchAdapter();
343
+ await searchAdapter.configure(TestUsers.systemAdmin.tenantId, { searchableFields: ["note"] });
344
+
345
+ const systemDb = createTenantDb(testDb.db, TestUsers.systemAdmin.tenantId, "system");
346
+ const res = await orderExec.list({ search: "acme" }, TestUsers.systemAdmin, systemDb, {
347
+ searchAdapter,
348
+ referenceSearch: referenceSearchWithTenant,
349
+ });
350
+
351
+ expect(res.rows.map((r) => r["id"])).toEqual([(tenantAOrder as { id: string }).id]);
352
+ // sanity: the match is scoped to the matching tenant, not "everything"
353
+ expect((acmeTenant as { id: string }).id).toBe(admin.tenantId);
354
+ });
355
+
356
+ test("tenant-mode search never issues the tenantId row-meta lookup (gate)", async () => {
357
+ await seedRows(testDb.db, tenantMetaTable, [
358
+ { id: admin.tenantId, tenantId: admin.tenantId, name: "Acme Tenant" },
359
+ ]);
360
+ await seedRows(testDb.db, orderTable, [
361
+ { id: crypto.randomUUID(), tenantId: admin.tenantId, note: "nothing relevant" },
362
+ ]);
363
+
364
+ const searchAdapter = createInMemorySearchAdapter();
365
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
366
+
367
+ // Drop the lookup target so the gated query would throw ("relation does
368
+ // not exist") if it were still issued — proves absence of the query, not
369
+ // just an equal-looking result.
370
+ await asRawClient(testDb.db).unsafe('DROP TABLE "read_tenants"');
371
+ try {
372
+ const res = await orderExec.list({ search: "acme" }, admin, tdbA, {
373
+ searchAdapter,
374
+ referenceSearch: referenceSearchWithTenant,
375
+ });
376
+ expect(res.rows).toHaveLength(0);
377
+ } finally {
378
+ await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
379
+ }
380
+ });
381
+ });