@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.
Files changed (146) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +32 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +267 -19
  5. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  6. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  7. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
  8. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  9. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  13. package/src/api/__tests__/sse-broker.test.ts +57 -0
  14. package/src/api/__tests__/sse-route.test.ts +4 -0
  15. package/src/api/auth-routes.ts +165 -33
  16. package/src/api/index.ts +1 -0
  17. package/src/api/jwt.ts +22 -1
  18. package/src/api/routes.ts +103 -35
  19. package/src/api/server.ts +17 -1
  20. package/src/api/sse-broker.ts +39 -0
  21. package/src/bun-db/index.ts +1 -0
  22. package/src/bun-db/query.ts +12 -3
  23. package/src/consumer-cli.ts +60 -13
  24. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
  25. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  26. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  27. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  28. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  29. package/src/db/api.ts +2 -2
  30. package/src/db/bun-provider.ts +2 -2
  31. package/src/db/connection.ts +6 -3
  32. package/src/db/dialect.ts +1 -6
  33. package/src/db/entity-table-meta-types.ts +1 -1
  34. package/src/db/event-store-executor-context.ts +2 -3
  35. package/src/db/event-store-executor-read.ts +2 -3
  36. package/src/db/event-store-executor-write.ts +8 -0
  37. package/src/db/index.ts +8 -1
  38. package/src/db/located-timestamp.ts +4 -0
  39. package/src/db/migrate-runner.ts +107 -11
  40. package/src/db/pg-error.ts +8 -0
  41. package/src/db/postgres-provider.ts +2 -2
  42. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  43. package/src/db/queries/ddl.ts +45 -0
  44. package/src/db/queries/event-store.ts +97 -5
  45. package/src/db/queries/test-stack.ts +4 -30
  46. package/src/db/reference-data.ts +2 -3
  47. package/src/db/replay-migration-sql.ts +114 -12
  48. package/src/db/tenant-db.ts +2 -4
  49. package/src/engine/__tests__/engine.test.ts +30 -0
  50. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  51. package/src/engine/__tests__/store-table.test.ts +2 -2
  52. package/src/engine/boot-validator/nav.ts +5 -0
  53. package/src/engine/constants.ts +32 -6
  54. package/src/engine/create-app.ts +11 -0
  55. package/src/engine/effective-features.ts +12 -2
  56. package/src/engine/extensions/user-data.ts +12 -4
  57. package/src/engine/feature-ui-extensions.ts +2 -2
  58. package/src/engine/hook-helpers.ts +3 -1
  59. package/src/engine/index.ts +1 -1
  60. package/src/engine/ownership.ts +4 -3
  61. package/src/engine/registry-ingest.ts +14 -14
  62. package/src/engine/registry-state.ts +4 -1
  63. package/src/engine/schema-builder.ts +1 -0
  64. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  65. package/src/engine/steps/_duration-utils.ts +2 -0
  66. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  67. package/src/engine/types/config.ts +1 -1
  68. package/src/engine/types/define-handler.ts +1 -1
  69. package/src/engine/types/entity-handlers.ts +1 -1
  70. package/src/engine/types/event-type-map.ts +1 -1
  71. package/src/engine/types/feature.ts +1 -1
  72. package/src/engine/types/fields.ts +1 -1
  73. package/src/engine/types/handlers.ts +1 -1
  74. package/src/engine/types/hooks.ts +1 -1
  75. package/src/engine/types/http-route.ts +1 -1
  76. package/src/engine/types/nav.ts +1 -1
  77. package/src/engine/types/ownership.ts +1 -1
  78. package/src/engine/types/projection.ts +1 -1
  79. package/src/engine/types/relations.ts +1 -1
  80. package/src/engine/types/screen.ts +1 -1
  81. package/src/engine/types/step.ts +1 -1
  82. package/src/engine/types/target-ref.ts +1 -1
  83. package/src/engine/types/tree-node.ts +1 -1
  84. package/src/engine/types/workspace.ts +1 -1
  85. package/src/engine/validate-projection-allowlist.ts +5 -5
  86. package/src/errors/classes.ts +21 -0
  87. package/src/errors/index.ts +1 -0
  88. package/src/errors/write-error-info.ts +6 -2
  89. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  90. package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
  91. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
  92. package/src/event-store/admin-api.ts +11 -4
  93. package/src/event-store/event-store.ts +19 -4
  94. package/src/event-store/types.ts +1 -1
  95. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  96. package/src/files/__tests__/local-provider.test.ts +31 -0
  97. package/src/files/__tests__/write-stream.test.ts +3 -3
  98. package/src/files/index.ts +1 -1
  99. package/src/files/local-provider.ts +6 -1
  100. package/src/files/types.ts +8 -1
  101. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  102. package/src/jobs/job-runner.ts +41 -11
  103. package/src/logging/types.ts +1 -1
  104. package/src/observability/index.ts +1 -0
  105. package/src/observability/standard-metrics.ts +35 -2
  106. package/src/observability/types/index.ts +1 -1
  107. package/src/observability/types/metric.ts +1 -1
  108. package/src/observability/types/provider.ts +1 -1
  109. package/src/observability/types/span.ts +1 -1
  110. package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
  111. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  112. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  113. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
  114. package/src/pipeline/dispatch-shared.ts +59 -6
  115. package/src/pipeline/dispatch-stream.ts +32 -11
  116. package/src/pipeline/dispatcher.ts +7 -1
  117. package/src/pipeline/event-consumer-state.ts +16 -13
  118. package/src/pipeline/event-dispatcher-delivery.ts +20 -6
  119. package/src/pipeline/event-dispatcher.ts +22 -0
  120. package/src/pipeline/index.ts +2 -0
  121. package/src/pipeline/system-hooks.ts +87 -0
  122. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  123. package/src/rate-limit/resolver.ts +6 -2
  124. package/src/schema-cli.ts +24 -12
  125. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  126. package/src/search/reindex-entity.ts +31 -2
  127. package/src/search/types.ts +1 -1
  128. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  129. package/src/stack/db.ts +2 -1
  130. package/src/stack/push-entity-projection-tables.ts +2 -1
  131. package/src/stack/request-helper.ts +20 -1
  132. package/src/stack/table-helpers.ts +6 -4
  133. package/src/stack/test-stack.ts +18 -15
  134. package/src/testing/__tests__/late-bound.test.ts +7 -0
  135. package/src/testing/__tests__/wait-for.test.ts +6 -0
  136. package/src/testing/file-provider-contract.ts +26 -6
  137. package/src/testing/index.ts +1 -0
  138. package/src/testing/late-bound.ts +5 -3
  139. package/src/testing/wait-for.ts +3 -0
  140. package/src/testing/without-ambient-temporal.ts +14 -0
  141. package/src/time/geo-tz.ts +1 -1
  142. package/src/time/polyfill.ts +21 -38
  143. package/src/time/tz-context.ts +37 -31
  144. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  145. package/src/utils/safe-json.ts +13 -1
  146. 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
- const glob = new Glob("packages/{framework,bundled-features}/src/**/*.ts");
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
- if (RESTRICTED_SYMBOLS.some((symbol) => content.includes(symbol))) {
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 new VersionConflictError(event.aggregateId, event.expectedVersion);
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 new VersionConflictError(firstEvent.aggregateId, firstEvent.expectedVersion);
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
- createdAt:
157
- row.created_at instanceof Date
158
- ? Temporal.Instant.fromEpochMilliseconds(row.created_at.getTime())
159
- : Temporal.Instant.from(row.created_at),
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
 
@@ -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
- /escapes basePath/,
156
+ /path-traversal/,
157
157
  );
158
- await expect(provider.read(escapingKey)).rejects.toThrow(/escapes basePath/);
159
- await expect(provider.delete(escapingKey)).rejects.toThrow(/escapes basePath/);
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
  });
@@ -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 { FileStorageProvider } from "./types";
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}"`);
@@ -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 ext = fileName.split(".").pop() ?? "bin";
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 { AppContext, Registry } from "../../engine/types";
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
- const tenants = [101, 102];
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
- // Third must be skipped by the maxPerTenant guard.
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
- await sleep(200);
699
- const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
700
- expect(started.length).toBeLessThanOrEqual(2);
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
  });