@cosmicdrift/kumiko-framework 0.163.1 → 0.163.3

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.163.1",
3
+ "version": "0.163.3",
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.163.1",
185
+ "@cosmicdrift/kumiko-types": "0.163.3",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.18",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.163.1",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.163.3",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -0,0 +1,41 @@
1
+ // kumiko-framework#1480: instantFromDriver referenced the global `Temporal`
2
+ // without importing it. Bun doesn't expose Temporal as a globalThis property
3
+ // reliably — any storeTable read of a timestamptz column crashed with
4
+ // "Temporal is not defined" unless some other boot path happened to install
5
+ // the polyfill globally first (order-dependent, easy to miss in a fresh
6
+ // process). The fix is a static `import { Temporal } from "temporal-polyfill"`
7
+ // in query.ts, so this test deletes globalThis.Temporal before calling
8
+ // coerceRow — proving the coercion no longer depends on the global at all.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { Temporal } from "temporal-polyfill";
12
+ import { coerceRow, type TableInfo } from "../query";
13
+
14
+ function timestamptzTableInfo(): TableInfo {
15
+ return {
16
+ name: "probe",
17
+ columnOf: (f) => f,
18
+ pgTypeOf: (c) => (c === "updated_at" ? "timestamptz" : undefined),
19
+ bigintJsModeOf: () => undefined,
20
+ fieldOf: (c) => c,
21
+ hasColumn: () => true,
22
+ };
23
+ }
24
+
25
+ describe("coerceRow — timestamptz → Temporal.Instant", () => {
26
+ test("coerces without relying on a global Temporal", () => {
27
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
28
+ delete (globalThis as { Temporal?: unknown }).Temporal;
29
+ try {
30
+ const row = { updated_at: new Date("2026-04-18T10:00:00Z") };
31
+ const result = coerceRow(row, timestamptzTableInfo());
32
+ expect(result.updated_at).toBeInstanceOf(Temporal.Instant);
33
+ expect((result.updated_at as unknown as Temporal.Instant).toString()).toBe(
34
+ "2026-04-18T10:00:00Z",
35
+ );
36
+ } finally {
37
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
38
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
39
+ }
40
+ });
41
+ });
@@ -24,6 +24,9 @@ import type {
24
24
  WhereObject,
25
25
  WhereOperator,
26
26
  } from "@cosmicdrift/kumiko-types/where-clause-types";
27
+ // Static import, not the ambient global: Bun doesn't expose Temporal on
28
+ // globalThis, so instantFromDriver crashed on timestamptz reads (#1480).
29
+ import { Temporal } from "temporal-polyfill";
27
30
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
28
31
  import type { EntityTableMeta } from "../db/entity-table-meta";
29
32
  import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
@@ -11,11 +11,8 @@
11
11
  // die invalid-Probe (echte Garbage muss weiterhin throwen — kein silent
12
12
  // swallowing).
13
13
 
14
- import { ensureTemporalPolyfill } from "../../time/polyfill";
15
-
16
- await ensureTemporalPolyfill();
17
-
18
14
  import { describe, expect, test } from "bun:test";
15
+ import { Temporal } from "temporal-polyfill";
19
16
  import { instantToDriver as toDriver } from "../dialect";
20
17
 
21
18
  describe("instant() customType — toDriver", () => {
@@ -0,0 +1,21 @@
1
+ // kumiko-framework#1480 twin bug: instantToDriver referenced the global
2
+ // `Temporal` the same way instantFromDriver in bun-db/query.ts did. Fixed
3
+ // the same way — static `import { Temporal } from "temporal-polyfill"` in
4
+ // dialect.ts. This test deletes globalThis.Temporal before calling it,
5
+ // proving the write path no longer depends on the global either.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { instantToDriver } from "../dialect";
9
+
10
+ describe("instantToDriver — without a global Temporal", () => {
11
+ test("coerces an ISO string without relying on a global Temporal", () => {
12
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
13
+ delete (globalThis as { Temporal?: unknown }).Temporal;
14
+ try {
15
+ expect(instantToDriver("2026-04-18T10:00:00Z")).toBe("2026-04-18T10:00:00Z");
16
+ } finally {
17
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
18
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
19
+ }
20
+ });
21
+ });
@@ -72,6 +72,54 @@ CREATE INDEX IF NOT EXISTS "read_accounts_tenant_id_idx" ON "read_accounts" ("te
72
72
  }
73
73
  });
74
74
 
