webspresso 0.0.98 → 0.0.100

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 (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. package/src/server.js +6 -0
@@ -0,0 +1,507 @@
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
+ let clean = input.toUpperCase().replace(/[\s-]/g, '');
62
+ while (clean.endsWith('=')) {
63
+ clean = clean.slice(0, -1);
64
+ }
65
+ if (clean.length === 0) return Buffer.alloc(0);
66
+
67
+ let bits = 0;
68
+ let value = 0;
69
+ const bytes = [];
70
+
71
+ for (let i = 0; i < clean.length; i++) {
72
+ const char = clean[i];
73
+ const val = RFC4648_BASE32_ALPHABET.indexOf(char);
74
+ if (val === -1) {
75
+ throw new Error(`Invalid Base32 character: "${char}"`);
76
+ }
77
+ value = (value << 5) | val;
78
+ bits += 5;
79
+ if (bits >= 8) {
80
+ bytes.push((value >>> (bits - 8)) & 0xff);
81
+ bits -= 8;
82
+ }
83
+ }
84
+
85
+ return Buffer.from(bytes);
86
+ }
87
+
88
+ /**
89
+ * Generate a random cryptographically secure TOTP secret key
90
+ * @param {Object} [options]
91
+ * @param {number} [options.length=20] - Number of random bytes (20 bytes = 160 bits per RFC recommendations)
92
+ * @param {boolean} [options.padding=false] - Whether to pad the Base32 string
93
+ * @returns {string} Base32 encoded secret
94
+ */
95
+ function generateTotpSecret(options = {}) {
96
+ const length = options.length || 20;
97
+ const buffer = crypto.randomBytes(length);
98
+ return base32Encode(buffer, options);
99
+ }
100
+
101
+ /**
102
+ * Normalize secret to Buffer
103
+ * @param {string|Buffer} secret
104
+ * @param {string} [encoding='base32'] - 'base32', 'ascii', 'utf8', 'hex'
105
+ * @returns {Buffer}
106
+ */
107
+ function normalizeSecretBuffer(secret, encoding = 'base32') {
108
+ if (Buffer.isBuffer(secret)) {
109
+ return secret;
110
+ }
111
+ if (typeof secret !== 'string') {
112
+ throw new TypeError('Secret must be a string or Buffer');
113
+ }
114
+
115
+ if (encoding === 'base32') {
116
+ return base32Decode(secret);
117
+ }
118
+ return Buffer.from(secret, encoding);
119
+ }
120
+
121
+ /**
122
+ * Normalize algorithm string to Node.js crypto algorithm name
123
+ * @param {string} algorithm
124
+ * @returns {string}
125
+ */
126
+ function normalizeAlgorithm(algorithm = 'sha1') {
127
+ const algo = String(algorithm).toLowerCase().replace(/[^a-z0-9]/g, '');
128
+ if (algo === 'sha1' || algo === 'sha256' || algo === 'sha512') {
129
+ return algo;
130
+ }
131
+ return 'sha1';
132
+ }
133
+
134
+ /**
135
+ * Generate HMAC-Based One-Time Password (HOTP, RFC 4226)
136
+ * @param {string|Buffer} secret - Secret key (Base32 string or Buffer)
137
+ * @param {number|BigInt} counter - 8-byte moving factor counter
138
+ * @param {Object} [options]
139
+ * @param {number} [options.digits=6] - Number of digits (typically 6, 7, or 8)
140
+ * @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
141
+ * @param {string} [options.encoding='base32'] - Encoding of secret if string ('base32', 'ascii', etc.)
142
+ * @returns {string} Numerical token padded with leading zeros
143
+ */
144
+ function generateHotp(secret, counter, options = {}) {
145
+ const key = normalizeSecretBuffer(secret, options.encoding);
146
+ const digits = options.digits || 6;
147
+ const algorithm = normalizeAlgorithm(options.algorithm);
148
+
149
+ const counterBuf = Buffer.alloc(8);
150
+ counterBuf.writeBigUInt64BE(BigInt(counter), 0);
151
+
152
+ const hmac = crypto.createHmac(algorithm, key).update(counterBuf).digest();
153
+
154
+ // Dynamic truncation (RFC 4226 Section 5.4)
155
+ const offset = hmac[hmac.length - 1] & 0x0f;
156
+ const binary =
157
+ ((hmac[offset] & 0x7f) << 24) |
158
+ ((hmac[offset + 1] & 0xff) << 16) |
159
+ ((hmac[offset + 2] & 0xff) << 8) |
160
+ (hmac[offset + 3] & 0xff);
161
+
162
+ const token = (binary % 10 ** digits).toString().padStart(digits, '0');
163
+ return token;
164
+ }
165
+
166
+ /**
167
+ * Timing-safe token equality check
168
+ * @param {string} a
169
+ * @param {string} b
170
+ * @returns {boolean}
171
+ */
172
+ function timingSafeTokenCompare(a, b) {
173
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
174
+ if (a.length !== b.length) return false;
175
+
176
+ const bufA = Buffer.from(a);
177
+ const bufB = Buffer.from(b);
178
+ return crypto.timingSafeEqual(bufA, bufB);
179
+ }
180
+
181
+ /**
182
+ * Verify HOTP token against counter
183
+ * @param {string} token - Submitted token
184
+ * @param {string|Buffer} secret - Secret key
185
+ * @param {number|BigInt} counter - Expected counter
186
+ * @param {Object} [options]
187
+ * @returns {boolean}
188
+ */
189
+ function verifyHotp(token, secret, counter, options = {}) {
190
+ if (typeof token !== 'string') return false;
191
+ const cleanToken = token.trim();
192
+ const digits = options.digits || cleanToken.length || 6;
193
+ const expected = generateHotp(secret, counter, { ...options, digits });
194
+ return timingSafeTokenCompare(cleanToken, expected);
195
+ }
196
+
197
+ /**
198
+ * Generate Time-Based One-Time Password (TOTP, RFC 6238)
199
+ * @param {string|Buffer} secret - Secret key (Base32 or Buffer)
200
+ * @param {Object} [options]
201
+ * @param {number|Date} [options.time=Date.now()] - Timestamp in ms, seconds, or Date
202
+ * @param {number} [options.step=30] - Time step in seconds (default: 30)
203
+ * @param {number} [options.epoch=0] - Epoch start in seconds (default: 0)
204
+ * @param {number} [options.digits=6] - Number of digits (default: 6)
205
+ * @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
206
+ * @param {string} [options.encoding='base32'] - Encoding of secret if string
207
+ * @returns {string} Numerical token padded with leading zeros
208
+ */
209
+ function generateTotp(secret, options = {}) {
210
+ let timeSeconds;
211
+ if (options.time instanceof Date) {
212
+ timeSeconds = Math.floor(options.time.getTime() / 1000);
213
+ } else if (typeof options.time === 'number') {
214
+ // If value looks like milliseconds (> 10000000000), convert to seconds
215
+ timeSeconds = options.time > 1e11 ? Math.floor(options.time / 1000) : Math.floor(options.time);
216
+ } else {
217
+ timeSeconds = Math.floor(Date.now() / 1000);
218
+ }
219
+
220
+ const step = options.step || 30;
221
+ const epoch = options.epoch || 0;
222
+ const counter = Math.floor((timeSeconds - epoch) / step);
223
+
224
+ return generateHotp(secret, counter, options);
225
+ }
226
+
227
+ /**
228
+ * Verify TOTP token with time-drift window support
229
+ * @param {string} token - Submitted token
230
+ * @param {string|Buffer} secret - Secret key
231
+ * @param {Object} [options]
232
+ * @param {number} [options.window=1] - Counter drift window (e.g. 1 checks counter-1, counter, counter+1)
233
+ * @param {number|Date} [options.time=Date.now()] - Current timestamp
234
+ * @param {number} [options.step=30] - Time step in seconds
235
+ * @param {number} [options.digits=6] - Expected digits
236
+ * @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
237
+ * @param {boolean} [options.detailed=false] - Return { valid: boolean, delta: number|null } instead of boolean
238
+ * @returns {boolean|{ valid: boolean, delta: number|null }}
239
+ */
240
+ function verifyTotp(token, secret, options = {}) {
241
+ if (typeof token !== 'string') {
242
+ return options.detailed ? { valid: false, delta: null } : false;
243
+ }
244
+
245
+ const cleanToken = token.trim();
246
+ const digits = options.digits || cleanToken.length || 6;
247
+ const step = options.step || 30;
248
+ const epoch = options.epoch || 0;
249
+ const windowSize = Math.max(0, options.window != null ? options.window : 1);
250
+
251
+ let timeSeconds;
252
+ if (options.time instanceof Date) {
253
+ timeSeconds = Math.floor(options.time.getTime() / 1000);
254
+ } else if (typeof options.time === 'number') {
255
+ timeSeconds = options.time > 1e11 ? Math.floor(options.time / 1000) : Math.floor(options.time);
256
+ } else {
257
+ timeSeconds = Math.floor(Date.now() / 1000);
258
+ }
259
+
260
+ const currentCounter = Math.floor((timeSeconds - epoch) / step);
261
+
262
+ for (let delta = -windowSize; delta <= windowSize; delta++) {
263
+ const counter = currentCounter + delta;
264
+ if (counter < 0) continue;
265
+
266
+ const expected = generateHotp(secret, counter, { ...options, digits });
267
+ if (timingSafeTokenCompare(cleanToken, expected)) {
268
+ if (options.detailed) {
269
+ return { valid: true, delta };
270
+ }
271
+ return true;
272
+ }
273
+ }
274
+
275
+ if (options.detailed) {
276
+ return { valid: false, delta: null };
277
+ }
278
+ return false;
279
+ }
280
+
281
+ /**
282
+ * Generate standard Key URI for Google Authenticator / 1Password / Authy (otpauth://)
283
+ * @param {Object} options
284
+ * @param {string} options.secret - Base32 encoded secret
285
+ * @param {string} [options.account] - User account identifier (e.g. 'alice@example.com')
286
+ * @param {string} [options.issuer='Webspresso'] - Application or organization name
287
+ * @param {string} [options.label] - Custom full label (overrides issuer:account)
288
+ * @param {string} [options.algorithm='SHA1'] - Algorithm ('SHA1', 'SHA256', 'SHA512')
289
+ * @param {number} [options.digits=6] - Number of digits
290
+ * @param {number} [options.period=30] - Time period in seconds
291
+ * @returns {string} otpauth URI string
292
+ */
293
+ function generateTotpUri(options = {}) {
294
+ const {
295
+ secret,
296
+ account,
297
+ issuer = 'Webspresso',
298
+ digits = 6,
299
+ period = 30,
300
+ algorithm = 'SHA1',
301
+ } = options;
302
+
303
+ if (!secret) {
304
+ throw new Error('Secret is required to generate TOTP URI');
305
+ }
306
+
307
+ // Label: "Issuer:Account" or "Account"
308
+ let label = options.label;
309
+ if (!label) {
310
+ const cleanAccount = account || 'user';
311
+ label = issuer ? `${issuer}:${cleanAccount}` : cleanAccount;
312
+ }
313
+
314
+ const params = new URLSearchParams();
315
+ params.set('secret', secret);
316
+ if (issuer) params.set('issuer', issuer);
317
+ if (algorithm && algorithm.toUpperCase() !== 'SHA1') {
318
+ params.set('algorithm', algorithm.toUpperCase());
319
+ }
320
+ if (digits && digits !== 6) {
321
+ params.set('digits', String(digits));
322
+ }
323
+ if (period && period !== 30) {
324
+ params.set('period', String(period));
325
+ }
326
+
327
+ // otpauth://totp/LABEL?PARAMS
328
+ return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
329
+ }
330
+
331
+ /**
332
+ * Generate SVG string of the QR Code for a TOTP URI
333
+ * @param {string|Object} uriOrOptions - URI string or options for generateTotpUri
334
+ * @param {Object} [svgOptions] - QR SVG rendering options (size, margin, darkColor, lightColor)
335
+ * @returns {string} SVG string
336
+ */
337
+ function generateTotpQrSvg(uriOrOptions, svgOptions = {}) {
338
+ const uri = typeof uriOrOptions === 'string' ? uriOrOptions : generateTotpUri(uriOrOptions);
339
+ return generateQrSvg(uri, svgOptions);
340
+ }
341
+
342
+ /**
343
+ * Generate Data URI of the QR Code SVG
344
+ * @param {string|Object} uriOrOptions
345
+ * @param {Object} [svgOptions]
346
+ * @returns {string} data:image/svg+xml;utf8,...
347
+ */
348
+ function generateTotpQrDataUri(uriOrOptions, svgOptions = {}) {
349
+ const uri = typeof uriOrOptions === 'string' ? uriOrOptions : generateTotpUri(uriOrOptions);
350
+ return generateQrDataUri(uri, svgOptions);
351
+ }
352
+
353
+ /**
354
+ * Generate a set of random recovery / backup codes
355
+ * E.g., 'a1b2-c3d4', '98f7-21a4'
356
+ * @param {Object} [options]
357
+ * @param {number} [options.count=8] - Number of codes to generate
358
+ * @param {number} [options.length=10] - Characters per code (before hyphen)
359
+ * @param {boolean} [options.hyphen=true] - Format with hyphen in middle
360
+ * @returns {string[]} Array of plaintext recovery codes
361
+ */
362
+ function generateRecoveryCodes(options = {}) {
363
+ const count = options.count || 8;
364
+ const length = options.length || 10;
365
+ const hyphen = options.hyphen !== false;
366
+
367
+ const codes = [];
368
+ for (let i = 0; i < count; i++) {
369
+ // Generate random hex characters
370
+ const byteCount = Math.ceil(length / 2);
371
+ let code = crypto.randomBytes(byteCount).toString('hex').slice(0, length);
372
+ if (hyphen && code.length >= 6) {
373
+ const mid = Math.floor(code.length / 2);
374
+ code = `${code.slice(0, mid)}-${code.slice(mid)}`;
375
+ }
376
+ codes.push(code);
377
+ }
378
+ return codes;
379
+ }
380
+
381
+ /**
382
+ * Hash a single recovery code for secure database storage
383
+ * @param {string} code - Plaintext recovery code
384
+ * @returns {string} SHA-256 hex hash
385
+ */
386
+ function hashRecoveryCode(code) {
387
+ if (typeof code !== 'string') {
388
+ throw new TypeError('Recovery code must be a string');
389
+ }
390
+ const clean = code.toLowerCase().replace(/[\s-]/g, '');
391
+ return crypto.createHash('sha256').update(clean, 'utf8').digest('hex');
392
+ }
393
+
394
+ /**
395
+ * Verify a submitted recovery code against an array of hashed recovery codes
396
+ * @param {string} code - Submitted plaintext recovery code
397
+ * @param {string[]} hashedCodes - Array of hashed codes from database
398
+ * @returns {{ valid: boolean, matchedIndex: number, remainingHashedCodes: string[] }}
399
+ */
400
+ function verifyRecoveryCode(code, hashedCodes = []) {
401
+ if (typeof code !== 'string' || !Array.isArray(hashedCodes) || hashedCodes.length === 0) {
402
+ return { valid: false, matchedIndex: -1, remainingHashedCodes: hashedCodes || [] };
403
+ }
404
+
405
+ const targetHash = hashRecoveryCode(code);
406
+ const targetBuf = Buffer.from(targetHash, 'hex');
407
+
408
+ let matchedIndex = -1;
409
+ for (let i = 0; i < hashedCodes.length; i++) {
410
+ const candidateHash = hashedCodes[i];
411
+ if (typeof candidateHash !== 'string' || candidateHash.length !== targetHash.length) {
412
+ continue;
413
+ }
414
+ const candidateBuf = Buffer.from(candidateHash, 'hex');
415
+ if (crypto.timingSafeEqual(targetBuf, candidateBuf)) {
416
+ matchedIndex = i;
417
+ break;
418
+ }
419
+ }
420
+
421
+ if (matchedIndex === -1) {
422
+ return { valid: false, matchedIndex: -1, remainingHashedCodes: [...hashedCodes] };
423
+ }
424
+
425
+ const remaining = hashedCodes.filter((_, idx) => idx !== matchedIndex);
426
+ return {
427
+ valid: true,
428
+ matchedIndex,
429
+ remainingHashedCodes: remaining,
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Convenience all-in-one helper to initialize 2FA setup for a user
435
+ * @param {Object} options
436
+ * @param {Object|string} options.user - User object (with email or username) or user identifier string
437
+ * @param {string} [options.issuer='Webspresso'] - App/Company name
438
+ * @param {number} [options.recoveryCodeCount=8] - Number of backup codes
439
+ * @param {Object} [options.qrOptions] - QR SVG options
440
+ * @returns {{
441
+ * secret: string,
442
+ * uri: string,
443
+ * qrSvg: string,
444
+ * qrDataUri: string,
445
+ * recoveryCodes: string[],
446
+ * recoveryCodesHashed: string[]
447
+ * }}
448
+ */
449
+ function createTotpSetup(options = {}) {
450
+ const secret = options.secret || generateTotpSecret(options);
451
+ const user = options.user;
452
+ const account = typeof user === 'string' ? user : (user?.email || user?.username || user?.id || 'user');
453
+ const issuer = options.issuer || 'Webspresso';
454
+
455
+ const uri = generateTotpUri({
456
+ secret,
457
+ account: String(account),
458
+ issuer,
459
+ ...options,
460
+ });
461
+
462
+ const qrSvg = generateTotpQrSvg(uri, options.qrOptions);
463
+ const qrDataUri = generateTotpQrDataUri(uri, options.qrOptions);
464
+
465
+ const recoveryCodes = generateRecoveryCodes({ count: options.recoveryCodeCount || 8 });
466
+ const recoveryCodesHashed = recoveryCodes.map(hashRecoveryCode);
467
+
468
+ return {
469
+ secret,
470
+ uri,
471
+ qrSvg,
472
+ qrDataUri,
473
+ recoveryCodes,
474
+ recoveryCodesHashed,
475
+ };
476
+ }
477
+
478
+ module.exports = {
479
+ // Base32
480
+ base32Encode,
481
+ base32Decode,
482
+
483
+ // HOTP & TOTP Core
484
+ generateTotpSecret,
485
+ generateHotp,
486
+ verifyHotp,
487
+ generateTotp,
488
+ verifyTotp,
489
+
490
+ // URI & QR
491
+ generateTotpUri,
492
+ generateTotpQrSvg,
493
+ generateTotpQrDataUri,
494
+
495
+ // Recovery Codes
496
+ generateRecoveryCodes,
497
+ hashRecoveryCode,
498
+ verifyRecoveryCode,
499
+
500
+ // All-in-one setup
501
+ createTotpSetup,
502
+
503
+ // QR primitives
504
+ createQrMatrix,
505
+ generateQrSvg,
506
+ generateQrDataUri,
507
+ };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Webspresso Zero-Dependency User-Agent & Device Parser
3
+ * Extracts browser, operating system, and device category from User-Agent header
4
+ * @module core/auth/user-agent
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ /**
10
+ * @typedef {Object} DeviceInfo
11
+ * @property {string} browser - Browser name with major version (e.g. 'Chrome 128', 'Safari 17')
12
+ * @property {string} os - Operating system name (e.g. 'macOS 14', 'Windows 11', 'iOS 17')
13
+ * @property {'desktop'|'mobile'|'tablet'|'bot'|'unknown'} device - Device category
14
+ * @property {boolean} isMobile
15
+ * @property {boolean} isTablet
16
+ * @property {boolean} isDesktop
17
+ * @property {boolean} isBot
18
+ */
19
+
20
+ /**
21
+ * Extract client IP address handling proxies
22
+ * @param {Object} req - Express / Node.js HTTP request
23
+ * @returns {string}
24
+ */
25
+ function getClientIp(req) {
26
+ if (!req) return '127.0.0.1';
27
+
28
+ const forwarded = req.headers?.['x-forwarded-for'];
29
+ if (forwarded) {
30
+ const ips = typeof forwarded === 'string' ? forwarded.split(',') : forwarded;
31
+ const first = Array.isArray(ips) ? ips[0] : ips;
32
+ if (first) return first.trim();
33
+ }
34
+
35
+ return req.headers?.['x-real-ip'] ||
36
+ req.ip ||
37
+ req.connection?.remoteAddress ||
38
+ req.socket?.remoteAddress ||
39
+ '127.0.0.1';
40
+ }
41
+
42
+ /**
43
+ * Parse raw User-Agent header string into structured device info
44
+ * Zero external dependencies — pure regex extraction
45
+ *
46
+ * @param {string} [userAgent='']
47
+ * @returns {DeviceInfo}
48
+ */
49
+ function parseUserAgent(userAgent = '') {
50
+ const ua = String(userAgent || '').trim();
51
+
52
+ if (!ua) {
53
+ return {
54
+ browser: 'Unknown',
55
+ os: 'Unknown',
56
+ device: 'unknown',
57
+ isMobile: false,
58
+ isTablet: false,
59
+ isDesktop: false,
60
+ isBot: false,
61
+ };
62
+ }
63
+
64
+ // 1. Detect Bots / Crawlers
65
+ const botPattern = /googlebot|bingbot|yandex|baiduspider|duckduckbot|slurp|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/|slackbot|vkshare|w3c_validator|whatsapp|curl|wget|postman/i;
66
+ const isBot = botPattern.test(ua);
67
+
68
+ // 2. Detect Operating System
69
+ let os = 'Unknown';
70
+ if (/iPad/i.test(ua)) {
71
+ const match = ua.match(/OS ([\d_\.]+)/i);
72
+ os = match ? `iPadOS ${match[1].replace(/_/g, '.')}` : 'iPadOS';
73
+ } else if (/iPhone|iPod/i.test(ua)) {
74
+ const match = ua.match(/OS ([\d_\.]+)/i);
75
+ os = match ? `iOS ${match[1].replace(/_/g, '.')}` : 'iOS';
76
+ } else if (/Android/i.test(ua)) {
77
+ const match = ua.match(/Android (\d+(\.\d+)?)/i);
78
+ os = match ? `Android ${match[1]}` : 'Android';
79
+ } else if (/Macintosh|Mac OS X/i.test(ua)) {
80
+ const match = ua.match(/Mac OS X ([\d_\.]+)/i);
81
+ if (match) {
82
+ const ver = match[1].replace(/_/g, '.');
83
+ os = `macOS ${ver}`;
84
+ } else {
85
+ os = 'macOS';
86
+ }
87
+ } else if (/Windows/i.test(ua)) {
88
+ if (/Windows NT 10\.0/i.test(ua)) os = 'Windows 10/11';
89
+ else if (/Windows NT 6\.3/i.test(ua)) os = 'Windows 8.1';
90
+ else if (/Windows NT 6\.2/i.test(ua)) os = 'Windows 8';
91
+ else if (/Windows NT 6\.1/i.test(ua)) os = 'Windows 7';
92
+ else os = 'Windows';
93
+ } else if (/CrOS/i.test(ua)) {
94
+ os = 'ChromeOS';
95
+ } else if (/Linux/i.test(ua)) {
96
+ if (/Ubuntu/i.test(ua)) os = 'Ubuntu Linux';
97
+ else if (/Fedora/i.test(ua)) os = 'Fedora Linux';
98
+ else os = 'Linux';
99
+ }
100
+
101
+ // 3. Detect Browser
102
+ let browser = 'Unknown';
103
+ if (/Edg(e|A|iOS)?\/(\d+)/i.test(ua)) {
104
+ const match = ua.match(/Edg(e|A|iOS)?\/(\d+)/i);
105
+ browser = `Edge ${match ? match[2] : ''}`.trim();
106
+ } else if (/OPR\/(\d+)/i.test(ua)) {
107
+ const match = ua.match(/OPR\/(\d+)/i);
108
+ browser = `Opera ${match ? match[1] : ''}`.trim();
109
+ } else if (/SamsungBrowser\/(\d+)/i.test(ua)) {
110
+ const match = ua.match(/SamsungBrowser\/(\d+)/i);
111
+ browser = `Samsung Internet ${match ? match[1] : ''}`.trim();
112
+ } else if (/Brave\/(\d+)/i.test(ua)) {
113
+ const match = ua.match(/Brave\/(\d+)/i);
114
+ browser = `Brave ${match ? match[1] : ''}`.trim();
115
+ } else if (/Vivaldi\/(\d+)/i.test(ua)) {
116
+ const match = ua.match(/Vivaldi\/(\d+)/i);
117
+ browser = `Vivaldi ${match ? match[1] : ''}`.trim();
118
+ } else if (/Chrome\/(\d+)/i.test(ua) && !/Chromium/i.test(ua)) {
119
+ const match = ua.match(/Chrome\/(\d+)/i);
120
+ browser = `Chrome ${match ? match[1] : ''}`.trim();
121
+ } else if (/Firefox\/(\d+)/i.test(ua) || /FxiOS\/(\d+)/i.test(ua)) {
122
+ const match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i);
123
+ browser = `Firefox ${match ? match[1] : ''}`.trim();
124
+ } else if (/Safari/i.test(ua) && /Version\/(\d+)/i.test(ua)) {
125
+ const match = ua.match(/Version\/(\d+)/i);
126
+ browser = `Safari ${match ? match[1] : ''}`.trim();
127
+ } else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
128
+ browser = 'Safari';
129
+ } else if (/MSIE (\d+)/i.test(ua) || (/Trident/i.test(ua) && /rv:(\d+)/i.test(ua))) {
130
+ browser = 'Internet Explorer';
131
+ } else if (isBot) {
132
+ const botNameMatch = ua.match(/(googlebot|bingbot|duckduckbot|slurp|twitterbot|slackbot|curl|wget)/i);
133
+ browser = botNameMatch ? botNameMatch[1] : 'Bot';
134
+ }
135
+
136
+ // 4. Detect Device Category
137
+ const isTablet = /iPad|tablet/i.test(ua) || (/android/i.test(ua) && !/mobile/i.test(ua));
138
+ const isMobile = !isTablet && /mobile|iphone|ipod|android|blackberry|opera mini|windows phone/i.test(ua);
139
+ const isDesktop = !isTablet && !isMobile && !isBot;
140
+
141
+ let device = 'unknown';
142
+ if (isBot) device = 'bot';
143
+ else if (isTablet) device = 'tablet';
144
+ else if (isMobile) device = 'mobile';
145
+ else if (isDesktop) device = 'desktop';
146
+
147
+ return {
148
+ browser,
149
+ os,
150
+ device,
151
+ isMobile,
152
+ isTablet,
153
+ isDesktop,
154
+ isBot,
155
+ };
156
+ }
157
+
158
+ module.exports = {
159
+ getClientIp,
160
+ parseUserAgent,
161
+ };