@superfunctions/middleware 0.1.0 → 0.3.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.
@@ -1,4 +1,5 @@
1
- import type { Adapter, KVStoreAdapter } from '@superfunctions/db';
1
+ import { type Adapter, type AtomicKVStoreAdapter, type KVStoreAdapter } from '@superfunctions/db';
2
+ import { type ObservabilityInput } from '@superfunctions/observability';
2
3
  export { KVStoreAdapter };
3
4
  export type LegacyWindowAlgorithm = 'sliding-window' | 'fixed-window';
4
5
  export type RateLimitAlgorithm = LegacyWindowAlgorithm | 'token-bucket';
@@ -7,8 +8,11 @@ export interface RateLimitConfig {
7
8
  maxRequests: number;
8
9
  keyPrefix?: string;
9
10
  persistence?: Adapter | KVStoreAdapter;
11
+ atomicStore?: AtomicKVStoreAdapter;
10
12
  algorithm?: RateLimitAlgorithm;
11
13
  now?: () => number;
14
+ observability?: ObservabilityInput;
15
+ component?: string;
12
16
  }
13
17
  export interface RateLimitResult {
14
18
  allowed: boolean;
@@ -21,8 +25,24 @@ export interface CheckLimitInput {
21
25
  windowSeconds?: number;
22
26
  limit?: number;
23
27
  }
28
+ export interface CheckManyLimitsInput {
29
+ keys: string[];
30
+ /** Override the shared limit for each unprefixed key. */
31
+ limitsByKey?: Record<string, number>;
32
+ windowSeconds?: number;
33
+ limit?: number;
34
+ }
35
+ export interface RateLimitManyResult {
36
+ allowed: boolean;
37
+ blockedKeys?: string[];
38
+ remainingByKey: Map<string, number>;
39
+ resetAtByKey: Map<string, string>;
40
+ resetAt: string;
41
+ total: number;
42
+ }
24
43
  export interface RateLimiter {
25
44
  check(input: CheckLimitInput): Promise<RateLimitResult>;
45
+ checkMany(input: CheckManyLimitsInput): Promise<RateLimitManyResult>;
26
46
  reset(key: string): Promise<void>;
27
47
  }
28
48
  declare function createInMemoryKVStore(now?: () => number): KVStoreAdapter;
@@ -1,3 +1,5 @@
1
+ import { instrumentKVStore } from '@superfunctions/db';
2
+ import { normalizeObservability } from '@superfunctions/observability';
1
3
  function createInMemoryKVStore(now = Date.now) {
2
4
  const store = new Map();
3
5
  return {
@@ -59,8 +61,7 @@ function ensureKV(p, now) {
59
61
  }
60
62
  return p;
61
63
  }
62
- async function getState(kv, key) {
63
- const data = await kv.get(key);
64
+ function parseState(data) {
64
65
  if (!data)
65
66
  return null;
66
67
  try {
@@ -70,20 +71,25 @@ async function getState(kv, key) {
70
71
  return null;
71
72
  }
72
73
  }
73
- async function setState(kv, key, state, ttlMs) {
74
- if (ttlMs <= 0) {
75
- return;
76
- }
77
- await kv.set({
78
- key,
79
- value: JSON.stringify(state),
80
- ttlSeconds: Math.ceil(ttlMs / 1000),
81
- });
82
- }
83
74
  export function createRateLimiter(config) {
84
75
  const now = config.now ?? Date.now;
85
- const { windowMs, maxRequests, keyPrefix = 'ratelimit:', persistence = createInMemoryKVStore(now), algorithm = 'fixed-window', } = config;
86
- const kv = ensureKV(persistence, now);
76
+ const { windowMs, maxRequests, keyPrefix = 'ratelimit:', persistence = createInMemoryKVStore(now), atomicStore, algorithm = 'fixed-window', observability: observabilityInput, component = 'rate-limit', } = config;
77
+ const observability = normalizeObservability(observabilityInput)?.child({ component });
78
+ if (atomicStore && typeof atomicStore.compareAndSet !== 'function') {
79
+ throw new Error('RATE_LIMIT_ATOMIC_CAS_REQUIRED');
80
+ }
81
+ const atomic = atomicStore
82
+ ? instrumentKVStore(atomicStore, {
83
+ observability,
84
+ kind: 'cache',
85
+ component: `${component}.atomic`,
86
+ })
87
+ : undefined;
88
+ const kv = instrumentKVStore(ensureKV(persistence, now), {
89
+ observability,
90
+ kind: 'cache',
91
+ component: `${component}.cache`,
92
+ });
87
93
  const keyLocks = new Map();
88
94
  async function withKeyLock(key, work) {
89
95
  const prior = keyLocks.get(key) ?? Promise.resolve();
@@ -104,89 +110,236 @@ export function createRateLimiter(config) {
104
110
  }
105
111
  }
106
112
  }
113
+ async function withKeyLocks(keys, work) {
114
+ const uniqueKeys = [...new Set(keys)].sort();
115
+ const releases = [];
116
+ const queuedLocks = [];
117
+ try {
118
+ for (const key of uniqueKeys) {
119
+ const prior = keyLocks.get(key) ?? Promise.resolve();
120
+ let resolveLock;
121
+ const current = new Promise((resolve) => {
122
+ resolveLock = resolve;
123
+ });
124
+ const queued = prior.then(() => current);
125
+ keyLocks.set(key, queued);
126
+ queuedLocks.push({ key, queued });
127
+ releases.push(resolveLock);
128
+ await prior;
129
+ }
130
+ return await work();
131
+ }
132
+ finally {
133
+ for (let index = releases.length - 1; index >= 0; index -= 1) {
134
+ releases[index]();
135
+ }
136
+ for (const { key, queued } of queuedLocks) {
137
+ if (keyLocks.get(key) === queued) {
138
+ keyLocks.delete(key);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ function evaluateState(rawState, currentTime, effectiveWindowMs, effectiveLimit, commit) {
144
+ if (!Number.isFinite(effectiveWindowMs) || effectiveWindowMs <= 0) {
145
+ throw new Error('RATE_LIMIT_WINDOW_INVALID');
146
+ }
147
+ if (!Number.isFinite(effectiveLimit) || effectiveLimit < 0) {
148
+ throw new Error('RATE_LIMIT_LIMIT_INVALID');
149
+ }
150
+ if (algorithm === 'sliding-window') {
151
+ const state = parseState(rawState) ?? { timestamps: [] };
152
+ const floor = currentTime - effectiveWindowMs;
153
+ const timestamps = state.timestamps.filter((timestamp) => timestamp > floor);
154
+ const allowed = timestamps.length < effectiveLimit;
155
+ if (allowed && commit) {
156
+ timestamps.push(currentTime);
157
+ }
158
+ const oldest = timestamps[0] ?? currentTime;
159
+ const resetAt = oldest + effectiveWindowMs;
160
+ return {
161
+ result: {
162
+ allowed,
163
+ remaining: Math.max(0, effectiveLimit - timestamps.length),
164
+ resetAt: new Date(resetAt).toISOString(),
165
+ total: effectiveLimit,
166
+ },
167
+ ...(commit
168
+ ? {
169
+ nextState: { timestamps, expiresAt: resetAt },
170
+ ttlMs: Math.max(1, resetAt - currentTime),
171
+ }
172
+ : {}),
173
+ };
174
+ }
175
+ if (algorithm === 'token-bucket') {
176
+ if (effectiveLimit <= 0) {
177
+ const resetAt = currentTime + Math.max(0, effectiveWindowMs);
178
+ return {
179
+ result: {
180
+ allowed: false,
181
+ remaining: 0,
182
+ resetAt: new Date(resetAt).toISOString(),
183
+ total: effectiveLimit,
184
+ },
185
+ };
186
+ }
187
+ const refillRatePerMs = effectiveLimit / effectiveWindowMs;
188
+ const state = parseState(rawState) ?? {
189
+ tokens: effectiveLimit,
190
+ lastRefill: currentTime,
191
+ };
192
+ const elapsed = Math.max(0, currentTime - state.lastRefill);
193
+ const tokens = Math.min(effectiveLimit, state.tokens + elapsed * refillRatePerMs);
194
+ const allowed = tokens >= 1;
195
+ const nextTokens = allowed && commit ? tokens - 1 : tokens;
196
+ const msUntilNextToken = nextTokens >= 1 ? 0 : Math.ceil((1 - nextTokens) / refillRatePerMs);
197
+ const msUntilFull = Math.ceil((effectiveLimit - nextTokens) / refillRatePerMs);
198
+ const resetAt = currentTime + msUntilNextToken;
199
+ return {
200
+ result: {
201
+ allowed,
202
+ remaining: Math.max(0, Math.floor(nextTokens)),
203
+ resetAt: new Date(resetAt).toISOString(),
204
+ total: effectiveLimit,
205
+ },
206
+ ...(commit
207
+ ? {
208
+ nextState: {
209
+ tokens: nextTokens,
210
+ lastRefill: currentTime,
211
+ expiresAt: currentTime + Math.max(1, msUntilFull),
212
+ },
213
+ ttlMs: Math.max(1, msUntilFull),
214
+ }
215
+ : {}),
216
+ };
217
+ }
218
+ const state = parseState(rawState) ?? {
219
+ count: 0,
220
+ windowStart: currentTime,
221
+ };
222
+ const windowState = currentTime >= state.windowStart + effectiveWindowMs
223
+ ? { count: 0, windowStart: currentTime }
224
+ : { ...state };
225
+ const allowed = windowState.count < effectiveLimit;
226
+ if (allowed && commit) {
227
+ windowState.count += 1;
228
+ }
229
+ const resetAt = windowState.windowStart + effectiveWindowMs;
230
+ return {
231
+ result: {
232
+ allowed,
233
+ remaining: Math.max(0, effectiveLimit - windowState.count),
234
+ resetAt: new Date(resetAt).toISOString(),
235
+ total: effectiveLimit,
236
+ },
237
+ ...(commit
238
+ ? {
239
+ nextState: { ...windowState, expiresAt: resetAt },
240
+ ttlMs: Math.max(1, resetAt - currentTime),
241
+ }
242
+ : {}),
243
+ };
244
+ }
245
+ async function evaluateKey(key, currentTime, effectiveWindowMs, effectiveLimit, commit) {
246
+ const evaluation = evaluateState(await kv.get(key), currentTime, effectiveWindowMs, effectiveLimit, commit);
247
+ if (commit && evaluation.nextState !== undefined && evaluation.ttlMs !== undefined) {
248
+ await kv.set({
249
+ key,
250
+ value: JSON.stringify(evaluation.nextState),
251
+ ttlSeconds: Math.ceil(evaluation.ttlMs / 1000),
252
+ });
253
+ }
254
+ return evaluation.result;
255
+ }
256
+ async function evaluateAtomicKey(key, currentTime, effectiveWindowMs, effectiveLimit, commit) {
257
+ if (!atomic?.compareAndSet) {
258
+ throw new Error('RATE_LIMIT_ATOMIC_CAS_REQUIRED');
259
+ }
260
+ for (let attempt = 0; attempt < 16; attempt += 1) {
261
+ const expected = await atomic.get(key);
262
+ const evaluation = evaluateState(expected, currentTime, effectiveWindowMs, effectiveLimit, commit);
263
+ if (!commit || evaluation.nextState === undefined || evaluation.ttlMs === undefined) {
264
+ return evaluation.result;
265
+ }
266
+ const result = await atomic.compareAndSet({
267
+ key,
268
+ expected,
269
+ value: JSON.stringify(evaluation.nextState),
270
+ ttlSeconds: Math.ceil(evaluation.ttlMs / 1000),
271
+ });
272
+ if (result.updated) {
273
+ return evaluation.result;
274
+ }
275
+ }
276
+ throw new Error('RATE_LIMIT_ATOMIC_CONTENTION');
277
+ }
278
+ const evaluateConfiguredKey = atomic ? evaluateAtomicKey : evaluateKey;
107
279
  return {
108
280
  async check(input) {
109
281
  const key = `${keyPrefix}${input.key}`;
110
282
  return withKeyLock(key, async () => {
111
283
  const effectiveWindowMs = (input.windowSeconds !== undefined ? input.windowSeconds * 1000 : undefined) ?? windowMs;
112
284
  const effectiveLimit = input.limit ?? maxRequests;
285
+ return evaluateConfiguredKey(key, now(), effectiveWindowMs, effectiveLimit, true);
286
+ });
287
+ },
288
+ async checkMany(input) {
289
+ const uniqueKeys = [...new Set(input.keys)];
290
+ const effectiveWindowMs = (input.windowSeconds !== undefined ? input.windowSeconds * 1000 : undefined) ?? windowMs;
291
+ const effectiveLimit = input.limit ?? maxRequests;
292
+ const limits = uniqueKeys.map((key) => input.limitsByKey && Object.prototype.hasOwnProperty.call(input.limitsByKey, key) ? input.limitsByKey[key] ?? effectiveLimit : effectiveLimit);
293
+ const namespacedKeys = uniqueKeys.map((key) => `${keyPrefix}${key}`);
294
+ if (uniqueKeys.length === 0) {
295
+ return {
296
+ allowed: true,
297
+ remainingByKey: new Map(),
298
+ resetAtByKey: new Map(),
299
+ resetAt: new Date(now()).toISOString(),
300
+ total: effectiveLimit,
301
+ };
302
+ }
303
+ return withKeyLocks(namespacedKeys, async () => {
113
304
  const currentTime = now();
114
- if (!Number.isFinite(effectiveWindowMs) || effectiveWindowMs <= 0) {
115
- throw new Error('RATE_LIMIT_WINDOW_INVALID');
116
- }
117
- if (!Number.isFinite(effectiveLimit) || effectiveLimit < 0) {
118
- throw new Error('RATE_LIMIT_LIMIT_INVALID');
119
- }
120
- if (algorithm === 'sliding-window') {
121
- const state = (await getState(kv, key)) ?? { timestamps: [] };
122
- const floor = currentTime - effectiveWindowMs;
123
- state.timestamps = state.timestamps.filter((timestamp) => timestamp > floor);
124
- const allowed = state.timestamps.length < effectiveLimit;
125
- if (allowed) {
126
- state.timestamps.push(currentTime);
127
- }
128
- const oldest = state.timestamps[0] ?? currentTime;
129
- const resetAt = oldest + effectiveWindowMs;
130
- const ttlMs = Math.max(1, resetAt - currentTime);
131
- await setState(kv, key, state, ttlMs);
305
+ const preflight = await Promise.all(namespacedKeys.map((key, index) => evaluateConfiguredKey(key, currentTime, effectiveWindowMs, limits[index], false)));
306
+ const blocked = preflight.filter((result) => !result.allowed);
307
+ if (blocked.length > 0) {
132
308
  return {
133
- allowed,
134
- remaining: Math.max(0, effectiveLimit - state.timestamps.length),
135
- resetAt: new Date(resetAt).toISOString(),
309
+ allowed: false,
310
+ blockedKeys: uniqueKeys.filter((_key, index) => !preflight[index].allowed),
311
+ remainingByKey: new Map(uniqueKeys.map((key, index) => [key, preflight[index].remaining])),
312
+ resetAtByKey: new Map(uniqueKeys.map((key, index) => [key, preflight[index].resetAt])),
313
+ resetAt: new Date(Math.max(...blocked.map((result) => Date.parse(result.resetAt)))).toISOString(),
136
314
  total: effectiveLimit,
137
315
  };
138
316
  }
139
- if (algorithm === 'token-bucket') {
140
- if (effectiveLimit <= 0) {
141
- const resetAt = currentTime + Math.max(0, effectiveWindowMs);
317
+ const committed = [];
318
+ for (let index = 0; index < namespacedKeys.length; index += 1) {
319
+ const key = namespacedKeys[index];
320
+ // Commit each key through the configured store. If a later commit
321
+ // becomes blocked or fails, retain earlier quota charges (fail
322
+ // closed) instead of restoring stale snapshots over concurrent work.
323
+ const result = await evaluateConfiguredKey(key, currentTime, effectiveWindowMs, limits[index], true);
324
+ committed.push(result);
325
+ if (!result.allowed) {
326
+ const actual = preflight.map((entry, entryIndex) => committed[entryIndex] ?? entry);
327
+ const blockedActual = actual.filter((entry) => !entry.allowed);
142
328
  return {
143
329
  allowed: false,
144
- remaining: 0,
145
- resetAt: new Date(resetAt).toISOString(),
330
+ blockedKeys: uniqueKeys.filter((_key, index) => !actual[index].allowed),
331
+ remainingByKey: new Map(uniqueKeys.map((entry, entryIndex) => [entry, actual[entryIndex].remaining])),
332
+ resetAtByKey: new Map(uniqueKeys.map((entry, entryIndex) => [entry, actual[entryIndex].resetAt])),
333
+ resetAt: new Date(Math.max(...blockedActual.map((entry) => Date.parse(entry.resetAt)))).toISOString(),
146
334
  total: effectiveLimit,
147
335
  };
148
336
  }
149
- const refillRatePerMs = effectiveLimit / effectiveWindowMs;
150
- const state = (await getState(kv, key)) ?? {
151
- tokens: effectiveLimit,
152
- lastRefill: currentTime,
153
- };
154
- const elapsed = Math.max(0, currentTime - state.lastRefill);
155
- state.tokens = Math.min(effectiveLimit, state.tokens + elapsed * refillRatePerMs);
156
- state.lastRefill = currentTime;
157
- const allowed = state.tokens >= 1;
158
- if (allowed) {
159
- state.tokens -= 1;
160
- }
161
- const msUntilNextToken = state.tokens >= 1 ? 0 : Math.ceil((1 - state.tokens) / refillRatePerMs);
162
- const msUntilFull = Math.ceil((effectiveLimit - state.tokens) / refillRatePerMs);
163
- const resetAt = currentTime + msUntilNextToken;
164
- await setState(kv, key, state, Math.max(1, msUntilFull));
165
- return {
166
- allowed,
167
- remaining: Math.max(0, Math.floor(state.tokens)),
168
- resetAt: new Date(resetAt).toISOString(),
169
- total: effectiveLimit,
170
- };
171
- }
172
- const state = (await getState(kv, key)) ?? {
173
- count: 0,
174
- windowStart: currentTime,
175
- };
176
- if (currentTime >= state.windowStart + effectiveWindowMs) {
177
- state.count = 0;
178
- state.windowStart = currentTime;
179
- }
180
- const allowed = state.count < effectiveLimit;
181
- if (allowed) {
182
- state.count += 1;
183
337
  }
184
- const resetAt = state.windowStart + effectiveWindowMs;
185
- await setState(kv, key, state, Math.max(1, resetAt - currentTime));
186
338
  return {
187
- allowed,
188
- remaining: Math.max(0, effectiveLimit - state.count),
189
- resetAt: new Date(resetAt).toISOString(),
339
+ allowed: true,
340
+ remainingByKey: new Map(uniqueKeys.map((key, index) => [key, committed[index].remaining])),
341
+ resetAtByKey: new Map(uniqueKeys.map((key, index) => [key, committed[index].resetAt])),
342
+ resetAt: new Date(Math.min(...committed.map((result) => Date.parse(result.resetAt)))).toISOString(),
190
343
  total: effectiveLimit,
191
344
  };
192
345
  });
@@ -194,7 +347,7 @@ export function createRateLimiter(config) {
194
347
  async reset(key) {
195
348
  const namespacedKey = `${keyPrefix}${key}`;
196
349
  await withKeyLock(namespacedKey, async () => {
197
- await kv.delete(namespacedKey);
350
+ await (atomic ?? kv).delete(namespacedKey);
198
351
  });
199
352
  },
200
353
  };
@@ -1,4 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createObservability, readObservationGroup } from '@superfunctions/observability';
3
+ import { createMemoryAtomicKVStore } from '@superfunctions/db/adapters';
2
4
  import { createRateLimiter, createInMemoryKVStore } from './rate-limit.js';
3
5
  describe('rate-limit', () => {
4
6
  beforeEach(() => {
@@ -109,6 +111,106 @@ describe('rate-limit', () => {
109
111
  expect((await limiter.check({ key: 'user2' })).allowed).toBe(true);
110
112
  expect((await limiter.check({ key: 'user1' })).allowed).toBe(false);
111
113
  });
114
+ it('checks multiple keys without consuming partial capacity when one key is blocked', async () => {
115
+ const limiter = createRateLimiter({
116
+ windowMs: 60000,
117
+ maxRequests: 1,
118
+ algorithm: 'token-bucket',
119
+ });
120
+ expect((await limiter.check({ key: 'provider' })).allowed).toBe(true);
121
+ const multi = await limiter.checkMany({ keys: ['provider', 'tenant'] });
122
+ expect(multi.allowed).toBe(false);
123
+ expect(multi.remainingByKey.get('provider')).toBe(0);
124
+ expect(multi.remainingByKey.get('tenant')).toBe(1);
125
+ expect((await limiter.check({ key: 'tenant' })).allowed).toBe(true);
126
+ });
127
+ it('uses the latest blocked reset time for multi-key denials', async () => {
128
+ vi.useFakeTimers();
129
+ vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z'));
130
+ const limiter = createRateLimiter({
131
+ windowMs: 1000,
132
+ maxRequests: 1,
133
+ algorithm: 'token-bucket',
134
+ });
135
+ await limiter.check({ key: 'provider' });
136
+ vi.setSystemTime(new Date('2026-03-12T00:00:00.500Z'));
137
+ await limiter.check({ key: 'tenant' });
138
+ vi.setSystemTime(new Date('2026-03-12T00:00:00.600Z'));
139
+ const multi = await limiter.checkMany({ keys: ['provider', 'tenant'] });
140
+ expect(multi.allowed).toBe(false);
141
+ expect(multi.resetAt).toBe('2026-03-12T00:00:01.500Z');
142
+ expect(multi.resetAtByKey.get('provider')).toBe('2026-03-12T00:00:01.000Z');
143
+ expect(multi.resetAtByKey.get('tenant')).toBe('2026-03-12T00:00:01.500Z');
144
+ });
145
+ it('fails closed without overwriting concurrent quota when a later write fails', async () => {
146
+ const internalStore = new Map();
147
+ let failTenantWrite = true;
148
+ const customStore = {
149
+ async get(key) {
150
+ return internalStore.get(key) ?? null;
151
+ },
152
+ async set(input) {
153
+ if (input.key === 'ratelimit:tenant' && failTenantWrite) {
154
+ failTenantWrite = false;
155
+ internalStore.set('ratelimit:provider', JSON.stringify({ count: 2, windowStart: 0, expiresAt: 60000 }));
156
+ throw new Error('write failed');
157
+ }
158
+ internalStore.set(input.key, input.value);
159
+ },
160
+ async delete(key) {
161
+ internalStore.delete(key);
162
+ },
163
+ };
164
+ const limiter = createRateLimiter({
165
+ windowMs: 60000,
166
+ maxRequests: 1,
167
+ algorithm: 'fixed-window',
168
+ persistence: customStore,
169
+ now: () => 0,
170
+ });
171
+ await expect(limiter.checkMany({ keys: ['provider', 'tenant'] })).rejects.toThrow('write failed');
172
+ expect(JSON.parse(internalStore.get('ratelimit:provider') ?? '{}')).toMatchObject({
173
+ count: 2,
174
+ });
175
+ expect((await limiter.check({ key: 'provider' })).allowed).toBe(false);
176
+ });
177
+ it('retains prior quota charges after a partial commit failure', async () => {
178
+ const internalStore = new Map();
179
+ const ttlByKey = new Map();
180
+ let currentTime = 0;
181
+ const customStore = {
182
+ async get(key) {
183
+ return internalStore.get(key) ?? null;
184
+ },
185
+ async set(input) {
186
+ if (input.key === 'ratelimit:tenant') {
187
+ throw new Error('write failed');
188
+ }
189
+ internalStore.set(input.key, input.value);
190
+ ttlByKey.set(input.key, input.ttlSeconds);
191
+ },
192
+ async delete(key) {
193
+ internalStore.delete(key);
194
+ ttlByKey.delete(key);
195
+ },
196
+ };
197
+ const limiter = createRateLimiter({
198
+ windowMs: 60000,
199
+ maxRequests: 2,
200
+ algorithm: 'fixed-window',
201
+ persistence: customStore,
202
+ now: () => currentTime,
203
+ });
204
+ await limiter.check({ key: 'provider' });
205
+ currentTime = 30000;
206
+ await expect(limiter.checkMany({ keys: ['provider', 'tenant'], windowSeconds: 10 })).rejects.toThrow('write failed');
207
+ expect(ttlByKey.get('ratelimit:provider')).toBe(10);
208
+ expect(JSON.parse(internalStore.get('ratelimit:provider') ?? '{}')).toMatchObject({
209
+ count: 1,
210
+ windowStart: 30000,
211
+ expiresAt: 40000,
212
+ });
213
+ });
112
214
  it('resets key state', async () => {
113
215
  const limiter = createRateLimiter({
114
216
  windowMs: 60000,
@@ -172,6 +274,54 @@ describe('rate-limit', () => {
172
274
  await limiter.check({ key: 'user1' });
173
275
  expect(internalStore.size).toBe(1);
174
276
  });
277
+ it('uses atomic compare-and-set across limiter instances', async () => {
278
+ const atomicStore = createMemoryAtomicKVStore();
279
+ const first = createRateLimiter({
280
+ windowMs: 60000,
281
+ maxRequests: 1,
282
+ atomicStore,
283
+ });
284
+ const second = createRateLimiter({
285
+ windowMs: 60000,
286
+ maxRequests: 1,
287
+ atomicStore,
288
+ });
289
+ const results = await Promise.all([
290
+ first.check({ key: 'shared-user' }),
291
+ second.check({ key: 'shared-user' }),
292
+ ]);
293
+ expect(results.filter((result) => result.allowed)).toHaveLength(1);
294
+ expect(results.filter((result) => !result.allowed)).toHaveLength(1);
295
+ });
296
+ it('rejects atomic stores that cannot perform compare-and-set', () => {
297
+ expect(() => createRateLimiter({
298
+ windowMs: 60000,
299
+ maxRequests: 1,
300
+ atomicStore: {
301
+ async get() { return null; },
302
+ async set() { },
303
+ async setIfAbsent() { return { inserted: true }; },
304
+ async delete() { },
305
+ async incr() { return { value: 1 }; },
306
+ },
307
+ })).toThrow('RATE_LIMIT_ATOMIC_CAS_REQUIRED');
308
+ });
309
+ it('records persistence latency through configured observability', async () => {
310
+ const observability = createObservability({ service: 'middleware-test' });
311
+ const request = observability.startRequest({ requestId: 'req_rate_limit' });
312
+ const limiter = createRateLimiter({
313
+ windowMs: 60000,
314
+ maxRequests: 2,
315
+ persistence: createInMemoryKVStore(),
316
+ observability,
317
+ component: 'test.rate-limit',
318
+ });
319
+ await observability.runWithRequest(request, () => limiter.check({ key: 'observed' }));
320
+ const snapshot = request.finish({ status: 200 });
321
+ const cache = readObservationGroup(snapshot, 'cache');
322
+ expect(cache.count).toBeGreaterThan(0);
323
+ expect(snapshot.metrics[0]?.component).toBe('test.rate-limit.cache');
324
+ });
175
325
  });
176
326
  describe('createInMemoryKVStore', () => {
177
327
  let store;
@@ -198,3 +348,15 @@ describe('rate-limit', () => {
198
348
  });
199
349
  });
200
350
  });
351
+ it('uses only own limit overrides, including inherited property names', async () => {
352
+ const limiter = createRateLimiter({ windowMs: 60000, maxRequests: 2, algorithm: 'fixed-window' });
353
+ const keys = ['constructor', 'toString', '__proto__'];
354
+ const limitsByKey = Object.create({ constructor: 0 });
355
+ limitsByKey.toString = 1;
356
+ const first = await limiter.checkMany({ keys, limitsByKey });
357
+ expect(first.allowed).toBe(true);
358
+ expect(first.remainingByKey.get('constructor')).toBe(1);
359
+ expect(first.remainingByKey.get('toString')).toBe(0);
360
+ expect(first.remainingByKey.get('__proto__')).toBe(1);
361
+ expect((await limiter.checkMany({ keys, limitsByKey })).blockedKeys).toEqual(['toString']);
362
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superfunctions/middleware",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared middleware utilities for Superfunctions",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,34 +15,50 @@
15
15
  "default": "./dist/rate-limit.js"
16
16
  }
17
17
  },
18
- "files": ["dist"],
18
+ "files": [
19
+ "dist"
20
+ ],
19
21
  "scripts": {
20
22
  "build": "tsc",
21
- "test": "vitest",
23
+ "test": "vitest run",
22
24
  "test:watch": "vitest --watch",
23
25
  "typecheck": "tsc --noEmit",
24
26
  "clean": "rm -rf dist"
25
27
  },
26
- "keywords": ["middleware", "rate-limit", "superfunctions"],
28
+ "keywords": [
29
+ "middleware",
30
+ "rate-limit",
31
+ "superfunctions"
32
+ ],
27
33
  "author": "21n",
28
34
  "license": "MIT",
29
- "bugs": { "url": "https://github.com/21nCo/super-functions/issues" },
35
+ "bugs": {
36
+ "url": "https://github.com/21nCo/super-functions/issues"
37
+ },
30
38
  "repository": {
31
39
  "type": "git",
32
40
  "url": "git+https://github.com/21nCo/super-functions.git",
33
41
  "directory": "packages/middleware"
34
42
  },
35
- "publishConfig": { "access": "public" },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
36
46
  "peerDependencies": {
37
- "@superfunctions/db": "0.1.4"
47
+ "@superfunctions/db": "^0.2.0 || ^0.3.0"
38
48
  },
39
49
  "peerDependenciesMeta": {
40
- "@superfunctions/db": { "optional": true }
50
+ "@superfunctions/db": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "dependencies": {
55
+ "@superfunctions/observability": "0.0.1"
41
56
  },
42
57
  "devDependencies": {
43
- "@superfunctions/db": "^0.1.3",
58
+ "@superfunctions/db": "0.3.0",
44
59
  "@types/node": "^22.0.0",
45
60
  "typescript": "^5.6.0",
46
61
  "vitest": "^3.2.4"
47
- }
62
+ },
63
+ "gitHead": "11f7e104d2d59f62ac94ab2dd06fc01144d0390b"
48
64
  }