@cosmicdrift/kumiko-framework 0.200.0 → 0.201.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 (31) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
  3. package/src/api/__tests__/api.test.ts +116 -1
  4. package/src/api/__tests__/batch.integration.test.ts +53 -0
  5. package/src/api/__tests__/body-limit.test.ts +16 -0
  6. package/src/api/route-registrars.ts +4 -3
  7. package/src/api/routes.ts +47 -1
  8. package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
  9. package/src/db/tenant-db.ts +46 -2
  10. package/src/engine/entity-handlers.ts +8 -1
  11. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
  12. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
  13. package/src/engine/feature-ast/__tests__/patch.test.ts +98 -0
  14. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
  15. package/src/engine/feature-ast/extractors/events.ts +5 -3
  16. package/src/engine/feature-ast/extractors/round3.ts +5 -3
  17. package/src/engine/feature-ast/extractors/round5.ts +5 -4
  18. package/src/engine/feature-ast/extractors/shared.ts +29 -4
  19. package/src/engine/feature-ast/patch.ts +28 -21
  20. package/src/engine/feature-ast/patterns.ts +18 -0
  21. package/src/engine/feature-ast/render.ts +19 -6
  22. package/src/engine/index.ts +1 -0
  23. package/src/files/__tests__/files.integration.test.ts +97 -1
  24. package/src/files/file-routes.ts +10 -2
  25. package/src/files/types.ts +72 -0
  26. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
  27. package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
  28. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
  29. package/src/pipeline/dispatch-batch.ts +11 -5
  30. package/src/pipeline/dispatch-shared.ts +42 -16
  31. package/src/pipeline/idempotency.ts +91 -30
@@ -33,7 +33,7 @@ import type { FileRoutesOptions } from "../file-routes";
33
33
  import { createInMemoryFileProvider } from "../in-memory-provider";
34
34
  import { createLocalProvider } from "../local-provider";
35
35
  import type { FileStorageProvider } from "../types";
36
- import { parseMaxSize, validateFile } from "../types";
36
+ import { parseMaxSize, sniffMimeType, validateFile } from "../types";
37
37
 
38
38
  // UUID for "this row doesn't exist" assertions. Valid v4 format so PG accepts
39
39
  // the query — the row just isn't there. Pre-v1 files-feature tests used
@@ -199,6 +199,28 @@ describe("file validation", () => {
199
199
  validateFile({ fileName: "a.jpg", mimeType: "image/jpg", size: 100 }, { accept: ["jpg"] }),
200
200
  ).toBeNull();
201
201
  });
202
+
203
+ test("sniffMimeType recognizes gif, webp and pdf signatures", () => {
204
+ expect(sniffMimeType(new TextEncoder().encode("GIF89a" + "x".repeat(20)))).toBe("image/gif");
205
+ const webp = new Uint8Array([
206
+ ...new TextEncoder().encode("RIFF"),
207
+ 0,
208
+ 0,
209
+ 0,
210
+ 0,
211
+ ...new TextEncoder().encode("WEBP"),
212
+ ...Array(20).fill(0),
213
+ ]);
214
+ expect(sniffMimeType(webp)).toBe("image/webp");
215
+ expect(sniffMimeType(new TextEncoder().encode("%PDF-1.4" + "x".repeat(20)))).toBe(
216
+ "application/pdf",
217
+ );
218
+ });
219
+
220
+ test("sniffMimeType returns null for unrecognized or too-short byte sequences", () => {
221
+ expect(sniffMimeType(new Uint8Array([0x00, 0x01, 0x02]))).toBeNull();
222
+ expect(sniffMimeType(new TextEncoder().encode("hello world"))).toBeNull();
223
+ });
202
224
  });
203
225
 
204
226
  // --- Integration: Upload → Download → Delete via real HTTP API ---
@@ -312,6 +334,80 @@ describe("file upload flow via API", () => {
312
334
  });
313
335
  });
314
336
 
