@joinremba/gate 0.1.0 → 0.3.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
@@ -162,7 +162,7 @@ Customise the key function to rate-limit by user ID, API key, or IP.
162
162
 
163
163
  ### API Keys (`@joinremba/gate/api-keys`)
164
164
 
165
- Validate API keys with optional scoped permissions.
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).
166
166
 
167
167
  ```ts
168
168
  import { createApiKeyValidator } from "@joinremba/gate/api-keys";
@@ -182,6 +182,89 @@ const result = auth(request);
182
182
  if (!result.authenticated) throw new AuthenticationError(result.error);
183
183
  ```
184
184
 
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.
186
+
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.
188
+
189
+ #### Hashed API Keys
190
+
191
+ Store SHA-256 hashes instead of plaintext keys. Protects against memory dumps.
192
+
193
+ ```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"] }
201
+ ```
202
+
203
+ #### DB-backed API Key Stores
204
+
205
+ Validate keys against Postgres or Redis. Keys can be added/removed at runtime.
206
+
207
+ ```ts
208
+ import { PostgresApiKeyStore } from "@joinremba/gate/stores/postgres-api-keys";
209
+
210
+ const store = new PostgresApiKeyStore(pgClient);
211
+ await store.ensureTable();
212
+
213
+ // Add a key
214
+ await store.setKey({ key: "sk-live-abc", scopes: ["read"] }, expiresAt);
215
+
216
+ // Validate
217
+ const result = await store.verify("sk-live-abc");
218
+
219
+ // Remove
220
+ await store.deleteKey("sk-live-abc");
221
+ ```
222
+
223
+ ```ts
224
+ import { RedisApiKeyStore } from "@joinremba/gate/stores/redis-api-keys";
225
+
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");
229
+ ```
230
+
231
+ ### Combined Middleware
232
+
233
+ Run auth, rate limiting, and idempotency in a single middleware call:
234
+
235
+ ```ts
236
+ const gate = createGate({
237
+ apiKeys: [{ key: "sk-admin" }],
238
+ rateLimit: { windowMs: 60_000, max: 100 },
239
+ });
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
+ ```
251
+
252
+ The middleware returns 401 for invalid/missing keys, 429 when rate limited, and caches idempotent responses automatically.
253
+
254
+ ### Per-key Rate Limiting
255
+
256
+ Rate-limit by API key instead of IP:
257
+
258
+ ```ts
259
+ import { rateLimit, keyByApiKey } from "@joinremba/gate/rate-limit";
260
+
261
+ const limiter = rateLimit({
262
+ windowMs: 60_000,
263
+ max: 30,
264
+ keyFn: keyByApiKey,
265
+ });
266
+ ```
267
+
185
268
  ### Errors (`@joinremba/gate/errors`)
186
269
 
187
270
  Standard error types for consistent error handling.
@@ -313,15 +396,13 @@ app.post("/orders", async (req, res, next) => {
313
396
  - In-memory idempotency store
314
397
  - In-memory rate limiting store
315
398
 
316
- **V1**
399
+ **V1** (current)
317
400
 
318
- - Idempotency middleware
319
- - Redis idempotency store
320
- - Postgres idempotency store
321
- - Rate limiting middleware
401
+ - Redis and Postgres stores for idempotency and rate limiting
322
402
  - API key hashing and validation
323
- - Scopes and permissions middleware
324
- - Usage tracking hooks
403
+ - DB-backed API key stores (Redis, Postgres)
404
+ - Combined middleware (auth + rate limit + idempotency)
405
+ - Per-key rate limiting helper
325
406
 
326
407
  **V2**
327
408
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinremba/gate",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -40,6 +40,26 @@
40
40
  "types": "./src/errors.ts",
41
41
  "import": "./src/errors.ts",
42
42
  "default": "./src/errors.ts"
43
+ },
44
+ "./stores/redis": {
45
+ "types": "./src/stores/redis.ts",
46
+ "import": "./src/stores/redis.ts",
47
+ "default": "./src/stores/redis.ts"
48
+ },
49
+ "./stores/redis-api-keys": {
50
+ "types": "./src/stores/redis-api-keys.ts",
51
+ "import": "./src/stores/redis-api-keys.ts",
52
+ "default": "./src/stores/redis-api-keys.ts"
53
+ },
54
+ "./stores/postgres": {
55
+ "types": "./src/stores/postgres.ts",
56
+ "import": "./src/stores/postgres.ts",
57
+ "default": "./src/stores/postgres.ts"
58
+ },
59
+ "./stores/postgres-api-keys": {
60
+ "types": "./src/stores/postgres-api-keys.ts",
61
+ "import": "./src/stores/postgres-api-keys.ts",
62
+ "default": "./src/stores/postgres-api-keys.ts"
43
63
  }
44
64
  },
