@adaptic/backend-legacy 0.0.996 → 0.0.998

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.
@@ -0,0 +1,85 @@
1
+ import { vi } from 'vitest';
2
+ import jwt from 'jsonwebtoken';
3
+ /**
4
+ * Shared fixtures for the rate-limiter test files (audit
5
+ * B01-backend-legacy-08). Pure helpers only — each test file remains
6
+ * responsible for setting `process.env.JWT_SECRET` via `vi.hoisted` BEFORE its
7
+ * first import pulls in `config/jwtConfig` (which reads the env at load time).
8
+ */
9
+ /** Window used by every test config. */
10
+ export const WINDOW_MS = 60_000;
11
+ /**
12
+ * Builds a small test limiter config.
13
+ *
14
+ * @param overrides - Field overrides applied over the defaults.
15
+ * @returns A complete {@link RateLimitConfig}.
16
+ */
17
+ export function makeConfig(overrides = {}) {
18
+ return {
19
+ name: 'graphql',
20
+ windowMs: WINDOW_MS,
21
+ maxAuthenticated: 100,
22
+ maxUnauthenticated: 2,
23
+ standardHeaders: true,
24
+ message: { errors: [{ message: 'Too many requests' }] },
25
+ ...overrides,
26
+ };
27
+ }
28
+ /**
29
+ * Creates a mock response whose header/status/json calls are observable —
30
+ * the shadow contract is proven by these spies staying untouched.
31
+ *
32
+ * @returns The {@link MockResponse}.
33
+ */
34
+ export function makeRes() {
35
+ const setHeader = vi.fn();
36
+ const json = vi.fn();
37
+ const status = vi.fn(() => ({ json }));
38
+ const res = { setHeader, status, json };
39
+ return { res, setHeader, status, json };
40
+ }
41
+ /**
42
+ * Creates a mock request. Defaults to an anonymous POST from a fixed IP.
43
+ *
44
+ * @param overrides - Request field overrides (headers, ip, method, …).
45
+ * @returns A Request usable by the limiter middleware.
46
+ */
47
+ export function makeReq(overrides = {}) {
48
+ return {
49
+ method: 'POST',
50
+ headers: {},
51
+ ip: '203.0.113.10',
52
+ socket: { remoteAddress: '203.0.113.10' },
53
+ ...overrides,
54
+ };
55
+ }
56
+ /**
57
+ * Reads the current value of a limiter counter for a label pair.
58
+ *
59
+ * @param counter - The prom-client counter to read.
60
+ * @param labels - The limiter/tier label pair identifying the series.
61
+ * @returns The current value (0 when the series does not exist yet).
62
+ */
63
+ export async function counterValue(counter, labels) {
64
+ const metric = await counter.get();
65
+ const sample = metric.values.find((v) => v.labels.limiter === labels.limiter && v.labels.tier === labels.tier);
66
+ return sample?.value ?? 0;
67
+ }
68
+ /**
69
+ * Signs a real HS256 user JWT with the ambient `JWT_SECRET` (the same secret
70
+ * `config/jwtConfig` resolved at load), so the tier resolver verifies it.
71
+ *
72
+ * @param sub - Subject claim for the token.
73
+ * @returns A signed JWT string.
74
+ */
75
+ export function signUserToken(sub) {
76
+ const secret = process.env.JWT_SECRET;
77
+ if (!secret) {
78
+ throw new Error('JWT_SECRET must be set by the test file before signing');
79
+ }
80
+ return jwt.sign({ sub, roles: ['user'] }, secret, {
81
+ algorithm: 'HS256',
82
+ expiresIn: '1h',
83
+ });
84
+ }
85
+ //# sourceMappingURL=rate-limit-test-utils.js.map
@@ -1,16 +1,93 @@
1
1
  import { Request, Response, NextFunction } from 'express';