337
+ // --- Serving hardens Content-Type against a spoofed/mismatched declared MIME ---
338
+
339
+ describe("download Content-Type is sniffed from bytes, not trusted from the declared MIME", () => {
340
+ const testPngContent = new Uint8Array([
341
+ 0x89,
342
+ 0x50,
343
+ 0x4e,
344
+ 0x47,
345
+ 0x0d,
346
+ 0x0a,
347
+ 0x1a,
348
+ 0x0a,
349
+ ...Array(50).fill(0),
350
+ ]);
351
+ const testJpegContent = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(50).fill(0)]);
352
+
353
+ test("real PNG bytes declared as text/html are served as octet-stream, never text/html", async () => {
354
+ // Bun's own Request#formData() derives File#type from the *filename*
355
+ // extension, not from the multipart part's declared Content-Type header
356
+ // — so the fileName here (.html) is what actually lands as the stored
357
+ // mimeType; the mismatch this test needs is against the real PNG bytes.
358
+ const uploadRes = await uploadFile(adminUser, "sneaky.html", testPngContent, "text/html");
359
+ // Upload itself is not rejected — mismatch enforcement stays at the
360
+ // serving side, existing uploads with an off mimeType keep working.
361
+ expect(uploadRes.status).toBe(201);
362
+ const { id } = await uploadRes.json();
363
+
364
+ const res = await getFile(adminUser, id);
365
+ expect(res.status).toBe(200);
366
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
367
+ expect(res.headers.get("Content-Disposition")).toContain("attachment");
368
+ });
369
+
370
+ test("actual HTML bytes are never served as text/html or image/svg+xml", async () => {
371
+ const htmlBytes = new TextEncoder().encode(
372
+ "<html><body><script>alert(document.domain)</script></body></html>",
373
+ );
374
+ const uploadRes = await uploadFile(adminUser, "evil.html", htmlBytes, "text/html");
375
+ expect(uploadRes.status).toBe(201);
376
+ const { id } = await uploadRes.json();
377
+
378
+ const res = await getFile(adminUser, id);
379
+ expect(res.status).toBe(200);
380
+ expect(res.headers.get("Content-Type")).not.toBe("text/html");
381
+ expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
382
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
383
+ expect(res.headers.get("Content-Disposition")).toContain("attachment");
384
+ });
385
+
386
+ test("an SVG upload — bytes and declared MIME both image/svg+xml — is still downgraded to octet-stream, never served inline", async () => {
387
+ const svgBytes = new TextEncoder().encode(
388
+ '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
389
+ );
390
+ const uploadRes = await uploadFile(adminUser, "logo.svg", svgBytes, "image/svg+xml");
391
+ expect(uploadRes.status).toBe(201);
392
+ const { id } = await uploadRes.json();
393
+
394
+ const res = await getFile(adminUser, id);
395
+ expect(res.status).toBe(200);
396
+ expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
397
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
398
+ });
399
+
400
+ test("regression: a real allowed image type with a correctly declared MIME still serves with the matching sniffed Content-Type", async () => {
401
+ const uploadRes = await uploadFile(adminUser, "photo.jpg", testJpegContent, "image/jpeg");
402
+ expect(uploadRes.status).toBe(201);
403
+ const { id } = await uploadRes.json();
404
+
405
+ const res = await getFile(adminUser, id);
406
+ expect(res.status).toBe(200);
407
+ expect(res.headers.get("Content-Type")).toBe("image/jpeg");
408
+ });
409
+ });
410
+
315
411
  // --- Cross-user access within a tenant (attached file owner-scope) ---
316
412
 
