@cosmicdrift/kumiko-framework 0.163.2 → 0.164.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.163.2",
3
+ "version": "0.164.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>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.163.2",
185
+ "@cosmicdrift/kumiko-types": "0.164.0",
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.2",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.164.0",
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
+ });
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,
@@ -3,7 +3,7 @@
3
3
  // unwrap both layers so callers don't have to know which layer produced the
4
4
  // error. Used by the event-store to distinguish a unique-violation on the
5
5
  // aggregate-version index (optimistic-concurrency conflict) from the one on
6
- // the request-id idempotency index (replay signal).
6
+ // the idempotency-key index (caller-side replay signal).
7
7
 
8
8
  export type PgErrorInfo = {
9
9
  readonly code: string | undefined;
@@ -6,6 +6,20 @@ export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void>
6
6
  await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
7
7
  }
8
8
 
9
+ // Tenant-scoped partial unique index over metadata.idempotencyKey.
10
+ // Expression index straight on the jsonb column — no dedicated key column,
11
+ // so it needs no INSERT-path change and covers admin-api's raw appends too
12
+ // (same metadata jsonb). CREATE ... IF NOT EXISTS makes this safe to call
13
+ // on every boot, same "ensure" pattern as ensureSnapshotVersionColumn: heals
14
+ // installs that predate the index without a table rebuild.
15
+ export async function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void> {
16
+ await asRawClient(db).unsafe(
17
+ `CREATE UNIQUE INDEX IF NOT EXISTS "events_idempotency_uq" ON "kumiko_events" ` +
18
+ `("tenant_id", (("metadata"->>'idempotencyKey'))) ` +
19
+ `WHERE "metadata"->>'idempotencyKey' IS NOT NULL`,
20
+ );
21
+ }
22
+
9
23
  export type SubsequentEventInsertParams = {
10
24
  readonly aggregateId: string;
11
25
  readonly aggregateType: string;
@@ -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
@@ -6,6 +6,7 @@ import { generateId as uuid } from "../../utils";
6
6
  import {
7
7
  append,
8
8
  createEventsTable,
9
+ IdempotentAppendConflictError,
9
10
  loadAggregate,
10
11
  loadAggregateAsOf,
11
12
  loadAllEventsByType,
@@ -93,6 +94,129 @@ describe("event-store: append + load", () => {
93
94
  });
94
95
  });
95
96
 
