@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.
- package/package.json +3 -3
- package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
- package/src/api/__tests__/api.test.ts +116 -1
- package/src/api/__tests__/batch.integration.test.ts +53 -0
- package/src/api/__tests__/body-limit.test.ts +16 -0
- package/src/api/route-registrars.ts +4 -3
- package/src/api/routes.ts +47 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
- package/src/db/tenant-db.ts +46 -2
- package/src/engine/entity-handlers.ts +8 -1
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +98 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
- package/src/engine/feature-ast/extractors/events.ts +5 -3
- package/src/engine/feature-ast/extractors/round3.ts +5 -3
- package/src/engine/feature-ast/extractors/round5.ts +5 -4
- package/src/engine/feature-ast/extractors/shared.ts +29 -4
- package/src/engine/feature-ast/patch.ts +28 -21
- package/src/engine/feature-ast/patterns.ts +18 -0
- package/src/engine/feature-ast/render.ts +19 -6
- package/src/engine/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +97 -1
- package/src/files/file-routes.ts +10 -2
- package/src/files/types.ts +72 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
- package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
- package/src/pipeline/dispatch-batch.ts +11 -5
- package/src/pipeline/dispatch-shared.ts +42 -16
- 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", () => {
|
package/src/files/file-routes.ts
CHANGED
|
@@ -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":
|
|
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
|
});
|
package/src/files/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
76
|
-
const result = await stack.http.queryOk<{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
1028
|
-
|
|
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
|
|
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).
|
|
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.
|
|
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).
|
|
39
|
-
if (
|
|
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.
|
|
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)).
|
|
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)).
|
|
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).
|
|
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,
|
|
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
|
|
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).
|
|
88
|
-
|
|
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).
|
|
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).
|
|
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).
|
|
116
|
-
|
|
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).
|
|
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).
|
|
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).
|
|
130
|
-
|
|
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
|
|
34
|
-
if (cached) {
|
|
35
|
-
const parsed = parseJsonSafe<BatchResult | 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
|
};
|