@joinremba/gate 0.4.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 -299
- package/package.json +6 -1
- package/src/adapters/hono.ts +134 -0
- package/src/stores/redis.ts +35 -0
package/README.md
CHANGED
|
@@ -1,425 +1,394 @@
|
|
|
1
1
|
# @joinremba/gate
|
|
2
2
|
|
|
3
|
-
[](https://bun.sh)
|
|
3
|
+
[](https://www.npmjs.com/package/@joinremba/gate)
|
|
4
|
+
[](https://github.com/joinremba/gate/blob/main/LICENSE)
|
|
5
|
+
[](https://bun.sh)
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
API safety layer for TypeScript backends — request validation, structured responses, idempotency, rate limiting, and API key authentication. Pluggable stores (in-memory, Redis, PostgreSQL). Framework-agnostic — operates on plain `Request`/`Response`.
|
|
9
8
|
|
|
10
9
|
## Features
|
|
11
10
|
|
|
12
|
-
- **Request validation**
|
|
13
|
-
- **Structured
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
11
|
+
- **Request validation** with Zod schemas (body, query, params, headers)
|
|
12
|
+
- **Structured response helpers** — `ok()`, `fail()`, `paginated()`, `problem()` (RFC 9457)
|
|
13
|
+
- **Idempotency middleware** with pluggable stores (InMemory, Redis, Postgres)
|
|
14
|
+
- **Rate limiting** with fixed-window stores (InMemory, Redis, Postgres)
|
|
15
|
+
- **API key authentication** with scopes, SHA-256 hashing, and expiry
|
|
16
|
+
- **Combined middleware** — auth + rate limit + idempotency in one pass
|
|
17
|
+
- **WeakMap side-channel** for pass-through state (`getGateAuth`, `getGateRateLimit`, `getGateIdempotencyKey`)
|
|
18
|
+
- **Rich error hierarchy** — `GateError`, `ValidationError`, `AuthenticationError`, `RateLimitError`, `IdempotencyError`
|
|
19
|
+
- **Remote delegation** via `client` option (connect to [Nexus](https://github.com/joinremba))
|
|
20
|
+
- **Pluggable stores** — swap implementations via interfaces
|
|
21
|
+
- **Auto-migration** for Postgres stores (`ensureTable()`)
|
|
22
|
+
- **Zero framework dependency** — works with Hono, Express, Elysia, Bun, Node `http`, or plain fetch
|
|
20
23
|
|
|
21
24
|
## Installation
|
|
22
25
|
|
|
23
|
-
```
|
|
26
|
+
```bash
|
|
24
27
|
bun add @joinremba/gate
|
|
25
28
|
```
|
|
26
29
|
|
|
27
30
|
## Quick Start
|
|
28
31
|
|
|
29
32
|
```ts
|
|
30
|
-
import { createGate
|
|
33
|
+
import { createGate } from "@joinremba/gate";
|
|
31
34
|
import { z } from "zod";
|
|
32
35
|
|
|
33
36
|
const gate = createGate({
|
|
34
|
-
apiKeys: [{ key: "sk-
|
|
37
|
+
apiKeys: [{ key: "sk-admin", scopes: ["admin"] }],
|
|
35
38
|
rateLimit: { windowMs: 60_000, max: 100 },
|
|
39
|
+
idempotency: { ttl: 86_400_000 },
|
|
36
40
|
});
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
});
|
|
41
|
-
```
|
|
42
|
+
// Validate a request body
|
|
43
|
+
const userSchema = z.object({ email: z.string().email() });
|
|
44
|
+
const result = gate.validate({ body: userSchema }, { body: { email: "test@example.com" } });
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
// Check rate limit
|
|
47
|
+
const rl = await gate.rateLimit.check(new Request("http://localhost"));
|
|
48
|
+
// => { allowed: boolean, remaining: number, reset: number }
|
|
44
49
|
|
|
45
|
-
|
|
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"] }
|
|
46
55
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
import { ok, fail, paginated, problem } from "@joinremba/gate/respond";
|
|
50
|
-
import { idempotency, InMemoryStore } from "@joinremba/gate/idempotency";
|
|
51
|
-
import { rateLimit, InMemoryRateLimitStore } from "@joinremba/gate/rate-limit";
|
|
52
|
-
import { createApiKeyValidator } from "@joinremba/gate/api-keys";
|
|
53
|
-
import { GateError, ValidationError, AuthenticationError } from "@joinremba/gate/errors";
|
|
56
|
+
// Combined middleware (Hono / Express / Bun serve)
|
|
57
|
+
app.use(gate.middleware({ auth: true, rateLimit: true, idempotency: true }));
|
|
54
58
|
```
|
|
55
59
|
|
|
56
|
-
|
|
60
|
+
## createGate
|
|
57
61
|
|
|
58
|
-
|
|
62
|
+
The main factory. Accepts an options object and returns a `Gate` instance with all modules wired together.
|
|
59
63
|
|
|
60
64
|
```ts
|
|
65
|
+
import { createGate } from "@joinremba/gate";
|
|
66
|
+
|
|
61
67
|
const gate = createGate({
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
};
|
|
68
88
|
});
|
|
69
89
|
```
|
|
70
90
|
|
|
71
|
-
|
|
91
|
+
**Returns:**
|
|
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 |
|
|
104
|
+
|
|
105
|
+
## Validation
|
|
72
106
|
|
|
73
107
|
Validate request body, query, params, and headers against Zod schemas.
|
|
74
108
|
|
|
75
109
|
```ts
|
|
76
|
-
import { validateRequest } from "@joinremba/gate
|
|
110
|
+
import { validateRequest, validate } from "@joinremba/gate";
|
|
77
111
|
import { z } from "zod";
|
|
78
112
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
113
|
+
// Low-level: pass parsed input directly
|
|
114
|
+
const result = validateRequest(
|
|
115
|
+
{
|
|
116
|
+
body: z.object({ email: z.string().email() }),
|
|
117
|
+
query: z.object({ page: z.coerce.number() }),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
body: { email: "test@example.com" },
|
|
121
|
+
query: { page: "1" },
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
// => { success: true, data: { body: { email: '...' }, query: { page: 1 } } }
|
|
125
|
+
// or { success: false, errors: { body: ['body.email: Invalid email'] } }
|
|
126
|
+
|
|
127
|
+
// Bind to a standard Request
|
|
128
|
+
const validateReq = validate({ body: z.object({ email: z.string().email() }) });
|
|
129
|
+
const result = await validateReq(new Request("http://localhost", {
|
|
130
|
+
method: "POST",
|
|
131
|
+
body: JSON.stringify({ email: "test@example.com" }),
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
}));
|
|
90
134
|
```
|
|
91
135
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
Build consistent API responses.
|
|
136
|
+
## Response Helpers
|
|
95
137
|
|
|
96
138
|
```ts
|
|
97
|
-
import { ok, fail, paginated, problem } from "@joinremba/gate
|
|
98
|
-
|
|
99
|
-
// Success
|
|
100
|
-
ok({ id: 1, name: "Alice" });
|
|
101
|
-
// -> { success: true, data: { id: 1, name: "Alice" } }
|
|
102
|
-
|
|
103
|
-
// Error
|
|
104
|
-
fail("Resource not found", "NOT_FOUND");
|
|
105
|
-
// -> { success: false, error: { message: "Resource not found", code: "NOT_FOUND" } }
|
|
106
|
-
|
|
107
|
-
// Paginated
|
|
108
|
-
paginated([{ id: 1 }], 25, 1, 10);
|
|
109
|
-
// -> { success: true, data: [...], pagination: { total: 25, page: 1, limit: 10, pages: 3 } }
|
|
110
|
-
|
|
111
|
-
// Problem details (RFC 9457)
|
|
112
|
-
problem({
|
|
113
|
-
type: "https://errors.remba.com/rate-limit",
|
|
114
|
-
title: "Rate Limit Exceeded",
|
|
115
|
-
status: 429,
|
|
116
|
-
detail: "Too many requests, please retry later",
|
|
117
|
-
});
|
|
118
|
-
```
|
|
139
|
+
import { ok, fail, paginated, problem } from "@joinremba/gate";
|
|
119
140
|
|
|
120
|
-
|
|
141
|
+
ok(userData);
|
|
142
|
+
// { success: true, data: userData }
|
|
121
143
|
|
|
122
|
-
|
|
144
|
+
fail("Not found", "NOT_FOUND");
|
|
145
|
+
// { success: false, error: { message: "Not found", code: "NOT_FOUND" } }
|
|
123
146
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const guard = idempotency({
|
|
128
|
-
store: new InMemoryStore(),
|
|
129
|
-
keyHeader: "Idempotency-Key",
|
|
130
|
-
ttl: 86_400_000, // 24 hours
|
|
131
|
-
});
|
|
147
|
+
fail("Invalid input", "VALIDATION_ERROR", { field: "email" });
|
|
148
|
+
// { success: false, error: { message: "Invalid input", code: "VALIDATION_ERROR", details: { field: "email" } } }
|
|
132
149
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (existing) return existing;
|
|
150
|
+
paginated(items, 100, 1, 20);
|
|
151
|
+
// { success: true, data: items, pagination: { total: 100, page: 1, limit: 20, pages: 5 } }
|
|
136
152
|
|
|
137
|
-
|
|
138
|
-
|
|
153
|
+
problem({ type: "/errors/rate-limit", title: "Rate Limit Exceeded", status: 429, detail: "..." });
|
|
154
|
+
// { success: false, error: { message: "Rate Limit Exceeded", ... }, problem: { type, title, status, detail } }
|
|
139
155
|
```
|
|
140
156
|
|
|
141
|
-
|
|
157
|
+
## Idempotency
|
|
142
158
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
Protect endpoints from abuse.
|
|
159
|
+
Prevent duplicate processing by storing responses keyed by an idempotency key (default `Idempotency-Key` header).
|
|
146
160
|
|
|
147
161
|
```ts
|
|
148
|
-
import {
|
|
162
|
+
import { idempotency, InMemoryStore } from "@joinremba/gate";
|
|
149
163
|
|
|
150
|
-
const
|
|
151
|
-
windowMs: 60_000, // 1 minute
|
|
152
|
-
max: 30,
|
|
153
|
-
keyFn: (req) => req.headers.get("x-forwarded-for") ?? "global",
|
|
154
|
-
});
|
|
164
|
+
const guard = idempotency({ store: new InMemoryStore(), ttl: 86_400_000 });
|
|
155
165
|
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
// Check if a response was already cached
|
|
167
|
+
const cached = await guard.getResponse("unique-key");
|
|
168
|
+
if (!cached) {
|
|
169
|
+
const response = { success: true, data: await processPayment() };
|
|
170
|
+
await guard.setResponse("unique-key", response);
|
|
171
|
+
}
|
|
158
172
|
```
|
|
159
173
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
### API Keys (`@joinremba/gate/api-keys`)
|
|
163
|
-
|
|
164
|
-
Validates API keys with optional scoped permissions. Designed for **internal** authentication — service-to-service, admin dashboards, cron jobs, webhooks. Not a replacement for user auth (OAuth, JWTs, password login).
|
|
174
|
+
**Via `createGate`:**
|
|
165
175
|
|
|
166
176
|
```ts
|
|
167
|
-
|
|
177
|
+
const gate = createGate({ idempotency: { ttl: 86_400_000 } });
|
|
178
|
+
const cached = await gate.idempotency.getResponse("key");
|
|
179
|
+
if (!cached) {
|
|
180
|
+
await gate.idempotency.setResponse("key", response);
|
|
181
|
+
}
|
|
182
|
+
```
|
|
168
183
|
|
|
169
|
-
|
|
170
|
-
{ key: "sk-read-only", scopes: ["read"] },
|
|
171
|
-
{ key: "sk-admin", scopes: ["read", "write", "delete"] },
|
|
172
|
-
]);
|
|
184
|
+
## Rate Limiting
|
|
173
185
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// -> { authenticated: true, key: "sk-read-only", scopes: ["read"] }
|
|
186
|
+
```ts
|
|
187
|
+
import { rateLimit, InMemoryRateLimitStore, keyByApiKey } from "@joinremba/gate";
|
|
177
188
|
|
|
178
|
-
|
|
179
|
-
const auth = keys.authenticate({ requiredScopes: ["write"], header: "Authorization" });
|
|
180
|
-
const result = auth(request);
|
|
181
|
-
if (!result.authenticated) throw new AuthenticationError(result.error);
|
|
182
|
-
```
|
|
189
|
+
const limiter = rateLimit({ windowMs: 60_000, max: 100 });
|
|
183
190
|
|
|
184
|
-
|
|
191
|
+
// Check a standard Request (keyed by IP via x-forwarded-for)
|
|
192
|
+
const result = await limiter.check(new Request("http://localhost"));
|
|
193
|
+
// { allowed: boolean, remaining: number, reset: number }
|
|
185
194
|
|
|
186
|
-
|
|
195
|
+
// Check with a custom key string
|
|
196
|
+
const result = await limiter.check("user:42");
|
|
187
197
|
|
|
188
|
-
|
|
198
|
+
// Key by API key (Bearer token) instead of IP
|
|
199
|
+
const limiter2 = rateLimit({ keyFn: keyByApiKey });
|
|
200
|
+
```
|
|
189
201
|
|
|
190
|
-
|
|
202
|
+
**Via `createGate`:**
|
|
191
203
|
|
|
192
204
|
```ts
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
await validator.verify("sk-live-123");
|
|
198
|
-
// -> { authenticated: true, key: "sk-live-123", scopes: ["admin"] }
|
|
205
|
+
const gate = createGate({ rateLimit: { windowMs: 60_000, max: 100 } });
|
|
206
|
+
const result = await gate.rateLimit.check(req);
|
|
199
207
|
```
|
|
200
208
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
Validate keys against Postgres or Redis. Keys can be added/removed at runtime.
|
|
209
|
+
## API Keys
|
|
204
210
|
|
|
205
211
|
```ts
|
|
206
|
-
import {
|
|
212
|
+
import { createApiKeyValidator } from "@joinremba/gate";
|
|
207
213
|
|
|
208
|
-
const
|
|
209
|
-
|
|
214
|
+
const validator = createApiKeyValidator([
|
|
215
|
+
{ key: "sk-admin", scopes: ["admin"] },
|
|
216
|
+
{ key: "sk-read", scopes: ["read"] },
|
|
217
|
+
]);
|
|
210
218
|
|
|
211
|
-
//
|
|
212
|
-
|
|
219
|
+
// Direct key validation
|
|
220
|
+
const result = validator.validate("sk-admin");
|
|
221
|
+
// { authenticated: true, key: "sk-admin", scopes: ["admin"] }
|
|
213
222
|
|
|
214
|
-
//
|
|
215
|
-
const
|
|
223
|
+
// Verifying against hashed keys (SHA-256)
|
|
224
|
+
const hashedValidator = createApiKeyValidator(
|
|
225
|
+
[{ key: "sk-admin", scopes: ["admin"] }],
|
|
226
|
+
{ hashKeys: true }
|
|
227
|
+
);
|
|
228
|
+
const result = await hashedValidator.verify("sk-admin");
|
|
216
229
|
|
|
217
|
-
//
|
|
218
|
-
|
|
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
|
+
}));
|
|
219
235
|
```
|
|
220
236
|
|
|
221
|
-
|
|
222
|
-
import { RedisApiKeyStore } from "@joinremba/gate/stores/redis-api-keys";
|
|
237
|
+
**Via `createGate`:**
|
|
223
238
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const result = await
|
|
239
|
+
```ts
|
|
240
|
+
const gate = createGate({ apiKeys: [{ key: "sk-admin", scopes: ["admin"] }] });
|
|
241
|
+
const result = await gate.apiKeys.verify("sk-admin");
|
|
227
242
|
```
|
|
228
243
|
|
|
229
|
-
|
|
244
|
+
## Combined Middleware
|
|
230
245
|
|
|
231
|
-
Run auth, rate limiting, and idempotency in a single middleware
|
|
246
|
+
Run auth, rate limiting, and idempotency in a single pass. Returns a middleware function compatible with any framework.
|
|
232
247
|
|
|
233
248
|
```ts
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
249
|
+
const middleware = gate.middleware({
|
|
250
|
+
auth: true, // authenticate API keys (auto-enabled if apiKeys configured)
|
|
251
|
+
requiredScopes: ["admin"], // scope check
|
|
252
|
+
rateLimit: true, // rate limit (auto-enabled if rateLimit configured)
|
|
253
|
+
idempotency: true, // check Idempotency-Key header
|
|
254
|
+
rateLimitMax: 50, // override max for this specific middleware
|
|
255
|
+
excludePaths: ["/health", "/webhook"], // skip entirely
|
|
237
256
|
});
|
|
238
|
-
|
|
239
|
-
app.use(
|
|
240
|
-
gate.middleware({
|
|
241
|
-
auth: true,
|
|
242
|
-
requiredScopes: ["write"],
|
|
243
|
-
rateLimit: true,
|
|
244
|
-
idempotency: true,
|
|
245
|
-
excludePaths: ["/health", "/metrics"],
|
|
246
|
-
})
|
|
247
|
-
);
|
|
248
257
|
```
|
|
249
258
|
|
|
250
|
-
|
|
259
|
+
**Play nice with Hono:**
|
|
251
260
|
|
|
252
|
-
|
|
261
|
+
```ts
|
|
262
|
+
import { Hono } from "hono";
|
|
263
|
+
const app = new Hono();
|
|
264
|
+
app.use("*", (c, next) => gate.middleware()(c.req.raw, () => next()));
|
|
265
|
+
```
|
|
253
266
|
|
|
254
|
-
|
|
267
|
+
**Access pass-through state via WeakMap:**
|
|
255
268
|
|
|
256
269
|
```ts
|
|
257
|
-
import {
|
|
270
|
+
import { getGateAuth, getGateRateLimit, getGateIdempotencyKey } from "@joinremba/gate";
|
|
258
271
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
});
|
|
272
|
+
// Inside your request handler
|
|
273
|
+
const auth = getGateAuth(req); // { authenticated, key, scopes }
|
|
274
|
+
const rl = getGateRateLimit(req); // { allowed, remaining, reset }
|
|
275
|
+
const idemKey = getGateIdempotencyKey(req); // string | undefined
|
|
264
276
|
```
|
|
265
277
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
Standard error types for consistent error handling.
|
|
278
|
+
## Stores
|
|
269
279
|
|
|
270
|
-
|
|
271
|
-
| --------------------- | ------ | ---------------------- | -------------------------- |
|
|
272
|
-
| `GateError` | 500 | `GATE_ERROR` | Base error type |
|
|
273
|
-
| `ValidationError` | 400 | `VALIDATION_ERROR` | Invalid request input |
|
|
274
|
-
| `AuthenticationError` | 401 | `AUTHENTICATION_ERROR` | Missing or invalid API key |
|
|
275
|
-
| `RateLimitError` | 429 | `RATE_LIMIT_ERROR` | Rate limit exceeded |
|
|
276
|
-
| `IdempotencyError` | 409 | `IDEMPOTENCY_ERROR` | Idempotency key conflict |
|
|
280
|
+
### InMemory (default)
|
|
277
281
|
|
|
278
|
-
|
|
282
|
+
Built-in stores for development and single-process deployments.
|
|
279
283
|
|
|
280
284
|
```ts
|
|
281
|
-
import
|
|
282
|
-
Gate,
|
|
283
|
-
GateOptions,
|
|
284
|
-
Middleware,
|
|
285
|
-
ValidationSchemas,
|
|
286
|
-
ValidationResult,
|
|
287
|
-
SuccessResponse,
|
|
288
|
-
ErrorResponse,
|
|
289
|
-
PaginatedResponse,
|
|
290
|
-
ProblemDetails,
|
|
291
|
-
IdempotencyStore,
|
|
292
|
-
IdempotencyOptions,
|
|
293
|
-
RateLimitStore,
|
|
294
|
-
RateLimitOptions,
|
|
295
|
-
RateLimitStrategy,
|
|
296
|
-
ApiKeyEntry,
|
|
297
|
-
AuthenticateOptions,
|
|
298
|
-
AuthenticateResult,
|
|
299
|
-
} from "@joinremba/gate";
|
|
300
|
-
```
|
|
285
|
+
import { InMemoryStore, InMemoryRateLimitStore } from "@joinremba/gate";
|
|
301
286
|
|
|
302
|
-
|
|
287
|
+
const idemStore = new InMemoryStore();
|
|
288
|
+
const rlStore = new InMemoryRateLimitStore();
|
|
289
|
+
```
|
|
303
290
|
|
|
304
|
-
###
|
|
291
|
+
### Redis
|
|
305
292
|
|
|
306
293
|
```ts
|
|
307
|
-
import
|
|
308
|
-
import {
|
|
309
|
-
import {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
(
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
req.body = result.data.body;
|
|
325
|
-
next();
|
|
326
|
-
},
|
|
327
|
-
async (req, res) => {
|
|
328
|
-
const order = await createOrder(req.body);
|
|
329
|
-
res.json(ok(order));
|
|
330
|
-
}
|
|
331
|
-
);
|
|
294
|
+
import { RedisIdempotencyStore, RedisRateLimitStore } from "@joinremba/gate/stores/redis";
|
|
295
|
+
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
|
+
|
|
308
|
+
const idemStore = new RedisIdempotencyStore(client);
|
|
309
|
+
const rlStore = new RedisRateLimitStore(client);
|
|
310
|
+
const apiKeyStore = new RedisApiKeyStore(client);
|
|
332
311
|
```
|
|
333
312
|
|
|
334
|
-
###
|
|
313
|
+
### PostgreSQL
|
|
335
314
|
|
|
336
315
|
```ts
|
|
337
|
-
import {
|
|
338
|
-
import {
|
|
316
|
+
import { PostgresIdempotencyStore, PostgresRateLimitStore } from "@joinremba/gate/stores/postgres";
|
|
317
|
+
import { PostgresApiKeyStore } from "@joinremba/gate/stores/postgres-api-keys";
|
|
318
|
+
import type { PostgresClient } from "@joinremba/gate/stores/postgres";
|
|
339
319
|
|
|
340
|
-
const
|
|
341
|
-
|
|
320
|
+
const sql: PostgresClient = {
|
|
321
|
+
query: (text, params) => pool.query(text, params),
|
|
322
|
+
};
|
|
342
323
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
324
|
+
const idemStore = new PostgresIdempotencyStore(sql);
|
|
325
|
+
const rlStore = new PostgresRateLimitStore(sql);
|
|
326
|
+
const apiKeyStore = new PostgresApiKeyStore(sql);
|
|
327
|
+
|
|
328
|
+
// Auto-create tables
|
|
329
|
+
await idemStore.ensureTable();
|
|
330
|
+
await rlStore.ensureTable();
|
|
331
|
+
await apiKeyStore.ensureTable();
|
|
350
332
|
```
|
|
351
333
|
|
|
352
|
-
|
|
334
|
+
## Error Classes
|
|
353
335
|
|
|
354
336
|
```ts
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
337
|
+
import {
|
|
338
|
+
GateError,
|
|
339
|
+
ValidationError,
|
|
340
|
+
AuthenticationError,
|
|
341
|
+
RateLimitError,
|
|
342
|
+
IdempotencyError,
|
|
343
|
+
} from "@joinremba/gate";
|
|
360
344
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const rl = await gate.rateLimit.check(req);
|
|
364
|
-
if (!rl.allowed) return res.status(429).json(gate.fail("Too many requests"));
|
|
345
|
+
// Base class
|
|
346
|
+
throw new GateError("Something went wrong", "INTERNAL_ERROR", 500);
|
|
365
347
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (idemKey) {
|
|
369
|
-
const cached = await gate.idempotency.getResponse(idemKey);
|
|
370
|
-
if (cached) return res.json(cached);
|
|
371
|
-
}
|
|
348
|
+
// Validation — 400
|
|
349
|
+
throw new ValidationError("Invalid email", [{ path: "email", message: "Invalid format" }]);
|
|
372
350
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (!result.success) return res.status(400).json(gate.fail("Validation failed"));
|
|
351
|
+
// Authentication — 401
|
|
352
|
+
throw new AuthenticationError("Invalid API key");
|
|
376
353
|
|
|
377
|
-
|
|
354
|
+
// Rate limiting — 429
|
|
355
|
+
throw new RateLimitError(30); // retry after 30 seconds
|
|
378
356
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
});
|
|
357
|
+
// Idempotency conflict — 409
|
|
358
|
+
throw new IdempotencyError("Idempotency key already in use");
|
|
382
359
|
```
|
|
383
360
|
|
|
384
|
-
##
|
|
361
|
+
## Remote Delegation (client)
|
|
385
362
|
|
|
386
|
-
|
|
363
|
+
When a `client` from `@joinremba/core` is provided, `createGate` delegates rate limit, idempotency, and API key checks to [Nexus](https://github.com/joinremba). If Nexus is unreachable, methods fall back to local stores.
|
|
387
364
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
- Pagination helper
|
|
392
|
-
- Request ID support
|
|
393
|
-
- Express and Hono middleware examples
|
|
394
|
-
- In-memory idempotency store
|
|
395
|
-
- In-memory rate limiting store
|
|
396
|
-
|
|
397
|
-
**V1** (current)
|
|
398
|
-
|
|
399
|
-
- Redis and Postgres stores for idempotency and rate limiting
|
|
400
|
-
- API key hashing and validation
|
|
401
|
-
- DB-backed API key stores (Redis, Postgres)
|
|
402
|
-
- Combined middleware (auth + rate limit + idempotency)
|
|
403
|
-
- Per-key rate limiting helper
|
|
404
|
-
|
|
405
|
-
**V2**
|
|
406
|
-
|
|
407
|
-
- API key dashboard
|
|
408
|
-
- Usage analytics
|
|
409
|
-
- Abuse detection
|
|
410
|
-
- Team API key management
|
|
411
|
-
- Hosted key verification
|
|
412
|
-
- Organisation-level quotas
|
|
413
|
-
|
|
414
|
-
## Related Packages
|
|
415
|
-
|
|
416
|
-
- [@joinremba/beacon](https://github.com/joinremba/beacon) — Environment validation, config, secrets, and feature gates.
|
|
417
|
-
- [@joinremba/catalog](https://github.com/joinremba/catalog) — Production-ready logging and error event layer built on Pino.
|
|
365
|
+
```ts
|
|
366
|
+
import { createClient } from "@joinremba/core";
|
|
367
|
+
import { createGate } from "@joinremba/gate";
|
|
418
368
|
|
|
419
|
-
|
|
369
|
+
const gate = createGate({
|
|
370
|
+
client: createClient({ apiKey: "api_core_live_..." }),
|
|
371
|
+
rateLimit: { max: 100 }, // local fallback
|
|
372
|
+
idempotency: { ttl: 86_400_000 },
|
|
373
|
+
});
|
|
374
|
+
```
|
|
420
375
|
|
|
421
|
-
|
|
376
|
+
## API Reference
|
|
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` |
|
|
422
391
|
|
|
423
392
|
## License
|
|
424
393
|
|
|
425
|
-
MIT
|
|
394
|
+
MIT
|
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": [
|
|
@@ -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/stores/redis.ts
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
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>;
|