97
+ describe("event-store: idempotency-key conflict", () => {
98
+ test("second append with a reused idempotencyKey (new aggregate+version) throws IdempotentAppendConflictError", async () => {
99
+ const first = uuid();
100
+ const second = uuid();
101
+ const key = uuid();
102
+
103
+ await append(testDb.db, {
104
+ aggregateId: first,
105
+ aggregateType: "task",
106
+ tenantId: tenantA,
107
+ expectedVersion: 0,
108
+ type: "task.created",
109
+ payload: { title: "Orig" },
110
+ metadata: { userId: userA, idempotencyKey: key },
111
+ });
112
+
113
+ // A retried command that re-runs after the Redis idempotency guard
114
+ // missed its window: different aggregate, fresh expectedVersion=0 — the
115
+ // aggregate-version unique index has nothing to say about this pair, so
116
+ // only the idempotency-key index catches the duplicate.
117
+ await expect(
118
+ append(testDb.db, {
119
+ aggregateId: second,
120
+ aggregateType: "task",
121
+ tenantId: tenantA,
122
+ expectedVersion: 0,
123
+ type: "task.created",
124
+ payload: { title: "Retry" },
125
+ metadata: { userId: userA, idempotencyKey: key },
126
+ }),
127
+ ).rejects.toThrow(IdempotentAppendConflictError);
128
+
129
+ const events = await loadAggregate(testDb.db, second, tenantA);
130
+ expect(events).toHaveLength(0);
131
+ });
132
+
133
+ test("subsequent-event append (expectedVersion > 0) also enforces the idempotency key", async () => {
134
+ const aggregateId = uuid();
135
+ const key = uuid();
136
+
137
+ await append(testDb.db, {
138
+ aggregateId,
139
+ aggregateType: "task",
140
+ tenantId: tenantA,
141
+ expectedVersion: 0,
142
+ type: "task.created",
143
+ payload: { title: "Orig" },
144
+ metadata: { userId: userA },
145
+ });
146
+
147
+ await append(testDb.db, {
148
+ aggregateId,
149
+ aggregateType: "task",
150
+ tenantId: tenantA,
151
+ expectedVersion: 1,
152
+ type: "task.updated",
153
+ payload: { title: "V2" },
154
+ metadata: { userId: userA, idempotencyKey: key },
155
+ });
156
+
157
+ // Retry of the v2 update: goes through insertSubsequentEventRow's raw
158
+ // INSERT ... SELECT ... WHERE EXISTS path, not insertFirstEvent — the
159
+ // idempotency index must catch it there too.
160
+ await expect(
161
+ append(testDb.db, {
162
+ aggregateId,
163
+ aggregateType: "task",
164
+ tenantId: tenantA,
165
+ expectedVersion: 2,
166
+ type: "task.updated",
167
+ payload: { title: "V3-retry" },
168
+ metadata: { userId: userA, idempotencyKey: key },
169
+ }),
170
+ ).rejects.toThrow(IdempotentAppendConflictError);
171
+
172
+ const events = await loadAggregate(testDb.db, aggregateId, tenantA);
173
+ expect(events).toHaveLength(2);
174
+ });
175
+
176
+ test("same idempotencyKey on a different tenant does not conflict", async () => {
177
+ const key = uuid();
178
+
179
+ const a = await append(testDb.db, {
180
+ aggregateId: uuid(),
181
+ aggregateType: "task",
182
+ tenantId: tenantA,
183
+ expectedVersion: 0,
184
+ type: "task.created",
185
+ payload: {},
186
+ metadata: { userId: userA, idempotencyKey: key },
187
+ });
188
+ const b = await append(testDb.db, {
189
+ aggregateId: uuid(),
190
+ aggregateType: "task",
191
+ tenantId: tenantB,
192
+ expectedVersion: 0,
193
+ type: "task.created",
194
+ payload: {},
195
+ metadata: { userId: userA, idempotencyKey: key },
196
+ });
197
+
198
+ expect(a.version).toBe(1);
199
+ expect(b.version).toBe(1);
200
+ });
201
+
202
+ test("omitting idempotencyKey allows unlimited appends, unchanged from before", async () => {
203
+ const events = await Promise.all(
204
+ Array.from({ length: 3 }, () =>
205
+ append(testDb.db, {
206
+ aggregateId: uuid(),
207
+ aggregateType: "task",
208
+ tenantId: tenantA,
209
+ expectedVersion: 0,
210
+ type: "task.created",
211
+ payload: {},
212
+ metadata: { userId: userA },
213
+ }),
214
+ ),
215
+ );
216
+ expect(events).toHaveLength(3);
217
+ });
218
+ });
219
+
96
220
  describe("event-store: optimistic concurrency", () => {
97
221
  test("wrong expectedVersion throws VersionConflictError (no write)", async () => {
98
222
  const aggregateId = uuid();
@@ -1,7 +1,7 @@
1
1
  import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
2
2
  import { encryptEventPayloadPii } from "../crypto/event-pii";
3
3
  import type { DbRunner } from "../db";
4
- import { isUniqueViolation } from "../db/pg-error";
4
+ import { constraintOf, isUniqueViolation } from "../db/pg-error";
5
5
  import {
6
6
  insertSubsequentEventRow,
7
7
  notifyPgChannel,
@@ -13,7 +13,7 @@ import {
13
13
  import { insertOne, selectMany } from "../db/query";
14
14
  import type { TenantId } from "../engine/types";
15
15
  import { isStreamArchived } from "./archive";
16
- import { VersionConflictError } from "./errors";
16
+ import { IdempotentAppendConflictError, VersionConflictError } from "./errors";
17
17
  import { eventsTable } from "./events-schema";
18
18
  import { toStoredEvent } from "./row-to-stored-event";
19
19
 
@@ -91,10 +91,15 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
91
91
  return buildStoredEvent(toStore, newVersion, eventVersion, row);
92
92
  } catch (e) {
93
93
  if (isUniqueViolation(e)) {
94
- // Only constraint left on the events table: events_aggregate_version_uq
95
- // on (tenant_id, aggregate_id, version). A unique violation here always
96
- // means a concurrent writer in the same tenant won the race to the
97
- // next version retry-able conflict.
94
+ // Two unique constraints on this table: events_aggregate_version_uq
95
+ // (tenant_id, aggregate_id, version) a concurrent writer won the
96
+ // race to the next version and events_idempotency_uq (tenant_id,
97
+ // metadata->>'idempotencyKey')the caller reused an idempotency key.
98
+ // constraintOf() tells them apart; unknown/renamed constraint falls
99
+ // back to VersionConflictError, the pre-existing behaviour.
100
+ if (constraintOf(e) === "events_idempotency_uq" && event.metadata.idempotencyKey) {
101
+ throw new IdempotentAppendConflictError(event.tenantId, event.metadata.idempotencyKey);
102
+ }
98
103
  throw new VersionConflictError(event.aggregateId, event.expectedVersion);
99
104
  }
100
105
  throw e;
@@ -12,6 +12,7 @@ import {
12
12
  uniqueIndex,
13
13
  uuid,
14
14
  } from "../db/dialect";
15
+ import { ensureIdempotencyKeyIndex } from "../db/queries/event-store";
15
16
  import { unsafePushTables } from "../stack";
16
17
  import { createArchivedStreamsTable } from "./archive";
17
18
  import { createSnapshotsTable } from "./snapshot";
@@ -22,9 +23,11 @@ import type { EventMetadata } from "./types";
22
23
  // INSERT ... SELECT ... WHERE EXISTS isn't ergonomic in the typed builder.
23
24
  //
24
25
  // HTTP-level retry idempotency is handled by pipeline/idempotency.ts
25
- // (Redis-backed check + cached-response replay). The event-store itself
26
- // imposes no idempotency indexa single HTTP request may write N events
27
- // freely, metadata.requestId is purely a trace marker.
26
+ // (Redis-backed check + cached-response replay); metadata.requestId is
27
+ // purely a trace marker (no uniqueness constraintone request may write
28
+ // N events). Callers that need a hard per-event guarantee as a second line
29
+ // of defense set metadata.idempotencyKey, enforced by the tenant-scoped
30
+ // partial unique index ensureIdempotencyKeyIndex() creates below.
28
31
 
29
32
  export const eventsTable = pgTable(
30
33
  "kumiko_events",
@@ -77,6 +80,10 @@ export async function createEventsTable(db: DbConnection): Promise<void> {
77
80
  if (!(await tableExists(db, "public.kumiko_events"))) {
78
81
  await unsafePushTables(db, { kumikoEvents: eventsTable });
79
82
  }
83
+ // Runs unconditionally (both fresh + already-existing table) so installs
84
+ // that predate the idempotency-key index get healed the same way
85
+ // ensureSnapshotVersionColumn heals kumiko_snapshots.
86
+ await ensureIdempotencyKeyIndex(db);
80
87
  await createArchivedStreamsTable(db);
81
88
  await createSnapshotsTable(db);
82
89
  }
@@ -12,7 +12,7 @@ export {
12
12
  isStreamArchived,
13
13
  restoreStream,
14
14
  } from "./archive";
15
- export { ArchivedStreamError, VersionConflictError } from "./errors";
15
+ export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError } from "./errors";
16
16
  export {
17
17
  append,
18
18
  EVENTS_PUBSUB_CHANNEL,
@@ -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;