@cosmicdrift/kumiko-framework 0.165.0 → 0.165.1

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 (147) 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 +146 -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 +178 -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 +26 -2
  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 +43 -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/__tests__/polyfill-reinstall.test.ts +17 -0
  142. package/src/time/geo-tz.ts +1 -1
  143. package/src/time/polyfill.ts +28 -39
  144. package/src/time/tz-context.ts +30 -24
  145. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
  146. package/src/utils/safe-json.ts +3 -2
  147. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/define-handler";
2
+ export type * from "@cosmicdrift/kumiko-types/define-handler";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/entity-handlers";
2
+ export type * from "@cosmicdrift/kumiko-types/entity-handlers";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/event-type-map";
1
+ export type * from "@cosmicdrift/kumiko-types/event-type-map";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/feature";
2
+ export type * from "@cosmicdrift/kumiko-types/feature";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/fields";
2
+ export type * from "@cosmicdrift/kumiko-types/fields";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/handlers";
2
+ export type * from "@cosmicdrift/kumiko-types/handlers";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/hooks";
2
+ export type * from "@cosmicdrift/kumiko-types/hooks";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/http-route";
1
+ export type * from "@cosmicdrift/kumiko-types/http-route";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/nav";
2
+ export type * from "@cosmicdrift/kumiko-types/nav";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/ownership";
2
+ export type * from "@cosmicdrift/kumiko-types/ownership";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/projection";
2
+ export type * from "@cosmicdrift/kumiko-types/projection";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/relations";
1
+ export type * from "@cosmicdrift/kumiko-types/relations";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/screen";
2
+ export type * from "@cosmicdrift/kumiko-types/screen";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/step";
2
+ export type * from "@cosmicdrift/kumiko-types/step";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/target-ref";
1
+ export type * from "@cosmicdrift/kumiko-types/target-ref";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/tree-node";
1
+ export type * from "@cosmicdrift/kumiko-types/tree-node";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/workspace";
2
+ export type * from "@cosmicdrift/kumiko-types/workspace";
@@ -23,6 +23,10 @@
23
23
  // caught by this validator. A future lint-rule will enforce the contract
24
24
  // statically; today it lives in this comment + the StepBuilder doc.
25
25
 
26
+ import {
27
+ KUMIKO_META_SYMBOL,
28
+ KUMIKO_NAME_SYMBOL,
29
+ } from "@cosmicdrift/kumiko-types/schema-table-types";
26
30
  import { getStep } from "./define-step";
27
31
  import { buildPipelineSteps } from "./pipeline";
28
32
  import type { FeatureDefinition, SessionUser, TenantId, WriteEvent } from "./types";
@@ -55,13 +59,9 @@ function* walkAllSteps(steps: readonly StepInstance[]): Generator<StepInstance,
55
59
  }
56
60
  }
57
61
 
58
- // @cast-boundary drizzle-bridge — reads table name from a Symbol without
59
- // importing drizzle-orm (bun-db pattern, see bun-db/query.ts).
60
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
61
62
  // table()/buildEntityTable spread column handles as enumerable props, so an
62
63
  // entity field named `tableName`/`source` would shadow the matching meta key —
63
- // the canonical meta under this symbol is the only collision-safe source.
64
- const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
64
+ // the canonical meta under KUMIKO_META_SYMBOL is the only collision-safe source.
65
65
 
66
66
  function resolveTableNameFromStep(table: unknown): string {
67
67
  if (typeof table === "object" && table !== null) {
@@ -138,6 +138,27 @@ export class VersionConflictError extends ConflictError {
138
138
  }
139
139
  }
140
140
 
141
+ // The caller retried a write with an idempotencyKey it had already used —
142
+ // the event-store's partial unique index on metadata.idempotencyKey caught
143
+ // the duplicate. Distinct from unique_violation/version_conflict: this is
144
+ // not an error the caller should retry, it means the earlier attempt
145
+ // already succeeded and this one is a no-op replay.
146
+ export class IdempotentReplayError extends ConflictError {
147
+ override readonly code: string = "idempotent_replay";
148
+
149
+ constructor(
150
+ details: { readonly idempotencyKey: string },
151
+ opts?: Pick<ErrorOpts, "i18nKey" | "cause">,
152
+ ) {
153
+ super({
154
+ message: `event with idempotencyKey "${details.idempotencyKey}" was already appended`,
155
+ i18nKey: opts?.i18nKey ?? "errors.idempotentReplay",
156
+ details,
157
+ ...(opts?.cause && { cause: opts.cause }),
158
+ });
159
+ }
160
+ }
161
+
141
162
  // Entity-level unique-index violation. Distinct from VersionConflictError:
142
163
  // version_conflict means "two writers raced on the events_aggregate_version
143
164
  // _uq index" (optimistic-concurrency); unique_violation means "you tried to
@@ -13,6 +13,7 @@ export {
13
13
  AccessDeniedError,
14
14
  ConflictError,
15
15
  FeatureDisabledError,
16
+ IdempotentReplayError,
16
17
  InternalError,
17
18
  NotFoundError,
18
19
  RateLimitError,
@@ -7,8 +7,12 @@ import { KumikoError } from "./kumiko-error";
7
7
  import { FrameworkReasons } from "./reasons";
8
8
  import { buildInvalidTransitionDetails } from "./transition-details";
9
9
 
10
- // Legacy types — re-exported for callers still importing this module directly.
11
- export * from "@cosmicdrift/kumiko-types/write-error-info-types";
10
+ // Re-exported so a caller importing this module directly (instead of via
11
+ // the `./errors` barrel, which already re-exports these by name) still
12
+ // gets the types — explicit, not a wildcard: this module isn't a public
13
+ // subpath export (see package.json `exports`), so the only readers are
14
+ // framework-internal.
15
+ export type { WriteErrorInfo, WriteFailure };
12
16
 
13
17
  // Convenience for call sites that return a failed WriteResult. Keeps the
14
18
  // pattern `return writeFailure(new NotFoundError(...))` compact so handlers
@@ -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
  }