@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 CHANGED
@@ -1,427 +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
- - **Request ID** Automatic request ID generation and propagation.
17
- - **Idempotency** — Prevent duplicate writes with idempotency keys. Ships with in-memory store; plug in Redis or Postgres.
18
- - **Rate limiting** Protect endpoints from abuse with configurable windows and limits.
19
- - **API key management** — Rotatable, scoped API key authentication with Bearer token support.
20
- - **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
21
23
 
22
24
  ## Installation
23
25
 
24
- ```sh
26
+ ```bash
25
27
  bun add @joinremba/gate
26
28
  ```
27
29
 
28
30
  ## Quick Start
29
31
 
30
32
  ```ts
31
- import { createGate, ok } from "@joinremba/gate";
33
+ import { createGate } from "@joinremba/gate";
32
34
  import { z } from "zod";
33
35
 
34
36
  const gate = createGate({
35
- apiKeys: [{ key: "sk-abc123", scopes: ["write"] }],
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
- app.post("/transfers", gate.middleware(), async (req, res) => {
40
- return ok({ message: "Transfer queued" });
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
- ## Modules
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
- 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"] }
47
55
 
48
- ```ts
49
- import { validateRequest } from "@joinremba/gate/validate";
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
- ### `createGate(options?)`
60
+ ## createGate
58
61
 
59
- 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.
60
63
 
61
64
  ```ts
65
+ import { createGate } from "@joinremba/gate";
66
+
62
67
  const gate = createGate({
63
- apiKeys: [
64
- { key: "sk-read-only", scopes: ["read"] },
65
- { key: "sk-admin", scopes: ["read", "write", "delete"] },
66
- ],
67
- rateLimit: { windowMs: 60_000, max: 50 },
68
- 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
+ };
69
88
  });
70
89
  ```
71
90
 
72
- ### 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
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/validate";
110
+ import { validateRequest, validate } from "@joinremba/gate";
78
111
  import { z } from "zod";
79
112
 
80
- const transferSchema = z.object({
81
- amount: z.number().positive(),
82
- currency: z.enum(["NGN", "USD", "EUR"]),
83
- recipient: z.string().min(1),
84
- });
85
-
86
- const result = validateRequest({ body: transferSchema }, { body: req.body });
87
-
88
- if (!result.success) {
89
- return gate.fail("Validation failed", "VALIDATION_ERROR", result.errors);
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
- ### Respond (`@joinremba/gate/respond`)
94
-
95
- Build consistent API responses.
136
+ ## Response Helpers
96
137
 
97
138
  ```ts
98
- import { ok, fail, paginated, problem } from "@joinremba/gate/respond";
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
- ### Idempotency (`@joinremba/gate/idempotency`)
141
+ ok(userData);
142
+ // { success: true, data: userData }
122
143
 
123
- 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" } }
124
146
 
125
- ```ts
126
- import { idempotency, InMemoryStore } from "@joinremba/gate/idempotency";
147
+ fail("Invalid input", "VALIDATION_ERROR", { field: "email" });
148
+ // { success: false, error: { message: "Invalid input", code: "VALIDATION_ERROR", details: { field: "email" } } }
127
149
 
128
- const guard = idempotency({
129
- store: new InMemoryStore(),
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
- // Check if a request has been processed
135
- const existing = await guard.getResponse(idempotencyKey);
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
- Bring your own store by implementing the `IdempotencyStore` interface (Redis, Postgres, etc.).
143
-
144
- ### Rate Limiting (`@joinremba/gate/rate-limit`)
157
+ ## Idempotency
145
158
 
146
- Protect endpoints from abuse.
159
+ Prevent duplicate processing by storing responses keyed by an idempotency key (default `Idempotency-Key` header).
147
160
 
148
161
  ```ts
149
- import { rateLimit, InMemoryRateLimitStore } from "@joinremba/gate/rate-limit";
162
+ import { idempotency, InMemoryStore } from "@joinremba/gate";
150
163
 
151
- const limiter = rateLimit({
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
- const { allowed, remaining } = await limiter.check(req);
158
- 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
+ }
159
172
  ```
160
173
 
161
- Customise the key function to rate-limit by user ID, API key, or IP.
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
- 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
+ ```
169
183
 
170
- const keys = createApiKeyValidator([
171
- { key: "sk-read-only", scopes: ["read"] },
172
- { key: "sk-admin", scopes: ["read", "write", "delete"] },
173
- ]);
184
+ ## Rate Limiting
174
185
 
175
- // Direct validation
176
- keys.validate("sk-read-only");
177
- // -> { authenticated: true, key: "sk-read-only", scopes: ["read"] }
186
+ ```ts
187
+ import { rateLimit, InMemoryRateLimitStore, keyByApiKey } from "@joinremba/gate";
178
188
 
179
- // Request authentication middleware
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
- **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 }
186
194
 
187
- **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");
188
197
 
189
- #### Hashed API Keys
198
+ // Key by API key (Bearer token) instead of IP
199
+ const limiter2 = rateLimit({ keyFn: keyByApiKey });
200
+ ```
190
201
 
191
- Store SHA-256 hashes instead of plaintext keys. Protects against memory dumps.
202
+ **Via `createGate`:**
192
203
 
193
204
  ```ts
194
- const validator = createApiKeyValidator(
195
- [{ key: "9418b81169b7...", scopes: ["admin"] }], // pre-computed sha256("sk-live-123")
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
- #### DB-backed API Key Stores
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 { PostgresApiKeyStore } from "@joinremba/gate/stores/postgres-api-keys";
212
+ import { createApiKeyValidator } from "@joinremba/gate";
209
213
 
210
- const store = new PostgresApiKeyStore(pgClient);
211
- await store.ensureTable();
214
+ const validator = createApiKeyValidator([
215
+ { key: "sk-admin", scopes: ["admin"] },
216
+ { key: "sk-read", scopes: ["read"] },
217
+ ]);
212
218
 
213
- // Add a key
214
- 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"] }
215
222
 
216
- // Validate
217
- 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");
218
229
 
219
- // Remove
220
- 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
+ }));
221
235
  ```
222
236
 
223
- ```ts
224
- import { RedisApiKeyStore } from "@joinremba/gate/stores/redis-api-keys";
237
+ **Via `createGate`:**
225
238
 
226
- const store = new RedisApiKeyStore(redisClient);
227
- await store.setKey({ key: "sk-redis-key", scopes: ["admin"] });
228
- 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");
229
242
  ```
230
243
 
231
- ### Combined Middleware
244
+ ## Combined Middleware
232
245
 
233
- 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.
234
247
 
235
248
  ```ts
236
- const gate = createGate({
237
- apiKeys: [{ key: "sk-admin" }],
238
- 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
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
- The middleware returns 401 for invalid/missing keys, 429 when rate limited, and caches idempotent responses automatically.
259
+ **Play nice with Hono:**
253
260
 
254
- ### 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
+ ```
255
266
 
256
- Rate-limit by API key instead of IP:
267
+ **Access pass-through state via WeakMap:**
257
268
 
258
269
  ```ts
259
- import { rateLimit, keyByApiKey } from "@joinremba/gate/rate-limit";
270
+ import { getGateAuth, getGateRateLimit, getGateIdempotencyKey } from "@joinremba/gate";
260
271
 
261
- const limiter = rateLimit({
262
- windowMs: 60_000,
263
- max: 30,
264
- keyFn: keyByApiKey,
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
- ### Errors (`@joinremba/gate/errors`)
269
-
270
- Standard error types for consistent error handling.
278
+ ## Stores
271
279
 
272
- | Error | Status | Code | When |
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
- ## TypeScript Types
282
+ Built-in stores for development and single-process deployments.
281
283
 
282
284
  ```ts
283
- import type {
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
- ## Examples
287
+ const idemStore = new InMemoryStore();
288
+ const rlStore = new InMemoryRateLimitStore();
289
+ ```
305
290
 
306
- ### Express middleware
291
+ ### Redis
307
292
 
308
293
  ```ts
309
- import express from "express";
310
- import { createGate, ok } from "@joinremba/gate";
311
- import { z } from "zod";
312
-
313
- const app = express();
314
- const gate = createGate({ rateLimit: { windowMs: 60_000, max: 30 } });
315
-
316
- app.post(
317
- "/api/orders",
318
- (req, res, next) => {
319
- const result = gate.validate(
320
- { body: z.object({ productId: z.string(), quantity: z.number() }) },
321
- { body: req.body }
322
- );
323
- if (!result.success) {
324
- return res.status(400).json(gate.fail("Validation failed", undefined, result.errors));
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
- ### Hono middleware
313
+ ### PostgreSQL
337
314
 
338
315
  ```ts
339
- import { Hono } from "hono";
340
- 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";
341
319
 
342
- const app = new Hono();
343
- const gate = createGate({ apiKeys: [{ key: "sk-admin" }] });
320
+ const sql: PostgresClient = {
321
+ query: (text, params) => pool.query(text, params),
322
+ };
344
323
 
345
- app.use("/api/*", async (c, next) => {
346
- const auth = gate.apiKeys.authenticate()(c.req.raw);
347
- if (!auth.authenticated) {
348
- return c.json(gate.fail("Unauthorized"), 401);
349
- }
350
- await next();
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
- ### Combining multiple features
334
+ ## Error Classes
355
335
 
356
336
  ```ts
357
- const gate = createGate({
358
- apiKeys: [{ key: "sk-admin", scopes: ["write"] }],
359
- idempotency: { store: new InMemoryStore(), ttl: 86_400_000 },
360
- rateLimit: { windowMs: 60_000, max: 50 },
361
- });
337
+ import {
338
+ GateError,
339
+ ValidationError,
340
+ AuthenticationError,
341
+ RateLimitError,
342
+ IdempotencyError,
343
+ } from "@joinremba/gate";
362
344
 
363
- app.post("/orders", async (req, res, next) => {
364
- // Rate limit
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
- // Idempotency
369
- const idemKey = req.headers.get(gate.idempotency.keyHeader);
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
- // Validate
376
- const result = gate.validate({ body: z.object({ amount: z.number() }) }, { body: req.body });
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
- const response = ok(await processOrder(result.data.body));
354
+ // Rate limiting 429
355
+ throw new RateLimitError(30); // retry after 30 seconds
380
356
 
381
- if (idemKey) await gate.idempotency.setResponse(idemKey, response);
382
- res.json(response);
383
- });
357
+ // Idempotency conflict 409
358
+ throw new IdempotencyError("Idempotency key already in use");
384
359
  ```
385
360
 
386
- ## Roadmap
361
+ ## Remote Delegation (client)
387
362
 
388
- **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.
389
364
 
390
- - Request validation (body, query, params, headers)
391
- - Standard success/error responses
392
- - Problem-details error format (RFC 9457)
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
- ## 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
+ ```
422
375
 
423
- 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` |
424
391
 
425
392
  ## License
426
393
 
427
- MIT — see [LICENSE](LICENSE).
394
+ MIT