@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 +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/db/pg-error.ts +1 -1
- package/src/db/queries/event-store.ts +14 -0
- package/src/engine/extensions/user-data.ts +9 -1
- package/src/event-store/__tests__/event-store.integration.test.ts +124 -0
- package/src/event-store/event-store.ts +11 -6
- package/src/event-store/events-schema.ts +10 -3
- package/src/event-store/index.ts +1 -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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|
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,
|
package/src/db/pg-error.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
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).
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// (Redis-backed check + cached-response replay); metadata.requestId is
|
|
27
|
+
// purely a trace marker (no uniqueness constraint — one 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
|
}
|
package/src/event-store/index.ts
CHANGED
|
@@ -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", () => {
|
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;
|