317
413
  describe("attached file owner-scope", () => {
@@ -18,7 +18,7 @@ import { createFileContext } from "./file-handle";
18
18
  import { fileRefEntity } from "./file-ref-entity";
19
19
  import { fileRefsTable } from "./file-ref-table";
20
20
  import type { FileProviderResolver } from "./provider-resolver";
21
- import { buildStorageKey, validateFile } from "./types";
21
+ import { buildStorageKey, resolveServedContentType, validateFile } from "./types";
22
22
 
23
23
  // Decision returned by a FileAccessGuard — distinct from boolean so callers
24
24
  // can't accidentally negate or default it.
@@ -234,11 +234,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
234
234
 
235
235
  const storageProvider = await options.resolveProvider(user.tenantId);
236
236
  const data = await storageProvider.read(fileRef.storageKey);
237
+ // The stored mimeType is client-declared (file.type off the upload) and
238
+ // never verified against the bytes — serving it as-is would let a
239
+ // client label real HTML/SVG as e.g. image/png to slip active content
240
+ // out under a trusted-looking Content-Type. Sniff the actual bytes
241
+ // instead; anything that isn't a known-safe binary signature (including
242
+ // svg/html, which have none) goes out as application/octet-stream.
243
+ const contentType = resolveServedContentType(data, fileRef.mimeType);
237
244
  return new Response(Buffer.from(data), {
238
245
  headers: {
239
- "Content-Type": fileRef.mimeType,
246
+ "Content-Type": contentType,
240
247
  "Content-Disposition": buildContentDispositionHeader(fileRef.fileName),
241
248
  "Content-Length": String(fileRef.size),
249
+ "X-Content-Type-Options": "nosniff",
242
250
  },
243
251
  });
244
252
  });
@@ -52,6 +52,78 @@ const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
52
52
  md: ["text/markdown", "text/plain"],
53
53
  } satisfies Record<string, readonly string[]>;
54
54
 
