Skip to content

Commit 8508599

Browse files
committed
Merge branch 'main' into staging
2 parents 0e60305 + 42c2dfc commit 8508599

6 files changed

Lines changed: 56 additions & 29 deletions

File tree

apps/server/src/context.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { createRedisClient, type RedisClient } from "@nimbus/cache";
22
import { createAuth, type Auth } from "@nimbus/auth/auth";
33
import { createEnv, type Env } from "@nimbus/env/server";
44
import { createDb, type DB } from "@nimbus/db";
5-
import type { PublicRouterVars } from "./hono";
65
import type { Context } from "hono";
76
import { Resend } from "resend";
87

@@ -69,7 +68,7 @@ export class ContextManager {
6968
}
7069
}
7170

72-
public async createContext(): Promise<PublicRouterVars> {
71+
public async createContext() {
7372
const env = this.env;
7473

7574
const [db, redisClient] = await Promise.all([this.initializeDatabase(env), this.initializeRedis(env)]);

apps/server/src/hono.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { Provider } from "./providers/interface/provider";
2+
import { Hono, type Context, type Env as HonoEnv } from "hono";
23
import type { Auth, SessionUser } from "@nimbus/auth/auth";
34
import { getContext } from "hono/context-storage";
45
import type { RedisClient } from "@nimbus/cache";
5-
import { Hono, type Env as HonoEnv } from "hono";
6+
import type { ContextManager } from "./context";
67
import type { Env } from "@nimbus/env/server";
78
import type { DB } from "@nimbus/db";
89

910
export interface BaseRouterVars {
11+
contextManager: ContextManager;
1012
env: Env;
1113
}
1214

@@ -36,6 +38,8 @@ export interface DriveProviderRouterEnv {
3638
Variables: DriveProviderRouterVars;
3739
}
3840

41+
export type PublicRouterContext = Context<PublicRouterEnv>;
42+
3943
function createHono<T extends HonoEnv>() {
4044
return new Hono<T>();
4145
}

apps/server/src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import routes from "./routes";
88
const app = createPublicRouter()
99
.use(contextStorage())
1010
.use("*", async (c, next) => {
11-
const env = ContextManager.getInstance().env;
11+
const contextManager = ContextManager.getInstance();
12+
const env = contextManager.env;
13+
c.set("contextManager", contextManager);
1214
c.set("env", env);
1315
await next();
1416
})
@@ -23,7 +25,7 @@ const app = createPublicRouter()
2325
)
2426
.use("*", async (c, next) => {
2527
const env = c.var.env;
26-
const { db, redisClient, auth } = await ContextManager.getInstance().createContext();
28+
const { db, redisClient, auth } = await c.var.contextManager.createContext();
2729
c.set("db", db);
2830
c.set("redisClient", redisClient);
2931
c.set("auth", auth);
@@ -33,7 +35,7 @@ const app = createPublicRouter()
3335
// WARNING: make sure to add WRANGLER_DEV to .dev.vars for wrangler dev
3436
// for local dev, always keep context open UNLESS wrangler dev, close context
3537
if (env.IS_EDGE_RUNTIME && (env.NODE_ENV === "production" || env.WRANGLER_DEV)) {
36-
await ContextManager.getInstance().close();
38+
await c.var.contextManager.close();
3739
}
3840
}
3941
})

apps/server/src/middleware/security.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { createRateLimiter, type CreateRateLimiterContext } from "@nimbus/cache/rate-limiters";
1+
import { createRateLimiter, type RateLimiterConfig } from "@nimbus/cache/rate-limiters";
2+
import { UpstashRateLimit, ValkeyRateLimit } from "@nimbus/cache";
3+
import type { PublicRouterContext } from "../hono";
24
import type { RateLimiter } from "@nimbus/cache";
35
import { sendError } from "../routes/utils";
46
import type { Context, Next } from "hono";
@@ -10,16 +12,21 @@ import { webcrypto } from "node:crypto";
1012
interface SecurityOptions {
1113
rateLimiting?: {
1214
enabled: boolean;
13-
rateLimiter: (c: Context) => RateLimiter;
15+
// Returns a factory that accepts an identifier and returns a RateLimiter instance
16+
rateLimiter: () => RateLimiter;
1417
};
1518
securityHeaders?: boolean;
1619
}
1720

