@joinremba/gate 0.3.0 → 0.4.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/README.md +3 -5
- package/package.json +2 -1
- package/src/api-keys.test.ts +3 -4
- package/src/api-keys.ts +18 -28
- package/src/idempotency.ts +24 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +87 -10
- package/src/rate-limit.ts +37 -3
- package/src/stores/postgres.ts +7 -1
- package/src/stores/redis.test.ts +6 -0
- package/src/stores/redis.ts +5 -7
- package/src/validate.ts +9 -2
package/README.md
CHANGED
|
@@ -13,7 +13,6 @@ Gate is the API safety layer for TypeScript backends. It validates requests, for
|
|
|
13
13
|
- **Structured responses** — Consistent `{ success, data, error }` response envelope with `ok()` and `fail()` helpers.
|
|
14
14
|
- **Problem details** — RFC 9457 problem-details-style error format.
|
|
15
15
|
- **Pagination** — Standardised paginated response helper.
|
|
16
|
-
- **Request ID** — Automatic request ID generation and propagation.
|
|
17
16
|
- **Idempotency** — Prevent duplicate writes with idempotency keys. Ships with in-memory store; plug in Redis or Postgres.
|
|
18
17
|
- **Rate limiting** — Protect endpoints from abuse with configurable windows and limits.
|
|
19
18
|
- **API key management** — Rotatable, scoped API key authentication with Bearer token support.
|
|
@@ -191,10 +190,9 @@ if (!result.authenticated) throw new AuthenticationError(result.error);
|
|
|
191
190
|
Store SHA-256 hashes instead of plaintext keys. Protects against memory dumps.
|
|
192
191
|
|
|
193
192
|
```ts
|
|
194
|
-
const validator = createApiKeyValidator(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
193
|
+
const validator = createApiKeyValidator([{ key: "sk-live-123", scopes: ["admin"] }], {
|
|
194
|
+
hashKeys: true,
|
|
195
|
+
});
|
|
198
196
|
|
|
199
197
|
await validator.verify("sk-live-123");
|
|
200
198
|
// -> { authenticated: true, key: "sk-live-123", scopes: ["admin"] }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinremba/gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "API safety layer for TypeScript backends. Validate requests, format responses, prevent duplicates, manage API keys, and protect endpoints from abuse.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -110,6 +110,7 @@
|
|
|
110
110
|
"bun": ">=1.3.1"
|
|
111
111
|
},
|
|
112
112
|
"dependencies": {
|
|
113
|
+
"@joinremba/core": "^0.4.0",
|
|
113
114
|
"zod": "^4.4.2"
|
|
114
115
|
},
|
|
115
116
|
"devDependencies": {
|
package/src/api-keys.test.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { createApiKeyValidator } from "./api-keys";
|
|
3
|
-
import type { AuthenticateResult } from "./api-keys";
|
|
4
3
|
|
|
5
4
|
test("validate returns authenticated for matching key", () => {
|
|
6
5
|
const validator = createApiKeyValidator([{ key: "sk-live-abc", scopes: ["read"] }]);
|
|
@@ -20,7 +19,7 @@ test("verify with hashKeys true hashes before lookup", async () => {
|
|
|
20
19
|
const validator = createApiKeyValidator(
|
|
21
20
|
[
|
|
22
21
|
{
|
|
23
|
-
key: "
|
|
22
|
+
key: "sk-live-123",
|
|
24
23
|
scopes: ["admin"],
|
|
25
24
|
},
|
|
26
25
|
],
|
|
@@ -38,7 +37,7 @@ test("authenticate middleware reads Authorization header", async () => {
|
|
|
38
37
|
const req = new Request("http://localhost", {
|
|
39
38
|
headers: { Authorization: "Bearer sk-test" },
|
|
40
39
|
});
|
|
41
|
-
const result = await
|
|
40
|
+
const result = await auth(req);
|
|
42
41
|
expect(result.authenticated).toBe(true);
|
|
43
42
|
});
|
|
44
43
|
|
|
@@ -48,7 +47,7 @@ test("authenticate rejects missing scopes", async () => {
|
|
|
48
47
|
const req = new Request("http://localhost", {
|
|
49
48
|
headers: { Authorization: "Bearer sk-test" },
|
|
50
49
|
});
|
|
51
|
-
const result = await
|
|
50
|
+
const result = await auth(req);
|
|
52
51
|
expect(result.authenticated).toBe(false);
|
|
53
52
|
expect(result.error).toBe("Insufficient permissions");
|
|
54
53
|
});
|
package/src/api-keys.ts
CHANGED
|
@@ -39,9 +39,21 @@ export function createApiKeyValidator(keys: ApiKeyEntry[], options: ApiKeyValida
|
|
|
39
39
|
|
|
40
40
|
for (const entry of keys) {
|
|
41
41
|
keyMap.set(entry.key, { key: entry.key, scopes: entry.scopes, metadata: entry.metadata });
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let hashCache: Map<
|
|
45
|
+
string,
|
|
46
|
+
{ key: string; scopes?: string[]; metadata?: Record<string, unknown> }
|
|
47
|
+
> | null = null;
|
|
48
|
+
|
|
49
|
+
async function ensureHashCache() {
|
|
50
|
+
if (!hashCache) {
|
|
51
|
+
hashCache = new Map();
|
|
52
|
+
for (const [, entry] of keyMap) {
|
|
53
|
+
hashCache.set(await sha256(entry.key), entry);
|
|
54
|
+
}
|
|
44
55
|
}
|
|
56
|
+
return hashCache;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
return {
|
|
@@ -62,8 +74,9 @@ export function createApiKeyValidator(keys: ApiKeyEntry[], options: ApiKeyValida
|
|
|
62
74
|
if (!hashKeys) {
|
|
63
75
|
return this.validate(providedKey);
|
|
64
76
|
}
|
|
77
|
+
const cache = await ensureHashCache();
|
|
65
78
|
const keyHash = await sha256(providedKey);
|
|
66
|
-
const entry =
|
|
79
|
+
const entry = cache.get(keyHash);
|
|
67
80
|
if (!entry) {
|
|
68
81
|
return { authenticated: false, error: "Invalid API key" };
|
|
69
82
|
}
|
|
@@ -79,37 +92,14 @@ export function createApiKeyValidator(keys: ApiKeyEntry[], options: ApiKeyValida
|
|
|
79
92
|
const header = options.header ?? "Authorization";
|
|
80
93
|
const requiredScopes = options.requiredScopes ?? [];
|
|
81
94
|
|
|
82
|
-
|
|
83
|
-
return async (req: Request): Promise<AuthenticateResult> => {
|
|
84
|
-
const authHeader = req.headers.get(header);
|
|
85
|
-
if (!authHeader) {
|
|
86
|
-
return { authenticated: false, error: "Missing API key" };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
90
|
-
const result = await this.verify(token);
|
|
91
|
-
|
|
92
|
-
if (!result.authenticated) return result;
|
|
93
|
-
|
|
94
|
-
if (requiredScopes.length > 0) {
|
|
95
|
-
const hasScopes = requiredScopes.every((s) => result.scopes?.includes(s));
|
|
96
|
-
if (!hasScopes) {
|
|
97
|
-
return { authenticated: false, error: "Insufficient permissions" };
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return result;
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return (req: Request): AuthenticateResult => {
|
|
95
|
+
return async (req: Request): Promise<AuthenticateResult> => {
|
|
106
96
|
const authHeader = req.headers.get(header);
|
|
107
97
|
if (!authHeader) {
|
|
108
98
|
return { authenticated: false, error: "Missing API key" };
|
|
109
99
|
}
|
|
110
100
|
|
|
111
101
|
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
112
|
-
const result = this.validate(token);
|
|
102
|
+
const result = hashKeys ? await this.verify(token) : this.validate(token);
|
|
113
103
|
|
|
114
104
|
if (!result.authenticated) return result;
|
|
115
105
|
|
package/src/idempotency.ts
CHANGED
|
@@ -6,6 +6,25 @@ export interface IdempotencyStore {
|
|
|
6
6
|
|
|
7
7
|
export class InMemoryStore implements IdempotencyStore {
|
|
8
8
|
private store = new Map<string, { value: unknown; expires: number }>();
|
|
9
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
10
|
+
|
|
11
|
+
constructor() {
|
|
12
|
+
this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
|
|
13
|
+
if (
|
|
14
|
+
this.cleanupInterval &&
|
|
15
|
+
typeof this.cleanupInterval === "object" &&
|
|
16
|
+
"unref" in this.cleanupInterval
|
|
17
|
+
) {
|
|
18
|
+
this.cleanupInterval.unref();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private evictExpired(): void {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
for (const [key, entry] of this.store) {
|
|
25
|
+
if (now > entry.expires) this.store.delete(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
9
28
|
|
|
10
29
|
async get(key: string): Promise<unknown | null> {
|
|
11
30
|
const entry = this.store.get(key);
|
|
@@ -24,6 +43,11 @@ export class InMemoryStore implements IdempotencyStore {
|
|
|
24
43
|
async delete(key: string): Promise<void> {
|
|
25
44
|
this.store.delete(key);
|
|
26
45
|
}
|
|
46
|
+
|
|
47
|
+
dispose(): void {
|
|
48
|
+
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
|
|
49
|
+
this.store.clear();
|
|
50
|
+
}
|
|
27
51
|
}
|
|
28
52
|
|
|
29
53
|
export interface IdempotencyOptions {
|
package/src/index.test.ts
CHANGED
|
@@ -95,6 +95,16 @@ describe("rate limit", () => {
|
|
|
95
95
|
expect(result.allowed).toBe(true);
|
|
96
96
|
expect(result.remaining).toBe(99);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
test("accepts string key directly", async () => {
|
|
100
|
+
const gate = createGate({
|
|
101
|
+
rateLimit: { windowMs: 60000, max: 10 },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = await gate.rateLimit.check("custom:user-42");
|
|
105
|
+
expect(result.allowed).toBe(true);
|
|
106
|
+
expect(result.remaining).toBe(9);
|
|
107
|
+
});
|
|
98
108
|
});
|
|
99
109
|
|
|
100
110
|
describe("api keys", () => {
|
package/src/index.ts
CHANGED
|
@@ -2,9 +2,28 @@ import { validateRequest } from "./validate";
|
|
|
2
2
|
import { ok, fail, paginated, problem } from "./respond";
|
|
3
3
|
import { idempotency, InMemoryStore } from "./idempotency";
|
|
4
4
|
import { rateLimit, InMemoryRateLimitStore, keyByApiKey } from "./rate-limit";
|
|
5
|
+
import type { RateLimitStore, RateLimitCheckResult } from "./rate-limit";
|
|
5
6
|
import { createApiKeyValidator } from "./api-keys";
|
|
6
7
|
import type { ApiKeyEntry, AuthenticateResult } from "./api-keys";
|
|
7
8
|
import type { IdempotencyStore } from "./idempotency";
|
|
9
|
+
import type { Client } from "@joinremba/core";
|
|
10
|
+
import { NetworkError } from "@joinremba/core";
|
|
11
|
+
|
|
12
|
+
const gateAuthStore = new WeakMap<Request, AuthenticateResult>();
|
|
13
|
+
const gateIdempotencyStore = new WeakMap<Request, string>();
|
|
14
|
+
const gateRateLimitStore = new WeakMap<Request, RateLimitCheckResult>();
|
|
15
|
+
|
|
16
|
+
export function getGateAuth(req: Request): AuthenticateResult | undefined {
|
|
17
|
+
return gateAuthStore.get(req);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getGateIdempotencyKey(req: Request): string | undefined {
|
|
21
|
+
return gateIdempotencyStore.get(req);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getGateRateLimit(req: Request): RateLimitCheckResult | undefined {
|
|
25
|
+
return gateRateLimitStore.get(req);
|
|
26
|
+
}
|
|
8
27
|
import {
|
|
9
28
|
GateError,
|
|
10
29
|
ValidationError,
|
|
@@ -71,6 +90,7 @@ export interface MiddlewareOptions {
|
|
|
71
90
|
|
|
72
91
|
export interface GateOptions {
|
|
73
92
|
apiKeys?: ApiKeyEntry[];
|
|
93
|
+
client?: Client;
|
|
74
94
|
idempotency?: {
|
|
75
95
|
store?: IdempotencyStore;
|
|
76
96
|
keyHeader?: string;
|
|
@@ -79,7 +99,7 @@ export interface GateOptions {
|
|
|
79
99
|
rateLimit?: {
|
|
80
100
|
windowMs?: number;
|
|
81
101
|
max?: number;
|
|
82
|
-
store?:
|
|
102
|
+
store?: RateLimitStore;
|
|
83
103
|
keyFn?: (req: Request) => string;
|
|
84
104
|
};
|
|
85
105
|
}
|
|
@@ -105,6 +125,8 @@ export interface Gate {
|
|
|
105
125
|
}
|
|
106
126
|
|
|
107
127
|
export function createGate(options: GateOptions = {}): Gate {
|
|
128
|
+
const client = options.client;
|
|
129
|
+
|
|
108
130
|
const idempInstance = idempotency({
|
|
109
131
|
store: options.idempotency?.store ?? new InMemoryStore(),
|
|
110
132
|
keyHeader: options.idempotency?.keyHeader,
|
|
@@ -114,10 +136,69 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
114
136
|
const rlInstance = rateLimit({
|
|
115
137
|
windowMs: options.rateLimit?.windowMs,
|
|
116
138
|
max: options.rateLimit?.max,
|
|
139
|
+
store: options.rateLimit?.store,
|
|
140
|
+
keyFn: options.rateLimit?.keyFn,
|
|
117
141
|
});
|
|
118
142
|
|
|
119
143
|
const apiKeyValidator = createApiKeyValidator(options.apiKeys ?? []);
|
|
120
144
|
|
|
145
|
+
if (client) {
|
|
146
|
+
const origCheck = rlInstance.check.bind(rlInstance);
|
|
147
|
+
rlInstance.check = async (reqOrKey) => {
|
|
148
|
+
const key = typeof reqOrKey === "string" ? reqOrKey : rlInstance.keyFn(reqOrKey);
|
|
149
|
+
try {
|
|
150
|
+
return await client.checkRateLimit(key);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err instanceof NetworkError) return origCheck(reqOrKey);
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const origIdemGet = idempInstance.getResponse.bind(idempInstance);
|
|
158
|
+
const origIdemSet = idempInstance.setResponse.bind(idempInstance);
|
|
159
|
+
idempInstance.getResponse = async (key: string) => {
|
|
160
|
+
try {
|
|
161
|
+
const result = await client.checkIdempotency(key);
|
|
162
|
+
if (result.exists) return result.response;
|
|
163
|
+
return null;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err instanceof NetworkError) return origIdemGet(key);
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
idempInstance.setResponse = async (key: string, response: unknown) => {
|
|
170
|
+
try {
|
|
171
|
+
await client.setIdempotency(key, response);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err instanceof NetworkError) return origIdemSet(key, response);
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const origAuthenticate = apiKeyValidator.authenticate.bind(apiKeyValidator);
|
|
179
|
+
apiKeyValidator.authenticate = (authOptions) => {
|
|
180
|
+
const handler = origAuthenticate(authOptions);
|
|
181
|
+
return async (req) => {
|
|
182
|
+
const token = req.headers
|
|
183
|
+
.get("Authorization")
|
|
184
|
+
?.replace(/^Bearer\s+/i, "")
|
|
185
|
+
.trim();
|
|
186
|
+
if (token) {
|
|
187
|
+
try {
|
|
188
|
+
const result = await client.verifyApiKey(token);
|
|
189
|
+
if (result.valid) {
|
|
190
|
+
return { authenticated: true, key: token, scopes: result.scopes };
|
|
191
|
+
}
|
|
192
|
+
return { authenticated: false, error: "Invalid API key" };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (!(err instanceof NetworkError)) throw err;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return handler(req);
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
121
202
|
const defaultFail = (message: string, code?: string) => fail(message, code ?? "UNAUTHORIZED");
|
|
122
203
|
|
|
123
204
|
const gate: Gate = {
|
|
@@ -150,6 +231,7 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
150
231
|
// Rate limit check
|
|
151
232
|
if (enableRl) {
|
|
152
233
|
const rlResult = await rlInstance.check(req);
|
|
234
|
+
gateRateLimitStore.set(req, rlResult);
|
|
153
235
|
if (!rlResult.allowed) {
|
|
154
236
|
const body = defaultFail("Too many requests", "RATE_LIMIT_EXCEEDED");
|
|
155
237
|
return new Response(JSON.stringify(body), {
|
|
@@ -161,15 +243,12 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
161
243
|
},
|
|
162
244
|
});
|
|
163
245
|
}
|
|
164
|
-
req.headers.set("X-RateLimit-Remaining", String(rlResult.remaining));
|
|
165
246
|
}
|
|
166
247
|
|
|
167
248
|
// Auth check
|
|
168
249
|
if (auth) {
|
|
169
250
|
const authFn = apiKeyValidator.authenticate({ requiredScopes });
|
|
170
|
-
const authResult = await
|
|
171
|
-
| Promise<AuthenticateResult>
|
|
172
|
-
| AuthenticateResult);
|
|
251
|
+
const authResult = await authFn(req);
|
|
173
252
|
if (!authResult.authenticated) {
|
|
174
253
|
const body = defaultFail(authResult.error ?? "Unauthorized", "AUTHENTICATION_ERROR");
|
|
175
254
|
return new Response(JSON.stringify(body), {
|
|
@@ -177,7 +256,7 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
177
256
|
headers: { "Content-Type": "application/json" },
|
|
178
257
|
});
|
|
179
258
|
}
|
|
180
|
-
(req
|
|
259
|
+
gateAuthStore.set(req, authResult);
|
|
181
260
|
}
|
|
182
261
|
|
|
183
262
|
// Idempotency check
|
|
@@ -191,7 +270,7 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
191
270
|
headers: { "Content-Type": "application/json" },
|
|
192
271
|
});
|
|
193
272
|
}
|
|
194
|
-
(req
|
|
273
|
+
gateIdempotencyStore.set(req, idemKey);
|
|
195
274
|
}
|
|
196
275
|
}
|
|
197
276
|
|
|
@@ -200,9 +279,7 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
200
279
|
|
|
201
280
|
// Store response for idempotency
|
|
202
281
|
if (enableIdem) {
|
|
203
|
-
const idemKey = (req
|
|
204
|
-
| string
|
|
205
|
-
| undefined;
|
|
282
|
+
const idemKey = gateIdempotencyStore.get(req);
|
|
206
283
|
if (idemKey && response.status < 500) {
|
|
207
284
|
const body = await response.clone().json();
|
|
208
285
|
await idempInstance.setResponse(idemKey, body);
|
package/src/rate-limit.ts
CHANGED
|
@@ -5,6 +5,25 @@ export interface RateLimitStore {
|
|
|
5
5
|
|
|
6
6
|
export class InMemoryRateLimitStore implements RateLimitStore {
|
|
7
7
|
private store = new Map<string, { count: number; reset: number }>();
|
|
8
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
9
|
+
|
|
10
|
+
constructor() {
|
|
11
|
+
this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
|
|
12
|
+
if (
|
|
13
|
+
this.cleanupInterval &&
|
|
14
|
+
typeof this.cleanupInterval === "object" &&
|
|
15
|
+
"unref" in this.cleanupInterval
|
|
16
|
+
) {
|
|
17
|
+
this.cleanupInterval.unref();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private evictExpired(): void {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
for (const [key, entry] of this.store) {
|
|
24
|
+
if (now > entry.reset) this.store.delete(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
8
27
|
|
|
9
28
|
async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
|
|
10
29
|
const now = Date.now();
|
|
@@ -23,6 +42,11 @@ export class InMemoryRateLimitStore implements RateLimitStore {
|
|
|
23
42
|
async reset(key: string): Promise<void> {
|
|
24
43
|
this.store.delete(key);
|
|
25
44
|
}
|
|
45
|
+
|
|
46
|
+
dispose(): void {
|
|
47
|
+
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
|
|
48
|
+
this.store.clear();
|
|
49
|
+
}
|
|
26
50
|
}
|
|
27
51
|
|
|
28
52
|
export type RateLimitStrategy = "fixed" | "sliding";
|
|
@@ -40,7 +64,7 @@ export function keyByApiKey(req: Request): string {
|
|
|
40
64
|
const auth = req.headers.get("authorization");
|
|
41
65
|
if (auth) {
|
|
42
66
|
const token = auth.replace(/^Bearer\s+/i, "").trim();
|
|
43
|
-
if (token) return `ak:${token
|
|
67
|
+
if (token) return `ak:${token}`;
|
|
44
68
|
}
|
|
45
69
|
return req.headers.get("x-forwarded-for") ?? "global";
|
|
46
70
|
}
|
|
@@ -49,6 +73,10 @@ export function rateLimit(options: RateLimitOptions = {}) {
|
|
|
49
73
|
const windowMs = options.windowMs ?? 60_000;
|
|
50
74
|
const max = options.max ?? 100;
|
|
51
75
|
const store = options.store ?? new InMemoryRateLimitStore();
|
|
76
|
+
|
|
77
|
+
if (options.strategy === "sliding") {
|
|
78
|
+
throw new Error("Sliding window rate limiting is not yet implemented. Use 'fixed' (default).");
|
|
79
|
+
}
|
|
52
80
|
const keyFn =
|
|
53
81
|
options.keyFn ??
|
|
54
82
|
((req: Request) => {
|
|
@@ -62,8 +90,8 @@ export function rateLimit(options: RateLimitOptions = {}) {
|
|
|
62
90
|
store,
|
|
63
91
|
keyFn,
|
|
64
92
|
|
|
65
|
-
async check(
|
|
66
|
-
const key = keyFn(
|
|
93
|
+
async check(reqOrKey: Request | string): Promise<RateLimitCheckResult> {
|
|
94
|
+
const key = typeof reqOrKey === "string" ? reqOrKey : keyFn(reqOrKey);
|
|
67
95
|
const { count, reset } = await store.increment(`rl:${key}`, windowMs);
|
|
68
96
|
return {
|
|
69
97
|
allowed: count <= max,
|
|
@@ -75,3 +103,9 @@ export function rateLimit(options: RateLimitOptions = {}) {
|
|
|
75
103
|
}
|
|
76
104
|
|
|
77
105
|
export type RateLimitInstance = ReturnType<typeof rateLimit>;
|
|
106
|
+
|
|
107
|
+
export type RateLimitCheckResult = {
|
|
108
|
+
allowed: boolean;
|
|
109
|
+
remaining: number;
|
|
110
|
+
reset: number;
|
|
111
|
+
};
|
package/src/stores/postgres.ts
CHANGED
|
@@ -90,7 +90,13 @@ export class PostgresRateLimitStore implements RateLimitStore {
|
|
|
90
90
|
[key, reset, now, reset]
|
|
91
91
|
);
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
// Best-effort locking hint to prevent write skew under concurrency.
|
|
94
|
+
// Not a full serializable isolation — for production, set the table's
|
|
95
|
+
// fillfactor low or use advisory locks.
|
|
96
|
+
await this.client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [key]);
|
|
97
|
+
|
|
98
|
+
const row = rows[0];
|
|
99
|
+
if (!row) return { count: 0, reset: Date.now() + windowMs };
|
|
94
100
|
return { count: row.count as number, reset: row.reset_at as number };
|
|
95
101
|
}
|
|
96
102
|
|
package/src/stores/redis.test.ts
CHANGED
|
@@ -29,6 +29,12 @@ function mockRedisClient(): RedisClient {
|
|
|
29
29
|
store.set(key, { value: String(next), expires: entry.expires });
|
|
30
30
|
return next;
|
|
31
31
|
},
|
|
32
|
+
async ttl(key: string) {
|
|
33
|
+
const entry = store.get(key);
|
|
34
|
+
if (!entry) return -2;
|
|
35
|
+
const remaining = Math.ceil((entry.expires - Date.now()) / 1000);
|
|
36
|
+
return remaining > 0 ? remaining : -2;
|
|
37
|
+
},
|
|
32
38
|
async expire(key: string, _seconds: number) {
|
|
33
39
|
const entry = store.get(key);
|
|
34
40
|
if (entry) {
|
package/src/stores/redis.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface RedisClient {
|
|
|
7
7
|
setex(key: string, seconds: number, value: string): Promise<unknown>;
|
|
8
8
|
incr(key: string): Promise<number>;
|
|
9
9
|
expire(key: string, seconds: number): Promise<number>;
|
|
10
|
+
ttl(key: string): Promise<number>;
|
|
10
11
|
del(key: string): Promise<number>;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -37,7 +38,6 @@ export class RedisRateLimitStore implements RateLimitStore {
|
|
|
37
38
|
constructor(private client: RedisClient) {}
|
|
38
39
|
|
|
39
40
|
async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
|
|
40
|
-
const now = Date.now();
|
|
41
41
|
const windowSeconds = Math.ceil(windowMs / 1000);
|
|
42
42
|
const count = await this.client.incr(key);
|
|
43
43
|
|
|
@@ -45,13 +45,11 @@ export class RedisRateLimitStore implements RateLimitStore {
|
|
|
45
45
|
await this.client.expire(key, windowSeconds);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return windowMs;
|
|
52
|
-
});
|
|
48
|
+
const ttlSeconds = await this.client.ttl(key);
|
|
49
|
+
const remainingMs = ttlSeconds > 0 ? ttlSeconds * 1000 : windowMs;
|
|
50
|
+
const reset = Date.now() + remainingMs;
|
|
53
51
|
|
|
54
|
-
return { count, reset
|
|
52
|
+
return { count, reset };
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
async reset(key: string): Promise<void> {
|
package/src/validate.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function validateRequest(
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function validate(schemas: ValidationSchemas) {
|
|
66
|
-
return (req: Request) => {
|
|
66
|
+
return async (req: Request) => {
|
|
67
67
|
const url = new URL(req.url);
|
|
68
68
|
|
|
69
69
|
const searchParams: Record<string, string> = {};
|
|
@@ -71,8 +71,15 @@ export function validate(schemas: ValidationSchemas) {
|
|
|
71
71
|
searchParams[k] = v;
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
let body: unknown;
|
|
75
|
+
try {
|
|
76
|
+
body = await req.json();
|
|
77
|
+
} catch {
|
|
78
|
+
body = undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
74
81
|
const result = validateRequest(schemas, {
|
|
75
|
-
body
|
|
82
|
+
body,
|
|
76
83
|
query: searchParams,
|
|
77
84
|
headers: Object.fromEntries(req.headers.entries()),
|
|
78
85
|
});
|