@cosmicdrift/kumiko-framework 0.186.0 → 0.186.2

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.186.0",
3
+ "version": "0.186.2",
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>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.186.0",
185
+ "@cosmicdrift/kumiko-types": "0.186.2",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.186.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.186.2",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -0,0 +1,140 @@
1
+ // fw#1855 — money columns in list()/detail() surfaced the raw minor-units
2
+ // integer instead of { amount, currency }: rehydrateCompoundTypes ran BEFORE
3
+ // coerceRow on raw-SQL rows, so rehydrateMoney looked up the still-snake_case
4
+ // key and silently no-op'd. money.test.ts/compound-types.test.ts only feed
5
+ // rehydrateMoney pre-camelCased rows directly — neither covers the real
6
+ // list()/detail() path through raw SQL, which is why this went unnoticed.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
10
+ import { asRawClient } from "../../db/query";
11
+ import { createEntity, createMoneyField, createTextField, from } from "../../engine";
12
+ import { createEventsTable } from "../../event-store";
13
+ import { TestUsers, unsafeCreateEntityTable } from "../../stack";
14
+ import { createTestEnvelopeCipher } from "../../testing";
15
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
16
+ import {
17
+ configureEntityFieldEncryption,
18
+ resetEntityFieldEncryptionCacheForTests,
19
+ } from "../entity-field-encryption";
20
+ import { createEventStoreExecutor } from "../event-store-executor";
21
+ import { buildEntityTable } from "../table-builder";
22
+ import { createTenantDb, type TenantDb } from "../tenant-db";
23
+
24
+ const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
25
+ const cipher = createTestEnvelopeCipher(TEST_KEY);
26
+
27
+ // Multi-word field name on purpose: "price" maps to column "price" (snake ==
28
+ // camel), which would pass even with the key-mismatch bug still present.
29
+ // "grossTotal" maps to "gross_total" / "gross_total_currency" and exposes it.
30
+ // billingIban ("billing_iban") is multi-word + encrypted: true for the same
31
+ // reason, targeting the encrypted-field variant of the bug on detail()'s
32
+ // ownership.kind==="sql" branch (decryptForRead used to run against a still
33
+ // snake_case row and silently skip the field, leaking ciphertext).
34
+ const entity = createEntity({
35
+ table: "read_money_orders",
36
+ fields: {
37
+ ownerId: createTextField({ required: true }),
38
+ grossTotal: createMoneyField(),
39
+ billingIban: createTextField({ required: true, encrypted: true }),
40
+ },
41
+ // A non-"all" read rule forces buildOwnershipClause into the
42
+ // ownership.kind==="sql" raw-SQL branch that list()/detail() read through.
43
+ // The "pass" branch (db.fetchOne → selectMany → coerceRows) is already
44
+ // camelCase and would pass this test even with the bug present.
45
+ access: { read: { Admin: from("user:id", "ownerId") } },
46
+ });
47
+ const table = buildEntityTable("moneyOrder", entity);
48
+
49
+ let testDb: BunTestDb;
50
+ let tdb: TenantDb;
51
+ const admin = TestUsers.admin;
52
+
53
+ beforeAll(async () => {
54
+ await ensureTemporalPolyfill();
55
+ testDb = await createTestDb();
56
+ await unsafeCreateEntityTable(testDb.db, entity, "moneyOrder");
57
+ await createEventsTable(testDb.db);
58
+ tdb = createTenantDb(testDb.db, admin.tenantId);
59
+ configureEntityFieldEncryption(cipher);
60
+ });
61
+
62
+ afterAll(async () => {
63
+ resetEntityFieldEncryptionCacheForTests();
64
+ await testDb.cleanup();
65
+ });
66
+
67
+ beforeEach(async () => {
68
+ await asRawClient(testDb.db).unsafe(
69
+ `TRUNCATE kumiko_events, read_money_orders RESTART IDENTITY CASCADE`,
70
+ );
71
+ });
72
+
73
+ describe("event-store-executor — money column rehydration through raw SQL (fw#1855)", () => {
74
+ const exec = createEventStoreExecutor(table, entity, { entityName: "moneyOrder" });
75
+
76
+ test("list(): money column arrives as { amount, currency }, not the raw minor-units integer", async () => {
77
+ const created = await exec.create(
78
+ {
79
+ ownerId: admin.id,
80
+ grossTotal: { amount: 136.85, currency: "EUR" },
81
+ billingIban: "DE1234567890",
82
+ },
83
+ admin,
84
+ tdb,
85
+ );
86
+ expect(created.isSuccess).toBe(true);
87
+
88
+ const res = await exec.list({ limit: 50 }, admin, tdb);
89
+ expect(res.rows).toHaveLength(1);
90
+ const row = res.rows[0] as Record<string, unknown>;
91
+ expect(row["grossTotal"]).toEqual({ amount: 136.85, currency: "EUR", amountMinor: 13685 });
92
+ expect("grossTotalCurrency" in row).toBe(false);
93
+ });
94
+
95
+ test('detail() via ownership.kind==="sql": money column arrives as { amount, currency }', async () => {
96
+ const created = await exec.create(
97
+ {
98
+ ownerId: admin.id,
99
+ grossTotal: { amount: 42.5, currency: "USD" },
100
+ billingIban: "DE9876543210",
101
+ },
102
+ admin,
103
+ tdb,
104
+ );
105
+ expect(created.isSuccess).toBe(true);
106
+ if (!created.isSuccess) return;
107
+
108
+ const row = (await exec.detail({ id: created.data.id }, admin, tdb)) as Record<
109
+ string,
110
+ unknown
111
+ > | null;
112
+ expect(row).not.toBeNull();
113
+ if (!row) return;
114
+ expect(row["grossTotal"]).toEqual({ amount: 42.5, currency: "USD", amountMinor: 4250 });
115
+ expect("grossTotalCurrency" in row).toBe(false);
116
+ });
117
+
118
+ test('detail() via ownership.kind==="sql": encrypted field decrypts to plaintext, not ciphertext', async () => {
119
+ const plainIban = "DE44500105175407324931";
120
+ const created = await exec.create(
121
+ {
122
+ ownerId: admin.id,
123
+ grossTotal: { amount: 10, currency: "EUR" },
124
+ billingIban: plainIban,
125
+ },
126
+ admin,
127
+ tdb,
128
+ );
129
+ expect(created.isSuccess).toBe(true);
130
+ if (!created.isSuccess) return;
131
+
132
+ const row = (await exec.detail({ id: created.data.id }, admin, tdb)) as Record<
133
+ string,
134
+ unknown
135
+ > | null;
136
+ expect(row).not.toBeNull();
137
+ if (!row) return;
138
+ expect(row["billingIban"]).toBe(plainIban);
139
+ });
140
+ });
@@ -167,13 +167,14 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
167
167
 
