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