@joinremba/gate 0.5.1 → 0.5.2
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 +377 -255
- package/package.json +2 -1
- package/src/adapters/hono.test.ts +168 -0
- package/src/adapters/hono.ts +10 -39
- package/src/errors.ts +4 -0
- package/src/index.ts +2 -2
- package/src/stores/redis.ts +1 -15
package/README.md
CHANGED
|
@@ -1,32 +1,36 @@
|
|
|
1
1
|
# @joinremba/gate
|
|
2
2
|
|
|
3
|
-
[](https://bun.sh)
|
|
3
|
+
[](https://www.npmjs.com/package/@joinremba/gate)
|
|
4
|
+
[](LICENSE)
|
|
6
5
|
|
|
7
|
-
API safety layer for TypeScript backends
|
|
6
|
+
**API safety layer for TypeScript backends.** Validate requests, format structured responses, prevent duplicate processing, rate-limit endpoints, and manage API keys — all with first-class TypeScript types and Zod schemas.
|
|
7
|
+
|
|
8
|
+
---
|
|
8
9
|
|
|
9
10
|
## Features
|
|
10
11
|
|
|
11
|
-
- **Request validation**
|
|
12
|
-
- **Structured
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **API
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- **Zero framework dependency** — works with Hono, Express, Elysia, Bun, Node `http`, or plain fetch
|
|
12
|
+
- **Request validation** — Validate `body`, `query`, `params`, and `headers` with Zod schemas
|
|
13
|
+
- **Structured responses** — Consistent `ok`, `fail`, `paginated`, and RFC 9457 `problem` response shapes
|
|
14
|
+
- **Rate limiting** — In-memory store included; pluggable Redis and Postgres stores for production
|
|
15
|
+
- **Idempotency** — Prevent duplicate processing with idempotency keys (`Idempotency-Key` header)
|
|
16
|
+
- **API keys** — Validate, hash, scope-check, and authenticate API keys from memory, Redis, or Postgres
|
|
17
|
+
- **Framework agnostic** — Core works with any runtime/framework; official Hono adapter included
|
|
18
|
+
- **Middleware** — Drop-in `gate.middleware()` for auth + rate limiting + idempotency in one call
|
|
19
|
+
- **TypeScript strict** — Full type inference with `strict: true` and Zod 4
|
|
20
|
+
- **Tree-shakeable** — Deep imports for every module; import only what you need
|
|
21
|
+
|
|
22
|
+
---
|
|
23
23
|
|
|
24
24
|
## Installation
|
|
25
25
|
|
|
26
|
-
```
|
|
26
|
+
```sh
|
|
27
27
|
bun add @joinremba/gate
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Requires **Bun >= 1.3.1** and **Zod ^4.4.2** (installed automatically).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
30
34
|
## Quick Start
|
|
31
35
|
|
|
32
36
|
```ts
|
|
@@ -34,304 +38,381 @@ import { createGate } from "@joinremba/gate";
|
|
|
34
38
|
import { z } from "zod";
|
|
35
39
|
|
|
36
40
|
const gate = createGate({
|
|
37
|
-
apiKeys: [{ key: "sk-
|
|
38
|
-
rateLimit: { windowMs: 60_000, max:
|
|
39
|
-
idempotency: { ttl: 86_400_000 },
|
|
41
|
+
apiKeys: [{ key: "sk-secret-123", scopes: ["read"] }],
|
|
42
|
+
rateLimit: { windowMs: 60_000, max: 10 },
|
|
40
43
|
});
|
|
41
44
|
|
|
42
|
-
// Validate
|
|
43
|
-
const
|
|
44
|
-
const result = gate.validate({ body: userSchema }, { body: { email: "test@example.com" } });
|
|
45
|
+
// Validate an incoming request
|
|
46
|
+
const result = gate.validate({ body: z.object({ name: z.string() }) }, { body: { name: "Alice" } });
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// Authenticate an API key
|
|
51
|
-
const auth = await gate.apiKeys.authenticate({ requiredScopes: ["admin"] })(
|
|
52
|
-
new Request("http://localhost", { headers: { Authorization: "Bearer sk-admin" } })
|
|
53
|
-
);
|
|
54
|
-
// => { authenticated: true, key: "sk-admin", scopes: ["admin"] }
|
|
55
|
-
|
|
56
|
-
// Combined middleware (Hono / Express / Bun serve)
|
|
57
|
-
app.use(gate.middleware({ auth: true, rateLimit: true, idempotency: true }));
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## createGate
|
|
61
|
-
|
|
62
|
-
The main factory. Accepts an options object and returns a `Gate` instance with all modules wired together.
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
import { createGate } from "@joinremba/gate";
|
|
48
|
+
if (!result.success) {
|
|
49
|
+
return gate.fail("Validation failed", "VALIDATION_ERROR", result.errors);
|
|
50
|
+
}
|
|
66
51
|
|
|
67
|
-
|
|
68
|
-
// API key authentication
|
|
69
|
-
apiKeys?: ApiKeyEntry[];
|
|
70
|
-
|
|
71
|
-
// Remote delegation — delegate checks to Nexus
|
|
72
|
-
client?: Client;
|
|
73
|
-
|
|
74
|
-
// Idempotency configuration
|
|
75
|
-
idempotency?: {
|
|
76
|
-
store?: IdempotencyStore; // default: new InMemoryStore()
|
|
77
|
-
keyHeader?: string; // default: "Idempotency-Key"
|
|
78
|
-
ttl?: number; // default: 86_400_000 (24 hours)
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// Rate limiting configuration
|
|
82
|
-
rateLimit?: {
|
|
83
|
-
windowMs?: number; // default: 60_000
|
|
84
|
-
max?: number; // default: 100
|
|
85
|
-
store?: RateLimitStore; // default: new InMemoryRateLimitStore()
|
|
86
|
-
keyFn?: (req: Request) => string;
|
|
87
|
-
};
|
|
88
|
-
});
|
|
52
|
+
return gate.ok({ name: result.data.body.name });
|
|
89
53
|
```
|
|
90
54
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
| Property | Type | Description |
|
|
94
|
-
|---|---|---|
|
|
95
|
-
| `gate.validate` | `typeof validateRequest` | Validate body/query/params/headers with Zod |
|
|
96
|
-
| `gate.ok` | `typeof ok` | Structured success response |
|
|
97
|
-
| `gate.fail` | `typeof fail` | Structured error response |
|
|
98
|
-
| `gate.paginated` | `typeof paginated` | Paginated response |
|
|
99
|
-
| `gate.problem` | `typeof problem` | RFC 9457 problem detail |
|
|
100
|
-
| `gate.idempotency` | `IdempotencyInstance` | Idempotency guard (`getResponse`/`setResponse`) |
|
|
101
|
-
| `gate.rateLimit` | `RateLimitInstance` | Rate limiter (`check`) |
|
|
102
|
-
| `gate.apiKeys` | `ApiKeyValidator` | API key validator (`validate`/`verify`/`authenticate`) |
|
|
103
|
-
| `gate.middleware` | `Middleware` | Combined middleware function |
|
|
55
|
+
---
|
|
104
56
|
|
|
105
57
|
## Validation
|
|
106
58
|
|
|
107
|
-
|
|
59
|
+
Validation uses [Zod](https://zod.dev) schemas. The `validate()` method accepts an object with optional `body`, `query`, `params`, and `headers` schemas.
|
|
108
60
|
|
|
109
61
|
```ts
|
|
110
|
-
import { validateRequest
|
|
111
|
-
|
|
62
|
+
import { validateRequest } from "@joinremba/gate/validate";
|
|
63
|
+
// or via gate instance:
|
|
64
|
+
// gate.validate(schemas, request)
|
|
65
|
+
|
|
66
|
+
const UserSchema = z.object({
|
|
67
|
+
name: z.string().min(1),
|
|
68
|
+
email: z.string().email(),
|
|
69
|
+
});
|
|
112
70
|
|
|
113
|
-
// Low-level: pass parsed input directly
|
|
114
71
|
const result = validateRequest(
|
|
115
72
|
{
|
|
116
|
-
body:
|
|
117
|
-
query: z.object({ page: z.coerce.number() }),
|
|
73
|
+
body: UserSchema,
|
|
74
|
+
query: z.object({ page: z.coerce.number().optional() }),
|
|
75
|
+
headers: z.object({ "x-request-id": z.string().optional() }),
|
|
118
76
|
},
|
|
119
77
|
{
|
|
120
|
-
body: { email: "
|
|
121
|
-
query: { page: "
|
|
78
|
+
body: { name: "Alice", email: "alice@example.com" },
|
|
79
|
+
query: { page: "2" },
|
|
80
|
+
params: { id: "123" },
|
|
81
|
+
headers: { "x-request-id": "abc-123" },
|
|
122
82
|
}
|
|
123
83
|
);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
headers: { "Content-Type": "application/json" },
|
|
133
|
-
}));
|
|
84
|
+
|
|
85
|
+
if (!result.success) {
|
|
86
|
+
// result.errors → { body: ["body.name: Required"], query: [...] }
|
|
87
|
+
console.error(result.errors);
|
|
88
|
+
} else {
|
|
89
|
+
// result.data → { body: { name: "Alice", ... }, query: { page: 2 } }
|
|
90
|
+
console.log(result.data);
|
|
91
|
+
}
|
|
134
92
|
```
|
|
135
93
|
|
|
136
|
-
|
|
94
|
+
The standalone `validate(schemas)` function also accepts a `Request` object directly, parsing the JSON body and URL search params automatically:
|
|
137
95
|
|
|
138
96
|
```ts
|
|
139
|
-
import {
|
|
97
|
+
import { validate } from "@joinremba/gate/validate";
|
|
140
98
|
|
|
141
|
-
|
|
142
|
-
|
|
99
|
+
const middleware = async (req: Request) => {
|
|
100
|
+
const result = await validate({
|
|
101
|
+
body: z.object({ title: z.string() }),
|
|
102
|
+
query: z.object({ limit: z.coerce.number() }),
|
|
103
|
+
})(req);
|
|
143
104
|
|
|
144
|
-
|
|
145
|
-
//
|
|
105
|
+
if (!result.success) return new Response("Invalid", { status: 400 });
|
|
106
|
+
// ...
|
|
107
|
+
};
|
|
108
|
+
```
|
|
146
109
|
|
|
147
|
-
|
|
148
|
-
// { success: false, error: { message: "Invalid input", code: "VALIDATION_ERROR", details: { field: "email" } } }
|
|
110
|
+
---
|
|
149
111
|
|
|
150
|
-
|
|
151
|
-
// { success: true, data: items, pagination: { total: 100, page: 1, limit: 20, pages: 5 } }
|
|
112
|
+
## Responses
|
|
152
113
|
|
|
153
|
-
|
|
154
|
-
// { success: false, error: { message: "Rate Limit Exceeded", ... }, problem: { type, title, status, detail } }
|
|
155
|
-
```
|
|
114
|
+
All response helpers return plain objects — serialise them however you like (JSON, Hono `c.json()`, etc.).
|
|
156
115
|
|
|
157
|
-
|
|
116
|
+
### `ok(data)`
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
gate.ok({ id: 1, name: "Alice" });
|
|
120
|
+
// → { success: true, data: { id: 1, name: "Alice" } }
|
|
121
|
+
```
|
|
158
122
|
|
|
159
|
-
|
|
123
|
+
### `fail(message, code?, details?)`
|
|
160
124
|
|
|
161
125
|
```ts
|
|
162
|
-
|
|
126
|
+
gate.fail("Not found", "NOT_FOUND");
|
|
127
|
+
// → { success: false, error: { message: "Not found", code: "NOT_FOUND" } }
|
|
163
128
|
|
|
164
|
-
|
|
129
|
+
gate.fail("Validation error", "VALIDATION_ERROR", { name: ["Required"] });
|
|
130
|
+
// → { success: false, error: { message: "Validation error", code: "VALIDATION_ERROR", details: { name: ["Required"] } } }
|
|
131
|
+
```
|
|
165
132
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
133
|
+
### `paginated(data, total, page, limit)`
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
gate.paginated([{ id: 1 }], 42, 1, 10);
|
|
137
|
+
// → { success: true, data: [...], pagination: { total: 42, page: 1, limit: 10, pages: 5 } }
|
|
172
138
|
```
|
|
173
139
|
|
|
174
|
-
|
|
140
|
+
### `problem(detail)` — RFC 9457 Problem Details
|
|
175
141
|
|
|
176
142
|
```ts
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
143
|
+
gate.problem({
|
|
144
|
+
type: "https://api.example.com/errors/rate-limit",
|
|
145
|
+
title: "Rate Limit Exceeded",
|
|
146
|
+
status: 429,
|
|
147
|
+
detail: "Too many requests. Retry after 30 seconds.",
|
|
148
|
+
instance: "/api/orders",
|
|
149
|
+
});
|
|
150
|
+
// → { success: false, error: { ... }, problem: { type, title, status, detail, instance } }
|
|
182
151
|
```
|
|
183
152
|
|
|
153
|
+
---
|
|
154
|
+
|
|
184
155
|
## Rate Limiting
|
|
185
156
|
|
|
186
|
-
|
|
187
|
-
import { rateLimit, InMemoryRateLimitStore, keyByApiKey } from "@joinremba/gate";
|
|
157
|
+
### Basic usage
|
|
188
158
|
|
|
189
|
-
|
|
159
|
+
```ts
|
|
160
|
+
import { createGate, InMemoryRateLimitStore } from "@joinremba/gate";
|
|
190
161
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
162
|
+
const gate = createGate({
|
|
163
|
+
rateLimit: {
|
|
164
|
+
windowMs: 60_000, // 1 minute window
|
|
165
|
+
max: 100, // 100 requests per window
|
|
166
|
+
// store: customStore // optional — defaults to InMemoryRateLimitStore
|
|
167
|
+
},
|
|
168
|
+
});
|
|
194
169
|
|
|
195
|
-
//
|
|
196
|
-
const result = await
|
|
170
|
+
// Usage
|
|
171
|
+
const result = await gate.rateLimit.check(request);
|
|
172
|
+
// → { allowed: boolean, remaining: number, reset: number (epoch ms) }
|
|
197
173
|
|
|
198
|
-
|
|
199
|
-
|
|
174
|
+
if (!result.allowed) {
|
|
175
|
+
return gate.fail("Too many requests", "RATE_LIMIT_EXCEEDED");
|
|
176
|
+
}
|
|
200
177
|
```
|
|
201
178
|
|
|
202
|
-
|
|
179
|
+
### Custom key function
|
|
203
180
|
|
|
204
181
|
```ts
|
|
205
|
-
const gate = createGate({
|
|
206
|
-
|
|
182
|
+
const gate = createGate({
|
|
183
|
+
rateLimit: {
|
|
184
|
+
keyFn: (req) => req.headers.get("x-api-key") ?? "anonymous",
|
|
185
|
+
},
|
|
186
|
+
});
|
|
207
187
|
```
|
|
208
188
|
|
|
209
|
-
|
|
189
|
+
### Redis store
|
|
210
190
|
|
|
211
191
|
```ts
|
|
212
|
-
import {
|
|
192
|
+
import { Redis, type Redis as RedisType } from "ioredis";
|
|
193
|
+
import { fromIORedis, RedisRateLimitStore } from "@joinremba/gate/stores/redis";
|
|
213
194
|
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
{ key: "sk-read", scopes: ["read"] },
|
|
217
|
-
]);
|
|
195
|
+
const client = new Redis();
|
|
196
|
+
const redisClient = fromIORedis(client);
|
|
218
197
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
{ hashKeys: true }
|
|
227
|
-
);
|
|
228
|
-
const result = await hashedValidator.verify("sk-admin");
|
|
229
|
-
|
|
230
|
-
// Request authentication with scope checking
|
|
231
|
-
const authFn = validator.authenticate({ requiredScopes: ["admin"] });
|
|
232
|
-
const result = await authFn(new Request("http://localhost", {
|
|
233
|
-
headers: { Authorization: "Bearer sk-admin" },
|
|
234
|
-
}));
|
|
198
|
+
const gate = createGate({
|
|
199
|
+
rateLimit: {
|
|
200
|
+
store: new RedisRateLimitStore(redisClient),
|
|
201
|
+
windowMs: 60_000,
|
|
202
|
+
max: 1000,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
235
205
|
```
|
|
236
206
|
|
|
237
|
-
|
|
207
|
+
### Postgres store
|
|
238
208
|
|
|
239
209
|
```ts
|
|
240
|
-
|
|
241
|
-
|
|
210
|
+
import { PostgresRateLimitStore } from "@joinremba/gate/stores/postgres";
|
|
211
|
+
import { sql } from "your-pg-client";
|
|
212
|
+
|
|
213
|
+
const store = new PostgresRateLimitStore({ query: sql.query.bind(sql) });
|
|
214
|
+
await store.ensureTable(); // creates gate_rate_limits table
|
|
242
215
|
```
|
|
243
216
|
|
|
244
|
-
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Idempotency
|
|
245
220
|
|
|
246
|
-
|
|
221
|
+
Prevent duplicate processing by storing responses keyed by an `Idempotency-Key` header.
|
|
247
222
|
|
|
248
223
|
```ts
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
excludePaths: ["/health", "/webhook"], // skip entirely
|
|
224
|
+
const gate = createGate({
|
|
225
|
+
idempotency: {
|
|
226
|
+
// store: customStore — defaults to InMemoryStore
|
|
227
|
+
keyHeader: "Idempotency-Key", // default
|
|
228
|
+
ttl: 86_400_000, // 24 hours (default)
|
|
229
|
+
},
|
|
256
230
|
});
|
|
231
|
+
|
|
232
|
+
// Check for cached response
|
|
233
|
+
const cached = await gate.idempotency.getResponse(key);
|
|
234
|
+
if (cached) {
|
|
235
|
+
return cached; // return previous response
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ... process request ...
|
|
239
|
+
|
|
240
|
+
// Store the response
|
|
241
|
+
await gate.idempotency.setResponse(key, responseData);
|
|
257
242
|
```
|
|
258
243
|
|
|
259
|
-
|
|
244
|
+
### Redis store
|
|
260
245
|
|
|
261
246
|
```ts
|
|
262
|
-
import {
|
|
263
|
-
|
|
264
|
-
|
|
247
|
+
import { Redis } from "ioredis";
|
|
248
|
+
import { fromIORedis, RedisIdempotencyStore } from "@joinremba/gate/stores/redis";
|
|
249
|
+
|
|
250
|
+
const client = new Redis();
|
|
251
|
+
const redisClient = fromIORedis(client);
|
|
252
|
+
|
|
253
|
+
const gate = createGate({
|
|
254
|
+
idempotency: {
|
|
255
|
+
store: new RedisIdempotencyStore(redisClient),
|
|
256
|
+
},
|
|
257
|
+
});
|
|
265
258
|
```
|
|
266
259
|
|
|
267
|
-
|
|
260
|
+
### Postgres store
|
|
268
261
|
|
|
269
262
|
```ts
|
|
270
|
-
import {
|
|
263
|
+
import { PostgresIdempotencyStore } from "@joinremba/gate/stores/postgres";
|
|
271
264
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const rl = getGateRateLimit(req); // { allowed, remaining, reset }
|
|
275
|
-
const idemKey = getGateIdempotencyKey(req); // string | undefined
|
|
265
|
+
const store = new PostgresIdempotencyStore({ query: sql.query.bind(sql) });
|
|
266
|
+
await store.ensureTable(); // creates gate_idempotency table
|
|
276
267
|
```
|
|
277
268
|
|
|
278
|
-
|
|
269
|
+
---
|
|
279
270
|
|
|
280
|
-
|
|
271
|
+
## API Keys
|
|
281
272
|
|
|
282
|
-
|
|
273
|
+
### In-memory validation
|
|
283
274
|
|
|
284
275
|
```ts
|
|
285
|
-
|
|
276
|
+
const gate = createGate({
|
|
277
|
+
apiKeys: [
|
|
278
|
+
{ key: "sk-test-1", scopes: ["read", "write"] },
|
|
279
|
+
{ key: "sk-test-2", scopes: ["read"] },
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Direct validation
|
|
284
|
+
const result = gate.apiKeys.validate("sk-test-1");
|
|
285
|
+
// → { authenticated: true, key: "sk-test-1", scopes: ["read", "write"] }
|
|
286
286
|
|
|
287
|
-
|
|
288
|
-
const
|
|
287
|
+
// Authenticate from a Request (extracts Bearer token from Authorization header)
|
|
288
|
+
const authenticate = gate.apiKeys.authenticate({ requiredScopes: ["read"] });
|
|
289
|
+
const authResult = await authenticate(request);
|
|
290
|
+
// → { authenticated: true, key: "sk-test-1", scopes: [...], metadata: {...} }
|
|
289
291
|
```
|
|
290
292
|
|
|
291
|
-
### Redis
|
|
293
|
+
### Redis store
|
|
292
294
|
|
|
293
295
|
```ts
|
|
294
|
-
import {
|
|
296
|
+
import { Redis } from "ioredis";
|
|
295
297
|
import { RedisApiKeyStore } from "@joinremba/gate/stores/redis-api-keys";
|
|
296
|
-
import type { RedisClient } from "@joinremba/gate/stores/redis";
|
|
297
|
-
|
|
298
|
-
// Use with any Redis client that implements the RedisClient interface
|
|
299
|
-
const client: RedisClient = {
|
|
300
|
-
get: (key) => redis.get(key),
|
|
301
|
-
setex: (key, sec, val) => redis.setex(key, sec, val),
|
|
302
|
-
incr: (key) => redis.incr(key),
|
|
303
|
-
expire: (key, sec) => redis.expire(key, sec),
|
|
304
|
-
ttl: (key) => redis.ttl(key),
|
|
305
|
-
del: (key) => redis.del(key),
|
|
306
|
-
};
|
|
307
298
|
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
299
|
+
const client = new Redis();
|
|
300
|
+
const store = new RedisApiKeyStore(client);
|
|
301
|
+
|
|
302
|
+
// Add a key
|
|
303
|
+
await store.setKey({ key: "sk-redis-1", scopes: ["admin"] });
|
|
304
|
+
|
|
305
|
+
// Validate
|
|
306
|
+
const result = await store.validate("sk-redis-1");
|
|
307
|
+
|
|
308
|
+
// Authenticate from request
|
|
309
|
+
const authenticate = store.authenticate({ requiredScopes: ["admin"] });
|
|
310
|
+
const authResult = await authenticate(request);
|
|
311
311
|
```
|
|
312
312
|
|
|
313
|
-
###
|
|
313
|
+
### Postgres store
|
|
314
314
|
|
|
315
315
|
```ts
|
|
316
|
-
import { PostgresIdempotencyStore, PostgresRateLimitStore } from "@joinremba/gate/stores/postgres";
|
|
317
316
|
import { PostgresApiKeyStore } from "@joinremba/gate/stores/postgres-api-keys";
|
|
318
|
-
import type { PostgresClient } from "@joinremba/gate/stores/postgres";
|
|
319
317
|
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
318
|
+
const store = new PostgresApiKeyStore({ query: sql.query.bind(sql) });
|
|
319
|
+
await store.ensureTable(); // creates gate_api_keys table
|
|
320
|
+
|
|
321
|
+
await store.setKey({ key: "sk-pg-1", scopes: ["read"] });
|
|
322
|
+
const result = await store.validate("sk-pg-1");
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Hono Adapter
|
|
328
|
+
|
|
329
|
+
The `@joinremba/gate/adapters/hono` module provides first-class middleware for [Hono](https://hono.dev).
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import { Hono } from "hono";
|
|
333
|
+
import { createGate } from "@joinremba/gate";
|
|
334
|
+
import {
|
|
335
|
+
createRateLimiter,
|
|
336
|
+
requireIdempotencyKey,
|
|
337
|
+
gateMiddleware,
|
|
338
|
+
} from "@joinremba/gate/adapters/hono";
|
|
323
339
|
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
340
|
+
const gate = createGate({
|
|
341
|
+
apiKeys: [{ key: "sk-hono-1", scopes: ["read"] }],
|
|
342
|
+
rateLimit: { windowMs: 60_000, max: 30 },
|
|
343
|
+
idempotency: { ttl: 86_400_000 },
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const app = new Hono();
|
|
327
347
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
348
|
+
// Standalone rate limiter middleware (limit/windowMs from gate config)
|
|
349
|
+
app.use(
|
|
350
|
+
"/api/*",
|
|
351
|
+
createRateLimiter({
|
|
352
|
+
gate,
|
|
353
|
+
keyPrefix: "api",
|
|
354
|
+
getKey: (c) => c.req.header("x-forwarded-for") ?? "unknown",
|
|
355
|
+
})
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// Standalone idempotency middleware
|
|
359
|
+
app.post("/api/orders", requireIdempotencyKey({ gate }), async (c) => {
|
|
360
|
+
// ...
|
|
361
|
+
return c.json(gate.ok({ orderId: "ord_123" }), 201);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Combined middleware (auth + rate limit + idempotency)
|
|
365
|
+
app.use(
|
|
366
|
+
"/admin/*",
|
|
367
|
+
gateMiddleware(gate, {
|
|
368
|
+
auth: true,
|
|
369
|
+
requiredScopes: ["admin"],
|
|
370
|
+
rateLimit: true,
|
|
371
|
+
idempotency: true,
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
app.get("/api/health", (c) => c.json(gate.ok({ status: "ok" })));
|
|
376
|
+
|
|
377
|
+
export default app;
|
|
332
378
|
```
|
|
333
379
|
|
|
334
|
-
|
|
380
|
+
### Adapter API
|
|
381
|
+
|
|
382
|
+
| Middleware | Description |
|
|
383
|
+
| ----------------------- | -------------------------------------------------------- |
|
|
384
|
+
| `createRateLimiter` | Rate-limit by a custom key (window/max from gate config) |
|
|
385
|
+
| `requireIdempotencyKey` | Validates `Idempotency-Key` header, caches responses |
|
|
386
|
+
| `gateMiddleware` | All-in-one: auth + rate limit + idempotency |
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Deep Imports
|
|
391
|
+
|
|
392
|
+
Every module can be imported individually for tree-shaking and direct use:
|
|
393
|
+
|
|
394
|
+
| Subpath Export | Exports |
|
|
395
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
|
|
396
|
+
| `@joinremba/gate` | `createGate`, `validateRequest`, `ok`, `fail`, `paginated`, `problem`, types |
|
|
397
|
+
| `@joinremba/gate/validate` | `validateRequest`, `validate`, types |
|
|
398
|
+
| `@joinremba/gate/respond` | `ok`, `fail`, `paginated`, `problem`, types |
|
|
399
|
+
| `@joinremba/gate/idempotency` | `idempotency`, `InMemoryStore`, types |
|
|
400
|
+
| `@joinremba/gate/rate-limit` | `rateLimit`, `InMemoryRateLimitStore`, `keyByApiKey`, types |
|
|
401
|
+
| `@joinremba/gate/api-keys` | `createApiKeyValidator`, types |
|
|
402
|
+
| `@joinremba/gate/errors` | `GateError`, `ValidationError`, `AuthenticationError`, `RateLimitError`, `IdempotencyError`, `isGateError` |
|
|
403
|
+
| `@joinremba/gate/stores/redis` | `fromIORedis`, `RedisIdempotencyStore`, `RedisRateLimitStore` |
|
|
404
|
+
| `@joinremba/gate/stores/redis-api-keys` | `RedisApiKeyStore` |
|
|
405
|
+
| `@joinremba/gate/stores/postgres` | `PostgresIdempotencyStore`, `PostgresRateLimitStore` |
|
|
406
|
+
| `@joinremba/gate/stores/postgres-api-keys` | `PostgresApiKeyStore` |
|
|
407
|
+
| `@joinremba/gate/adapters/hono` | `createRateLimiter`, `requireIdempotencyKey`, `gateMiddleware` |
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## Error Handling
|
|
412
|
+
|
|
413
|
+
Gate throws typed errors for programmatic handling, and the `fail()` helper for HTTP responses.
|
|
414
|
+
|
|
415
|
+
### Error classes
|
|
335
416
|
|
|
336
417
|
```ts
|
|
337
418
|
import {
|
|
@@ -340,55 +421,96 @@ import {
|
|
|
340
421
|
AuthenticationError,
|
|
341
422
|
RateLimitError,
|
|
342
423
|
IdempotencyError,
|
|
343
|
-
|
|
424
|
+
isGateError,
|
|
425
|
+
} from "@joinremba/gate/errors";
|
|
426
|
+
```
|
|
344
427
|
|
|
345
|
-
|
|
346
|
-
|
|
428
|
+
| Class | Code | Status | Description |
|
|
429
|
+
| --------------------- | ---------------------- | ------ | ------------------------------ |
|
|
430
|
+
| `GateError` | (custom) | 500 | Base error class |
|
|
431
|
+
| `ValidationError` | `VALIDATION_ERROR` | 400 | Invalid request data |
|
|
432
|
+
| `AuthenticationError` | `AUTHENTICATION_ERROR` | 401 | Missing or invalid credentials |
|
|
433
|
+
| `RateLimitError` | `RATE_LIMIT_ERROR` | 429 | Rate limit exceeded |
|
|
434
|
+
| `IdempotencyError` | `IDEMPOTENCY_ERROR` | 409 | Idempotency key conflict |
|
|
347
435
|
|
|
348
|
-
|
|
349
|
-
throw new ValidationError("Invalid email", [{ path: "email", message: "Invalid format" }]);
|
|
436
|
+
Check for Gate errors:
|
|
350
437
|
|
|
351
|
-
|
|
352
|
-
|
|
438
|
+
```ts
|
|
439
|
+
try {
|
|
440
|
+
// ...
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (isGateError(err)) {
|
|
443
|
+
console.error(err.code, err.status, err.message);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
```
|
|
353
447
|
|
|
354
|
-
|
|
355
|
-
throw new RateLimitError(30); // retry after 30 seconds
|
|
448
|
+
---
|
|
356
449
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
450
|
+
## Configuration Reference
|
|
451
|
+
|
|
452
|
+
### `createGate(options?)`
|
|
453
|
+
|
|
454
|
+
| Option | Type | Default | Description |
|
|
455
|
+
| ----------------------- | -------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------- |
|
|
456
|
+
| `apiKeys` | `ApiKeyEntry[]` | `[]` | Static API keys for in-memory validation |
|
|
457
|
+
| `client` | `Client` | — | `@joinremba/core` client for remote rate-limit, idempotency & API key validation with local fallback |
|
|
458
|
+
| `rateLimit.windowMs` | `number` | `60_000` | Rate limit window in milliseconds |
|
|
459
|
+
| `rateLimit.max` | `number` | `100` | Max requests per window |
|
|
460
|
+
| `rateLimit.store` | `RateLimitStore` | `InMemoryRateLimitStore` | Persistent store for rate limit data |
|
|
461
|
+
| `rateLimit.keyFn` | `(req: Request) => string` | IP via `x-forwarded-for` | Function to derive rate limit key |
|
|
462
|
+
| `idempotency.store` | `IdempotencyStore` | `InMemoryStore` | Persistent store for idempotency data |
|
|
463
|
+
| `idempotency.keyHeader` | `string` | `Idempotency-Key` | Header name for idempotency key |
|
|
464
|
+
| `idempotency.ttl` | `number` | `86_400_000` (24h) | Time-to-live for cached responses |
|
|
465
|
+
|
|
466
|
+
### `MiddlewareOptions`
|
|
467
|
+
|
|
468
|
+
| Option | Type | Default | Description |
|
|
469
|
+
| ---------------- | ---------- | -------------------------------- | ------------------------------ |
|
|
470
|
+
| `auth` | `boolean` | `true` if `apiKeys` provided | Enable API key authentication |
|
|
471
|
+
| `requiredScopes` | `string[]` | `[]` | Require specific scopes |
|
|
472
|
+
| `rateLimit` | `boolean` | `true` if `rateLimit` configured | Enable rate limiting |
|
|
473
|
+
| `idempotency` | `boolean` | `false` | Enable idempotency checks |
|
|
474
|
+
| `excludePaths` | `string[]` | `[]` | Path prefixes to skip entirely |
|
|
360
475
|
|
|
361
|
-
|
|
476
|
+
---
|
|
362
477
|
|
|
363
|
-
|
|
478
|
+
## TypeScript
|
|
479
|
+
|
|
480
|
+
Gate is built with TypeScript under `strict: true`. All validation schemas use Zod for full type inference.
|
|
364
481
|
|
|
365
482
|
```ts
|
|
366
|
-
import {
|
|
367
|
-
import {
|
|
483
|
+
import { z } from "zod";
|
|
484
|
+
import type { ValidationSchemas, ValidationResult, SuccessResponse } from "@joinremba/gate";
|
|
368
485
|
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
486
|
+
const schemas: ValidationSchemas = {
|
|
487
|
+
body: z.object({ email: z.string().email() }),
|
|
488
|
+
query: z.object({ page: z.coerce.number() }),
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
type Body = z.infer<typeof schemas.body>; // { email: string }
|
|
492
|
+
|
|
493
|
+
// Response types
|
|
494
|
+
const res: SuccessResponse<{ id: string }> = gate.ok({ id: "abc" });
|
|
495
|
+
// → { success: true, data: { id: "abc" } }
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Response types are branded with `success: true` / `success: false` for discriminated unions:
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
type Response = SuccessResponse<unknown> | ErrorResponse;
|
|
502
|
+
|
|
503
|
+
function handle(res: Response) {
|
|
504
|
+
if (res.success) {
|
|
505
|
+
// TS narrows to SuccessResponse — access .data
|
|
506
|
+
} else {
|
|
507
|
+
// TS narrows to ErrorResponse — access .error
|
|
508
|
+
}
|
|
509
|
+
}
|
|
374
510
|
```
|
|
375
511
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
| Entry Point | Exports |
|
|
379
|
-
|---|---|
|
|
380
|
-
| `@joinremba/gate` | `createGate`, `idempotency`, `rateLimit`, `createApiKeyValidator`, `keyByApiKey`, `ok`, `fail`, `paginated`, `problem`, `InMemoryStore`, `InMemoryRateLimitStore`, `GateError`, `ValidationError`, `AuthenticationError`, `RateLimitError`, `IdempotencyError`, `getGateAuth`, `getGateRateLimit`, `getGateIdempotencyKey` |
|
|
381
|
-
| `@joinremba/gate/validate` | `validateRequest`, `validate` |
|
|
382
|
-
| `@joinremba/gate/respond` | `ok`, `fail`, `paginated`, `problem` |
|
|
383
|
-
| `@joinremba/gate/idempotency` | `idempotency`, `InMemoryStore` |
|
|
384
|
-
| `@joinremba/gate/rate-limit` | `rateLimit`, `InMemoryRateLimitStore`, `keyByApiKey` |
|
|
385
|
-
| `@joinremba/gate/api-keys` | `createApiKeyValidator` |
|
|
386
|
-
| `@joinremba/gate/errors` | `GateError`, `ValidationError`, `AuthenticationError`, `RateLimitError`, `IdempotencyError` |
|
|
387
|
-
| `@joinremba/gate/stores/redis` | `RedisIdempotencyStore`, `RedisRateLimitStore`, `RedisClient` |
|
|
388
|
-
| `@joinremba/gate/stores/redis-api-keys` | `RedisApiKeyStore` |
|
|
389
|
-
| `@joinremba/gate/stores/postgres` | `PostgresIdempotencyStore`, `PostgresRateLimitStore` |
|
|
390
|
-
| `@joinremba/gate/stores/postgres-api-keys` | `PostgresApiKeyStore` |
|
|
512
|
+
---
|
|
391
513
|
|
|
392
514
|
## License
|
|
393
515
|
|
|
394
|
-
MIT
|
|
516
|
+
MIT © [Benson Isaac](https://github.com/bensxn)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinremba/gate",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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",
|
|
@@ -124,6 +124,7 @@
|
|
|
124
124
|
"@typescript-eslint/parser": "^7.18.0",
|
|
125
125
|
"eslint": "^8.57.1",
|
|
126
126
|
"eslint-config-prettier": "^10.1.8",
|
|
127
|
+
"hono": "^4.12.23",
|
|
127
128
|
"prettier": "^3.8.3",
|
|
128
129
|
"typescript": "^6.0.3"
|
|
129
130
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { createGate } from "../index";
|
|
4
|
+
import { createRateLimiter, requireIdempotencyKey, gateMiddleware } from "./hono";
|
|
5
|
+
|
|
6
|
+
function createApp(
|
|
7
|
+
...middleware: ReturnType<
|
|
8
|
+
typeof createRateLimiter | typeof requireIdempotencyKey | typeof gateMiddleware
|
|
9
|
+
>[]
|
|
10
|
+
): Hono {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
app.use(...middleware);
|
|
13
|
+
app.post("/test", (c) => c.json({ success: true, data: { id: "1" } }, 201));
|
|
14
|
+
app.get("/safe", (c) => c.json({ ok: true }));
|
|
15
|
+
return app;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("createRateLimiter", () => {
|
|
19
|
+
test("allows requests within limit", async () => {
|
|
20
|
+
const gate = createGate({ rateLimit: { windowMs: 60000, max: 5 } });
|
|
21
|
+
const app = createApp(createRateLimiter({ gate, keyPrefix: "test" }));
|
|
22
|
+
|
|
23
|
+
const res = await app.request("http://localhost/test", { method: "POST" });
|
|
24
|
+
expect(res.status).toBe(201);
|
|
25
|
+
expect(res.headers.get("X-RateLimit-Remaining")).toBe("4");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("blocks requests over limit", async () => {
|
|
29
|
+
const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
|
|
30
|
+
const app = createApp(createRateLimiter({ gate, keyPrefix: "test" }));
|
|
31
|
+
|
|
32
|
+
const res = await app.request("http://localhost/test", { method: "POST" });
|
|
33
|
+
expect(res.status).toBe(429);
|
|
34
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
35
|
+
expect(body).toHaveProperty("error");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("uses custom getKey function", async () => {
|
|
39
|
+
const gate = createGate({ rateLimit: { windowMs: 60000, max: 5 } });
|
|
40
|
+
const app = new Hono();
|
|
41
|
+
app.use(
|
|
42
|
+
createRateLimiter({
|
|
43
|
+
gate,
|
|
44
|
+
keyPrefix: "custom",
|
|
45
|
+
getKey: (c) => c.req.header("x-custom") ?? "unknown",
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
app.post("/test", (c) => c.json({ ok: true }, 201));
|
|
49
|
+
|
|
50
|
+
const res = await app.request("http://localhost/test", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "x-custom": "user-42" },
|
|
53
|
+
});
|
|
54
|
+
expect(res.status).toBe(201);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("falls back to clientIp then x-forwarded-for", async () => {
|
|
58
|
+
const gate = createGate({ rateLimit: { windowMs: 60000, max: 5 } });
|
|
59
|
+
const app = new Hono();
|
|
60
|
+
app.use(createRateLimiter({ gate, keyPrefix: "ip" }));
|
|
61
|
+
app.post("/test", (c) => c.json({ ok: true }, 201));
|
|
62
|
+
|
|
63
|
+
const res = await app.request("http://localhost/test", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "x-forwarded-for": "10.0.0.1" },
|
|
66
|
+
});
|
|
67
|
+
expect(res.status).toBe(201);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("requireIdempotencyKey", () => {
|
|
72
|
+
test("rejects missing key header", async () => {
|
|
73
|
+
const gate = createGate({ idempotency: { ttl: 60000 } });
|
|
74
|
+
const app = createApp(requireIdempotencyKey({ gate }));
|
|
75
|
+
|
|
76
|
+
const res = await app.request("http://localhost/test", { method: "POST" });
|
|
77
|
+
expect(res.status).toBe(400);
|
|
78
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
79
|
+
expect((body.error as Record<string, unknown>).message).toContain(
|
|
80
|
+
"Idempotency-Key header is required"
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("rejects invalid key format", async () => {
|
|
85
|
+
const gate = createGate({ idempotency: { ttl: 60000 } });
|
|
86
|
+
const app = createApp(requireIdempotencyKey({ gate }));
|
|
87
|
+
|
|
88
|
+
const res = await app.request("http://localhost/test", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Idempotency-Key": "short" },
|
|
91
|
+
});
|
|
92
|
+
expect(res.status).toBe(400);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("passes through on GET requests", async () => {
|
|
96
|
+
const gate = createGate({ idempotency: { ttl: 60000 } });
|
|
97
|
+
const app = new Hono();
|
|
98
|
+
app.use(requireIdempotencyKey({ gate }));
|
|
99
|
+
app.get("/safe", (c) => c.json({ ok: true }));
|
|
100
|
+
|
|
101
|
+
const res = await app.request("http://localhost/safe");
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns cached response on duplicate key", async () => {
|
|
106
|
+
const gate = createGate({ idempotency: { ttl: 60000 } });
|
|
107
|
+
await gate.idempotency.setResponse("idemp-dup-key", { success: true, data: { id: "cached" } });
|
|
108
|
+
const app = createApp(requireIdempotencyKey({ gate }));
|
|
109
|
+
|
|
110
|
+
const res = await app.request("http://localhost/test", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Idempotency-Key": "idemp-dup-key" },
|
|
113
|
+
});
|
|
114
|
+
expect(res.status).toBe(200);
|
|
115
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
116
|
+
expect((body.data as Record<string, unknown>).id).toBe("cached");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("caches successful response for idempotent re-use", async () => {
|
|
120
|
+
const gate = createGate({ idempotency: { ttl: 60000 } });
|
|
121
|
+
const app = createApp(requireIdempotencyKey({ gate }));
|
|
122
|
+
|
|
123
|
+
const res1 = await app.request("http://localhost/test", {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "Idempotency-Key": "cache-me" },
|
|
126
|
+
});
|
|
127
|
+
expect(res1.status).toBe(201);
|
|
128
|
+
|
|
129
|
+
const cached = await gate.idempotency.getResponse("cache-me");
|
|
130
|
+
expect(cached).toBeDefined();
|
|
131
|
+
expect((cached as Record<string, unknown>).data).toBeDefined();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("gateMiddleware", () => {
|
|
136
|
+
test("passes through when no features configured", async () => {
|
|
137
|
+
const gate = createGate();
|
|
138
|
+
const app = new Hono();
|
|
139
|
+
app.use(gateMiddleware(gate));
|
|
140
|
+
app.post("/test", (c) => c.json({ ok: true }));
|
|
141
|
+
|
|
142
|
+
const res = await app.request("http://localhost/test", { method: "POST" });
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("rejects when auth fails", async () => {
|
|
147
|
+
const gate = createGate({ apiKeys: [{ key: "sk-valid" }] });
|
|
148
|
+
const app = new Hono();
|
|
149
|
+
app.use(gateMiddleware(gate, { auth: true }));
|
|
150
|
+
app.post("/test", (c) => c.json({ ok: true }));
|
|
151
|
+
|
|
152
|
+
const res = await app.request("http://localhost/test", {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { Authorization: "Bearer sk-wrong" },
|
|
155
|
+
});
|
|
156
|
+
expect(res.status).toBe(401);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("rejects when rate limit exceeded", async () => {
|
|
160
|
+
const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
|
|
161
|
+
const app = new Hono();
|
|
162
|
+
app.use(gateMiddleware(gate, { rateLimit: true }));
|
|
163
|
+
app.post("/test", (c) => c.json({ ok: true }));
|
|
164
|
+
|
|
165
|
+
const res = await app.request("http://localhost/test", { method: "POST" });
|
|
166
|
+
expect(res.status).toBe(429);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/adapters/hono.ts
CHANGED
|
@@ -4,21 +4,13 @@ import type { Context, Next } from "hono";
|
|
|
4
4
|
|
|
5
5
|
type HonoRateLimitOptions = {
|
|
6
6
|
gate: Gate;
|
|
7
|
-
limit: number;
|
|
8
|
-
windowMs: number;
|
|
9
7
|
keyPrefix: string;
|
|
10
8
|
message?: string;
|
|
11
9
|
getKey?: (c: Context) => string;
|
|
12
10
|
};
|
|
13
11
|
|
|
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
12
|
export function createRateLimiter({
|
|
19
13
|
gate,
|
|
20
|
-
limit: max,
|
|
21
|
-
windowMs,
|
|
22
14
|
keyPrefix,
|
|
23
15
|
message = "Too many requests",
|
|
24
16
|
getKey,
|
|
@@ -26,21 +18,15 @@ export function createRateLimiter({
|
|
|
26
18
|
return createMiddleware(async (c: Context, next: Next) => {
|
|
27
19
|
const identifier = getKey
|
|
28
20
|
? getKey(c)
|
|
29
|
-
: (c.get("clientIp") as string | undefined) ??
|
|
30
|
-
c.req.header("x-forwarded-for") ??
|
|
31
|
-
"unknown";
|
|
21
|
+
: ((c.get("clientIp") as string | undefined) ?? c.req.header("x-forwarded-for") ?? "unknown");
|
|
32
22
|
|
|
33
23
|
const result = await gate.rateLimit.check(`${keyPrefix}:${identifier}`);
|
|
34
24
|
|
|
35
25
|
if (!result.allowed) {
|
|
36
|
-
return c.json(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"Retry-After": String(Math.ceil((result.reset - Date.now()) / 1000)),
|
|
41
|
-
"X-RateLimit-Remaining": "0",
|
|
42
|
-
}
|
|
43
|
-
);
|
|
26
|
+
return c.json({ success: false, error: { message, code: "RATE_LIMIT_EXCEEDED" } }, 429, {
|
|
27
|
+
"Retry-After": String(Math.ceil((result.reset - Date.now()) / 1000)),
|
|
28
|
+
"X-RateLimit-Remaining": "0",
|
|
29
|
+
});
|
|
44
30
|
}
|
|
45
31
|
|
|
46
32
|
c.res.headers.set("X-RateLimit-Remaining", String(result.remaining));
|
|
@@ -53,11 +39,6 @@ type HonoIdempotencyOptions = {
|
|
|
53
39
|
keyHeader?: string;
|
|
54
40
|
};
|
|
55
41
|
|
|
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
42
|
export function requireIdempotencyKey({
|
|
62
43
|
gate,
|
|
63
44
|
keyHeader = "Idempotency-Key",
|
|
@@ -99,25 +80,15 @@ export function requireIdempotencyKey({
|
|
|
99
80
|
return c.json(cached, 200);
|
|
100
81
|
}
|
|
101
82
|
|
|
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 any, headers);
|
|
112
|
-
};
|
|
113
|
-
|
|
114
83
|
await next();
|
|
84
|
+
|
|
85
|
+
if (c.res.status < 500) {
|
|
86
|
+
const body = await c.res.clone().json();
|
|
87
|
+
gate.idempotency.setResponse(key, body).catch(() => {});
|
|
88
|
+
}
|
|
115
89
|
});
|
|
116
90
|
}
|
|
117
91
|
|
|
118
|
-
/**
|
|
119
|
-
* Hono middleware wrapper for Gate's combined middleware.
|
|
120
|
-
*/
|
|
121
92
|
export function gateMiddleware(gate: Gate, opts?: MiddlewareOptions) {
|
|
122
93
|
const mw = gate.middleware(opts);
|
|
123
94
|
return createMiddleware(async (c: Context, next: Next) => {
|
package/src/errors.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
AuthenticationError,
|
|
31
31
|
RateLimitError,
|
|
32
32
|
IdempotencyError,
|
|
33
|
+
isGateError,
|
|
33
34
|
} from "./errors";
|
|
34
35
|
|
|
35
36
|
export {
|
|
@@ -49,6 +50,7 @@ export {
|
|
|
49
50
|
AuthenticationError,
|
|
50
51
|
RateLimitError,
|
|
51
52
|
IdempotencyError,
|
|
53
|
+
isGateError,
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
export type { ValidationSchemas, ValidationResult, ValidatedRequest } from "./validate";
|
|
@@ -82,8 +84,6 @@ export interface MiddlewareOptions {
|
|
|
82
84
|
requiredScopes?: string[];
|
|
83
85
|
rateLimit?: boolean;
|
|
84
86
|
idempotency?: boolean;
|
|
85
|
-
/** Override the max for this specific middleware. */
|
|
86
|
-
rateLimitMax?: number;
|
|
87
87
|
/** Paths to skip entirely. */
|
|
88
88
|
excludePaths?: string[];
|
|
89
89
|
}
|
package/src/stores/redis.ts
CHANGED
|
@@ -1,20 +1,6 @@
|
|
|
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
4
|
export function fromIORedis(client: {
|
|
19
5
|
get(key: string): Promise<string | null>;
|
|
20
6
|
set(key: string, value: string): Promise<unknown>;
|
|
@@ -27,7 +13,7 @@ export function fromIORedis(client: {
|
|
|
27
13
|
return {
|
|
28
14
|
get: (key) => client.get(key),
|
|
29
15
|
set: (key, value, opts) =>
|
|
30
|
-
opts?.ex ? client.setex(key, opts.ex, value) : client.set(key, value) as Promise<unknown
|
|
16
|
+
opts?.ex ? client.setex(key, opts.ex, value) : (client.set(key, value) as Promise<unknown>),
|
|
31
17
|
setex: (key, seconds, value) => client.setex(key, seconds, value),
|
|
32
18
|
incr: (key) => client.incr(key),
|
|
33
19
|
expire: (key, seconds) => client.expire(key, seconds),
|