@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 +5 -1
- package/package.json +11 -1
- package/src/stores/postgres.test.ts +101 -0
- package/src/stores/postgres.ts +100 -0
- package/src/stores/redis.test.ts +77 -0
- package/src/stores/redis.ts +60 -0
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
|
-
|
|
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.
|
|
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
|
+
}
|