2
+ import { Counter, Gauge } from 'prom-client';
3
+ /**
4
+ * Environment variable gating 429 enforcement. When set to `true`/`1`, the
5
+ * limiters block over-limit requests with HTTP 429. Unset or any other value
6
+ * keeps them in SHADOW mode (observe + count + always proceed).
7
+ */
8
+ export declare const CORTEX_RATE_LIMIT_ENFORCE_ENV = "CORTEX_RATE_LIMIT_ENFORCE";
9
+ /**
10
+ * Whether rate-limit 429 enforcement is ON. Read fresh on each request (a cheap
11
+ * env read) so the shadow→enforce graduation can be flipped operationally
12
+ * without a restart. Defaults to OFF (shadow) for any unset/unrecognised value.
13
+ *
14
+ * @param env - Environment source (defaults to `process.env`); injectable for tests.
15
+ * @returns `true` only when the flag is explicitly `true`/`1`.
16
+ */
17
+ export declare function isRateLimitEnforced(env?: Record<string, string | undefined>): boolean;
18
+ /**
19
+ * Counts requests that rate limiting WOULD block (HTTP 429) while in SHADOW
20
+ * mode. This is the signal proving whether flipping
21
+ * `CORTEX_RATE_LIMIT_ENFORCE` to enforce would reject legitimate traffic.
22
+ * Labelled by the `limiter` name (`graphql`/`auth`) and the request `tier`
23
+ * (`auth`/`anon`). Real enforce-mode denials count on
24
+ * {@link cortexRateLimitBlockedTotal} instead, keeping the observed-only and
25
+ * actually-blocked series distinct (audit B01-backend-legacy-14).
26
+ */
27
+ export declare const cortexRateLimitWouldBlockTotal: Counter<"limiter" | "tier">;
28
+ /**
29
+ * Counts requests actually blocked with HTTP 429 in ENFORCE mode. Kept as a
30
+ * separate series from {@link cortexRateLimitWouldBlockTotal} so shadow
31
+ * observations and real blocks never mix (audit B01-backend-legacy-14).
32
+ */
33
+ export declare const cortexRateLimitBlockedTotal: Counter<"limiter" | "tier">;
34
+ /**
35
+ * Tracked bucket-key cardinality per limiter store. Bounds memory ahead of the
36
+ * trust-proxy fix, after which the identifier derives from X-Forwarded-For and
37
+ * an attacker rotating spoofed values could otherwise mint unbounded entries
38
+ * (audit B01-backend-legacy-12).
39
+ */
40
+ export declare const cortexRateLimitStoreKeys: Gauge<"limiter">;
41
+ /** Configuration for one rate limiter instance. */
42
+ export interface RateLimitConfig {
43
+ /** Stable limiter identifier used as the `limiter` metric label. */
44
+ name: string;
45
+ windowMs: number;
46
+ maxAuthenticated: number;
47
+ maxUnauthenticated: number;
48
+ message: {
49
+ errors: Array<{
50
+ message: string;
51
+ }>;
52
+ };
53
+ standardHeaders?: boolean;
54
+ legacyHeaders?: boolean;
55
+ /** Cap on distinct bucket keys (defaults to {@link DEFAULT_MAX_TRACKED_KEYS}). */
56
+ maxTrackedKeys?: number;
57
+ }
58
+ /**
59
+ * Creates an in-memory rate limiter middleware with separate limits for
60
+ * verified (auth) and anonymous requests.
61
+ *
62
+ * Bucketing: verified callers are keyed per principal (`sub:<sub>`); anonymous
63
+ * callers are keyed by client IP, which resolves correctly behind the LB only
64
+ * with `trust proxy` configured in server.ts (audit B01-backend-legacy-02).
65
+ * The `auth` tier requires actual offline verification — a fabricated
66
+ * JWT-shaped header no longer earns the higher budget (B01-backend-legacy-09).
67
+ * CORS preflights (`OPTIONS`) are never counted (B01-backend-legacy-11).
68
+ *
69
+ * Response headers (enforce mode only, when standardHeaders is enabled):
70
+ * X-RateLimit-Limit - maximum requests allowed in the current window
71
+ * X-RateLimit-Remaining - requests remaining in the current window
72
+ * X-RateLimit-Reset - seconds until the current window resets
73
+ * Retry-After - seconds to wait before retrying (only on 429)
74
+ *
75
+ * @param config - Rate limit configuration.
76
+ * @returns Express middleware function.
77
+ */
78
+ export declare function createRateLimiter(config: RateLimitConfig): (req: Request, res: Response, next: NextFunction) => void;
2
79
  /**
3
80
  * Rate limiter for GraphQL endpoint.
4
81
  *
5
- * Authenticated requests: 1000 requests per 15 minutes (configurable via RATE_LIMIT_MAX)
6
- * Unauthenticated requests: 200 requests per 15 minutes (configurable via RATE_LIMIT_MAX_UNAUTH)
82
+ * Verified requests: 1000 requests per 15 minutes (configurable via RATE_LIMIT_MAX)
83
+ * Anonymous requests: 200 requests per 15 minutes (configurable via RATE_LIMIT_MAX_UNAUTH)
7
84
  */
