@joinremba/gate 0.1.0 → 0.2.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,10 @@ 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, extend with a DB-backed validator (e.g. query Postgres with `SELECT * FROM api_keys WHERE key_hash = $1`).
188
+
185
189
  ### Errors (`@joinremba/gate/errors`)
186
190
 
187
191
  Standard error types for consistent error handling.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinremba/gate",
3
- "version": "0.1.0",
3
+ "version": "0.2.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,16 @@
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/postgres": {
50
+ "types": "./src/stores/postgres.ts",
51
+ "import": "./src/stores/postgres.ts",
52
+ "default": "./src/stores/postgres.ts"
43
53
  }
44
54
  },
45
55
  "files": [
@@ -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,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
+ }