@heavybit/pendoadmin-shared-lib 1.6.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.
Files changed (70) hide show
  1. package/dist/common/gateway-signature.d.ts +49 -0
  2. package/dist/common/gateway-signature.d.ts.map +1 -0
  3. package/dist/common/gateway-signature.js +99 -0
  4. package/dist/common/gateway-signature.js.map +1 -0
  5. package/dist/common/index.d.ts +1 -0
  6. package/dist/common/index.d.ts.map +1 -1
  7. package/dist/common/index.js +1 -0
  8. package/dist/common/index.js.map +1 -1
  9. package/dist/express/auth.middleware.d.ts +5 -4
  10. package/dist/express/auth.middleware.d.ts.map +1 -1
  11. package/dist/express/auth.middleware.js +49 -65
  12. package/dist/express/auth.middleware.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/logging/index.d.ts +1 -0
  18. package/dist/logging/index.d.ts.map +1 -1
  19. package/dist/logging/index.js +5 -2
  20. package/dist/logging/index.js.map +1 -1
  21. package/dist/logging/redact.d.ts +16 -0
  22. package/dist/logging/redact.d.ts.map +1 -0
  23. package/dist/logging/redact.js +83 -0
  24. package/dist/logging/redact.js.map +1 -0
  25. package/dist/nestjs/auth.guard.d.ts +2 -0
  26. package/dist/nestjs/auth.guard.d.ts.map +1 -1
  27. package/dist/nestjs/auth.guard.js +11 -3
  28. package/dist/nestjs/auth.guard.js.map +1 -1
  29. package/dist/nestjs/permission.guard.js +5 -4
  30. package/dist/nestjs/permission.guard.js.map +1 -1
  31. package/dist/rate-limit/create-rate-limiter.d.ts +21 -2
  32. package/dist/rate-limit/create-rate-limiter.d.ts.map +1 -1
  33. package/dist/rate-limit/create-rate-limiter.js +29 -3
  34. package/dist/rate-limit/create-rate-limiter.js.map +1 -1
  35. package/dist/rate-limit/index.d.ts +1 -1
  36. package/dist/rate-limit/index.d.ts.map +1 -1
  37. package/dist/rate-limit/index.js +1 -1
  38. package/dist/rate-limit/index.js.map +1 -1
  39. package/dist/redis/index.d.ts +3 -0
  40. package/dist/redis/index.d.ts.map +1 -0
  41. package/dist/redis/index.js +2 -0
  42. package/dist/redis/index.js.map +1 -0
  43. package/dist/redis/keyspace.d.ts +46 -0
  44. package/dist/redis/keyspace.d.ts.map +1 -0
  45. package/dist/redis/keyspace.js +82 -0
  46. package/dist/redis/keyspace.js.map +1 -0
  47. package/dist/types/index.d.ts +2 -0
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/types/index.js +2 -0
  50. package/dist/types/index.js.map +1 -1
  51. package/package.json +7 -3
  52. package/src/common/gateway-signature.ts +124 -0
  53. package/src/common/index.ts +1 -0
  54. package/src/express/auth.middleware.ts +46 -64
  55. package/src/index.ts +3 -0
  56. package/src/logging/index.ts +6 -2
  57. package/src/logging/redact.ts +84 -0
  58. package/src/nestjs/auth.guard.ts +12 -3
  59. package/src/nestjs/permission.guard.ts +5 -5
  60. package/src/rate-limit/create-rate-limiter.ts +44 -5
  61. package/src/rate-limit/index.ts +1 -1
  62. package/src/redis/index.ts +10 -0
  63. package/src/redis/keyspace.ts +127 -0
  64. package/src/types/index.ts +2 -0
  65. package/tests/auth-middleware.test.mjs +91 -0
  66. package/tests/gateway-signature.test.mjs +93 -0
  67. package/tests/nestjs-guards.test.mjs +45 -0
  68. package/tests/rate-limit-key.test.mjs +25 -0
  69. package/tests/redact.test.mjs +61 -0
  70. package/tests/redis-keyspace.test.mjs +149 -0