8
85
  export declare const graphqlRateLimiter: (req: Request, res: Response, next: NextFunction) => void;
9
86
  /**
10
87
  * Rate limiter for authentication endpoints.
11
88
  *
12
- * Authenticated requests: 50 requests per 15 minutes
13
- * Unauthenticated requests: 20 requests per 15 minutes
89
+ * Verified requests: 50 requests per 15 minutes
90
+ * Anonymous requests: 20 requests per 15 minutes
14
91
  */
15
92
  export declare const authRateLimiter: (req: Request, res: Response, next: NextFunction) => void;
16
93
  //# sourceMappingURL=rate-limiter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../../../src/middleware/rate-limiter.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAyG1D;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,QA9ChB,OAAO,OAAO,QAAQ,QAAQ,YAAY,KAAG,IAuD1D,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,QA/Db,OAAO,OAAO,QAAQ,QAAQ,YAAY,KAAG,IAsE1D,CAAC"}
1
+ {"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../../../src/middleware/rate-limiter.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAK7C;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,8BAA8B,CAAC;AAEzE;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GACpD,OAAO,CAGT;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,6BAKzC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,6BAKtC,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,kBAKnC,CAAC;AAeH,mDAAmD;AACnD,MAAM,WAAW,eAAe;IAC9B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;IACzB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE;QAAE,MAAM,EAAE,KAAK,CAAC;YAAE,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC;IAChD,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,kFAAkF;IAClF,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAOD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,eAAe,GACtB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAgG3D;AAED;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,QAxGtB,OAAO,OAAO,QAAQ,QAAQ,YAAY,KAAK,IAkHtD,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,QA1HnB,OAAO,OAAO,QAAQ,QAAQ,YAAY,KAAK,IAkItD,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limiter.js","sourceRoot":"","sources":["../../../src/middleware/rate-limiter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"rate-limiter.js","sourceRoot":"","sources":["../../../src/middleware/rate-limiter.ts"],"names":[],"mappings":"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"}
@@ -1,75 +1,179 @@
1
- // Integration: add to server.ts - app.use('/graphql', graphqlRateLimiter) and app.use('/auth', authRateLimiter)
1
+ // CORTEX-P0-001: mounted in server.ts as
2
+ // app.use('/graphql', cors, graphqlRateLimiter, …) // GraphQL surface (after CORS)
3
+ // app.use('/api', authRateLimiter) // authenticated Express surface
4
+ // SHADOW-FIRST: 429 enforcement is gated behind CORTEX_RATE_LIMIT_ENFORCE.
5
+ // While OFF (default), the limiters observe + count requests that WOULD be
6
+ // blocked but never touch the response — live behaviour is byte-identical.
7
+ import { Counter, Gauge } from 'prom-client';
8
+ import { metricsRegistry } from '../config/metrics.mjs';
9
+ import { logger } from '../utils/logger.mjs';
10
+ import { resolveRateTier } from './rate-tier.mjs';
2
11
  /**
3
- * Checks whether a request carries a valid-looking authentication token.
4
- * Does not verify the token -- only checks for its presence in the
5
- * Authorization header as a Bearer token with three dot-separated parts
6
- * (standard JWT structure).
12
+ * Environment variable gating 429 enforcement. When set to `true`/`1`, the
13
+ * limiters block over-limit requests with HTTP 429. Unset or any other value
14
+ * keeps them in SHADOW mode (observe + count + always proceed).
7
15
  */
8
- function isAuthenticated(req) {
9
- const authHeader = req.headers.authorization || '';
10
- if (!authHeader.startsWith('Bearer ')) {
11
- return false;
12
- }
13
- const token = authHeader.slice(7);
14
- // Only count 3-segment JWT-shaped tokens as "authenticated" for rate-limit
15
- // tiering. Opaque OAuth access tokens (e.g. `ya29.…`) are rejected by the
16
- // verifier downstream — treating them as authenticated here would let a
17
- // caller spamming opaque tokens enjoy the higher auth-tier limits.
18
- return token.split('.').length === 3;
16
+ export const CORTEX_RATE_LIMIT_ENFORCE_ENV = 'CORTEX_RATE_LIMIT_ENFORCE';
17
+ /**
18
+ * Whether rate-limit 429 enforcement is ON. Read fresh on each request (a cheap
19
+ * env read) so the shadow→enforce graduation can be flipped operationally
20
+ * without a restart. Defaults to OFF (shadow) for any unset/unrecognised value.
21
+ *
22
+ * @param env - Environment source (defaults to `process.env`); injectable for tests.
23
+ * @returns `true` only when the flag is explicitly `true`/`1`.
24
+ */
25
+ export function isRateLimitEnforced(env = process.env) {
26
+ const raw = (env[CORTEX_RATE_LIMIT_ENFORCE_ENV] ?? '').trim().toLowerCase();
27
+ return raw === 'true' || raw === '1';
19
28
  }
20
29
  /**
21
- * Creates a simple in-memory rate limiter middleware with separate limits
22
- * for authenticated and unauthenticated requests.
30
+ * Counts requests that rate limiting WOULD block (HTTP 429) while in SHADOW
31
+ * mode. This is the signal proving whether flipping
32
+ * `CORTEX_RATE_LIMIT_ENFORCE` to enforce would reject legitimate traffic.
33
+ * Labelled by the `limiter` name (`graphql`/`auth`) and the request `tier`
34
+ * (`auth`/`anon`). Real enforce-mode denials count on
35
+ * {@link cortexRateLimitBlockedTotal} instead, keeping the observed-only and
36
+ * actually-blocked series distinct (audit B01-backend-legacy-14).
37
+ */
38
+ export const cortexRateLimitWouldBlockTotal = new Counter({
39
+ name: 'cortex_rate_limit_would_block_total',
40
+ help: 'Requests rate limiting would block (429), by limiter and tier (shadow mode only; real denials count on cortex_rate_limit_blocked_total)',
41
+ labelNames: ['limiter', 'tier'],
42
+ registers: [metricsRegistry],
43
+ });
44
+ /**
45
+ * Counts requests actually blocked with HTTP 429 in ENFORCE mode. Kept as a
46
+ * separate series from {@link cortexRateLimitWouldBlockTotal} so shadow
47
+ * observations and real blocks never mix (audit B01-backend-legacy-14).
48
+ */
49
+ export const cortexRateLimitBlockedTotal = new Counter({
50
+ name: 'cortex_rate_limit_blocked_total',
51
+ help: 'Requests actually blocked with HTTP 429, by limiter and tier (enforce mode only)',
52
+ labelNames: ['limiter', 'tier'],
53
+ registers: [metricsRegistry],
54
+ });
55
+ /**
56
+ * Tracked bucket-key cardinality per limiter store. Bounds memory ahead of the
57
+ * trust-proxy fix, after which the identifier derives from X-Forwarded-For and
58
+ * an attacker rotating spoofed values could otherwise mint unbounded entries
59
+ * (audit B01-backend-legacy-12).
60
+ */
61
+ export const cortexRateLimitStoreKeys = new Gauge({
62
+ name: 'cortex_rate_limit_store_keys',
63
+ help: 'Current tracked bucket-key cardinality per limiter store',
64
+ labelNames: ['limiter'],
65
+ registers: [metricsRegistry],
66
+ });
67
+ /** Sweep cadence for expired bucket entries. */
68
+ const STORE_SWEEP_INTERVAL_MS = 60_000;
69
+ /**
70
+ * Default cap on distinct bucket keys per limiter store. Past the cap, new
71
+ * identifiers aggregate into a shared per-tier overflow bucket instead of
72
+ * allocating — bounding memory under identifier-rotation abuse.
73
+ */
74
+ const DEFAULT_MAX_TRACKED_KEYS = 100_000;
75
+ /** Bucket-key prefix used once the store cardinality cap is reached. */
76
+ const OVERFLOW_KEY_PREFIX = 'overflow';
77
+ /**
78
+ * Creates an in-memory rate limiter middleware with separate limits for
79
+ * verified (auth) and anonymous requests.
23
80
  *
24
- * Response headers (when standardHeaders is enabled):
81
+ * Bucketing: verified callers are keyed per principal (`sub:<sub>`); anonymous
82
+ * callers are keyed by client IP, which resolves correctly behind the LB only
83
+ * with `trust proxy` configured in server.ts (audit B01-backend-legacy-02).
84
+ * The `auth` tier requires actual offline verification — a fabricated
85
+ * JWT-shaped header no longer earns the higher budget (B01-backend-legacy-09).
86
+ * CORS preflights (`OPTIONS`) are never counted (B01-backend-legacy-11).
87
+ *
88
+ * Response headers (enforce mode only, when standardHeaders is enabled):
25
89
  * X-RateLimit-Limit - maximum requests allowed in the current window
26
90
  * X-RateLimit-Remaining - requests remaining in the current window
27
91
  * X-RateLimit-Reset - seconds until the current window resets
28
92
  * Retry-After - seconds to wait before retrying (only on 429)
29
93
  *
30
- * @param config - Rate limit configuration
31
- * @returns Express middleware function
94
+ * @param config - Rate limit configuration.
95
+ * @returns Express middleware function.
32
96
  */
33
- function createRateLimiter(config) {
34
- const store = {};
35
- // Clean up expired entries every minute
97
+ export function createRateLimiter(config) {
98
+ const store = new Map();
99
+ const maxTrackedKeys = config.maxTrackedKeys ?? DEFAULT_MAX_TRACKED_KEYS;
100
+ // Sweep expired entries every minute. `unref()` so an importing process
101
+ // (tests, one-off scripts) is never kept alive by the sweeper
102
+ // (audit B01-backend-legacy-12).
36
103
  setInterval(() => {
37
104
  const now = Date.now();
38
- Object.keys(store).forEach((key) => {
39
- if (store[key].resetTime < now) {
40
- delete store[key];
105
+ for (const [key, entry] of store) {
106
+ if (entry.resetTime < now) {
107
+ store.delete(key);
41
108
  }
42
- });
43
- }, 60000);
109
+ }
110
+ cortexRateLimitStoreKeys.set({ limiter: config.name }, store.size);
111
+ }, STORE_SWEEP_INTERVAL_MS).unref();
44
112
  return (req, res, next) => {
45
- const identifier = req.ip || req.connection.remoteAddress || 'unknown';
46
- const authenticated = isAuthenticated(req);
47
- const effectiveMax = authenticated
48
- ? config.maxAuthenticated
49
- : config.maxUnauthenticated;
50
- const storeKey = `${identifier}:${authenticated ? 'auth' : 'anon'}`;
113
+ // CORS preflights carry no credentials and must not consume rate budget
114
+ // (audit B01-backend-legacy-11).
115
+ if (req.method === 'OPTIONS') {
116
+ next();
117
+ return;
118
+ }
119
+ const { tier, principalKey } = resolveRateTier(req);
120
+ const identifier = principalKey ?? req.ip ?? req.socket.remoteAddress ?? 'unknown';
121
+ const effectiveMax = tier === 'auth' ? config.maxAuthenticated : config.maxUnauthenticated;
122
+ let storeKey = `${identifier}:${tier}`;
51
123
  const now = Date.now();
52
- if (!store[storeKey] || store[storeKey].resetTime < now) {
53
- store[storeKey] = {
54
- count: 1,
55
- resetTime: now + config.windowMs,
56
- };
124
+ // Cardinality cap: aggregate novel identifiers into a shared per-tier
125
+ // overflow bucket rather than growing without bound
126
+ // (audit B01-backend-legacy-12).
127
+ if (!store.has(storeKey) && store.size >= maxTrackedKeys) {
128
+ storeKey = `${OVERFLOW_KEY_PREFIX}:${tier}`;
129
+ }
130
+ const existing = store.get(storeKey);
131
+ let current;
132
+ if (!existing || existing.resetTime < now) {
133
+ current = { count: 1, resetTime: now + config.windowMs };
134
+ store.set(storeKey, current);
57
135
  }
58
136
  else {
59
- store[storeKey].count += 1;
137
+ existing.count += 1;
138
+ current = existing;
60
139
  }
61
- const current = store[storeKey];
62
140
  const remaining = Math.max(0, effectiveMax - current.count);
63
141
  const resetSeconds = Math.ceil((current.resetTime - now) / 1000);
64
- // Add rate limit headers
142
+ const overLimit = current.count > effectiveMax;
143
+ // SHADOW mode (default): observe + count over-limit requests but NEVER touch
144
+ // the response — no rate-limit headers, no 429. This keeps live behaviour
145
+ // byte-identical until CORTEX_RATE_LIMIT_ENFORCE is flipped on.
146
+ if (!isRateLimitEnforced()) {
147
+ if (overLimit) {
148
+ cortexRateLimitWouldBlockTotal.inc({ limiter: config.name, tier });
149
+ // Log only the FIRST over-limit request per (bucket, window) — the
150
+ // counter carries per-request cardinality; unthrottled logging floods
151
+ // Cloud Logging and buries real warnings (audit B01-backend-legacy-10).
152
+ if (current.count === effectiveMax + 1) {
153
+ logger.warn('[cortex-rate-limit] shadow would-block (allowing)', {
154
+ limiter: config.name,
155
+ identifier,
156
+ tier,
157
+ count: current.count,
158
+ limit: effectiveMax,
159
+ resetSeconds,
160
+ });
161
+ }
162
+ }
163
+ next();
164
+ return;
165
+ }
166
+ // ENFORCE mode: emit the informational rate-limit headers and block
167
+ // over-limit requests with HTTP 429.
65
168
  if (config.standardHeaders !== false) {
66
169
  res.setHeader('X-RateLimit-Limit', effectiveMax.toString());
67
170
  res.setHeader('X-RateLimit-Remaining', remaining.toString());
68
171
  res.setHeader('X-RateLimit-Reset', resetSeconds.toString());
69
172
  }
70
- if (current.count > effectiveMax) {
173
+ if (overLimit) {
71
174
  // Include Retry-After header on 429 responses (RFC 6585 / RFC 7231)
72
175
  res.setHeader('Retry-After', resetSeconds.toString());
176
+ cortexRateLimitBlockedTotal.inc({ limiter: config.name, tier });
73
177
  res.status(429).json(config.message);
74
178
  return;
75
179
  }
@@ -79,10 +183,11 @@ function createRateLimiter(config) {
79
183
  /**
80
184
  * Rate limiter for GraphQL endpoint.
81
185
  *
82
- * Authenticated requests: 1000 requests per 15 minutes (configurable via RATE_LIMIT_MAX)
83
- * Unauthenticated requests: 200 requests per 15 minutes (configurable via RATE_LIMIT_MAX_UNAUTH)
186
+ * Verified requests: 1000 requests per 15 minutes (configurable via RATE_LIMIT_MAX)
187
+ * Anonymous requests: 200 requests per 15 minutes (configurable via RATE_LIMIT_MAX_UNAUTH)
84
188
  */
85
189
  export const graphqlRateLimiter = createRateLimiter({
190
+ name: 'graphql',
86
191
  windowMs: 15 * 60 * 1000, // 15 minutes
87
192
  maxAuthenticated: parseInt(process.env.RATE_LIMIT_MAX || '1000', 10),
88
193
  maxUnauthenticated: parseInt(process.env.RATE_LIMIT_MAX_UNAUTH || '200', 10),
@@ -95,10 +200,11 @@ export const graphqlRateLimiter = createRateLimiter({
95
200
  /**
96
201
  * Rate limiter for authentication endpoints.
97
202
  *
98
- * Authenticated requests: 50 requests per 15 minutes
99
- * Unauthenticated requests: 20 requests per 15 minutes
203
+ * Verified requests: 50 requests per 15 minutes
204
+ * Anonymous requests: 20 requests per 15 minutes
100
205
  */
101
206
  export const authRateLimiter = createRateLimiter({
207
+ name: 'auth',
102
208
  windowMs: 15 * 60 * 1000, // 15 minutes
103
209
  maxAuthenticated: 50,
104
210
  maxUnauthenticated: 20,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Rate-limit tier resolution (audit B01-backend-legacy-09).
3
+ *
4
+ * The rate limiter previously granted the 5x-higher authenticated tier to any
5
+ * request carrying a 3-dot-segment `Bearer` header — a trivially fabricated
6
+ * shape (`Bearer a.b.c`). This module keys the tier off ACTUAL verification
7
+ * instead, using the two offline-verifiable credential classes:
8
+ *
9
+ * 1. `SERVER_AUTH_TOKEN` exact match (the engine / server-to-server path).
10
+ * 2. App-issued HS256 JWTs verified against the shared `jwtSecret`.
11
+ *
12
+ * Google ID tokens (RS256, JWKS-verified) cannot be verified synchronously
13
+ * without a network dependency in the request hot path; they are deliberately
14
+ * tiered as `anon`. This is a metrics-only inaccuracy while the limiter is in
15
+ * shadow mode — before any enforce graduation, either extend this resolver
16
+ * with an async JWKS-backed path or raise the anon ceiling to cover browser
17
+ * traffic (tracked in the B01-02 graduation checklist).
18
+ *
19
+ * Verified results are memoised in a bounded, TTL'd cache keyed by the token's
20
+ * SHA-256 (raw tokens are never retained) so the per-request cost is one hash.
21
+ */
22
+ import type { Request } from 'express';
23
+ /** Rate-limit tier: `auth` = verified caller budget, `anon` = default budget. */
24
+ export type RateTier = 'auth' | 'anon';
25
+ /**
26
+ * Outcome of tier resolution for one request.
27
+ */
28
+ export interface RateTierResolution {
29
+ /** The tier the request's rate budget is drawn from. */
30
+ readonly tier: RateTier;
31
+ /**
32
+ * Stable per-principal bucket key (`sub:<sub>`) for verified callers, so
33
+ * authenticated traffic is bucketed per principal rather than per IP
34
+ * (audit B01-backend-legacy-02). `null` for anonymous callers — the limiter
35
+ * falls back to the client IP.
36
+ */
37
+ readonly principalKey: string | null;
38
+ }
39
+ /**
40
+ * Clears the memoised token-verdict cache. Test-only.
41
+ *
42
+ * @internal exported for testing
43
+ */
44
+ export declare function _resetRateTierCacheForTests(): void;
45
+ /**
46
+ * Resolves the rate-limit tier and principal bucket key for a request.
47
+ *
48
+ * Cheap on the hot path: one SHA-256 over the token plus a bounded map lookup;
49
+ * the HMAC verification runs at most once per token per TTL window.
50
+ *
51
+ * @param req - Incoming Express request.
52
+ * @returns The request's {@link RateTierResolution}; `anon` when no `Bearer`
53
+ * token is present or the token cannot be verified offline.
54
+ */
55
+ export declare function resolveRateTier(req: Request): RateTierResolution;
56
+ //# sourceMappingURL=rate-tier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-tier.d.ts","sourceRoot":"","sources":["../../../src/middleware/rate-tier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAGvC,iFAAiF;AACjF,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvC;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,wDAAwD;IACxD,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAiBD;;;;GAIG;AACH,wBAAgB,2BAA2B,IAAI,IAAI,CAElD;AAiCD;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,kBAAkB,CA6BhE"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-tier.js","sourceRoot":"","sources":["../../../src/middleware/rate-tier.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Rate-limit tier resolution (audit B01-backend-legacy-09).
3
+ *
4
+ * The rate limiter previously granted the 5x-higher authenticated tier to any
5
+ * request carrying a 3-dot-segment `Bearer` header — a trivially fabricated
6
+ * shape (`Bearer a.b.c`). This module keys the tier off ACTUAL verification
7
+ * instead, using the two offline-verifiable credential classes:
8
+ *
9
+ * 1. `SERVER_AUTH_TOKEN` exact match (the engine / server-to-server path).
10
+ * 2. App-issued HS256 JWTs verified against the shared `jwtSecret`.
11
+ *
12
+ * Google ID tokens (RS256, JWKS-verified) cannot be verified synchronously
13
+ * without a network dependency in the request hot path; they are deliberately
14
+ * tiered as `anon`. This is a metrics-only inaccuracy while the limiter is in
15
+ * shadow mode — before any enforce graduation, either extend this resolver
16
+ * with an async JWKS-backed path or raise the anon ceiling to cover browser
17
+ * traffic (tracked in the B01-02 graduation checklist).
18
+ *
19
+ * Verified results are memoised in a bounded, TTL'd cache keyed by the token's
20
+ * SHA-256 (raw tokens are never retained) so the per-request cost is one hash.
21
+ */
22
+ import { createHash } from 'crypto';
23
+ import jwt from 'jsonwebtoken';
24
+ import { jwtSecret } from '../config/jwtConfig.mjs';
25
+ /** Bound on memoised token verdicts; oldest entries evicted first. */
26
+ const TIER_CACHE_MAX_ENTRIES = 2048;
27
+ /** Memoised verdicts expire after one rate-limit window (15 minutes). */
28
+ const TIER_CACHE_TTL_MS = 15 * 60 * 1000;
29
+ const ANON_RESOLUTION = { tier: 'anon', principalKey: null };
30
+ const tierCache = new Map();
31
+ /**
32
+ * Clears the memoised token-verdict cache. Test-only.
33
+ *
34
+ * @internal exported for testing
35
+ */
36
+ export function _resetRateTierCacheForTests() {
37
+ tierCache.clear();
38
+ }
39
+ /**
40
+ * Verifies a bearer token offline and classifies its rate tier.
41
+ *
42
+ * @param token - Raw bearer token value (after the `Bearer ` prefix).
43
+ * @returns The resolved tier; `anon` for anything that fails verification.
44
+ */
45
+ function verifyTier(token) {
46
+ const serverAuthToken = process.env.SERVER_AUTH_TOKEN;
47
+ if (typeof serverAuthToken === 'string' &&
48
+ serverAuthToken.length > 0 &&
49
+ token === serverAuthToken) {
50
+ return { tier: 'auth', principalKey: 'sub:server' };
51
+ }
52
+ try {
53
+ // HS256 pinned for the same alg-confusion reasons as the token-verifier.
54
+ const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] });
55
+ if (typeof payload !== 'string' && typeof payload.sub === 'string') {
56
+ return { tier: 'auth', principalKey: `sub:${payload.sub}` };
57
+ }
58
+ return ANON_RESOLUTION;
59
+ }
60
+ catch {
61
+ // Unverifiable (bad signature, expired, RS256 Google ID token, garbage
62
+ // shape) — treated as anonymous for tier purposes. Never throws upward:
63
+ // the limiter must not be able to fail a request.
64
+ return ANON_RESOLUTION;
65
+ }
66
+ }
67
+ /**
68
+ * Resolves the rate-limit tier and principal bucket key for a request.
69
+ *
70
+ * Cheap on the hot path: one SHA-256 over the token plus a bounded map lookup;
71
+ * the HMAC verification runs at most once per token per TTL window.
72
+ *
73
+ * @param req - Incoming Express request.
74
+ * @returns The request's {@link RateTierResolution}; `anon` when no `Bearer`
75
+ * token is present or the token cannot be verified offline.
76
+ */
77
+ export function resolveRateTier(req) {
78
+ const authHeader = req.headers.authorization ?? '';
79
+ if (!authHeader.startsWith('Bearer ')) {
80
+ return ANON_RESOLUTION;
81
+ }
82
+ const token = authHeader.slice('Bearer '.length).trim();
83
+ if (token.length === 0) {
84
+ return ANON_RESOLUTION;
85
+ }
86
+ const cacheKey = createHash('sha256').update(token).digest('hex');
87
+ const now = Date.now();
88
+ const cached = tierCache.get(cacheKey);
89
+ if (cached && cached.expiresAt > now) {
90
+ return cached.resolution;
91
+ }
92
+ const resolution = verifyTier(token);
93
+ if (tierCache.size >= TIER_CACHE_MAX_ENTRIES) {
94
+ // Map preserves insertion order — evict the oldest entry.
95
+ const oldestKey = tierCache.keys().next().value;
96
+ if (oldestKey !== undefined) {
97
+ tierCache.delete(oldestKey);
98
+ }
99
+ }
100
+ tierCache.set(cacheKey, { resolution, expiresAt: now + TIER_CACHE_TTL_MS });
101
+ return resolution;
102
+ }
103
+ //# sourceMappingURL=rate-tier.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adaptic/backend-legacy",
3
- "version": "0.0.996",
3
+ "version": "0.0.998",
4
4
  "description": "Backend executable CRUD functions with dynamic variables construction, and type definitions for the Adaptic AI platform.",
5
5
  "type": "module",
6
6
  "types": "index.d.ts",