@joinremba/gate 0.2.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
@@ -184,7 +184,86 @@ if (!result.authenticated) throw new AuthenticationError(result.error);
184
184
 
185
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
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, extend with a DB-backed validator (e.g. query Postgres with `SELECT * FROM api_keys WHERE key_hash = $1`).
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
+ ```
188
267
 
189
268
  ### Errors (`@joinremba/gate/errors`)
190
269
 
@@ -317,15 +396,13 @@ app.post("/orders", async (req, res, next) => {
317
396
  - In-memory idempotency store
318
397
  - In-memory rate limiting store
319
398
 
320
- **V1**
399
+ **V1** (current)
321
400
 
322
- - Idempotency middleware
323
- - Redis idempotency store
324
- - Postgres idempotency store
325
- - Rate limiting middleware
401
+ - Redis and Postgres stores for idempotency and rate limiting
326
402
  - API key hashing and validation
327
- - Scopes and permissions middleware
328
- - Usage tracking hooks
403
+ - DB-backed API key stores (Redis, Postgres)
404
+ - Combined middleware (auth + rate limit + idempotency)
405
+ - Per-key rate limiting helper
329
406
 
330
407
  **V2**
331
408
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinremba/gate",
3
- "version": "0.2.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",
@@ -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": [
@@ -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,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
+ }