@getstrata/core 1.0.9 → 1.1.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/core/auth/authContext.d.ts +5 -2
  3. package/dist/core/auth/emailVerification.d.ts +1 -1
  4. package/dist/core/auth/guard.d.ts +5 -1
  5. package/dist/core/auth/jwtGuard.d.ts +6 -2
  6. package/dist/core/auth/oauth/oidcIdToken.d.ts +19 -0
  7. package/dist/core/auth/oauth/oidcProvider.d.ts +16 -4
  8. package/dist/core/auth/oauth/samlProvider.d.ts +7 -4
  9. package/dist/core/auth/oneTimeToken.d.ts +19 -0
  10. package/dist/core/auth/passwordLogin.d.ts +20 -0
  11. package/dist/core/auth/saml/samlAssertionReplay.d.ts +20 -0
  12. package/dist/core/auth/saml/samlServiceProvider.d.ts +51 -0
  13. package/dist/core/contracts/authUserDirectory.d.ts +2 -3
  14. package/dist/core/database/errors.d.ts +2 -1
  15. package/dist/core/http/response.d.ts +3 -1
  16. package/dist/core/mail/mailer.d.ts +6 -1
  17. package/dist/core/security/oauthState.d.ts +6 -2
  18. package/dist/core/security/safeFetch.d.ts +1 -0
  19. package/dist/core/security/safePath.d.ts +4 -0
  20. package/dist/core/security/safeUrl.d.ts +14 -1
  21. package/dist/core/security/totp.d.ts +1 -1
  22. package/dist/core/tenant/databaseTenantContext.d.ts +2 -1
  23. package/dist/core/tenant/enableTenantRls.d.ts +6 -0
  24. package/dist/core/tenant/postgresAppRole.d.ts +75 -0
  25. package/dist/entries/audit/exportAuditLogs.js +223 -40
  26. package/dist/entries/auth/accessControl.js +1 -0
  27. package/dist/entries/auth/basicAuthGuard.js +638 -17
  28. package/dist/entries/auth/emailVerification.js +1 -1
  29. package/dist/entries/auth/intendedUrlCookie.js +32 -2
  30. package/dist/entries/auth/jwt.js +1 -1
  31. package/dist/entries/auth/jwtGuard.js +192 -3
  32. package/dist/entries/auth/membershipMiddleware.js +1 -0
  33. package/dist/entries/auth/membershipScope.js +1 -0
  34. package/dist/entries/auth/membershipService.js +1 -0
  35. package/dist/entries/auth/oauth/oidcProvider.js +542 -20
  36. package/dist/entries/auth/oauth/providers.js +313 -5
  37. package/dist/entries/auth/oauth/samlProvider.js +552 -74
  38. package/dist/entries/auth/oneTimeToken.js +362 -0
  39. package/dist/entries/auth/password.js +1 -1
  40. package/dist/entries/auth/passwordLogin.js +548 -0
  41. package/dist/entries/auth/policy.js +1 -0
  42. package/dist/entries/auth/saml/samlServiceProvider.js +571 -0
  43. package/dist/entries/auth/scimAuthMiddleware.js +10 -3
  44. package/dist/entries/auth/sessionCookie.js +1 -1
  45. package/dist/entries/auth/sessionGuard.js +1 -1
  46. package/dist/entries/auth/tokenHash.js +2 -6
  47. package/dist/entries/crypto/fieldEncryption.js +1 -1
  48. package/dist/entries/crypto/mfaSecret.js +31 -5
  49. package/dist/entries/database/errors.js +29 -12
  50. package/dist/entries/database/model.js +9 -9
  51. package/dist/entries/facades.js +72 -9
  52. package/dist/entries/http/authMiddleware.js +7 -14
  53. package/dist/entries/http/corsMiddleware.js +69 -2
  54. package/dist/entries/http/csrfMiddleware.js +33 -43
  55. package/dist/entries/http/csrfToken.js +2 -2
  56. package/dist/entries/http/flashMiddleware.js +4 -2
  57. package/dist/entries/http/flashSession.js +4 -2
  58. package/dist/entries/http/formRequest.js +2 -2
  59. package/dist/entries/http/memoryThrottleMiddleware.js +63 -0
  60. package/dist/entries/http/pagination.js +2 -2
  61. package/dist/entries/http/requirePasswordConfirmMiddleware.js +36 -3
  62. package/dist/entries/http/requireWebAuthMiddleware.js +32 -2
  63. package/dist/entries/http/response.js +101 -45
  64. package/dist/entries/http/routeModelBinding.js +2 -2
  65. package/dist/entries/http/safeInternalPath.js +32 -2
  66. package/dist/entries/http/scimThrottleMiddleware.js +7 -1
  67. package/dist/entries/http/securedRouteModelBinding.js +2 -2
  68. package/dist/entries/http/securityHeadersMiddleware.js +3 -64
  69. package/dist/entries/http/signedUrl.js +6 -5
  70. package/dist/entries/http/throttleMiddleware.js +63 -0
  71. package/dist/entries/http/validation.js +2 -2
  72. package/dist/entries/http/webFormRequest.js +2 -2
  73. package/dist/entries/jobs/exportAuditLogsJob.js +223 -40
  74. package/dist/entries/mail/mailer.js +54 -8
  75. package/dist/entries/media/imageTransform.js +12 -1
  76. package/dist/entries/openapi/generator.js +0 -7
  77. package/dist/entries/security/oauthState.js +30 -39
  78. package/dist/entries/security/publicReads.js +1 -1
  79. package/dist/entries/security/recoveryCodes.js +2 -2
  80. package/dist/entries/security/safeFetch.js +195 -94
  81. package/dist/entries/security/safePath.js +25 -0
  82. package/dist/entries/security/scimTenantTokens.js +10 -3
  83. package/dist/entries/security/totp.js +16 -4
  84. package/dist/entries/storage/storage.js +24 -2
  85. package/dist/entries/tenant/databaseTenantContext.js +34 -12
  86. package/dist/entries/tenant/enableTenantRls.js +385 -0
  87. package/dist/entries/tracing/tracingMiddleware.js +308 -5
  88. package/dist/framework/public-api.d.ts +10 -3
  89. package/dist/index.js +2767 -1515
  90. package/package.json +32 -3
