@joinremba/gate 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinremba/gate",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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",
@@ -97,7 +97,7 @@ export function gateMiddleware(gate: Gate, opts?: MiddlewareOptions) {
97
97
  await next();
98
98
  return c.res;
99
99
  });
100
- if (res && res.status !== 200) {
100
+ if (res && res.status >= 400) {
101
101
  const body = await res.json();
102
102
  return c.json(body, res.status as 200 | 400 | 401 | 429 | 500);
103
103
  }
package/src/index.ts CHANGED
@@ -63,12 +63,7 @@ export type {
63
63
  StructuredResponse,
64
64
  } from "./respond";
65
65
  export type { IdempotencyStore, IdempotencyOptions, IdempotencyInstance } from "./idempotency";
66
- export type {
67
- RateLimitStore,
68
- RateLimitOptions,
69
- RateLimitStrategy,
70
- RateLimitInstance,
71
- } from "./rate-limit";
66
+ export type { RateLimitStore, RateLimitOptions, RateLimitInstance } from "./rate-limit";
72
67
  export type {
73
68
  ApiKeyEntry,
74
69
  AuthenticateOptions,
@@ -104,14 +99,6 @@ export interface GateOptions {
104
99
  };
105
100
  }
106
101
 
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
-
115
102
  export interface Gate {
116
103
  validate: typeof validateRequest;
117
104
  ok: typeof ok;
@@ -122,6 +109,7 @@ export interface Gate {
122
109
  rateLimit: ReturnType<typeof rateLimit>;
123
110
  apiKeys: ReturnType<typeof createApiKeyValidator>;
124
111
  middleware(opts?: MiddlewareOptions): Middleware;
112
+ dispose(): void;
125
113
  }
126
114
 
127
115
  export function createGate(options: GateOptions = {}): Gate {
@@ -211,6 +199,25 @@ export function createGate(options: GateOptions = {}): Gate {
211
199
  rateLimit: rlInstance,
212
200
  apiKeys: apiKeyValidator,
213
201
 
202
+ dispose(): void {
203
+ const idemStore = idempInstance.store;
204
+ if (
205
+ idemStore &&
206
+ "dispose" in idemStore &&
207
+ typeof (idemStore as { dispose: () => void }).dispose === "function"
208
+ ) {
209
+ (idemStore as { dispose: () => void }).dispose();
210
+ }
211
+ const rlStore = rlInstance.store;
212
+ if (
213
+ rlStore &&
214
+ "dispose" in rlStore &&
215
+ typeof (rlStore as { dispose: () => void }).dispose === "function"
216
+ ) {
217
+ (rlStore as { dispose: () => void }).dispose();
218
+ }
219
+ },
220
+
214
221
  middleware(opts?: MiddlewareOptions) {
215
222
  const {
216
223
  auth = options.apiKeys != null && options.apiKeys.length > 0,
package/src/rate-limit.ts CHANGED
@@ -49,7 +49,7 @@ export class InMemoryRateLimitStore implements RateLimitStore {
49
49
  }
50
50
  }
51
51
 
52
- export type RateLimitStrategy = "fixed" | "sliding";
52
+ export type RateLimitStrategy = "fixed";
53
53
 
54
54
  export interface RateLimitOptions {
55
55
  windowMs?: number;
@@ -74,9 +74,6 @@ export function rateLimit(options: RateLimitOptions = {}) {
74
74
  const max = options.max ?? 100;
75
75
  const store = options.store ?? new InMemoryRateLimitStore();
76
76
 
77
- if (options.strategy === "sliding") {
78
- throw new Error("Sliding window rate limiting is not yet implemented. Use 'fixed' (default).");
79
- }
80
77
  const keyFn =
81
78
  options.keyFn ??
82
79
  ((req: Request) => {
@@ -74,6 +74,8 @@ export class PostgresRateLimitStore implements RateLimitStore {
74
74
  const now = Date.now();
75
75
  const reset = now + windowMs;
76
76
 
77
+ await this.client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [key]);
78
+
77
79
  const { rows } = await this.client.query(
78
80
  `INSERT INTO ${this.tableName} (key, count, reset_at)
79
81
  VALUES ($1, 1, $2)
@@ -90,11 +92,6 @@ export class PostgresRateLimitStore implements RateLimitStore {
90
92
  [key, reset, now, reset]
91
93
  );
92
94
 
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
95
  const row = rows[0];
99
96
  if (!row) return { count: 0, reset: Date.now() + windowMs };
100
97
  return { count: row.count as number, reset: row.reset_at as number };
@@ -2,24 +2,27 @@ import { expect, test } from "bun:test";
2
2
  import { RedisApiKeyStore, type RedisClient } from "./redis-api-keys";
3
3
 
4
4
  function mockRedisClient(): RedisClient {
5
- const store = new Map<string, string>();
5
+ const hashes = new Map<string, Record<string, string>>();
6
6
  return {
7
7
  async get(key: string) {
8
- return store.get(key) ?? null;
8
+ const h = hashes.get(key);
9
+ if (!h) return null;
10
+ return JSON.stringify(h);
9
11
  },
10
12
  async hget(_key: string, _field: string) {
11
13
  return undefined;
12
14
  },
13
15
  async hgetall(key: string) {
14
- const val = store.get(key);
15
- if (!val) return null;
16
- return JSON.parse(val) as Record<string, string>;
16
+ return hashes.get(key) ?? null;
17
+ },
18
+ async hset(key: string, data: Record<string, string>) {
19
+ hashes.set(key, { ...hashes.get(key), ...data });
17
20
  },
18
21
  async set(key: string, value: string) {
19
- store.set(key, value);
22
+ hashes.set(key, JSON.parse(value) as Record<string, string>);
20
23
  },
21
24
  async del(key: string) {
22
- return store.delete(key) ? 1 : 0;
25
+ return hashes.delete(key) ? 1 : 0;
23
26
  },
24
27
  };
25
28
  }
@@ -4,6 +4,7 @@ export interface RedisClient {
4
4
  get(key: string): Promise<string | null>;
5
5
  hget(key: string, field: string): Promise<string | undefined>;
6
6
  hgetall(key: string): Promise<Record<string, string> | null>;
7
+ hset(key: string, data: Record<string, string>): Promise<unknown>;
7
8
  set(key: string, value: string, opts?: { ex?: number }): Promise<unknown>;
8
9
  del(key: string): Promise<number>;
9
10
  }
@@ -60,7 +61,7 @@ export class RedisApiKeyStore {
60
61
  const data: Record<string, string> = {};
61
62
  if (entry.scopes) data.scopes = JSON.stringify(entry.scopes);
62
63
  if (entry.metadata) data.metadata = JSON.stringify(entry.metadata);
63
- await this.client.set(`${this.keyPrefix}${keyHash}`, JSON.stringify(data));
64
+ await this.client.hset(`${this.keyPrefix}${keyHash}`, data);
64
65
  }
65
66
 
66
67
  async deleteKey(key: string): Promise<void> {