@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinremba/gate",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "API safety layer for TypeScript backends. Validate requests, format responses, prevent duplicates, manage API keys, and protect endpoints from abuse.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -60,6 +60,11 @@
60
60
  "types": "./src/stores/postgres-api-keys.ts",
61
61
  "import": "./src/stores/postgres-api-keys.ts",
62
62
  "default": "./src/stores/postgres-api-keys.ts"
63
+ },
64
+ "./adapters/hono": {
65
+ "types": "./src/adapters/hono.ts",
66
+ "import": "./src/adapters/hono.ts",
67
+ "default": "./src/adapters/hono.ts"
63
68
  }
64
69
  },
65
70
  "files": [
@@ -110,6 +115,7 @@
110
115
  "bun": ">=1.3.1"
111
116
  },
112
117
  "dependencies": {
118
+ "@joinremba/core": "^0.4.0",
113
119
  "zod": "^4.4.2"
114
120
  },
115
121
  "devDependencies": {
@@ -0,0 +1,134 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import type { Gate, MiddlewareOptions } from "../index";
3
+ import type { Context, Next } from "hono";
4
+
5
+ type HonoRateLimitOptions = {
6
+ gate: Gate;
7
+ limit: number;
8
+ windowMs: number;
9
+ keyPrefix: string;
10
+ message?: string;
11
+ getKey?: (c: Context) => string;
12
+ };
13
+
14
+ /**
15
+ * Create a Hono rate-limit middleware from a Gate instance.
16
+ * Sets X-RateLimit-Remaining header and returns 429 with Retry-After when exceeded.
17
+ */
18
+ export function createRateLimiter({
19
+ gate,
20
+ limit: max,
21
+ windowMs,
22
+ keyPrefix,
23
+ message = "Too many requests",
24
+ getKey,
25
+ }: HonoRateLimitOptions) {
26
+ return createMiddleware(async (c: Context, next: Next) => {
27
+ const identifier = getKey
28
+ ? getKey(c)
29
+ : ((c as Record<string, unknown>).var as Record<string, unknown>).clientIp ??
30
+ c.req.header("x-forwarded-for") ??
31
+ "unknown";
32
+
33
+ const result = await gate.rateLimit.check(`${keyPrefix}:${identifier}`);
34
+
35
+ if (!result.allowed) {
36
+ return c.json(
37
+ { success: false, error: { message, code: "RATE_LIMIT_EXCEEDED" } },
38
+ 429,
39
+ {
40
+ "Retry-After": String(Math.ceil((result.reset - Date.now()) / 1000)),
41
+ "X-RateLimit-Remaining": "0",
42
+ }
43
+ );
44
+ }
45
+
46
+ c.res.headers.set("X-RateLimit-Remaining", String(result.remaining));
47
+ await next();
48
+ });
49
+ }
50
+
51
+ type HonoIdempotencyOptions = {
52
+ gate: Gate;
53
+ keyHeader?: string;
54
+ };
55
+
56
+ /**
57
+ * Create a Hono idempotency middleware from a Gate instance.
58
+ * Requires Idempotency-Key header on mutating requests.
59
+ * Returns cached response on repeat requests. Stores response after handler completes.
60
+ */
61
+ export function requireIdempotencyKey({
62
+ gate,
63
+ keyHeader = "Idempotency-Key",
64
+ }: HonoIdempotencyOptions) {
65
+ const KEY_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/;
66
+
67
+ return createMiddleware(async (c: Context, next: Next) => {
68
+ const safeMethods = ["GET", "HEAD", "OPTIONS"];
69
+ if (safeMethods.includes(c.req.method)) {
70
+ await next();
71
+ return;
72
+ }
73
+
74
+ const key = c.req.header(keyHeader)?.trim() ?? "";
75
+ if (!key) {
76
+ return c.json(
77
+ {
78
+ success: false,
79
+ error: { message: `${keyHeader} header is required`, code: "BAD_REQUEST" },
80
+ },
81
+ 400
82
+ );
83
+ }
84
+ if (!KEY_PATTERN.test(key)) {
85
+ return c.json(
86
+ {
87
+ success: false,
88
+ error: {
89
+ message: `${keyHeader} must be 8-128 chars (letters, numbers, ., _, :, -)`,
90
+ code: "BAD_REQUEST",
91
+ },
92
+ },
93
+ 400
94
+ );
95
+ }
96
+
97
+ const cached = await gate.idempotency.getResponse(key);
98
+ if (cached) {
99
+ return c.json(cached, 200);
100
+ }
101
+
102
+ const originalJson = c.json.bind(c);
103
+ (c.json as any) = (
104
+ body: unknown,
105
+ status?: number,
106
+ headers?: Record<string, string>
107
+ ) => {
108
+ if (status === undefined || status < 500) {
109
+ gate.idempotency.setResponse(key, body).catch(() => {});
110
+ }
111
+ return originalJson(body, status as number, headers);
112
+ };
113
+
114
+ await next();
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Hono middleware wrapper for Gate's combined middleware.
120
+ */
121
+ export function gateMiddleware(gate: Gate, opts?: MiddlewareOptions) {
122
+ const mw = gate.middleware(opts);
123
+ return createMiddleware(async (c: Context, next: Next) => {
124
+ const req = new Request(c.req.raw);
125
+ const res = await mw(req, async () => {
126
+ await next();
127
+ return c.res;
128
+ });
129
+ if (res && res.status !== 200) {
130
+ const body = await res.json();
131
+ return c.json(body, res.status as 200 | 400 | 401 | 429 | 500);
132
+ }
133
+ });
134
+ }
@@ -1,6 +1,5 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { createApiKeyValidator } from "./api-keys";
3
- import type { AuthenticateResult } from "./api-keys";
4
3
 
5
4
  test("validate returns authenticated for matching key", () => {
6
5
  const validator = createApiKeyValidator([{ key: "sk-live-abc", scopes: ["read"] }]);
@@ -20,7 +19,7 @@ test("verify with hashKeys true hashes before lookup", async () => {
20
19
  const validator = createApiKeyValidator(
21
20
  [
22
21
  {
23
- key: "9418b81169b79003fd8c4481e61b79a762e996a0c172cda188c927714b5ee05b",
22
+ key: "sk-live-123",
24
23
  scopes: ["admin"],
25
24
  },
26
25
  ],
@@ -38,7 +37,7 @@ test("authenticate middleware reads Authorization header", async () => {
38
37
  const req = new Request("http://localhost", {
39
38
  headers: { Authorization: "Bearer sk-test" },
40
39
  });
41
- const result = await (auth(req) as Promise<AuthenticateResult>);
40
+ const result = await auth(req);
42
41
  expect(result.authenticated).toBe(true);
43
42
  });
44
43
 
@@ -48,7 +47,7 @@ test("authenticate rejects missing scopes", async () => {
48
47
  const req = new Request("http://localhost", {
49
48
  headers: { Authorization: "Bearer sk-test" },
50
49
  });
51
- const result = await (auth(req) as Promise<AuthenticateResult>);
50
+ const result = await auth(req);
52
51
  expect(result.authenticated).toBe(false);
53
52
  expect(result.error).toBe("Insufficient permissions");
54
53
  });
package/src/api-keys.ts CHANGED
@@ -39,9 +39,21 @@ export function createApiKeyValidator(keys: ApiKeyEntry[], options: ApiKeyValida
39
39
 
40
40
  for (const entry of keys) {
41
41
  keyMap.set(entry.key, { key: entry.key, scopes: entry.scopes, metadata: entry.metadata });
42
- if (hashKeys) {
43
- // Pre-compute hashes for lookup
42
+ }
43
+
44
+ let hashCache: Map<
45
+ string,
46
+ { key: string; scopes?: string[]; metadata?: Record<string, unknown> }
47
+ > | null = null;
48
+
49
+ async function ensureHashCache() {
50
+ if (!hashCache) {
51
+ hashCache = new Map();
52
+ for (const [, entry] of keyMap) {
53
+ hashCache.set(await sha256(entry.key), entry);
54
+ }
44
55
  }
56
+ return hashCache;
45
57
  }
46
58
 
47
59
  return {
@@ -62,8 +74,9 @@ export function createApiKeyValidator(keys: ApiKeyEntry[], options: ApiKeyValida
62
74
  if (!hashKeys) {
63
75
  return this.validate(providedKey);
64
76
  }
77
+ const cache = await ensureHashCache();
65
78
  const keyHash = await sha256(providedKey);
66
- const entry = keyMap.get(keyHash);
79
+ const entry = cache.get(keyHash);
67
80
  if (!entry) {
68
81
  return { authenticated: false, error: "Invalid API key" };
69
82
  }
@@ -79,37 +92,14 @@ export function createApiKeyValidator(keys: ApiKeyEntry[], options: ApiKeyValida
79
92
  const header = options.header ?? "Authorization";
80
93
  const requiredScopes = options.requiredScopes ?? [];
81
94
 
82
- if (hashKeys) {
83
- return async (req: Request): Promise<AuthenticateResult> => {
84
- const authHeader = req.headers.get(header);
85
- if (!authHeader) {
86
- return { authenticated: false, error: "Missing API key" };
87
- }
88
-
89
- const token = authHeader.replace(/^Bearer\s+/i, "").trim();
90
- const result = await this.verify(token);
91
-
92
- if (!result.authenticated) return result;
93
-
94
- if (requiredScopes.length > 0) {
95
- const hasScopes = requiredScopes.every((s) => result.scopes?.includes(s));
96
- if (!hasScopes) {
97
- return { authenticated: false, error: "Insufficient permissions" };
98
- }
99
- }
100
-
101
- return result;
102
- };
103
- }
104
-
105
- return (req: Request): AuthenticateResult => {
95
+ return async (req: Request): Promise<AuthenticateResult> => {
106
96
  const authHeader = req.headers.get(header);
107
97
  if (!authHeader) {
108
98
  return { authenticated: false, error: "Missing API key" };
109
99
  }
110
100
 
111
101
  const token = authHeader.replace(/^Bearer\s+/i, "").trim();
112
- const result = this.validate(token);
102
+ const result = hashKeys ? await this.verify(token) : this.validate(token);
113
103
 
114
104
  if (!result.authenticated) return result;
115
105
 
@@ -6,6 +6,25 @@ export interface IdempotencyStore {
6
6
 
7
7
  export class InMemoryStore implements IdempotencyStore {
8
8
  private store = new Map<string, { value: unknown; expires: number }>();
9
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
10
+
11
+ constructor() {
12
+ this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
13
+ if (
14
+ this.cleanupInterval &&
15
+ typeof this.cleanupInterval === "object" &&
16
+ "unref" in this.cleanupInterval
17
+ ) {
18
+ this.cleanupInterval.unref();
19
+ }
20
+ }
21
+
22
+ private evictExpired(): void {
23
+ const now = Date.now();
24
+ for (const [key, entry] of this.store) {
25
+ if (now > entry.expires) this.store.delete(key);
26
+ }
27
+ }
9
28
 
10
29
  async get(key: string): Promise<unknown | null> {
11
30
  const entry = this.store.get(key);
@@ -24,6 +43,11 @@ export class InMemoryStore implements IdempotencyStore {
24
43
  async delete(key: string): Promise<void> {
25
44
  this.store.delete(key);
26
45
  }
46
+
47
+ dispose(): void {
48
+ if (this.cleanupInterval) clearInterval(this.cleanupInterval);
49
+ this.store.clear();
50
+ }
27
51
  }
28
52
 
29
53
  export interface IdempotencyOptions {
package/src/index.test.ts CHANGED
@@ -95,6 +95,16 @@ describe("rate limit", () => {
95
95
  expect(result.allowed).toBe(true);
96
96
  expect(result.remaining).toBe(99);
97
97
  });
98
+
99
+ test("accepts string key directly", async () => {
100
+ const gate = createGate({
101
+ rateLimit: { windowMs: 60000, max: 10 },
102
+ });
103
+
104
+ const result = await gate.rateLimit.check("custom:user-42");
105
+ expect(result.allowed).toBe(true);
106
+ expect(result.remaining).toBe(9);
107
+ });
98
108
  });
99
109
 
100
110
  describe("api keys", () => {
package/src/index.ts CHANGED
@@ -2,9 +2,28 @@ import { validateRequest } from "./validate";
2
2
  import { ok, fail, paginated, problem } from "./respond";
3
3
  import { idempotency, InMemoryStore } from "./idempotency";
4
4
  import { rateLimit, InMemoryRateLimitStore, keyByApiKey } from "./rate-limit";
5
+ import type { RateLimitStore, RateLimitCheckResult } from "./rate-limit";
5
6
  import { createApiKeyValidator } from "./api-keys";
6
7
  import type { ApiKeyEntry, AuthenticateResult } from "./api-keys";
7
8
  import type { IdempotencyStore } from "./idempotency";
9
+ import type { Client } from "@joinremba/core";
10
+ import { NetworkError } from "@joinremba/core";
11
+
12
+ const gateAuthStore = new WeakMap<Request, AuthenticateResult>();
13
+ const gateIdempotencyStore = new WeakMap<Request, string>();
14
+ const gateRateLimitStore = new WeakMap<Request, RateLimitCheckResult>();
15
+
16
+ export function getGateAuth(req: Request): AuthenticateResult | undefined {
17
+ return gateAuthStore.get(req);
18
+ }
19
+
20
+ export function getGateIdempotencyKey(req: Request): string | undefined {
21
+ return gateIdempotencyStore.get(req);
22
+ }
23
+
24
+ export function getGateRateLimit(req: Request): RateLimitCheckResult | undefined {
25
+ return gateRateLimitStore.get(req);
26
+ }
8
27
  import {
9
28
  GateError,
10
29
  ValidationError,
@@ -71,6 +90,7 @@ export interface MiddlewareOptions {
71
90
 
72
91
  export interface GateOptions {
73
92
  apiKeys?: ApiKeyEntry[];
93
+ client?: Client;
74
94
  idempotency?: {
75
95
  store?: IdempotencyStore;
76
96
  keyHeader?: string;
@@ -79,7 +99,7 @@ export interface GateOptions {
79
99
  rateLimit?: {
80
100
  windowMs?: number;
81
101
  max?: number;
82
- store?: IdempotencyStore;
102
+ store?: RateLimitStore;
83
103
  keyFn?: (req: Request) => string;
84
104
  };
85
105
  }
@@ -105,6 +125,8 @@ export interface Gate {
105
125
  }
106
126
 
107
127
  export function createGate(options: GateOptions = {}): Gate {
128
+ const client = options.client;
129
+
108
130
  const idempInstance = idempotency({
109
131
  store: options.idempotency?.store ?? new InMemoryStore(),
110
132
  keyHeader: options.idempotency?.keyHeader,
@@ -114,10 +136,69 @@ export function createGate(options: GateOptions = {}): Gate {
114
136
  const rlInstance = rateLimit({
115
137
  windowMs: options.rateLimit?.windowMs,
116
138
  max: options.rateLimit?.max,
139
+ store: options.rateLimit?.store,
140
+ keyFn: options.rateLimit?.keyFn,
117
141
  });
118
142
 
119
143
  const apiKeyValidator = createApiKeyValidator(options.apiKeys ?? []);
120
144
 
145
+ if (client) {
146
+ const origCheck = rlInstance.check.bind(rlInstance);
147
+ rlInstance.check = async (reqOrKey) => {
148
+ const key = typeof reqOrKey === "string" ? reqOrKey : rlInstance.keyFn(reqOrKey);
149
+ try {
150
+ return await client.checkRateLimit(key);
151
+ } catch (err) {
152
+ if (err instanceof NetworkError) return origCheck(reqOrKey);
153
+ throw err;
154
+ }
155
+ };
156
+
157
+ const origIdemGet = idempInstance.getResponse.bind(idempInstance);
158
+ const origIdemSet = idempInstance.setResponse.bind(idempInstance);
159
+ idempInstance.getResponse = async (key: string) => {
160
+ try {
161
+ const result = await client.checkIdempotency(key);
162
+ if (result.exists) return result.response;
163
+ return null;
164
+ } catch (err) {
165
+ if (err instanceof NetworkError) return origIdemGet(key);
166
+ throw err;
167
+ }
168
+ };
169
+ idempInstance.setResponse = async (key: string, response: unknown) => {
170
+ try {
171
+ await client.setIdempotency(key, response);
172
+ } catch (err) {
173
+ if (err instanceof NetworkError) return origIdemSet(key, response);
174
+ throw err;
175
+ }
176
+ };
177
+
178
+ const origAuthenticate = apiKeyValidator.authenticate.bind(apiKeyValidator);
179
+ apiKeyValidator.authenticate = (authOptions) => {
180
+ const handler = origAuthenticate(authOptions);
181
+ return async (req) => {
182
+ const token = req.headers
183
+ .get("Authorization")
184
+ ?.replace(/^Bearer\s+/i, "")
185
+ .trim();
186
+ if (token) {
187
+ try {
188
+ const result = await client.verifyApiKey(token);
189
+ if (result.valid) {
190
+ return { authenticated: true, key: token, scopes: result.scopes };
191
+ }
192
+ return { authenticated: false, error: "Invalid API key" };
193
+ } catch (err) {
194
+ if (!(err instanceof NetworkError)) throw err;
195
+ }
196
+ }
197
+ return handler(req);
198
+ };
199
+ };
200
+ }
201
+
121
202
  const defaultFail = (message: string, code?: string) => fail(message, code ?? "UNAUTHORIZED");
122
203
 
123
204
  const gate: Gate = {
@@ -150,6 +231,7 @@ export function createGate(options: GateOptions = {}): Gate {
150
231
  // Rate limit check
151
232
  if (enableRl) {
152
233
  const rlResult = await rlInstance.check(req);
234
+ gateRateLimitStore.set(req, rlResult);
153
235
  if (!rlResult.allowed) {
154
236
  const body = defaultFail("Too many requests", "RATE_LIMIT_EXCEEDED");
155
237
  return new Response(JSON.stringify(body), {
@@ -161,15 +243,12 @@ export function createGate(options: GateOptions = {}): Gate {
161
243
  },
162
244
  });
163
245
  }
164
- req.headers.set("X-RateLimit-Remaining", String(rlResult.remaining));
165
246
  }
166
247
 
167
248
  // Auth check
168
249
  if (auth) {
169
250
  const authFn = apiKeyValidator.authenticate({ requiredScopes });
170
- const authResult = await (authFn(req) as
171
- | Promise<AuthenticateResult>
172
- | AuthenticateResult);
251
+ const authResult = await authFn(req);
173
252
  if (!authResult.authenticated) {
174
253
  const body = defaultFail(authResult.error ?? "Unauthorized", "AUTHENTICATION_ERROR");
175
254
  return new Response(JSON.stringify(body), {
@@ -177,7 +256,7 @@ export function createGate(options: GateOptions = {}): Gate {
177
256
  headers: { "Content-Type": "application/json" },
178
257
  });
179
258
  }
180
- (req as unknown as Record<string, unknown>).gateAuth = authResult;
259
+ gateAuthStore.set(req, authResult);
181
260
  }
182
261
 
183
262
  // Idempotency check
@@ -191,7 +270,7 @@ export function createGate(options: GateOptions = {}): Gate {
191
270
  headers: { "Content-Type": "application/json" },
192
271
  });
193
272
  }
194
- (req as unknown as Record<string, unknown>).gateIdempotencyKey = idemKey;
273
+ gateIdempotencyStore.set(req, idemKey);
195
274
  }
196
275
  }
197
276
 
@@ -200,9 +279,7 @@ export function createGate(options: GateOptions = {}): Gate {
200
279
 
201
280
  // Store response for idempotency
202
281
  if (enableIdem) {
203
- const idemKey = (req as unknown as Record<string, unknown>).gateIdempotencyKey as
204
- | string
205
- | undefined;
282
+ const idemKey = gateIdempotencyStore.get(req);
206
283
  if (idemKey && response.status < 500) {
207
284
  const body = await response.clone().json();
208
285
  await idempInstance.setResponse(idemKey, body);
package/src/rate-limit.ts CHANGED
@@ -5,6 +5,25 @@ export interface RateLimitStore {
5
5
 
6
6
  export class InMemoryRateLimitStore implements RateLimitStore {
7
7
  private store = new Map<string, { count: number; reset: number }>();
8
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
9
+
10
+ constructor() {
11
+ this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
12
+ if (
13
+ this.cleanupInterval &&
14
+ typeof this.cleanupInterval === "object" &&
15
+ "unref" in this.cleanupInterval
16
+ ) {
17
+ this.cleanupInterval.unref();
18
+ }
19
+ }
20
+
21
+ private evictExpired(): void {
22
+ const now = Date.now();
23
+ for (const [key, entry] of this.store) {
24
+ if (now > entry.reset) this.store.delete(key);
25
+ }
26
+ }
8
27
 
9
28
  async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
10
29
  const now = Date.now();
@@ -23,6 +42,11 @@ export class InMemoryRateLimitStore implements RateLimitStore {
23
42
  async reset(key: string): Promise<void> {
24
43
  this.store.delete(key);
25
44
  }
45
+
46
+ dispose(): void {
47
+ if (this.cleanupInterval) clearInterval(this.cleanupInterval);
48
+ this.store.clear();
49
+ }
26
50
  }
27
51
 
28
52
  export type RateLimitStrategy = "fixed" | "sliding";
@@ -40,7 +64,7 @@ export function keyByApiKey(req: Request): string {
40
64
  const auth = req.headers.get("authorization");
41
65
  if (auth) {
42
66
  const token = auth.replace(/^Bearer\s+/i, "").trim();
43
- if (token) return `ak:${token.slice(0, 12)}`;
67
+ if (token) return `ak:${token}`;
44
68
  }
45
69
  return req.headers.get("x-forwarded-for") ?? "global";
46
70
  }
@@ -49,6 +73,10 @@ export function rateLimit(options: RateLimitOptions = {}) {
49
73
  const windowMs = options.windowMs ?? 60_000;
50
74
  const max = options.max ?? 100;
51
75
  const store = options.store ?? new InMemoryRateLimitStore();
76
+
77
+ if (options.strategy === "sliding") {
78
+ throw new Error("Sliding window rate limiting is not yet implemented. Use 'fixed' (default).");
79
+ }
52
80
  const keyFn =
53
81
  options.keyFn ??
54
82
  ((req: Request) => {
@@ -62,8 +90,8 @@ export function rateLimit(options: RateLimitOptions = {}) {
62
90
  store,
63
91
  keyFn,
64
92
 
65
- async check(req: Request): Promise<{ allowed: boolean; remaining: number; reset: number }> {
66
- const key = keyFn(req);
93
+ async check(reqOrKey: Request | string): Promise<RateLimitCheckResult> {
94
+ const key = typeof reqOrKey === "string" ? reqOrKey : keyFn(reqOrKey);
67
95
  const { count, reset } = await store.increment(`rl:${key}`, windowMs);
68
96
  return {
69
97
  allowed: count <= max,
@@ -75,3 +103,9 @@ export function rateLimit(options: RateLimitOptions = {}) {
75
103
  }
76
104
 
77
105
  export type RateLimitInstance = ReturnType<typeof rateLimit>;
106
+
107
+ export type RateLimitCheckResult = {
108
+ allowed: boolean;
109
+ remaining: number;
110
+ reset: number;
111
+ };
@@ -90,7 +90,13 @@ export class PostgresRateLimitStore implements RateLimitStore {
90
90
  [key, reset, now, reset]
91
91
  );
92
92
 
93
- const row = rows[0]!;
93
+ // Best-effort locking hint to prevent write skew under concurrency.
94
+ // Not a full serializable isolation — for production, set the table's
95
+ // fillfactor low or use advisory locks.
96
+ await this.client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [key]);
97
+
98
+ const row = rows[0];
99
+ if (!row) return { count: 0, reset: Date.now() + windowMs };
94
100
  return { count: row.count as number, reset: row.reset_at as number };
95
101
  }
96
102
 
@@ -29,6 +29,12 @@ function mockRedisClient(): RedisClient {
29
29
  store.set(key, { value: String(next), expires: entry.expires });
30
30
  return next;
31
31
  },
32
+ async ttl(key: string) {
33
+ const entry = store.get(key);
34
+ if (!entry) return -2;
35
+ const remaining = Math.ceil((entry.expires - Date.now()) / 1000);
36
+ return remaining > 0 ? remaining : -2;
37
+ },
32
38
  async expire(key: string, _seconds: number) {
33
39
  const entry = store.get(key);
34
40
  if (entry) {
@@ -1,12 +1,48 @@
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>;
7
42
  setex(key: string, seconds: number, value: string): Promise<unknown>;
8
43
  incr(key: string): Promise<number>;
9
44
  expire(key: string, seconds: number): Promise<number>;
45
+ ttl(key: string): Promise<number>;
10
46
  del(key: string): Promise<number>;
11
47
  }
12
48
 
@@ -37,7 +73,6 @@ export class RedisRateLimitStore implements RateLimitStore {
37
73
  constructor(private client: RedisClient) {}
38
74
 
39
75
  async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
40
- const now = Date.now();
41
76
  const windowSeconds = Math.ceil(windowMs / 1000);
42
77
  const count = await this.client.incr(key);
43
78
 
@@ -45,13 +80,11 @@ export class RedisRateLimitStore implements RateLimitStore {
45
80
  await this.client.expire(key, windowSeconds);
46
81
  }
47
82
 
48
- const ttl = await this.client.get(key).then((v) => {
49
- if (!v) return windowSeconds * 1000;
50
- // approximate remaining TTL — we use incr+expire so ttl is reset
51
- return windowMs;
52
- });
83
+ const ttlSeconds = await this.client.ttl(key);
84
+ const remainingMs = ttlSeconds > 0 ? ttlSeconds * 1000 : windowMs;
85
+ const reset = Date.now() + remainingMs;
53
86
 
54
- return { count, reset: now + ttl };
87
+ return { count, reset };
55
88
  }
56
89
 
57
90
  async reset(key: string): Promise<void> {