@@ -44,6 +44,71 @@ function resolveAuthUserDirectory(container) {
44
44
  return null;
45
45
  }
46
46
 
47
+ // ../../src/core/database/boundConnection.ts
48
+ var BOUND_CONNECTION_KEY = Symbol.for("@getstrata/boundDatabaseConnection");
49
+ function boundConnectionState() {
50
+ const globalRecord = globalThis;
51
+ const existing = globalRecord[BOUND_CONNECTION_KEY];
52
+ if (existing) {
53
+ return existing;
54
+ }
55
+ const created = { connection: null };
56
+ globalRecord[BOUND_CONNECTION_KEY] = created;
57
+ return created;
58
+ }
59
+ function getBoundDatabaseConnection() {
60
+ return boundConnectionState().connection;
61
+ }
62
+
63
+ // ../../src/core/runtime/asyncContextStore.ts
64
+ import { AsyncLocalStorage } from "async_hooks";
65
+ function createAsyncContextStore(key) {
66
+ const symbol = Symbol.for(key);
67
+ const globalRecord = globalThis;
68
+ const existing = globalRecord[symbol];
69
+ if (existing) {
70
+ return existing;
71
+ }
72
+ const store = new AsyncLocalStorage;
73
+ globalRecord[symbol] = store;
74
+ return store;
75
+ }
76
+
77
+ // ../../src/core/database/connectionContext.ts
78
+ var activeConnection = createAsyncContextStore("@getstrata/databaseConnectionContext");
79
+ function runWithDatabaseConnection(connection, callback) {
80
+ return activeConnection.run(connection, callback);
81
+ }
82
+ function getActiveDatabaseConnection(fallback) {
83
+ return activeConnection.getStore() ?? fallback;
84
+ }
85
+ function hasActiveDatabaseConnection() {
86
+ return activeConnection.getStore() !== undefined;
87
+ }
88
+
89
+ // ../../src/core/database/queryProxy.ts
90
+ var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
91
+
92
+ // ../../src/core/database/defaultConnection.ts
93
+ var DEFAULT_CONNECTION_KEY = Symbol.for("@getstrata/defaultDatabaseConnection");
94
+ function defaultConnectionState() {
95
+ const globalRecord = globalThis;
96
+ const existing = globalRecord[DEFAULT_CONNECTION_KEY];
97
+ if (existing) {
98
+ return existing;
99
+ }
100
+ const created = { pool: null, query: null };
101
+ globalRecord[DEFAULT_CONNECTION_KEY] = created;
102
+ return created;
103
+ }
104
+ function getDefaultDatabasePool() {
105
+ const pool = defaultConnectionState().pool;
106
+ if (!pool) {
107
+ throw new Error("Default database pool is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
108
+ }
109
+ return pool;
110
+ }
111
+
47
112
  // ../../src/core/http/statelessAuth.ts
48
113
  function authorizationScheme(request) {
49
114
  const header = request.headers.get("authorization")?.trim() ?? "";
@@ -90,13 +155,542 @@ function readBasicCredentials(request) {
90
155
  async function hashPassword(password) {
91
156
  return await Bun.password.hash(password, {
92
157
  algorithm: "bcrypt",
93
- cost: 10
158
+ cost: 12
94
159
  });
95
160
  }
96
161
  async function verifyPassword(password, passwordHash) {
97
162
  return await Bun.password.verify(password, passwordHash);
98
163
  }
99
164
 
165
+ // ../../src/core/runtime/appEnv.ts
166
+ var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
167
+ function normalizeEnvValue(value) {
168
+ return (value ?? "").trim().toLowerCase();
169
+ }
170
+ function isProductionEnv(env = process.env) {
171
+ const appEnv = normalizeEnvValue(env.APP_ENV);
172
+ const nodeEnv = normalizeEnvValue(env.NODE_ENV);
173
+ if (appEnv === "production" || nodeEnv === "production") {
174
+ return true;
175
+ }
176
+ if (appEnv === "") {
177
+ return false;
178
+ }
179
+ return !NON_PRODUCTION_APP_ENVS.has(appEnv);
180
+ }
181
+ function envFlagEnabled(value) {
182
+ return value === "true";
183
+ }
184
+
185
+ // ../../src/core/crypto/fieldEncryption.ts
186
+ import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
187
+ var ENCRYPTION_PREFIX = "enc:v1:";
188
+ var IV_LENGTH = 12;
189
+ var TAG_LENGTH = 16;
190
+ function resolveEncryptionKey() {
191
+ const raw = process.env.KMS_ENCRYPTION_KEY?.trim();
192
+ if (!raw) {
193
+ return null;
194
+ }
195
+ if (/^[0-9a-f]{64}$/i.test(raw)) {
196
+ return Buffer.from(raw, "hex");
197
+ }
198
+ const decoded = Buffer.from(raw, "base64");
199
+ if (decoded.length === 32) {
200
+ return decoded;
201
+ }
202
+ throw new Error("KMS_ENCRYPTION_KEY must be 32 bytes (hex or base64).");
203
+ }
204
+ function isFieldEncryptionEnabled() {
205
+ const featureFlag = process.env.FEATURE_FIELD_ENCRYPTION;
206
+ if (featureFlag === "false") {
207
+ return false;
208
+ }
209
+ if (featureFlag === "true") {
210
+ return true;
211
+ }
212
+ return Boolean(resolveEncryptionKey());
213
+ }
214
+ function encryptField(plaintext, key) {
215
+ const iv = randomBytes(IV_LENGTH);
216
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
217
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
218
+ const tag = cipher.getAuthTag();
219
+ const payload = Buffer.concat([iv, encrypted, tag]).toString("base64");
220
+ return `${ENCRYPTION_PREFIX}${payload}`;
221
+ }
222
+ function decryptField(value, key) {
223
+ if (!value.startsWith(ENCRYPTION_PREFIX)) {
224
+ return value;
225
+ }
226
+ const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
227
+ const iv = payload.subarray(0, IV_LENGTH);
228
+ const tag = payload.subarray(payload.length - TAG_LENGTH);
229
+ const ciphertext = payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH);
230
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
231
+ decipher.setAuthTag(tag);
232
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
233
+ }
234
+ function hashLookupValue(normalizedValue, key) {
235
+ return createHmac("sha256", key).update(normalizedValue).digest("hex");
236
+ }
237
+ function normalizeEmail(email) {
238
+ return email.trim().toLowerCase();
239
+ }
240
+ function protectEmail(email) {
241
+ const normalized = normalizeEmail(email);
242
+ const key = resolveEncryptionKey();
243
+ if (!key || !isFieldEncryptionEnabled()) {
244
+ return { storedEmail: normalized, emailLookup: normalized };
245
+ }
246
+ return {
247
+ storedEmail: encryptField(normalized, key),
248
+ emailLookup: hashLookupValue(normalized, key)
249
+ };
250
+ }
251
+ function revealEmail(storedEmail) {
252
+ const key = resolveEncryptionKey();
253
+ if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
254
+ return storedEmail;
255
+ }
256
+ return decryptField(storedEmail, key);
257
+ }
258
+ function emailLookupForQuery(email) {
259
+ const normalized = normalizeEmail(email);
260
+ const key = resolveEncryptionKey();
261
+ if (!key || !isFieldEncryptionEnabled()) {
262
+ return normalized;
263
+ }
264
+ return hashLookupValue(normalized, key);
265
+ }
266
+
267
+ // ../../src/core/crypto/mfaSecret.ts
268
+ function protectMfaSecret(secret) {
269
+ const key = resolveEncryptionKey();
270
+ if (!key) {
271
+ throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
272
+ }
273
+ return encryptField(secret, key);
274
+ }
275
+ function revealMfaSecret(stored) {
276
+ if (!stored) {
277
+ return null;
278
+ }
279
+ if (!stored.startsWith("enc:v1:")) {
280
+ if (isProductionEnv() || resolveEncryptionKey()) {
281
+ throw new Error("MFA secrets must be encrypted.");
282
+ }
283
+ return stored;
284
+ }
285
+ const key = resolveEncryptionKey();
286
+ if (!key) {
287
+ throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
288
+ }
289
+ return decryptField(stored, key);
290
+ }
291
+
292
+ // ../../src/core/database/schema/driver.ts
293
+ function normalizeConnectionName(connection) {
294
+ const normalized = connection.trim().toLowerCase();
295
+ if (normalized === "pgsql" || normalized === "postgres" || normalized === "postgresql") {
296
+ return "pgsql";
297
+ }
298
+ if (normalized === "mysql" || normalized === "mariadb") {
299
+ return "mysql";
300
+ }
301
+ if (normalized === "sqlite") {
302
+ return "sqlite";
303
+ }
304
+ throw new Error(`Unsupported DB_CONNECTION: ${connection}`);
305
+ }
306
+ function resolveDriverFromUrl(url) {
307
+ const normalized = url.trim().toLowerCase();
308
+ if (normalized.startsWith("postgres://") || normalized.startsWith("postgresql://") || normalized.startsWith("postgres:")) {
309
+ return "pgsql";
310
+ }
311
+ if (normalized.startsWith("mysql://") || normalized.startsWith("mysql:")) {
312
+ return "mysql";
313
+ }
314
+ if (normalized.startsWith("sqlite:")) {
315
+ return "sqlite";
316
+ }
317
+ return null;
318
+ }
319
+ function resolveDatabaseDriver(options = {}) {
320
+ const connection = options.connection ?? process.env.DB_CONNECTION;
321
+ if (connection) {
322
+ return normalizeConnectionName(connection);
323
+ }
324
+ const url = options.url ?? process.env.DATABASE_URL ?? "";
325
+ const fromUrl = resolveDriverFromUrl(url);
326
+ if (fromUrl) {
327
+ return fromUrl;
328
+ }
329
+ return "pgsql";
330
+ }
331
+
332
+ // ../../src/core/database/dialect.ts
333
+ function assertSafeIdentifier(identifier) {
334
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
335
+ throw new Error(`Invalid SQL identifier: ${identifier}`);
336
+ }
337
+ return identifier;
338
+ }
339
+ function quoteIdentifierFor(dialect, column) {
340
+ return dialect.quoteIdentifier(column);
341
+ }
342
+ var postgresDialect = {
343
+ driver: "pgsql",
344
+ placeholder(index) {
345
+ return `$${index}`;
346
+ },
347
+ quoteIdentifier(identifier) {
348
+ return `"${assertSafeIdentifier(identifier)}"`;
349
+ },
350
+ nowExpression() {
351
+ return "NOW()";
352
+ },
353
+ timestampValue(value) {
354
+ return value.toISOString();
355
+ },
356
+ returningClause(columns) {
357
+ return ` RETURNING ${columns}`;
358
+ },
359
+ ilikeOperator() {
360
+ return "ILIKE";
361
+ },
362
+ nullsLastSuffix() {
363
+ return " NULLS LAST";
364
+ },
365
+ castToText(expression) {
366
+ return `${expression}::text`;
367
+ },
368
+ upsertSuffix(conflictColumns, updateColumns) {
369
+ const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
370
+ if (updateColumns.length === 0) {
371
+ return ` ON CONFLICT (${target}) DO NOTHING`;
372
+ }
373
+ const assignments = updateColumns.map((column) => {
374
+ const quoted = quoteIdentifierFor(this, column);
375
+ return `${quoted} = excluded.${quoted}`;
376
+ }).join(", ");
377
+ return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
378
+ }
379
+ };
380
+ var mysqlDialect = {
381
+ driver: "mysql",
382
+ placeholder() {
383
+ return "?";
384
+ },
385
+ quoteIdentifier(identifier) {
386
+ return `\`${assertSafeIdentifier(identifier)}\``;
387
+ },
388
+ nowExpression() {
389
+ return "CURRENT_TIMESTAMP";
390
+ },
391
+ timestampValue(value) {
392
+ return value.toISOString().slice(0, 19).replace("T", " ");
393
+ },
394
+ returningClause() {
395
+ return "";
396
+ },
397
+ ilikeOperator() {
398
+ return "LIKE";
399
+ },
400
+ nullsLastSuffix() {
401
+ return "";
402
+ },
403
+ castToText(expression) {
404
+ return `CAST(${expression} AS CHAR)`;
405
+ },
406
+ upsertSuffix(conflictColumns, updateColumns) {
407
+ if (updateColumns.length === 0) {
408
+ const anchor = quoteIdentifierFor(this, conflictColumns[0] ?? "");
409
+ return ` ON DUPLICATE KEY UPDATE ${anchor} = ${anchor}`;
410
+ }
411
+ const assignments = updateColumns.map((column) => {
412
+ const quoted = quoteIdentifierFor(this, column);
413
+ return `${quoted} = VALUES(${quoted})`;
414
+ }).join(", ");
415
+ return ` ON DUPLICATE KEY UPDATE ${assignments}`;
416
+ }
417
+ };
418
+ var sqliteDialect = {
419
+ driver: "sqlite",
420
+ placeholder() {
421
+ return "?";
422
+ },
423
+ quoteIdentifier(identifier) {
424
+ return `"${assertSafeIdentifier(identifier)}"`;
425
+ },
426
+ nowExpression() {
427
+ return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
428
+ },
429
+ timestampValue(value) {
430
+ return value.toISOString();
431
+ },
432
+ returningClause(columns) {
433
+ return ` RETURNING ${columns}`;
434
+ },
435
+ ilikeOperator() {
436
+ return "LIKE";
437
+ },
438
+ nullsLastSuffix() {
439
+ return "";
440
+ },
441
+ castToText(expression) {
442
+ return `CAST(${expression} AS TEXT)`;
443
+ },
444
+ upsertSuffix(conflictColumns, updateColumns) {
445
+ const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
446
+ if (updateColumns.length === 0) {
447
+ return ` ON CONFLICT (${target}) DO NOTHING`;
448
+ }
449
+ const assignments = updateColumns.map((column) => {
450
+ const quoted = quoteIdentifierFor(this, column);
451
+ return `${quoted} = excluded.${quoted}`;
452
+ }).join(", ");
453
+ return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
454
+ }
455
+ };
456
+ var dialects = {
457
+ pgsql: postgresDialect,
458
+ mysql: mysqlDialect,
459
+ sqlite: sqliteDialect
460
+ };
461
+ var dialectContext = createAsyncContextStore("@getstrata/sqlDialect");
462
+ var dialectOverride = null;
463
+ function dialectFor(driver) {
464
+ return dialects[driver];
465
+ }
466
+ function currentSqlDialect() {
467
+ return dialectContext.getStore() ?? dialectOverride ?? dialectFor(resolveDatabaseDriver());
468
+ }
469
+
470
+ // ../../src/core/security/recoveryCodes.ts
471
+ import { randomBytes as randomBytes2 } from "crypto";
472
+ import { hashApiToken } from "@getstrata/core/auth/tokenHash";
473
+ import { timingSafeCompareString } from "@getstrata/core/security/timingSafeCompare";
474
+ var DEFAULT_RECOVERY_CODE_COUNT = 8;
475
+ var RECOVERY_CODE_BYTES = 16;
476
+ function normalizeRecoveryCode(code) {
477
+ return code.replace(/[^a-z0-9]/giu, "").toLowerCase();
478
+ }
479
+ function formatRecoveryCode(normalized) {
480
+ if (normalized.length !== RECOVERY_CODE_BYTES * 2) {
481
+ return normalized;
482
+ }
483
+ return normalized.match(/.{1,4}/gu)?.join("-") ?? normalized;
484
+ }
485
+ function generateRecoveryCodes(count = DEFAULT_RECOVERY_CODE_COUNT) {
486
+ const size = Number.isInteger(count) && count > 0 ? count : DEFAULT_RECOVERY_CODE_COUNT;
487
+ const codes = new Set;
488
+ while (codes.size < size) {
489
+ codes.add(formatRecoveryCode(randomBytes2(RECOVERY_CODE_BYTES).toString("hex")));
490
+ }
491
+ return [...codes];
492
+ }
493
+ function hashRecoveryCode(code) {
494
+ return hashApiToken(normalizeRecoveryCode(code));
495
+ }
496
+ function recoveryCodeMatches(code, hash) {
497
+ return timingSafeCompareString(hashRecoveryCode(code), hash);
498
+ }
499
+
500
+ // ../../src/core/security/totp.ts
501
+ import { createHmac as createHmac2, randomBytes as randomBytes3 } from "crypto";
502
+
503
+ // ../../src/core/runtime/appKeyPrefix.ts
504
+ function appKeyPrefix() {
505
+ return process.env.APP_KEY_PREFIX?.trim() || "strata";
506
+ }
507
+ function appCookieName(kind) {
508
+ return `${appKeyPrefix()}_${kind}`;
509
+ }
510
+ function appDevSecret(kind) {
511
+ return `${appKeyPrefix()}-dev-${kind}`;
512
+ }
513
+ function requireConfiguredSecret(names, devKind, env = process.env) {
514
+ for (const name of names) {
515
+ const value = env[name]?.trim();
516
+ if (value) {
517
+ return value;
518
+ }
519
+ }
520
+ if (isProductionEnv(env)) {
521
+ throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
522
+ }
523
+ return appDevSecret(devKind);
524
+ }
525
+ function namespacedRedisKey(kind) {
526
+ return `${appKeyPrefix()}:${kind}`;
527
+ }
528
+ function smtpEhloHost() {
529
+ const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
530
+ const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
531
+ return safe || "strata.local";
532
+ }
533
+ function siemEventType() {
534
+ return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
535
+ }
536
+ function appUserAgent() {
537
+ return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
538
+ }
539
+ function otelServiceName() {
540
+ return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
541
+ }
542
+ function appDisplayName() {
543
+ return process.env.APP_NAME?.trim() || "Strata";
544
+ }
545
+ function appEnv() {
546
+ return process.env.APP_ENV?.trim() || "local";
547
+ }
548
+ function appUrl() {
549
+ return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
550
+ }
551
+ function apiPrefix() {
552
+ const raw = process.env.API_PREFIX?.trim() || "/api/v1";
553
+ const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
554
+ const trimmed = withSlash.replace(/\/+$/, "");
555
+ return trimmed || "/api/v1";
556
+ }
557
+ function sdkClientClassName() {
558
+ const override = process.env.APP_SDK_CLASS?.trim();
559
+ if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
560
+ return override;
561
+ }
562
+ const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
563
+ return fromName ? `${fromName}Client` : "AppClient";
564
+ }
565
+
566
+ // ../../src/core/security/timingSafeCompare.ts
567
+ import { timingSafeEqual } from "crypto";
568
+ function timingSafeCompareString2(left, right) {
569
+ const leftBuffer = Buffer.from(left);
570
+ const rightBuffer = Buffer.from(right);
571
+ if (leftBuffer.length !== rightBuffer.length) {
572
+ return false;
573
+ }
574
+ return timingSafeEqual(leftBuffer, rightBuffer);
575
+ }
576
+
577
+ // ../../src/core/security/totp.ts
578
+ function decodeBase32(input) {
579
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
580
+ const normalized = input.replace(/=+$/u, "").toUpperCase();
581
+ let bits = "";
582
+ for (const char of normalized) {
583
+ const value = alphabet.indexOf(char);
584
+ if (value === -1) {
585
+ throw new Error("Invalid base32 character in MFA secret.");
586
+ }
587
+ bits += value.toString(2).padStart(5, "0");
588
+ }
589
+ const bytes = [];
590
+ for (let index = 0;index + 8 <= bits.length; index += 8) {
591
+ bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
592
+ }
593
+ return Buffer.from(bytes);
594
+ }
595
+ function encodeBase32(bytes) {
596
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
597
+ let bits = "";
598
+ for (const byte of bytes) {
599
+ bits += byte.toString(2).padStart(8, "0");
600
+ }
601
+ let output = "";
602
+ for (let index = 0;index < bits.length; index += 5) {
603
+ const chunk = bits.slice(index, index + 5).padEnd(5, "0");
604
+ output += alphabet[Number.parseInt(chunk, 2)] ?? "";
605
+ }
606
+ return output;
607
+ }
608
+ function generateTotpSecret(byteLength = 20) {
609
+ return encodeBase32(randomBytes3(byteLength));
610
+ }
611
+ function buildOtpauthUrl(options) {
612
+ const issuer = options.issuer?.trim() || appDisplayName();
613
+ const label = `${issuer}:${options.account}`;
614
+ const params = new URLSearchParams({
615
+ secret: options.secret,
616
+ issuer,
617
+ algorithm: "SHA1",
618
+ digits: "6",
619
+ period: "30"
620
+ });
621
+ return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
622
+ }
623
+ function generateTotp(secret, counter = Math.floor(Date.now() / 30000), digits = 6) {
624
+ const key = decodeBase32(secret);
625
+ const buffer = Buffer.alloc(8);
626
+ buffer.writeBigUInt64BE(BigInt(counter));
627
+ const digest = createHmac2("sha1", key).update(buffer).digest();
628
+ const lastByte = digest[digest.length - 1] ?? 0;
629
+ const offset = lastByte & 15;
630
+ const b0 = digest[offset] ?? 0;
631
+ const b1 = digest[offset + 1] ?? 0;
632
+ const b2 = digest[offset + 2] ?? 0;
633
+ const b3 = digest[offset + 3] ?? 0;
634
+ const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
635
+ return String(code % 10 ** digits).padStart(digits, "0");
636
+ }
637
+ function verifyTotp(secret, token, window = 1) {
638
+ const normalized = token.trim();
639
+ if (!/^\d{6}$/u.test(normalized)) {
640
+ return false;
641
+ }
642
+ const timestep = Math.floor(Date.now() / 30000);
643
+ let matched = false;
644
+ for (let offset = -window;offset <= window; offset += 1) {
645
+ if (timingSafeCompareString2(generateTotp(secret, timestep + offset), normalized)) {
646
+ matched = true;
647
+ }
648
+ }
649
+ return matched;
650
+ }
651
+
652
+ // ../../src/core/auth/passwordLogin.ts
653
+ function parseRecoveryHashes(raw) {
654
+ if (!raw?.trim()) {
655
+ return [];
656
+ }
657
+ try {
658
+ const parsed = JSON.parse(raw);
659
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
660
+ } catch {
661
+ return [];
662
+ }
663
+ }
664
+ function completePasswordLogin(user, options = {}) {
665
+ if (!user.mfa_enabled) {
666
+ return { ok: true };
667
+ }
668
+ const submitted = options.mfaCode?.trim() ?? "";
669
+ if (!submitted) {
670
+ return { ok: false, error: "mfa_required" };
671
+ }
672
+ const secret = revealMfaSecret(user.mfa_secret);
673
+ if (secret && verifyTotp(secret, submitted)) {
674
+ return { ok: true };
675
+ }
676
+ const hashes = parseRecoveryHashes(user.mfa_recovery_codes);
677
+ const matched = hashes.find((hash) => recoveryCodeMatches(submitted, hash));
678
+ if (matched) {
679
+ return { ok: true, consumedRecoveryHash: matched };
680
+ }
681
+ return { ok: false, error: "mfa_invalid" };
682
+ }
683
+ async function persistConsumedRecoveryHash(sql, userId, currentRaw, consumedHash) {
684
+ if (!consumedHash) {
685
+ return;
686
+ }
687
+ const dialect = currentSqlDialect();
688
+ await sql.unsafe(`UPDATE users SET mfa_recovery_codes = ${dialect.placeholder(1)} WHERE id = ${dialect.placeholder(2)}`, [
689
+ JSON.stringify(parseRecoveryHashes(currentRaw).filter((hash) => hash !== consumedHash)),
690
+ userId
691
+ ]);
692
+ }
693
+
100
694
  // ../../src/core/auth/basicAuthGuard.ts
