guardian-risk-redis 0.1.1 → 0.2.1

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
@@ -4,35 +4,56 @@
4
4
 
5
5
  ```bash
6
6
  npm install guardian-risk guardian-risk-redis
7
+ # optional peer for real Redis:
8
+ npm install ioredis
7
9
  ```
8
10
 
9
- > **Stub package** API may change before `1.0.0`.
11
+ Session and rate-limit signals backed by Redis (or in-memory for development).
10
12
 
11
- Redis integration for [guardian-risk](https://www.npmjs.com/package/guardian-risk). Stores events and exposes session-based counters as signals.
12
-
13
- ## Planned signals
13
+ ## Signals
14
14
 
15
15
  | Signal | Source |
16
16
  |--------|--------|
17
- | `requestsPerMinute` | Sliding window counter |
18
- | `sessionAge` | First-seen timestamp |
19
- | `failedLoginCount` | Incremented on auth failures |
20
- | `uniqueIpsPerSession` | HyperLogLog or set cardinality |
17
+ | `sessionId` | Sanitized session header or `anonymous` |
18
+ | `requestsInWindow` | Atomic counter in sliding window |
19
+ | `requestsPerMinute` | Same as `requestsInWindow` (compat alias) |
20
+ | `loginAttempts` | Incremented via `recordLoginAttempt()` |
21
+ | `sessionAgeSeconds` | Age since session creation or window start |
22
+ | `signalSource` | Always `'session'` |
21
23
 
22
- ## Usage (stub)
24
+ ## Production usage
23
25
 
24
26
  ```typescript
25
27
  import { Guardian } from 'guardian-risk';
26
- import { redisPlugin, loadSessionSignals } from 'guardian-risk-redis';
27
-
28
- const guardian = new Guardian().use(
29
- redisPlugin({ url: process.env.REDIS_URL, keyPrefix: 'app:risk:' }),
28
+ import { redisPlugin, recordLoginAttempt } from 'guardian-risk-redis';
29
+
30
+ const template = new Guardian().use(
31
+ redisPlugin({
32
+ url: process.env.REDIS_URL,
33
+ keyPrefix: 'myapp:risk:',
34
+ sessionIdHeader: 'x-session-id',
35
+ allowInMemoryFallback: false, // default — fail loud if Redis unavailable
36
+ rateLimitByIpWhenNoSession: true,
37
+ }),
30
38
  );
31
39
 
32
- await loadSessionSignals('session-123', guardian);
33
- const report = guardian.analyze();
40
+ // On failed login:
41
+ await recordLoginAttempt(sessionId, store);
34
42
  ```
35
43
 
36
- ## Status
44
+ ## Security notes
45
+
46
+ - **`x-session-id` is client-supplied** — bind it to your server session in production.
47
+ - Session IDs are **sanitized** (length + charset); invalid IDs are ignored.
48
+ - When no session is present, rate limiting falls back to **validated `clientIp`** (from express plugin).
49
+ - **`allowInMemoryFallback` defaults to `false`** — `createRedisStore()` throws if `ioredis` is missing.
50
+ - Do not use in-memory store in multi-instance deployments.
51
+
52
+ ## API
53
+
54
+ - `redisPlugin(options)` — `beforeAnalyze` hook
55
+ - `loadSessionSignals(sessionId, guardian, options)` — manual preload
56
+ - `recordLoginAttempt(sessionId, store?)` — increment login counter
57
+ - `createRedisStore({ url, keyPrefix, allowInMemoryFallback })` — standalone store
37
58
 
38
- Not yet published. Implementation in progress.
59
+ See [SECURITY.md](../../SECURITY.md).
package/dist/index.cjs CHANGED
@@ -1,17 +1,251 @@
1
1
  'use strict';
2
2
 
3
+ var guardianRisk = require('guardian-risk');
4
+
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __esm = (fn, res, err) => function __init() {
8
+ if (err) throw err[0];
9
+ try {
10
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
11
+ } catch (e) {
12
+ throw err = [e], e;
13
+ }
14
+ };
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
19
+
20
+ // src/sessionStore.ts
21
+ var sessionStore_exports = {};
22
+ __export(sessionStore_exports, {
23
+ InMemorySessionStore: () => exports.InMemorySessionStore,
24
+ defaultSessionStore: () => exports.defaultSessionStore
25
+ });
26
+ exports.InMemorySessionStore = void 0; exports.defaultSessionStore = void 0;
27
+ var init_sessionStore = __esm({
28
+ "src/sessionStore.ts"() {
29
+ exports.InMemorySessionStore = class {
30
+ requests = /* @__PURE__ */ new Map();
31
+ logins = /* @__PURE__ */ new Map();
32
+ maxEntries;
33
+ constructor(maxEntries = 1e4) {
34
+ this.maxEntries = maxEntries;
35
+ }
36
+ async incrementRequests(sessionId, windowMs) {
37
+ this.evictIfNeeded(this.requests);
38
+ const now = Date.now();
39
+ let entry = this.requests.get(sessionId);
40
+ if (!entry || now - entry.windowStart > windowMs) {
41
+ entry = { count: 0, windowStart: now };
42
+ }
43
+ entry.count += 1;
44
+ this.requests.set(sessionId, entry);
45
+ return {
46
+ requestsInWindow: entry.count,
47
+ windowStartedAt: entry.windowStart,
48
+ loginAttempts: this.logins.get(sessionId) ?? 0
49
+ };
50
+ }
51
+ async incrementLoginAttempts(sessionId) {
52
+ const next = (this.logins.get(sessionId) ?? 0) + 1;
53
+ this.logins.set(sessionId, next);
54
+ return next;
55
+ }
56
+ async getLoginAttempts(sessionId) {
57
+ return this.logins.get(sessionId) ?? 0;
58
+ }
59
+ evictIfNeeded(map) {
60
+ if (map.size < this.maxEntries) {
61
+ return;
62
+ }
63
+ const firstKey = map.keys().next().value;
64
+ if (firstKey) {
65
+ map.delete(firstKey);
66
+ }
67
+ }
68
+ };
69
+ exports.defaultSessionStore = new exports.InMemorySessionStore();
70
+ }
71
+ });
72
+
3
73
  // src/index.ts
74
+ init_sessionStore();
75
+
76
+ // src/redisStore.ts
77
+ var RedisSessionStore = class {
78
+ constructor(client, keyPrefix) {
79
+ this.client = client;
80
+ this.keyPrefix = keyPrefix;
81
+ }
82
+ client;
83
+ keyPrefix;
84
+ async incrementRequests(sessionId, windowMs) {
85
+ const windowSlot = Math.floor(Date.now() / windowMs);
86
+ const requestKey = `${this.keyPrefix}req:${sessionId}:${windowSlot}`;
87
+ const loginKey = `${this.keyPrefix}login:${sessionId}`;
88
+ const ttlSeconds = Math.max(1, Math.ceil(windowMs / 1e3) + 1);
89
+ const count = await this.client.incr(requestKey);
90
+ await this.client.expire(requestKey, ttlSeconds);
91
+ return {
92
+ requestsInWindow: count,
93
+ windowStartedAt: windowSlot * windowMs,
94
+ loginAttempts: await this.readLoginAttempts(loginKey)
95
+ };
96
+ }
97
+ async incrementLoginAttempts(sessionId) {
98
+ const loginKey = `${this.keyPrefix}login:${sessionId}`;
99
+ return this.client.incr(loginKey);
100
+ }
101
+ async getLoginAttempts(sessionId) {
102
+ return this.readLoginAttempts(`${this.keyPrefix}login:${sessionId}`);
103
+ }
104
+ async readLoginAttempts(loginKey) {
105
+ const raw = await this.client.get(loginKey);
106
+ return raw ? Number(raw) : 0;
107
+ }
108
+ };
109
+ async function createRedisStore(urlOrOptions, legacyKeyPrefix = "guardian:") {
110
+ const options = typeof urlOrOptions === "string" ? { url: urlOrOptions, keyPrefix: legacyKeyPrefix } : urlOrOptions;
111
+ const { url, keyPrefix = "guardian:", allowInMemoryFallback = false } = options;
112
+ const { InMemorySessionStore: InMemorySessionStore2 } = await Promise.resolve().then(() => (init_sessionStore(), sessionStore_exports));
113
+ try {
114
+ const module = await import('ioredis');
115
+ const client = new module.default(url);
116
+ return new RedisSessionStore(client, keyPrefix);
117
+ } catch (error) {
118
+ if (allowInMemoryFallback) {
119
+ return new InMemorySessionStore2();
120
+ }
121
+ const message = error instanceof Error ? error.message : "unknown error";
122
+ throw new Error(
123
+ `Failed to connect Redis store. Install ioredis or set allowInMemoryFallback: true. ${message}`
124
+ );
125
+ }
126
+ }
127
+
128
+ // src/index.ts
129
+ var DEFAULT_WINDOW_MS = 6e4;
130
+ var storePromises = /* @__PURE__ */ new Map();
4
131
  function redisPlugin(options = {}) {
5
- const { url = "redis://localhost:6379", keyPrefix = "guardian:" } = options;
132
+ const {
133
+ windowMs = DEFAULT_WINDOW_MS,
134
+ sessionIdHeader = "x-session-id",
135
+ keyPrefix = "guardian:",
136
+ url,
137
+ store: providedStore,
138
+ rateLimitByIpWhenNoSession = true,
139
+ allowInMemoryFallback = false
140
+ } = options;
6
141
  return {
7
142
  name: "guardian-risk-redis",
8
- install(_guardian) {
143
+ install(guardian) {
144
+ guardian.beforeAnalyze(async ({ data, guardian: g }) => {
145
+ const sessionId = resolveSessionId(data, sessionIdHeader);
146
+ const store = await resolvePluginStore({
147
+ providedStore,
148
+ url,
149
+ keyPrefix,
150
+ allowInMemoryFallback
151
+ });
152
+ if (sessionId) {
153
+ await applySessionSignals(sessionId, g, store, windowMs);
154
+ return;
155
+ }
156
+ if (rateLimitByIpWhenNoSession) {
157
+ const ip = readValidatedClientIp(g, data);
158
+ if (ip) {
159
+ await applySessionSignals(`ip:${ip}`, g, store, windowMs);
160
+ }
161
+ }
162
+ });
9
163
  }
10
164
  };
11
165
  }
12
- async function loadSessionSignals(_sessionId, guardian) {
13
- return guardian.signal("signalSource", "redis").signal("redisPlugin", "stub");
166
+ async function loadSessionSignals(sessionId, guardian, options = {}) {
167
+ const sanitized = guardianRisk.sanitizeSessionId(sessionId);
168
+ if (!sanitized) {
169
+ throw new TypeError("Invalid session ID");
170
+ }
171
+ const store = await resolvePluginStore({
172
+ providedStore: options.store,
173
+ url: options.url,
174
+ keyPrefix: options.keyPrefix ?? "guardian:",
175
+ allowInMemoryFallback: options.allowInMemoryFallback ?? false
176
+ });
177
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
178
+ await applySessionSignals(sanitized, guardian, store, windowMs, options.sessionCreatedAt);
179
+ return guardian;
180
+ }
181
+ async function recordLoginAttempt(sessionId, store = exports.defaultSessionStore) {
182
+ const sanitized = guardianRisk.sanitizeSessionId(sessionId);
183
+ if (!sanitized) {
184
+ throw new TypeError("Invalid session ID");
185
+ }
186
+ return store.incrementLoginAttempts(sanitized);
187
+ }
188
+ async function applySessionSignals(counterKey, guardian, store, windowMs, sessionCreatedAt) {
189
+ const snapshot = await store.incrementRequests(counterKey, windowMs);
190
+ const sessionAgeSeconds = sessionCreatedAt ? Math.max(0, Math.floor((Date.now() - sessionCreatedAt) / 1e3)) : Math.max(0, Math.floor((Date.now() - snapshot.windowStartedAt) / 1e3));
191
+ guardian.signal("sessionId", counterKey.startsWith("ip:") ? "anonymous" : counterKey).signal("requestsInWindow", snapshot.requestsInWindow).signal("requestsPerMinute", snapshot.requestsInWindow).signal("loginAttempts", snapshot.loginAttempts).signal("sessionAgeSeconds", sessionAgeSeconds).signal("signalSource", "session");
192
+ }
193
+ function resolveSessionId(data, headerName) {
194
+ if (data !== null && typeof data === "object" && "sessionId" in data) {
195
+ const value = data.sessionId;
196
+ if (typeof value === "string") {
197
+ return guardianRisk.sanitizeSessionId(value) ?? void 0;
198
+ }
199
+ }
200
+ if (data !== null && typeof data === "object" && "headers" in data) {
201
+ const headers = data.headers;
202
+ const raw = headers[headerName] ?? headers[headerName.toLowerCase()];
203
+ const candidate = Array.isArray(raw) ? raw[0] : raw;
204
+ if (typeof candidate === "string") {
205
+ return guardianRisk.sanitizeSessionId(candidate) ?? void 0;
206
+ }
207
+ }
208
+ return void 0;
209
+ }
210
+ function readValidatedClientIp(guardian, data) {
211
+ const fromSignal = guardian.getSignal("clientIp");
212
+ if (typeof fromSignal === "string") {
213
+ const parsed = guardianRisk.parseIpAddress(fromSignal);
214
+ if (parsed) {
215
+ return parsed;
216
+ }
217
+ }
218
+ if (data !== null && typeof data === "object" && "ip" in data) {
219
+ const ip = data.ip;
220
+ if (typeof ip === "string") {
221
+ return guardianRisk.parseIpAddress(ip);
222
+ }
223
+ }
224
+ return null;
225
+ }
226
+ async function resolvePluginStore(options) {
227
+ if (options.providedStore) {
228
+ return options.providedStore;
229
+ }
230
+ if (options.url) {
231
+ const cacheKey = `${options.url}::${options.keyPrefix}`;
232
+ if (!storePromises.has(cacheKey)) {
233
+ storePromises.set(
234
+ cacheKey,
235
+ createRedisStore({
236
+ url: options.url,
237
+ keyPrefix: options.keyPrefix,
238
+ allowInMemoryFallback: options.allowInMemoryFallback
239
+ })
240
+ );
241
+ }
242
+ return storePromises.get(cacheKey);
243
+ }
244
+ return exports.defaultSessionStore;
14
245
  }
15
246
 
247
+ exports.RedisSessionStore = RedisSessionStore;
248
+ exports.createRedisStore = createRedisStore;
16
249
  exports.loadSessionSignals = loadSessionSignals;
250
+ exports.recordLoginAttempt = recordLoginAttempt;
17
251
  exports.redisPlugin = redisPlugin;
package/dist/index.d.cts CHANGED
@@ -1,23 +1,85 @@
1
1
  import * as guardian_risk from 'guardian-risk';
2
2
  import { Plugin } from 'guardian-risk';
3
3
 
4
- /** Options for the Redis plugin (stub). */
5
- interface RedisPluginOptions {
6
- /** Redis connection URL. */
7
- readonly url?: string;
8
- /** Key prefix for Guardian counters. */
4
+ /** In-memory session counter store (single-node). Swap for Redis in production. */
5
+ interface SessionSnapshot {
6
+ readonly requestsInWindow: number;
7
+ readonly windowStartedAt: number;
8
+ readonly loginAttempts: number;
9
+ }
10
+ interface SessionStore {
11
+ incrementRequests(sessionId: string, windowMs: number): Promise<SessionSnapshot>;
12
+ incrementLoginAttempts(sessionId: string): Promise<number>;
13
+ getLoginAttempts(sessionId: string): Promise<number>;
14
+ }
15
+ declare class InMemorySessionStore implements SessionStore {
16
+ private readonly requests;
17
+ private readonly logins;
18
+ private readonly maxEntries;
19
+ constructor(maxEntries?: number);
20
+ incrementRequests(sessionId: string, windowMs: number): Promise<SessionSnapshot>;
21
+ incrementLoginAttempts(sessionId: string): Promise<number>;
22
+ getLoginAttempts(sessionId: string): Promise<number>;
23
+ private evictIfNeeded;
24
+ }
25
+ /** Shared default store for single-process apps and tests. */
26
+ declare const defaultSessionStore: InMemorySessionStore;
27
+
28
+ /** Minimal Redis client surface used by Guardian. */
29
+ interface RedisClientLike {
30
+ incr(key: string): Promise<number>;
31
+ expire(key: string, seconds: number): Promise<number>;
32
+ get(key: string): Promise<string | null>;
33
+ set(key: string, value: string): Promise<unknown>;
34
+ quit(): Promise<unknown>;
35
+ }
36
+ interface CreateRedisStoreOptions {
37
+ readonly url: string;
9
38
  readonly keyPrefix?: string;
39
+ /**
40
+ * When true, falls back to in-memory if ioredis is unavailable.
41
+ * Default: false (throws on failure — recommended for production).
42
+ */
43
+ readonly allowInMemoryFallback?: boolean;
10
44
  }
11
45
  /**
12
- * Redis plugin for guardian-risk.
13
- *
14
- * @stub Future versions will read/write session counters in Redis and
15
- * expose signals like `requestsPerMinute` and `sessionAge`.
46
+ * Redis-backed session store with atomic windowed counters.
16
47
  */
17
- declare function redisPlugin(options?: RedisPluginOptions): Plugin;
48
+ declare class RedisSessionStore implements SessionStore {
49
+ private readonly client;
50
+ private readonly keyPrefix;
51
+ constructor(client: RedisClientLike, keyPrefix: string);
52
+ incrementRequests(sessionId: string, windowMs: number): Promise<SessionSnapshot>;
53
+ incrementLoginAttempts(sessionId: string): Promise<number>;
54
+ getLoginAttempts(sessionId: string): Promise<number>;
55
+ private readLoginAttempts;
56
+ }
18
57
  /**
19
- * @stub Future helper to load session counters from Redis into signals.
58
+ * Create a Redis session store.
59
+ * @throws When ioredis is unavailable and `allowInMemoryFallback` is false.
20
60
  */
21
- declare function loadSessionSignals(_sessionId: string, guardian: guardian_risk.Guardian): Promise<guardian_risk.Guardian>;
61
+ declare function createRedisStore(urlOrOptions: string | CreateRedisStoreOptions, legacyKeyPrefix?: string): Promise<SessionStore>;
62
+
63
+ /** Options for the Redis / session plugin. */
64
+ interface RedisPluginOptions {
65
+ readonly url?: string;
66
+ readonly keyPrefix?: string;
67
+ readonly windowMs?: number;
68
+ readonly sessionIdHeader?: string;
69
+ readonly store?: SessionStore;
70
+ /** Rate-limit by validated client IP when no session ID is present (default: true). */
71
+ readonly rateLimitByIpWhenNoSession?: boolean;
72
+ /** Only used with `url` — default false (fail loud in production). */
73
+ readonly allowInMemoryFallback?: boolean;
74
+ }
75
+ declare function redisPlugin(options?: RedisPluginOptions): Plugin;
76
+ interface SessionContext {
77
+ readonly sessionId: string;
78
+ readonly sessionCreatedAt?: number;
79
+ }
80
+ declare function loadSessionSignals(sessionId: string, guardian: guardian_risk.Guardian, options?: RedisPluginOptions & {
81
+ sessionCreatedAt?: number;
82
+ }): Promise<guardian_risk.Guardian>;
83
+ declare function recordLoginAttempt(sessionId: string, store?: SessionStore): Promise<number>;
22
84
 
23
- export { type RedisPluginOptions, loadSessionSignals, redisPlugin };
85
+ export { type CreateRedisStoreOptions, InMemorySessionStore, type RedisClientLike, type RedisPluginOptions, RedisSessionStore, type SessionContext, type SessionSnapshot, type SessionStore, createRedisStore, defaultSessionStore, loadSessionSignals, recordLoginAttempt, redisPlugin };
package/dist/index.d.ts CHANGED
@@ -1,23 +1,85 @@
1
1
  import * as guardian_risk from 'guardian-risk';
2
2
  import { Plugin } from 'guardian-risk';
3
3
 
4
- /** Options for the Redis plugin (stub). */
5
- interface RedisPluginOptions {
6
- /** Redis connection URL. */
7
- readonly url?: string;
8
- /** Key prefix for Guardian counters. */
4
+ /** In-memory session counter store (single-node). Swap for Redis in production. */
5
+ interface SessionSnapshot {
6
+ readonly requestsInWindow: number;
7
+ readonly windowStartedAt: number;
8
+ readonly loginAttempts: number;
9
+ }
10
+ interface SessionStore {
11
+ incrementRequests(sessionId: string, windowMs: number): Promise<SessionSnapshot>;
12
+ incrementLoginAttempts(sessionId: string): Promise<number>;
13
+ getLoginAttempts(sessionId: string): Promise<number>;
14
+ }
15
+ declare class InMemorySessionStore implements SessionStore {
16
+ private readonly requests;
17
+ private readonly logins;
18
+ private readonly maxEntries;
19
+ constructor(maxEntries?: number);
20
+ incrementRequests(sessionId: string, windowMs: number): Promise<SessionSnapshot>;
21
+ incrementLoginAttempts(sessionId: string): Promise<number>;
22
+ getLoginAttempts(sessionId: string): Promise<number>;
23
+ private evictIfNeeded;
24
+ }
25
+ /** Shared default store for single-process apps and tests. */
26
+ declare const defaultSessionStore: InMemorySessionStore;
27
+
28
+ /** Minimal Redis client surface used by Guardian. */
29
+ interface RedisClientLike {
30
+ incr(key: string): Promise<number>;
31
+ expire(key: string, seconds: number): Promise<number>;
32
+ get(key: string): Promise<string | null>;
33
+ set(key: string, value: string): Promise<unknown>;
34
+ quit(): Promise<unknown>;
35
+ }
36
+ interface CreateRedisStoreOptions {
37
+ readonly url: string;
9
38
  readonly keyPrefix?: string;
39
+ /**
40
+ * When true, falls back to in-memory if ioredis is unavailable.
41
+ * Default: false (throws on failure — recommended for production).
42
+ */
43
+ readonly allowInMemoryFallback?: boolean;
10
44
  }
11
45
  /**
12
- * Redis plugin for guardian-risk.
13
- *
14
- * @stub Future versions will read/write session counters in Redis and
15
- * expose signals like `requestsPerMinute` and `sessionAge`.
46
+ * Redis-backed session store with atomic windowed counters.
16
47
  */
17
- declare function redisPlugin(options?: RedisPluginOptions): Plugin;
48
+ declare class RedisSessionStore implements SessionStore {
49
+ private readonly client;
50
+ private readonly keyPrefix;
51
+ constructor(client: RedisClientLike, keyPrefix: string);
52
+ incrementRequests(sessionId: string, windowMs: number): Promise<SessionSnapshot>;
53
+ incrementLoginAttempts(sessionId: string): Promise<number>;
54
+ getLoginAttempts(sessionId: string): Promise<number>;
55
+ private readLoginAttempts;
56
+ }
18
57
  /**
19
- * @stub Future helper to load session counters from Redis into signals.
58
+ * Create a Redis session store.
59
+ * @throws When ioredis is unavailable and `allowInMemoryFallback` is false.
20
60
  */
21
- declare function loadSessionSignals(_sessionId: string, guardian: guardian_risk.Guardian): Promise<guardian_risk.Guardian>;
61
+ declare function createRedisStore(urlOrOptions: string | CreateRedisStoreOptions, legacyKeyPrefix?: string): Promise<SessionStore>;
62
+
63
+ /** Options for the Redis / session plugin. */
64
+ interface RedisPluginOptions {
65
+ readonly url?: string;
66
+ readonly keyPrefix?: string;
67
+ readonly windowMs?: number;
68
+ readonly sessionIdHeader?: string;
69
+ readonly store?: SessionStore;
70
+ /** Rate-limit by validated client IP when no session ID is present (default: true). */
71
+ readonly rateLimitByIpWhenNoSession?: boolean;
72
+ /** Only used with `url` — default false (fail loud in production). */
73
+ readonly allowInMemoryFallback?: boolean;
74
+ }
75
+ declare function redisPlugin(options?: RedisPluginOptions): Plugin;
76
+ interface SessionContext {
77
+ readonly sessionId: string;
78
+ readonly sessionCreatedAt?: number;
79
+ }
80
+ declare function loadSessionSignals(sessionId: string, guardian: guardian_risk.Guardian, options?: RedisPluginOptions & {
81
+ sessionCreatedAt?: number;
82
+ }): Promise<guardian_risk.Guardian>;
83
+ declare function recordLoginAttempt(sessionId: string, store?: SessionStore): Promise<number>;
22
84
 
23
- export { type RedisPluginOptions, loadSessionSignals, redisPlugin };
85
+ export { type CreateRedisStoreOptions, InMemorySessionStore, type RedisClientLike, type RedisPluginOptions, RedisSessionStore, type SessionContext, type SessionSnapshot, type SessionStore, createRedisStore, defaultSessionStore, loadSessionSignals, recordLoginAttempt, redisPlugin };
package/dist/index.js CHANGED
@@ -1,14 +1,176 @@
1
+ import { defaultSessionStore } from './chunk-E6WVCFQU.js';
2
+ export { InMemorySessionStore, defaultSessionStore } from './chunk-E6WVCFQU.js';
3
+ import { sanitizeSessionId, parseIpAddress } from 'guardian-risk';
4
+
5
+ // src/redisStore.ts
6
+ var RedisSessionStore = class {
7
+ constructor(client, keyPrefix) {
8
+ this.client = client;
9
+ this.keyPrefix = keyPrefix;
10
+ }
11
+ client;
12
+ keyPrefix;
13
+ async incrementRequests(sessionId, windowMs) {
14
+ const windowSlot = Math.floor(Date.now() / windowMs);
15
+ const requestKey = `${this.keyPrefix}req:${sessionId}:${windowSlot}`;
16
+ const loginKey = `${this.keyPrefix}login:${sessionId}`;
17
+ const ttlSeconds = Math.max(1, Math.ceil(windowMs / 1e3) + 1);
18
+ const count = await this.client.incr(requestKey);
19
+ await this.client.expire(requestKey, ttlSeconds);
20
+ return {
21
+ requestsInWindow: count,
22
+ windowStartedAt: windowSlot * windowMs,
23
+ loginAttempts: await this.readLoginAttempts(loginKey)
24
+ };
25
+ }
26
+ async incrementLoginAttempts(sessionId) {
27
+ const loginKey = `${this.keyPrefix}login:${sessionId}`;
28
+ return this.client.incr(loginKey);
29
+ }
30
+ async getLoginAttempts(sessionId) {
31
+ return this.readLoginAttempts(`${this.keyPrefix}login:${sessionId}`);
32
+ }
33
+ async readLoginAttempts(loginKey) {
34
+ const raw = await this.client.get(loginKey);
35
+ return raw ? Number(raw) : 0;
36
+ }
37
+ };
38
+ async function createRedisStore(urlOrOptions, legacyKeyPrefix = "guardian:") {
39
+ const options = typeof urlOrOptions === "string" ? { url: urlOrOptions, keyPrefix: legacyKeyPrefix } : urlOrOptions;
40
+ const { url, keyPrefix = "guardian:", allowInMemoryFallback = false } = options;
41
+ const { InMemorySessionStore: InMemorySessionStore2 } = await import('./sessionStore-KDK2DHV7.js');
42
+ try {
43
+ const module = await import('ioredis');
44
+ const client = new module.default(url);
45
+ return new RedisSessionStore(client, keyPrefix);
46
+ } catch (error) {
47
+ if (allowInMemoryFallback) {
48
+ return new InMemorySessionStore2();
49
+ }
50
+ const message = error instanceof Error ? error.message : "unknown error";
51
+ throw new Error(
52
+ `Failed to connect Redis store. Install ioredis or set allowInMemoryFallback: true. ${message}`
53
+ );
54
+ }
55
+ }
56
+
1
57
  // src/index.ts
58
+ var DEFAULT_WINDOW_MS = 6e4;
59
+ var storePromises = /* @__PURE__ */ new Map();
2
60
  function redisPlugin(options = {}) {
3
- const { url = "redis://localhost:6379", keyPrefix = "guardian:" } = options;
61
+ const {
62
+ windowMs = DEFAULT_WINDOW_MS,
63
+ sessionIdHeader = "x-session-id",
64
+ keyPrefix = "guardian:",
65
+ url,
66
+ store: providedStore,
67
+ rateLimitByIpWhenNoSession = true,
68
+ allowInMemoryFallback = false
69
+ } = options;
4
70
  return {
5
71
  name: "guardian-risk-redis",
6
- install(_guardian) {
72
+ install(guardian) {
73
+ guardian.beforeAnalyze(async ({ data, guardian: g }) => {
74
+ const sessionId = resolveSessionId(data, sessionIdHeader);
75
+ const store = await resolvePluginStore({
76
+ providedStore,
77
+ url,
78
+ keyPrefix,
79
+ allowInMemoryFallback
80
+ });
81
+ if (sessionId) {
82
+ await applySessionSignals(sessionId, g, store, windowMs);
83
+ return;
84
+ }
85
+ if (rateLimitByIpWhenNoSession) {
86
+ const ip = readValidatedClientIp(g, data);
87
+ if (ip) {
88
+ await applySessionSignals(`ip:${ip}`, g, store, windowMs);
89
+ }
90
+ }
91
+ });
7
92
  }
8
93
  };
9
94
  }
10
- async function loadSessionSignals(_sessionId, guardian) {
11
- return guardian.signal("signalSource", "redis").signal("redisPlugin", "stub");
95
+ async function loadSessionSignals(sessionId, guardian, options = {}) {
96
+ const sanitized = sanitizeSessionId(sessionId);
97
+ if (!sanitized) {
98
+ throw new TypeError("Invalid session ID");
99
+ }
100
+ const store = await resolvePluginStore({
101
+ providedStore: options.store,
102
+ url: options.url,
103
+ keyPrefix: options.keyPrefix ?? "guardian:",
104
+ allowInMemoryFallback: options.allowInMemoryFallback ?? false
105
+ });
106
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
107
+ await applySessionSignals(sanitized, guardian, store, windowMs, options.sessionCreatedAt);
108
+ return guardian;
109
+ }
110
+ async function recordLoginAttempt(sessionId, store = defaultSessionStore) {
111
+ const sanitized = sanitizeSessionId(sessionId);
112
+ if (!sanitized) {
113
+ throw new TypeError("Invalid session ID");
114
+ }
115
+ return store.incrementLoginAttempts(sanitized);
116
+ }
117
+ async function applySessionSignals(counterKey, guardian, store, windowMs, sessionCreatedAt) {
118
+ const snapshot = await store.incrementRequests(counterKey, windowMs);
119
+ const sessionAgeSeconds = sessionCreatedAt ? Math.max(0, Math.floor((Date.now() - sessionCreatedAt) / 1e3)) : Math.max(0, Math.floor((Date.now() - snapshot.windowStartedAt) / 1e3));
120
+ guardian.signal("sessionId", counterKey.startsWith("ip:") ? "anonymous" : counterKey).signal("requestsInWindow", snapshot.requestsInWindow).signal("requestsPerMinute", snapshot.requestsInWindow).signal("loginAttempts", snapshot.loginAttempts).signal("sessionAgeSeconds", sessionAgeSeconds).signal("signalSource", "session");
121
+ }
122
+ function resolveSessionId(data, headerName) {
123
+ if (data !== null && typeof data === "object" && "sessionId" in data) {
124
+ const value = data.sessionId;
125
+ if (typeof value === "string") {
126
+ return sanitizeSessionId(value) ?? void 0;
127
+ }
128
+ }
129
+ if (data !== null && typeof data === "object" && "headers" in data) {
130
+ const headers = data.headers;
131
+ const raw = headers[headerName] ?? headers[headerName.toLowerCase()];
132
+ const candidate = Array.isArray(raw) ? raw[0] : raw;
133
+ if (typeof candidate === "string") {
134
+ return sanitizeSessionId(candidate) ?? void 0;
135
+ }
136
+ }
137
+ return void 0;
138
+ }
139
+ function readValidatedClientIp(guardian, data) {
140
+ const fromSignal = guardian.getSignal("clientIp");
141
+ if (typeof fromSignal === "string") {
142
+ const parsed = parseIpAddress(fromSignal);
143
+ if (parsed) {
144
+ return parsed;
145
+ }
146
+ }
147
+ if (data !== null && typeof data === "object" && "ip" in data) {
148
+ const ip = data.ip;
149
+ if (typeof ip === "string") {
150
+ return parseIpAddress(ip);
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ async function resolvePluginStore(options) {
156
+ if (options.providedStore) {
157
+ return options.providedStore;
158
+ }
159
+ if (options.url) {
160
+ const cacheKey = `${options.url}::${options.keyPrefix}`;
161
+ if (!storePromises.has(cacheKey)) {
162
+ storePromises.set(
163
+ cacheKey,
164
+ createRedisStore({
165
+ url: options.url,
166
+ keyPrefix: options.keyPrefix,
167
+ allowInMemoryFallback: options.allowInMemoryFallback
168
+ })
169
+ );
170
+ }
171
+ return storePromises.get(cacheKey);
172
+ }
173
+ return defaultSessionStore;
12
174
  }
13
175
 
14
- export { loadSessionSignals, redisPlugin };
176
+ export { RedisSessionStore, createRedisStore, loadSessionSignals, recordLoginAttempt, redisPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardian-risk-redis",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Redis plugin for guardian-risk — session counters and event history",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -54,7 +54,7 @@
54
54
  "access": "public"
55
55
  },
56
56
  "peerDependencies": {
57
- "guardian-risk": "^0.2.0",
57
+ "guardian-risk": "^0.3.1",
58
58
  "ioredis": ">=5"
59
59
  },
60
60
  "peerDependenciesMeta": {
@@ -65,10 +65,12 @@
65
65
  "devDependencies": {
66
66
  "tsup": "^8.3.5",
67
67
  "typescript": "^5.7.2",
68
- "guardian-risk": "0.2.1"
68
+ "vitest": "^4.1.9",
69
+ "guardian-risk": "0.3.1"
69
70
  },
70
71
  "scripts": {
71
72
  "build": "tsup",
73
+ "test": "vitest run",
72
74
  "typecheck": "tsc --noEmit"
73
75
  }
74
76
  }