@joinremba/gate 0.4.0 → 0.5.1

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 CHANGED
@@ -1,425 +1,394 @@
1
1
  # @joinremba/gate
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@joinremba/gate?logo=npm)](https://www.npmjs.com/package/@joinremba/gate)
4
- [![Licence](https://img.shields.io/npm/l/@joinremba/gate)](LICENSE)
5
- [![CI](https://github.com/joinremba/gate/actions/workflows/ci.yml/badge.svg)](https://github.com/joinremba/gate/actions/workflows/ci.yml)
6
- [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.3.1-black?logo=bun)](https://bun.sh)
3
+ [![npm version](https://img.shields.io/npm/v/@joinremba/gate.svg)](https://www.npmjs.com/package/@joinremba/gate)
4
+ [![license](https://img.shields.io/npm/l/@joinremba/gate.svg)](https://github.com/joinremba/gate/blob/main/LICENSE)
5
+ [![bun](https://img.shields.io/badge/bun-%3E%3D1.3.1-black)](https://bun.sh)
7
6
 
8
- Gate is the API safety layer for TypeScript backends. It validates requests, formats responses, prevents duplicate operations, manages API keys, and protects endpoints from abuse.
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** Validate body, query, params, and headers with Zod schemas.
13
- - **Structured responses** — Consistent `{ success, data, error }` response envelope with `ok()` and `fail()` helpers.
14
- - **Problem details** RFC 9457 problem-details-style error format.
15
- - **Pagination** Standardised paginated response helper.
16
- - **Idempotency** Prevent duplicate writes with idempotency keys. Ships with in-memory store; plug in Redis or Postgres.
17
- - **Rate limiting** — Protect endpoints from abuse with configurable windows and limits.
18
- - **API key management** Rotatable, scoped API key authentication with Bearer token support.
19
- - **Framework-agnostic** — Use with Express, Hono, Fastify, Elysia, or raw Bun.
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
- ```sh
26
+ ```bash
24
27
  bun add @joinremba/gate
25
28
  ```
26
29
 
27
30
  ## Quick Start
28
31
 
29
32
  ```ts
30
- import { createGate, ok } from "@joinremba/gate";
33
+ import { createGate } from "@joinremba/gate";
31
34
  import { z } from "zod";
32
35
 
33
36
  const gate = createGate({
34
- apiKeys: [{ key: "sk-abc123", scopes: ["write"] }],
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
- app.post("/transfers", gate.middleware(), async (req, res) => {
39
- return ok({ message: "Transfer queued" });
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
- ## Modules
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
- Gate is organised into sub-modules that can be imported individually:
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
- ```ts
48
- import { validateRequest } from "@joinremba/gate/validate";
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
- ### `createGate(options?)`
60
+ ## createGate
57
61
 
58
- Factory function that returns a `Gate` instance with all modules pre-configured.
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
- apiKeys: [
63
- { key: "sk-read-only", scopes: ["read"] },
64
- { key: "sk-admin", scopes: ["read", "write", "delete"] },
65
- ],
66
- rateLimit: { windowMs: 60_000, max: 50 },
67
- idempotency: { keyHeader: "Idempotency-Key", ttl: 86_400_000 },
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
- ### Validate (`@joinremba/gate/validate`)
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/validate";
110
+ import { validateRequest, validate } from "@joinremba/gate";
77
111
  import { z } from "zod";
78
112
 
79
- const transferSchema = z.object({
80
- amount: z.number().positive(),
81
- currency: z.enum(["NGN", "USD", "EUR"]),
82
- recipient: z.string().min(1),
83
- });
84
-
85
- const result = validateRequest({ body: transferSchema }, { body: req.body });
86
-
87
- if (!result.success) {
88
- return gate.fail("Validation failed", "VALIDATION_ERROR", result.errors);
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
- ### Respond (`@joinremba/gate/respond`)
93
-
94
- Build consistent API responses.
136
+ ## Response Helpers
95
137
 
96
138
  ```ts
97
- import { ok, fail, paginated, problem } from "@joinremba/gate/respond";
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
- ### Idempotency (`@joinremba/gate/idempotency`)
141
+ ok(userData);
142
+ // { success: true, data: userData }
121
143
 
122
- Prevent duplicate processing of the same request using idempotency keys.
144
+ fail("Not found", "NOT_FOUND");
145
+ // { success: false, error: { message: "Not found", code: "NOT_FOUND" } }
123
146
 
124
- ```ts
125
- import { idempotency, InMemoryStore } from "@joinremba/gate/idempotency";
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
- // Check if a request has been processed
134
- const existing = await guard.getResponse(idempotencyKey);
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
- // Store the response after processing
138
- 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 } }
139
155
  ```
140
156
 
141
- Bring your own store by implementing the `IdempotencyStore` interface (Redis, Postgres, etc.).
157
+ ## Idempotency
142
158
 
143
- ### Rate Limiting (`@joinremba/gate/rate-limit`)
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 { rateLimit, InMemoryRateLimitStore } from "@joinremba/gate/rate-limit";
162
+ import { idempotency, InMemoryStore } from "@joinremba/gate";
149
163
 
150
- const limiter = rateLimit({
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
- const { allowed, remaining } = await limiter.check(req);
157
- if (!allowed) throw new RateLimitError();
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
- Customise the key function to rate-limit by user ID, API key, or IP.
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
- import { createApiKeyValidator } from "@joinremba/gate/api-keys";
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
- const keys = createApiKeyValidator([
170
- { key: "sk-read-only", scopes: ["read"] },
171
- { key: "sk-admin", scopes: ["read", "write", "delete"] },
172
- ]);
184
+ ## Rate Limiting
173
185
 
174
- // Direct validation
175
- keys.validate("sk-read-only");
176
- // -> { authenticated: true, key: "sk-read-only", scopes: ["read"] }
186
+ ```ts
187
+ import { rateLimit, InMemoryRateLimitStore, keyByApiKey } from "@joinremba/gate";
177
188
 
178
- // Request authentication middleware
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
- **When to use it:** You have a few static keys for internal services, a shared webhook secret, or scoped tokens for admin tools. Keys are configured at startup and held in memory — no database query per request, zero dependencies.
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
- **When not to use it:** You need key rotation, hashed storage, per-user API keys, expiry/revocation, or rate limiting per key. For those cases, use the hashed or DB-backed stores below.
195
+ // Check with a custom key string
196
+ const result = await limiter.check("user:42");
187
197
 
188
- #### Hashed API Keys
198
+ // Key by API key (Bearer token) instead of IP
199
+ const limiter2 = rateLimit({ keyFn: keyByApiKey });
200
+ ```
189
201
 
190
- Store SHA-256 hashes instead of plaintext keys. Protects against memory dumps.
202
+ **Via `createGate`:**
191
203
 
192
204
  ```ts
193
- const validator = createApiKeyValidator([{ key: "sk-live-123", scopes: ["admin"] }], {
194
- hashKeys: true,
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
- #### DB-backed API Key Stores
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 { PostgresApiKeyStore } from "@joinremba/gate/stores/postgres-api-keys";
212
+ import { createApiKeyValidator } from "@joinremba/gate";
207
213
 
208
- const store = new PostgresApiKeyStore(pgClient);
209
- await store.ensureTable();
214
+ const validator = createApiKeyValidator([
215
+ { key: "sk-admin", scopes: ["admin"] },
216
+ { key: "sk-read", scopes: ["read"] },
217
+ ]);
210
218
 
211
- // Add a key
212
- await store.setKey({ key: "sk-live-abc", scopes: ["read"] }, expiresAt);
219
+ // Direct key validation
220
+ const result = validator.validate("sk-admin");
221
+ // { authenticated: true, key: "sk-admin", scopes: ["admin"] }
213
222
 
214
- // Validate
215
- const result = await store.verify("sk-live-abc");
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
- // Remove
218
- await store.deleteKey("sk-live-abc");
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
- ```ts
222
- import { RedisApiKeyStore } from "@joinremba/gate/stores/redis-api-keys";
237
+ **Via `createGate`:**
223
238
 
224
- const store = new RedisApiKeyStore(redisClient);
225
- await store.setKey({ key: "sk-redis-key", scopes: ["admin"] });
226
- const result = await store.verify("sk-redis-key");
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
- ### Combined Middleware
244
+ ## Combined Middleware
230
245
 
231
- Run auth, rate limiting, and idempotency in a single middleware call:
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 gate = createGate({
235
- apiKeys: [{ key: "sk-admin" }],
236
- rateLimit: { windowMs: 60_000, max: 100 },
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
- The middleware returns 401 for invalid/missing keys, 429 when rate limited, and caches idempotent responses automatically.
259
+ **Play nice with Hono:**
251
260
 
252
- ### Per-key Rate Limiting
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
- Rate-limit by API key instead of IP:
267
+ **Access pass-through state via WeakMap:**
255
268
 
256
269
  ```ts
257
- import { rateLimit, keyByApiKey } from "@joinremba/gate/rate-limit";
270
+ import { getGateAuth, getGateRateLimit, getGateIdempotencyKey } from "@joinremba/gate";
258
271
 
259
- const limiter = rateLimit({
260
- windowMs: 60_000,
261
- max: 30,
262
- keyFn: keyByApiKey,
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
- ### Errors (`@joinremba/gate/errors`)
267
-
268
- Standard error types for consistent error handling.
278
+ ## Stores
269
279
 
270
- | Error | Status | Code | When |
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
- ## TypeScript Types
282
+ Built-in stores for development and single-process deployments.
279
283
 
280
284
  ```ts
281
- import type {
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
- ## Examples
287
+ const idemStore = new InMemoryStore();
288
+ const rlStore = new InMemoryRateLimitStore();
289
+ ```
303
290
 
304
- ### Express middleware
291
+ ### Redis
305
292
 
306
293
  ```ts
307
- import express from "express";
308
- import { createGate, ok } from "@joinremba/gate";
309
- import { z } from "zod";
310
-
311
- const app = express();
312
- const gate = createGate({ rateLimit: { windowMs: 60_000, max: 30 } });
313
-
314
- app.post(
315
- "/api/orders",
316
- (req, res, next) => {
317
- const result = gate.validate(
318
- { body: z.object({ productId: z.string(), quantity: z.number() }) },
319
- { body: req.body }
320
- );
321
- if (!result.success) {
322
- return res.status(400).json(gate.fail("Validation failed", undefined, result.errors));
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
- ### Hono middleware
313
+ ### PostgreSQL
335
314
 
336
315
  ```ts
337
- import { Hono } from "hono";
338
- import { createGate, ok } from "@joinremba/gate";
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 app = new Hono();
341
- const gate = createGate({ apiKeys: [{ key: "sk-admin" }] });
320
+ const sql: PostgresClient = {
321
+ query: (text, params) => pool.query(text, params),
322
+ };
342
323
 
343
- app.use("/api/*", async (c, next) => {
344
- const auth = gate.apiKeys.authenticate()(c.req.raw);
345
- if (!auth.authenticated) {
346
- return c.json(gate.fail("Unauthorized"), 401);
347
- }
348
- await next();
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
- ### Combining multiple features
334
+ ## Error Classes
353
335
 
354
336
  ```ts
355
- const gate = createGate({
356
- apiKeys: [{ key: "sk-admin", scopes: ["write"] }],
357
- idempotency: { store: new InMemoryStore(), ttl: 86_400_000 },
358
- rateLimit: { windowMs: 60_000, max: 50 },
359
- });
337
+ import {
338
+ GateError,
339
+ ValidationError,
340
+ AuthenticationError,
341
+ RateLimitError,
342
+ IdempotencyError,
343
+ } from "@joinremba/gate";
360
344
 
361
- app.post("/orders", async (req, res, next) => {
362
- // Rate limit
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
- // Idempotency
367
- const idemKey = req.headers.get(gate.idempotency.keyHeader);
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
- // Validate
374
- const result = gate.validate({ body: z.object({ amount: z.number() }) }, { body: req.body });
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
- const response = ok(await processOrder(result.data.body));
354
+ // Rate limiting 429
355
+ throw new RateLimitError(30); // retry after 30 seconds
378
356
 
379
- if (idemKey) await gate.idempotency.setResponse(idemKey, response);
380
- res.json(response);
381
- });
357
+ // Idempotency conflict 409
358
+ throw new IdempotencyError("Idempotency key already in use");
382
359
  ```
383
360
 
384
- ## Roadmap
361
+ ## Remote Delegation (client)
385
362
 
386
- **MVP** (current)
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
- - Request validation (body, query, params, headers)
389
- - Standard success/error responses
390
- - Problem-details error format (RFC 9457)
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
- ## Contributing
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
- Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, development workflow, and pull request process.
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 — see [LICENSE](LICENSE).
394
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinremba/gate",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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.get("clientIp") as string | undefined) ??
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 any, 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
+ }
@@ -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>;