guardian-risk-redis 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 +38 -17
- package/dist/index.cjs +238 -6
- package/dist/index.d.cts +75 -13
- package/dist/index.d.ts +75 -13
- package/dist/index.js +167 -7
- package/package.json +11 -4
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
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
|
-
|
|
11
|
+
Session and rate-limit signals backed by Redis (or in-memory for development).
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
## Planned signals
|
|
13
|
+
## Signals
|
|
14
14
|
|
|
15
15
|
| Signal | Source |
|
|
16
16
|
|--------|--------|
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
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
|
-
##
|
|
24
|
+
## Production usage
|
|
23
25
|
|
|
24
26
|
```typescript
|
|
25
27
|
import { Guardian } from 'guardian-risk';
|
|
26
|
-
import { redisPlugin,
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
redisPlugin({
|
|
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
|
-
|
|
33
|
-
|
|
40
|
+
// On failed login:
|
|
41
|
+
await recordLoginAttempt(sessionId, store);
|
|
34
42
|
```
|
|
35
43
|
|
|
36
|
-
##
|
|
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
|
-
|
|
59
|
+
See [SECURITY.md](../../SECURITY.md).
|
package/dist/index.cjs
CHANGED
|
@@ -1,19 +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 {
|
|
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(
|
|
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(
|
|
13
|
-
|
|
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;
|
|
18
|
-
//# sourceMappingURL=index.cjs.map
|
|
19
|
-
//# sourceMappingURL=index.cjs.map
|
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
|
-
/**
|
|
5
|
-
interface
|
|
6
|
-
|
|
7
|
-
readonly
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
58
|
+
* Create a Redis session store.
|
|
59
|
+
* @throws When ioredis is unavailable and `allowInMemoryFallback` is false.
|
|
20
60
|
*/
|
|
21
|
-
declare function
|
|
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
|
-
/**
|
|
5
|
-
interface
|
|
6
|
-
|
|
7
|
-
readonly
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
58
|
+
* Create a Redis session store.
|
|
59
|
+
* @throws When ioredis is unavailable and `allowInMemoryFallback` is false.
|
|
20
60
|
*/
|
|
21
|
-
declare function
|
|
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,16 +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 {
|
|
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(
|
|
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(
|
|
11
|
-
|
|
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 };
|
|
15
|
-
//# sourceMappingURL=index.js.map
|
|
16
|
-
//# sourceMappingURL=index.js.map
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Redis plugin for guardian-risk — session counters and event history",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
},
|
|
21
21
|
"sideEffects": false,
|
|
22
22
|
"files": [
|
|
23
|
-
"dist",
|
|
23
|
+
"dist/index.js",
|
|
24
|
+
"dist/index.cjs",
|
|
25
|
+
"dist/index.d.ts",
|
|
26
|
+
"dist/index.d.cts",
|
|
24
27
|
"README.md",
|
|
25
28
|
"LICENSE"
|
|
26
29
|
],
|
|
@@ -35,6 +38,7 @@
|
|
|
35
38
|
"rate-limit",
|
|
36
39
|
"risk"
|
|
37
40
|
],
|
|
41
|
+
"author": "himanshuusinghh",
|
|
38
42
|
"license": "MIT",
|
|
39
43
|
"repository": {
|
|
40
44
|
"type": "git",
|
|
@@ -45,11 +49,12 @@
|
|
|
45
49
|
"bugs": {
|
|
46
50
|
"url": "https://github.com/himanshu6306singh/guardian-risk/issues"
|
|
47
51
|
},
|
|
52
|
+
"security": "https://github.com/himanshu6306singh/guardian-risk/security/policy",
|
|
48
53
|
"publishConfig": {
|
|
49
54
|
"access": "public"
|
|
50
55
|
},
|
|
51
56
|
"peerDependencies": {
|
|
52
|
-
"guardian-risk": "^0.
|
|
57
|
+
"guardian-risk": "^0.3.0",
|
|
53
58
|
"ioredis": ">=5"
|
|
54
59
|
},
|
|
55
60
|
"peerDependenciesMeta": {
|
|
@@ -60,10 +65,12 @@
|
|
|
60
65
|
"devDependencies": {
|
|
61
66
|
"tsup": "^8.3.5",
|
|
62
67
|
"typescript": "^5.7.2",
|
|
63
|
-
"
|
|
68
|
+
"vitest": "^4.1.9",
|
|
69
|
+
"guardian-risk": "0.3.0"
|
|
64
70
|
},
|
|
65
71
|
"scripts": {
|
|
66
72
|
"build": "tsup",
|
|
73
|
+
"test": "vitest run",
|
|
67
74
|
"typecheck": "tsc --noEmit"
|
|
68
75
|
}
|
|
69
76
|
}
|
package/dist/index.cjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AAgBO,SAAS,WAAA,CAAY,OAAA,GAA8B,EAAC,EAAW;AACpE,EAAA,MAAM,EAAE,GAAA,GAAM,wBAAA,EAA0B,SAAA,GAAY,aAAY,GAAI,OAAA;AAEpE,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,qBAAA;AAAA,IACN,QAAQ,SAAA,EAAW;AAEZ,IAEP;AAAA,GACF;AACF;AAKA,eAAsB,kBAAA,CACpB,YACA,QAAA,EAC2C;AAC3C,EAAA,OAAO,SACJ,MAAA,CAAO,cAAA,EAAgB,OAAO,CAAA,CAC9B,MAAA,CAAO,eAAe,MAAM,CAAA;AACjC","file":"index.cjs","sourcesContent":["import type { Plugin } from 'guardian-risk';\n\n/** Options for the Redis plugin (stub). */\nexport interface RedisPluginOptions {\n /** Redis connection URL. */\n readonly url?: string;\n /** Key prefix for Guardian counters. */\n readonly keyPrefix?: string;\n}\n\n/**\n * Redis plugin for guardian-risk.\n *\n * @stub Future versions will read/write session counters in Redis and\n * expose signals like `requestsPerMinute` and `sessionAge`.\n */\nexport function redisPlugin(options: RedisPluginOptions = {}): Plugin {\n const { url = 'redis://localhost:6379', keyPrefix = 'guardian:' } = options;\n\n return {\n name: 'guardian-risk-redis',\n install(_guardian) {\n void url;\n void keyPrefix;\n // Stub: will connect to Redis and sync counters into signals\n },\n };\n}\n\n/**\n * @stub Future helper to load session counters from Redis into signals.\n */\nexport async function loadSessionSignals(\n _sessionId: string,\n guardian: import('guardian-risk').Guardian,\n): Promise<import('guardian-risk').Guardian> {\n return guardian\n .signal('signalSource', 'redis')\n .signal('redisPlugin', 'stub');\n}\n"]}
|
package/dist/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";AAgBO,SAAS,WAAA,CAAY,OAAA,GAA8B,EAAC,EAAW;AACpE,EAAA,MAAM,EAAE,GAAA,GAAM,wBAAA,EAA0B,SAAA,GAAY,aAAY,GAAI,OAAA;AAEpE,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,qBAAA;AAAA,IACN,QAAQ,SAAA,EAAW;AAEZ,IAEP;AAAA,GACF;AACF;AAKA,eAAsB,kBAAA,CACpB,YACA,QAAA,EAC2C;AAC3C,EAAA,OAAO,SACJ,MAAA,CAAO,cAAA,EAAgB,OAAO,CAAA,CAC9B,MAAA,CAAO,eAAe,MAAM,CAAA;AACjC","file":"index.js","sourcesContent":["import type { Plugin } from 'guardian-risk';\n\n/** Options for the Redis plugin (stub). */\nexport interface RedisPluginOptions {\n /** Redis connection URL. */\n readonly url?: string;\n /** Key prefix for Guardian counters. */\n readonly keyPrefix?: string;\n}\n\n/**\n * Redis plugin for guardian-risk.\n *\n * @stub Future versions will read/write session counters in Redis and\n * expose signals like `requestsPerMinute` and `sessionAge`.\n */\nexport function redisPlugin(options: RedisPluginOptions = {}): Plugin {\n const { url = 'redis://localhost:6379', keyPrefix = 'guardian:' } = options;\n\n return {\n name: 'guardian-risk-redis',\n install(_guardian) {\n void url;\n void keyPrefix;\n // Stub: will connect to Redis and sync counters into signals\n },\n };\n}\n\n/**\n * @stub Future helper to load session counters from Redis into signals.\n */\nexport async function loadSessionSignals(\n _sessionId: string,\n guardian: import('guardian-risk').Guardian,\n): Promise<import('guardian-risk').Guardian> {\n return guardian\n .signal('signalSource', 'redis')\n .signal('redisPlugin', 'stub');\n}\n"]}
|