45
65
  "files": [
@@ -0,0 +1,54 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createApiKeyValidator } from "./api-keys";
3
+ import type { AuthenticateResult } from "./api-keys";
4
+
5
+ test("validate returns authenticated for matching key", () => {
6
+ const validator = createApiKeyValidator([{ key: "sk-live-abc", scopes: ["read"] }]);
7
+ const result = validator.validate("sk-live-abc");
8
+ expect(result.authenticated).toBe(true);
9
+ expect(result.scopes).toEqual(["read"]);
10
+ });
11
+
12
+ test("validate returns error for unknown key", () => {
13
+ const validator = createApiKeyValidator([{ key: "sk-live-abc" }]);
14
+ const result = validator.validate("sk-live-xyz");
15
+ expect(result.authenticated).toBe(false);
16
+ expect(result.error).toBe("Invalid API key");
17
+ });
18
+
19
+ test("verify with hashKeys true hashes before lookup", async () => {
20
+ const validator = createApiKeyValidator(
21
+ [
22
+ {
23
+ key: "9418b81169b79003fd8c4481e61b79a762e996a0c172cda188c927714b5ee05b",
24
+ scopes: ["admin"],
25
+ },
26
+ ],
27
+ { hashKeys: true }
28
+ );
29
+ // "sk-live-123" sha256 = 9418b81169b7...
30
+ const result = await validator.verify("sk-live-123");
31
+ expect(result.authenticated).toBe(true);
32
+ expect(result.scopes).toEqual(["admin"]);
33
+ });
34
+
35
+ test("authenticate middleware reads Authorization header", async () => {
36
+ const validator = createApiKeyValidator([{ key: "sk-test", scopes: ["read"] }]);
37
+ const auth = validator.authenticate({ requiredScopes: ["read"] });
38
+ const req = new Request("http://localhost", {
39
+ headers: { Authorization: "Bearer sk-test" },
40
+ });
41
+ const result = await (auth(req) as Promise<AuthenticateResult>);
42
+ expect(result.authenticated).toBe(true);
43
+ });
44
+
45
+ test("authenticate rejects missing scopes", async () => {
46
+ const validator = createApiKeyValidator([{ key: "sk-test", scopes: ["read"] }]);
47
+ const auth = validator.authenticate({ requiredScopes: ["write"] });
48
+ const req = new Request("http://localhost", {
49
+ headers: { Authorization: "Bearer sk-test" },
50
+ });
51
+ const result = await (auth(req) as Promise<AuthenticateResult>);
52
+ expect(result.authenticated).toBe(false);
53
+ expect(result.error).toBe("Insufficient permissions");
54
+ });
package/src/api-keys.ts CHANGED
@@ -4,6 +4,10 @@ export interface ApiKeyEntry {
4
4
  metadata?: Record<string, unknown>;
5
5
  }
6
6
 