101
695
  class BasicAuthGuard {
102
696
  container;
@@ -112,24 +706,51 @@ class BasicAuthGuard {
112
706
  if (!directory) {
113
707
  return null;
114
708
  }
115
- if (typeof directory.verifyCredentials === "function") {
116
- return await directory.verifyCredentials(credentials.username, credentials.password);
709
+ const record = typeof directory.findByEmail === "function" ? await directory.findByEmail(credentials.username) : null;
710
+ if (record?.password) {
711
+ if (!await verifyPassword(credentials.password, record.password)) {
712
+ return null;
713
+ }
714
+ const mfa = completePasswordLogin(record, {
715
+ mfaCode: request.headers.get("x-mfa-code")
716
+ });
717
+ if (!mfa.ok) {
718
+ return null;
719
+ }
720
+ if (mfa.consumedRecoveryHash) {
721
+ await persistConsumedRecoveryHash(getBoundDatabaseConnection() ?? getDefaultDatabasePool(), record.id, record.mfa_recovery_codes, mfa.consumedRecoveryHash);
722
+ }
723
+ return {
724
+ id: record.id,
725
+ role: record.role,
726
+ emailVerifiedAt: record.email_verified_at ?? null
727
+ };
117
728
  }
118
- if (typeof directory.findByEmail !== "function") {
119
- return null;
120
- }
121
- const record = await directory.findByEmail(credentials.username);
122
- if (!record?.password) {
123
- return null;
124
- }
125
- if (!await verifyPassword(credentials.password, record.password)) {
126
- return null;
729
+ if (typeof directory.verifyCredentials === "function") {
730
+ const user = await directory.verifyCredentials(credentials.username, credentials.password);
731
+ if (!user) {
732
+ return null;
733
+ }
734
+ let record = typeof directory.findByEmail === "function" ? await directory.findByEmail(credentials.username) : null;
735
+ if (!record) {
736
+ try {
737
+ record = await directory.findByIdOrThrow(Number(user.id));
738
+ } catch {
739
+ return null;
740
+ }
741
+ }
742
+ const mfa = completePasswordLogin(record, {
743
+ mfaCode: request.headers.get("x-mfa-code")
744
+ });
745
+ if (!mfa.ok) {
746
+ return null;
747
+ }
748
+ if (mfa.consumedRecoveryHash) {
749
+ await persistConsumedRecoveryHash(getBoundDatabaseConnection() ?? getDefaultDatabasePool(), record.id, record.mfa_recovery_codes, mfa.consumedRecoveryHash);
750
+ }
751
+ return user;
127
752
  }
128
- return {
129
- id: record.id,
130
- role: record.role,
131
- emailVerifiedAt: record.email_verified_at ?? null
132
- };
753
+ return null;
133
754
  }
134
755
  }
135
756
  export {
@@ -4,7 +4,7 @@ function isEmailVerificationRequired() {
4
4
  return (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true";
5
5
  }
6
6
  function hasVerifiedEmail(user) {
7
- return user.emailVerifiedAt !== null;
7
+ return user.emailVerifiedAt != null;
8
8
  }
9
9
  export {
10
10
  hasVerifiedEmail,