@joinremba/gate 0.2.0 → 0.4.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
@@ -13,7 +13,6 @@ Gate is the API safety layer for TypeScript backends. It validates requests, for
13
13
  - **Structured responses** — Consistent `{ success, data, error }` response envelope with `ok()` and `fail()` helpers.
14
14
  - **Problem details** — RFC 9457 problem-details-style error format.
15
15
  - **Pagination** — Standardised paginated response helper.
16
- - **Request ID** — Automatic request ID generation and propagation.
17
16
  - **Idempotency** — Prevent duplicate writes with idempotency keys. Ships with in-memory store; plug in Redis or Postgres.
18
17
  - **Rate limiting** — Protect endpoints from abuse with configurable windows and limits.
19
18
  - **API key management** — Rotatable, scoped API key authentication with Bearer token support.
@@ -184,7 +183,85 @@ if (!result.authenticated) throw new AuthenticationError(result.error);
184
183
 
185
184
  **When to use it:** You have a few static keys for internal services, a shared webhook secret, or scoped tokens for admin tools. Keys are configured at startup and held in memory — no database query per request, zero dependencies.
186
185
 
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, extend with a DB-backed validator (e.g. query Postgres with `SELECT * FROM api_keys WHERE key_hash = $1`).
186
+ **When not to use it:** You need key rotation, hashed storage, per-user API keys, expiry/revocation, or rate limiting per key. For those cases, use the hashed or DB-backed stores below.
187
+
188
+ #### Hashed API Keys
189
+
190
+ Store SHA-256 hashes instead of plaintext keys. Protects against memory dumps.
191
+
192
+ ```ts
193
+ const validator = createApiKeyValidator([{ key: "sk-live-123", scopes: ["admin"] }], {
194
+ hashKeys: true,
195
+ });
196
+
197
+ await validator.verify("sk-live-123");
198
+ // -> { authenticated: true, key: "sk-live-123", scopes: ["admin"] }
199
+ ```
200
+
201
+ #### DB-backed API Key Stores
202
+
203
+ Validate keys against Postgres or Redis. Keys can be added/removed at runtime.
204
+
205
+ ```ts
206
+ import { PostgresApiKeyStore } from "@joinremba/gate/stores/postgres-api-keys";
207
+
208
+ const store = new PostgresApiKeyStore(pgClient);
209
+ await store.ensureTable();
210
+
211
+ // Add a key
212
+ await store.setKey({ key: "sk-live-abc", scopes: ["read"] }, expiresAt);
213
+
214
+ // Validate
215
+ const result = await store.verify("sk-live-abc");
216
+
217
+ // Remove
218
+ await store.deleteKey("sk-live-abc");
219
+ ```
220
+
221
+ ```ts
222
+ import { RedisApiKeyStore } from "@joinremba/gate/stores/redis-api-keys";
223
+
224
+ const store = new RedisApiKeyStore(redisClient);
225
+ await store.setKey({ key: "sk-redis-key", scopes: ["admin"] });
226
+ const result = await store.verify("sk-redis-key");
227
+ ```
228
+
229
+ ### Combined Middleware
230
+
231
+ Run auth, rate limiting, and idempotency in a single middleware call:
232
+
233
+ ```ts
234
+ const gate = createGate({
235
+ apiKeys: [{ key: "sk-admin" }],
236
+ rateLimit: { windowMs: 60_000, max: 100 },
237
+ });
238
+
239
+ app.use(
240
+ gate.middleware({
241
+ auth: true,
242
+ requiredScopes: ["write"],
243
+ rateLimit: true,
244
+ idempotency: true,
245
+ excludePaths: ["/health", "/metrics"],
246
+ })
247
+ );
248
+ ```
249
+
250
+ The middleware returns 401 for invalid/missing keys, 429 when rate limited, and caches idempotent responses automatically.
251
+
252
+ ### Per-key Rate Limiting
253
+
254
+ Rate-limit by API key instead of IP:
255
+
256
+ ```ts
257
+ import { rateLimit, keyByApiKey } from "@joinremba/gate/rate-limit";
258
+
259
+ const limiter = rateLimit({
260
+ windowMs: 60_000,
261
+ max: 30,
262
+ keyFn: keyByApiKey,
263
+ });
264
+ ```
188
265
 