7
+ export interface ApiKeyValidatorOptions {
8
+ hashKeys?: boolean;
9
+ }
10
+
7
11
  export interface AuthenticateOptions {
8
12
  requiredScopes?: string[];
9
13
  header?: string;
@@ -13,11 +17,32 @@ export interface AuthenticateResult {
13
17
  authenticated: boolean;
14
18
  key?: string;
15
19
  scopes?: string[];
20
+ metadata?: Record<string, unknown>;
16
21
  error?: string;
17
22
  }
18
23
 
19
- export function createApiKeyValidator(keys: ApiKeyEntry[]) {
20
- const keyMap = new Map(keys.map((k) => [k.key, k]));
24
+ async function sha256(input: string): Promise<string> {
25
+ const encoder = new TextEncoder();
26
+ const data = encoder.encode(input);
27
+ const hash = await crypto.subtle.digest("SHA-256", data);
28
+ return Array.from(new Uint8Array(hash))
29
+ .map((b) => b.toString(16).padStart(2, "0"))
30
+ .join("");
31
+ }
32
+
33
+ export function createApiKeyValidator(keys: ApiKeyEntry[], options: ApiKeyValidatorOptions = {}) {
34
+ const { hashKeys = false } = options;
35
+ const keyMap = new Map<
36
+ string,
37
+ { key: string; scopes?: string[]; metadata?: Record<string, unknown> }
38
+ >();
39
+
40
+ for (const entry of keys) {
41
+ keyMap.set(entry.key, { key: entry.key, scopes: entry.scopes, metadata: entry.metadata });
42
+ if (hashKeys) {
43
+ // Pre-compute hashes for lookup
44
+ }
45
+ }
21
46
 
22
47
  return {
23
48
  validate(providedKey: string): AuthenticateResult {
@@ -25,13 +50,58 @@ export function createApiKeyValidator(keys: ApiKeyEntry[]) {
25
50
  if (!entry) {
26
51
  return { authenticated: false, error: "Invalid API key" };
27
52
  }
28
- return { authenticated: true, key: entry.key, scopes: entry.scopes };
53
+ return {
54
+ authenticated: true,
55
+ key: entry.key,
56
+ scopes: entry.scopes,
57
+ metadata: entry.metadata,
58
+ };
59
+ },
60
+
61
+ async verify(providedKey: string): Promise<AuthenticateResult> {
62
+ if (!hashKeys) {
63
+ return this.validate(providedKey);
64
+ }
65
+ const keyHash = await sha256(providedKey);
66
+ const entry = keyMap.get(keyHash);
67
+ if (!entry) {
68
+ return { authenticated: false, error: "Invalid API key" };
69
+ }
70
+ return {
71
+ authenticated: true,
72
+ key: providedKey,
73
+ scopes: entry.scopes,
74
+ metadata: entry.metadata,
75
+ };
29
76
  },
30
77
 
31
78
  authenticate(options: AuthenticateOptions = {}) {
32
79
  const header = options.header ?? "Authorization";
33
80
  const requiredScopes = options.requiredScopes ?? [];
34
81
 
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
+
35
105
  return (req: Request): AuthenticateResult => {
36
106
  const authHeader = req.headers.get(header);
37
107
  if (!authHeader) {
package/src/index.test.ts CHANGED
@@ -114,3 +114,69 @@ describe("api keys", () => {
114
114
  expect(result.authenticated).toBe(false);
115
115
  });
116
116
  });
117
+
118
+ describe("keyByApiKey", () => {
119
+ test("uses API key from Authorization header", async () => {
120
+ const { keyByApiKey } = await import("./rate-limit");
121
+ const req = new Request("http://localhost", {
122
+ headers: { Authorization: "Bearer sk-live-abc123" },
123
+ });
124
+ const key = keyByApiKey(req);
125
+ expect(key).toStartWith("ak:sk-live-abc1");
126
+ });
127
+
128
+ test("falls back to IP when no auth header", async () => {
129
+ const { keyByApiKey } = await import("./rate-limit");
130
+ const req = new Request("http://localhost");
131
+ const key = keyByApiKey(req);
132
+ expect(typeof key).toBe("string");
133
+ });
134
+ });
135
+
136
+ describe("middleware", () => {
137
+ test("passes through when no features enabled", async () => {
138
+ const gate = createGate();
139
+ const mw = gate.middleware();
140
+ const req = new Request("http://localhost/api");
141
+ let called = false;
142
+ const res = await mw(req, async () => {
143
+ called = true;
144
+ return new Response("ok");
145
+ });
146
+ expect(called).toBe(true);
147
+ expect(res).toBeInstanceOf(Response);
148
+ });
149
+
150
+ test("rejects request when auth fails", async () => {
151
+ const gate = createGate({ apiKeys: [{ key: "sk-valid" }] });
152
+ const mw = gate.middleware({ auth: true });
153
+ const req = new Request("http://localhost/api", {
154
+ headers: { Authorization: "Bearer sk-wrong" },
155
+ });
156
+ const res = await mw(req, async () => new Response("ok"));
157
+ expect(res!.status).toBe(401);
158
+ const body = (await res!.json()) as Record<string, unknown>;
159
+ expect(body.success).toBe(false);
160
+ });
161
+
162
+ test("rejects when rate limit exceeded", async () => {
163
+ const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
164
+ const mw = gate.middleware({ rateLimit: true });
165
+ const req = new Request("http://localhost/api");
166
+ const res = await mw(req, async () => new Response("ok"));
167
+ expect(res!.status).toBe(429);
168
+ });
169
+
170
+ test("skips excluded paths", async () => {
171
+ const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
172
+ const mw = gate.middleware({ rateLimit: true, excludePaths: ["/health"] });
173
+ const req = new Request("http://localhost/health");
174
+ let called = false;
175
+ const res = await mw(req, async () => {
176
+ called = true;
177
+ return new Response("ok");
178
+ });
179
+ expect(called).toBe(true);
180
+ expect(res!.status).toBe(200);
181
+ });
182
+ });
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { validateRequest } from "./validate";
2
2
  import { ok, fail, paginated, problem } from "./respond";
3
3
  import { idempotency, InMemoryStore } from "./idempotency";
4
- import { rateLimit, InMemoryRateLimitStore } from "./rate-limit";
4
+ import { rateLimit, InMemoryRateLimitStore, keyByApiKey } from "./rate-limit";
5
5
  import { createApiKeyValidator } from "./api-keys";
6
- import type { ApiKeyEntry } from "./api-keys";
6
+ import type { ApiKeyEntry, AuthenticateResult } from "./api-keys";
7
7
  import type { IdempotencyStore } from "./idempotency";
8
8
  import {
9
9
  GateError,
@@ -23,6 +23,7 @@ export {
23
23
  InMemoryStore,
24
24
  rateLimit,
25
25
  InMemoryRateLimitStore,
26
+ keyByApiKey,
26
27
  createApiKeyValidator,
27
28
  GateError,
28
29
  ValidationError,
@@ -52,10 +53,22 @@ export type {
52
53
  AuthenticateOptions,
53
54
  AuthenticateResult,
54
55
  ApiKeyValidator,
56
+ ApiKeyValidatorOptions,
55
57
  } from "./api-keys";
56
58
 
57
59
  export type Middleware = (req: Request, next?: () => Promise<Response>) => Promise<Response | null>;
58
60
 
61
+ export interface MiddlewareOptions {
62
+ auth?: boolean;
63
+ requiredScopes?: string[];
64
+ rateLimit?: boolean;
65
+ idempotency?: boolean;
66
+ /** Override the max for this specific middleware. */
67
+ rateLimitMax?: number;
68
+ /** Paths to skip entirely. */
69
+ excludePaths?: string[];
70
+ }
71
+
59
72
  export interface GateOptions {
60
73
  apiKeys?: ApiKeyEntry[];
61
74
  idempotency?: {
@@ -66,10 +79,19 @@ export interface GateOptions {
66
79
  rateLimit?: {
67
80
  windowMs?: number;
68
81
  max?: number;
69
- strategy?: "fixed" | "sliding";
82
+ store?: IdempotencyStore;
83
+ keyFn?: (req: Request) => string;
70
84
  };
71
85
  }
72
86
 
87
+ export type MiddlewareResult =
88
+ | {
89
+ passed: true;
90
+ auth?: { key: string; scopes?: string[] };
91
+ rateLimit?: { remaining: number; reset: number };
92
+ }
93
+ | { passed: false; status: number; body: unknown };
94
+
73
95
  export interface Gate {
74
96
  validate: typeof validateRequest;
75
97
  ok: typeof ok;
@@ -79,7 +101,7 @@ export interface Gate {
79
101
  idempotency: ReturnType<typeof idempotency>;
80
102
  rateLimit: ReturnType<typeof rateLimit>;
81
103
  apiKeys: ReturnType<typeof createApiKeyValidator>;
82
- middleware(): Middleware;
104
+ middleware(opts?: MiddlewareOptions): Middleware;
83
105
  }
84
106
 
85
107
  export function createGate(options: GateOptions = {}): Gate {
@@ -96,6 +118,8 @@ export function createGate(options: GateOptions = {}): Gate {
96
118
 
97
119
  const apiKeyValidator = createApiKeyValidator(options.apiKeys ?? []);
98
120
 
121
+ const defaultFail = (message: string, code?: string) => fail(message, code ?? "UNAUTHORIZED");
122
+
99
123
  const gate: Gate = {
100
124
  validate: validateRequest,
101
125
  ok,
@@ -106,10 +130,86 @@ export function createGate(options: GateOptions = {}): Gate {
106
130
  rateLimit: rlInstance,
107
131
  apiKeys: apiKeyValidator,
108
132
 
109
- middleware() {
133
+ middleware(opts?: MiddlewareOptions) {
134
+ const {
135
+ auth = options.apiKeys != null && options.apiKeys.length > 0,
136
+ requiredScopes,
137
+ rateLimit: enableRl = options.rateLimit != null,
138
+ idempotency: enableIdem = false,
139
+ excludePaths = [],
140
+ } = opts ?? {};
141
+
110
142
  return async (req: Request, next?: () => Promise<Response>) => {
111
143
  if (!next) return null;
112
- return next();
144
+
145
+ const path = new URL(req.url).pathname;
146
+ if (excludePaths.some((p) => path === p || path.startsWith(p))) {
147
+ return next();
148
+ }
149
+
150
+ // Rate limit check
151
+ if (enableRl) {
152
+ const rlResult = await rlInstance.check(req);
153
+ if (!rlResult.allowed) {
154
+ const body = defaultFail("Too many requests", "RATE_LIMIT_EXCEEDED");
155
+ return new Response(JSON.stringify(body), {
156
+ status: 429,
157
+ headers: {
158
+ "Content-Type": "application/json",
159
+ "Retry-After": String(Math.ceil((rlResult.reset - Date.now()) / 1000)),
160
+ "X-RateLimit-Remaining": "0",
161
+ },
162
+ });
163
+ }
164
+ req.headers.set("X-RateLimit-Remaining", String(rlResult.remaining));
165
+ }
166
+
167
+ // Auth check
168
+ if (auth) {
169
+ const authFn = apiKeyValidator.authenticate({ requiredScopes });
170
+ const authResult = await (authFn(req) as
171
+ | Promise<AuthenticateResult>
172
+ | AuthenticateResult);
173
+ if (!authResult.authenticated) {
174
+ const body = defaultFail(authResult.error ?? "Unauthorized", "AUTHENTICATION_ERROR");
175
+ return new Response(JSON.stringify(body), {
176
+ status: 401,
177
+ headers: { "Content-Type": "application/json" },
178
+ });
179
+ }
180
+ (req as unknown as Record<string, unknown>).gateAuth = authResult;
181
+ }
182
+
183
+ // Idempotency check
184
+ if (enableIdem) {
185
+ const idemKey = req.headers.get(idempInstance.keyHeader);
186
+ if (idemKey) {
187
+ const cached = await idempInstance.getResponse(idemKey);
188
+ if (cached) {
189
+ return new Response(JSON.stringify(cached), {
190
+ status: 200,
191
+ headers: { "Content-Type": "application/json" },
192
+ });
193
+ }
194
+ (req as unknown as Record<string, unknown>).gateIdempotencyKey = idemKey;
195
+ }
196
+ }
197
+
198
+ const response = await next();
199
+ if (!response) return null;
200
+
201
+ // Store response for idempotency
202
+ if (enableIdem) {
203
+ const idemKey = (req as unknown as Record<string, unknown>).gateIdempotencyKey as
204
+ | string
205
+ | undefined;
206
+ if (idemKey && response.status < 500) {
207
+ const body = await response.clone().json();
208
+ await idempInstance.setResponse(idemKey, body);
209
+ }
210
+ }
211
+
212
+ return response;
113
213
  };
114
214
  },
115
215
  };
package/src/rate-limit.ts CHANGED
@@ -35,6 +35,16 @@ export interface RateLimitOptions {
35
35
  keyFn?: (req: Request) => string;
36
36
  }
37
37
 
38
+ /** Rate-limit by API key (Bearer token from Authorization header). Falls back to IP. */
39
+ export function keyByApiKey(req: Request): string {
40
+ const auth = req.headers.get("authorization");
41
+ if (auth) {
42
+ const token = auth.replace(/^Bearer\s+/i, "").trim();
43
+ if (token) return `ak:${token.slice(0, 12)}`;
44
+ }
45
+ return req.headers.get("x-forwarded-for") ?? "global";
46
+ }
47
+
38
48
  export function rateLimit(options: RateLimitOptions = {}) {
39
49
  const windowMs = options.windowMs ?? 60_000;
40
50
  const max = options.max ?? 100;
@@ -0,0 +1,86 @@
1
+ import { expect, test } from "bun:test";
2
+ import { PostgresApiKeyStore, type PostgresClient } from "./postgres-api-keys";
3
+
4
+ function mockPostgresClient(): PostgresClient {
5
+ const tables: Record<string, Map<string, Record<string, unknown>>> = {};
6
+ return {
7
+ async query(sql: string, params?: unknown[]) {
8
+ const tableMatch = sql.match(/(?:FROM|INTO|UPDATE|TABLE)\s+(\w+)/i);
9
+ const tableName = tableMatch?.[1] ?? "unknown";
10
+ if (!tables[tableName]) tables[tableName] = new Map();
11
+ const table = tables[tableName];
12
+
13
+ if (sql.includes("CREATE TABLE IF NOT EXISTS")) {
14
+ return { rows: [] };
15
+ }
16
+
17
+ if (sql.includes("DELETE")) {
18
+ if (params?.[0]) table.delete(params[0] as string);
19
+ return { rows: [] };
20
+ }
21
+
22
+ if (sql.includes("SELECT") && sql.includes("WHERE key_hash = $1")) {
23
+ const keyHash = params?.[0] as string;
24
+ const entry = table.get(keyHash);
25
+ if (!entry) return { rows: [] };
26
+ return { rows: [entry] };
27
+ }
28
+
29
+ if (sql.includes("INSERT") && sql.includes("ON CONFLICT")) {
30
+ const keyHash = params?.[0] as string;
31
+ table.set(keyHash, {
32
+ key_hash: keyHash,
33
+ scopes: params?.[1],
34
+ metadata: params?.[2],
35
+ expires_at: params?.[3],
36
+ });
37
+ return { rows: [] };
38
+ }
39
+
40
+ return { rows: [] };
41
+ },
42
+ };
43
+ }
44
+
45
+ test("PostgresApiKeyStore validate and setKey", async () => {
46
+ const client = mockPostgresClient();
47
+ const store = new PostgresApiKeyStore(client);
48
+ await store.ensureTable();
49
+ await store.setKey({ key: "sk-live-test", scopes: ["admin"] });
50
+
51
+ const result = await store.validate("sk-live-test");
52
+ expect(result.authenticated).toBe(true);
53
+ expect(result.scopes).toEqual(["admin"]);
54
+ });
55
+
56
+ test("PostgresApiKeyStore rejects unknown key", async () => {
57
+ const client = mockPostgresClient();
58
+ const store = new PostgresApiKeyStore(client);
59
+ await store.ensureTable();
60
+ const result = await store.validate("sk-unknown");
61
+ expect(result.authenticated).toBe(false);
62
+ });
63
+
64
+ test("PostgresApiKeyStore rejects expired key", async () => {
65
+ const client = mockPostgresClient();
66
+ const store = new PostgresApiKeyStore(client);
67
+ await store.ensureTable();
68
+ await store.setKey({ key: "sk-expired" }, Date.now() - 1000);
69
+ const result = await store.validate("sk-expired");
70
+ expect(result.authenticated).toBe(false);
71
+ expect(result.error).toBe("API key expired");
72
+ });
73
+
74
+ test("PostgresApiKeyStore authenticate middleware", async () => {
75
+ const client = mockPostgresClient();
76
+ const store = new PostgresApiKeyStore(client);
77
+ await store.ensureTable();
78
+ await store.setKey({ key: "sk-auth-test", scopes: ["read"] });
79
+
80
+ const auth = store.authenticate({ requiredScopes: ["read"] });
81
+ const req = new Request("http://localhost", {
82
+ headers: { Authorization: "Bearer sk-auth-test" },
83
+ });
84
+ const result = await auth(req);
85
+ expect(result.authenticated).toBe(true);
86
+ });
@@ -0,0 +1,107 @@
1
+ import type { ApiKeyEntry, AuthenticateResult } from "../api-keys";
2
+
3
+ export interface PostgresClient {
4
+ query(sql: string, params?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
5
+ }
6
+
7
+ const TABLE_NAME = "gate_api_keys";
8
+
9
+ export class PostgresApiKeyStore {
10
+ constructor(
11
+ private client: PostgresClient,
12
+ private tableName: string = TABLE_NAME
13
+ ) {}
14
+
15
+ async ensureTable(): Promise<void> {
16
+ await this.client.query(`
17
+ CREATE TABLE IF NOT EXISTS ${this.tableName} (
18
+ key_hash TEXT PRIMARY KEY,
19
+ scopes TEXT,
20
+ metadata TEXT,
21
+ created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000),
22
+ expires_at BIGINT
23
+ )
24
+ `);
25
+ }
26
+
27
+ async validate(providedKey: string): Promise<AuthenticateResult> {
28
+ const keyHash = await sha256(providedKey);
29
+ const { rows } = await this.client.query(
30
+ `SELECT key_hash, scopes, metadata, expires_at FROM ${this.tableName} WHERE key_hash = $1`,
31
+ [keyHash]
32
+ );
33
+ const row = rows[0];
34
+ if (!row) {
35
+ return { authenticated: false, error: "Invalid API key" };
36
+ }
37
+
38
+ const expiresAt = row.expires_at as number | null;
39
+ if (expiresAt && Date.now() > expiresAt) {
40
+ return { authenticated: false, error: "API key expired" };
41
+ }
42
+
43
+ const scopes = row.scopes ? (JSON.parse(row.scopes as string) as string[]) : undefined;
44
+ const metadata = row.metadata
45
+ ? (JSON.parse(row.metadata as string) as Record<string, unknown>)
46
+ : undefined;
47
+ return { authenticated: true, key: providedKey, scopes, metadata };
48
+ }
49
+
50
+ verify = this.validate;
51
+
52
+ authenticate(options: { requiredScopes?: string[]; header?: string } = {}) {
53
+ const header = options.header ?? "Authorization";
54
+ const requiredScopes = options.requiredScopes ?? [];
55
+
56
+ return async (req: Request): Promise<AuthenticateResult> => {
57
+ const authHeader = req.headers.get(header);
58
+ if (!authHeader) {
59
+ return { authenticated: false, error: "Missing API key" };
60
+ }
61
+
62
+ const token = authHeader.replace(/^Bearer\s+/i, "").trim();
63
+ const result = await this.verify(token);
64
+
65
+ if (!result.authenticated) return result;
66
+
67
+ if (requiredScopes.length > 0) {
68
+ const hasScopes = requiredScopes.every((s) => result.scopes?.includes(s));
69
+ if (!hasScopes) {
70
+ return { authenticated: false, error: "Insufficient permissions" };
71
+ }
72
+ }
73
+
74
+ return result;
75
+ };
76
+ }
77
+
78
+ async setKey(entry: ApiKeyEntry, expiresAt?: number): Promise<void> {
79
+ const keyHash = await sha256(entry.key);
80
+ await this.client.query(
81
+ `INSERT INTO ${this.tableName} (key_hash, scopes, metadata, expires_at)
82
+ VALUES ($1, $2, $3, $4)
83
+ ON CONFLICT (key_hash) DO UPDATE
84
+ SET scopes = $2, metadata = $3, expires_at = $4`,
85
+ [
86
+ keyHash,
87
+ entry.scopes ? JSON.stringify(entry.scopes) : null,
88
+ entry.metadata ? JSON.stringify(entry.metadata) : null,
89
+ expiresAt ?? null,
90
+ ]
91
+ );
92
+ }
93
+
94
+ async deleteKey(key: string): Promise<void> {
95
+ const keyHash = await sha256(key);
96
+ await this.client.query(`DELETE FROM ${this.tableName} WHERE key_hash = $1`, [keyHash]);
97
+ }
98
+ }
99
+
100
+ async function sha256(input: string): Promise<string> {
101
+ const encoder = new TextEncoder();
102
+ const data = encoder.encode(input);
103
+ const hash = await crypto.subtle.digest("SHA-256", data);
104
+ return Array.from(new Uint8Array(hash))
105
+ .map((b) => b.toString(16).padStart(2, "0"))
106
+ .join("");
107
+ }
@@ -0,0 +1,101 @@
1
+ import { expect, test } from "bun:test";
2
+ import { PostgresIdempotencyStore, PostgresRateLimitStore, type PostgresClient } from "./postgres";
3
+
4
+ function mockPostgresClient(): PostgresClient {
5
+ const tables: Record<string, Map<string, Record<string, unknown>>> = {};
6
+ return {
7
+ async query(sql: string, params?: unknown[]) {
8
+ // rudimentary parser for our specific SQL patterns
9
+ const tableMatch = sql.match(/(?:FROM|INTO|UPDATE)\s+(\w+)/i);
10
+ const tableName = tableMatch?.[1] ?? "unknown";
11
+ if (!tables[tableName]) tables[tableName] = new Map();
12
+
13
+ const table = tables[tableName];
14
+
15
+ if (sql.includes("CREATE TABLE IF NOT EXISTS")) {
16
+ return { rows: [] };
17
+ }
18
+
19
+ if (sql.includes("DELETE")) {
20
+ const keyIdx = sql.indexOf("$1");
21
+ if (keyIdx !== -1 && params?.[0]) {
22
+ table.delete(params[0] as string);
23
+ }
24
+ return { rows: [] };
25
+ }
26
+
27
+ if (sql.includes("SELECT") && sql.includes("WHERE key = $1")) {
28
+ const key = params?.[0] as string;
29
+ const now = (params?.[1] as number) ?? Date.now();
30
+ const entry = table.get(key);
31
+ if (!entry || (entry.expires_at as number) <= now) {
32
+ return { rows: [] };
33
+ }
34
+ return { rows: [entry] };
35
+ }
36
+
37
+ if (sql.includes("INSERT") && sql.includes("ON CONFLICT") && !sql.includes("RETURNING")) {
38
+ const key = params?.[0] as string;
39
+ const value = params?.[1] as string;
40
+ const expiresAt = params?.[2] as number;
41
+ table.set(key, { key, value, expires_at: expiresAt });
42
+ return { rows: [] };
43
+ }
44
+
45
+ if (sql.includes("INSERT") && sql.includes("ON CONFLICT") && sql.includes("RETURNING")) {
46
+ const key = params?.[0] as string;
47
+ const reset = params?.[1] as number;
48
+ const now = (params?.[2] as number) ?? Date.now();
49
+ const existing = table.get(key);
50
+
51
+ if (existing && (existing.reset_at as number) > now) {
52
+ existing.count = (existing.count as number) + 1;
53
+ return { rows: [{ count: existing.count, reset_at: existing.reset_at }] };
54
+ }
55
+
56
+ table.set(key, { key, count: 1, reset_at: reset });
57
+ return { rows: [{ count: 1, reset_at: reset }] };
58
+ }
59
+
60
+ return { rows: [] };
61
+ },
62
+ };
63
+ }
64
+
65
+ test("PostgresIdempotencyStore set and get", async () => {
66
+ const client = mockPostgresClient();
67
+ const store = new PostgresIdempotencyStore(client);
68
+ await store.ensureTable();
69
+ await store.set("payment:99", { id: "99", amount: 50 }, 60_000);
70
+ const result = await store.get("payment:99");
71
+ expect(result).toEqual({ id: "99", amount: 50 });
72
+ });
73
+
74
+ test("PostgresIdempotencyStore returns null for expired key", async () => {
75
+ const client = mockPostgresClient();
76
+ const store = new PostgresIdempotencyStore(client);
77
+ await store.ensureTable();
78
+ await store.set("expired-key", "value", -1);
79
+ const result = await store.get("expired-key");
80
+ expect(result).toBeNull();
81
+ });
82
+
83
+ test("PostgresRateLimitStore increment", async () => {
84
+ const client = mockPostgresClient();
85
+ const store = new PostgresRateLimitStore(client);
86
+ await store.ensureTable();
87
+ const first = await store.increment("ip:1.2.3.4", 60_000);
88
+ expect(first.count).toBe(1);
89
+ const second = await store.increment("ip:1.2.3.4", 60_000);
90
+ expect(second.count).toBe(2);
91
+ });
92
+
93
+ test("PostgresRateLimitStore reset", async () => {
94
+ const client = mockPostgresClient();
95
+ const store = new PostgresRateLimitStore(client);
96
+ await store.ensureTable();
97
+ await store.increment("ip:1.2.3.4", 60_000);
98
+ await store.reset("ip:1.2.3.4");
99
+ const result = await store.increment("ip:1.2.3.4", 60_000);
100
+ expect(result.count).toBe(1);
101
+ });
@@ -0,0 +1,100 @@
1
+ import type { IdempotencyStore } from "../idempotency";
2
+ import type { RateLimitStore } from "../rate-limit";
3
+
4
+ export interface PostgresClient {
5
+ query(sql: string, params?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
6
+ }
7
+
8
+ const IDEMPOTENCY_TABLE = "gate_idempotency";
9
+ const RATE_LIMIT_TABLE = "gate_rate_limits";
10
+
11
+ export class PostgresIdempotencyStore implements IdempotencyStore {
12
+ constructor(
13
+ private client: PostgresClient,
14
+ private tableName: string = IDEMPOTENCY_TABLE
15
+ ) {}
16
+
17
+ async ensureTable(): Promise<void> {
18
+ await this.client.query(`
19
+ CREATE TABLE IF NOT EXISTS ${this.tableName} (
20
+ key TEXT PRIMARY KEY,
21
+ value TEXT NOT NULL,
22
+ expires_at BIGINT NOT NULL
23
+ )
24
+ `);
25
+ }
26
+
27
+ async get(key: string): Promise<unknown | null> {
28
+ const { rows } = await this.client.query(
29
+ `SELECT value, expires_at FROM ${this.tableName} WHERE key = $1 AND expires_at > $2`,
30
+ [key, Date.now()]
31
+ );
32
+ const row = rows[0];
33
+ if (!row) return null;
34
+ const val = row.value as string;
35
+ try {
36
+ return JSON.parse(val) as unknown;
37
+ } catch {
38
+ return val;
39
+ }
40
+ }
41
+
42
+ async set(key: string, value: unknown, ttl: number): Promise<void> {
43
+ const serialized = typeof value === "string" ? value : JSON.stringify(value);
44
+ await this.client.query(
45
+ `INSERT INTO ${this.tableName} (key, value, expires_at)
46
+ VALUES ($1, $2, $3)
47
+ ON CONFLICT (key) DO UPDATE SET value = $2, expires_at = $3`,
48
+ [key, serialized, Date.now() + ttl]
49
+ );
50
+ }
51
+
52
+ async delete(key: string): Promise<void> {
53
+ await this.client.query(`DELETE FROM ${this.tableName} WHERE key = $1`, [key]);
54
+ }
55
+ }
56
+
57
+ export class PostgresRateLimitStore implements RateLimitStore {
58
+ constructor(
59
+ private client: PostgresClient,
60
+ private tableName: string = RATE_LIMIT_TABLE
61
+ ) {}
62
+
63
+ async ensureTable(): Promise<void> {
64
+ await this.client.query(`
65
+ CREATE TABLE IF NOT EXISTS ${this.tableName} (
66
+ key TEXT PRIMARY KEY,
67
+ count INTEGER NOT NULL DEFAULT 1,
68
+ reset_at BIGINT NOT NULL
69
+ )
70
+ `);
71
+ }
72
+
73
+ async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
74
+ const now = Date.now();
75
+ const reset = now + windowMs;
76
+
77
+ const { rows } = await this.client.query(
78
+ `INSERT INTO ${this.tableName} (key, count, reset_at)
79
+ VALUES ($1, 1, $2)
80
+ ON CONFLICT (key) DO UPDATE
81
+ SET count = CASE
82
+ WHEN ${this.tableName}.reset_at <= $3 THEN 1
83
+ ELSE ${this.tableName}.count + 1
84
+ END,
85
+ reset_at = CASE
86
+ WHEN ${this.tableName}.reset_at <= $3 THEN $4
87
+ ELSE ${this.tableName}.reset_at
88
+ END
89
+ RETURNING count, reset_at`,
90
+ [key, reset, now, reset]
91
+ );
92
+
93
+ const row = rows[0]!;
94
+ return { count: row.count as number, reset: row.reset_at as number };
95
+ }
96
+
97
+ async reset(key: string): Promise<void> {
98
+ await this.client.query(`DELETE FROM ${this.tableName} WHERE key = $1`, [key]);
99
+ }
100
+ }
@@ -0,0 +1,51 @@
1
+ import { expect, test } from "bun:test";
2
+ import { RedisApiKeyStore, type RedisClient } from "./redis-api-keys";
3
+
4
+ function mockRedisClient(): RedisClient {
5
+ const store = new Map<string, string>();
6
+ return {
7
+ async get(key: string) {
8
+ return store.get(key) ?? null;
9
+ },
10
+ async hget(_key: string, _field: string) {
11
+ return undefined;
12
+ },
13
+ async hgetall(key: string) {
14
+ const val = store.get(key);
15
+ if (!val) return null;
16
+ return JSON.parse(val) as Record<string, string>;
17
+ },
18
+ async set(key: string, value: string) {
19
+ store.set(key, value);
20
+ },
21
+ async del(key: string) {
22
+ return store.delete(key) ? 1 : 0;
23
+ },
24
+ };
25
+ }
26
+
27
+ test("RedisApiKeyStore validate and setKey", async () => {
28
+ const client = mockRedisClient();
29
+ const store = new RedisApiKeyStore(client);
30
+ await store.setKey({ key: "sk-redis-test", scopes: ["read"] });
31
+
32
+ const result = await store.validate("sk-redis-test");
33
+ expect(result.authenticated).toBe(true);
34
+ expect(result.scopes).toEqual(["read"]);
35
+ });
36
+
37
+ test("RedisApiKeyStore rejects unknown key", async () => {
38
+ const client = mockRedisClient();
39
+ const store = new RedisApiKeyStore(client);
40
+ const result = await store.validate("sk-unknown");
41
+ expect(result.authenticated).toBe(false);
42
+ });
43
+
44
+ test("RedisApiKeyStore supports deleteKey", async () => {
45
+ const client = mockRedisClient();
46
+ const store = new RedisApiKeyStore(client);
47
+ await store.setKey({ key: "sk-deletable" });
48
+ await store.deleteKey("sk-deletable");
49
+ const result = await store.validate("sk-deletable");
50
+ expect(result.authenticated).toBe(false);
51
+ });
@@ -0,0 +1,79 @@
1
+ import type { ApiKeyEntry, AuthenticateResult } from "../api-keys";
2
+
3
+ export interface RedisClient {
4
+ get(key: string): Promise<string | null>;
5
+ hget(key: string, field: string): Promise<string | undefined>;
6
+ hgetall(key: string): Promise<Record<string, string> | null>;
7
+ set(key: string, value: string, opts?: { ex?: number }): Promise<unknown>;
8
+ del(key: string): Promise<number>;
9
+ }
10
+
11
+ export class RedisApiKeyStore {
12
+ constructor(
13
+ private client: RedisClient,
14
+ private keyPrefix = "gate:apikey:"
15
+ ) {}
16
+
17
+ async validate(providedKey: string): Promise<AuthenticateResult> {
18
+ const keyHash = await sha256(providedKey);
19
+ const entry = await this.client.hgetall(`${this.keyPrefix}${keyHash}`);
20
+ if (!entry) {
21
+ return { authenticated: false, error: "Invalid API key" };
22
+ }
23
+ const scopes = entry.scopes ? (JSON.parse(entry.scopes) as string[]) : undefined;
24
+ const metadata = entry.metadata
25
+ ? (JSON.parse(entry.metadata) as Record<string, unknown>)
26
+ : undefined;
27
+ return { authenticated: true, key: providedKey, scopes, metadata };
28
+ }
29
+
30
+ verify = this.validate;
31
+
32
+ authenticate(options: { requiredScopes?: string[]; header?: string } = {}) {
33
+ const header = options.header ?? "Authorization";
34
+ const requiredScopes = options.requiredScopes ?? [];
35
+
36
+ return async (req: Request): Promise<AuthenticateResult> => {
37
+ const authHeader = req.headers.get(header);
38
+ if (!authHeader) {
39
+ return { authenticated: false, error: "Missing API key" };
40
+ }
41
+
42
+ const token = authHeader.replace(/^Bearer\s+/i, "").trim();
43
+ const result = await this.verify(token);
44
+
45
+ if (!result.authenticated) return result;
46
+
47
+ if (requiredScopes.length > 0) {
48
+ const hasScopes = requiredScopes.every((s) => result.scopes?.includes(s));
49
+ if (!hasScopes) {
50
+ return { authenticated: false, error: "Insufficient permissions" };
51
+ }
52
+ }
53
+
54
+ return result;
55
+ };
56
+ }
57
+
58
+ async setKey(entry: ApiKeyEntry): Promise<void> {
59
+ const keyHash = await sha256(entry.key);
60
+ const data: Record<string, string> = {};
61
+ if (entry.scopes) data.scopes = JSON.stringify(entry.scopes);
62
+ if (entry.metadata) data.metadata = JSON.stringify(entry.metadata);
63
+ await this.client.set(`${this.keyPrefix}${keyHash}`, JSON.stringify(data));
64
+ }
65
+
66
+ async deleteKey(key: string): Promise<void> {
67
+ const keyHash = await sha256(key);
68
+ await this.client.del(`${this.keyPrefix}${keyHash}`);
69
+ }
70
+ }
71
+
72
+ async function sha256(input: string): Promise<string> {
73
+ const encoder = new TextEncoder();
74
+ const data = encoder.encode(input);
75
+ const hash = await crypto.subtle.digest("SHA-256", data);
76
+ return Array.from(new Uint8Array(hash))
77
+ .map((b) => b.toString(16).padStart(2, "0"))
78
+ .join("");
79
+ }
@@ -0,0 +1,77 @@
1
+ import { expect, test } from "bun:test";
2
+ import { RedisIdempotencyStore, RedisRateLimitStore, type RedisClient } from "./redis";
3
+
4
+ function mockRedisClient(): RedisClient {
5
+ const store = new Map<string, { value: string; expires: number }>();
6
+ return {
7
+ async get(key: string) {
8
+ const entry = store.get(key);
9
+ if (!entry) return null;
10
+ if (Date.now() > entry.expires) {
11
+ store.delete(key);
12
+ return null;
13
+ }
14
+ return entry.value;
15
+ },
16
+ async set(_key: string, _value: string, _opts?: { ex?: number }) {
17
+ // not used by current impl
18
+ },
19
+ async setex(key: string, seconds: number, value: string) {
20
+ store.set(key, { value, expires: Date.now() + seconds * 1000 });
21
+ },
22
+ async incr(key: string) {
23
+ const entry = store.get(key);
24
+ if (!entry) {
25
+ store.set(key, { value: "1", expires: Infinity });
26
+ return 1;
27
+ }
28
+ const next = Number(entry.value) + 1;
29
+ store.set(key, { value: String(next), expires: entry.expires });
30
+ return next;
31
+ },
32
+ async expire(key: string, _seconds: number) {
33
+ const entry = store.get(key);
34
+ if (entry) {
35
+ store.set(key, { ...entry, expires: Date.now() + _seconds * 1000 });
36
+ return 1;
37
+ }
38
+ return 0;
39
+ },
40
+ async del(key: string) {
41
+ return store.delete(key) ? 1 : 0;
42
+ },
43
+ };
44
+ }
45
+
46
+ test("RedisIdempotencyStore set and get", async () => {
47
+ const client = mockRedisClient();
48
+ const store = new RedisIdempotencyStore(client);
49
+ await store.set("order:42", { status: "confirmed" }, 60_000);
50
+ const result = await store.get("order:42");
51
+ expect(result).toEqual({ status: "confirmed" });
52
+ });
53
+
54
+ test("RedisIdempotencyStore returns null for missing key", async () => {
55
+ const client = mockRedisClient();
56
+ const store = new RedisIdempotencyStore(client);
57
+ const result = await store.get("nonexistent");
58
+ expect(result).toBeNull();
59
+ });
60
+
61
+ test("RedisRateLimitStore increment", async () => {
62
+ const client = mockRedisClient();
63
+ const store = new RedisRateLimitStore(client);
64
+ const first = await store.increment("user:1", 60_000);
65
+ expect(first.count).toBe(1);
66
+ const second = await store.increment("user:1", 60_000);
67
+ expect(second.count).toBe(2);
68
+ });
69
+
70
+ test("RedisRateLimitStore reset", async () => {
71
+ const client = mockRedisClient();
72
+ const store = new RedisRateLimitStore(client);
73
+ await store.increment("user:1", 60_000);
74
+ await store.reset("user:1");
75
+ const result = await store.increment("user:1", 60_000);
76
+ expect(result.count).toBe(1);
77
+ });
@@ -0,0 +1,60 @@
1
+ import type { IdempotencyStore } from "../idempotency";
2
+ import type { RateLimitStore } from "../rate-limit";
3
+
4
+ export interface RedisClient {
5
+ get(key: string): Promise<string | null>;
6
+ set(key: string, value: string, opts?: { ex?: number }): Promise<unknown>;
7
+ setex(key: string, seconds: number, value: string): Promise<unknown>;
8
+ incr(key: string): Promise<number>;
9
+ expire(key: string, seconds: number): Promise<number>;
10
+ del(key: string): Promise<number>;
11
+ }
12
+
13
+ export class RedisIdempotencyStore implements IdempotencyStore {
14
+ constructor(private client: RedisClient) {}
15
+
16
+ async get(key: string): Promise<unknown | null> {
17
+ const val = await this.client.get(key);
18
+ if (!val) return null;
19
+ try {
20
+ return JSON.parse(val) as unknown;
21
+ } catch {
22
+ return val;
23
+ }
24
+ }
25
+
26
+ async set(key: string, value: unknown, ttl: number): Promise<void> {
27
+ const serialized = typeof value === "string" ? value : JSON.stringify(value);
28
+ await this.client.setex(key, Math.ceil(ttl / 1000), serialized);
29
+ }
30
+
31
+ async delete(key: string): Promise<void> {
32
+ await this.client.del(key);
33
+ }
34
+ }
35
+
36
+ export class RedisRateLimitStore implements RateLimitStore {
37
+ constructor(private client: RedisClient) {}
38
+
39
+ async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
40
+ const now = Date.now();
41
+ const windowSeconds = Math.ceil(windowMs / 1000);
42
+ const count = await this.client.incr(key);
43
+
44
+ if (count === 1) {
45
+ await this.client.expire(key, windowSeconds);
46
+ }
47
+
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
+ });
53
+
54
+ return { count, reset: now + ttl };
55
+ }
56
+
57
+ async reset(key: string): Promise<void> {
58
+ await this.client.del(key);
59
+ }
60
+ }