@joinremba/gate 0.5.1 → 0.5.2

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