@joinremba/gate 0.5.2 → 0.5.3
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 +1 -1
- package/src/adapters/hono.ts +1 -1
- package/src/index.ts +21 -14
- package/src/rate-limit.ts +1 -4
- package/src/stores/postgres.ts +2 -5
- package/src/stores/redis-api-keys.test.ts +10 -7
- package/src/stores/redis-api-keys.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinremba/gate",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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",
|
package/src/adapters/hono.ts
CHANGED
|
@@ -97,7 +97,7 @@ export function gateMiddleware(gate: Gate, opts?: MiddlewareOptions) {
|
|
|
97
97
|
await next();
|
|
98
98
|
return c.res;
|
|
99
99
|
});
|
|
100
|
-
if (res && res.status
|
|
100
|
+
if (res && res.status >= 400) {
|
|
101
101
|
const body = await res.json();
|
|
102
102
|
return c.json(body, res.status as 200 | 400 | 401 | 429 | 500);
|
|
103
103
|
}
|
package/src/index.ts
CHANGED
|
@@ -63,12 +63,7 @@ export type {
|
|
|
63
63
|
StructuredResponse,
|
|
64
64
|
} from "./respond";
|
|
65
65
|
export type { IdempotencyStore, IdempotencyOptions, IdempotencyInstance } from "./idempotency";
|
|
66
|
-
export type {
|
|
67
|
-
RateLimitStore,
|
|
68
|
-
RateLimitOptions,
|
|
69
|
-
RateLimitStrategy,
|
|
70
|
-
RateLimitInstance,
|
|
71
|
-
} from "./rate-limit";
|
|
66
|
+
export type { RateLimitStore, RateLimitOptions, RateLimitInstance } from "./rate-limit";
|
|
72
67
|
export type {
|
|
73
68
|
ApiKeyEntry,
|
|
74
69
|
AuthenticateOptions,
|
|
@@ -104,14 +99,6 @@ export interface GateOptions {
|
|
|
104
99
|
};
|
|
105
100
|
}
|
|
106
101
|
|
|
107
|
-
export type MiddlewareResult =
|
|
108
|
-
| {
|
|
109
|
-
passed: true;
|
|
110
|
-
auth?: { key: string; scopes?: string[] };
|
|
111
|
-
rateLimit?: { remaining: number; reset: number };
|
|
112
|
-
}
|
|
113
|
-
| { passed: false; status: number; body: unknown };
|
|
114
|
-
|
|
115
102
|
export interface Gate {
|
|
116
103
|
validate: typeof validateRequest;
|
|
117
104
|
ok: typeof ok;
|
|
@@ -122,6 +109,7 @@ export interface Gate {
|
|
|
122
109
|
rateLimit: ReturnType<typeof rateLimit>;
|
|
123
110
|
apiKeys: ReturnType<typeof createApiKeyValidator>;
|
|
124
111
|
middleware(opts?: MiddlewareOptions): Middleware;
|
|
112
|
+
dispose(): void;
|
|
125
113
|
}
|
|
126
114
|
|
|
127
115
|
export function createGate(options: GateOptions = {}): Gate {
|
|
@@ -211,6 +199,25 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
211
199
|
rateLimit: rlInstance,
|
|
212
200
|
apiKeys: apiKeyValidator,
|
|
213
201
|
|
|
202
|
+
dispose(): void {
|
|
203
|
+
const idemStore = idempInstance.store;
|
|
204
|
+
if (
|
|
205
|
+
idemStore &&
|
|
206
|
+
"dispose" in idemStore &&
|
|
207
|
+
typeof (idemStore as { dispose: () => void }).dispose === "function"
|
|
208
|
+
) {
|
|
209
|
+
(idemStore as { dispose: () => void }).dispose();
|
|
210
|
+
}
|
|
211
|
+
const rlStore = rlInstance.store;
|
|
212
|
+
if (
|
|
213
|
+
rlStore &&
|
|
214
|
+
"dispose" in rlStore &&
|
|
215
|
+
typeof (rlStore as { dispose: () => void }).dispose === "function"
|
|
216
|
+
) {
|
|
217
|
+
(rlStore as { dispose: () => void }).dispose();
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
|
|
214
221
|
middleware(opts?: MiddlewareOptions) {
|
|
215
222
|
const {
|
|
216
223
|
auth = options.apiKeys != null && options.apiKeys.length > 0,
|
package/src/rate-limit.ts
CHANGED
|
@@ -49,7 +49,7 @@ export class InMemoryRateLimitStore implements RateLimitStore {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export type RateLimitStrategy = "fixed"
|
|
52
|
+
export type RateLimitStrategy = "fixed";
|
|
53
53
|
|
|
54
54
|
export interface RateLimitOptions {
|
|
55
55
|
windowMs?: number;
|
|
@@ -74,9 +74,6 @@ export function rateLimit(options: RateLimitOptions = {}) {
|
|
|
74
74
|
const max = options.max ?? 100;
|
|
75
75
|
const store = options.store ?? new InMemoryRateLimitStore();
|
|
76
76
|
|
|
77
|
-
if (options.strategy === "sliding") {
|
|
78
|
-
throw new Error("Sliding window rate limiting is not yet implemented. Use 'fixed' (default).");
|
|
79
|
-
}
|
|
80
77
|
const keyFn =
|
|
81
78
|
options.keyFn ??
|
|
82
79
|
((req: Request) => {
|
package/src/stores/postgres.ts
CHANGED
|
@@ -74,6 +74,8 @@ export class PostgresRateLimitStore implements RateLimitStore {
|
|
|
74
74
|
const now = Date.now();
|
|
75
75
|
const reset = now + windowMs;
|
|
76
76
|
|
|
77
|
+
await this.client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [key]);
|
|
78
|
+
|
|
77
79
|
const { rows } = await this.client.query(
|
|
78
80
|
`INSERT INTO ${this.tableName} (key, count, reset_at)
|
|
79
81
|
VALUES ($1, 1, $2)
|
|
@@ -90,11 +92,6 @@ export class PostgresRateLimitStore implements RateLimitStore {
|
|
|
90
92
|
[key, reset, now, reset]
|
|
91
93
|
);
|
|
92
94
|
|
|
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
95
|
const row = rows[0];
|
|
99
96
|
if (!row) return { count: 0, reset: Date.now() + windowMs };
|
|
100
97
|
return { count: row.count as number, reset: row.reset_at as number };
|
|
@@ -2,24 +2,27 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { RedisApiKeyStore, type RedisClient } from "./redis-api-keys";
|
|
3
3
|
|
|
4
4
|
function mockRedisClient(): RedisClient {
|
|
5
|
-
const
|
|
5
|
+
const hashes = new Map<string, Record<string, string>>();
|
|
6
6
|
return {
|
|
7
7
|
async get(key: string) {
|
|
8
|
-
|
|
8
|
+
const h = hashes.get(key);
|
|
9
|
+
if (!h) return null;
|
|
10
|
+
return JSON.stringify(h);
|
|
9
11
|
},
|
|
10
12
|
async hget(_key: string, _field: string) {
|
|
11
13
|
return undefined;
|
|
12
14
|
},
|
|
13
15
|
async hgetall(key: string) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
return hashes.get(key) ?? null;
|
|
17
|
+
},
|
|
18
|
+
async hset(key: string, data: Record<string, string>) {
|
|
19
|
+
hashes.set(key, { ...hashes.get(key), ...data });
|
|
17
20
|
},
|
|
18
21
|
async set(key: string, value: string) {
|
|
19
|
-
|
|
22
|
+
hashes.set(key, JSON.parse(value) as Record<string, string>);
|
|
20
23
|
},
|
|
21
24
|
async del(key: string) {
|
|
22
|
-
return
|
|
25
|
+
return hashes.delete(key) ? 1 : 0;
|
|
23
26
|
},
|
|
24
27
|
};
|
|
25
28
|
}
|
|
@@ -4,6 +4,7 @@ export interface RedisClient {
|
|
|
4
4
|
get(key: string): Promise<string | null>;
|
|
5
5
|
hget(key: string, field: string): Promise<string | undefined>;
|
|
6
6
|
hgetall(key: string): Promise<Record<string, string> | null>;
|
|
7
|
+
hset(key: string, data: Record<string, string>): Promise<unknown>;
|
|
7
8
|
set(key: string, value: string, opts?: { ex?: number }): Promise<unknown>;
|
|
8
9
|
del(key: string): Promise<number>;
|
|
9
10
|
}
|
|
@@ -60,7 +61,7 @@ export class RedisApiKeyStore {
|
|
|
60
61
|
const data: Record<string, string> = {};
|
|
61
62
|
if (entry.scopes) data.scopes = JSON.stringify(entry.scopes);
|
|
62
63
|
if (entry.metadata) data.metadata = JSON.stringify(entry.metadata);
|
|
63
|
-
await this.client.
|
|
64
|
+
await this.client.hset(`${this.keyPrefix}${keyHash}`, data);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
async deleteKey(key: string): Promise<void> {
|