@avijs/security 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -2,4 +2,6 @@ export { cors } from './cors.js';
2
2
  export type { CorsOptions, CorsOrigin } from './cors.js';
3
3
  export { securityHeaders } from './headers.js';
4
4
  export type { SecurityHeadersOptions } from './headers.js';
5
+ export { memoryStore, rateLimit } from './rate-limit.js';
6
+ export type { MemoryStore, MemoryStoreOptions, RateLimitOptions, RateLimitResult, RateLimitStore, RateLimiter, } from './rate-limit.js';
5
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,YAAY,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,YAAY,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACzD,YAAY,EACV,WAAW,EACX,kBAAkB,EAClB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACd,WAAW,GACZ,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { cors } from './cors.js';
2
2
  export { securityHeaders } from './headers.js';
3
+ export { memoryStore, rateLimit } from './rate-limit.js';
3
4
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,48 @@
1
+ import { type Context, type Guard, type Logger, type RequestHook } from '@avijs/http';
2
+ /** Counts requests per key in fixed windows. Implement it to share counts, for example in Redis. */
3
+ export interface RateLimitStore {
4
+ /** Counts one request for `key` and returns the window's count and end time (ms since epoch). */
5
+ increment(key: string, window: number): RateLimitResult | Promise<RateLimitResult>;
6
+ }
7
+ export interface RateLimitResult {
8
+ readonly count: number;
9
+ readonly resetAt: number;
10
+ }
11
+ export interface RateLimitOptions {
12
+ /** Requests allowed per window. */
13
+ limit: number;
14
+ /** Window length in ms. */
15
+ window: number;
16
+ /** What to count by. Default: the client IP, `ctx.ip`. */
17
+ key?: (ctx: Context) => string;
18
+ /** Default: a new in-memory store for this limiter. */
19
+ store?: RateLimitStore;
20
+ /** When the store fails: let requests through (default) or reject them with `503`. */
21
+ onStoreError?: 'allow' | 'deny';
22
+ /** Receives store failures. Default: `console.error`. */
23
+ logger?: Pick<Logger, 'error'>;
24
+ }
25
+ /** A rate limit as an `onRequest` hook, with `.guard` to limit single routes instead. */
26
+ export interface RateLimiter extends RequestHook {
27
+ readonly guard: Guard;
28
+ }
29
+ /**
30
+ * Limits each client to `limit` requests per `window`. Over the limit: `429` with
31
+ * `Retry-After`. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and
32
+ * `RateLimit-Reset`, so clients can slow down first.
33
+ */
34
+ export declare function rateLimit(options: RateLimitOptions): RateLimiter;
35
+ export interface MemoryStoreOptions {
36
+ /**
37
+ * Most keys tracked at once. When full, the oldest window is dropped, so a flood of new clients
38
+ * cannot use unbounded memory. Default: 100 000.
39
+ */
40
+ maxKeys?: number;
41
+ }
42
+ /** A store that counts synchronously, in this process. */
43
+ export interface MemoryStore extends RateLimitStore {
44
+ increment(key: string, window: number): RateLimitResult;
45
+ }
46
+ /** Counts in this process only. For several processes or servers, use a shared store. */
47
+ export declare function memoryStore(options?: MemoryStoreOptions): MemoryStore;
48
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,OAAO,EAAE,KAAK,KAAK,EAAE,KAAK,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAEjG,oGAAoG;AACpG,MAAM,WAAW,cAAc;IAC7B,iGAAiG;IACjG,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;CACpF;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,mCAAmC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC;IAC/B,uDAAuD;IACvD,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,sFAAsF;IACtF,YAAY,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAChC,yDAAyD;IACzD,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,yFAAyF;AACzF,MAAM,WAAW,WAAY,SAAQ,WAAW;IAC9C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,WAAW,CAuDhE;AAED,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,0DAA0D;AAC1D,MAAM,WAAW,WAAY,SAAQ,cAAc;IACjD,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,eAAe,CAAC;CACzD;AAED,yFAAyF;AACzF,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,WAAW,CAuBzE"}
@@ -0,0 +1,91 @@
1
+ import { HttpError } from '@avijs/http';
2
+ /**
3
+ * Limits each client to `limit` requests per `window`. Over the limit: `429` with
4
+ * `Retry-After`. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and
5
+ * `RateLimit-Reset`, so clients can slow down first.
6
+ */
7
+ export function rateLimit(options) {
8
+ const limit = positive('limit', options.limit);
9
+ const window = positive('window', options.window);
10
+ const store = options.store ?? memoryStore();
11
+ const key = options.key ?? ((ctx) => ctx.ip);
12
+ const deny = options.onStoreError === 'deny';
13
+ const logger = options.logger ?? {
14
+ error: (message, error) => {
15
+ console.error(message, error);
16
+ },
17
+ };
18
+ const apply = (ctx, { count, resetAt }) => {
19
+ const seconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000));
20
+ ctx.setHeader('ratelimit-limit', limit);
21
+ ctx.setHeader('ratelimit-remaining', Math.max(0, limit - count));
22
+ ctx.setHeader('ratelimit-reset', seconds);
23
+ if (count > limit) {
24
+ throw new HttpError(429, 'Too many requests', {
25
+ headers: { 'retry-after': String(seconds) },
26
+ });
27
+ }
28
+ };
29
+ const failed = (ctx, error) => {
30
+ logger.error(`Rate limit store failed: ${ctx.method} ${ctx.path}`, error);
31
+ if (deny)
32
+ throw new HttpError(503, 'Service unavailable');
33
+ };
34
+ const check = (ctx) => {
35
+ let result;
36
+ try {
37
+ result = store.increment(key(ctx), window);
38
+ }
39
+ catch (error) {
40
+ failed(ctx, error);
41
+ return;
42
+ }
43
+ if (result instanceof Promise) {
44
+ return result.then((value) => {
45
+ apply(ctx, value);
46
+ }, (error) => {
47
+ failed(ctx, error);
48
+ });
49
+ }
50
+ apply(ctx, result);
51
+ };
52
+ const hook = (ctx) => check(ctx);
53
+ const guard = (ctx) => {
54
+ const pending = check(ctx);
55
+ return pending ? pending.then(() => true) : true;
56
+ };
57
+ return Object.assign(hook, { guard });
58
+ }
59
+ /** Counts in this process only. For several processes or servers, use a shared store. */
60
+ export function memoryStore(options = {}) {
61
+ const maxKeys = positive('maxKeys', options.maxKeys ?? 100_000);
62
+ // Insertion order is window order: a new window is re-inserted at the end.
63
+ const windows = new Map();
64
+ return {
65
+ increment(key, window) {
66
+ const now = Date.now();
67
+ const current = windows.get(key);
68
+ if (current && current.resetAt > now) {
69
+ current.count++;
70
+ return { count: current.count, resetAt: current.resetAt };
71
+ }
72
+ if (current)
73
+ windows.delete(key);
74
+ for (const [oldest, entry] of windows) {
75
+ if (entry.resetAt > now && windows.size < maxKeys)
76
+ break;
77
+ windows.delete(oldest);
78
+ }
79
+ const fresh = { count: 1, resetAt: now + window };
80
+ windows.set(key, fresh);
81
+ return { count: fresh.count, resetAt: fresh.resetAt };
82
+ },
83
+ };
84
+ }
85
+ function positive(name, value) {
86
+ if (!Number.isInteger(value) || value <= 0) {
87
+ throw new RangeError(`rateLimit: ${name} must be a positive integer, got ${String(value)}`);
88
+ }
89
+ return value;
90
+ }
91
+ //# sourceMappingURL=rate-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avijs/security",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "CORS and security headers for Avi.js.",
5
5
  "keywords": [
6
6
  "avi",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@avijs/http": "0.1.2"
45
+ "@avijs/http": "0.2.0"
46
46
  },
