@heavybit/pendoadmin-shared-lib 1.7.0 → 1.8.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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/rate-limit/create-rate-limiter.d.ts +14 -1
- package/dist/rate-limit/create-rate-limiter.d.ts.map +1 -1
- package/dist/rate-limit/create-rate-limiter.js +17 -1
- package/dist/rate-limit/create-rate-limiter.js.map +1 -1
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/index.d.ts.map +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/rate-limit/index.js.map +1 -1
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/index.d.ts.map +1 -0
- package/dist/redis/index.js +2 -0
- package/dist/redis/index.js.map +1 -0
- package/dist/redis/keyspace.d.ts +46 -0
- package/dist/redis/keyspace.d.ts.map +1 -0
- package/dist/redis/keyspace.js +82 -0
- package/dist/redis/keyspace.js.map +1 -0
- package/package.json +6 -2
- package/src/index.ts +3 -0
- package/src/rate-limit/create-rate-limiter.ts +31 -2
- package/src/rate-limit/index.ts +1 -1
- package/src/redis/index.ts +10 -0
- package/src/redis/keyspace.ts +127 -0
- package/tests/redis-keyspace.test.mjs +149 -0
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,kBAAkB,CAAC;AAGjC,cAAc,mBAAmB,CAAC;AAGlC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,oBAAoB,CAAC;AAGnC,cAAc,mBAAmB,CAAC;AAGlC,cAAc,sBAAsB,CAAC;AAQrC,cAAc,oBAAoB,CAAC;AAGnC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,uBAAuB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,kBAAkB,CAAC;AAGjC,cAAc,mBAAmB,CAAC;AAGlC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,oBAAoB,CAAC;AAGnC,cAAc,mBAAmB,CAAC;AAGlC,cAAc,sBAAsB,CAAC;AAQrC,cAAc,oBAAoB,CAAC;AAGnC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,kBAAkB,CAAC;AAGjC,cAAc,uBAAuB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,8 @@ export * from './messaging/index.js';
|
|
|
18
18
|
export * from './express/index.js';
|
|
19
19
|
// Rate limiting
|
|
20
20
|
export * from './rate-limit/index.js';
|
|
21
|
+
// Redis key namespace (G5)
|
|
22
|
+
export * from './redis/index.js';
|
|
21
23
|
// Scheduling
|
|
22
24
|
export * from './scheduling/index.js';
|
|
23
25
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,mBAAmB;AACnB,cAAc,mBAAmB,CAAC;AAElC,aAAa;AACb,cAAc,uBAAuB,CAAC;AAEtC,UAAU;AACV,cAAc,oBAAoB,CAAC;AAEnC,SAAS;AACT,cAAc,mBAAmB,CAAC;AAElC,YAAY;AACZ,cAAc,sBAAsB,CAAC;AAErC,2EAA2E;AAC3E,iFAAiF;AACjF,wEAAwE;AACxE,6EAA6E;AAE7E,kDAAkD;AAClD,cAAc,oBAAoB,CAAC;AAEnC,gBAAgB;AAChB,cAAc,uBAAuB,CAAC;AAEtC,aAAa;AACb,cAAc,uBAAuB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,mBAAmB;AACnB,cAAc,mBAAmB,CAAC;AAElC,aAAa;AACb,cAAc,uBAAuB,CAAC;AAEtC,UAAU;AACV,cAAc,oBAAoB,CAAC;AAEnC,SAAS;AACT,cAAc,mBAAmB,CAAC;AAElC,YAAY;AACZ,cAAc,sBAAsB,CAAC;AAErC,2EAA2E;AAC3E,iFAAiF;AACjF,wEAAwE;AACxE,6EAA6E;AAE7E,kDAAkD;AAClD,cAAc,oBAAoB,CAAC;AAEnC,gBAAgB;AAChB,cAAc,uBAAuB,CAAC;AAEtC,2BAA2B;AAC3B,cAAc,kBAAkB,CAAC;AAEjC,aAAa;AACb,cAAc,uBAAuB,CAAC"}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { Request, Response, NextFunction } from "express";
|
|
2
2
|
import { RateLimiterAbstract } from "rate-limiter-flexible";
|
|
3
3
|
import type Redis from "ioredis";
|
|
4
|
+
import type { RedisKeyspace } from "../redis/keyspace.js";
|
|
4
5
|
export interface RateLimitConfig {
|
|
5
6
|
/** Max requests per window (default: 100) */
|
|
6
7
|
maxRequests?: number;
|
|
7
8
|
/** Window duration in milliseconds (default: 60 000) */
|
|
8
9
|
windowMs?: number;
|
|
9
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Service keyspace (G5). With it, keys are `{app}:{env}:{service}:rl:{keyPrefix}:…`
|
|
12
|
+
* and `keyPrefix` is only the limiter name. Required with a Redis client
|
|
13
|
+
* in NODE_ENV production/staging.
|
|
14
|
+
*/
|
|
15
|
+
keyspace?: RedisKeyspace;
|
|
16
|
+
/** Limiter name with a keyspace; the raw Redis key prefix without one (default: "rl:service") */
|
|
10
17
|
keyPrefix?: string;
|
|
11
18
|
/** Optional Redis client — falls back to in-memory if omitted */
|
|
12
19
|
redisClient?: Redis | null;
|
|
@@ -20,6 +27,12 @@ export interface RateLimitResult {
|
|
|
20
27
|
middleware: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
21
28
|
}
|
|
22
29
|
export declare function createRateLimiter(cfg?: RateLimitConfig): RateLimitResult;
|
|
30
|
+
/**
|
|
31
|
+
* With a keyspace: `{prefix}rl:{name}`. Without one the legacy raw prefix is
|
|
32
|
+
* kept for in-memory or local use only — staging and production share Redis
|
|
33
|
+
* with each other and with other apps, so unscoped Redis keys are refused there.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveLimiterKeyPrefix(keyPrefix: string | undefined, keyspace: RedisKeyspace | undefined, usesRedis: boolean): string;
|
|
23
36
|
/**
|
|
24
37
|
* The gateway user id only when its signature verifies — an unsigned
|
|
25
38
|
* x-user-id is caller-controlled, and a fresh value per request would get a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-rate-limiter.d.ts","sourceRoot":"","sources":["../../src/rate-limit/create-rate-limiter.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAAuC,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACjG,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"create-rate-limiter.d.ts","sourceRoot":"","sources":["../../src/rate-limit/create-rate-limiter.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAAuC,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACjG,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAI1D,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,iGAAiG;IACjG,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,WAAW,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC;IAC3B,oGAAoG;IACpG,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC;CACzC;AAED,MAAM,WAAW,eAAe;IAC9B,mDAAmD;IACnD,OAAO,EAAE,mBAAmB,CAAC;IAC7B,yBAAyB;IACzB,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAChF;AAID,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,eAAoB,GAAG,eAAe,CAoE5E;AAMD;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,QAAQ,EAAE,aAAa,GAAG,SAAS,EACnC,SAAS,EAAE,OAAO,GACjB,MAAM,CAMR;AAID;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAMxD"}
|
|
@@ -6,7 +6,8 @@ import { RateLimiterRedis, RateLimiterMemory } from "rate-limiter-flexible";
|
|
|
6
6
|
import { isTrustedGatewayIdentity } from "../common/gateway-signature.js";
|
|
7
7
|
// ─── Factory ────────────────────────────────────────────
|
|
8
8
|
export function createRateLimiter(cfg = {}) {
|
|
9
|
-
const { maxRequests = 100, windowMs = 60_000,
|
|
9
|
+
const { maxRequests = 100, windowMs = 60_000, keyspace, redisClient = null, keyExtractor = defaultKeyExtractor, } = cfg;
|
|
10
|
+
const keyPrefix = resolveLimiterKeyPrefix(cfg.keyPrefix, keyspace, Boolean(redisClient));
|
|
10
11
|
const durationSecs = Math.max(1, Math.round(windowMs / 1000));
|
|
11
12
|
const memoryLimiter = new RateLimiterMemory({
|
|
12
13
|
keyPrefix,
|
|
@@ -54,6 +55,21 @@ export function createRateLimiter(cfg = {}) {
|
|
|
54
55
|
}
|
|
55
56
|
return { limiter, middleware };
|
|
56
57
|
}
|
|
58
|
+
// ─── Key prefix ─────────────────────────────────────────
|
|
59
|
+
const DEPLOYED_NODE_ENVS = new Set(["production", "staging"]);
|
|
60
|
+
/**
|
|
61
|
+
* With a keyspace: `{prefix}rl:{name}`. Without one the legacy raw prefix is
|
|
62
|
+
* kept for in-memory or local use only — staging and production share Redis
|
|
63
|
+
* with each other and with other apps, so unscoped Redis keys are refused there.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveLimiterKeyPrefix(keyPrefix, keyspace, usesRedis) {
|
|
66
|
+
if (keyspace)
|
|
67
|
+
return keyspace.key("rl", keyPrefix || "default");
|
|
68
|
+
if (usesRedis && DEPLOYED_NODE_ENVS.has(process.env.NODE_ENV ?? "")) {
|
|
69
|
+
throw new Error("createRateLimiter: a keyspace is required for Redis-backed limiters in production/staging");
|
|
70
|
+
}
|
|
71
|
+
return keyPrefix || "rl:service";
|
|
72
|
+
}
|
|
57
73
|
// ─── Defaults ───────────────────────────────────────────
|
|
58
74
|
/**
|
|
59
75
|
* The gateway user id only when its signature verifies — an unsigned
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-rate-limiter.js","sourceRoot":"","sources":["../../src/rate-limit/create-rate-limiter.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,4EAA4E;AAC5E,2CAA2C;AAG3C,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAuB,MAAM,uBAAuB,CAAC;AAEjG,OAAO,EAAE,wBAAwB,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"create-rate-limiter.js","sourceRoot":"","sources":["../../src/rate-limit/create-rate-limiter.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { createRateLimiter, defaultKeyExtractor } from "./create-rate-limiter.js";
|
|
1
|
+
export { createRateLimiter, defaultKeyExtractor, resolveLimiterKeyPrefix } from "./create-rate-limiter.js";
|
|
2
2
|
export type { RateLimitConfig, RateLimitResult } from "./create-rate-limiter.js";
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rate-limit/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rate-limit/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAC3G,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC"}
|
package/dist/rate-limit/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createRateLimiter, defaultKeyExtractor } from "./create-rate-limiter.js";
|
|
1
|
+
export { createRateLimiter, defaultKeyExtractor, resolveLimiterKeyPrefix } from "./create-rate-limiter.js";
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/rate-limit/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/rate-limit/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createRedisKeyspace, resolveRedisKeyApp, resolveRedisKeyEnv, encodeRedisKeyPart, hashRedisKeyPart, DEFAULT_REDIS_KEY_APP, DEFAULT_REDIS_KEY_ENV, } from "./keyspace.js";
|
|
2
|
+
export type { RedisKeyspace, RedisKeyspaceOptions, RedisKeyPart } from "./keyspace.js";
|
|
3
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/redis/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,aAAa,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/redis/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,eAAe,CAAC"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type RedisKeyPart = string | number;
|
|
2
|
+
export interface RedisKeyspaceOptions {
|
|
3
|
+
/** Canonical owning service name (the service that writes the key), e.g. "api-gateway", "sms". */
|
|
4
|
+
service: string;
|
|
5
|
+
/** Defaults to REDIS_KEY_APP, then "pendoadmin". */
|
|
6
|
+
app?: string;
|
|
7
|
+
/** Defaults to resolveRedisKeyEnv(). */
|
|
8
|
+
env?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface RedisKeyspace {
|
|
11
|
+
readonly app: string;
|
|
12
|
+
readonly env: string;
|
|
13
|
+
readonly service: string;
|
|
14
|
+
/** `{app}:{env}:{service}:` — every key and pattern starts with this. */
|
|
15
|
+
readonly prefix: string;
|
|
16
|
+
/**
|
|
17
|
+
* The prefix without its trailing ':' — pass as `environment` to
|
|
18
|
+
* @heavybit/cache-package so its `${environment}|k:v` keys are anchored too.
|
|
19
|
+
*/
|
|
20
|
+
readonly cacheEnvironment: string;
|
|
21
|
+
/** `{prefix}{purpose}[:{part}...]`, each part encoded so it can't fake extra segments. */
|
|
22
|
+
key(purpose: string, ...parts: RedisKeyPart[]): string;
|
|
23
|
+
/** SCAN pattern anchored on the prefix: `{prefix}{purpose}:{suffix}` (suffix defaults to "*"). */
|
|
24
|
+
pattern(purpose: string, suffix?: string): string;
|
|
25
|
+
}
|
|
26
|
+
type EnvSource = Record<string, string | undefined>;
|
|
27
|
+
export declare const DEFAULT_REDIS_KEY_APP = "pendoadmin";
|
|
28
|
+
export declare const DEFAULT_REDIS_KEY_ENV = "dev";
|
|
29
|
+
export declare function resolveRedisKeyApp(source?: EnvSource): string;
|
|
30
|
+
/**
|
|
31
|
+
* REDIS_KEY_ENV, else CACHE_KEY_PREFIX ("prod" / "staging" in compose), else
|
|
32
|
+
* "dev". Throws in NODE_ENV production/staging when neither is set, so a
|
|
33
|
+
* deployed service can never write unscoped or "dev" keys.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveRedisKeyEnv(source?: EnvSource): string;
|
|
36
|
+
/**
|
|
37
|
+
* Percent-encodes a variable key part. Beyond encodeURIComponent this also
|
|
38
|
+
* encodes the glob characters SCAN patterns interpret, so a part can never
|
|
39
|
+
* add segments or widen a pattern.
|
|
40
|
+
*/
|
|
41
|
+
export declare function encodeRedisKeyPart(part: RedisKeyPart): string;
|
|
42
|
+
/** sha256 hex of a long or sensitive part (URLs, tokens). Never put a raw token in a key. */
|
|
43
|
+
export declare function hashRedisKeyPart(value: string): string;
|
|
44
|
+
export declare function createRedisKeyspace(options: RedisKeyspaceOptions): RedisKeyspace;
|
|
45
|
+
export {};
|
|
46
|
+
//# sourceMappingURL=keyspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keyspace.d.ts","sourceRoot":"","sources":["../../src/redis/keyspace.ts"],"names":[],"mappings":"AAgBA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAE3C,MAAM,WAAW,oBAAoB;IACnC,kGAAkG;IAClG,OAAO,EAAE,MAAM,CAAC;IAChB,oDAAoD;IACpD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,0FAA0F;IAC1F,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,KAAK,EAAE,YAAY,EAAE,GAAG,MAAM,CAAC;IACvD,kGAAkG;IAClG,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACnD;AAED,KAAK,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAIpD,eAAO,MAAM,qBAAqB,eAAe,CAAC;AAClD,eAAO,MAAM,qBAAqB,QAAQ,CAAC;AAgB3C,wBAAgB,kBAAkB,CAAC,MAAM,GAAE,SAAuB,GAAG,MAAM,CAE1E;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,GAAE,SAAuB,GAAG,MAAM,CAS1E;AAID;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,CAI7D;AAED,6FAA6F;AAC7F,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEtD;AAID,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CAqBhF"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// pendoadmin-shared-lib/src/redis/keyspace.ts
|
|
2
|
+
//
|
|
3
|
+
// One Redis installation is shared by other applications and by PendoAdmin
|
|
4
|
+
// staging and production, so every PendoAdmin key is namespaced by app,
|
|
5
|
+
// environment and owning service:
|
|
6
|
+
//
|
|
7
|
+
// {app}:{env}:{service}:{purpose}[:{part}...]
|
|
8
|
+
// e.g. pendoadmin:prod:api-gateway:authz:018ea4b0-…
|
|
9
|
+
//
|
|
10
|
+
// Build every key and SCAN pattern through createRedisKeyspace(); never
|
|
11
|
+
// hand-write prefixes (tracker decisions G5 / D39).
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
// ─── Constants ──────────────────────────────────────────
|
|
14
|
+
export const DEFAULT_REDIS_KEY_APP = "pendoadmin";
|
|
15
|
+
export const DEFAULT_REDIS_KEY_ENV = "dev";
|
|
16
|
+
/** NODE_ENV values that must name their key environment explicitly. */
|
|
17
|
+
const DEPLOYED_NODE_ENVS = new Set(["production", "staging"]);
|
|
18
|
+
const SEGMENT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
19
|
+
// ─── Segment resolution ─────────────────────────────────
|
|
20
|
+
function assertSegment(kind, value) {
|
|
21
|
+
if (!SEGMENT_RE.test(value)) {
|
|
22
|
+
throw new Error(`Redis keyspace: invalid ${kind} "${value}" (lower-case kebab-case [a-z0-9-] only)`);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
export function resolveRedisKeyApp(source = process.env) {
|
|
27
|
+
return assertSegment("app", source.REDIS_KEY_APP?.trim() || DEFAULT_REDIS_KEY_APP);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* REDIS_KEY_ENV, else CACHE_KEY_PREFIX ("prod" / "staging" in compose), else
|
|
31
|
+
* "dev". Throws in NODE_ENV production/staging when neither is set, so a
|
|
32
|
+
* deployed service can never write unscoped or "dev" keys.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveRedisKeyEnv(source = process.env) {
|
|
35
|
+
const explicit = source.REDIS_KEY_ENV?.trim() || source.CACHE_KEY_PREFIX?.trim();
|
|
36
|
+
if (explicit)
|
|
37
|
+
return assertSegment("env", explicit);
|
|
38
|
+
const nodeEnv = source.NODE_ENV?.trim() ?? "";
|
|
39
|
+
if (DEPLOYED_NODE_ENVS.has(nodeEnv)) {
|
|
40
|
+
throw new Error(`Redis keyspace: REDIS_KEY_ENV or CACHE_KEY_PREFIX must be set when NODE_ENV=${nodeEnv}`);
|
|
41
|
+
}
|
|
42
|
+
return DEFAULT_REDIS_KEY_ENV;
|
|
43
|
+
}
|
|
44
|
+
// ─── Part encoding ──────────────────────────────────────
|
|
45
|
+
/**
|
|
46
|
+
* Percent-encodes a variable key part. Beyond encodeURIComponent this also
|
|
47
|
+
* encodes the glob characters SCAN patterns interpret, so a part can never
|
|
48
|
+
* add segments or widen a pattern.
|
|
49
|
+
*/
|
|
50
|
+
export function encodeRedisKeyPart(part) {
|
|
51
|
+
const value = String(part);
|
|
52
|
+
if (!value)
|
|
53
|
+
throw new Error("Redis keyspace: key parts must not be empty");
|
|
54
|
+
return encodeURIComponent(value).replace(/[*?[\]!'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
55
|
+
}
|
|
56
|
+
/** sha256 hex of a long or sensitive part (URLs, tokens). Never put a raw token in a key. */
|
|
57
|
+
export function hashRedisKeyPart(value) {
|
|
58
|
+
return createHash("sha256").update(value).digest("hex");
|
|
59
|
+
}
|
|
60
|
+
// ─── Factory ────────────────────────────────────────────
|
|
61
|
+
export function createRedisKeyspace(options) {
|
|
62
|
+
const app = options.app !== undefined ? assertSegment("app", options.app) : resolveRedisKeyApp();
|
|
63
|
+
const env = options.env !== undefined ? assertSegment("env", options.env) : resolveRedisKeyEnv();
|
|
64
|
+
const service = assertSegment("service", options.service);
|
|
65
|
+
const cacheEnvironment = `${app}:${env}:${service}`;
|
|
66
|
+
const prefix = `${cacheEnvironment}:`;
|
|
67
|
+
return {
|
|
68
|
+
app,
|
|
69
|
+
env,
|
|
70
|
+
service,
|
|
71
|
+
prefix,
|
|
72
|
+
cacheEnvironment,
|
|
73
|
+
key(purpose, ...parts) {
|
|
74
|
+
const head = `${prefix}${assertSegment("purpose", purpose)}`;
|
|
75
|
+
return parts.length ? `${head}:${parts.map(encodeRedisKeyPart).join(":")}` : head;
|
|
76
|
+
},
|
|
77
|
+
pattern(purpose, suffix = "*") {
|
|
78
|
+
return `${prefix}${assertSegment("purpose", purpose)}:${suffix}`;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=keyspace.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keyspace.js","sourceRoot":"","sources":["../../src/redis/keyspace.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heavybit/pendoadmin-shared-lib",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Shared library for PendoAdmin microservices",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"./rate-limit": "./dist/rate-limit/index.js",
|
|
46
46
|
"./socket": "./dist/socket/index.js",
|
|
47
47
|
"./tenant": "./dist/tenant/index.js",
|
|
48
|
-
"./scheduling": "./dist/scheduling/index.js"
|
|
48
|
+
"./scheduling": "./dist/scheduling/index.js",
|
|
49
|
+
"./redis": "./dist/redis/index.js"
|
|
49
50
|
},
|
|
50
51
|
"typesVersions": {
|
|
51
52
|
"*": {
|
|
@@ -87,6 +88,9 @@
|
|
|
87
88
|
],
|
|
88
89
|
"scheduling": [
|
|
89
90
|
"dist/scheduling/index.d.ts"
|
|
91
|
+
],
|
|
92
|
+
"redis": [
|
|
93
|
+
"dist/redis/index.d.ts"
|
|
90
94
|
]
|
|
91
95
|
}
|
|
92
96
|
},
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Request, Response, NextFunction } from "express";
|
|
|
7
7
|
import { RateLimiterRedis, RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible";
|
|
8
8
|
import type Redis from "ioredis";
|
|
9
9
|
import { isTrustedGatewayIdentity } from "../common/gateway-signature.js";
|
|
10
|
+
import type { RedisKeyspace } from "../redis/keyspace.js";
|
|
10
11
|
|
|
11
12
|
// ─── Types ──────────────────────────────────────────────
|
|
12
13
|
|
|
@@ -15,7 +16,13 @@ export interface RateLimitConfig {
|
|
|
15
16
|
maxRequests?: number;
|
|
16
17
|
/** Window duration in milliseconds (default: 60 000) */
|
|
17
18
|
windowMs?: number;
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Service keyspace (G5). With it, keys are `{app}:{env}:{service}:rl:{keyPrefix}:…`
|
|
21
|
+
* and `keyPrefix` is only the limiter name. Required with a Redis client
|
|
22
|
+
* in NODE_ENV production/staging.
|
|
23
|
+
*/
|
|
24
|
+
keyspace?: RedisKeyspace;
|
|
25
|
+
/** Limiter name with a keyspace; the raw Redis key prefix without one (default: "rl:service") */
|
|
19
26
|
keyPrefix?: string;
|
|
20
27
|
/** Optional Redis client — falls back to in-memory if omitted */
|
|
21
28
|
redisClient?: Redis | null;
|
|
@@ -36,10 +43,11 @@ export function createRateLimiter(cfg: RateLimitConfig = {}): RateLimitResult {
|
|
|
36
43
|
const {
|
|
37
44
|
maxRequests = 100,
|
|
38
45
|
windowMs = 60_000,
|
|
39
|
-
|
|
46
|
+
keyspace,
|
|
40
47
|
redisClient = null,
|
|
41
48
|
keyExtractor = defaultKeyExtractor,
|
|
42
49
|
} = cfg;
|
|
50
|
+
const keyPrefix = resolveLimiterKeyPrefix(cfg.keyPrefix, keyspace, Boolean(redisClient));
|
|
43
51
|
|
|
44
52
|
const durationSecs = Math.max(1, Math.round(windowMs / 1000));
|
|
45
53
|
|
|
@@ -101,6 +109,27 @@ export function createRateLimiter(cfg: RateLimitConfig = {}): RateLimitResult {
|
|
|
101
109
|
return { limiter, middleware };
|
|
102
110
|
}
|
|
103
111
|
|
|
112
|
+
// ─── Key prefix ─────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const DEPLOYED_NODE_ENVS = new Set(["production", "staging"]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* With a keyspace: `{prefix}rl:{name}`. Without one the legacy raw prefix is
|
|
118
|
+
* kept for in-memory or local use only — staging and production share Redis
|
|
119
|
+
* with each other and with other apps, so unscoped Redis keys are refused there.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveLimiterKeyPrefix(
|
|
122
|
+
keyPrefix: string | undefined,
|
|
123
|
+
keyspace: RedisKeyspace | undefined,
|
|
124
|
+
usesRedis: boolean,
|
|
125
|
+
): string {
|
|
126
|
+
if (keyspace) return keyspace.key("rl", keyPrefix || "default");
|
|
127
|
+
if (usesRedis && DEPLOYED_NODE_ENVS.has(process.env.NODE_ENV ?? "")) {
|
|
128
|
+
throw new Error("createRateLimiter: a keyspace is required for Redis-backed limiters in production/staging");
|
|
129
|
+
}
|
|
130
|
+
return keyPrefix || "rl:service";
|
|
131
|
+
}
|
|
132
|
+
|
|
104
133
|
// ─── Defaults ───────────────────────────────────────────
|
|
105
134
|
|
|
106
135
|
/**
|
package/src/rate-limit/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createRateLimiter, defaultKeyExtractor } from "./create-rate-limiter.js";
|
|
1
|
+
export { createRateLimiter, defaultKeyExtractor, resolveLimiterKeyPrefix } from "./create-rate-limiter.js";
|
|
2
2
|
export type { RateLimitConfig, RateLimitResult } from "./create-rate-limiter.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createRedisKeyspace,
|
|
3
|
+
resolveRedisKeyApp,
|
|
4
|
+
resolveRedisKeyEnv,
|
|
5
|
+
encodeRedisKeyPart,
|
|
6
|
+
hashRedisKeyPart,
|
|
7
|
+
DEFAULT_REDIS_KEY_APP,
|
|
8
|
+
DEFAULT_REDIS_KEY_ENV,
|
|
9
|
+
} from "./keyspace.js";
|
|
10
|
+
export type { RedisKeyspace, RedisKeyspaceOptions, RedisKeyPart } from "./keyspace.js";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// pendoadmin-shared-lib/src/redis/keyspace.ts
|
|
2
|
+
//
|
|
3
|
+
// One Redis installation is shared by other applications and by PendoAdmin
|
|
4
|
+
// staging and production, so every PendoAdmin key is namespaced by app,
|
|
5
|
+
// environment and owning service:
|
|
6
|
+
//
|
|
7
|
+
// {app}:{env}:{service}:{purpose}[:{part}...]
|
|
8
|
+
// e.g. pendoadmin:prod:api-gateway:authz:018ea4b0-…
|
|
9
|
+
//
|
|
10
|
+
// Build every key and SCAN pattern through createRedisKeyspace(); never
|
|
11
|
+
// hand-write prefixes (tracker decisions G5 / D39).
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
// ─── Types ──────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export type RedisKeyPart = string | number;
|
|
18
|
+
|
|
19
|
+
export interface RedisKeyspaceOptions {
|
|
20
|
+
/** Canonical owning service name (the service that writes the key), e.g. "api-gateway", "sms". */
|
|
21
|
+
service: string;
|
|
22
|
+
/** Defaults to REDIS_KEY_APP, then "pendoadmin". */
|
|
23
|
+
app?: string;
|
|
24
|
+
/** Defaults to resolveRedisKeyEnv(). */
|
|
25
|
+
env?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RedisKeyspace {
|
|
29
|
+
readonly app: string;
|
|
30
|
+
readonly env: string;
|
|
31
|
+
readonly service: string;
|
|
32
|
+
/** `{app}:{env}:{service}:` — every key and pattern starts with this. */
|
|
33
|
+
readonly prefix: string;
|
|
34
|
+
/**
|
|
35
|
+
* The prefix without its trailing ':' — pass as `environment` to
|
|
36
|
+
* @heavybit/cache-package so its `${environment}|k:v` keys are anchored too.
|
|
37
|
+
*/
|
|
38
|
+
readonly cacheEnvironment: string;
|
|
39
|
+
/** `{prefix}{purpose}[:{part}...]`, each part encoded so it can't fake extra segments. */
|
|
40
|
+
key(purpose: string, ...parts: RedisKeyPart[]): string;
|
|
41
|
+
/** SCAN pattern anchored on the prefix: `{prefix}{purpose}:{suffix}` (suffix defaults to "*"). */
|
|
42
|
+
pattern(purpose: string, suffix?: string): string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type EnvSource = Record<string, string | undefined>;
|
|
46
|
+
|
|
47
|
+
// ─── Constants ──────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_REDIS_KEY_APP = "pendoadmin";
|
|
50
|
+
export const DEFAULT_REDIS_KEY_ENV = "dev";
|
|
51
|
+
|
|
52
|
+
/** NODE_ENV values that must name their key environment explicitly. */
|
|
53
|
+
const DEPLOYED_NODE_ENVS = new Set(["production", "staging"]);
|
|
54
|
+
|
|
55
|
+
const SEGMENT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
56
|
+
|
|
57
|
+
// ─── Segment resolution ─────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function assertSegment(kind: string, value: string): string {
|
|
60
|
+
if (!SEGMENT_RE.test(value)) {
|
|
61
|
+
throw new Error(`Redis keyspace: invalid ${kind} "${value}" (lower-case kebab-case [a-z0-9-] only)`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveRedisKeyApp(source: EnvSource = process.env): string {
|
|
67
|
+
return assertSegment("app", source.REDIS_KEY_APP?.trim() || DEFAULT_REDIS_KEY_APP);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* REDIS_KEY_ENV, else CACHE_KEY_PREFIX ("prod" / "staging" in compose), else
|
|
72
|
+
* "dev". Throws in NODE_ENV production/staging when neither is set, so a
|
|
73
|
+
* deployed service can never write unscoped or "dev" keys.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveRedisKeyEnv(source: EnvSource = process.env): string {
|
|
76
|
+
const explicit = source.REDIS_KEY_ENV?.trim() || source.CACHE_KEY_PREFIX?.trim();
|
|
77
|
+
if (explicit) return assertSegment("env", explicit);
|
|
78
|
+
|
|
79
|
+
const nodeEnv = source.NODE_ENV?.trim() ?? "";
|
|
80
|
+
if (DEPLOYED_NODE_ENVS.has(nodeEnv)) {
|
|
81
|
+
throw new Error(`Redis keyspace: REDIS_KEY_ENV or CACHE_KEY_PREFIX must be set when NODE_ENV=${nodeEnv}`);
|
|
82
|
+
}
|
|
83
|
+
return DEFAULT_REDIS_KEY_ENV;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Part encoding ──────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Percent-encodes a variable key part. Beyond encodeURIComponent this also
|
|
90
|
+
* encodes the glob characters SCAN patterns interpret, so a part can never
|
|
91
|
+
* add segments or widen a pattern.
|
|
92
|
+
*/
|
|
93
|
+
export function encodeRedisKeyPart(part: RedisKeyPart): string {
|
|
94
|
+
const value = String(part);
|
|
95
|
+
if (!value) throw new Error("Redis keyspace: key parts must not be empty");
|
|
96
|
+
return encodeURIComponent(value).replace(/[*?[\]!'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** sha256 hex of a long or sensitive part (URLs, tokens). Never put a raw token in a key. */
|
|
100
|
+
export function hashRedisKeyPart(value: string): string {
|
|
101
|
+
return createHash("sha256").update(value).digest("hex");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Factory ────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export function createRedisKeyspace(options: RedisKeyspaceOptions): RedisKeyspace {
|
|
107
|
+
const app = options.app !== undefined ? assertSegment("app", options.app) : resolveRedisKeyApp();
|
|
108
|
+
const env = options.env !== undefined ? assertSegment("env", options.env) : resolveRedisKeyEnv();
|
|
109
|
+
const service = assertSegment("service", options.service);
|
|
110
|
+
const cacheEnvironment = `${app}:${env}:${service}`;
|
|
111
|
+
const prefix = `${cacheEnvironment}:`;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
app,
|
|
115
|
+
env,
|
|
116
|
+
service,
|
|
117
|
+
prefix,
|
|
118
|
+
cacheEnvironment,
|
|
119
|
+
key(purpose, ...parts) {
|
|
120
|
+
const head = `${prefix}${assertSegment("purpose", purpose)}`;
|
|
121
|
+
return parts.length ? `${head}:${parts.map(encodeRedisKeyPart).join(":")}` : head;
|
|
122
|
+
},
|
|
123
|
+
pattern(purpose, suffix = "*") {
|
|
124
|
+
return `${prefix}${assertSegment("purpose", purpose)}:${suffix}`;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { test, describe, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
createRedisKeyspace,
|
|
5
|
+
resolveRedisKeyEnv,
|
|
6
|
+
resolveRedisKeyApp,
|
|
7
|
+
encodeRedisKeyPart,
|
|
8
|
+
hashRedisKeyPart,
|
|
9
|
+
} from '../dist/redis/index.js';
|
|
10
|
+
import { resolveLimiterKeyPrefix } from '../dist/rate-limit/index.js';
|
|
11
|
+
|
|
12
|
+
const ENV_KEYS = ['NODE_ENV', 'REDIS_KEY_ENV', 'REDIS_KEY_APP', 'CACHE_KEY_PREFIX'];
|
|
13
|
+
const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
14
|
+
function restoreEnv() {
|
|
15
|
+
for (const k of ENV_KEYS) {
|
|
16
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
17
|
+
else process.env[k] = saved[k];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('resolveRedisKeyEnv', () => {
|
|
22
|
+
test('prefers REDIS_KEY_ENV over CACHE_KEY_PREFIX', () => {
|
|
23
|
+
assert.equal(resolveRedisKeyEnv({ REDIS_KEY_ENV: 'prod', CACHE_KEY_PREFIX: 'other' }), 'prod');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('falls back to CACHE_KEY_PREFIX', () => {
|
|
27
|
+
assert.equal(resolveRedisKeyEnv({ CACHE_KEY_PREFIX: 'staging', NODE_ENV: 'staging' }), 'staging');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('defaults to dev outside deployed environments', () => {
|
|
31
|
+
assert.equal(resolveRedisKeyEnv({ NODE_ENV: 'development' }), 'dev');
|
|
32
|
+
assert.equal(resolveRedisKeyEnv({ NODE_ENV: 'test' }), 'dev');
|
|
33
|
+
assert.equal(resolveRedisKeyEnv({}), 'dev');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('refuses to start in production or staging without an explicit env', () => {
|
|
37
|
+
assert.throws(() => resolveRedisKeyEnv({ NODE_ENV: 'production' }), /REDIS_KEY_ENV or CACHE_KEY_PREFIX/);
|
|
38
|
+
assert.throws(() => resolveRedisKeyEnv({ NODE_ENV: 'staging', REDIS_KEY_ENV: ' ' }), /must be set/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('rejects env values that are not kebab-case segments', () => {
|
|
42
|
+
assert.throws(() => resolveRedisKeyEnv({ REDIS_KEY_ENV: 'prod:x' }), /invalid env/);
|
|
43
|
+
assert.throws(() => resolveRedisKeyEnv({ REDIS_KEY_ENV: 'Prod' }), /invalid env/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('resolveRedisKeyApp', () => {
|
|
48
|
+
test('defaults to pendoadmin and validates overrides', () => {
|
|
49
|
+
assert.equal(resolveRedisKeyApp({}), 'pendoadmin');
|
|
50
|
+
assert.equal(resolveRedisKeyApp({ REDIS_KEY_APP: 'pendo-admin' }), 'pendo-admin');
|
|
51
|
+
assert.throws(() => resolveRedisKeyApp({ REDIS_KEY_APP: 'pendo admin' }), /invalid app/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('createRedisKeyspace', () => {
|
|
56
|
+
afterEach(restoreEnv);
|
|
57
|
+
|
|
58
|
+
test('builds {app}:{env}:{service}:{purpose}:{parts}', () => {
|
|
59
|
+
const ks = createRedisKeyspace({ service: 'api-gateway', env: 'prod' });
|
|
60
|
+
assert.equal(ks.prefix, 'pendoadmin:prod:api-gateway:');
|
|
61
|
+
assert.equal(ks.cacheEnvironment, 'pendoadmin:prod:api-gateway');
|
|
62
|
+
assert.equal(ks.key('authz', 'u1'), 'pendoadmin:prod:api-gateway:authz:u1');
|
|
63
|
+
assert.equal(ks.key('authz', 'apikey', 42), 'pendoadmin:prod:api-gateway:authz:apikey:42');
|
|
64
|
+
assert.equal(ks.key('gwcache'), 'pendoadmin:prod:api-gateway:gwcache');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('resolves app and env from process.env when not given', () => {
|
|
68
|
+
process.env.NODE_ENV = 'staging';
|
|
69
|
+
process.env.CACHE_KEY_PREFIX = 'staging';
|
|
70
|
+
delete process.env.REDIS_KEY_ENV;
|
|
71
|
+
delete process.env.REDIS_KEY_APP;
|
|
72
|
+
assert.equal(createRedisKeyspace({ service: 'sms' }).prefix, 'pendoadmin:staging:sms:');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('staging and prod keyspaces never produce the same key', () => {
|
|
76
|
+
const prod = createRedisKeyspace({ service: 'sms', env: 'prod' });
|
|
77
|
+
const staging = createRedisKeyspace({ service: 'sms', env: 'staging' });
|
|
78
|
+
assert.notEqual(prod.key('cache', 'x'), staging.key('cache', 'x'));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('throws at creation in production without an env', () => {
|
|
82
|
+
process.env.NODE_ENV = 'production';
|
|
83
|
+
delete process.env.REDIS_KEY_ENV;
|
|
84
|
+
delete process.env.CACHE_KEY_PREFIX;
|
|
85
|
+
assert.throws(() => createRedisKeyspace({ service: 'sms' }), /must be set/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a part containing ":" cannot fake extra segments', () => {
|
|
89
|
+
const ks = createRedisKeyspace({ service: 'auth-admin', env: 'prod' });
|
|
90
|
+
const key = ks.key('rl', 'a:b');
|
|
91
|
+
assert.equal(key, 'pendoadmin:prod:auth-admin:rl:a%3Ab');
|
|
92
|
+
assert.equal(key.split(':').length, 5);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('rejects invalid service and purpose segments', () => {
|
|
96
|
+
assert.throws(() => createRedisKeyspace({ service: 'API_Gateway', env: 'prod' }), /invalid service/);
|
|
97
|
+
const ks = createRedisKeyspace({ service: 'sms', env: 'prod' });
|
|
98
|
+
assert.throws(() => ks.key('gw:cache'), /invalid purpose/);
|
|
99
|
+
assert.throws(() => ks.pattern('*'), /invalid purpose/);
|
|
100
|
+
assert.throws(() => ks.key('cache', ''), /must not be empty/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('patterns are always anchored on the full prefix', () => {
|
|
104
|
+
const ks = createRedisKeyspace({ service: 'api-gateway', env: 'prod' });
|
|
105
|
+
assert.equal(ks.pattern('gwcache'), 'pendoadmin:prod:api-gateway:gwcache:*');
|
|
106
|
+
assert.equal(ks.pattern('authz', 'apikey:*'), 'pendoadmin:prod:api-gateway:authz:apikey:*');
|
|
107
|
+
assert.ok(!ks.pattern('authz').startsWith('*'));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('part encoding and hashing', () => {
|
|
112
|
+
test('encodes separators, whitespace and SCAN glob characters', () => {
|
|
113
|
+
assert.equal(encodeRedisKeyPart('a b'), 'a%20b');
|
|
114
|
+
assert.equal(encodeRedisKeyPart('a|b'), 'a%7Cb');
|
|
115
|
+
assert.equal(encodeRedisKeyPart('x*y?[z]'), 'x%2Ay%3F%5Bz%5D');
|
|
116
|
+
assert.equal(encodeRedisKeyPart(7), '7');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('hashes to sha256 hex and never echoes the input', () => {
|
|
120
|
+
const h = hashRedisKeyPart('/api/v1/users?token=secret');
|
|
121
|
+
assert.match(h, /^[0-9a-f]{64}$/);
|
|
122
|
+
assert.ok(!h.includes('secret'));
|
|
123
|
+
assert.equal(h, hashRedisKeyPart('/api/v1/users?token=secret'));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('resolveLimiterKeyPrefix', () => {
|
|
128
|
+
afterEach(restoreEnv);
|
|
129
|
+
const ks = createRedisKeyspace({ service: 'api-gateway', env: 'prod' });
|
|
130
|
+
|
|
131
|
+
test('uses the keyspace with keyPrefix as the limiter name', () => {
|
|
132
|
+
assert.equal(resolveLimiterKeyPrefix('sensitive', ks, true), 'pendoadmin:prod:api-gateway:rl:sensitive');
|
|
133
|
+
assert.equal(resolveLimiterKeyPrefix(undefined, ks, true), 'pendoadmin:prod:api-gateway:rl:default');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('refuses an unscoped Redis limiter in production and staging', () => {
|
|
137
|
+
process.env.NODE_ENV = 'production';
|
|
138
|
+
assert.throws(() => resolveLimiterKeyPrefix('rl:x', undefined, true), /keyspace is required/);
|
|
139
|
+
process.env.NODE_ENV = 'staging';
|
|
140
|
+
assert.throws(() => resolveLimiterKeyPrefix(undefined, undefined, true), /keyspace is required/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('keeps the legacy raw prefix for in-memory or local use', () => {
|
|
144
|
+
process.env.NODE_ENV = 'production';
|
|
145
|
+
assert.equal(resolveLimiterKeyPrefix(undefined, undefined, false), 'rl:service');
|
|
146
|
+
process.env.NODE_ENV = 'development';
|
|
147
|
+
assert.equal(resolveLimiterKeyPrefix('rl:x', undefined, true), 'rl:x');
|
|
148
|
+
});
|
|
149
|
+
});
|