168
168
  const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
169
169
  // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
170
- // Coerce BEFORE decrypt: the raw SELECT * rows carry snake_case column
171
- // names, while the encrypted/pii field lists are camelCase — decrypting
172
- // first silently skipped every multi-word field (ciphertext leaked to
173
- // the caller).
170
+ // Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
171
+ // column names, while compound-type lookups (rehydrateMoney et al.) and the
172
+ // encrypted/pii field lists are all camelCase running either first on a
173
+ // still-snake_case row silently no-ops (money) or skips every multi-word
174
+ // field (ciphertext leaked to the caller).
174
175
  const tableInfo = extractTableInfo(table);
175
176
  const encryptedRows = rawRows.map((r) =>
176
- coerceRow(rehydrateCompoundTypes(r, entity), tableInfo),
177
+ rehydrateCompoundTypes(coerceRow(r, tableInfo), entity),
177
178
  );
178
179
  const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
179
180
 
@@ -256,9 +257,11 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
256
257
  const rows = await loadWithOwnership(db, idWhere, ownership);
257
258
  const raw = rows[0];
258
259
  if (!raw) return null;
259
- const row = await decryptForRead(rehydrateCompoundTypes(raw, entity));
260
+ // Same coerce-before-rehydrate/decrypt ordering as list() above — raw
261
+ // is snake_case only on the ownership.kind==="sql" branch (raw SQL);
262
+ // coerceRow is a no-op on the already-camelCase "pass" branch rows.
260
263
  const rowInfo = extractTableInfo(table);
261
- const coerced = coerceRow(row, rowInfo);
264
+ const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
262
265
 
263
266
  if (entityCache && entityName) {
264
267
  await entityCache.set(