189
266
  ### Errors (`@joinremba/gate/errors`)
190
267
 
@@ -317,15 +394,13 @@ app.post("/orders", async (req, res, next) => {
317
394
  - In-memory idempotency store
318
395
  - In-memory rate limiting store
319
396
 
320
- **V1**
397
+ **V1** (current)
321
398
 
322
- - Idempotency middleware
323
- - Redis idempotency store
324
- - Postgres idempotency store
325
- - Rate limiting middleware
399
+ - Redis and Postgres stores for idempotency and rate limiting
326
400
  - API key hashing and validation
327
- - Scopes and permissions middleware
328
- - Usage tracking hooks
401
+ - DB-backed API key stores (Redis, Postgres)
402
+ - Combined middleware (auth + rate limit + idempotency)
403
+ - Per-key rate limiting helper
329
404
 
330
405
  **V2**
331
406
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinremba/gate",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
@@ -46,10 +46,20 @@
46
46
  "import": "./src/stores/redis.ts",
47
47
  "default": "./src/stores/redis.ts"
48
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
+ },
49
54
  "./stores/postgres": {
50
55
  "types": "./src/stores/postgres.ts",
51
56
  "import": "./src/stores/postgres.ts",
52
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"
53
63
  }
54
64
  },
55
65
  "files": [
@@ -100,6 +110,7 @@
100
110
  "bun": ">=1.3.1"
101
111
  },
102
112
  "dependencies": {
113
+ "@joinremba/core": "^0.4.0",
103
114
  "zod": "^4.4.2"
104
115
  },
105
116
  "devDependencies": {
@@ -0,0 +1,53 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createApiKeyValidator } from "./api-keys";
3
+
4
+ test("validate returns authenticated for matching key", () => {
5
+ const validator = createApiKeyValidator([{ key: "sk-live-abc", scopes: ["read"] }]);
6
+ const result = validator.validate("sk-live-abc");
7
+ expect(result.authenticated).toBe(true);
8
+ expect(result.scopes).toEqual(["read"]);
9
+ });
10
+
11
+ test("validate returns error for unknown key", () => {
12
+ const validator = createApiKeyValidator([{ key: "sk-live-abc" }]);
13
+ const result = validator.validate("sk-live-xyz");
14
+ expect(result.authenticated).toBe(false);
15
+ expect(result.error).toBe("Invalid API key");
16
+ });
17
+
18
+ test("verify with hashKeys true hashes before lookup", async () => {
19
+ const validator = createApiKeyValidator(
20
+ [
21
+ {
22
+ key: "sk-live-123",
23
+ scopes: ["admin"],
24
+ },
25
+ ],
26
+ { hashKeys: true }
27
+ );
28
+ // "sk-live-123" sha256 = 9418b81169b7...
29
+ const result = await validator.verify("sk-live-123");
30
+ expect(result.authenticated).toBe(true);
31
+ expect(result.scopes).toEqual(["admin"]);
32
+ });
33
+
34
+ test("authenticate middleware reads Authorization header", async () => {
35
+ const validator = createApiKeyValidator([{ key: "sk-test", scopes: ["read"] }]);
36
+ const auth = validator.authenticate({ requiredScopes: ["read"] });
37
+ const req = new Request("http://localhost", {
38
+ headers: { Authorization: "Bearer sk-test" },
39
+ });
40
+ const result = await auth(req);
41
+ expect(result.authenticated).toBe(true);
42
+ });
43
+
44
+ test("authenticate rejects missing scopes", async () => {
45
+ const validator = createApiKeyValidator([{ key: "sk-test", scopes: ["read"] }]);
46
+ const auth = validator.authenticate({ requiredScopes: ["write"] });
47
+ const req = new Request("http://localhost", {
48
+ headers: { Authorization: "Bearer sk-test" },
49
+ });
50
+ const result = await auth(req);
51
+ expect(result.authenticated).toBe(false);
52
+ expect(result.error).toBe("Insufficient permissions");
53
+ });
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,44 @@ 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
+ }
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
+ }
55
+ }
56
+ return hashCache;
57
+ }
21
58
 
