@cosmicdrift/kumiko-framework 0.318.0 → 0.320.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 +8 -4
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +139 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +91 -0
- package/src/api/index.ts +15 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +128 -40
- package/src/changes.json +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
2
|
export type * from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
-
export { isOpenToAllGranted } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
+
export { isOpenToAllGranted, isUiAccessGranted } from "@cosmicdrift/kumiko-types/handlers";
|
|
@@ -218,7 +218,11 @@ export type {
|
|
|
218
218
|
WriteHandlerFn,
|
|
219
219
|
WriteResult,
|
|
220
220
|
} from "@cosmicdrift/kumiko-types/handlers";
|
|
221
|
-
export {
|
|
221
|
+
export {
|
|
222
|
+
isOpenToAllGranted,
|
|
223
|
+
isRateLimitDisabled,
|
|
224
|
+
isUiAccessGranted,
|
|
225
|
+
} from "@cosmicdrift/kumiko-types/handlers";
|
|
222
226
|
export type {
|
|
223
227
|
DeleteContext,
|
|
224
228
|
EntityHookMap,
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -17,7 +17,18 @@
|
|
|
17
17
|
// const entry = createApiEntrypoint(opts);
|
|
18
18
|
// attachSignalHandlers(entry.lifecycle);
|
|
19
19
|
// await entry.start();
|
|
20
|
-
// serve({
|
|
20
|
+
// serve({
|
|
21
|
+
// fetch: (req, server) => entry.app.fetch(req, server.requestIP(req)?.address),
|
|
22
|
+
// port: 3000,
|
|
23
|
+
// });
|
|
24
|
+
//
|
|
25
|
+
// `entry.app.fetch` alone (without the 2nd arg) loses the caller's real
|
|
26
|
+
// socket address — the client-ip resolver then sees no env, falls back to
|
|
27
|
+
// "unknown", and every caller shares one IP-keyed rate-limit bucket (see
|
|
28
|
+
// api/client-ip.ts). @cosmicdrift/kumiko-server-runtime's
|
|
29
|
+
// `buildBunServeOptions(port, entry.app.fetch)` wires this same pattern
|
|
30
|
+
// plus the SSE-safe idleTimeout default, for consumers that already
|
|
31
|
+
// depend on that package.
|
|
21
32
|
//
|
|
22
33
|
// The `lifecycle` handle drives graceful-shutdown LIFO; the framework
|
|
23
34
|
// registers its own shutdown hooks (eventDispatcher.stop, jobRunner.stop)
|
|
@@ -107,6 +118,7 @@ export type ApiEntrypointOptions = BaseEntrypointOptions & {
|
|
|
107
118
|
readonly eventDispatcher?: ServerOptions["eventDispatcher"] & {
|
|
108
119
|
readonly runLocal?: boolean;
|
|
109
120
|
};
|
|
121
|
+
readonly trustedProxyHops?: ServerOptions["trustedProxyHops"];
|
|
110
122
|
};
|
|
111
123
|
|
|
112
124
|
export type WorkerEntrypointOptions = BaseEntrypointOptions &
|
|
@@ -231,6 +243,7 @@ function buildApiServer(
|
|
|
231
243
|
processLane,
|
|
232
244
|
jwtIssuer: opts.jwtIssuer,
|
|
233
245
|
auth: opts.auth,
|
|
246
|
+
trustedProxyHops: opts.trustedProxyHops,
|
|
234
247
|
anonymousAccess: opts.anonymousAccess,
|
|
235
248
|
sseBroker: opts.sseBroker,
|
|
236
249
|
rateLimit: opts.rateLimit,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import { createFilesFeature } from "../feature";
|
|
34
34
|
import { fileRefsTable } from "../file-ref-table";
|
|
35
35
|
import type { FileRoutesOptions } from "../file-routes";
|
|
36
|
-
import { createInMemoryFileProvider } from "../in-memory-provider";
|
|
36
|
+
import { createInMemoryFileProvider, type InMemoryFileProvider } from "../in-memory-provider";
|
|
37
37
|
import { createLocalProvider } from "../local-provider";
|
|
38
38
|
import type { FileStorageProvider, SignedUrlOptions } from "../types";
|
|
39
39
|
import {
|
|
@@ -1643,3 +1643,107 @@ describe("byte-serving routes with field-encrypted fileName", () => {
|
|
|
1643
1643
|
}
|
|
1644
1644
|
});
|
|
1645
1645
|
});
|
|
1646
|
+
|
|
1647
|
+
describe("upload body-size limit", () => {
|
|
1648
|
+
// Small on purpose: the pre-parseBody body limit is maxUploadSize + a
|
|
1649
|
+
// fixed multipart margin regardless of scale, so a tiny maxUploadSize
|
|
1650
|
+
// keeps the over-limit payloads (and this suite) fast.
|
|
1651
|
+
const SMALL_MAX_UPLOAD_SIZE = "1kb";
|
|
1652
|
+
const OVER_LIMIT_FILE_BYTES = 200 * 1024;
|
|
1653
|
+
|
|
1654
|
+
async function withSizeLimitedServer(
|
|
1655
|
+
body: (args: { app: Hono; jwt: JwtHelper; provider: InMemoryFileProvider }) => Promise<void>,
|
|
1656
|
+
): Promise<void> {
|
|
1657
|
+
const isolatedDb = await createTestDb();
|
|
1658
|
+
await unsafePushTables(isolatedDb.db, { fileRefsTable });
|
|
1659
|
+
const provider = createInMemoryFileProvider();
|
|
1660
|
+
const isolatedRegistry = createRegistry([
|
|
1661
|
+
createFilesFeature({ maxUploadSize: SMALL_MAX_UPLOAD_SIZE }),
|
|
1662
|
+
]);
|
|
1663
|
+
const isolatedServer = buildServer({
|
|
1664
|
+
registry: isolatedRegistry,
|
|
1665
|
+
context: { db: isolatedDb.db, _fileProviderResolver: () => Promise.resolve(provider) },
|
|
1666
|
+
jwtSecret: JWT_SECRET,
|
|
1667
|
+
});
|
|
1668
|
+
try {
|
|
1669
|
+
await body({ app: isolatedServer.app, jwt: isolatedServer.jwt, provider });
|
|
1670
|
+
} finally {
|
|
1671
|
+
await isolatedDb.cleanup();
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
async function buildUnattachedMultipartBody(
|
|
1676
|
+
fileSizeBytes: number,
|
|
1677
|
+
): Promise<{ body: Uint8Array; contentType: string }> {
|
|
1678
|
+
const fd = new FormData();
|
|
1679
|
+
const content = new Uint8Array(fileSizeBytes);
|
|
1680
|
+
content.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic bytes
|
|
1681
|
+
fd.append("file", new File([Buffer.from(content)], "big.png", { type: "image/png" }));
|
|
1682
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
1683
|
+
return { body: body as Uint8Array, contentType };
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
test("Content-Length above the limit is rejected without writing to storage", async () => {
|
|
1687
|
+
await withSizeLimitedServer(async ({ app, jwt, provider }) => {
|
|
1688
|
+
const token = await jwt.sign(adminUser);
|
|
1689
|
+
const { body, contentType } = await buildUnattachedMultipartBody(OVER_LIMIT_FILE_BYTES);
|
|
1690
|
+
const res = await app.request("/api/files", {
|
|
1691
|
+
method: "POST",
|
|
1692
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
1693
|
+
body: body as unknown as BodyInit,
|
|
1694
|
+
});
|
|
1695
|
+
expect(res.status).toBe(413);
|
|
1696
|
+
const json = (await res.json()) as { error: string };
|
|
1697
|
+
expectErrorIncludes(json.error, "request_too_large");
|
|
1698
|
+
expect(provider.keys()).toEqual([]);
|
|
1699
|
+
});
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
test("a chunked body without Content-Length above the limit is rejected while streaming", async () => {
|
|
1703
|
+
await withSizeLimitedServer(async ({ app, jwt, provider }) => {
|
|
1704
|
+
const token = await jwt.sign(adminUser);
|
|
1705
|
+
const { body, contentType } = await buildUnattachedMultipartBody(OVER_LIMIT_FILE_BYTES);
|
|
1706
|
+
// Feeding the buffer through a ReadableStream (instead of handing it to
|
|
1707
|
+
// fetch directly) means no Content-Length can be inferred — this drives
|
|
1708
|
+
// bodyLimit's streaming/counting branch instead of its Content-Length
|
|
1709
|
+
// shortcut.
|
|
1710
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
1711
|
+
start(controller) {
|
|
1712
|
+
const chunkSize = 8 * 1024;
|
|
1713
|
+
for (let offset = 0; offset < body.length; offset += chunkSize) {
|
|
1714
|
+
controller.enqueue(body.subarray(offset, offset + chunkSize));
|
|
1715
|
+
}
|
|
1716
|
+
controller.close();
|
|
1717
|
+
},
|
|
1718
|
+
});
|
|
1719
|
+
const req = new Request("http://localhost/api/files", {
|
|
1720
|
+
method: "POST",
|
|
1721
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
1722
|
+
body: stream,
|
|
1723
|
+
duplex: "half",
|
|
1724
|
+
} as RequestInit);
|
|
1725
|
+
// A stream body has no known length — confirms this exercises
|
|
1726
|
+
// bodyLimit's streaming/counting branch, not its Content-Length shortcut.
|
|
1727
|
+
expect(req.headers.get("content-length")).toBeNull();
|
|
1728
|
+
const res = await app.request(req);
|
|
1729
|
+
expect(res.status).toBe(413);
|
|
1730
|
+
const json = (await res.json()) as { error: string };
|
|
1731
|
+
expectErrorIncludes(json.error, "request_too_large");
|
|
1732
|
+
expect(provider.keys()).toEqual([]);
|
|
1733
|
+
});
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
test("an upload within the limit still succeeds", async () => {
|
|
1737
|
+
await withSizeLimitedServer(async ({ app, jwt, provider }) => {
|
|
1738
|
+
const token = await jwt.sign(adminUser);
|
|
1739
|
+
const { body, contentType } = await buildUnattachedMultipartBody(128);
|
|
1740
|
+
const res = await app.request("/api/files", {
|
|
1741
|
+
method: "POST",
|
|
1742
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
1743
|
+
body: body as unknown as BodyInit,
|
|
1744
|
+
});
|
|
1745
|
+
expect(res.status).toBe(201);
|
|
1746
|
+
expect(provider.keys().length).toBe(1);
|
|
1747
|
+
});
|
|
1748
|
+
});
|
|
1749
|
+
});
|
package/src/files/file-routes.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
4
|
import { getUser } from "../api/auth-middleware";
|
|
4
5
|
import { requestContext } from "../api/request-context";
|
|
5
6
|
import {
|
|
@@ -29,6 +30,7 @@ import { fileRefsTable } from "./file-ref-table";
|
|
|
29
30
|
import type { FileProviderResolver } from "./provider-resolver";
|
|
30
31
|
import {
|
|
31
32
|
buildStorageKey,
|
|
33
|
+
parseMaxSize,
|
|
32
34
|
resolveServedContentType,
|
|
33
35
|
resolveUploadMimeType,
|
|
34
36
|
validateFile,
|
|
@@ -130,6 +132,45 @@ function resolveAttachedField(
|
|
|
130
132
|
return { kind: "resolved", fieldDef };
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
136
|
+
|
|
137
|
+
// Multipart framing plus the small entityType/entityId/fieldName parts
|
|
138
|
+
// riding alongside the file.
|
|
139
|
+
const MULTIPART_OVERHEAD_MARGIN_BYTES = 64 * 1024;
|
|
140
|
+
|
|
141
|
+
// entityType/fieldName (and therefore the field's own maxSize) are only
|
|
142
|
+
// known after the body is parsed, so this covers the largest configured
|
|
143
|
+
// field, not just the global default.
|
|
144
|
+
export function resolveMaxUploadBodyBytes(
|
|
145
|
+
options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">,
|
|
146
|
+
): number {
|
|
147
|
+
let maxBytes = parseMaxSize(options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE);
|
|
148
|
+
for (const entity of options.registry?.getAllEntities().values() ?? []) {
|
|
149
|
+
for (const field of Object.values(entity.fields)) {
|
|
150
|
+
if (isFileField(field) && field.maxSize) {
|
|
151
|
+
maxBytes = Math.max(maxBytes, parseMaxSize(field.maxSize));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return maxBytes + MULTIPART_OVERHEAD_MARGIN_BYTES;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Upload-route policy carried by createFilesFeature(opts?) — read from the
|
|
159
|
+
// feature's exports so callers apply it without a parallel options surface.
|
|
160
|
+
export function readFilesRouteOptions(
|
|
161
|
+
registry: Registry,
|
|
162
|
+
): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize"> {
|
|
163
|
+
const exp = registry.features.get("files")?.exports;
|
|
164
|
+
if (exp && typeof exp === "object" && "routeOptions" in exp) {
|
|
165
|
+
const ro = (exp as { routeOptions?: unknown }).routeOptions;
|
|
166
|
+
if (ro && typeof ro === "object") {
|
|
167
|
+
// @cast-boundary feature-exports: engine-payload (unknown) → known shape
|
|
168
|
+
return ro as Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
|
|
133
174
|
export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
134
175
|
const { db } = options;
|
|
135
176
|
const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
|
|
@@ -168,8 +209,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
168
209
|
return typeof decrypted["fileName"] === "string" ? decrypted["fileName"] : "download";
|
|
169
210
|
}
|
|
170
211
|
|
|
212
|
+
// Runs before parseBody buffers the request below: checks Content-Length
|
|
213
|
+
// upfront, counts chunked bodies while streaming.
|
|
214
|
+
const uploadBodyLimit = bodyLimit({
|
|
215
|
+
maxSize: resolveMaxUploadBodyBytes(options),
|
|
216
|
+
onError: (c) =>
|
|
217
|
+
c.json(
|
|
218
|
+
{ error: "request_too_large: request body exceeds the maximum allowed upload size" },
|
|
219
|
+
413,
|
|
220
|
+
),
|
|
221
|
+
});
|
|
222
|
+
|
|
171
223
|
// POST /files — multipart upload.
|
|
172
|
-
api.post("/files", async (c) => {
|
|
224
|
+
api.post("/files", uploadBodyLimit, async (c) => {
|
|
173
225
|
const user = getUser(c);
|
|
174
226
|
const body = await c.req.parseBody();
|
|
175
227
|
const file = body["file"];
|
|
@@ -198,7 +250,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
198
250
|
}
|
|
199
251
|
|
|
200
252
|
// Validate against entity field definition if available.
|
|
201
|
-
let maxSize = options.maxUploadSize ??
|
|
253
|
+
let maxSize = options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
|
|
202
254
|
let accept: readonly string[] | undefined;
|
|
203
255
|
|
|
204
256
|
const attachedField = resolveAttachedField(options.registry, entityType, fieldName);
|
package/src/files/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type {
|
|
|
11
11
|
FileRef,
|
|
12
12
|
FileRoutesOptions,
|
|
13
13
|
} from "./file-routes";
|
|
14
|
-
export { createFileRoutes } from "./file-routes";
|
|
14
|
+
export { createFileRoutes, readFilesRouteOptions, resolveMaxUploadBodyBytes } from "./file-routes";
|
|
15
15
|
export type { InMemoryFileProvider } from "./in-memory-provider";
|
|
16
16
|
export { createInMemoryFileProvider } from "./in-memory-provider";
|
|
17
17
|
export { createLocalProvider } from "./local-provider";
|
package/src/http/index.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
|
-
export type { EgressPolicy } from "@cosmicdrift/kumiko-http";
|
|
2
|
-
export {
|
|
1
|
+
export type { EgressPolicy, ResolvedHost } from "@cosmicdrift/kumiko-http";
|
|
2
|
+
export {
|
|
3
|
+
BlockedHostError,
|
|
4
|
+
buildPinnedRequest,
|
|
5
|
+
egress,
|
|
6
|
+
HostResolutionError,
|
|
7
|
+
isPublicHost,
|
|
8
|
+
resolvePublicHost,
|
|
9
|
+
resolvePublicHostname,
|
|
10
|
+
} from "@cosmicdrift/kumiko-http";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { Queue } from "bullmq";
|
|
3
|
+
import { Redis } from "ioredis";
|
|
3
4
|
import * as z from "zod";
|
|
4
5
|
import { requestContext } from "../../api/request-context";
|
|
5
6
|
import { tenantDbRunner } from "../../db/tenant-db-runner";
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
TenantId,
|
|
14
15
|
} from "../../engine/types";
|
|
15
16
|
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
17
|
+
import { RedisKeys } from "../../pipeline/redis-keys";
|
|
16
18
|
import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
17
19
|
import { sleep, waitFor } from "../../testing";
|
|
18
20
|
import {
|
|
@@ -498,10 +500,110 @@ describe("concurrency: sequential", () => {
|
|
|
498
500
|
expect(delta12).toBeGreaterThanOrEqual(250);
|
|
499
501
|
expect(delta23).toBeGreaterThanOrEqual(250);
|
|
500
502
|
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
503
|
+
// NOT dispatch order: "sequential" is mutual exclusion, not a FIFO
|
|
504
|
+
// queue. A re-enqueued lock loser goes to the back of its
|
|
505
|
+
// queue, so it can still win the next race. What's guaranteed, and
|
|
506
|
+
// already asserted above via delta12/delta23, is that runs never
|
|
507
|
+
// overlap. Assert the complete set instead of a specific order.
|
|
508
|
+
expect(entries.map((e) => e.payload)).toEqual(
|
|
509
|
+
expect.arrayContaining([{ n: 1 }, { n: 2 }, { n: 3 }]),
|
|
510
|
+
);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("runners with distinct queueNamePrefix do not share the sequential lock", async () => {
|
|
515
|
+
clearLog();
|
|
516
|
+
// Regression guard: the lock key used to omit queueNamePrefix, so two
|
|
517
|
+
// isolated runners sharing a Redis collided on the same lock.
|
|
518
|
+
const prefixA = `kumiko-test-lockscope-a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
519
|
+
const prefixB = `kumiko-test-lockscope-b-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
520
|
+
const runnerA = createJobRunner({
|
|
521
|
+
registry: createRegistry([testFeature]),
|
|
522
|
+
context: {},
|
|
523
|
+
redisUrl,
|
|
524
|
+
consumerLane: "worker",
|
|
525
|
+
queueNamePrefix: prefixA,
|
|
526
|
+
});
|
|
527
|
+
const runnerB = createJobRunner({
|
|
528
|
+
registry: createRegistry([testFeature]),
|
|
529
|
+
context: {},
|
|
530
|
+
redisUrl,
|
|
531
|
+
consumerLane: "worker",
|
|
532
|
+
queueNamePrefix: prefixB,
|
|
533
|
+
});
|
|
534
|
+
// Own client, no keyPrefix — testRedis.redis auto-prefixes SET/DEL and
|
|
535
|
+
// would write a different key than the runner's own lock connection.
|
|
536
|
+
const lockKeyA = `${RedisKeys.lock}seq:${prefixA}:worker:test:job:sequential-job`;
|
|
537
|
+
const rawRedis = new Redis(redisUrl);
|
|
538
|
+
rawRedis.on("error", () => {});
|
|
539
|
+
const queueA = new Queue(`${prefixA}-worker`, {
|
|
540
|
+
connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
|
|
504
541
|
});
|
|
542
|
+
// A post-close 'error' here is otherwise unhandled and bun:test
|
|
543
|
+
// attributes it to whichever test runs next (fw#1805).
|
|
544
|
+
queueA.on("error", () => {});
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
await rawRedis.set(lockKeyA, "held-by-test", "EX", 30, "NX");
|
|
548
|
+
|
|
549
|
+
await runnerA.start();
|
|
550
|
+
await runnerB.start();
|
|
551
|
+
|
|
552
|
+
await runnerA.dispatch("test:job:sequential-job", { n: 100 });
|
|
553
|
+
|
|
554
|
+
// A's handler returns successfully on a lock loss (re-enqueue, not a
|
|
555
|
+
// throw) — BullMQ records the dropped attempt as completed even
|
|
556
|
+
// though the job body never ran. Filtered by name+payload so unrelated
|
|
557
|
+
// boot/cron jobs on the same worker queue don't false-positive this.
|
|
558
|
+
await waitFor(
|
|
559
|
+
async () => {
|
|
560
|
+
const completed = await queueA.getCompleted();
|
|
561
|
+
const lostAttempts = completed.filter(
|
|
562
|
+
(job) => job.name === "test:job:sequential-job" && job.data.n === 100,
|
|
563
|
+
);
|
|
564
|
+
expect(lostAttempts.length).toBeGreaterThanOrEqual(1);
|
|
565
|
+
},
|
|
566
|
+
{ delays: [400, 800, 1500, 3000] },
|
|
567
|
+
);
|
|
568
|
+
expect(
|
|
569
|
+
jobLog.some((e) => e.name === "test:job:sequential-job" && e.payload["n"] === 100),
|
|
570
|
+
).toBe(false);
|
|
571
|
+
|
|
572
|
+
// B shares no lock scope with the externally held A-key, so it must
|
|
573
|
+
// run despite A being blocked.
|
|
574
|
+
await runnerB.dispatch("test:job:sequential-job", { n: 200 });
|
|
575
|
+
await waitFor(
|
|
576
|
+
() => {
|
|
577
|
+
const entries = jobLog.filter(
|
|
578
|
+
(e) => e.name === "test:job:sequential-job" && e.payload["n"] === 200,
|
|
579
|
+
);
|
|
580
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
581
|
+
},
|
|
582
|
+
{ delays: [400, 800, 1500, 3000] },
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
// Releasing the externally held key unblocks A's own dispatch.
|
|
586
|
+
await rawRedis.del(lockKeyA);
|
|
587
|
+
await waitFor(
|
|
588
|
+
() => {
|
|
589
|
+
const entries = jobLog.filter(
|
|
590
|
+
(e) => e.name === "test:job:sequential-job" && e.payload["n"] === 100,
|
|
591
|
+
);
|
|
592
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
593
|
+
},
|
|
594
|
+
{ delays: [400, 800, 1500, 3000] },
|
|
595
|
+
);
|
|
596
|
+
} finally {
|
|
597
|
+
await runnerA.stop();
|
|
598
|
+
await runnerB.stop();
|
|
599
|
+
await queueA.close();
|
|
600
|
+
await rawRedis.del(lockKeyA);
|
|
601
|
+
rawRedis.disconnect();
|
|
602
|
+
const keysA = await testRedis.redis.keys(`bull:${prefixA}-worker:*`);
|
|
603
|
+
if (keysA.length > 0) await testRedis.redis.del(...keysA);
|
|
604
|
+
const keysB = await testRedis.redis.keys(`bull:${prefixB}-worker:*`);
|
|
605
|
+
if (keysB.length > 0) await testRedis.redis.del(...keysB);
|
|
606
|
+
}
|
|
505
607
|
});
|
|
506
608
|
|
|
507
609
|
test("lock is released even when the handler throws", async () => {
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -550,7 +550,12 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
550
550
|
log("lock redis connection error", { error: err.message });
|
|
551
551
|
});
|
|
552
552
|
const lockScope = consumerLane ?? "enqueue";
|
|
553
|
-
|
|
553
|
+
// Scoped by queueNamePrefix like the queues themselves, so runners
|
|
554
|
+
// isolated by prefix on a shared Redis don't contend for the same lock.
|
|
555
|
+
sequentialLock = createDistributedLock(
|
|
556
|
+
lockRedis,
|
|
557
|
+
`${RedisKeys.lock}seq:${queueNamePrefix}:${lockScope}:`,
|
|
558
|
+
);
|
|
554
559
|
}
|
|
555
560
|
// Default lock-TTL for sequential jobs that didn't declare a timeout.
|
|
556
561
|
// 5 minutes matches BullMQ's default stalledInterval — long enough for
|
|
@@ -28,7 +28,16 @@ describe("globalIpRateLimit (L1)", () => {
|
|
|
28
28
|
const app = new Hono();
|
|
29
29
|
app.use(
|
|
30
30
|
"/api/*",
|
|
31
|
-
|
|
31
|
+
// trustedProxyHops: 1 — the default hops=0 ignores x-forwarded-for
|
|
32
|
+
// outright, which would collapse the bucket
|
|
33
|
+
// assertion below onto "l1:unknown" instead of the literal IP.
|
|
34
|
+
globalIpRateLimit({
|
|
35
|
+
resolver,
|
|
36
|
+
limit: 3,
|
|
37
|
+
windowSeconds: 60,
|
|
38
|
+
trustedProxyHops: 1,
|
|
39
|
+
onFailClosed: () => {},
|
|
40
|
+
}),
|
|
32
41
|
);
|
|
33
42
|
app.get("/api/probe", (c) => c.text("ok"));
|
|
34
43
|
|
|
@@ -56,7 +65,13 @@ describe("globalIpRateLimit (L1)", () => {
|
|
|
56
65
|
const app = new Hono();
|
|
57
66
|
app.use(
|
|
58
67
|
"/api/*",
|
|
59
|
-
globalIpRateLimit({
|
|
68
|
+
globalIpRateLimit({
|
|
69
|
+
resolver,
|
|
70
|
+
limit: 2,
|
|
71
|
+
windowSeconds: 60,
|
|
72
|
+
trustedProxyHops: 1,
|
|
73
|
+
onFailClosed: () => {},
|
|
74
|
+
}),
|
|
60
75
|
);
|
|
61
76
|
app.get("/api/probe", (c) => c.text("ok"));
|
|
62
77
|
|
|
@@ -73,7 +88,11 @@ describe("globalIpRateLimit (L1)", () => {
|
|
|
73
88
|
expect(otherIp.status).toBe(200);
|
|
74
89
|
});
|
|
75
90
|
|
|
76
|
-
|
|
91
|
+
// The built-in resolver never returns undefined — no x-forwarded-for (or
|
|
92
|
+
// an untrusted one at hops=0) falls back to a fixed "unknown" bucket
|
|
93
|
+
// instead of skipping the middleware: skipping would fail open, letting
|
|
94
|
+
// an attacker omit/rotate the header to dodge L1 entirely.
|
|
95
|
+
test("no x-forwarded-for: still enforced via the shared 'unknown' bucket, never skipped", async () => {
|
|
77
96
|
const app = new Hono();
|
|
78
97
|
app.use(
|
|
79
98
|
"/api/*",
|
|
@@ -81,12 +100,10 @@ describe("globalIpRateLimit (L1)", () => {
|
|
|
81
100
|
);
|
|
82
101
|
app.get("/api/probe", (c) => c.text("ok"));
|
|
83
102
|
|
|
84
|
-
// No xff header — extractIp returns undefined → middleware skips.
|
|
85
|
-
// Both calls succeed even though limit=1.
|
|
86
103
|
const a = await app.request("/api/probe");
|
|
87
|
-
const b = await app.request("/api/probe");
|
|
88
104
|
expect(a.status).toBe(200);
|
|
89
|
-
|
|
105
|
+
const b = await app.request("/api/probe");
|
|
106
|
+
expect(b.status).toBe(429);
|
|
90
107
|
});
|
|
91
108
|
|
|
92
109
|
test("fail-closed when resolver throws non-RateLimit error (Redis down)", async () => {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
import { isAuthRateLimitExempt } from "../api/api-constants";
|
|
3
|
+
import {
|
|
4
|
+
type ClientIpResolver,
|
|
5
|
+
clientIpSourceFromHonoContext,
|
|
6
|
+
createClientIpResolver,
|
|
7
|
+
} from "../api/client-ip";
|
|
3
8
|
import { requestContext } from "../api/request-context";
|
|
4
9
|
import { RateLimitError, serializeError } from "../errors";
|
|
5
10
|
import type { RateLimitDecision, RateLimitResolver } from "./resolver";
|
|
@@ -22,8 +27,17 @@ export type GlobalIpRateLimitOptions = {
|
|
|
22
27
|
readonly resolver: RateLimitResolver;
|
|
23
28
|
readonly limit?: number;
|
|
24
29
|
readonly windowSeconds?: number;
|
|
30
|
+
// Number of trusted reverse-proxy hops for the default client-IP
|
|
31
|
+
// resolver — see createClientIpResolver. Ignored when `extractIp` is set.
|
|
32
|
+
// Default 0 (no proxy trusted).
|
|
33
|
+
readonly trustedProxyHops?: number;
|
|
34
|
+
// Shared resolver instance from buildServer — takes precedence over
|
|
35
|
+
// `trustedProxyHops` so the whole server has exactly one resolver (and
|
|
36
|
+
// one warn-once flag) rather than one per middleware.
|
|
37
|
+
// Only standalone callers (no buildServer) should rely on `trustedProxyHops`.
|
|
38
|
+
readonly clientIpResolver?: ClientIpResolver;
|
|
25
39
|
// Override IP extraction — useful when behind a non-standard proxy.
|
|
26
|
-
// Default:
|
|
40
|
+
// Default: the shared resolveClientIp, honoring `trustedProxyHops`.
|
|
27
41
|
readonly extractIp?: (c: Context) => string | undefined;
|
|
28
42
|
// Hook for ops logging when fail-closed fires (Redis down). Default:
|
|
29
43
|
// emits to console.error so the misbehaviour is loud at minimum.
|
|
@@ -33,7 +47,11 @@ export type GlobalIpRateLimitOptions = {
|
|
|
33
47
|
export function globalIpRateLimit(opts: GlobalIpRateLimitOptions): MiddlewareHandler {
|
|
34
48
|
const limit = opts.limit ?? 1000;
|
|
35
49
|
const windowSeconds = opts.windowSeconds ?? 60;
|
|
36
|
-
const
|
|
50
|
+
const clientIpResolver =
|
|
51
|
+
opts.clientIpResolver ??
|
|
52
|
+
createClientIpResolver(opts.trustedProxyHops ?? 0, "globalIpRateLimit");
|
|
53
|
+
const extractIp =
|
|
54
|
+
opts.extractIp ?? ((c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c)));
|
|
37
55
|
const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("l1-global-ip");
|
|
38
56
|
|
|
39
57
|
return async (c, next) => {
|
|
@@ -73,6 +91,13 @@ export type AuthEndpointRateLimitOptions = {
|
|
|
73
91
|
// top of IP. Default: bucket on `l2:${ip}:${path}` (IP + route),
|
|
74
92
|
// which catches naive IP-flood without consuming the request body.
|
|
75
93
|
readonly extractTarget?: (c: Context) => string | undefined | Promise<string | undefined>;
|
|
94
|
+
// Number of trusted reverse-proxy hops for the default client-IP
|
|
95
|
+
// resolver — see createClientIpResolver. Ignored when `extractIp` is set.
|
|
96
|
+
// Default 0 (no proxy trusted).
|
|
97
|
+
readonly trustedProxyHops?: number;
|
|
98
|
+
// See GlobalIpRateLimitOptions.clientIpResolver — same single-resolver
|
|
99
|
+
// rationale.
|
|
100
|
+
readonly clientIpResolver?: ClientIpResolver;
|
|
76
101
|
readonly extractIp?: (c: Context) => string | undefined;
|
|
77
102
|
readonly onFailClosed?: (err: unknown) => void;
|
|
78
103
|
};
|
|
@@ -80,7 +105,11 @@ export type AuthEndpointRateLimitOptions = {
|
|
|
80
105
|
export function authEndpointRateLimit(opts: AuthEndpointRateLimitOptions): MiddlewareHandler {
|
|
81
106
|
const limit = opts.limit ?? 5;
|
|
82
107
|
const windowSeconds = opts.windowSeconds ?? 60;
|
|
83
|
-
const
|
|
108
|
+
const clientIpResolver =
|
|
109
|
+
opts.clientIpResolver ??
|
|
110
|
+
createClientIpResolver(opts.trustedProxyHops ?? 0, "authEndpointRateLimit");
|
|
111
|
+
const extractIp =
|
|
112
|
+
opts.extractIp ?? ((c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c)));
|
|
84
113
|
const extractTarget = opts.extractTarget;
|
|
85
114
|
const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("l2-auth-endpoints");
|
|
86
115
|
|
|
@@ -110,11 +139,6 @@ export function authEndpointRateLimit(opts: AuthEndpointRateLimitOptions): Middl
|
|
|
110
139
|
};
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
function defaultExtractIp(c: Context): string | undefined {
|
|
114
|
-
const xff = c.req.header("x-forwarded-for");
|
|
115
|
-
return xff?.split(",")[0]?.trim() || undefined;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
142
|
function defaultOnFailClosed(label: string): (err: unknown) => void {
|
|
119
143
|
return (err) => {
|
|
120
144
|
// Loud by default — fail-closed for an unknown reason is an ops
|