47
47
  "scripts": {
48
48
  "typecheck": "tsc -p tsconfig.json"
package/src/index.ts CHANGED
@@ -2,3 +2,12 @@ export { cors } from './cors.js';
2
2
  export type { CorsOptions, CorsOrigin } from './cors.js';
3
3
  export { securityHeaders } from './headers.js';
4
4
  export type { SecurityHeadersOptions } from './headers.js';
5
+ export { memoryStore, rateLimit } from './rate-limit.js';
6
+ export type {
7
+ MemoryStore,
8
+ MemoryStoreOptions,
9
+ RateLimitOptions,
10
+ RateLimitResult,
11
+ RateLimitStore,
12
+ RateLimiter,
13
+ } from './rate-limit.js';
@@ -0,0 +1,140 @@
1
+ import { HttpError, type Context, type Guard, type Logger, type RequestHook } from '@avijs/http';
2
+
3
+ /** Counts requests per key in fixed windows. Implement it to share counts, for example in Redis. */
4
+ export interface RateLimitStore {
5
+ /** Counts one request for `key` and returns the window's count and end time (ms since epoch). */
6
+ increment(key: string, window: number): RateLimitResult | Promise<RateLimitResult>;
7
+ }
8
+
9
+ export interface RateLimitResult {
10
+ readonly count: number;
11
+ readonly resetAt: number;
12
+ }
13
+
14
+ export interface RateLimitOptions {
15
+ /** Requests allowed per window. */
16
+ limit: number;
17
+ /** Window length in ms. */
18
+ window: number;
19
+ /** What to count by. Default: the client IP, `ctx.ip`. */
20
+ key?: (ctx: Context) => string;
21
+ /** Default: a new in-memory store for this limiter. */
22
+ store?: RateLimitStore;
23
+ /** When the store fails: let requests through (default) or reject them with `503`. */
24
+ onStoreError?: 'allow' | 'deny';
25
+ /** Receives store failures. Default: `console.error`. */
26
+ logger?: Pick<Logger, 'error'>;
27
+ }
28
+
29
+ /** A rate limit as an `onRequest` hook, with `.guard` to limit single routes instead. */
30
+ export interface RateLimiter extends RequestHook {
31
+ readonly guard: Guard;
32
+ }
33
+
34
+ /**
35
+ * Limits each client to `limit` requests per `window`. Over the limit: `429` with
36
+ * `Retry-After`. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and
37
+ * `RateLimit-Reset`, so clients can slow down first.
38
+ */
39
+ export function rateLimit(options: RateLimitOptions): RateLimiter {
40
+ const limit = positive('limit', options.limit);
41
+ const window = positive('window', options.window);
42
+ const store = options.store ?? memoryStore();
43
+ const key = options.key ?? ((ctx: Context) => ctx.ip);
44
+ const deny = options.onStoreError === 'deny';
45
+ const logger = options.logger ?? {
46
+ error: (message: string, error: unknown) => {
47
+ console.error(message, error);
48
+ },
49
+ };
50
+
51
+ const apply = (ctx: Context, { count, resetAt }: RateLimitResult): void => {
52
+ const seconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000));
53
+ ctx.setHeader('ratelimit-limit', limit);
54
+ ctx.setHeader('ratelimit-remaining', Math.max(0, limit - count));
55
+ ctx.setHeader('ratelimit-reset', seconds);
56
+ if (count > limit) {
57
+ throw new HttpError(429, 'Too many requests', {
58
+ headers: { 'retry-after': String(seconds) },
59
+ });
60
+ }
61
+ };
62
+ const failed = (ctx: Context, error: unknown): void => {
63
+ logger.error(`Rate limit store failed: ${ctx.method} ${ctx.path}`, error);
64
+ if (deny) throw new HttpError(503, 'Service unavailable');
65
+ };
66
+
67
+ const check = (ctx: Context): void | Promise<void> => {
68
+ let result: RateLimitResult | Promise<RateLimitResult>;
69
+ try {
70
+ result = store.increment(key(ctx), window);
71
+ } catch (error) {
72
+ failed(ctx, error);
73
+ return;
74
+ }
75
+ if (result instanceof Promise) {
76
+ return result.then(
77
+ (value) => {
78
+ apply(ctx, value);
79
+ },
80
+ (error: unknown) => {
81
+ failed(ctx, error);
82
+ },
83
+ );
84
+ }
85
+ apply(ctx, result);
86
+ };
87
+
88
+ const hook = (ctx: Context) => check(ctx);
89
+ const guard: Guard = (ctx) => {
90
+ const pending = check(ctx);
91
+ return pending ? pending.then(() => true) : true;
92
+ };
93
+ return Object.assign(hook, { guard });
94
+ }
95
+
96
+ export interface MemoryStoreOptions {
97
+ /**
98
+ * Most keys tracked at once. When full, the oldest window is dropped, so a flood of new clients
99
+ * cannot use unbounded memory. Default: 100 000.
100
+ */
101
+ maxKeys?: number;
102
+ }
103
+
104
+ /** A store that counts synchronously, in this process. */
105
+ export interface MemoryStore extends RateLimitStore {
106
+ increment(key: string, window: number): RateLimitResult;
107
+ }
108
+
109
+ /** Counts in this process only. For several processes or servers, use a shared store. */
110
+ export function memoryStore(options: MemoryStoreOptions = {}): MemoryStore {
111
+ const maxKeys = positive('maxKeys', options.maxKeys ?? 100_000);
112
+ // Insertion order is window order: a new window is re-inserted at the end.
113
+ const windows = new Map<string, { count: number; resetAt: number }>();
114
+
115
+ return {
116
+ increment(key, window) {
117
+ const now = Date.now();
118
+ const current = windows.get(key);
119
+ if (current && current.resetAt > now) {
120
+ current.count++;
121
+ return { count: current.count, resetAt: current.resetAt };
122
+ }
123
+ if (current) windows.delete(key);
124
+ for (const [oldest, entry] of windows) {
125
+ if (entry.resetAt > now && windows.size < maxKeys) break;
126
+ windows.delete(oldest);
127
+ }
128
+ const fresh = { count: 1, resetAt: now + window };
129
+ windows.set(key, fresh);
130
+ return { count: fresh.count, resetAt: fresh.resetAt };
131
+ },
132
+ };
133
+ }
134
+
135
+ function positive(name: string, value: number): number {
136
+ if (!Number.isInteger(value) || value <= 0) {
137
+ throw new RangeError(`rateLimit: ${name} must be a positive integer, got ${String(value)}`);
138
+ }
139
+ return value;
140
+ }