@constructive-io/express-context 0.26.2 → 0.27.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/admission.d.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * admission — the two in-process bounds that decide whether a request is
3
+ * allowed to start at all.
4
+ *
5
+ * `request-protection` resolves the numbers; this module is what spends them.
6
+ * The three GUCs it hands to PostgreSQL bound how *long* one request may run,
7
+ * which is a different question from how *many* may run at once: fifty legal
8
+ * 20-second queries saturate the pool that serves every other tenant on the
9
+ * replica without any single one of them misbehaving. So there are two bounds
10
+ * here, and they protect different things:
11
+ *
12
+ * - {@link ConcurrencyLimiter} bounds width per tenant. The resource being
13
+ * protected is *this process's* PostgreSQL pool, so the counter is
14
+ * deliberately in-process and `maxConcurrentRequests` is a per-replica
15
+ * budget. Coordinating it cluster-wide would need shared state on the hot
16
+ * path to bound something that is not shared.
17
+ *
18
+ * - {@link RateWindow} bounds one caller's rate. A tenant-wide rate limit
19
+ * cannot answer abuse on a public API: the tenant's own budget is what an
20
+ * anonymous flood spends, so exhausting it turns the tenant's site off on
21
+ * the attacker's behalf. The per-caller bound is therefore keyed on the
22
+ * only discriminator an anonymous request has, and it fails closed —
23
+ * unlike a billing quota, which serves and records overage.
24
+ *
25
+ * Neither touches the database. A limiter that needs a row lock to decide
26
+ * whether to admit a request has made the saturation it exists to prevent
27
+ * cheaper to cause.
28
+ */
29
+ /** Why a request was refused a slot. */
30
+ export type AdmissionRefusal =
31
+ /** The queue was already full, or the tenant chose not to wait. */
32
+ 'queue_full'
33
+ /** The request waited `maxQueueWaitMs` and no slot came free. */
34
+ | 'queue_timeout';
35
+ /** The outcome of an {@link ConcurrencyLimiter.acquire} call. */
36
+ export interface AdmissionLease {
37
+ /** Whether the request may proceed. */
38
+ readonly granted: boolean;
39
+ /** How long the request waited for a slot, in milliseconds. */
40
+ readonly queuedMs: number;
41
+ /** Set when `granted` is false. */
42
+ readonly refusal?: AdmissionRefusal;
43
+ /**
44
+ * Return the slot. Idempotent, and a no-op on a refused lease, so a caller
45
+ * can release unconditionally in a `finally` and again on client abort
46
+ * without double-counting.
47
+ */
48
+ release(): void;
49
+ }
50
+ /** What a single {@link ConcurrencyLimiter.acquire} is bounded by. */
51
+ export interface AdmissionRequest {
52
+ /** In-flight requests allowed for this key in this process. */
53
+ limit: number;
54
+ /** How long a request may wait for a slot. `0` means never queue. */
55
+ queueWaitMs: number;
56
+ /**
57
+ * How many requests may wait at once. Defaults to `limit`: a queue deeper
58
+ * than the number of slots draining it is latency nobody asked for, and an
59
+ * unbounded queue converts a saturated tenant into a memory leak.
60
+ */
61
+ queueLimit?: number;
62
+ }
63
+ /**
64
+ * Per-key in-flight counter with a bounded wait queue.
65
+ *
66
+ * A released slot is handed straight to the longest-waiting request rather
67
+ * than decremented and re-acquired, so a queue drains in arrival order and a
68
+ * burst of new arrivals cannot starve what is already waiting.
69
+ */
70
+ export declare class ConcurrencyLimiter {
71
+ private readonly keys;
72
+ /**
73
+ * Take a slot for `key`, waiting up to `queueWaitMs` for one.
74
+ *
75
+ * Always resolves — a refusal is a value, not a throw, because the caller
76
+ * has to answer the client either way.
77
+ */
78
+ acquire(key: string, request: AdmissionRequest): Promise<AdmissionLease>;
79
+ /** In-flight requests currently holding a slot for `key`. */
80
+ activeFor(key: string): number;
81
+ /** Requests currently waiting for a slot for `key`. */
82
+ queuedFor(key: string): number;
83
+ /** How many keys are being tracked — zero once every tenant goes idle. */
84
+ get trackedKeys(): number;
85
+ private lease;
86
+ /**
87
+ * Drop a key nobody is using, so the map is bounded by *live* tenants rather
88
+ * than by every tenant this replica has ever served.
89
+ */
90
+ private evictIfIdle;
91
+ }
92
+ /**
93
+ * Fixed-window request counter keyed by an arbitrary string.
94
+ *
95
+ * Ported from the sync/static gateways (`compute/services/worker/src/rate-window.ts`
96
+ * in constructive-db) rather than reinvented, so both edges refuse abuse the
97
+ * same way.
98
+ */
99
+ export declare class RateWindow {
100
+ private readonly windowMs;
101
+ private readonly maxKeys;
102
+ private readonly counts;
103
+ constructor(windowMs?: number, maxKeys?: number);
104
+ /**
105
+ * Count one request against `key`; returns false once it is over `limit`.
106
+ *
107
+ * `limit` is per call rather than per instance because it comes from the
108
+ * tenant's settings, and one process serves many tenants.
109
+ */
110
+ admit(key: string, limit: number, burst?: number): boolean;
111
+ /** Milliseconds until `key`'s window rolls over — the `Retry-After` hint. */
112
+ retryAfterMs(key: string): number;
113
+ }
114
+ /**
115
+ * The caller's address, read as far back through `X-Forwarded-For` as the
116
+ * deployment's own proxies reach — and no further.
117
+ *
118
+ * The header is caller-writable: whatever the client sends arrives at the left
119
+ * of the chain, and each proxy appends the peer it actually saw. So the
120
+ * rightmost entry is the one this deployment's edge observed and the leftmost
121
+ * is the one an abuser gets to choose. Taking the leftmost entry — the usual
122
+ * shape of this code — hands every attacker a fresh rate-limit key per request
123
+ * by sending a random `X-Forwarded-For`, which is worse than having no limiter
124
+ * at all, because it reads as one.
125
+ *
126
+ * `trustedProxyHops` is how many proxies of our own sit in front of the server:
127
+ * 0 means none (only the socket peer is believed, and the header is ignored
128
+ * outright), 1 means one ingress appended the client's address, and so on.
129
+ * Anything the chain cannot supply — an absent header, or a chain too short to
130
+ * have been written by all of them — falls back to the socket peer, which no
131
+ * caller can forge.
132
+ */
133
+ export declare const clientIpFrom: (req: {
134
+ headers: Record<string, string | string[] | undefined>;
135
+ socket?: {
136
+ remoteAddress?: string;
137
+ };
138
+ }, trustedProxyHops: number) => string;
139
+ /**
140
+ * How many proxies of our own sit in front of the server.
141
+ *
142
+ * Defaults to 0 — the socket peer, which cannot be forged. A deployment behind
143
+ * an ingress sets `TRUSTED_PROXY_HOPS` to the number of hops it owns; guessing
144
+ * a higher number here would let a direct caller pick its own limiter key.
145
+ */
146
+ export declare const trustedProxyHops: () => number;
package/admission.js ADDED
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ /**
3
+ * admission — the two in-process bounds that decide whether a request is
4
+ * allowed to start at all.
5
+ *
6
+ * `request-protection` resolves the numbers; this module is what spends them.
7
+ * The three GUCs it hands to PostgreSQL bound how *long* one request may run,
8
+ * which is a different question from how *many* may run at once: fifty legal
9
+ * 20-second queries saturate the pool that serves every other tenant on the
10
+ * replica without any single one of them misbehaving. So there are two bounds
11
+ * here, and they protect different things:
12
+ *
13
+ * - {@link ConcurrencyLimiter} bounds width per tenant. The resource being
14
+ * protected is *this process's* PostgreSQL pool, so the counter is
15
+ * deliberately in-process and `maxConcurrentRequests` is a per-replica
16
+ * budget. Coordinating it cluster-wide would need shared state on the hot
17
+ * path to bound something that is not shared.
18
+ *
19
+ * - {@link RateWindow} bounds one caller's rate. A tenant-wide rate limit
20
+ * cannot answer abuse on a public API: the tenant's own budget is what an
21
+ * anonymous flood spends, so exhausting it turns the tenant's site off on
22
+ * the attacker's behalf. The per-caller bound is therefore keyed on the
23
+ * only discriminator an anonymous request has, and it fails closed —
24
+ * unlike a billing quota, which serves and records overage.
25
+ *
26
+ * Neither touches the database. A limiter that needs a row lock to decide
27
+ * whether to admit a request has made the saturation it exists to prevent
28
+ * cheaper to cause.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.trustedProxyHops = exports.clientIpFrom = exports.RateWindow = exports.ConcurrencyLimiter = void 0;
32
+ const REFUSED = (refusal, queuedMs) => ({
33
+ granted: false,
34
+ queuedMs,
35
+ refusal,
36
+ release: () => { }
37
+ });
38
+ /**
39
+ * Per-key in-flight counter with a bounded wait queue.
40
+ *
41
+ * A released slot is handed straight to the longest-waiting request rather
42
+ * than decremented and re-acquired, so a queue drains in arrival order and a
43
+ * burst of new arrivals cannot starve what is already waiting.
44
+ */
45
+ class ConcurrencyLimiter {
46
+ keys = new Map();
47
+ /**
48
+ * Take a slot for `key`, waiting up to `queueWaitMs` for one.
49
+ *
50
+ * Always resolves — a refusal is a value, not a throw, because the caller
51
+ * has to answer the client either way.
52
+ */
53
+ async acquire(key, request) {
54
+ const limit = Math.floor(request.limit);
55
+ // A non-positive limit is a misconfiguration, not "unlimited": the bounds
56
+ // in request-protection have a floor of 1, so reaching here with 0 means
57
+ // something bypassed them. Refuse rather than silently admit everything.
58
+ if (!Number.isFinite(limit) || limit <= 0)
59
+ return REFUSED('queue_full', 0);
60
+ const state = this.keys.get(key) ?? { active: 0, queue: [] };
61
+ if (!this.keys.has(key))
62
+ this.keys.set(key, state);
63
+ if (state.active < limit) {
64
+ state.active += 1;
65
+ return this.lease(key, state, 0);
66
+ }
67
+ const queueLimit = Math.max(Math.floor(request.queueLimit ?? limit), 0);
68
+ const queueWaitMs = Math.max(request.queueWaitMs, 0);
69
+ if (queueWaitMs === 0 || state.queue.length >= queueLimit) {
70
+ this.evictIfIdle(key, state);
71
+ return REFUSED('queue_full', 0);
72
+ }
73
+ const waitStarted = Date.now();
74
+ const granted = await new Promise((resolve) => {
75
+ let settled = false;
76
+ const waiter = {
77
+ settle: (value) => {
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ clearTimeout(waiter.timer);
82
+ resolve(value);
83
+ },
84
+ timer: setTimeout(() => {
85
+ const index = state.queue.indexOf(waiter);
86
+ if (index >= 0)
87
+ state.queue.splice(index, 1);
88
+ waiter.settle(false);
89
+ }, queueWaitMs)
90
+ };
91
+ state.queue.push(waiter);
92
+ });
93
+ const queuedMs = Date.now() - waitStarted;
94
+ if (!granted) {
95
+ this.evictIfIdle(key, state);
96
+ return REFUSED('queue_timeout', queuedMs);
97
+ }
98
+ // The slot was transferred by `release`, which left `active` untouched.
99
+ return this.lease(key, state, queuedMs);
100
+ }
101
+ /** In-flight requests currently holding a slot for `key`. */
102
+ activeFor(key) {
103
+ return this.keys.get(key)?.active ?? 0;
104
+ }
105
+ /** Requests currently waiting for a slot for `key`. */
106
+ queuedFor(key) {
107
+ return this.keys.get(key)?.queue.length ?? 0;
108
+ }
109
+ /** How many keys are being tracked — zero once every tenant goes idle. */
110
+ get trackedKeys() {
111
+ return this.keys.size;
112
+ }
113
+ lease(key, state, queuedMs) {
114
+ let released = false;
115
+ return {
116
+ granted: true,
117
+ queuedMs,
118
+ release: () => {
119
+ if (released)
120
+ return;
121
+ released = true;
122
+ const next = state.queue.shift();
123
+ if (next) {
124
+ // Hand the slot over instead of freeing it: `active` is unchanged
125
+ // and the woken request owns what this one held.
126
+ next.settle(true);
127
+ return;
128
+ }
129
+ state.active -= 1;
130
+ this.evictIfIdle(key, state);
131
+ }
132
+ };
133
+ }
134
+ /**
135
+ * Drop a key nobody is using, so the map is bounded by *live* tenants rather
136
+ * than by every tenant this replica has ever served.
137
+ */
138
+ evictIfIdle(key, state) {
139
+ if (state.active <= 0 && state.queue.length === 0)
140
+ this.keys.delete(key);
141
+ }
142
+ }
143
+ exports.ConcurrencyLimiter = ConcurrencyLimiter;
144
+ // ─── Per-caller rate ────────────────────────────────────────────────────────
145
+ /**
146
+ * Fixed-window request counter keyed by an arbitrary string.
147
+ *
148
+ * Ported from the sync/static gateways (`compute/services/worker/src/rate-window.ts`
149
+ * in constructive-db) rather than reinvented, so both edges refuse abuse the
150
+ * same way.
151
+ */
152
+ class RateWindow {
153
+ windowMs;
154
+ maxKeys;
155
+ counts = new Map();
156
+ constructor(windowMs = 60_000, maxKeys = 10_000) {
157
+ this.windowMs = windowMs;
158
+ this.maxKeys = maxKeys;
159
+ }
160
+ /**
161
+ * Count one request against `key`; returns false once it is over `limit`.
162
+ *
163
+ * `limit` is per call rather than per instance because it comes from the
164
+ * tenant's settings, and one process serves many tenants.
165
+ */
166
+ admit(key, limit, burst = 0) {
167
+ const ceiling = Math.floor(limit) + Math.max(Math.floor(burst), 0);
168
+ if (!Number.isFinite(ceiling) || ceiling <= 0)
169
+ return false;
170
+ const now = Date.now();
171
+ const entry = this.counts.get(key);
172
+ if (!entry || now - entry.windowStart >= this.windowMs) {
173
+ this.counts.set(key, { windowStart: now, count: 1 });
174
+ // Opportunistic sweep: drop stale windows so the map stays bounded.
175
+ if (this.counts.size > this.maxKeys) {
176
+ for (const [k, v] of this.counts) {
177
+ if (now - v.windowStart >= this.windowMs)
178
+ this.counts.delete(k);
179
+ }
180
+ }
181
+ return true;
182
+ }
183
+ entry.count += 1;
184
+ return entry.count <= ceiling;
185
+ }
186
+ /** Milliseconds until `key`'s window rolls over — the `Retry-After` hint. */
187
+ retryAfterMs(key) {
188
+ const entry = this.counts.get(key);
189
+ if (!entry)
190
+ return 0;
191
+ return Math.max(this.windowMs - (Date.now() - entry.windowStart), 0);
192
+ }
193
+ }
194
+ exports.RateWindow = RateWindow;
195
+ /**
196
+ * The caller's address, read as far back through `X-Forwarded-For` as the
197
+ * deployment's own proxies reach — and no further.
198
+ *
199
+ * The header is caller-writable: whatever the client sends arrives at the left
200
+ * of the chain, and each proxy appends the peer it actually saw. So the
201
+ * rightmost entry is the one this deployment's edge observed and the leftmost
202
+ * is the one an abuser gets to choose. Taking the leftmost entry — the usual
203
+ * shape of this code — hands every attacker a fresh rate-limit key per request
204
+ * by sending a random `X-Forwarded-For`, which is worse than having no limiter
205
+ * at all, because it reads as one.
206
+ *
207
+ * `trustedProxyHops` is how many proxies of our own sit in front of the server:
208
+ * 0 means none (only the socket peer is believed, and the header is ignored
209
+ * outright), 1 means one ingress appended the client's address, and so on.
210
+ * Anything the chain cannot supply — an absent header, or a chain too short to
211
+ * have been written by all of them — falls back to the socket peer, which no
212
+ * caller can forge.
213
+ */
214
+ const clientIpFrom = (req, trustedProxyHops) => {
215
+ const socketIp = req.socket?.remoteAddress ?? 'unknown';
216
+ if (trustedProxyHops <= 0)
217
+ return socketIp;
218
+ const header = req.headers['x-forwarded-for'];
219
+ const raw = Array.isArray(header) ? header.join(',') : header;
220
+ if (!raw)
221
+ return socketIp;
222
+ const chain = raw
223
+ .split(',')
224
+ .map((entry) => entry.trim())
225
+ .filter(Boolean);
226
+ // A chain with fewer entries than we own proxies cannot have been written by
227
+ // all of them, so the request did not travel the path this number describes
228
+ // and nothing in the header is attributable. Reading its leftmost entry would
229
+ // return exactly the value a direct caller chose.
230
+ if (chain.length < trustedProxyHops)
231
+ return socketIp;
232
+ // Otherwise walk in from the right by the number of proxies we own: with one
233
+ // ingress a legitimate chain is `[client]` and a forged one is
234
+ // `[forged, client]`, and both resolve to the entry the ingress appended.
235
+ return chain[chain.length - trustedProxyHops];
236
+ };
237
+ exports.clientIpFrom = clientIpFrom;
238
+ /**
239
+ * How many proxies of our own sit in front of the server.
240
+ *
241
+ * Defaults to 0 — the socket peer, which cannot be forged. A deployment behind
242
+ * an ingress sets `TRUSTED_PROXY_HOPS` to the number of hops it owns; guessing
243
+ * a higher number here would let a direct caller pick its own limiter key.
244
+ */
245
+ const trustedProxyHops = () => {
246
+ const raw = Number(process.env.TRUSTED_PROXY_HOPS ?? '');
247
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
248
+ };
249
+ exports.trustedProxyHops = trustedProxyHops;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * admission — the two in-process bounds that decide whether a request is
3
+ * allowed to start at all.
4
+ *
5
+ * `request-protection` resolves the numbers; this module is what spends them.
6
+ * The three GUCs it hands to PostgreSQL bound how *long* one request may run,
7
+ * which is a different question from how *many* may run at once: fifty legal
8
+ * 20-second queries saturate the pool that serves every other tenant on the
9
+ * replica without any single one of them misbehaving. So there are two bounds
10
+ * here, and they protect different things:
11
+ *
12
+ * - {@link ConcurrencyLimiter} bounds width per tenant. The resource being
13
+ * protected is *this process's* PostgreSQL pool, so the counter is
14
+ * deliberately in-process and `maxConcurrentRequests` is a per-replica
15
+ * budget. Coordinating it cluster-wide would need shared state on the hot
16
+ * path to bound something that is not shared.
17
+ *
18
+ * - {@link RateWindow} bounds one caller's rate. A tenant-wide rate limit
19
+ * cannot answer abuse on a public API: the tenant's own budget is what an
20
+ * anonymous flood spends, so exhausting it turns the tenant's site off on
21
+ * the attacker's behalf. The per-caller bound is therefore keyed on the
22
+ * only discriminator an anonymous request has, and it fails closed —
23
+ * unlike a billing quota, which serves and records overage.
24
+ *
25
+ * Neither touches the database. A limiter that needs a row lock to decide
26
+ * whether to admit a request has made the saturation it exists to prevent
27
+ * cheaper to cause.
28
+ */
29
+ const REFUSED = (refusal, queuedMs) => ({
30
+ granted: false,
31
+ queuedMs,
32
+ refusal,
33
+ release: () => { }
34
+ });
35
+ /**
36
+ * Per-key in-flight counter with a bounded wait queue.
37
+ *
38
+ * A released slot is handed straight to the longest-waiting request rather
39
+ * than decremented and re-acquired, so a queue drains in arrival order and a
40
+ * burst of new arrivals cannot starve what is already waiting.
41
+ */
42
+ export class ConcurrencyLimiter {
43
+ keys = new Map();
44
+ /**
45
+ * Take a slot for `key`, waiting up to `queueWaitMs` for one.
46
+ *
47
+ * Always resolves — a refusal is a value, not a throw, because the caller
48
+ * has to answer the client either way.
49
+ */
50
+ async acquire(key, request) {
51
+ const limit = Math.floor(request.limit);
52
+ // A non-positive limit is a misconfiguration, not "unlimited": the bounds
53
+ // in request-protection have a floor of 1, so reaching here with 0 means
54
+ // something bypassed them. Refuse rather than silently admit everything.
55
+ if (!Number.isFinite(limit) || limit <= 0)
56
+ return REFUSED('queue_full', 0);
57
+ const state = this.keys.get(key) ?? { active: 0, queue: [] };
58
+ if (!this.keys.has(key))
59
+ this.keys.set(key, state);
60
+ if (state.active < limit) {
61
+ state.active += 1;
62
+ return this.lease(key, state, 0);
63
+ }
64
+ const queueLimit = Math.max(Math.floor(request.queueLimit ?? limit), 0);
65
+ const queueWaitMs = Math.max(request.queueWaitMs, 0);
66
+ if (queueWaitMs === 0 || state.queue.length >= queueLimit) {
67
+ this.evictIfIdle(key, state);
68
+ return REFUSED('queue_full', 0);
69
+ }
70
+ const waitStarted = Date.now();
71
+ const granted = await new Promise((resolve) => {
72
+ let settled = false;
73
+ const waiter = {
74
+ settle: (value) => {
75
+ if (settled)
76
+ return;
77
+ settled = true;
78
+ clearTimeout(waiter.timer);
79
+ resolve(value);
80
+ },
81
+ timer: setTimeout(() => {
82
+ const index = state.queue.indexOf(waiter);
83
+ if (index >= 0)
84
+ state.queue.splice(index, 1);
85
+ waiter.settle(false);
86
+ }, queueWaitMs)
87
+ };
88
+ state.queue.push(waiter);
89
+ });
90
+ const queuedMs = Date.now() - waitStarted;
91
+ if (!granted) {
92
+ this.evictIfIdle(key, state);
93
+ return REFUSED('queue_timeout', queuedMs);
94
+ }
95
+ // The slot was transferred by `release`, which left `active` untouched.
96
+ return this.lease(key, state, queuedMs);
97
+ }
98
+ /** In-flight requests currently holding a slot for `key`. */
99
+ activeFor(key) {
100
+ return this.keys.get(key)?.active ?? 0;
101
+ }
102
+ /** Requests currently waiting for a slot for `key`. */
103
+ queuedFor(key) {
104
+ return this.keys.get(key)?.queue.length ?? 0;
105
+ }
106
+ /** How many keys are being tracked — zero once every tenant goes idle. */
107
+ get trackedKeys() {
108
+ return this.keys.size;
109
+ }
110
+ lease(key, state, queuedMs) {
111
+ let released = false;
112
+ return {
113
+ granted: true,
114
+ queuedMs,
115
+ release: () => {
116
+ if (released)
117
+ return;
118
+ released = true;
119
+ const next = state.queue.shift();
120
+ if (next) {
121
+ // Hand the slot over instead of freeing it: `active` is unchanged
122
+ // and the woken request owns what this one held.
123
+ next.settle(true);
124
+ return;
125
+ }
126
+ state.active -= 1;
127
+ this.evictIfIdle(key, state);
128
+ }
129
+ };
130
+ }
131
+ /**
132
+ * Drop a key nobody is using, so the map is bounded by *live* tenants rather
133
+ * than by every tenant this replica has ever served.
134
+ */
135
+ evictIfIdle(key, state) {
136
+ if (state.active <= 0 && state.queue.length === 0)
137
+ this.keys.delete(key);
138
+ }
139
+ }
140
+ // ─── Per-caller rate ────────────────────────────────────────────────────────
141
+ /**
142
+ * Fixed-window request counter keyed by an arbitrary string.
143
+ *
144
+ * Ported from the sync/static gateways (`compute/services/worker/src/rate-window.ts`
145
+ * in constructive-db) rather than reinvented, so both edges refuse abuse the
146
+ * same way.
147
+ */
148
+ export class RateWindow {
149
+ windowMs;
150
+ maxKeys;
151
+ counts = new Map();
152
+ constructor(windowMs = 60_000, maxKeys = 10_000) {
153
+ this.windowMs = windowMs;
154
+ this.maxKeys = maxKeys;
155
+ }
156
+ /**
157
+ * Count one request against `key`; returns false once it is over `limit`.
158
+ *
159
+ * `limit` is per call rather than per instance because it comes from the
160
+ * tenant's settings, and one process serves many tenants.
161
+ */
162
+ admit(key, limit, burst = 0) {
163
+ const ceiling = Math.floor(limit) + Math.max(Math.floor(burst), 0);
164
+ if (!Number.isFinite(ceiling) || ceiling <= 0)
165
+ return false;
166
+ const now = Date.now();
167
+ const entry = this.counts.get(key);
168
+ if (!entry || now - entry.windowStart >= this.windowMs) {
169
+ this.counts.set(key, { windowStart: now, count: 1 });
170
+ // Opportunistic sweep: drop stale windows so the map stays bounded.
171
+ if (this.counts.size > this.maxKeys) {
172
+ for (const [k, v] of this.counts) {
173
+ if (now - v.windowStart >= this.windowMs)
174
+ this.counts.delete(k);
175
+ }
176
+ }
177
+ return true;
178
+ }
179
+ entry.count += 1;
180
+ return entry.count <= ceiling;
181
+ }
182
+ /** Milliseconds until `key`'s window rolls over — the `Retry-After` hint. */
183
+ retryAfterMs(key) {
184
+ const entry = this.counts.get(key);
185
+ if (!entry)
186
+ return 0;
187
+ return Math.max(this.windowMs - (Date.now() - entry.windowStart), 0);
188
+ }
189
+ }
190
+ /**
191
+ * The caller's address, read as far back through `X-Forwarded-For` as the
192
+ * deployment's own proxies reach — and no further.
193
+ *
194
+ * The header is caller-writable: whatever the client sends arrives at the left
195
+ * of the chain, and each proxy appends the peer it actually saw. So the
196
+ * rightmost entry is the one this deployment's edge observed and the leftmost
197
+ * is the one an abuser gets to choose. Taking the leftmost entry — the usual
198
+ * shape of this code — hands every attacker a fresh rate-limit key per request
199
+ * by sending a random `X-Forwarded-For`, which is worse than having no limiter
200
+ * at all, because it reads as one.
201
+ *
202
+ * `trustedProxyHops` is how many proxies of our own sit in front of the server:
203
+ * 0 means none (only the socket peer is believed, and the header is ignored
204
+ * outright), 1 means one ingress appended the client's address, and so on.
205
+ * Anything the chain cannot supply — an absent header, or a chain too short to
206
+ * have been written by all of them — falls back to the socket peer, which no
207
+ * caller can forge.
208
+ */
209
+ export const clientIpFrom = (req, trustedProxyHops) => {
210
+ const socketIp = req.socket?.remoteAddress ?? 'unknown';
211
+ if (trustedProxyHops <= 0)
212
+ return socketIp;
213
+ const header = req.headers['x-forwarded-for'];
214
+ const raw = Array.isArray(header) ? header.join(',') : header;
215
+ if (!raw)
216
+ return socketIp;
217
+ const chain = raw
218
+ .split(',')
219
+ .map((entry) => entry.trim())
220
+ .filter(Boolean);
221
+ // A chain with fewer entries than we own proxies cannot have been written by
222
+ // all of them, so the request did not travel the path this number describes
223
+ // and nothing in the header is attributable. Reading its leftmost entry would
224
+ // return exactly the value a direct caller chose.
225
+ if (chain.length < trustedProxyHops)
226
+ return socketIp;
227
+ // Otherwise walk in from the right by the number of proxies we own: with one
228
+ // ingress a legitimate chain is `[client]` and a forged one is
229
+ // `[forged, client]`, and both resolve to the entry the ingress appended.
230
+ return chain[chain.length - trustedProxyHops];
231
+ };
232
+ /**
233
+ * How many proxies of our own sit in front of the server.
234
+ *
235
+ * Defaults to 0 — the socket peer, which cannot be forged. A deployment behind
236
+ * an ingress sets `TRUSTED_PROXY_HOPS` to the number of hops it owns; guessing
237
+ * a higher number here would let a direct caller pick its own limiter key.
238
+ */
239
+ export const trustedProxyHops = () => {
240
+ const raw = Number(process.env.TRUSTED_PROXY_HOPS ?? '');
241
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
242
+ };
package/esm/index.js CHANGED
@@ -39,6 +39,9 @@ export { buildPgSettings } from './pg-settings';
39
39
  // withPgClient helper
40
40
  export { withPgClient } from './pg-client';
41
41
  export { ASSUMED_PAGE_SIZE, DEFAULT_REQUEST_PROTECTION, INTROSPECTION_BOUND, PROTECTION_BOUNDS, protectionPgSettings, resolveRequestProtection, } from './request-protection';
42
+ export { clientIpFrom, ConcurrencyLimiter, RateWindow, trustedProxyHops } from './admission';
43
+ export { createRecordRefusalsSink, DEFAULT_REFUSAL_FLUSH_INTERVAL_MS, DEFAULT_REFUSAL_FLUSH_JITTER_MS, DEFAULT_REFUSAL_MAX_KEYS, OVERFLOW_ROUTE, OVERFLOW_SOURCE, REFUSAL_REASONS, refusalKeyOf, refusalOverflowKey, RefusalRecorder, refusalRows, sourceBucket, UNKNOWN_SOURCE } from './refusals';
44
+ export { BoundedCounter, CounterFlusher } from './usage-counter';
42
45
  // Request ID middleware
43
46
  export { requestIdMiddleware } from './request-id';
44
47
  export { buildContext, createContextMiddleware } from './context';