75
+ // Regression: publicstatus#0007_fix-secrets-table-columns adds three
76
+ // columns in ONE statement (comma-separated ADD COLUMN clauses) — the
77
+ // replay used to only pick up the first, reporting "metadata" and
78
+ // "last_rotated_at" as missing even though the migration creates them.
79
+ test("multiple ADD COLUMN clauses in a single ALTER TABLE statement all extend the table", () => {
80
+ const dir = tmpMigrationsDir();
81
+ try {
82
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
83
+ write(
84
+ dir,
85
+ "0002_add-cols.sql",
86
+ `ALTER TABLE "read_a"
87
+ ADD COLUMN IF NOT EXISTS "envelope" jsonb NOT NULL,
88
+ ADD COLUMN IF NOT EXISTS "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
89
+ ADD COLUMN IF NOT EXISTS "last_rotated_at" timestamp with time zone DEFAULT now() NOT NULL;`,
90
+ );
91
+ const replayed = replayMigrationsDir(dir);
92
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual([
93
+ "envelope",
94
+ "id",
95
+ "last_rotated_at",
96
+ "metadata",
97
+ ]);
98
+ } finally {
99
+ rmSync(dir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("mixed ADD + DROP COLUMN clauses in one statement apply in order", () => {
104
+ const dir = tmpMigrationsDir();
105
+ try {
106
+ write(
107
+ dir,
108
+ "0001_init.sql",
109
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "legacy" text);`,
110
+ );
111
+ write(
112
+ dir,
113
+ "0002_migrate-cols.sql",
114
+ `ALTER TABLE "read_a" DROP COLUMN IF EXISTS "legacy", ADD COLUMN IF NOT EXISTS "title" text;`,
115
+ );
116
+ const replayed = replayMigrationsDir(dir);
117
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
118
+ } finally {
119
+ rmSync(dir, { recursive: true, force: true });
120
+ }
121
+ });
122
+
75
123
  test("DROP COLUMN IF EXISTS (hand-edited) still removes the column", () => {
76
124
  const dir = tmpMigrationsDir();
77
125
  try {
package/src/db/dialect.ts CHANGED
@@ -12,6 +12,9 @@
12
12
  //
13
13
  // The framework no longer imports drizzle-orm at runtime — schema-files
14
14
  // use only this module.
15
+ //
16
+ // Static import, not the ambient global: Bun doesn't expose Temporal on
17
+ // globalThis, so instantToDriver crashed on timestamptz writes (#1480).
15
18
 
16
19
  import {
17
20
  type ColumnHandle,
@@ -19,6 +22,7 @@ import {
19
22
  KUMIKO_NAME_SYMBOL,
20
23
  type SchemaTable,
21
24
  } from "@cosmicdrift/kumiko-types/schema-table-types";
25
+ import { Temporal } from "temporal-polyfill";
22
26
  import type {
23
27
  ColumnMeta,
24
28
  CompositePrimaryKeyMeta,
@@ -54,37 +54,40 @@ function applyStatement(schema: Map<string, { columns: Set<string> }>, statement
54
54
  const create = statement.match(
55
55
  /^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s*\(([\s\S]*)\);?\s*$/i,
56
56
  );
57
- const createTableName = create?.[1];
58
- const createBody = create?.[2];
59
-
60
- const addColumn = statement.match(
61
- /^ALTER TABLE\s+"([^"]+)"\s+ADD COLUMN\s+(?:IF NOT EXISTS\s+)?"([^"]+)"/i,
62
- );
63
- const addColumnTable = addColumn?.[1];
64
- const addColumnName = addColumn?.[2];
57
+ if (create?.[1] !== undefined && create[2] !== undefined) {
58
+ schema.set(create[1], { columns: parseColumnNames(create[2]) });
59
+ // skip: CREATE TABLE fully handled above, no other clause can also match
60
+ return;
61
+ }
65
62
 
66
63
  const dropTable = statement.match(/^DROP TABLE\s+(?:IF EXISTS\s+)?"([^"]+)"/i);
67
- const dropTableName = dropTable?.[1];
68
-
69
- const dropColumn = statement.match(
70
- /^ALTER TABLE\s+"([^"]+)"\s+DROP COLUMN\s+(?:IF EXISTS\s+)?"([^"]+)"/i,
71
- );
72
- const dropColumnTable = dropColumn?.[1];
73
- const dropColumnName = dropColumn?.[2];
64
+ if (dropTable?.[1] !== undefined) {
65
+ schema.delete(dropTable[1]);
66
+ // skip: DROP TABLE fully handled above, no other clause can also match
67
+ return;
68
+ }
74
69
 
75
- if (createTableName !== undefined && createBody !== undefined) {
76
- schema.set(createTableName, { columns: parseColumnNames(createBody) });
77
- } else if (addColumnTable !== undefined && addColumnName !== undefined) {
78
- const table = schema.get(addColumnTable);
79
- if (table) table.columns.add(addColumnName);
80
- else schema.set(addColumnTable, { columns: new Set([addColumnName]) });
81
- } else if (dropTableName !== undefined) {
82
- schema.delete(dropTableName);
83
- } else if (dropColumnTable !== undefined && dropColumnName !== undefined) {
84
- schema.get(dropColumnTable)?.columns.delete(dropColumnName);
70
+ // A single ALTER TABLE statement can carry multiple comma-separated
71
+ // ADD/DROP COLUMN clauses (e.g. migration 0007_fix-secrets-table-columns'
72
+ // three-column fix in one statement) matchAll over the whole body
73
+ // instead of matching only the first clause, in statement order so an
74
+ // add-then-drop of the same column (unusual, but not impossible) resolves
75
+ // correctly.
76
+ const alterTable = statement.match(/^ALTER TABLE\s+"([^"]+)"\s+([\s\S]*?);?\s*$/i);
77
+ const alterTableName = alterTable?.[1];
78
+ const alterBody = alterTable?.[2];
79
+ if (alterTableName !== undefined && alterBody !== undefined) {
80
+ const table = schema.get(alterTableName) ?? { columns: new Set<string>() };
81
+ schema.set(alterTableName, table);
82
+ const clauseRe = /(ADD|DROP)\s+COLUMN\s+(?:IF (?:NOT )?EXISTS\s+)?"([^"]+)"/gi;
83
+ for (const [, verb, name] of alterBody.matchAll(clauseRe)) {
84
+ if (verb === undefined || name === undefined) continue;
85
+ if (verb.toUpperCase() === "ADD") table.columns.add(name);
86
+ else table.columns.delete(name);
87
+ }
85
88
  }
86
- // else: CREATE INDEX, ALTER COLUMN (TYPE/DEFAULT/NOT NULL) and everything
87
- // else don't change the table/column shape this replay tracks.
89
+ // else: CREATE INDEX and everything else don't change the table/column
90
+ // shape this replay tracks.
88
91
  }
89
92
 
90
93
  // Reads `<migrationsDir>/*.sql` in sequence order and replays every
@@ -145,11 +145,19 @@ export type UserDataExportHook = (ctx: UserDataHookCtx) => Promise<UserDataExpor
145
145
  *
146
146
  * Idempotent — wenn der Job zweimal läuft (Crash-Recovery), darf der
147
147
  * Hook nicht crashen.
148
+ *
149
+ * Return additive to `void`: a hook can return `{status:"ok"}` or
150
+ * `{status:"incomplete", reason}` to signal a partial success to the
151
+ * cleanup runner (e.g. an external provider call failed) without
152
+ * throwing/rolling back the sub-transaction. Existing `void` returners
153
+ * stay valid unchanged.
148
154
  */
149
155
  export type UserDataDeleteHook = (
150
156
  ctx: UserDataHookCtx,
151
157
  strategy: UserDataDeleteStrategy,
152
- ) => Promise<void>;
158
+ ) => Promise<
159
+ undefined | { readonly status: "ok" } | { readonly status: "incomplete"; readonly reason: string }
160
+ >;
153
161
 
154
162
  /**
155
163
  * Komplette Hook-Tafel für EXT_USER_DATA. Sprint 2 user-data-rights
@@ -593,6 +593,30 @@ describe("error handling", () => {
593
593
  expect(body.error).toContain("invalid_file_type");
594
594
  });
595
595
 
596
+ test("upload with path-traversal entityType is rejected before it reaches storage", async () => {
597
+ const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
598
+ const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
599
+ entityType: "../../tenantB",
600
+ entityId: "1",
601
+ fieldName: "logo",
602
+ });
603
+ expect(res.status).toBe(400);
604
+ const body = await res.json();
605
+ expect(body.error).toContain("invalid_entityType");
606
+ });
607
+
608
+ test("upload with path-traversal fieldName is rejected before it reaches storage", async () => {
609
+ const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
610
+ const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
611
+ entityType: "tenant",
612
+ entityId: "1",
613
+ fieldName: "../../escape",
614
+ });
615
+ expect(res.status).toBe(400);
616
+ const body = await res.json();
617
+ expect(body.error).toContain("invalid_fieldName");
618
+ });
619
+
596
620
  test("upload without auth returns 401", async () => {
597
621
  const formData = new FormData();
598
622
  formData.append("file", new File([new Uint8Array(10)], "test.png", { type: "image/png" }));
@@ -147,6 +147,19 @@ describe("FileStorageProvider.writeStream — local-filesystem", () => {
147
147
  const data = await provider.read("k");
148
148
  expect(Array.from(data)).toEqual([2, 2, 2]);
149
149
  });
150
+
151
+ test("write/read/delete reject a key that escapes basePath; exists reports false", async () => {
152
+ const provider = createLocalProvider(basePath);
153
+ const escapingKey = "../../etc/passwd";
154
+
155
+ await expect(provider.write(escapingKey, new Uint8Array([1]))).rejects.toThrow(
156
+ /escapes basePath/,
157
+ );
158
+ await expect(provider.read(escapingKey)).rejects.toThrow(/escapes basePath/);
159
+ await expect(provider.delete(escapingKey)).rejects.toThrow(/escapes basePath/);
160
+ // exists() treats any stat failure (missing file, escaping key) as "not there".
161
+ expect(await provider.exists(escapingKey)).toBe(false);
162
+ });
150
163
  });
151
164
 
152
165
  describe("FileStorageProvider.writeStream — Streaming-Property", () => {
@@ -112,6 +112,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
112
112
  const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
113
113
  const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
114
114
 
115
+ // These interpolate directly into the storage key (buildStorageKey below) —
116
+ // reject anything but a safe identifier so a client can't path-traverse
117
+ // out of its tenant's storage prefix (../.. segments, absolute paths).
118
+ for (const [field, value] of [
119
+ ["entityType", entityType],
120
+ ["entityId", entityId],
121
+ ["fieldName", fieldName],
122
+ ] as const) {
123
+ if (value !== undefined && !/^[A-Za-z0-9_-]+$/.test(value)) {
124
+ return c.json({ error: `invalid_${field}: must match /^[A-Za-z0-9_-]+$/` }, 400);
125
+ }
126
+ }
127
+
115
128
  // Validate against entity field definition if available.
116
129
  let maxSize = options.maxUploadSize ?? "10mb";
117
130
  let accept: readonly string[] | undefined;
@@ -1,6 +1,6 @@
1
1
  import { createReadStream, createWriteStream } from "node:fs";
2
2
  import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import type { FileStorageProvider } from "./types";
6
6
 
@@ -8,9 +8,23 @@ import type { FileStorageProvider } from "./types";
8
8
  // pick an object-store provider (S3/R2/…). mimeType is ignored here; the
9
9
  // filesystem tracks no metadata beyond what the caller stores on FileRef.
10
10
  export function createLocalProvider(basePath: string): FileStorageProvider {
11
+ const resolvedBase = resolve(basePath);
12
+
13
+ // Callers are expected to only ever pass keys built by buildStorageKey(),
14
+ // but the storage layer shouldn't rely solely on that — this resolves the
15
+ // real path and rejects anything that escapes basePath (e.g. a `..`
16
+ // segment that slipped past an upstream check), regardless of the key's
17
+ // source.
18
+ function resolveContainedPath(key: string): string {
19
+ const filePath = resolve(join(basePath, key));
20
+ if (filePath !== resolvedBase && !filePath.startsWith(resolvedBase + sep)) {
21
+ throw new Error(`storage key escapes basePath: "${key}"`);
22
+ }
23
+ return filePath;
24
+ }
11
25
  return {
12
26
  async write(key: string, data: Uint8Array, _mimeType?: string): Promise<void> {
13
- const filePath = join(basePath, key);
27
+ const filePath = resolveContainedPath(key);
14
28
  await mkdir(dirname(filePath), { recursive: true });
15
29
  await writeFile(filePath, data);
16
30
  },
@@ -26,7 +40,7 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
26
40
  // Hygiene; ein periodischer cron-cleanup auf alten `.tmp`-Files
27
41
  // ist die saubere Loesung wenn das in Production realistisch
28
42
  // greift.
29
- const filePath = join(basePath, key);
43
+ const filePath = resolveContainedPath(key);
30
44
  const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
31
45
  await mkdir(dirname(filePath), { recursive: true });
32
46
  try {
@@ -41,7 +55,7 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
41
55
  },
42
56
 
43
57
  async read(key: string): Promise<Uint8Array> {
44
- const filePath = join(basePath, key);
58
+ const filePath = resolveContainedPath(key);
45
59
  return readFile(filePath);
46
60
  },
47
61
 
@@ -55,7 +69,7 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
55
69
  // (z.B. ENOENT bei Missing-File faellt erst beim ersten chunk-
56
70
  // pull, nicht beim readStream-Aufruf — gleiches Lazy-Verhalten
57
71
  // wie inmemory + S3).
58
- const filePath = join(basePath, key);
72
+ const filePath = resolveContainedPath(key);
59
73
  const stream = createReadStream(filePath);
60
74
  return {
61
75
  async *[Symbol.asyncIterator]() {
@@ -76,13 +90,13 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
76
90
  },
77
91
 
78
92
  async delete(key: string): Promise<void> {
79
- const filePath = join(basePath, key);
93
+ const filePath = resolveContainedPath(key);
80
94
  await rm(filePath, { force: true });
81
95
  },
82
96
 
83
97
  async exists(key: string): Promise<boolean> {
84
98
  try {
85
- await stat(join(basePath, key));
99
+ await stat(resolveContainedPath(key));
86
100
  return true;
87
101
  } catch {
88
102
  return false;