@joinremba/gate 0.3.0 → 0.5.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 +268 -301
- package/package.json +7 -1
- package/src/adapters/hono.ts +134 -0
- 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 +40 -7
- package/src/validate.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinremba/gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -60,6 +60,11 @@
|
|
|
60
60
|
"types": "./src/stores/postgres-api-keys.ts",
|
|
61
61
|
"import": "./src/stores/postgres-api-keys.ts",
|
|
62
62
|
"default": "./src/stores/postgres-api-keys.ts"
|
|
63
|
+
},
|
|
64
|
+
"./adapters/hono": {
|
|
65
|
+
"types": "./src/adapters/hono.ts",
|
|
66
|
+
"import": "./src/adapters/hono.ts",
|
|
67
|
+
"default": "./src/adapters/hono.ts"
|
|
63
68
|
}
|
|
64
69
|
},
|
|
65
70
|
"files": [
|
|
@@ -110,6 +115,7 @@
|
|
|
110
115
|
"bun": ">=1.3.1"
|
|
111
116
|
},
|
|
112
117
|
"dependencies": {
|
|
118
|
+
"@joinremba/core": "^0.4.0",
|
|
113
119
|
"zod": "^4.4.2"
|
|
114
120
|
},
|
|
115
121
|
"devDependencies": {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import type { Gate, MiddlewareOptions } from "../index";
|
|
3
|
+
import type { Context, Next } from "hono";
|
|
4
|
+
|
|
5
|
+
type HonoRateLimitOptions = {
|
|
6
|
+
gate: Gate;
|
|
7
|
+
limit: number;
|
|
8
|
+
windowMs: number;
|
|
9
|
+
keyPrefix: string;
|
|
10
|
+
message?: string;
|
|
11
|
+
getKey?: (c: Context) => string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a Hono rate-limit middleware from a Gate instance.
|
|
16
|
+
* Sets X-RateLimit-Remaining header and returns 429 with Retry-After when exceeded.
|
|
17
|
+
*/
|
|
18
|
+
export function createRateLimiter({
|
|
19
|
+
gate,
|
|
20
|
+
limit: max,
|
|
21
|
+
windowMs,
|
|
22
|
+
keyPrefix,
|
|
23
|
+
message = "Too many requests",
|
|
24
|
+
getKey,
|
|
25
|
+
}: HonoRateLimitOptions) {
|
|
26
|
+
return createMiddleware(async (c: Context, next: Next) => {
|
|
27
|
+
const identifier = getKey
|
|
28
|
+
? getKey(c)
|
|
29
|
+
: ((c as Record<string, unknown>).var as Record<string, unknown>).clientIp ??
|
|
30
|
+
c.req.header("x-forwarded-for") ??
|
|
31
|
+
"unknown";
|
|
32
|
+
|
|
33
|
+
const result = await gate.rateLimit.check(`${keyPrefix}:${identifier}`);
|
|
34
|
+
|
|
35
|
+
if (!result.allowed) {
|
|
36
|
+
return c.json(
|
|
37
|
+
{ success: false, error: { message, code: "RATE_LIMIT_EXCEEDED" } },
|
|
38
|
+
429,
|
|
39
|
+
{
|
|
40
|
+
"Retry-After": String(Math.ceil((result.reset - Date.now()) / 1000)),
|
|
41
|
+
"X-RateLimit-Remaining": "0",
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
c.res.headers.set("X-RateLimit-Remaining", String(result.remaining));
|
|
47
|
+
await next();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type HonoIdempotencyOptions = {
|
|
52
|
+
gate: Gate;
|
|
53
|
+
keyHeader?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a Hono idempotency middleware from a Gate instance.
|
|
58
|
+
* Requires Idempotency-Key header on mutating requests.
|
|
59
|
+
* Returns cached response on repeat requests. Stores response after handler completes.
|
|
60
|
+
*/
|
|
61
|
+
export function requireIdempotencyKey({
|
|
62
|
+
gate,
|
|
63
|
+
keyHeader = "Idempotency-Key",
|
|
64
|
+
}: HonoIdempotencyOptions) {
|
|
65
|
+
const KEY_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/;
|
|
66
|
+
|
|
67
|
+
return createMiddleware(async (c: Context, next: Next) => {
|
|
68
|
+
const safeMethods = ["GET", "HEAD", "OPTIONS"];
|
|
69
|
+
if (safeMethods.includes(c.req.method)) {
|
|
70
|
+
await next();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const key = c.req.header(keyHeader)?.trim() ?? "";
|
|
75
|
+
if (!key) {
|
|
76
|
+
return c.json(
|
|
77
|
+
{
|
|
78
|
+
success: false,
|
|
79
|
+
error: { message: `${keyHeader} header is required`, code: "BAD_REQUEST" },
|
|
80
|
+
},
|
|
81
|
+
400
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (!KEY_PATTERN.test(key)) {
|
|
85
|
+
return c.json(
|
|
86
|
+
{
|
|
87
|
+
success: false,
|
|
88
|
+
error: {
|
|
89
|
+
message: `${keyHeader} must be 8-128 chars (letters, numbers, ., _, :, -)`,
|
|
90
|
+
code: "BAD_REQUEST",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
400
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cached = await gate.idempotency.getResponse(key);
|
|
98
|
+
if (cached) {
|
|
99
|
+
return c.json(cached, 200);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const originalJson = c.json.bind(c);
|
|
103
|
+
(c.json as any) = (
|
|
104
|
+
body: unknown,
|
|
105
|
+
status?: number,
|
|
106
|
+
headers?: Record<string, string>
|
|
107
|
+
) => {
|
|
108
|
+
if (status === undefined || status < 500) {
|
|
109
|
+
gate.idempotency.setResponse(key, body).catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
return originalJson(body, status as number, headers);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
await next();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Hono middleware wrapper for Gate's combined middleware.
|
|
120
|
+
*/
|
|
121
|
+
export function gateMiddleware(gate: Gate, opts?: MiddlewareOptions) {
|
|
122
|
+
const mw = gate.middleware(opts);
|
|
123
|
+
return createMiddleware(async (c: Context, next: Next) => {
|
|
124
|
+
const req = new Request(c.req.raw);
|
|
125
|
+
const res = await mw(req, async () => {
|
|
126
|
+
await next();
|
|
127
|
+
return c.res;
|
|
128
|
+
});
|
|
129
|
+
if (res && res.status !== 200) {
|
|
130
|
+
const body = await res.json();
|
|
131
|
+
return c.json(body, res.status as 200 | 400 | 401 | 429 | 500);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
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
|
@@ -1,12 +1,48 @@
|
|
|
1
1
|
import type { IdempotencyStore } from "../idempotency";
|
|
2
2
|
import type { RateLimitStore } from "../rate-limit";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Adapt an ioredis-compatible Redis instance to the Gate RedisClient interface.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import Redis from "ioredis";
|
|
9
|
+
* import { fromIORedis, RedisIdempotencyStore } from "@joinremba/gate/stores/redis";
|
|
10
|
+
*
|
|
11
|
+
* const redis = new Redis();
|
|
12
|
+
* const adapted = fromIORedis(redis);
|
|
13
|
+
* const store = new RedisIdempotencyStore(adapted);
|
|
14
|
+
*
|
|
15
|
+
* The adapter expects an object with the following methods matching ioredis's
|
|
16
|
+
* signature: `get`, `set`, `setex`, `incr`, `expire`, `ttl`, `del`.
|
|
17
|
+
*/
|
|
18
|
+
export function fromIORedis(client: {
|
|
19
|
+
get(key: string): Promise<string | null>;
|
|
20
|
+
set(key: string, value: string): Promise<unknown>;
|
|
21
|
+
setex(key: string, seconds: number, value: string): Promise<unknown>;
|
|
22
|
+
incr(key: string): Promise<number>;
|
|
23
|
+
expire(key: string, seconds: number): Promise<number>;
|
|
24
|
+
ttl(key: string): Promise<number>;
|
|
25
|
+
del(...keys: string[]): Promise<number>;
|
|
26
|
+
}): RedisClient {
|
|
27
|
+
return {
|
|
28
|
+
get: (key) => client.get(key),
|
|
29
|
+
set: (key, value, opts) =>
|
|
30
|
+
opts?.ex ? client.setex(key, opts.ex, value) : client.set(key, value) as Promise<unknown>,
|
|
31
|
+
setex: (key, seconds, value) => client.setex(key, seconds, value),
|
|
32
|
+
incr: (key) => client.incr(key),
|
|
33
|
+
expire: (key, seconds) => client.expire(key, seconds),
|
|
34
|
+
ttl: (key) => client.ttl(key),
|
|
35
|
+
del: (key) => client.del(key),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
4
39
|
export interface RedisClient {
|
|
5
40
|
get(key: string): Promise<string | null>;
|
|
6
41
|
set(key: string, value: string, opts?: { ex?: number }): Promise<unknown>;
|
|
7
42
|
setex(key: string, seconds: number, value: string): Promise<unknown>;
|
|
8
43
|
incr(key: string): Promise<number>;
|
|
9
44
|
expire(key: string, seconds: number): Promise<number>;
|
|
45
|
+
ttl(key: string): Promise<number>;
|
|
10
46
|
del(key: string): Promise<number>;
|
|
11
47
|
}
|
|
12
48
|
|
|
@@ -37,7 +73,6 @@ export class RedisRateLimitStore implements RateLimitStore {
|
|
|
37
73
|
constructor(private client: RedisClient) {}
|
|
38
74
|
|
|
39
75
|
async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
|
|
40
|
-
const now = Date.now();
|
|
41
76
|
const windowSeconds = Math.ceil(windowMs / 1000);
|
|
42
77
|
const count = await this.client.incr(key);
|
|
43
78
|
|
|
@@ -45,13 +80,11 @@ export class RedisRateLimitStore implements RateLimitStore {
|
|
|
45
80
|
await this.client.expire(key, windowSeconds);
|
|
46
81
|
}
|
|
47
82
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return windowMs;
|
|
52
|
-
});
|
|
83
|
+
const ttlSeconds = await this.client.ttl(key);
|
|
84
|
+
const remainingMs = ttlSeconds > 0 ? ttlSeconds * 1000 : windowMs;
|
|
85
|
+
const reset = Date.now() + remainingMs;
|
|
53
86
|
|
|
54
|
-
return { count, reset
|
|
87
|
+
return { count, reset };
|
|
55
88
|
}
|
|
56
89
|
|
|
57
90
|
async reset(key: string): Promise<void> {
|