webspresso 0.0.97 → 0.0.99

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.
Files changed (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Webspresso Auth - Two-Factor Authentication (2FA / TOTP)
3
+ * Zero-dependency RFC 6238 (TOTP), RFC 4226 (HOTP), and RFC 4648 (Base32) implementation
4
+ * @module core/auth/totp
5
+ */
6
+
7
+ const crypto = require('crypto');
8
+ const { generateQrSvg, generateQrDataUri, createQrMatrix } = require('./qrcode');
9
+
10
+ const RFC4648_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
11
+
12
+ /**
13
+ * Encode buffer or string to RFC 4648 Base32
14
+ * @param {Buffer|string} buffer
15
+ * @param {Object} [options]
16
+ * @param {boolean} [options.padding=false] - Whether to include '=' padding (default: false for authenticator apps)
17
+ * @returns {string} Base32 uppercase string
18
+ */
19
+ function base32Encode(buffer, options = {}) {
20
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || '', 'utf8');
21
+ if (buf.length === 0) return '';
22
+ const pad = options.padding === true;
23
+
24
+ let bits = 0;
25
+ let value = 0;
26
+ let output = '';
27
+
28
+ for (let i = 0; i < buf.length; i++) {
29
+ value = (value << 8) | buf[i];
30
+ bits += 8;
31
+ while (bits >= 5) {
32
+ output += RFC4648_BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
33
+ bits -= 5;
34
+ }
35
+ }
36
+
37
+ if (bits > 0) {
38
+ output += RFC4648_BASE32_ALPHABET[(value << (5 - bits)) & 31];
39
+ }
40
+
41
+ if (pad) {
42
+ while (output.length % 8 !== 0) {
43
+ output += '=';
44
+ }
45
+ }
46
+
47
+ return output;
48
+ }
49
+
50
+ /**
51
+ * Decode RFC 4648 Base32 string to Buffer
52
+ * Tolerates lowercase, spaces, hyphens, and padding
53
+ * @param {string} input
54
+ * @returns {Buffer}
55
+ */
56
+ function base32Decode(input) {
57
+ if (typeof input !== 'string') {
58
+ throw new TypeError('Base32 input must be a string');
59
+ }
60
+
61
+ const clean = input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/, '');
62
+ if (clean.length === 0) return Buffer.alloc(0);
63
+
64
+ let bits = 0;
65
+ let value = 0;
66
+ const bytes = [];
67
+
68
+ for (let i = 0; i < clean.length; i++) {
69
+ const char = clean[i];
70
+ const val = RFC4648_BASE32_ALPHABET.indexOf(char);
71
+ if (val === -1) {
72
+ throw new Error(`Invalid Base32 character: "${char}"`);
73
+ }
74
+ value = (value << 5) | val;
75
+ bits += 5;
76
+ if (bits >= 8) {
77
+ bytes.push((value >>> (bits - 8)) & 0xff);
78
+ bits -= 8;
79
+ }
80
+ }
81
+
82
+ return Buffer.from(bytes);
83
+ }
84
+
85
+ /**
86
+ * Generate a random cryptographically secure TOTP secret key
87
+ * @param {Object} [options]
88
+ * @param {number} [options.length=20] - Number of random bytes (20 bytes = 160 bits per RFC recommendations)
89
+ * @param {boolean} [options.padding=false] - Whether to pad the Base32 string
90
+ * @returns {string} Base32 encoded secret
91
+ */
92
+ function generateTotpSecret(options = {}) {
93
+ const length = options.length || 20;
94
+ const buffer = crypto.randomBytes(length);
95
+ return base32Encode(buffer, options);
96
+ }
97
+
98
+ /**
99
+ * Normalize secret to Buffer
100
+ * @param {string|Buffer} secret
101
+ * @param {string} [encoding='base32'] - 'base32', 'ascii', 'utf8', 'hex'
102
+ * @returns {Buffer}
103
+ */
104
+ function normalizeSecretBuffer(secret, encoding = 'base32') {
105
+ if (Buffer.isBuffer(secret)) {
106
+ return secret;
107
+ }
108
+ if (typeof secret !== 'string') {
109
+ throw new TypeError('Secret must be a string or Buffer');
110
+ }
111
+
112
+ if (encoding === 'base32') {
113
+ return base32Decode(secret);
114
+ }
115
+ return Buffer.from(secret, encoding);
116
+ }
117
+
118
+ /**
119
+ * Normalize algorithm string to Node.js crypto algorithm name
120
+ * @param {string} algorithm
121
+ * @returns {string}
122
+ */
123
+ function normalizeAlgorithm(algorithm = 'sha1') {
124
+ const algo = String(algorithm).toLowerCase().replace(/[^a-z0-9]/g, '');
125
+ if (algo === 'sha1' || algo === 'sha256' || algo === 'sha512') {
126
+ return algo;
127
+ }
128
+ return 'sha1';
129
+ }
130
+
131
+ /**
132
+ * Generate HMAC-Based One-Time Password (HOTP, RFC 4226)
133
+ * @param {string|Buffer} secret - Secret key (Base32 string or Buffer)
134
+ * @param {number|BigInt} counter - 8-byte moving factor counter
135
+ * @param {Object} [options]
136
+ * @param {number} [options.digits=6] - Number of digits (typically 6, 7, or 8)
137
+ * @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
138
+ * @param {string} [options.encoding='base32'] - Encoding of secret if string ('base32', 'ascii', etc.)
139
+ * @returns {string} Numerical token padded with leading zeros
140
+ */
141
+ function generateHotp(secret, counter, options = {}) {
142
+ const key = normalizeSecretBuffer(secret, options.encoding);
143
+ const digits = options.digits || 6;
144
+ const algorithm = normalizeAlgorithm(options.algorithm);
145
+
146
+ const counterBuf = Buffer.alloc(8);
147
+ counterBuf.writeBigUInt64BE(BigInt(counter), 0);
148
+
149
+ const hmac = crypto.createHmac(algorithm, key).update(counterBuf).digest();
150
+
151
+ // Dynamic truncation (RFC 4226 Section 5.4)
152
+ const offset = hmac[hmac.length - 1] & 0x0f;
153
+ const binary =
154
+ ((hmac[offset] & 0x7f) << 24) |
155
+ ((hmac[offset + 1] & 0xff) << 16) |
156
+ ((hmac[offset + 2] & 0xff) << 8) |
157
+ (hmac[offset + 3] & 0xff);
158
+
159
+ const token = (binary % 10 ** digits).toString().padStart(digits, '0');
160
+ return token;
161
+ }
162
+
163
+ /**
164
+ * Timing-safe token equality check
165
+ * @param {string} a
166
+ * @param {string} b
167
+ * @returns {boolean}
168
+ */
169
+ function timingSafeTokenCompare(a, b) {
170
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
171
+ if (a.length !== b.length) return false;
172
+
173
+ const bufA = Buffer.from(a);
174
+ const bufB = Buffer.from(b);
175
+ return crypto.timingSafeEqual(bufA, bufB);
176
+ }
177
+
178
+ /**
179
+ * Verify HOTP token against counter
180
+ * @param {string} token - Submitted token
181
+ * @param {string|Buffer} secret - Secret key
182
+ * @param {number|BigInt} counter - Expected counter
183
+ * @param {Object} [options]
184
+ * @returns {boolean}
185
+ */
186
+ function verifyHotp(token, secret, counter, options = {}) {
187
+ if (typeof token !== 'string') return false;
188
+ const cleanToken = token.trim();
189
+ const digits = options.digits || cleanToken.length || 6;
190
+ const expected = generateHotp(secret, counter, { ...options, digits });
191
+ return timingSafeTokenCompare(cleanToken, expected);
192
+ }
193
+
194
+ /**
195
+ * Generate Time-Based One-Time Password (TOTP, RFC 6238)
196
+ * @param {string|Buffer} secret - Secret key (Base32 or Buffer)
197
+ * @param {Object} [options]
198
+ * @param {number|Date} [options.time=Date.now()] - Timestamp in ms, seconds, or Date
199
+ * @param {number} [options.step=30] - Time step in seconds (default: 30)
200
+ * @param {number} [options.epoch=0] - Epoch start in seconds (default: 0)
201
+ * @param {number} [options.digits=6] - Number of digits (default: 6)
202
+ * @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
203
+ * @param {string} [options.encoding='base32'] - Encoding of secret if string
204
+ * @returns {string} Numerical token padded with leading zeros
205
+ */
206
+ function generateTotp(secret, options = {}) {
207
+ let timeSeconds;
208
+ if (options.time instanceof Date) {
209
+ timeSeconds = Math.floor(options.time.getTime() / 1000);
210
+ } else if (typeof options.time === 'number') {
211
+ // If value looks like milliseconds (> 10000000000), convert to seconds
212
+ timeSeconds = options.time > 1e11 ? Math.floor(options.time / 1000) : Math.floor(options.time);
213
+ } else {
214
+ timeSeconds = Math.floor(Date.now() / 1000);
215
+ }
216
+
217
+ const step = options.step || 30;
218
+ const epoch = options.epoch || 0;
219
+ const counter = Math.floor((timeSeconds - epoch) / step);
220
+
221
+ return generateHotp(secret, counter, options);
222
+ }
223
+
224
+ /**
225
+ * Verify TOTP token with time-drift window support
226
+ * @param {string} token - Submitted token
227
+ * @param {string|Buffer} secret - Secret key
228
+ * @param {Object} [options]
229
+ * @param {number} [options.window=1] - Counter drift window (e.g. 1 checks counter-1, counter, counter+1)
230
+ * @param {number|Date} [options.time=Date.now()] - Current timestamp
231
+ * @param {number} [options.step=30] - Time step in seconds
232
+ * @param {number} [options.digits=6] - Expected digits
233
+ * @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
234
+ * @param {boolean} [options.detailed=false] - Return { valid: boolean, delta: number|null } instead of boolean
235
+ * @returns {boolean|{ valid: boolean, delta: number|null }}
236
+ */
237
+ function verifyTotp(token, secret, options = {}) {
238
+ if (typeof token !== 'string') {
239
+ return options.detailed ? { valid: false, delta: null } : false;
240
+ }
241
+
242
+ const cleanToken = token.trim();
243
+ const digits = options.digits || cleanToken.length || 6;
244
+ const step = options.step || 30;
245
+ const epoch = options.epoch || 0;
246
+ const windowSize = Math.max(0, options.window != null ? options.window : 1);
247
+
248
+ let timeSeconds;
249
+ if (options.time instanceof Date) {
250
+ timeSeconds = Math.floor(options.time.getTime() / 1000);
251
+ } else if (typeof options.time === 'number') {
252
+ timeSeconds = options.time > 1e11 ? Math.floor(options.time / 1000) : Math.floor(options.time);
253
+ } else {
254
+ timeSeconds = Math.floor(Date.now() / 1000);
255
+ }
256
+
257
+ const currentCounter = Math.floor((timeSeconds - epoch) / step);
258
+
259
+ for (let delta = -windowSize; delta <= windowSize; delta++) {
260
+ const counter = currentCounter + delta;
261
+ if (counter < 0) continue;
262
+
263
+ const expected = generateHotp(secret, counter, { ...options, digits });
264
+ if (timingSafeTokenCompare(cleanToken, expected)) {
265
+ if (options.detailed) {
266
+ return { valid: true, delta };
267
+ }
268
+ return true;
269
+ }
270
+ }
271
+
272
+ if (options.detailed) {
273
+ return { valid: false, delta: null };
274
+ }
275
+ return false;
276
+ }
277
+
278
+ /**
279
+ * Generate standard Key URI for Google Authenticator / 1Password / Authy (otpauth://)
280
+ * @param {Object} options
281
+ * @param {string} options.secret - Base32 encoded secret
282
+ * @param {string} [options.account] - User account identifier (e.g. 'alice@example.com')
283
+ * @param {string} [options.issuer='Webspresso'] - Application or organization name
284
+ * @param {string} [options.label] - Custom full label (overrides issuer:account)
285
+ * @param {string} [options.algorithm='SHA1'] - Algorithm ('SHA1', 'SHA256', 'SHA512')
286
+ * @param {number} [options.digits=6] - Number of digits
287
+ * @param {number} [options.period=30] - Time period in seconds
288
+ * @returns {string} otpauth URI string
289
+ */
290
+ function generateTotpUri(options = {}) {
291
+ const {
292
+ secret,
293
+ account,
294
+ issuer = 'Webspresso',
295
+ digits = 6,
296
+ period = 30,
297
+ algorithm = 'SHA1',
298
+ } = options;
299
+
300
+ if (!secret) {
301
+ throw new Error('Secret is required to generate TOTP URI');
302
+ }
303
+
304
+ // Label: "Issuer:Account" or "Account"
305
+ let label = options.label;
306
+ if (!label) {
307
+ const cleanAccount = account || 'user';
308
+ label = issuer ? `${issuer}:${cleanAccount}` : cleanAccount;
309
+ }
310
+
311
+ const params = new URLSearchParams();
312
+ params.set('secret', secret);
313
+ if (issuer) params.set('issuer', issuer);
314
+ if (algorithm && algorithm.toUpperCase() !== 'SHA1') {
315
+ params.set('algorithm', algorithm.toUpperCase());
316
+ }
317
+ if (digits && digits !== 6) {
318
+ params.set('digits', String(digits));
319
+ }
320
+ if (period && period !== 30) {
321
+ params.set('period', String(period));
322
+ }
323
+
324
+ // otpauth://totp/LABEL?PARAMS
325
+ return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
326
+ }
327
+
328
+ /**
329
+ * Generate SVG string of the QR Code for a TOTP URI
330
+ * @param {string|Object} uriOrOptions - URI string or options for generateTotpUri
331
+ * @param {Object} [svgOptions] - QR SVG rendering options (size, margin, darkColor, lightColor)
332
+ * @returns {string} SVG string
333
+ */
334
+ function generateTotpQrSvg(uriOrOptions, svgOptions = {}) {
335
+ const uri = typeof uriOrOptions === 'string' ? uriOrOptions : generateTotpUri(uriOrOptions);
336
+ return generateQrSvg(uri, svgOptions);
337
+ }
338
+
339
+ /**
340
+ * Generate Data URI of the QR Code SVG
341
+ * @param {string|Object} uriOrOptions
342
+ * @param {Object} [svgOptions]
343
+ * @returns {string} data:image/svg+xml;utf8,...
344
+ */
345
+ function generateTotpQrDataUri(uriOrOptions, svgOptions = {}) {
346
+ const uri = typeof uriOrOptions === 'string' ? uriOrOptions : generateTotpUri(uriOrOptions);
347
+ return generateQrDataUri(uri, svgOptions);
348
+ }
349
+
350
+ /**
351
+ * Generate a set of random recovery / backup codes
352
+ * E.g., 'a1b2-c3d4', '98f7-21a4'
353
+ * @param {Object} [options]
354
+ * @param {number} [options.count=8] - Number of codes to generate
355
+ * @param {number} [options.length=10] - Characters per code (before hyphen)
356
+ * @param {boolean} [options.hyphen=true] - Format with hyphen in middle
357
+ * @returns {string[]} Array of plaintext recovery codes
358
+ */
359
+ function generateRecoveryCodes(options = {}) {
360
+ const count = options.count || 8;
361
+ const length = options.length || 10;
362
+ const hyphen = options.hyphen !== false;
363
+
364
+ const codes = [];
365
+ for (let i = 0; i < count; i++) {
366
+ // Generate random hex characters
367
+ const byteCount = Math.ceil(length / 2);
368
+ let code = crypto.randomBytes(byteCount).toString('hex').slice(0, length);
369
+ if (hyphen && code.length >= 6) {
370
+ const mid = Math.floor(code.length / 2);
371
+ code = `${code.slice(0, mid)}-${code.slice(mid)}`;
372
+ }
373
+ codes.push(code);
374
+ }
375
+ return codes;
376
+ }
377
+
378
+ /**
379
+ * Hash a single recovery code for secure database storage
380
+ * @param {string} code - Plaintext recovery code
381
+ * @returns {string} SHA-256 hex hash
382
+ */
383
+ function hashRecoveryCode(code) {
384
+ if (typeof code !== 'string') {
385
+ throw new TypeError('Recovery code must be a string');
386
+ }
387
+ const clean = code.toLowerCase().replace(/[\s-]/g, '');
388
+ return crypto.createHash('sha256').update(clean, 'utf8').digest('hex');
389
+ }
390
+
391
+ /**
392
+ * Verify a submitted recovery code against an array of hashed recovery codes
393
+ * @param {string} code - Submitted plaintext recovery code
394
+ * @param {string[]} hashedCodes - Array of hashed codes from database
395
+ * @returns {{ valid: boolean, matchedIndex: number, remainingHashedCodes: string[] }}
396
+ */
397
+ function verifyRecoveryCode(code, hashedCodes = []) {
398
+ if (typeof code !== 'string' || !Array.isArray(hashedCodes) || hashedCodes.length === 0) {
399
+ return { valid: false, matchedIndex: -1, remainingHashedCodes: hashedCodes || [] };
400
+ }
401
+
402
+ const targetHash = hashRecoveryCode(code);
403
+ const targetBuf = Buffer.from(targetHash, 'hex');
404
+
405
+ let matchedIndex = -1;
406
+ for (let i = 0; i < hashedCodes.length; i++) {
407
+ const candidateHash = hashedCodes[i];
408
+ if (typeof candidateHash !== 'string' || candidateHash.length !== targetHash.length) {
409
+ continue;
410
+ }
411
+ const candidateBuf = Buffer.from(candidateHash, 'hex');
412
+ if (crypto.timingSafeEqual(targetBuf, candidateBuf)) {
413
+ matchedIndex = i;
414
+ break;
415
+ }
416
+ }
417
+
418
+ if (matchedIndex === -1) {
419
+ return { valid: false, matchedIndex: -1, remainingHashedCodes: [...hashedCodes] };
420
+ }
421
+
422
+ const remaining = hashedCodes.filter((_, idx) => idx !== matchedIndex);
423
+ return {
424
+ valid: true,
425
+ matchedIndex,
426
+ remainingHashedCodes: remaining,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Convenience all-in-one helper to initialize 2FA setup for a user
432
+ * @param {Object} options
433
+ * @param {Object|string} options.user - User object (with email or username) or user identifier string
434
+ * @param {string} [options.issuer='Webspresso'] - App/Company name
435
+ * @param {number} [options.recoveryCodeCount=8] - Number of backup codes
436
+ * @param {Object} [options.qrOptions] - QR SVG options
437
+ * @returns {{
438
+ * secret: string,
439
+ * uri: string,
440
+ * qrSvg: string,
441
+ * qrDataUri: string,
442
+ * recoveryCodes: string[],
443
+ * recoveryCodesHashed: string[]
444
+ * }}
445
+ */
446
+ function createTotpSetup(options = {}) {
447
+ const secret = options.secret || generateTotpSecret(options);
448
+ const user = options.user;
449
+ const account = typeof user === 'string' ? user : (user?.email || user?.username || user?.id || 'user');
450
+ const issuer = options.issuer || 'Webspresso';
451
+
452
+ const uri = generateTotpUri({
453
+ secret,
454
+ account: String(account),
455
+ issuer,
456
+ ...options,
457
+ });
458
+
459
+ const qrSvg = generateTotpQrSvg(uri, options.qrOptions);
460
+ const qrDataUri = generateTotpQrDataUri(uri, options.qrOptions);
461
+
462
+ const recoveryCodes = generateRecoveryCodes({ count: options.recoveryCodeCount || 8 });
463
+ const recoveryCodesHashed = recoveryCodes.map(hashRecoveryCode);
464
+
465
+ return {
466
+ secret,
467
+ uri,
468
+ qrSvg,
469
+ qrDataUri,
470
+ recoveryCodes,
471
+ recoveryCodesHashed,
472
+ };
473
+ }
474
+
475
+ module.exports = {
476
+ // Base32
477
+ base32Encode,
478
+ base32Decode,
479
+
480
+ // HOTP & TOTP Core
481
+ generateTotpSecret,
482
+ generateHotp,
483
+ verifyHotp,
484
+ generateTotp,
485
+ verifyTotp,
486
+
487
+ // URI & QR
488
+ generateTotpUri,
489
+ generateTotpQrSvg,
490
+ generateTotpQrDataUri,
491
+
492
+ // Recovery Codes
493
+ generateRecoveryCodes,
494
+ hashRecoveryCode,
495
+ verifyRecoveryCode,
496
+
497
+ // All-in-one setup
498
+ createTotpSetup,
499
+
500
+ // QR primitives
501
+ createQrMatrix,
502
+ generateQrSvg,
503
+ generateQrDataUri,
504
+ };
@@ -42,6 +42,11 @@ function appendVary(res, field) {
42
42
  * @param {Object} [options.deflate] - Custom Deflate options
43
43
  * @returns {import('express').RequestHandler}
44
44
  */
45
+ function resCompress(enable = true) {
46
+ this._noCompression = !enable;
47
+ return this;
48
+ }
49
+
45
50
  function createCompressionMiddleware(options = {}) {
46
51
  const threshold = typeof options.threshold === 'number' && options.threshold >= 0 ? options.threshold : 1024;
47
52
  const supportedEncodings = Array.isArray(options.encodings) && options.encodings.length > 0
@@ -53,10 +58,7 @@ function createCompressionMiddleware(options = {}) {
53
58
  return function compressionMiddleware(req, res, next) {
54
59
  // Expose opt-out API on response object
55
60
  res._noCompression = false;
56
- res.compress = function(enable = true) {
57
- res._noCompression = !enable;
58
- return res;
59
- };
61
+ res.compress = resCompress;
60
62
 
61
63
  // Skip compression for HEAD requests
62
64
  if (req.method === 'HEAD') {
@@ -64,6 +66,10 @@ function createCompressionMiddleware(options = {}) {
64
66
  }
65
67
 
66
68
  const acceptEncoding = req.headers['accept-encoding'];
69
+ if (!acceptEncoding) {
70
+ return next();
71
+ }
72
+
67
73
  let selectedEncoding = null;
68
74
  let checkedEncoding = false;
69
75
 
@@ -75,10 +81,10 @@ function createCompressionMiddleware(options = {}) {
75
81
  return selectedEncoding;
76
82
  }
77
83
 
78
- // Original methods to wrap
79
- const origWrite = res.write.bind(res);
80
- const origEnd = res.end.bind(res);
81
- const origWriteHead = res.writeHead.bind(res);
84
+ // Original methods to wrap (store references without .bind() allocation)
85
+ const origWrite = res.write;
86
+ const origEnd = res.end;
87
+ const origWriteHead = res.writeHead;
82
88
 
83
89
  let compressStream = null;
84
90
  let isCompressing = false;
@@ -151,15 +157,15 @@ function createCompressionMiddleware(options = {}) {
151
157
 
152
158
  // Pipe compression stream chunks directly into underlying socket/response
153
159
  compressStream.on('data', (chunk) => {
154
- origWrite(chunk);
160
+ origWrite.call(res, chunk);
155
161
  });
156
162
 
157
163
  compressStream.on('end', () => {
158
- origEnd();
164
+ origEnd.call(res);
159
165
  });
160
166
 
161
167
  compressStream.on('error', (err) => {
162
- origEnd();
168
+ origEnd.call(res);
163
169
  });
164
170
  }
165
171
 
@@ -219,7 +225,7 @@ function createCompressionMiddleware(options = {}) {
219
225
  } else {
220
226
  // Send buffered chunks uncompressed
221
227
  for (const b of bufferedChunks) {
222
- origWrite(b);
228
+ origWrite.call(res, b);
223
229
  }
224
230
  bufferedChunks = [];
225
231
  }
@@ -232,7 +238,7 @@ function createCompressionMiddleware(options = {}) {
232
238
  return compressStream.write(buf, callback);
233
239
  }
234
240
 
235
- return origWrite(buf, callback);
241
+ return origWrite.call(res, buf, callback);
236
242
  };
237
243
 
238
244
  // Wrap end
@@ -253,7 +259,7 @@ function createCompressionMiddleware(options = {}) {
253
259
  } else if (isCompressing && compressStream) {
254
260
  compressStream.write(buf);
255
261
  } else {
256
- origWrite(buf);
262
+ origWrite.call(res, buf);
257
263
  }
258
264
  }
259
265
 
@@ -282,10 +288,10 @@ function createCompressionMiddleware(options = {}) {
282
288
  appendVary(res, 'Accept-Encoding');
283
289
  }
284
290
  for (const b of bufferedChunks) {
285
- origWrite(b);
291
+ origWrite.call(res, b);
286
292
  }
287
293
  bufferedChunks = [];
288
- return origEnd(callback);
294
+ return origEnd.call(res, callback);
289
295
  }
290
296
  }
291
297
 
@@ -297,7 +303,7 @@ function createCompressionMiddleware(options = {}) {
297
303
  return res;
298
304
  }
299
305
 
300
- return origEnd(callback);
306
+ return origEnd.call(res, callback);
301
307
  };
302
308
 
303
309
  next();