@cosmicdrift/kumiko-framework 0.165.0 → 2.0.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 +5 -3
- package/src/__tests__/consumer-cli.integration.test.ts +32 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +267 -19
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +21 -2
- package/src/api/__tests__/jwt.test.ts +52 -2
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/auth-routes.ts +165 -33
- package/src/api/index.ts +1 -0
- package/src/api/jwt.ts +22 -1
- package/src/api/routes.ts +103 -35
- package/src/api/server.ts +17 -1
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +12 -3
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- package/src/db/connection.ts +6 -3
- package/src/db/dialect.ts +1 -6
- package/src/db/entity-table-meta-types.ts +1 -1
- package/src/db/event-store-executor-context.ts +2 -3
- package/src/db/event-store-executor-read.ts +2 -3
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/index.ts +8 -1
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +8 -0
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-store.ts +97 -5
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +114 -12
- package/src/db/tenant-db.ts +2 -4
- package/src/engine/__tests__/engine.test.ts +30 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/store-table.test.ts +2 -2
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/extensions/user-data.ts +12 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/hook-helpers.ts +3 -1
- package/src/engine/index.ts +1 -1
- package/src/engine/ownership.ts +4 -3
- package/src/engine/registry-ingest.ts +14 -14
- package/src/engine/registry-state.ts +4 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/types/config.ts +1 -1
- package/src/engine/types/define-handler.ts +1 -1
- package/src/engine/types/entity-handlers.ts +1 -1
- package/src/engine/types/event-type-map.ts +1 -1
- package/src/engine/types/feature.ts +1 -1
- package/src/engine/types/fields.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +1 -1
- package/src/engine/types/http-route.ts +1 -1
- package/src/engine/types/nav.ts +1 -1
- package/src/engine/types/ownership.ts +1 -1
- package/src/engine/types/projection.ts +1 -1
- package/src/engine/types/relations.ts +1 -1
- package/src/engine/types/screen.ts +1 -1
- package/src/engine/types/step.ts +1 -1
- package/src/engine/types/target-ref.ts +1 -1
- package/src/engine/types/tree-node.ts +1 -1
- package/src/engine/types/workspace.ts +1 -1
- package/src/engine/validate-projection-allowlist.ts +5 -5
- package/src/errors/classes.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/write-error-info.ts +6 -2
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +19 -4
- package/src/event-store/types.ts +1 -1
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/write-stream.test.ts +3 -3
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +6 -1
- package/src/files/types.ts +8 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
- package/src/jobs/job-runner.ts +41 -11
- package/src/logging/types.ts +1 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +35 -2
- package/src/observability/types/index.ts +1 -1
- package/src/observability/types/metric.ts +1 -1
- package/src/observability/types/provider.ts +1 -1
- package/src/observability/types/span.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +32 -11
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +20 -6
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +87 -0
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +6 -2
- package/src/schema-cli.ts +24 -12
- package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
- package/src/search/reindex-entity.ts +31 -2
- package/src/search/types.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +2 -1
- package/src/stack/request-helper.ts +20 -1
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +18 -15
- package/src/testing/__tests__/late-bound.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +6 -0
- package/src/testing/file-provider-contract.ts +26 -6
- package/src/testing/index.ts +1 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +37 -31
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
|
@@ -15,7 +15,7 @@ import { asRawClient, selectMany } from "../../db/query";
|
|
|
15
15
|
import { createTestDb, type TestDb } from "../../stack";
|
|
16
16
|
import { generateId as uuid } from "../../utils";
|
|
17
17
|
import { appendRaw, appendRawBatch, type RawEventToAppend } from "../admin-api";
|
|
18
|
-
import { VersionConflictError } from "../errors";
|
|
18
|
+
import { IdempotentAppendConflictError, VersionConflictError } from "../errors";
|
|
19
19
|
import { append, loadAggregate } from "../event-store";
|
|
20
20
|
import { createEventsTable, eventsTable } from "../events-schema";
|
|
21
21
|
|
|
@@ -187,6 +187,32 @@ describe("appendRaw — single event", () => {
|
|
|
187
187
|
).rejects.toBeInstanceOf(VersionConflictError);
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
test("appendRaw distinguishes an idempotency-key replay from a version conflict (#1499)", async () => {
|
|
191
|
+
const idempotencyKey = uuid();
|
|
192
|
+
await appendRaw(
|
|
193
|
+
testDb.db,
|
|
194
|
+
makeEvent({
|
|
195
|
+
aggregateId: uuid(),
|
|
196
|
+
expectedVersion: 0,
|
|
197
|
+
metadata: { userId: userMigration, idempotencyKey },
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// A second, different aggregate reusing the same idempotencyKey hits
|
|
202
|
+
// the tenant-scoped partial unique index — distinct from a version
|
|
203
|
+
// race on the same aggregate, and must not be reported as one.
|
|
204
|
+
await expect(
|
|
205
|
+
appendRaw(
|
|
206
|
+
testDb.db,
|
|
207
|
+
makeEvent({
|
|
208
|
+
aggregateId: uuid(),
|
|
209
|
+
expectedVersion: 0,
|
|
210
|
+
metadata: { userId: userMigration, idempotencyKey },
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
).rejects.toBeInstanceOf(IdempotentAppendConflictError);
|
|
214
|
+
});
|
|
215
|
+
|
|
190
216
|
test("version_conflict on missing predecessor (appendRaw v=5 without v=1..4)", async () => {
|
|
191
217
|
const aggregateId = uuid();
|
|
192
218
|
// Try to write version=5 (expectedVersion=4) against an empty stream.
|
|
@@ -60,6 +60,38 @@ describe("event-store: append + load", () => {
|
|
|
60
60
|
expect(events[0]?.metadata.userId).toBe(userA);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("kumiko-framework#1490: subsequent append doesn't rely on globalThis.Temporal", async () => {
|
|
64
|
+
const aggregateId = uuid();
|
|
65
|
+
const first = await append(testDb.db, {
|
|
66
|
+
aggregateId,
|
|
67
|
+
aggregateType: "task",
|
|
68
|
+
tenantId: tenantA,
|
|
69
|
+
expectedVersion: 0,
|
|
70
|
+
type: "task.created",
|
|
71
|
+
payload: { title: "Buy milk" },
|
|
72
|
+
metadata: { userId: userA },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
76
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
77
|
+
try {
|
|
78
|
+
const second = await append(testDb.db, {
|
|
79
|
+
aggregateId,
|
|
80
|
+
aggregateType: "task",
|
|
81
|
+
tenantId: tenantA,
|
|
82
|
+
expectedVersion: first.version,
|
|
83
|
+
type: "task.updated",
|
|
84
|
+
payload: { title: "Buy oat milk" },
|
|
85
|
+
metadata: { userId: userA },
|
|
86
|
+
});
|
|
87
|
+
expect(second.version).toBe(2);
|
|
88
|
+
expect(second.createdAt).toBeDefined();
|
|
89
|
+
} finally {
|
|
90
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
91
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
63
95
|
test("subsequent appends increment version and are ordered", async () => {
|
|
64
96
|
const aggregateId = uuid();
|
|
65
97
|
const base = {
|
|
@@ -12,8 +12,6 @@ const ALLOWED_FILES = new Set([
|
|
|
12
12
|
"packages/framework/src/event-store/index.ts",
|
|
13
13
|
"packages/bundled-features/src/tenant/seeding.ts",
|
|
14
14
|
"packages/bundled-features/src/tier-engine/feature.ts",
|
|
15
|
-
"packages/bundled-features/src/auth-email-password/__tests__/email-verification.integration.test.ts",
|
|
16
|
-
"packages/bundled-features/src/auth-email-password/__tests__/password-reset.integration.test.ts",
|
|
17
15
|
"packages/framework/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts",
|
|
18
16
|
]);
|
|
19
17
|
|
|
@@ -21,11 +19,31 @@ const REPO_ROOT = `${import.meta.dir}/../../../../..`;
|
|
|
21
19
|
|
|
22
20
|
describe("unscoped stream primitives — caller allowlist", () => {
|
|
23
21
|
test("only seed/system-internal paths reference the existence-oracle primitives", async () => {
|
|
24
|
-
|
|
22
|
+
// Widened from {framework,bundled-features} — the primitives are exported
|
|
23
|
+
// from the public @cosmicdrift/kumiko-framework/event-store barrel, so any
|
|
24
|
+
// package (server-runtime, dispatcher-live, headless, renderer-web, cli, ...)
|
|
25
|
+
// can import and call them; the allowlist must cover all of them, not just
|
|
26
|
+
// the two packages that happened to have callers when this guard was written.
|
|
27
|
+
const glob = new Glob("packages/*/src/**/*.ts");
|
|
25
28
|
const matches = new Set<string>();
|
|
26
29
|
for await (const relPath of glob.scan({ cwd: REPO_ROOT })) {
|
|
27
30
|
const content = await Bun.file(`${REPO_ROOT}/${relPath}`).text();
|
|
28
|
-
|
|
31
|
+
// Strip full-line `//` comments before scanning — a bare
|
|
32
|
+
// `content.includes(symbol)` also matches a symbol only mentioned in
|
|
33
|
+
// prose (e.g. explaining why a helper isn't needed), which would force
|
|
34
|
+
// an unrelated file into the allowlist for a reference that isn't an
|
|
35
|
+
// actual import or call.
|
|
36
|
+
const code = content
|
|
37
|
+
.split("\n")
|
|
38
|
+
.filter((line) => !line.trim().startsWith("//"))
|
|
39
|
+
.join("\n");
|
|
40
|
+
const referencesSymbol = (symbol: string): boolean =>
|
|
41
|
+
new RegExp(`\\b${symbol}\\s*\\(`).test(code) ||
|
|
42
|
+
// Covers both `import { symbol } from "..."` and a re-export barrel
|
|
43
|
+
// (`export { symbol } from "./event-store"`) — a new barrel that
|
|
44
|
+
// surfaces the oracle primitive must widen the allowlist too.
|
|
45
|
+
new RegExp(`\\b(import|export)\\b[^;]*\\b${symbol}\\b`).test(code);
|
|
46
|
+
if (RESTRICTED_SYMBOLS.some(referencesSymbol)) {
|
|
29
47
|
matches.add(relPath);
|
|
30
48
|
}
|
|
31
49
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// selbst, das Guard-Script selbst.
|
|
9
9
|
|
|
10
10
|
import type { DbRunner } from "../db";
|
|
11
|
-
import { isUniqueViolation } from "../db/pg-error";
|
|
11
|
+
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
12
12
|
import {
|
|
13
13
|
eventPredecessorExists,
|
|
14
14
|
findExistingEventVersion,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
insertRawSubsequentEvent,
|
|
18
18
|
} from "../db/queries/event-store-admin";
|
|
19
19
|
import type { TenantId } from "../engine/types";
|
|
20
|
-
import { VersionConflictError } from "./errors";
|
|
20
|
+
import { IdempotentAppendConflictError, VersionConflictError } from "./errors";
|
|
21
21
|
import type { EventMetadata } from "./event-store";
|
|
22
22
|
|
|
23
23
|
export type RawEventToAppend = {
|
|
@@ -39,6 +39,13 @@ export type RawEventToAppend = {
|
|
|
39
39
|
readonly createdBy: string;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
function mapEventUniqueViolation(e: unknown, event: RawEventToAppend): Error {
|
|
43
|
+
if (constraintOf(e) === "events_idempotency_uq" && event.metadata.idempotencyKey) {
|
|
44
|
+
return new IdempotentAppendConflictError(event.tenantId, event.metadata.idempotencyKey);
|
|
45
|
+
}
|
|
46
|
+
return new VersionConflictError(event.aggregateId, event.expectedVersion);
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
// Mirrors append()'s two-path structure: typed builder equivalent for v=0,
|
|
43
50
|
// INSERT … SELECT … WHERE EXISTS gate for v>0. Caller-supplied createdAt +
|
|
44
51
|
// createdBy skip the usual userResolver/now() paths.
|
|
@@ -54,7 +61,7 @@ export async function appendRaw(runner: DbRunner, event: RawEventToAppend): Prom
|
|
|
54
61
|
}
|
|
55
62
|
} catch (e) {
|
|
56
63
|
if (isUniqueViolation(e)) {
|
|
57
|
-
throw
|
|
64
|
+
throw mapEventUniqueViolation(e, event);
|
|
58
65
|
}
|
|
59
66
|
throw e;
|
|
60
67
|
}
|
|
@@ -143,7 +150,7 @@ export async function appendRawBatch(
|
|
|
143
150
|
if (isUniqueViolation(e)) {
|
|
144
151
|
// Pre-flight ran but lost a race against a concurrent writer. Rare for
|
|
145
152
|
// migration (single-runner) but possible; we can't name the exact row.
|
|
146
|
-
throw
|
|
153
|
+
throw mapEventUniqueViolation(e, firstEvent);
|
|
147
154
|
}
|
|
148
155
|
throw e;
|
|
149
156
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
|
+
// Value-only import, aliased to avoid shadowing the ambient global
|
|
3
|
+
// `Temporal` TYPE this file's other Temporal.Instant annotations resolve
|
|
4
|
+
// against (StoredEvent.createdAt et al. — importing the bare name here
|
|
5
|
+
// conflicts with that ambient type, see #1438). Bun doesn't expose Temporal
|
|
6
|
+
// as a runtime value on globalThis, so the un-aliased call below crashed
|
|
7
|
+
// with "Temporal is not defined" outside boot paths that install it (#1480).
|
|
8
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
2
9
|
import { encryptEventPayloadPii } from "../crypto/event-pii";
|
|
3
10
|
import type { DbRunner } from "../db";
|
|
4
11
|
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
@@ -151,12 +158,20 @@ async function insertSubsequentEvent(
|
|
|
151
158
|
expectedVersion: event.expectedVersion,
|
|
152
159
|
});
|
|
153
160
|
if (!row) throw new VersionConflictError(event.aggregateId, event.expectedVersion);
|
|
161
|
+
const createdAt =
|
|
162
|
+
row.created_at instanceof Date
|
|
163
|
+
? TemporalPolyfill.Instant.fromEpochMilliseconds(row.created_at.getTime())
|
|
164
|
+
: TemporalPolyfill.Instant.from(row.created_at);
|
|
154
165
|
return {
|
|
155
166
|
id: typeof row.id === "bigint" ? row.id : BigInt(row.id),
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
// @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
|
|
168
|
+
// at runtime, two separate .d.ts sources (temporal-polyfill's bundled
|
|
169
|
+
// types vs the ambient temporal-spec global) disagree on a couple of
|
|
170
|
+
// method-overload signatures (until/equals argument types), so TS treats
|
|
171
|
+
// them as nominally distinct. Safe: this value is only ever consumed as
|
|
172
|
+
// a Temporal.Instant (comparisons, .toString(), arithmetic), never
|
|
173
|
+
// narrowed on the specific overload shape that differs.
|
|
174
|
+
createdAt: createdAt as unknown as Temporal.Instant,
|
|
160
175
|
};
|
|
161
176
|
}
|
|
162
177
|
|
package/src/event-store/types.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/event-store-types";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildStorageKey } from "../types";
|
|
3
|
+
|
|
4
|
+
describe("buildStorageKey", () => {
|
|
5
|
+
test("uses the lowercased extension for a normal filename", () => {
|
|
6
|
+
const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "logo.PNG", "u1");
|
|
7
|
+
expect(key).toBe("T1/invoice/1/attachment/u1.png");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("uses the sole segment as extension when there is no dot", () => {
|
|
11
|
+
const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "noext", "u1");
|
|
12
|
+
expect(key).toBe("T1/invoice/1/attachment/u1.noext");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("rejects a path-traversal filename and falls back to bin instead of leaking the payload", () => {
|
|
16
|
+
const key = buildStorageKey(
|
|
17
|
+
"T1" as never,
|
|
18
|
+
"unattached",
|
|
19
|
+
"file",
|
|
20
|
+
"u",
|
|
21
|
+
"a.b/../../../../evil",
|
|
22
|
+
"u1",
|
|
23
|
+
);
|
|
24
|
+
expect(key).toBe("T1/unattached/file/u/u1.bin");
|
|
25
|
+
expect(key).not.toContain("..");
|
|
26
|
+
expect(key.split("/")).toHaveLength(5);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createLocalProvider } from "../local-provider";
|
|
6
|
+
|
|
7
|
+
describe("createLocalProvider path-traversal guard", () => {
|
|
8
|
+
const basePath = join(tmpdir(), `kumiko-local-provider-test-${Date.now()}`);
|
|
9
|
+
const provider = createLocalProvider(basePath);
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await rm(basePath, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("rejects a key with a `..` segment even when it resolves inside basePath", async () => {
|
|
16
|
+
await expect(
|
|
17
|
+
provider.write("T1/e/1/f/x.png/../../../../T2/y.png", new Uint8Array([1])),
|
|
18
|
+
).rejects.toThrow(/path-traversal/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects a `..` segment on read/delete too, and exists() reports false instead of throwing", async () => {
|
|
22
|
+
await expect(provider.read("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
|
|
23
|
+
await expect(provider.delete("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
|
|
24
|
+
expect(await provider.exists("a/../../etc/passwd")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("still allows a normal, contained key", async () => {
|
|
28
|
+
await provider.write("T1/entity/1/field/file.png", new Uint8Array([1, 2, 3]));
|
|
29
|
+
expect(await provider.exists("T1/entity/1/field/file.png")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -153,10 +153,10 @@ describe("FileStorageProvider.writeStream — local-filesystem", () => {
|
|
|
153
153
|
const escapingKey = "../../etc/passwd";
|
|
154
154
|
|
|
155
155
|
await expect(provider.write(escapingKey, new Uint8Array([1]))).rejects.toThrow(
|
|
156
|
-
/
|
|
156
|
+
/path-traversal/,
|
|
157
157
|
);
|
|
158
|
-
await expect(provider.read(escapingKey)).rejects.toThrow(/
|
|
159
|
-
await expect(provider.delete(escapingKey)).rejects.toThrow(/
|
|
158
|
+
await expect(provider.read(escapingKey)).rejects.toThrow(/path-traversal/);
|
|
159
|
+
await expect(provider.delete(escapingKey)).rejects.toThrow(/path-traversal/);
|
|
160
160
|
// exists() treats any stat failure (missing file, escaping key) as "not there".
|
|
161
161
|
expect(await provider.exists(escapingKey)).toBe(false);
|
|
162
162
|
});
|
package/src/files/index.ts
CHANGED
|
@@ -34,6 +34,6 @@ export type {
|
|
|
34
34
|
SignedUrlOptions,
|
|
35
35
|
WriteStreamOptions,
|
|
36
36
|
} from "./types";
|
|
37
|
-
export { buildStorageKey, parseMaxSize, validateFile } from "./types";
|
|
37
|
+
export { assertSafeStorageKey, buildStorageKey, parseMaxSize, validateFile } from "./types";
|
|
38
38
|
export type { ZipEntry } from "./zip-stream";
|
|
39
39
|
export { createZipStream } from "./zip-stream";
|
|
@@ -2,7 +2,7 @@ import { createReadStream, createWriteStream } from "node:fs";
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve, sep } from "node:path";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
|
-
import type
|
|
5
|
+
import { assertSafeStorageKey, type FileStorageProvider } from "./types";
|
|
6
6
|
|
|
7
7
|
// Local-filesystem backend — intended for dev + tests. Production deploys
|
|
8
8
|
// pick an object-store provider (S3/R2/…). mimeType is ignored here; the
|
|
@@ -16,6 +16,11 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
|
|
|
16
16
|
// segment that slipped past an upstream check), regardless of the key's
|
|
17
17
|
// source.
|
|
18
18
|
function resolveContainedPath(key: string): string {
|
|
19
|
+
assertSafeStorageKey(key);
|
|
20
|
+
// assertSafeStorageKey rejects every literal "."/".." segment, which
|
|
21
|
+
// covers all string-based traversal a join+resolve can produce; this
|
|
22
|
+
// startsWith check is a second, independent line of defense in case a
|
|
23
|
+
// future caller feeds resolveContainedPath something pre-resolved.
|
|
19
24
|
const filePath = resolve(join(basePath, key));
|
|
20
25
|
if (filePath !== resolvedBase && !filePath.startsWith(resolvedBase + sep)) {
|
|
21
26
|
throw new Error(`storage key escapes basePath: "${key}"`);
|
package/src/files/types.ts
CHANGED
|
@@ -84,6 +84,12 @@ export function validateFile(
|
|
|
84
84
|
return null;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
export function assertSafeStorageKey(key: string): void {
|
|
88
|
+
if (key.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
89
|
+
throw new Error(`storage key contains a path-traversal segment: "${key}"`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
87
93
|
export function buildStorageKey(
|
|
88
94
|
tenantId: TenantId,
|
|
89
95
|
entityType: string,
|
|
@@ -92,6 +98,7 @@ export function buildStorageKey(
|
|
|
92
98
|
fileName: string,
|
|
93
99
|
uniqueId: string,
|
|
94
100
|
): string {
|
|
95
|
-
const
|
|
101
|
+
const rawExt = fileName.split(".").pop() ?? "";
|
|
102
|
+
const ext = /^[A-Za-z0-9]+$/.test(rawExt) ? rawExt.toLowerCase() : "bin";
|
|
96
103
|
return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uniqueId}.${ext}`;
|
|
97
104
|
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Queue } from "bullmq";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { requestContext } from "../../api/request-context";
|
|
4
5
|
import { createRegistry, defineFeature } from "../../engine";
|
|
5
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
AppContext,
|
|
8
|
+
ConfigAccessor,
|
|
9
|
+
ConfigAccessorFactory,
|
|
10
|
+
NotifyFactory,
|
|
11
|
+
Registry,
|
|
12
|
+
TenantId,
|
|
13
|
+
} from "../../engine/types";
|
|
14
|
+
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
6
15
|
import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
7
16
|
import { sleep, waitFor } from "../../testing";
|
|
8
17
|
import {
|
|
9
18
|
createJobRunner,
|
|
19
|
+
type JobLogEntry,
|
|
10
20
|
type JobMeta,
|
|
11
21
|
type JobRunner,
|
|
12
22
|
type JobRunnerOptions,
|
|
@@ -167,6 +177,30 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
167
177
|
log.child({ probe: true }).info("log-probe-child");
|
|
168
178
|
jobLog.push({ name: "test:job:log-probe", payload, timestamp: Date.now() });
|
|
169
179
|
});
|
|
180
|
+
// job-runner must build ctx.files the same way dispatch-shared.ts does for
|
|
181
|
+
// write-handlers/MSPs — regression guard for #1520 (ctx.files was never
|
|
182
|
+
// materialized for jobs, so ctx.files.ref(...).read() threw for every job).
|
|
183
|
+
r.job("filesProbe", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
184
|
+
const content = await ctx.files!.ref(payload["key"] as string).read();
|
|
185
|
+
jobLog.push({
|
|
186
|
+
name: "test:job:files-probe",
|
|
187
|
+
payload: { text: new TextDecoder().decode(content) },
|
|
188
|
+
timestamp: Date.now(),
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// job-runner must build ctx.notify/ctx.config the same way dispatch-shared.ts
|
|
193
|
+
// does for write-handlers — regression guard for #1532 (only ctx.files got
|
|
194
|
+
// the same fix; ctx.notify/ctx.config stayed undefined for every job).
|
|
195
|
+
r.job("notifyProbe", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
196
|
+
await ctx.notify!("test-notification", { to: payload["toUserId"] as string });
|
|
197
|
+
jobLog.push({ name: "test:job:notify-probe", payload, timestamp: Date.now() });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
r.job("configProbe", { trigger: { manual: true } }, async (_payload, ctx) => {
|
|
201
|
+
const value = await ctx.config!("test:config:probe-key");
|
|
202
|
+
jobLog.push({ name: "test:job:config-probe", payload: { value }, timestamp: Date.now() });
|
|
203
|
+
});
|
|
170
204
|
});
|
|
171
205
|
|
|
172
206
|
beforeAll(async () => {
|
|
@@ -278,6 +312,75 @@ describe("scenario 3: manual trigger", () => {
|
|
|
278
312
|
});
|
|
279
313
|
});
|
|
280
314
|
|
|
315
|
+
describe("scenario 3b: ctx.files resolves through the file provider", () => {
|
|
316
|
+
test("job handler can read back a file via ctx.files.ref(key)", async () => {
|
|
317
|
+
clearLog();
|
|
318
|
+
const provider = createInMemoryFileProvider();
|
|
319
|
+
await provider.write("probe/hello.txt", new TextEncoder().encode("hello from files probe"));
|
|
320
|
+
|
|
321
|
+
await withRunner(
|
|
322
|
+
async (runner) => {
|
|
323
|
+
await runner.dispatch("test:job:files-probe", { key: "probe/hello.txt" });
|
|
324
|
+
await waitFor(() => {
|
|
325
|
+
const entries = jobLog.filter((e) => e.name === "test:job:files-probe");
|
|
326
|
+
expect(entries.length).toBe(1);
|
|
327
|
+
expect(entries[0]?.payload["text"]).toBe("hello from files probe");
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
{ context: { _fileProviderResolver: () => Promise.resolve(provider) } },
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("scenario 3c: ctx.notify resolves through the notify factory (#1532)", () => {
|
|
336
|
+
test("job handler can call ctx.notify and the factory is bound to the job's system user + tenant", async () => {
|
|
337
|
+
clearLog();
|
|
338
|
+
const calls: Array<{ readonly notifierTenantId: string; readonly notificationType: string }> =
|
|
339
|
+
[];
|
|
340
|
+
const notifyFactory: NotifyFactory = (_user, tenantId) => async (notificationType) => {
|
|
341
|
+
calls.push({ notifierTenantId: tenantId, notificationType });
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
await withRunner(
|
|
345
|
+
async (runner) => {
|
|
346
|
+
await runner.dispatch("test:job:notify-probe", { toUserId: "u1" });
|
|
347
|
+
await waitFor(() => {
|
|
348
|
+
const entries = jobLog.filter((e) => e.name === "test:job:notify-probe");
|
|
349
|
+
expect(entries.length).toBe(1);
|
|
350
|
+
});
|
|
351
|
+
expect(calls).toHaveLength(1);
|
|
352
|
+
expect(calls[0]?.notificationType).toBe("test-notification");
|
|
353
|
+
},
|
|
354
|
+
{ context: { _notifyFactory: notifyFactory } },
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("scenario 3d: ctx.config resolves through the config accessor factory (#1532)", () => {
|
|
360
|
+
test("job handler can call ctx.config and the factory receives a tenant-scoped db", async () => {
|
|
361
|
+
clearLog();
|
|
362
|
+
let receivedDb: unknown;
|
|
363
|
+
const configAccessorFactory: ConfigAccessorFactory = (deps) => {
|
|
364
|
+
receivedDb = deps.db;
|
|
365
|
+
// Stub accessor: only the string-key overload is exercised here.
|
|
366
|
+
return (async () => "probe-value") as ConfigAccessor;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
await withRunner(
|
|
370
|
+
async (runner) => {
|
|
371
|
+
await runner.dispatch("test:job:config-probe", {});
|
|
372
|
+
await waitFor(() => {
|
|
373
|
+
const entries = jobLog.filter((e) => e.name === "test:job:config-probe");
|
|
374
|
+
expect(entries.length).toBe(1);
|
|
375
|
+
expect(entries[0]?.payload["value"]).toBe("probe-value");
|
|
376
|
+
});
|
|
377
|
+
expect(receivedDb).toBeDefined();
|
|
378
|
+
},
|
|
379
|
+
{ context: { _configAccessorFactory: configAccessorFactory, db: {} as AppContext["db"] } },
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
281
384
|
// --- Concurrency modes ---
|
|
282
385
|
|
|
283
386
|
describe("concurrency: parallel", () => {
|
|
@@ -464,6 +567,7 @@ describe("concurrency: replace", () => {
|
|
|
464
567
|
test("enqueuer-only: each dispatch removes prior waiting peers", async () => {
|
|
465
568
|
// No consumer → jobs stay waiting. The replace branch walks getWaiting()
|
|
466
569
|
// and removes same-name peers before add — so three dispatches leave one.
|
|
570
|
+
const queueNamePrefix = `kumiko-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
467
571
|
await withRunner(
|
|
468
572
|
async (runner) => {
|
|
469
573
|
const id1 = await runner.dispatch("test:job:replace-job", { n: 1 });
|
|
@@ -474,8 +578,21 @@ describe("concurrency: replace", () => {
|
|
|
474
578
|
expect(id3).toBeDefined();
|
|
475
579
|
expect(id1).not.toBe("skipped");
|
|
476
580
|
expect(id3).not.toBe(id1);
|
|
581
|
+
|
|
582
|
+
const queue = new Queue(`${queueNamePrefix}-worker`, {
|
|
583
|
+
connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
|
|
584
|
+
});
|
|
585
|
+
try {
|
|
586
|
+
const waiting = (await queue.getWaiting()).filter(
|
|
587
|
+
(job) => job.name === "test:job:replace-job",
|
|
588
|
+
);
|
|
589
|
+
expect(waiting).toHaveLength(1);
|
|
590
|
+
expect(waiting[0]?.data.n).toBe(3);
|
|
591
|
+
} finally {
|
|
592
|
+
await queue.close();
|
|
593
|
+
}
|
|
477
594
|
},
|
|
478
|
-
{ consumerLane: undefined },
|
|
595
|
+
{ consumerLane: undefined, queueNamePrefix },
|
|
479
596
|
);
|
|
480
597
|
});
|
|
481
598
|
});
|
|
@@ -553,6 +670,36 @@ describe("job logger", () => {
|
|
|
553
670
|
});
|
|
554
671
|
});
|
|
555
672
|
});
|
|
673
|
+
|
|
674
|
+
// Inspects the actual createJobLogger output instead of only asserting the
|
|
675
|
+
// handler ran — a child() that drops its context or a warn() mapped to
|
|
676
|
+
// debug would previously stay green.
|
|
677
|
+
test("info/warn/error land with the right level; debug is dropped; child merges its context", async () => {
|
|
678
|
+
clearLog();
|
|
679
|
+
let capturedLogs: JobLogEntry[] = [];
|
|
680
|
+
await withRunner(
|
|
681
|
+
async (runner) => {
|
|
682
|
+
await runner.dispatch("test:job:log-probe", { n: 1 });
|
|
683
|
+
await waitFor(() => {
|
|
684
|
+
expect(capturedLogs.length).toBeGreaterThan(0);
|
|
685
|
+
});
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
onJobComplete: (jobName, _id, _duration, logs) => {
|
|
689
|
+
if (jobName === "test:job:log-probe") capturedLogs = logs;
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// debug() is a no-op by design (JobLogEntry.level has no "debug" slot) —
|
|
695
|
+
// info/warn/error from the handler body, plus one from child().info(...).
|
|
696
|
+
expect(capturedLogs).toHaveLength(4);
|
|
697
|
+
expect(capturedLogs.map((l) => l.level)).toEqual(["info", "warn", "error", "info"]);
|
|
698
|
+
expect(capturedLogs[0]?.message).toContain("log-probe-info");
|
|
699
|
+
expect(capturedLogs[3]?.message).toContain("log-probe-child");
|
|
700
|
+
expect(capturedLogs[3]?.message).toContain("probe");
|
|
701
|
+
expect(capturedLogs[3]?.message).toContain("true");
|
|
702
|
+
});
|
|
556
703
|
});
|
|
557
704
|
|
|
558
705
|
describe("correlation propagation", () => {
|
|
@@ -671,7 +818,8 @@ describe("error handling", () => {
|
|
|
671
818
|
|
|
672
819
|
test("perTenant with getActiveTenantIds fans out one run per tenant", async () => {
|
|
673
820
|
clearLog();
|
|
674
|
-
|
|
821
|
+
// @cast-boundary test fixture: TenantId is a branded string.
|
|
822
|
+
const tenants = ["per-tenant-fanout-a", "per-tenant-fanout-b"] as TenantId[];
|
|
675
823
|
await withRunner(
|
|
676
824
|
async (runner) => {
|
|
677
825
|
await runner.dispatch("test:job:per-tenant-fanout", { n: 7 });
|
|
@@ -693,11 +841,23 @@ describe("handleEvent maxPerTenant", () => {
|
|
|
693
841
|
const user = { ...TestUsers.admin, tenantId: "cap-tenant-evt" };
|
|
694
842
|
await runner.handleEvent("test:write:capped-event", { n: 1 }, user);
|
|
695
843
|
await runner.handleEvent("test:write:capped-event", { n: 2 }, user);
|
|
696
|
-
|
|
844
|
+
await waitFor(() => {
|
|
845
|
+
const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
|
|
846
|
+
expect(started.length).toBe(2);
|
|
847
|
+
});
|
|
848
|
+
// Third must be skipped by the maxPerTenant guard. handleEvent's cap
|
|
849
|
+
// check runs synchronously before this call resolves, but a wrongly
|
|
850
|
+
// enqueued job still needs the worker to pick it up and push its
|
|
851
|
+
// jobLog entry — polling several times (failing at the first sign of
|
|
852
|
+
// a 3rd start) is more CI-load-tolerant than a single fixed sleep(200)
|
|
853
|
+
// racing against the handler's own 500ms sleep, which could land
|
|
854
|
+
// outside the window on a starved runner and silently pass.
|
|
697
855
|
await runner.handleEvent("test:write:capped-event", { n: 3 }, user);
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
856
|
+
for (const delayMs of [50, 100, 200, 400]) {
|
|
857
|
+
await sleep(delayMs);
|
|
858
|
+
const startedNow = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
|
|
859
|
+
expect(startedNow.length).toBe(2);
|
|
860
|
+
}
|
|
701
861
|
});
|
|
702
862
|
});
|
|
703
863
|
});
|