@zintrust/core 0.1.17 → 0.1.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/core",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Production-grade TypeScript backend framework for JavaScript",
5
5
  "homepage": "https://zintrust.com",
6
6
  "repository": {
@@ -63,7 +63,7 @@ export const PromptHelper = Object.freeze({
63
63
  /**
64
64
  * Ask for port number
65
65
  */
66
- async port(defaultPort = 3000, interactive = true) {
66
+ async port(defaultPort = 7777, interactive = true) {
67
67
  if (!interactive) {
68
68
  return defaultPort;
69
69
  }
@@ -54,7 +54,7 @@ const getProjectDefaults = (name, options) => {
54
54
  const database = getStringOption(options, 'database', 'sqlite');
55
55
  const portRaw = getStringOption(options, 'port', '7777');
56
56
  const portParsed = Number.parseInt(portRaw, 10);
57
- const port = Number.isFinite(portParsed) && portParsed > 0 ? portParsed : 3000;
57
+ const port = Number.isFinite(portParsed) && portParsed > 0 ? portParsed : 7777;
58
58
  const author = getStringOption(options, 'author', '');
59
59
  const description = getStringOption(options, 'description', `A new Zintrust project: ${name}`);
60
60
  const interactive = getBooleanOption(options, 'interactive', true);
@@ -18,7 +18,7 @@ export const DEFAULT_CONFIG = Object.freeze({
18
18
  logging: false,
19
19
  },
20
20
  server: {
21
- port: 3000,
21
+ port: 7777,
22
22
  host: '0.0.0.0',
23
23
  environment: 'development',
24
24
  debug: false,
@@ -1 +1 @@
1
- {"version":3,"file":"ProjectScaffolder.d.ts","sourceRoot":"","sources":["../../../../src/cli/scaffolding/ProjectScaffolder.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAQH,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GAAG,sBAAsB,CAAC;AAEpD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,cAAc,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IACtD,YAAY,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,eAAe,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAAC;IACpE,cAAc,IAAI,MAAM,CAAC;IACzB,sBAAsB,IAAI,OAAO,CAAC;IAClC,iBAAiB,IAAI,MAAM,CAAC;IAC5B,WAAW,CAAC,OAAO,CAAC,EAAE,sBAAsB,GAAG,MAAM,CAAC;IACtD,gBAAgB,IAAI,OAAO,CAAC;IAC5B,aAAa,IAAI,OAAO,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;CAC3E;AAyaD,wBAAgB,qBAAqB,IAAI,MAAM,EAAE,CAEhD;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAsBrE;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG;IAChE,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB,CAsBA;AA0ID;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,GAAE,MAAsB,GAAG,kBAAkB,CAsB/F;AAED,wBAAsB,eAAe,CACnC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,qBAAqB,CAAC,CAEhC;AAED;;GAEG;AACH,eAAO,MAAM,iBAAiB;;;;;;EAM5B,CAAC"}
1
+ {"version":3,"file":"ProjectScaffolder.d.ts","sourceRoot":"","sources":["../../../../src/cli/scaffolding/ProjectScaffolder.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAQH,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GAAG,sBAAsB,CAAC;AAEpD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,cAAc,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IACtD,YAAY,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,eAAe,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAAC;IACpE,cAAc,IAAI,MAAM,CAAC;IACzB,sBAAsB,IAAI,OAAO,CAAC;IAClC,iBAAiB,IAAI,MAAM,CAAC;IAC5B,WAAW,CAAC,OAAO,CAAC,EAAE,sBAAsB,GAAG,MAAM,CAAC;IACtD,gBAAgB,IAAI,OAAO,CAAC;IAC5B,aAAa,IAAI,OAAO,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;CAC3E;AAsfD,wBAAgB,qBAAqB,IAAI,MAAM,EAAE,CAEhD;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAsBrE;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG;IAChE,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB,CAsBA;AA0ID;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,GAAE,MAAsB,GAAG,kBAAkB,CAsB/F;AAED,wBAAsB,eAAe,CACnC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,qBAAqB,CAAC,CAEhC;AAED;;GAEG;AACH,eAAO,MAAM,iBAAiB;;;;;;EAM5B,CAAC"}
@@ -87,7 +87,7 @@ const createProjectConfigFile = (projectPath, variables) => {
87
87
  connection: variables['database'] ?? 'sqlite',
88
88
  },
89
89
  server: {
90
- port: variables['port'] ?? 3000,
90
+ port: variables['port'] ?? 7777,
91
91
  },
92
92
  };
93
93
  fs.writeFileSync(fullPath, JSON.stringify(config, null, 2));
@@ -97,18 +97,96 @@ const createProjectConfigFile = (projectPath, variables) => {
97
97
  return false;
98
98
  }
99
99
  };
100
+ const stripEnvInlineComment = (value) => {
101
+ let inSingle = false;
102
+ let inDouble = false;
103
+ for (let i = 0; i < value.length; i += 1) {
104
+ const ch = value[i];
105
+ if (ch === "'" && !inDouble)
106
+ inSingle = !inSingle;
107
+ if (ch === '"' && !inSingle)
108
+ inDouble = !inDouble;
109
+ if (!inSingle && !inDouble && ch === '#') {
110
+ const prev = value[i - 1];
111
+ if (prev === undefined || prev === ' ' || prev === '\t') {
112
+ return value.slice(0, i).trimEnd();
113
+ }
114
+ }
115
+ }
116
+ return value;
117
+ };
118
+ const backfillEnvDefaults = (envPath, defaults) => {
119
+ const raw = fs.readFileSync(envPath, 'utf8');
120
+ const lines = raw.split(/\r?\n/);
121
+ const seen = new Set();
122
+ const filled = new Set();
123
+ const out = lines.map((line) => {
124
+ const trimmed = line.trim();
125
+ if (trimmed === '' || trimmed.startsWith('#'))
126
+ return line;
127
+ const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length) : trimmed;
128
+ const eq = withoutExport.indexOf('=');
129
+ if (eq <= 0)
130
+ return line;
131
+ const key = withoutExport.slice(0, eq).trim();
132
+ if (key === '')
133
+ return line;
134
+ if (!Object.hasOwn(defaults, key))
135
+ return line;
136
+ if (seen.has(key))
137
+ return line;
138
+ seen.add(key);
139
+ const rhs = withoutExport.slice(eq + 1);
140
+ const withoutComment = stripEnvInlineComment(rhs);
141
+ const value = withoutComment.trim();
142
+ if (value !== '')
143
+ return line;
144
+ filled.add(key);
145
+ return `${key}=${defaults[key]}`;
146
+ });
147
+ const missingKeys = Object.keys(defaults).filter((k) => !seen.has(k));
148
+ if (missingKeys.length > 0) {
149
+ out.push(...missingKeys.map((k) => `${k}=${defaults[k]}`));
150
+ }
151
+ // Avoid rewriting if nothing changed.
152
+ if (filled.size === 0 && missingKeys.length === 0)
153
+ return;
154
+ fs.writeFileSync(envPath, out.join('\n') + (out.at(-1) === '' ? '' : '\n'));
155
+ };
156
+ const buildDatabaseEnvLines = (database) => {
157
+ if (database === 'postgresql' || database === 'postgres') {
158
+ return [
159
+ 'DB_HOST=localhost',
160
+ 'DB_PORT=5432',
161
+ 'DB_DATABASE=zintrust',
162
+ 'DB_USERNAME=postgres',
163
+ 'DB_PASSWORD=',
164
+ ];
165
+ }
166
+ if (database === 'sqlite') {
167
+ // Provide both DB_DATABASE (used by the framework) and DB_PATH (common alias)
168
+ return ['DB_DATABASE=./database.sqlite', 'DB_PATH=./database.sqlite'];
169
+ }
170
+ return [];
171
+ };
100
172
  const createEnvFile = (projectPath, variables) => {
101
173
  try {
102
174
  if (!fs.existsSync(projectPath)) {
103
175
  fs.mkdirSync(projectPath, { recursive: true });
104
176
  }
105
177
  const fullPath = path.join(projectPath, '.env');
106
- // If the template already produced an .env, do not overwrite it here.
178
+ // If an .env already exists (e.g., from a template), do not overwrite user values.
179
+ // But we *do* backfill safe defaults for common bootstrap keys when missing/blank.
107
180
  if (fs.existsSync(fullPath)) {
181
+ backfillEnvDefaults(fullPath, {
182
+ HOST: 'localhost',
183
+ PORT: String(Number(variables['port'] ?? 7777)),
184
+ LOG_LEVEL: 'debug',
185
+ });
108
186
  return true;
109
187
  }
110
188
  const name = typeof variables['projectName'] === 'string' ? variables['projectName'] : 'zintrust-app';
111
- const port = Number(variables['port'] ?? 3000);
189
+ const port = Number(variables['port'] ?? 7777);
112
190
  const database = typeof variables['database'] === 'string' ? variables['database'] : 'sqlite';
113
191
  // Generate a secure APP_KEY (32 bytes = 256-bit, base64 encoded)
114
192
  const appKeyBytes = randomBytes(32);
@@ -125,22 +203,7 @@ const createEnvFile = (projectPath, variables) => {
125
203
  `APP_KEY=${appKey}`,
126
204
  `DB_CONNECTION=${database}`,
127
205
  ];
128
- const dbLines = (() => {
129
- if (database === 'postgresql' || database === 'postgres') {
130
- return [
131
- 'DB_HOST=localhost',
132
- 'DB_PORT=5432',
133
- 'DB_DATABASE=zintrust',
134
- 'DB_USERNAME=postgres',
135
- 'DB_PASSWORD=',
136
- ];
137
- }
138
- if (database === 'sqlite') {
139
- // Provide both DB_DATABASE (used by the framework) and DB_PATH (common alias)
140
- return ['DB_DATABASE=./database.sqlite', 'DB_PATH=./database.sqlite'];
141
- }
142
- return [];
143
- })();
206
+ const dbLines = buildDatabaseEnvLines(database);
144
207
  const placeholderLines = [
145
208
  '',
146
209
  '# Logging',
@@ -245,11 +308,15 @@ const loadTemplateFiles = (templateDir) => {
245
308
  if (relPath === 'template.json')
246
309
  return false;
247
310
  const normalized = normalizeRelPath(relPath);
311
+ // Project `.env` is generated by createEnvFile() so it can set defaults and create a secure APP_KEY.
312
+ // Some templates ship `.env.tpl` (which would become `.env`), but that file is intentionally ignored.
313
+ const outputRel = normalizeRelPath(getOutputRelPath(relPath));
314
+ if (outputRel === '.env')
315
+ return false;
248
316
  if (!normalized.startsWith('config/'))
249
317
  return true;
250
318
  // Starter apps should only ship app-level config modules.
251
319
  // Core/framework config internals (e.g. config/logging/*) remain core-owned.
252
- const outputRel = normalizeRelPath(getOutputRelPath(relPath));
253
320
  return allowedConfigFiles.has(outputRel);
254
321
  };
255
322
  const readUtf8FileOrUndefined = (absPath) => {
@@ -424,7 +491,7 @@ const prepareContext = (state, options) => {
424
491
  projectSlug: options.name,
425
492
  author: options.author ?? 'Your Name',
426
493
  description: options.description ?? '',
427
- port: options.port ?? 3000,
494
+ port: options.port ?? 7777,
428
495
  database: options.database ?? 'sqlite',
429
496
  template: state.templateName,
430
497
  migrationTimestamp,
@@ -12,8 +12,43 @@ export interface RateLimitOptions {
12
12
  statusCode?: number;
13
13
  headers?: boolean;
14
14
  keyGenerator?: (req: IRequest) => string;
15
+ /**
16
+ * Optional store selection for this middleware instance.
17
+ * - 'memory' uses an in-process Map (default)
18
+ * - 'redis' uses Cache.store('redis')
19
+ * - 'kv' uses Cache.store('kv')
20
+ * - 'db' uses Cache.store('mongodb')
21
+ */
22
+ store?: RateLimitStoreName;
15
23
  }
24
+ export type RateLimitStoreName = 'memory' | 'redis' | 'kv' | 'db';
16
25
  export declare const RateLimiter: Readonly<{
26
+ /**
27
+ * Configure the store used by the programmatic API (attempt/tooManyAttempts/till/clear).
28
+ * Defaults to 'memory'.
29
+ */
30
+ configure(config?: {
31
+ store?: RateLimitStoreName;
32
+ }): void;
33
+ /**
34
+ * Attempt to perform an action.
35
+ *
36
+ * Returns true if allowed (and records the hit), false if rate limited.
37
+ */
38
+ attempt(key: string, maxAttempts: number, decaySeconds: number): Promise<boolean>;
39
+ /**
40
+ * Check if the key is currently rate limited.
41
+ */
42
+ tooManyAttempts(key: string, maxAttempts: number): Promise<boolean>;
43
+ /**
44
+ * Seconds until the key is available again.
45
+ * Returns 0 if not rate limited.
46
+ */
47
+ till(key: string): Promise<number>;
48
+ /**
49
+ * Clear rate limit state for a key.
50
+ */
51
+ clear(key: string): Promise<void>;
17
52
  /**
18
53
  * Create rate limiter middleware
19
54
  */
@@ -1 +1 @@
1
- {"version":3,"file":"RateLimiter.d.ts","sourceRoot":"","sources":["../../../src/middleware/RateLimiter.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAEzC,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAEzD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,QAAQ,KAAK,MAAM,CAAC;CAC1C;AAoBD,eAAO,MAAM,WAAW;IACtB;;OAEG;qBACa,gBAAgB,GAAqB,UAAU;EA4D/D,CAAC"}
1
+ {"version":3,"file":"RateLimiter.d.ts","sourceRoot":"","sources":["../../../src/middleware/RateLimiter.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAEzC,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAEzD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,QAAQ,KAAK,MAAM,CAAC;IAEzC;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,kBAAkB,CAAC;CAC5B;AAED,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,OAAO,GAAG,IAAI,GAAG,IAAI,CAAC;AAyIlE,eAAO,MAAM,WAAW;IACtB;;;OAGG;uBACgB;QAAE,KAAK,CAAC,EAAE,kBAAkB,CAAA;KAAE,GAAG,IAAI;IAKxD;;;;OAIG;iBACgB,MAAM,eAAe,MAAM,gBAAgB,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAYvF;;OAEG;yBACwB,MAAM,eAAe,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAQzE;;;OAGG;cACa,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQxC;;OAEG;eACc,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKvC;;OAEG;qBACa,gBAAgB,GAAqB,UAAU;EAgF/D,CAAC"}
@@ -3,7 +3,104 @@
3
3
  * Token bucket implementation for request rate limiting
4
4
  * Zero-dependency implementation
5
5
  */
6
+ import { Cache } from '../cache/Cache.js';
6
7
  import { Logger } from '../config/logger.js';
8
+ const createMemoryStore = () => {
9
+ const entries = new Map();
10
+ let nextCleanupAt = Date.now() + 60_000;
11
+ const cleanupExpired = (now) => {
12
+ if (now < nextCleanupAt)
13
+ return;
14
+ for (const [k, state] of entries.entries()) {
15
+ if (now > state.resetTime)
16
+ entries.delete(k);
17
+ }
18
+ nextCleanupAt = now + 60_000;
19
+ };
20
+ return Object.freeze({
21
+ async get(key) {
22
+ await Promise.resolve();
23
+ const now = Date.now();
24
+ cleanupExpired(now);
25
+ const state = entries.get(key);
26
+ if (!state)
27
+ return null;
28
+ if (now > state.resetTime) {
29
+ entries.delete(key);
30
+ return null;
31
+ }
32
+ return { ...state };
33
+ },
34
+ async set(key, value) {
35
+ await Promise.resolve();
36
+ const now = Date.now();
37
+ cleanupExpired(now);
38
+ entries.set(key, { ...value });
39
+ },
40
+ async delete(key) {
41
+ await Promise.resolve();
42
+ entries.delete(key);
43
+ },
44
+ });
45
+ };
46
+ const createCacheStore = (storeName) => {
47
+ const store = Cache.store(storeName);
48
+ return Object.freeze({
49
+ async get(key) {
50
+ return store.get(key);
51
+ },
52
+ async set(key, value, ttlSeconds) {
53
+ await store.set(key, value, ttlSeconds);
54
+ },
55
+ async delete(key) {
56
+ await store.delete(key);
57
+ },
58
+ });
59
+ };
60
+ const normalizeStoreName = (name) => {
61
+ const raw = String(name ?? '')
62
+ .trim()
63
+ .toLowerCase();
64
+ if (raw === 'redis')
65
+ return 'redis';
66
+ if (raw === 'kv')
67
+ return 'kv';
68
+ if (raw === 'db' || raw === 'database' || raw === 'mongo' || raw === 'mongodb')
69
+ return 'db';
70
+ return 'memory';
71
+ };
72
+ const resolveStore = (name) => {
73
+ const selected = normalizeStoreName(name ?? process.env['RATE_LIMIT_STORE'] ?? process.env['RATE_LIMIT_DRIVER'] ?? 'memory');
74
+ if (selected === 'redis')
75
+ return { storeName: 'redis', store: createCacheStore('redis') };
76
+ if (selected === 'kv')
77
+ return { storeName: 'kv', store: createCacheStore('kv') };
78
+ if (selected === 'db')
79
+ return { storeName: 'db', store: createCacheStore('mongodb') };
80
+ return { storeName: 'memory', store: createMemoryStore() };
81
+ };
82
+ let serviceStoreSelection = normalizeStoreName(process.env['RATE_LIMIT_STORE'] ?? process.env['RATE_LIMIT_DRIVER'] ?? 'memory');
83
+ let serviceStore = resolveStore(serviceStoreSelection).store;
84
+ const prefixKey = (purpose, key) => {
85
+ const prefix = (process.env['RATE_LIMIT_KEY_PREFIX'] ?? 'zintrust:ratelimit:').toString().trim();
86
+ return `${prefix}${purpose}:${key}`;
87
+ };
88
+ const consume = async (params) => {
89
+ const now = Date.now();
90
+ const ttlSeconds = Math.max(1, Math.ceil(params.windowMs / 1000));
91
+ const existing = await params.store.get(params.key);
92
+ const state = existing === null || now > existing.resetTime
93
+ ? { count: 0, resetTime: now + params.windowMs }
94
+ : existing;
95
+ const nextCount = state.count + 1;
96
+ const nextState = { count: nextCount, resetTime: state.resetTime };
97
+ await params.store.set(params.key, nextState, ttlSeconds);
98
+ return {
99
+ count: nextCount,
100
+ resetTime: nextState.resetTime,
101
+ allowed: nextCount <= params.max,
102
+ };
103
+ };
7
104
  const DEFAULT_OPTIONS = {
8
105
  windowMs: 60 * 1000, // 1 minute
9
106
  max: 100, // 100 requests per minute
@@ -15,11 +112,68 @@ const DEFAULT_OPTIONS = {
15
112
  },
16
113
  };
17
114
  export const RateLimiter = Object.freeze({
115
+ /**
116
+ * Configure the store used by the programmatic API (attempt/tooManyAttempts/till/clear).
117
+ * Defaults to 'memory'.
118
+ */
119
+ configure(config) {
120
+ serviceStoreSelection = normalizeStoreName(config?.store);
121
+ serviceStore = resolveStore(serviceStoreSelection).store;
122
+ },
123
+ /**
124
+ * Attempt to perform an action.
125
+ *
126
+ * Returns true if allowed (and records the hit), false if rate limited.
127
+ */
128
+ async attempt(key, maxAttempts, decaySeconds) {
129
+ const windowMs = Math.max(1, Math.floor(decaySeconds * 1000));
130
+ const namespacedKey = prefixKey('service', key);
131
+ const out = await consume({
132
+ store: serviceStore,
133
+ key: namespacedKey,
134
+ max: maxAttempts,
135
+ windowMs,
136
+ });
137
+ return out.allowed;
138
+ },
139
+ /**
140
+ * Check if the key is currently rate limited.
141
+ */
142
+ async tooManyAttempts(key, maxAttempts) {
143
+ const now = Date.now();
144
+ const namespacedKey = prefixKey('service', key);
145
+ const state = await serviceStore.get(namespacedKey);
146
+ if (!state || now > state.resetTime)
147
+ return false;
148
+ return state.count >= maxAttempts;
149
+ },
150
+ /**
151
+ * Seconds until the key is available again.
152
+ * Returns 0 if not rate limited.
153
+ */
154
+ async till(key) {
155
+ const now = Date.now();
156
+ const namespacedKey = prefixKey('service', key);
157
+ const state = await serviceStore.get(namespacedKey);
158
+ if (!state || now > state.resetTime)
159
+ return 0;
160
+ return Math.max(0, Math.ceil((state.resetTime - now) / 1000));
161
+ },
162
+ /**
163
+ * Clear rate limit state for a key.
164
+ */
165
+ async clear(key) {
166
+ const namespacedKey = prefixKey('service', key);
167
+ await serviceStore.delete(namespacedKey);
168
+ },
18
169
  /**
19
170
  * Create rate limiter middleware
20
171
  */
21
172
  create(options = DEFAULT_OPTIONS) {
22
173
  const config = { ...DEFAULT_OPTIONS, ...options };
174
+ const { storeName, store } = resolveStore(config.store);
175
+ const useMemoryInstanceStore = storeName === 'memory';
176
+ // Middleware store is per-instance (matches prior behavior).
23
177
  const clients = new Map();
24
178
  // Cleanup to prevent unbounded growth.
25
179
  // Done lazily (on requests) to avoid background timers in serverless/test environments.
@@ -27,29 +181,47 @@ export const RateLimiter = Object.freeze({
27
181
  const cleanupExpiredClients = (now) => {
28
182
  if (now < nextCleanupAt)
29
183
  return;
30
- for (const [key, state] of clients.entries()) {
184
+ for (const [k, state] of clients.entries()) {
31
185
  if (now > state.resetTime) {
32
- clients.delete(key);
186
+ clients.delete(k);
33
187
  }
34
188
  }
35
189
  nextCleanupAt = now + config.windowMs;
36
190
  };
37
- return async (req, res, next) => {
38
- const key = config.keyGenerator ? config.keyGenerator(req) : 'unknown';
39
- const now = Date.now();
40
- cleanupExpiredClients(now);
191
+ const getOrInitClient = (key, now) => {
41
192
  let client = clients.get(key);
42
- // Initialize or reset if window expired
43
193
  if (!client || now > client.resetTime) {
44
- client = {
45
- count: 0,
46
- resetTime: now + config.windowMs,
47
- };
194
+ client = { count: 0, resetTime: now + config.windowMs };
48
195
  clients.set(key, client);
49
196
  }
50
- client.count++;
51
- const remaining = Math.max(0, config.max - client.count);
52
- const resetTime = Math.ceil((client.resetTime - now) / 1000);
197
+ return client;
198
+ };
199
+ return async (req, res, next) => {
200
+ const key = config.keyGenerator ? config.keyGenerator(req) : 'unknown';
201
+ const now = Date.now();
202
+ let count;
203
+ let resetAt;
204
+ if (useMemoryInstanceStore) {
205
+ cleanupExpiredClients(now);
206
+ const client = getOrInitClient(key, now);
207
+ client.count++;
208
+ count = client.count;
209
+ resetAt = client.resetTime;
210
+ }
211
+ else {
212
+ // Include limiter config to avoid collisions between different middleware instances.
213
+ const middlewareKey = prefixKey('middleware', `${config.max}:${config.windowMs}:${key}`);
214
+ const out = await consume({
215
+ store,
216
+ key: middlewareKey,
217
+ max: config.max,
218
+ windowMs: config.windowMs,
219
+ });
220
+ count = out.count;
221
+ resetAt = out.resetTime;
222
+ }
223
+ const remaining = Math.max(0, config.max - count);
224
+ const resetTime = Math.ceil((resetAt - now) / 1000);
53
225
  // Set headers
54
226
  if (config.headers ?? false) {
55
227
  res.setHeader('X-RateLimit-Limit', config.max.toString());
@@ -57,7 +229,7 @@ export const RateLimiter = Object.freeze({
57
229
  res.setHeader('X-RateLimit-Reset', resetTime.toString());
58
230
  }
59
231
  // Check limit
60
- if (client.count > config.max) {
232
+ if (count > config.max) {
61
233
  Logger.warn(`Rate limit exceeded for IP: ${key}`);
62
234
  res.setStatus(config.statusCode ?? 429);
63
235
  res.json({