@cosmicdrift/kumiko-framework 0.163.2 → 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 +3 -3
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
- package/src/bun-db/query.ts +3 -0
- package/src/db/__tests__/dialect-instant.test.ts +1 -4
- package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
- package/src/db/dialect.ts +4 -0
- package/src/engine/extensions/user-data.ts +9 -1
- package/src/files/__tests__/files.integration.test.ts +24 -0
- package/src/files/__tests__/write-stream.test.ts +13 -0
- package/src/files/file-routes.ts +13 -0
- package/src/files/local-provider.ts +21 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.163.
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -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
|
+
});
|
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,
|
|
@@ -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<
|
|
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", () => {
|
package/src/files/file-routes.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
99
|
+
await stat(resolveContainedPath(key));
|
|
86
100
|
return true;
|
|
87
101
|
} catch {
|
|
88
102
|
return false;
|