@@ -0,0 +1,93 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { signGatewayIdentity, verifyGatewayIdentity, isTrustedGatewayIdentity } from '../dist/common/index.js';
4
+
5
+ // Shared with api-gateway/tests/utils/gateway-signature.test.ts: the gateway's
6
+ // own signer must produce this exact signature.
7
+ const VECTOR = {
8
+ secret: 'test-signing-secret',
9
+ nowMs: 1_790_000_000_000,
10
+ headers: {
11
+ 'x-correlation-id': 'corr-1',
12
+ 'x-user-id': 'user-1',
13
+ 'x-user-email': 'alice@example.com',
14
+ 'x-company-id': 'comp-1',
15
+ 'x-company-slug': 'acme',
16
+ 'x-user-roles': '["admin"]',
17
+ 'x-user-permissions': '["users:list"]',
18
+ 'x-is-superadmin': 'false',
19
+ 'x-otp-verified': 'true',
20
+ },
21
+ signature: '681c8ff3354db858c618a257601cced23ed694f824db116eb200c724470fe55d',
22
+ timestamp: '1790000000',
23
+ };
24
+
25
+ const signed = (overrides = {}) => ({
26
+ ...VECTOR.headers,
27
+ 'x-gateway-signature': VECTOR.signature,
28
+ 'x-gateway-ts': VECTOR.timestamp,
29
+ ...overrides,
30
+ });
31
+ const verify = (headers, opts = {}) => verifyGatewayIdentity(headers, VECTOR.secret, { nowMs: VECTOR.nowMs, ...opts });
32
+
33
+ describe('gateway signature', () => {
34
+ test('matches the api-gateway signer test vector', () => {
35
+ assert.deepEqual(signGatewayIdentity(VECTOR.headers, VECTOR.secret, VECTOR.nowMs), {
36
+ 'x-gateway-signature': VECTOR.signature,
37
+ 'x-gateway-ts': VECTOR.timestamp,
38
+ });
39
+ });
40
+
41
+ test('accepts a valid, fresh signature', () => {
42
+ assert.equal(verify(signed()), true);
43
+ });
44
+
45
+ test('rejects a missing or malformed signature', () => {
46
+ assert.equal(verify(VECTOR.headers), false);
47
+ assert.equal(verify(signed({ 'x-gateway-signature': 'nothex' })), false);
48
+ assert.equal(verify(signed({ 'x-gateway-ts': 'soon' })), false);
49
+ });
50
+
51
+ test('rejects tampered identity headers', () => {
52
+ assert.equal(verify(signed({ 'x-is-superadmin': 'true' })), false);
53
+ assert.equal(verify(signed({ 'x-company-id': 'other-company' })), false);
54
+ assert.equal(verify(signed({ 'x-user-permissions': '["*"]' })), false);
55
+ });
56
+
57
+ test('rejects a timestamp outside the ±60 s window', () => {
58
+ assert.equal(verify(signed(), { nowMs: VECTOR.nowMs + 61_000 }), false);
59
+ assert.equal(verify(signed(), { nowMs: VECTOR.nowMs - 61_000 }), false);
60
+ assert.equal(verify(signed(), { nowMs: VECTOR.nowMs + 59_000 }), true);
61
+ });
62
+
63
+ test('rejects a signature made with a different secret, or an empty secret', () => {
64
+ assert.equal(verifyGatewayIdentity(signed(), 'other-secret', { nowMs: VECTOR.nowMs }), false);
65
+ assert.equal(verifyGatewayIdentity(signed(), '', { nowMs: VECTOR.nowMs }), false);
66
+ });
67
+ });
68
+
69
+ describe('isTrustedGatewayIdentity', () => {
70
+ const saved = { secret: process.env.GATEWAY_SIGNING_SECRET, env: process.env.NODE_ENV };
71
+ beforeEach(() => { delete process.env.GATEWAY_SIGNING_SECRET; });
72
+ afterEach(() => {
73
+ if (saved.secret === undefined) delete process.env.GATEWAY_SIGNING_SECRET; else process.env.GATEWAY_SIGNING_SECRET = saved.secret;
74
+ if (saved.env === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = saved.env;
75
+ });
76
+
77
+ test('with a secret configured, requires a valid signature', () => {
78
+ process.env.GATEWAY_SIGNING_SECRET = VECTOR.secret;
79
+ const fresh = { ...VECTOR.headers, ...signGatewayIdentity(VECTOR.headers, VECTOR.secret) };
80
+ assert.equal(isTrustedGatewayIdentity(fresh), true);
81
+ assert.equal(isTrustedGatewayIdentity(VECTOR.headers), false);
82
+ });
83
+
84
+ test('without a secret, never trusts unsigned identity in production', () => {
85
+ process.env.NODE_ENV = 'production';
86
+ assert.equal(isTrustedGatewayIdentity(VECTOR.headers), false);
87
+ });
88
+
89
+ test('without a secret, trusts unsigned identity in local development', () => {
90
+ process.env.NODE_ENV = 'development';
91
+ assert.equal(isTrustedGatewayIdentity(VECTOR.headers), true);
92
+ });
93
+ });
@@ -0,0 +1,45 @@
1
+ import 'reflect-metadata';
2
+ import { test, describe, beforeEach, afterEach } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { AuthGuard, PermissionGuard } from '../dist/nestjs/index.js';
5
+ import { signGatewayIdentity } from '../dist/common/index.js';
6
+
7
+ const SECRET = 'gateway-secret';
8
+ const identity = { 'x-user-id': 'key-1', 'x-company-id': 'comp-1', 'x-user-roles': '["api-client"]', 'x-user-permissions': '["ussd.sessions:list"]', 'x-is-superadmin': 'false' };
9
+
10
+ const reflector = (values = {}) => ({ getAllAndOverride: (key) => values[key] });
11
+ const contextFor = (request) => ({
12
+ getHandler: () => null,
13
+ getClass: () => null,
14
+ switchToHttp: () => ({ getRequest: () => request }),
15
+ });
16
+
17
+ describe('NestJS AuthGuard + PermissionGuard', () => {
18
+ const saved = { secret: process.env.GATEWAY_SIGNING_SECRET, env: process.env.NODE_ENV };
19
+ beforeEach(() => { process.env.GATEWAY_SIGNING_SECRET = SECRET; process.env.NODE_ENV = 'production'; });
20
+ afterEach(() => {
21
+ if (saved.secret === undefined) delete process.env.GATEWAY_SIGNING_SECRET; else process.env.GATEWAY_SIGNING_SECRET = saved.secret;
22
+ if (saved.env === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = saved.env;
23
+ });
24
+
25
+ test('AuthGuard trusts signed gateway headers', async () => {
26
+ const request = { headers: { ...identity, ...signGatewayIdentity(identity, SECRET) } };
27
+ assert.equal(await new AuthGuard(reflector()).canActivate(contextFor(request)), true);
28
+ assert.equal(request.user.id, 'key-1');
29
+ assert.equal(request.gatewayVerified, true);
30
+ });
31
+
32
+ test('AuthGuard rejects forged superadmin headers', async () => {
33
+ const request = { headers: { ...identity, 'x-is-superadmin': 'true', 'x-gateway-auth': 'verified' } };
34
+ await assert.rejects(new AuthGuard(reflector()).canActivate(contextFor(request)), { status: 401 });
35
+ assert.equal(request.user, undefined);
36
+ });
37
+
38
+ test('PermissionGuard role bypass needs a verified signature, not the x-gateway-auth header', () => {
39
+ const guard = new PermissionGuard(reflector({ permissions: ['ussd.sessions:list'], roles: ['company_admin'] }));
40
+ const user = { id: 'key-1', roles: ['api-client'], permissions: ['ussd.sessions:list'], isSuperAdmin: false };
41
+
42
+ assert.throws(() => guard.canActivate(contextFor({ user, headers: { 'x-gateway-auth': 'verified' } })), { status: 403 });
43
+ assert.equal(guard.canActivate(contextFor({ user, headers: {}, gatewayVerified: true })), true);
44
+ });
45
+ });
@@ -0,0 +1,25 @@
1
+ import { test, describe, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { defaultKeyExtractor } from '../dist/rate-limit/index.js';
4
+ import { signGatewayIdentity } from '../dist/common/index.js';
5
+
6
+ describe('defaultKeyExtractor', () => {
7
+ afterEach(() => { delete process.env.GATEWAY_SIGNING_SECRET; });
8
+
9
+ test('ignores an unsigned, caller-supplied x-user-id when a signing secret is set', () => {
10
+ process.env.GATEWAY_SIGNING_SECRET = 's';
11
+ assert.equal(defaultKeyExtractor({ ip: '81.2.69.160', headers: { 'x-user-id': 'rotating-1' } }), 'ip:81.2.69.160');
12
+ });
13
+
14
+ test('keys on the user id when the gateway signature verifies', () => {
15
+ process.env.GATEWAY_SIGNING_SECRET = 's';
16
+ const identity = { 'x-user-id': 'u1' };
17
+ const headers = { ...identity, ...signGatewayIdentity(identity, 's') };
18
+ assert.equal(defaultKeyExtractor({ ip: '10.0.0.1', headers }), 'user:u1');
19
+ });
20
+
21
+ test('falls back to "unknown" with no IP', () => {
22
+ process.env.GATEWAY_SIGNING_SECRET = 's';
23
+ assert.equal(defaultKeyExtractor({ headers: {} }), 'ip:unknown');
24
+ });
25
+ });
@@ -0,0 +1,61 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { redactSensitive, redactFormat, isSensitiveKey, REDACTED } from '../dist/logging/index.js';
4
+
5
+ describe('redactSensitive', () => {
6
+ test('redacts credential keys at any depth, case- and separator-insensitively', () => {
7
+ const out = redactSensitive({
8
+ headers: { Authorization: 'Bearer abc.def', cookie: 'sid=1', 'x-internal-service-secret': 's' },
9
+ body: { email: 'a@b.c', password: 'hunter2', otp: '123456', pin: '0000' },
10
+ client_secret: 'cs', apiKey: 'k', smtpPassword: 'p', encryptionKey: 'e',
11
+ accessToken: 't', refresh_token: 'r', captchaToken: 'c',
12
+ nested: [{ privateKey: '-----BEGIN' }],
13
+ });
14
+ assert.equal(out.headers.Authorization, REDACTED);
15
+ assert.equal(out.headers.cookie, REDACTED);
16
+ assert.equal(out.headers['x-internal-service-secret'], REDACTED);
17
+ assert.equal(out.body.email, 'a@b.c');
18
+ for (const key of ['password', 'otp', 'pin']) assert.equal(out.body[key], REDACTED);
19
+ for (const key of ['client_secret', 'apiKey', 'smtpPassword', 'encryptionKey', 'accessToken', 'refresh_token', 'captchaToken']) {
20
+ assert.equal(out[key], REDACTED, key);
21
+ }
22
+ assert.equal(out.nested[0].privateKey, REDACTED);
23
+ });
24
+
25
+ test('keeps non-sensitive fields, including token counters', () => {
26
+ const out = redactSensitive({ userId: 'u1', promptTokens: 12, tokenType: 'Bearer', status: 200 });
27
+ assert.deepEqual(out, { userId: 'u1', promptTokens: 12, tokenType: 'Bearer', status: 200 });
28
+ });
29
+
30
+ test('masks bearer/basic credentials inside free text and error messages', () => {
31
+ assert.equal(redactSensitive('auth header was Bearer eyJhbGciOi.x.y'), `auth header was Bearer ${REDACTED}`);
32
+ const err = redactSensitive(new Error('upstream rejected Basic dXNlcjpwYXNz'));
33
+ assert.equal(err.message, `upstream rejected Basic ${REDACTED}`);
34
+ });
35
+
36
+ test('survives cycles without throwing', () => {
37
+ const a = { name: 'a' };
38
+ a.self = a;
39
+ assert.equal(redactSensitive(a).self, '[Circular]');
40
+ });
41
+
42
+ test('isSensitiveKey', () => {
43
+ assert.equal(isSensitiveKey('X-Gateway-Signature'), true);
44
+ assert.equal(isSensitiveKey('correlationId'), false);
45
+ });
46
+ });
47
+
48
+ describe('redactFormat', () => {
49
+ test('redacts log entry metadata and message', () => {
50
+ const info = redactFormat().transform({
51
+ level: 'error',
52
+ message: 'login failed for Bearer abc',
53
+ password: 'p',
54
+ request: { headers: { authorization: 'Bearer abc' } },
55
+ });
56
+ assert.equal(info.password, REDACTED);
57
+ assert.equal(info.request.headers.authorization, REDACTED);
58
+ assert.equal(info.message, `login failed for Bearer ${REDACTED}`);
59
+ assert.equal(info.level, 'error');
60
+ });
61
+ });
@@ -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
+ });