55
+ // Magic-byte signatures for the subset of EXTENSION_MIME_WHITELIST that has
56
+ // a reliable binary signature. Used at SERVE time — never trust the stored/
57
+ // client-declared mimeType for Content-Type on its own, sniff the actual
58
+ // bytes instead. Types without a stable signature (svg, txt, csv, json, md)
59
+ // and anything that matches none of these fall back to
60
+ // application/octet-stream in resolveServedContentType below — this also
61
+ // guarantees text/html and image/svg+xml are never served inline, even for
62
+ // an honestly-declared upload.
63
+ const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{
64
+ readonly mimeType: string;
65
+ readonly matches: (bytes: Uint8Array) => boolean;
66
+ }> = [
67
+ {
68
+ mimeType: "image/png",
69
+ matches: (bytes) => startsWithBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
70
+ },
71
+ { mimeType: "image/jpeg", matches: (bytes) => startsWithBytes(bytes, [0xff, 0xd8, 0xff]) },
72
+ {
73
+ mimeType: "image/gif",
74
+ matches: (bytes) => startsWithAscii(bytes, "GIF87a") || startsWithAscii(bytes, "GIF89a"),
75
+ },
76
+ {
77
+ mimeType: "image/webp",
78
+ matches: (bytes) => startsWithAscii(bytes, "RIFF") && startsWithAscii(bytes, "WEBP", 8),
79
+ },
80
+ { mimeType: "application/pdf", matches: (bytes) => startsWithAscii(bytes, "%PDF-") },
81
+ ];
82
+
83
+ function startsWithBytes(bytes: Uint8Array, signature: readonly number[]): boolean {
84
+ if (bytes.length < signature.length) return false;
85
+ return signature.every((byte, i) => bytes[i] === byte);
86
+ }
87
+
88
+ function startsWithAscii(bytes: Uint8Array, ascii: string, offset = 0): boolean {
89
+ if (bytes.length < offset + ascii.length) return false;
90
+ for (let i = 0; i < ascii.length; i++) {
91
+ if (bytes[offset + i] !== ascii.charCodeAt(i)) return false;
92
+ }
93
+ return true;
94
+ }
95
+
96
+ export function sniffMimeType(bytes: Uint8Array): string | null {
97
+ for (const signature of MAGIC_BYTE_SIGNATURES) {
98
+ if (signature.matches(bytes)) return signature.mimeType;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ // image/jpg is not a registered IANA type but EXTENSION_MIME_WHITELIST above
104
+ // accepts it as a jpg alias — sniffMimeType only ever returns the canonical
105
+ // image/jpeg, so without this a legitimately-declared "image/jpg" upload
106
+ // would mismatch its own sniffed type and get needlessly downgraded.
107
+ const DECLARED_MIME_ALIASES: Readonly<Record<string, string>> = {
108
+ "image/jpg": "image/jpeg",
109
+ };
110
+
111
+ // The Content-Type a served file's bytes are safe to go out with. Bytes that
112
+ // don't sniff as one of the known-safe binary signatures (including
113
+ // svg/html/text formats, which have no reliable magic bytes) always
114
+ // downgrade to application/octet-stream. Bytes that DO sniff safely still
115
+ // downgrade unless the sniffed type matches what was declared at upload —
116
+ // a mismatch (e.g. real PNG bytes uploaded as "text/html") is itself a
117
+ // spoofing signal and is never trusted enough to pick a Content-Type from,
118
+ // even one that would otherwise be harmless.
119
+ export function resolveServedContentType(bytes: Uint8Array, declaredMimeType: string): string {
120
+ const sniffed = sniffMimeType(bytes);
121
+ if (!sniffed) return "application/octet-stream";
122
+ const normalizedDeclared = declaredMimeType.toLowerCase().split(";")[0]?.trim() ?? "";
123
+ const declared = DECLARED_MIME_ALIASES[normalizedDeclared] ?? normalizedDeclared;
124
+ return sniffed === declared ? sniffed : "application/octet-stream";
125
+ }
126
+
55
127
  export function validateFile(
56
128
  metadata: FileMetadata,
57
129
  options: FileValidationOptions,
@@ -14,7 +14,14 @@ const systemScopedFeature = defineFeature("ctxsystemdb-system", (r) => {
14
14
  z.object({}),
15
15
  async (query, ctx) => {
16
16
  if (!ctx.systemDb)
17
- return { present: false as const, tenantIdMatches: false, dbThrows: false };
17
+ return {
18
+ present: false as const,
19
+ tenantIdMatches: false,
20
+ dbThrows: false,
21
+ dbOutsideTransactionPresent: false,
22
+ dbOutsideTransactionThrows: false,
23
+ outsideTransactionTenantIdMatches: false,
24
+ };
18
25
  // ctx.db is fail-closed for r.systemScope() handlers — dispatch-shared.ts
19
26
  // builds `as HandlerContext`, so a mis-wired property wouldn't be caught
20
27
  // by tsc. Prove it at runtime: assertTenantMatch must hand back a
@@ -27,10 +34,31 @@ const systemScopedFeature = defineFeature("ctxsystemdb-system", (r) => {
27
34
  } catch {
28
35
  dbThrows = true;
29
36
  }
37
+ // Same fail-closed proof for ctx.dbOutsideTransaction — the second,
38
+ // previously-unguarded door #2118 closes. Its guarded escape hatch is
39
+ // ctx.systemDb.outsideTransaction, not ctx.systemDb itself. The guard
40
+ // is a Proxy (truthy), so `?.` here would mask the two regressions
41
+ // this test needs to tell apart: "field silently undefined" vs. "field
42
+ // holds a working, unfiltered db" both look identical under `?.`.
43
+ // Check presence without touching a proxy property (`!==` on the field
44
+ // itself doesn't trap), then force the property read separately.
45
+ const dbOutsideTransactionPresent = ctx.dbOutsideTransaction !== undefined;
46
+ let dbOutsideTransactionThrows = false;
47
+ try {
48
+ void ctx.dbOutsideTransaction!.tenantId;
49
+ } catch {
50
+ dbOutsideTransactionThrows = true;
51
+ }
52
+ const checkedOutsideTx = ctx.systemDb.outsideTransaction.assertTenantMatch(
53
+ query.user.tenantId,
54
+ );
30
55
  return {
31
56
  present: true as const,
32
57
  tenantIdMatches: checked.tenantId === query.user.tenantId,
33
58
  dbThrows,
59
+ dbOutsideTransactionPresent,
60
+ dbOutsideTransactionThrows,
61
+ outsideTransactionTenantIdMatches: checkedOutsideTx.tenantId === query.user.tenantId,
34
62
  };
35
63
  },
36
64
  { access: { roles: ["Admin"] } },
@@ -44,6 +72,7 @@ const tenantScopedFeature = defineFeature("ctxsystemdb-tenant", (r) => {
44
72
  async (query, ctx) => ({
45
73
  present: ctx.systemDb !== undefined,
46
74
  dbWorks: ctx.db.tenantId === query.user.tenantId,
75
+ dbOutsideTransactionWorks: ctx.dbOutsideTransaction?.tenantId === query.user.tenantId,
47
76
  }),
48
77
  { access: { roles: ["Admin"] } },
49
78
  );
@@ -61,24 +90,31 @@ afterAll(async () => {
61
90
  });
62
91
 
63
92
  describe("ctx.systemDb", () => {
64
- test("is present for r.systemScope() handlers; ctx.db is fail-closed there", async () => {
93
+ test("is present for r.systemScope() handlers; ctx.db and ctx.dbOutsideTransaction are both fail-closed there", async () => {
65
94
  const result = await stack.http.queryOk<{
66
95
  present: boolean;
67
96
  tenantIdMatches: boolean;
68
97
  dbThrows: boolean;
98
+ dbOutsideTransactionPresent: boolean;
99
+ dbOutsideTransactionThrows: boolean;
100
+ outsideTransactionTenantIdMatches: boolean;
69
101
  }>("ctxsystemdb-system:query:check", {}, admin);
70
102
  expect(result.present).toBe(true);
71
103
  expect(result.tenantIdMatches).toBe(true);
72
104
  expect(result.dbThrows).toBe(true);
105
+ expect(result.dbOutsideTransactionPresent).toBe(true);
106
+ expect(result.dbOutsideTransactionThrows).toBe(true);
107
+ expect(result.outsideTransactionTenantIdMatches).toBe(true);
73
108
  });
74
109
 
75
- test("is absent for non-system-scoped handlers; ctx.db works normally there", async () => {
76
- const result = await stack.http.queryOk<{ present: boolean; dbWorks: boolean }>(
77
- "ctxsystemdb-tenant:query:check",
78
- {},
79
- admin,
80
- );
110
+ test("is absent for non-system-scoped handlers; ctx.db and ctx.dbOutsideTransaction work normally there", async () => {
111
+ const result = await stack.http.queryOk<{
112
+ present: boolean;
113
+ dbWorks: boolean;
114
+ dbOutsideTransactionWorks: boolean;
115
+ }>("ctxsystemdb-tenant:query:check", {}, admin);
81
116
  expect(result.present).toBe(false);
82
117
  expect(result.dbWorks).toBe(true);
118
+ expect(result.dbOutsideTransactionWorks).toBe(true);
83
119
  });
84
120
  });
@@ -1019,13 +1019,32 @@ describe("dispatcher context.geoTzProvider (680/1)", () => {
1019
1019
  // --- Mock helpers ---
1020
1020
 
1021
1021
  function createMockIdempotencyGuard() {
1022
- const cache = new Map<string, string>();
1022
+ const results = new Map<string, string>();
1023
+ const pendingTokens = new Map<string, string>();
1024
+ let nextToken = 0;
1025
+
1023
1026
  return {
1024
1027
  async check(tenantId: string, userId: string, requestId: string) {
1025
- return cache.get(`${tenantId}:${userId}:${requestId}`) ?? null;
1028
+ const key = `${tenantId}:${userId}:${requestId}`;
1029
+ const cached = results.get(key);
1030
+ if (cached !== undefined) return { status: "cached" as const, result: cached };
1031
+ const token = `token-${nextToken++}`;
1032
+ pendingTokens.set(key, token);
1033
+ return { status: "acquired" as const, token };
1026
1034
  },
1027
- async store(tenantId: string, userId: string, requestId: string, result: unknown) {
1028
- cache.set(`${tenantId}:${userId}:${requestId}`, JSON.stringify(result));
1035
+ async store(
1036
+ tenantId: string,
1037
+ userId: string,
1038
+ requestId: string,
1039
+ token: string,
1040
+ result: unknown,
1041
+ ) {
1042
+ const key = `${tenantId}:${userId}:${requestId}`;
1043
+ // Mirrors the real guard's CAS: only persist if we still hold the token
1044
+ // this run acquired.
1045
+ if (pendingTokens.get(key) !== token) return;
1046
+ pendingTokens.delete(key);
1047
+ results.set(key, JSON.stringify(result));
1029
1048
  },
1030
1049
  };
1031
1050
  }
@@ -22,37 +22,44 @@ describe("idempotency guard", () => {
22
22
  const tenantA = "00000000-0000-4000-8000-00000000000a";
23
23
  const userA = "00000000-0000-4000-8000-0000000000a1";
24
24
 
25
- test("returns null for new request", async () => {
25
+ test("returns acquired for new request", async () => {
26
26
  const guard = createIdempotencyGuard(testRedis.redis);
27
27
  const result = await guard.check(tenantA, userA, "req-new-123");
28
- expect(result).toBeNull();
28
+ expect(result.status).toBe("acquired");
29
29
  });
30
30
 
31
31
  test("returns cached result for duplicate request", async () => {
32
32
  const guard = createIdempotencyGuard(testRedis.redis);
33
33
  const requestId = "req-dup-456";
34
34
 
35
- await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { id: 1 } });
35
+ const acquired = await guard.check(tenantA, userA, requestId);
36
+ if (acquired.status !== "acquired") throw new Error("expected to acquire the lock");
37
+ await guard.store(tenantA, userA, requestId, acquired.token, {
38
+ isSuccess: true,
39
+ data: { id: 1 },
40
+ });
36
41
  const cached = await guard.check(tenantA, userA, requestId);
37
42
 
38
- expect(cached).not.toBeNull();
39
- if (!cached) throw new Error("expected cached value");
40
- expect(JSON.parse(cached)).toEqual({ isSuccess: true, data: { id: 1 } });
43
+ expect(cached.status).toBe("cached");
44
+ if (cached.status !== "cached") throw new Error("expected cached value");
45
+ expect(JSON.parse(cached.result)).toEqual({ isSuccess: true, data: { id: 1 } });
41
46
  });
42
47
 
43
48
  test("expires after TTL", async () => {
44
49
  const guard = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 1 });
45
50
  const requestId = "req-ttl-789";
46
51
 
47
- await guard.store(tenantA, userA, requestId, { done: true });
52
+ const acquired = await guard.check(tenantA, userA, requestId);
53
+ if (acquired.status !== "acquired") throw new Error("expected to acquire the lock");
54
+ await guard.store(tenantA, userA, requestId, acquired.token, { done: true });
48
55
 
49
56
  // Should exist immediately
50
- expect(await guard.check(tenantA, userA, requestId)).not.toBeNull();
57
+ expect((await guard.check(tenantA, userA, requestId)).status).toBe("cached");
51
58
 
52
59
  // Wait for expiry
53
60
  await new Promise((r) => setTimeout(r, 1100));
54
61
 
55
- expect(await guard.check(tenantA, userA, requestId)).toBeNull();
62
+ expect((await guard.check(tenantA, userA, requestId)).status).toBe("acquired");
56
63
  });
57
64
 
58
65
  test("parallel check(): second caller waits for the first's store() instead of racing", async () => {
@@ -65,7 +72,8 @@ describe("idempotency guard", () => {
65
72
 
66
73
  // Request #1 starts — claims the in-progress lock.
67
74
  const first = await guard.check(tenantA, userA, requestId);
68
- expect(first).toBeNull(); // got the lock
75
+ expect(first.status).toBe("acquired");
76
+ if (first.status !== "acquired") throw new Error("expected to acquire the lock");
69
77
 
70
78
  // Request #2 runs concurrently — must block until #1 stores a result.
71
79
  const secondPromise = guard.check(tenantA, userA, requestId);
@@ -80,12 +88,17 @@ describe("idempotency guard", () => {
80
88
  expect(quickResult.done).toBe(false);
81
89
 
82
90
  // Request #1 finishes.
83
- await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { id: 99 } });
91
+ await guard.store(tenantA, userA, requestId, first.token, {
92
+ isSuccess: true,
93
+ data: { id: 99 },
94
+ });
84
95
 
85
- // Request #2 should now see the stored result, not null no duplicate work.
96
+ // Request #2 should now see the stored result, not a fresh acquisition
97
+ // no duplicate work.
86
98
  const second = await secondPromise;
87
- expect(second).not.toBeNull();
88
- expect(JSON.parse(second as string)).toEqual({ isSuccess: true, data: { id: 99 } });
99
+ expect(second.status).toBe("cached");
100
+ if (second.status !== "cached") throw new Error("expected cached value");
101
+ expect(JSON.parse(second.result)).toEqual({ isSuccess: true, data: { id: 99 } });
89
102
  });
90
103
 
91
104
  test("crashed handler: pending marker expires, next caller reclaims the lock", async () => {
@@ -97,11 +110,11 @@ describe("idempotency guard", () => {
97
110
  const requestId = "req-crashed";
98
111
 
99
112
  const first = await guard.check(tenantA, userA, requestId);
100
- expect(first).toBeNull(); // we acquired the lock, then "crash" — never call store()
113
+ expect(first.status).toBe("acquired"); // we acquired the lock, then "crash" — never call store()
101
114
 
102
115
  // After the pending-TTL lapses, a retry should be allowed to take over.
103
116
  const second = await guard.check(tenantA, userA, requestId);
104
- expect(second).toBeNull(); // reclaimed
117
+ expect(second.status).toBe("acquired"); // reclaimed
105
118
  });
106
119
 
107
120
  test("same requestId from different tenant/user does not hit the same cache entry", async () => {
@@ -112,22 +125,103 @@ describe("idempotency guard", () => {
112
125
 
113
126
  // Tenant A / user A owns the request and stores its result.
114
127
  const firstCheck = await guard.check(tenantA, userA, requestId);
115
- expect(firstCheck).toBeNull();
116
- await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { tenant: "A" } });
128
+ expect(firstCheck.status).toBe("acquired");
129
+ if (firstCheck.status !== "acquired") throw new Error("expected to acquire the lock");
130
+ await guard.store(tenantA, userA, requestId, firstCheck.token, {
131
+ isSuccess: true,
132
+ data: { tenant: "A" },
133
+ });
117
134
 
118
135
  // Same requestId, different tenant+user: must be treated as a fresh
119
136
  // request, not see tenant A's cached/pending state.
120
137
  const otherTenantCheck = await guard.check(tenantB, userB, requestId);
121
- expect(otherTenantCheck).toBeNull();
138
+ expect(otherTenantCheck.status).toBe("acquired");
122
139
 
123
140
  // Different user, same tenant: also isolated.
124
141
  const otherUserCheck = await guard.check(tenantA, userB, requestId);
125
- expect(otherUserCheck).toBeNull();
142
+ expect(otherUserCheck.status).toBe("acquired");
126
143
 
127
144
  // Tenant A's own result is still retrievable and unaffected.
128
145
  const ownResult = await guard.check(tenantA, userA, requestId);
129
- expect(ownResult).not.toBeNull();
130
- expect(JSON.parse(ownResult as string)).toEqual({ isSuccess: true, data: { tenant: "A" } });
146
+ expect(ownResult.status).toBe("cached");
147
+ if (ownResult.status !== "cached") throw new Error("expected cached value");
148
+ expect(JSON.parse(ownResult.result)).toEqual({ isSuccess: true, data: { tenant: "A" } });
149
+ });
150
+
151
+ test("bug 1 — wait shorter than the pending lock no longer forces a duplicate re-run", async () => {
152
+ // Same inverted ratio as the pre-fix defaults (waitTimeoutMs < pendingTtl),
153
+ // scaled to sub-second so the test stays fast. Pre-fix, the internal
154
+ // waitTimeoutMs was trusted as-is: the waiter gives up at 100ms and
155
+ // reports "acquired" even though request #1 is still legitimately
156
+ // running and stores its result 200ms later — the double-execute bug.
157
+ // Post-fix, waitTimeoutMs is clamped to stay above pendingTtl, so the
158
+ // waiter keeps polling and observes the real result instead.
159
+ const guard = createIdempotencyGuard(testRedis.redis, {
160
+ pendingTtlSeconds: 1,
161
+ waitTimeoutMs: 100,
162
+ pollIntervalMs: 20,
163
+ });
164
+ const requestId = "req-bug1-inverted-timeout";
165
+
166
+ const first = await guard.check(tenantA, userA, requestId);
167
+ expect(first.status).toBe("acquired");
168
+ if (first.status !== "acquired") throw new Error("expected to acquire the lock");
169
+
170
+ // Request #1 is "slow" — stores well after the old 100ms wait window,
171
+ // but well within pendingTtl (1s).
172
+ const storeAfterDelay = (async () => {
173
+ await new Promise((r) => setTimeout(r, 300));
174
+ await guard.store(tenantA, userA, requestId, first.token, {
175
+ isSuccess: true,
176
+ data: { id: "slow-handler" },
177
+ });
178
+ })();
179
+
180
+ const second = await guard.check(tenantA, userA, requestId);
181
+ await storeAfterDelay;
182
+
183
+ // Must observe request #1's real result — never a second "acquired".
184
+ expect(second.status).toBe("cached");
185
+ if (second.status !== "cached") throw new Error("expected cached value, not a re-run");
186
+ expect(JSON.parse(second.result)).toEqual({ isSuccess: true, data: { id: "slow-handler" } });
187
+ });
188
+
189
+ test("bug 2 (window B) — a reclaimed lock's fresh result survives the original owner's stale store()", async () => {
190
+ const guard = createIdempotencyGuard(testRedis.redis, {
191
+ pendingTtlSeconds: 1, // expire fast so we can force a reclaim quickly
192
+ waitTimeoutMs: 6_000,
193
+ pollIntervalMs: 20,
194
+ });
195
+ const requestId = "req-bug2-window-b";
196
+
197
+ // Original owner acquires, then "hangs" (never stores) past pendingTtl.
198
+ const original = await guard.check(tenantA, userA, requestId);
199
+ expect(original.status).toBe("acquired");
200
+ if (original.status !== "acquired") throw new Error("expected to acquire the lock");
201
+
202
+ // Let the lock expire, then a second run reclaims it and finishes fast.
203
+ await new Promise((r) => setTimeout(r, 1100));
204
+ const reclaimer = await guard.check(tenantA, userA, requestId);
205
+ expect(reclaimer.status).toBe("acquired");
206
+ if (reclaimer.status !== "acquired") throw new Error("expected to reclaim the lock");
207
+ await guard.store(tenantA, userA, requestId, reclaimer.token, {
208
+ isSuccess: true,
209
+ data: { owner: "reclaimer" },
210
+ });
211
+
212
+ // The original (now-stale) run finally "finishes" and tries to store its
213
+ // own, outdated result using its original token.
214
+ await guard.store(tenantA, userA, requestId, original.token, {
215
+ isSuccess: true,
216
+ data: { owner: "original-stale" },
217
+ });
218
+
219
+ // The reclaimer's fresh result must survive — the stale store() must be
220
+ // a no-op, not a silent overwrite.
221
+ const final = await guard.check(tenantA, userA, requestId);
222
+ expect(final.status).toBe("cached");
223
+ if (final.status !== "cached") throw new Error("expected cached value");
224
+ expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
131
225
  });
132
226
  });
133
227
 
@@ -29,20 +29,26 @@ export async function runBatch(
29
29
 
30
30
  // Idempotency: if the same requestId has already been processed, return the
31
31
  // cached result without re-executing. The cache holds the full BatchResult.
32
+ // idempotencyToken is only set when we actually acquired the lock — the
33
+ // corrupted-cache fallthrough below leaves it unset, so finalize() skips
34
+ // store() rather than writing over an entry it never owned.
35
+ let idempotencyToken: string | undefined;
32
36
  if (requestId && idempotency) {
33
- const cached = await idempotency.check(user.tenantId, user.id, requestId);
34
- if (cached) {
35
- const parsed = parseJsonSafe<BatchResult | null>(cached, null);
37
+ const checked = await idempotency.check(user.tenantId, user.id, requestId);
38
+ if (checked.status === "cached") {
39
+ const parsed = parseJsonSafe<BatchResult | null>(checked.result, null);
36
40
  if (parsed) return parsed;
37
41
  // corrupted cache entry — treat as miss, let the request re-run
42
+ } else {
43
+ idempotencyToken = checked.token;
38
44
  }
39
45
  }
40
46
 
41
47
  // Wrap return paths: cache the final result under requestId so retries get
42
48
  // the same answer (both success and failure results are cached).
43
49
  const finalize = async (result: BatchResult): Promise<BatchResult> => {
44
- if (requestId && idempotency) {
45
- await idempotency.store(user.tenantId, user.id, requestId, result);
50
+ if (requestId && idempotency && idempotencyToken) {
51
+ await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, result);
46
52
  }
47
53
  return result;
48
54
  };