18-
export function buildSecurityMiddleware(ctx: CreateRateLimiterContext) {
21+
export function buildSecurityMiddleware(ctx: PublicRouterContext, config: RateLimiterConfig) {
1922
return securityMiddleware({
2023
rateLimiting: {
2124
enabled: true,
22-
rateLimiter: _c => createRateLimiter(ctx),
25+
rateLimiter: createRateLimiter({
26+
isEdgeRuntime: ctx.var.env.IS_EDGE_RUNTIME,
27+
redisClient: ctx.var.redisClient,
28+
config,
29+
}),
2330
},
2431
securityHeaders: true,
2532
});
@@ -46,7 +53,9 @@ const getClientIp = (c: Context): string => {
4653
return realIp.trim();
4754
}
4855

49-
return `unidentifiable-${webcrypto.randomUUID()}`;
56+
return "unidentifiable";
57+
// Gotta block for now, can't let unknown IP addresses through
58+
// return `unidentifiable-${webcrypto.randomUUID()}`;
5059
};
5160

5261
const securityMiddleware = (options: SecurityOptions = {}) => {
@@ -114,9 +123,10 @@ const securityMiddleware = (options: SecurityOptions = {}) => {
114123
const identifier = user?.id || ip;
115124

116125
try {
117-
const limiter = rateLimiting.rateLimiter(c);
118-
if ("limit" in limiter) {
119-
// Handle Upstash limit
126+
const limiterFactory = rateLimiting.rateLimiter;
127+
const limiter = limiterFactory();
128+
if (limiter instanceof UpstashRateLimit) {
129+
// Upstash
120130
const result = await limiter.limit(identifier);
121131
if (!result.success) {
122132
const retryAfter = Math.ceil((result.reset - Date.now()) / 1000);
@@ -130,8 +140,8 @@ const securityMiddleware = (options: SecurityOptions = {}) => {
130140
429
131141
);
132142
}
133-
} else {
134-
// Handle Valkey limit
143+
} else if (limiter instanceof ValkeyRateLimit) {
144+
// Valkey
135145
await limiter.consume(identifier);
136146
}
137147
} catch (error: any) {

apps/server/src/routes/waitlist/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1+
import { createPublicRouter, type PublicRouterContext } from "../../hono";
12
import { emailObjectSchema, type WaitlistCount } from "@nimbus/shared";
3+
import { buildSecurityMiddleware } from "../../middleware/security";
24
import { sendError, sendSuccess } from "../utils";
35
import { zValidator } from "@hono/zod-validator";
4-
import { createPublicRouter } from "../../hono";
56
import { waitlist } from "@nimbus/db/schema";
67
import { count } from "drizzle-orm";
78
import { nanoid } from "nanoid";
89

10+
const rateLimiter = (c: PublicRouterContext) =>
11+
buildSecurityMiddleware(c, {
12+
points: 3,
13+
duration: 120, // 2 minutes
14+
blockDuration: 60, // 1 minute
15+
keyPrefix: "rl:waitlist",
16+
});
17+
918
const waitlistRouter = createPublicRouter()
19+
.use("*", (c, next) => rateLimiter(c)(c, next))
1020
.get("/count", async c => {
1121
try {
1222
const result = await c.var.db.select({ count: count() }).from(waitlist);

packages/cache/src/rate-limiters.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,35 @@ import { type RateLimiter, type RedisClient, UpstashRateLimit, ValkeyRateLimit }
22
import { Redis as UpstashRedis } from "@upstash/redis/cloudflare";
33
import { Redis as ValkeyRedis } from "iovalkey";
44

5-
interface RateLimiterConfig {
5+
export interface RateLimiterConfig {
66
points: number;
77
duration: number;
88
blockDuration?: number;
99
keyPrefix: string;
1010
}
1111

12-
interface RateLimiterFactory<T extends RateLimiter> {
13-
(identifier: string): T;
12+
export interface RateLimiterFactory<T extends RateLimiter> {
13+
(): T;
1414
}
1515

1616
export interface CreateRateLimiterContext {
1717
isEdgeRuntime: boolean;
1818
redisClient: RedisClient;
1919
config: RateLimiterConfig;
20-
identifier: string;
2120
}
2221

2322
// Create Upstash rate limiter factory
2423
function createUpstashRateLimiter(
2524
redisClient: UpstashRedis,
2625
config: RateLimiterConfig
2726
): RateLimiterFactory<UpstashRateLimit> {
28-
return (identifier: string) =>
27+
return () =>
2928
new UpstashRateLimit({
3029
redis: redisClient,
31-
prefix: `${config.keyPrefix}${identifier}`,
32-
limiter: UpstashRateLimit.slidingWindow(config.points, `${Math.ceil(config.duration / 60)} s`),
30+
// Do not include the identifier in the prefix; it's passed to limit() per request
31+
prefix: `${config.keyPrefix}`,
32+
// config.duration is in seconds (to match Valkey). Upstash accepts duration strings like "10 s".
33+
limiter: UpstashRateLimit.slidingWindow(config.points, `${config.duration} s`),
3334
analytics: true,
3435
});
3536
}
@@ -39,10 +40,11 @@ function createValkeyRateLimiter(
3940
redisClient: ValkeyRedis,
4041
config: RateLimiterConfig
4142
): RateLimiterFactory<ValkeyRateLimit> {
42-
return (identifier: string) =>
43+
return () =>
4344
new ValkeyRateLimit({
4445
storeClient: redisClient,
45-
keyPrefix: `${config.keyPrefix}${identifier}`,
46+
// Keep prefix stable and pass identifier to consume()
47+
keyPrefix: `${config.keyPrefix}`,
4648
points: config.points,
4749
duration: config.duration,
4850
blockDuration: config.blockDuration,
@@ -51,10 +53,10 @@ function createValkeyRateLimiter(
5153
});
5254
}
5355

54-
export function createRateLimiter(ctx: CreateRateLimiterContext): RateLimiter {
56+
export function createRateLimiter(ctx: CreateRateLimiterContext): RateLimiterFactory<RateLimiter> {
5557
if (ctx.isEdgeRuntime) {
56-
return createUpstashRateLimiter(ctx.redisClient as UpstashRedis, ctx.config)(ctx.identifier);
58+
return createUpstashRateLimiter(ctx.redisClient as UpstashRedis, ctx.config) as RateLimiterFactory<RateLimiter>;
5759
} else {
58-
return createValkeyRateLimiter(ctx.redisClient as ValkeyRedis, ctx.config)(ctx.identifier);
60+
return createValkeyRateLimiter(ctx.redisClient as ValkeyRedis, ctx.config) as RateLimiterFactory<RateLimiter>;
5961
}
6062
}

0 commit comments

Comments
 (0)