22
59
  return {
23
60
  validate(providedKey: string): AuthenticateResult {
@@ -25,21 +62,44 @@ export function createApiKeyValidator(keys: ApiKeyEntry[]) {
25
62
  if (!entry) {
26
63
  return { authenticated: false, error: "Invalid API key" };
27
64
  }
28
- return { authenticated: true, key: entry.key, scopes: entry.scopes };
65
+ return {
66
+ authenticated: true,
67
+ key: entry.key,
68
+ scopes: entry.scopes,
69
+ metadata: entry.metadata,
70
+ };
71
+ },
72
+
73
+ async verify(providedKey: string): Promise<AuthenticateResult> {
74
+ if (!hashKeys) {
75
+ return this.validate(providedKey);
76
+ }
77
+ const cache = await ensureHashCache();
78
+ const keyHash = await sha256(providedKey);
79
+ const entry = cache.get(keyHash);
80
+ if (!entry) {
81
+ return { authenticated: false, error: "Invalid API key" };
82
+ }
83
+ return {
84
+ authenticated: true,
85
+ key: providedKey,
86
+ scopes: entry.scopes,
87
+ metadata: entry.metadata,
88
+ };
29
89
  },
30
90
 
31
91
  authenticate(options: AuthenticateOptions = {}) {
32
92
  const header = options.header ?? "Authorization";
33
93
  const requiredScopes = options.requiredScopes ?? [];
34
94
 
35
- return (req: Request): AuthenticateResult => {
95
+ return async (req: Request): Promise<AuthenticateResult> => {
36
96
  const authHeader = req.headers.get(header);
37
97
  if (!authHeader) {
38
98
  return { authenticated: false, error: "Missing API key" };
39
99
  }
40
100
 
41
101
  const token = authHeader.replace(/^Bearer\s+/i, "").trim();
42
- const result = this.validate(token);
102
+ const result = hashKeys ? await this.verify(token) : this.validate(token);
43
103
 
44
104
  if (!result.authenticated) return result;
45
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", () => {
@@ -114,3 +124,69 @@ describe("api keys", () => {
114
124
  expect(result.authenticated).toBe(false);
115
125
  });
116
126
  });
127
+
128
+ describe("keyByApiKey", () => {
129
+ test("uses API key from Authorization header", async () => {
130
+ const { keyByApiKey } = await import("./rate-limit");
131
+ const req = new Request("http://localhost", {
132
+ headers: { Authorization: "Bearer sk-live-abc123" },
133
+ });
134
+ const key = keyByApiKey(req);
135
+ expect(key).toStartWith("ak:sk-live-abc1");
136
+ });
137
+
138
+ test("falls back to IP when no auth header", async () => {
139
+ const { keyByApiKey } = await import("./rate-limit");
140
+ const req = new Request("http://localhost");
141
+ const key = keyByApiKey(req);
142
+ expect(typeof key).toBe("string");
143
+ });
144
+ });
145
+
146
+ describe("middleware", () => {
147
+ test("passes through when no features enabled", async () => {
148
+ const gate = createGate();
149
+ const mw = gate.middleware();
150
+ const req = new Request("http://localhost/api");
151
+ let called = false;
152
+ const res = await mw(req, async () => {
153
+ called = true;
154
+ return new Response("ok");
155
+ });
156
+ expect(called).toBe(true);
157
+ expect(res).toBeInstanceOf(Response);
158
+ });
159
+
160
+ test("rejects request when auth fails", async () => {
161
+ const gate = createGate({ apiKeys: [{ key: "sk-valid" }] });
162
+ const mw = gate.middleware({ auth: true });
163
+ const req = new Request("http://localhost/api", {
164
+ headers: { Authorization: "Bearer sk-wrong" },
165
+ });
166
+ const res = await mw(req, async () => new Response("ok"));
167
+ expect(res!.status).toBe(401);
168
+ const body = (await res!.json()) as Record<string, unknown>;
169
+ expect(body.success).toBe(false);
170
+ });
171
+
172
+ test("rejects when rate limit exceeded", async () => {
173
+ const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
174
+ const mw = gate.middleware({ rateLimit: true });
175
+ const req = new Request("http://localhost/api");
176
+ const res = await mw(req, async () => new Response("ok"));
177
+ expect(res!.status).toBe(429);
178
+ });
179
+
180
+ test("skips excluded paths", async () => {
181
+ const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
182
+ const mw = gate.middleware({ rateLimit: true, excludePaths: ["/health"] });
183
+ const req = new Request("http://localhost/health");
184
+ let called = false;
185
+ const res = await mw(req, async () => {
186
+ called = true;
187
+ return new Response("ok");
188
+ });
189
+ expect(called).toBe(true);
190
+ expect(res!.status).toBe(200);
191
+ });
192
+ });
package/src/index.ts CHANGED
@@ -1,10 +1,29 @@
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
+ import type { RateLimitStore, RateLimitCheckResult } from "./rate-limit";
5
6
  import { createApiKeyValidator } from "./api-keys";
6
- import type { ApiKeyEntry } from "./api-keys";
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,
@@ -23,6 +42,7 @@ export {
23
42
  InMemoryStore,
24
43
  rateLimit,
25
44
  InMemoryRateLimitStore,
45
+ keyByApiKey,
26
46
  createApiKeyValidator,
27
47
  GateError,
28
48
  ValidationError,
@@ -52,12 +72,25 @@ export type {
52
72
  AuthenticateOptions,
53
73
  AuthenticateResult,
54
74
  ApiKeyValidator,
75
+ ApiKeyValidatorOptions,
55
76
  } from "./api-keys";
56
77
 
57
78
  export type Middleware = (req: Request, next?: () => Promise<Response>) => Promise<Response | null>;
58
79
 
80
+ export interface MiddlewareOptions {
81
+ auth?: boolean;
82
+ requiredScopes?: string[];
83
+ rateLimit?: boolean;
84
+ idempotency?: boolean;
85
+ /** Override the max for this specific middleware. */
86
+ rateLimitMax?: number;
87
+ /** Paths to skip entirely. */
88
+ excludePaths?: string[];
89
+ }
90
+
59
91
  export interface GateOptions {
60
92
  apiKeys?: ApiKeyEntry[];
93
+ client?: Client;
61
94
  idempotency?: {
62
95
  store?: IdempotencyStore;
63
96
  keyHeader?: string;
@@ -66,10 +99,19 @@ export interface GateOptions {
66
99
  rateLimit?: {
67
100
  windowMs?: number;
68
101
  max?: number;
69
- strategy?: "fixed" | "sliding";
102
+ store?: RateLimitStore;
103
+ keyFn?: (req: Request) => string;
70
104
  };
71
105
  }
72
106
 
107
+ export type MiddlewareResult =
108
+ | {
109
+ passed: true;
110
+ auth?: { key: string; scopes?: string[] };
111
+ rateLimit?: { remaining: number; reset: number };
112
+ }
113
+ | { passed: false; status: number; body: unknown };
114
+
73
115
  export interface Gate {
74
116
  validate: typeof validateRequest;
75
117
  ok: typeof ok;
@@ -79,10 +121,12 @@ export interface Gate {
79
121
  idempotency: ReturnType<typeof idempotency>;
80
122
  rateLimit: ReturnType<typeof rateLimit>;
81
123
  apiKeys: ReturnType<typeof createApiKeyValidator>;
82
- middleware(): Middleware;
124
+ middleware(opts?: MiddlewareOptions): Middleware;
83
125
  }
84
126
 
85
127
  export function createGate(options: GateOptions = {}): Gate {
128
+ const client = options.client;
129
+
86
130
  const idempInstance = idempotency({
87
131
  store: options.idempotency?.store ?? new InMemoryStore(),
88
132
  keyHeader: options.idempotency?.keyHeader,
@@ -92,10 +136,71 @@ export function createGate(options: GateOptions = {}): Gate {
92
136
  const rlInstance = rateLimit({
93
137
  windowMs: options.rateLimit?.windowMs,
94
138
  max: options.rateLimit?.max,
139
+ store: options.rateLimit?.store,
140
+ keyFn: options.rateLimit?.keyFn,
95
141
  });
96
142
 
97
143
  const apiKeyValidator = createApiKeyValidator(options.apiKeys ?? []);
98
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
+
202
+ const defaultFail = (message: string, code?: string) => fail(message, code ?? "UNAUTHORIZED");
203
+
99
204
  const gate: Gate = {
100
205
  validate: validateRequest,
101
206
  ok,
@@ -106,10 +211,82 @@ export function createGate(options: GateOptions = {}): Gate {
106
211
  rateLimit: rlInstance,
107
212
  apiKeys: apiKeyValidator,
108
213
 
109
- middleware() {
214
+ middleware(opts?: MiddlewareOptions) {
215
+ const {
216
+ auth = options.apiKeys != null && options.apiKeys.length > 0,
217
+ requiredScopes,
218
+ rateLimit: enableRl = options.rateLimit != null,
219
+ idempotency: enableIdem = false,
220
+ excludePaths = [],
221
+ } = opts ?? {};
222
+
110
223
  return async (req: Request, next?: () => Promise<Response>) => {
111
224
  if (!next) return null;
112
- return next();
225
+
226
+ const path = new URL(req.url).pathname;
227
+ if (excludePaths.some((p) => path === p || path.startsWith(p))) {
228
+ return next();
229
+ }
230
+
231
+ // Rate limit check
232
+ if (enableRl) {
233
+ const rlResult = await rlInstance.check(req);
234
+ gateRateLimitStore.set(req, rlResult);
235
+ if (!rlResult.allowed) {
236
+ const body = defaultFail("Too many requests", "RATE_LIMIT_EXCEEDED");
237
+ return new Response(JSON.stringify(body), {
238
+ status: 429,
239
+ headers: {
240
+ "Content-Type": "application/json",
241
+ "Retry-After": String(Math.ceil((rlResult.reset - Date.now()) / 1000)),
242
+ "X-RateLimit-Remaining": "0",
243
+ },
244
+ });
245
+ }
246
+ }
247
+
248
+ // Auth check
249
+ if (auth) {
250
+ const authFn = apiKeyValidator.authenticate({ requiredScopes });
251
+ const authResult = await authFn(req);
252
+ if (!authResult.authenticated) {
253
+ const body = defaultFail(authResult.error ?? "Unauthorized", "AUTHENTICATION_ERROR");
254
+ return new Response(JSON.stringify(body), {
255
+ status: 401,
256
+ headers: { "Content-Type": "application/json" },
257
+ });
258
+ }
259
+ gateAuthStore.set(req, authResult);
260
+ }
261
+
262
+ // Idempotency check
263
+ if (enableIdem) {
264
+ const idemKey = req.headers.get(idempInstance.keyHeader);
265
+ if (idemKey) {
266
+ const cached = await idempInstance.getResponse(idemKey);
267
+ if (cached) {
268
+ return new Response(JSON.stringify(cached), {
269
+ status: 200,
270
+ headers: { "Content-Type": "application/json" },
271
+ });
272
+ }
273
+ gateIdempotencyStore.set(req, idemKey);
274
+ }
275
+ }
276
+
277
+ const response = await next();
278
+ if (!response) return null;
279
+
280
+ // Store response for idempotency
281
+ if (enableIdem) {
282
+ const idemKey = gateIdempotencyStore.get(req);
283
+ if (idemKey && response.status < 500) {
284
+ const body = await response.clone().json();
285
+ await idempInstance.setResponse(idemKey, body);
286
+ }
287
+ }
288
+
289
+ return response;
113
290
  };
114
291
  },
115
292
  };
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";
@@ -35,10 +59,24 @@ export interface RateLimitOptions {
35
59
  keyFn?: (req: Request) => string;
36
60
  }
37
61
 
62
+ /** Rate-limit by API key (Bearer token from Authorization header). Falls back to IP. */
63
+ export function keyByApiKey(req: Request): string {
64
+ const auth = req.headers.get("authorization");
65
+ if (auth) {
66
+ const token = auth.replace(/^Bearer\s+/i, "").trim();
67
+ if (token) return `ak:${token}`;
68
+ }
69
+ return req.headers.get("x-forwarded-for") ?? "global";
70
+ }
71
+
38
72
  export function rateLimit(options: RateLimitOptions = {}) {
39
73
  const windowMs = options.windowMs ?? 60_000;
40
74
  const max = options.max ?? 100;
41
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
+ }
42
80
  const keyFn =
43
81
  options.keyFn ??
44
82
  ((req: Request) => {
@@ -52,8 +90,8 @@ export function rateLimit(options: RateLimitOptions = {}) {
52
90
  store,
53
91
  keyFn,
54
92
 
55
- async check(req: Request): Promise<{ allowed: boolean; remaining: number; reset: number }> {
56
- const key = keyFn(req);
93
+ async check(reqOrKey: Request | string): Promise<RateLimitCheckResult> {
94
+ const key = typeof reqOrKey === "string" ? reqOrKey : keyFn(reqOrKey);
57
95
  const { count, reset } = await store.increment(`rl:${key}`, windowMs);
58
96
  return {
59
97
  allowed: count <= max,
@@ -65,3 +103,9 @@ export function rateLimit(options: RateLimitOptions = {}) {
65
103
  }
66
104
 
67
105
  export type RateLimitInstance = ReturnType<typeof rateLimit>;
106
+
107
+ export type RateLimitCheckResult = {
108
+ allowed: boolean;
109
+ remaining: number;
110
+ reset: number;
111
+ };
@@ -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
+ }
@@ -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
 
@@ -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
+ }
@@ -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) {
@@ -7,6 +7,7 @@ export interface RedisClient {
7
7
  setex(key: string, seconds: number, value: string): Promise<unknown>;
8
8
  incr(key: string): Promise<number>;
9
9
  expire(key: string, seconds: number): Promise<number>;
10
+ ttl(key: string): Promise<number>;
10
11
  del(key: string): Promise<number>;
11
12
  }
12
13
 
@@ -37,7 +38,6 @@ export class RedisRateLimitStore implements RateLimitStore {
37
38
  constructor(private client: RedisClient) {}
38
39
 
39
40
  async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
40
- const now = Date.now();
41
41
  const windowSeconds = Math.ceil(windowMs / 1000);
42
42
  const count = await this.client.incr(key);
43
43
 
@@ -45,13 +45,11 @@ export class RedisRateLimitStore implements RateLimitStore {
45
45
  await this.client.expire(key, windowSeconds);
46
46
  }
47
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
- });
48
+ const ttlSeconds = await this.client.ttl(key);
49
+ const remainingMs = ttlSeconds > 0 ? ttlSeconds * 1000 : windowMs;
50
+ const reset = Date.now() + remainingMs;
53
51
 
54
- return { count, reset: now + ttl };
52
+ return { count, reset };
55
53
  }
56
54
 
57
55
  async reset(key: string): Promise<void> {
package/src/validate.ts CHANGED
@@ -63,7 +63,7 @@ export function validateRequest(
63
63
  }
64
64
 
65
65
  export function validate(schemas: ValidationSchemas) {
66
- return (req: Request) => {
66
+ return async (req: Request) => {
67
67
  const url = new URL(req.url);
68
68
 
69
69
  const searchParams: Record<string, string> = {};
@@ -71,8 +71,15 @@ export function validate(schemas: ValidationSchemas) {
71
71
  searchParams[k] = v;
72
72
  });
73
73
 
74
+ let body: unknown;
75
+ try {
76
+ body = await req.json();
77
+ } catch {
78
+ body = undefined;
79
+ }
80
+
74
81
  const result = validateRequest(schemas, {
75
- body: req.body,
82
+ body,
76
83
  query: searchParams,
77
84
  headers: Object.fromEntries(req.headers.entries()),
78
85
  });