@syncello/auth 3.6.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  enums,
4
4
  tableNames,
5
5
  tables
6
- } from "./chunk-OZ26T3ZA.js";
6
+ } from "./chunk-W35EDOHW.js";
7
7
 
8
8
  // src/core/password.ts
9
9
  import bcrypt from "bcryptjs";
@@ -255,6 +255,8 @@ var AUTH_DEFAULTS = {
255
255
  // Absolute cap: a session dies this long after creation regardless of
256
256
  // sliding-window activity (limits the value of a stolen session token).
257
257
  SESSION_MAX_LIFETIME_DAYS: 30,
258
+ // The sliding window moves at most this often, so a busy session is not a write per request.
259
+ SESSION_REFRESH_INTERVAL_MINUTES: 15,
258
260
  LOCKOUT_DURATION_MINUTES: 30,
259
261
  LOCKOUT_MAX_ATTEMPTS: 5,
260
262
  PASSWORD_RESET_TTL_MINUTES: 15,
@@ -335,7 +337,7 @@ async function validateSession(db, tables2, sessionId, currentFingerprint, sessi
335
337
  note: "SSR requests have different User-Agent, causing expected mismatch"
336
338
  });
337
339
  }
338
- await refreshSession(db, { sessions }, sessionId);
340
+ if (sessionRefreshDue(row.sessionLastActiveAt)) await refreshSession(db, { sessions }, sessionId);
339
341
  return {
340
342
  user: {
341
343
  id: row.userId,
@@ -354,6 +356,10 @@ async function validateSession(db, tables2, sessionId, currentFingerprint, sessi
354
356
  }
355
357
  };
356
358
  }
359
+ function sessionRefreshDue(lastActiveAt, now = Date.now()) {
360
+ const interval = AUTH_DEFAULTS.SESSION_REFRESH_INTERVAL_MINUTES * 60 * 1e3;
361
+ return lastActiveAt === null || now - lastActiveAt >= interval;
362
+ }
357
363
  async function refreshSession(db, tables2, sessionId, sessionTtlMs = AUTH_DEFAULTS.SESSION_TTL_DAYS * 24 * 60 * 60 * 1e3) {
358
364
  const { sessions } = tables2;
359
365
  const newExpiration = Date.now() + sessionTtlMs;
@@ -521,16 +527,16 @@ function createDeviceToken() {
521
527
  };
522
528
  }
523
529
  async function validateTrustedDevice(db, userTrustedDevicesTable, userId, tokenHash) {
524
- const { eq: eq33, and: and14, gt: gt6 } = await import("drizzle-orm");
530
+ const { eq: eq34, and: and15, gt: gt7 } = await import("drizzle-orm");
525
531
  const [device] = await db.select().from(userTrustedDevicesTable).where(
526
- and14(
527
- eq33(userTrustedDevicesTable.userId, userId),
528
- eq33(userTrustedDevicesTable.tokenHash, tokenHash),
529
- gt6(userTrustedDevicesTable.expiresAt, Date.now())
532
+ and15(
533
+ eq34(userTrustedDevicesTable.userId, userId),
534
+ eq34(userTrustedDevicesTable.tokenHash, tokenHash),
535
+ gt7(userTrustedDevicesTable.expiresAt, Date.now())
530
536
  )
531
537
  ).limit(1);
532
538
  if (device) {
533
- await db.update(userTrustedDevicesTable).set({ lastUsedAt: Date.now() }).where(eq33(userTrustedDevicesTable.id, device.id));
539
+ await db.update(userTrustedDevicesTable).set({ lastUsedAt: Date.now() }).where(eq34(userTrustedDevicesTable.id, device.id));
534
540
  return true;
535
541
  }
536
542
  return false;
@@ -943,582 +949,48 @@ function base64UrlEncode(data) {
943
949
  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
944
950
  }
945
951
 
946
- // src/middleware/auth.ts
947
- import { createMiddleware } from "hono/factory";
948
- import { eq as eq4, and as and2, gt as gt2 } from "drizzle-orm";
952
+ // src/core/passwordless.ts
953
+ import { and as and2, desc, eq as eq4, gt as gt2, isNull, lt, sql as sql2 } from "drizzle-orm";
949
954
 
950
- // src/factory.ts
951
- function createAuth(config) {
952
- const { db, schema, getEnv, emailBranding, invitations } = config;
953
- const getContext = (c) => ({
954
- db,
955
- schema,
956
- env: getEnv(c),
957
- emailBranding,
958
- invitations
959
- });
960
- return {
961
- db,
962
- schema,
963
- getContext
964
- };
965
- }
966
- function createAuthMiddleware(auth2) {
967
- return async (c, next) => {
968
- const ctx = auth2.getContext(c);
969
- c.set("authContext", ctx);
970
- c.set("db", auth2.db);
971
- await next();
972
- };
973
- }
974
- function getAuthContext(c) {
975
- const ctx = c.get("authContext");
976
- if (!ctx) {
977
- throw new Error(
978
- "AuthContext not found. Did you apply createAuthMiddleware before the auth routes?"
979
- );
955
+ // src/lib/email/security.ts
956
+ function getAllowedDomains(appUrl) {
957
+ const base = ["localhost", "127.0.0.1"];
958
+ try {
959
+ const url = new URL(appUrl);
960
+ return [...base, url.hostname];
961
+ } catch {
962
+ return base;
980
963
  }
981
- return ctx;
982
- }
983
-
984
- // src/lib/problem-json.ts
985
- var ProblemTypes = {
986
- // 4xx Client Errors
987
- BAD_REQUEST: "/errors/bad-request",
988
- UNAUTHORIZED: "/errors/unauthorized",
989
- FORBIDDEN: "/errors/forbidden",
990
- NOT_FOUND: "/errors/not-found",
991
- CONFLICT: "/errors/conflict",
992
- GONE: "/errors/gone",
993
- VALIDATION_ERROR: "/errors/validation-error",
994
- RATE_LIMIT_EXCEEDED: "/errors/rate-limit-exceeded",
995
- // 5xx Server Errors
996
- INTERNAL_ERROR: "/errors/internal-error",
997
- SERVICE_UNAVAILABLE: "/errors/service-unavailable"
998
- };
999
- function generateTraceId() {
1000
- const bytes = crypto.getRandomValues(new Uint8Array(16));
1001
- return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
1002
964
  }
1003
- function createProblemDetails(type, title, status, options) {
1004
- const problem = {
1005
- type,
1006
- title,
1007
- status
1008
- };
1009
- if (options?.detail) {
1010
- problem.detail = options.detail;
1011
- }
1012
- if (options?.instance) {
1013
- problem.instance = options.instance;
965
+ function sanitizeEmailUrl(url, appUrl = "") {
966
+ let parsed;
967
+ try {
968
+ parsed = new URL(url);
969
+ } catch {
970
+ throw new Error("Invalid URL format");
1014
971
  }
1015
- problem.traceId = options?.traceId || generateTraceId();
1016
- if (options?.requestId) {
1017
- problem.requestId = options.requestId;
972
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
973
+ throw new Error(`Disallowed protocol: ${parsed.protocol}`);
1018
974
  }
1019
- if (options?.extensions) {
1020
- Object.assign(problem, options.extensions);
975
+ const allowedDomains = getAllowedDomains(appUrl);
976
+ const hostname = parsed.hostname.toLowerCase();
977
+ const isAllowed = allowedDomains.some((domain) => {
978
+ return hostname === domain || hostname.endsWith(`.${domain}`);
979
+ });
980
+ if (!isAllowed) {
981
+ throw new Error(`Disallowed domain: ${hostname}`);
1021
982
  }
1022
- return problem;
983
+ return parsed.toString();
1023
984
  }
1024
- function problemJson(c, problem) {
1025
- logger_default.warn("API error response", {
1026
- type: problem.type,
1027
- status: problem.status,
1028
- traceId: problem.traceId,
1029
- requestId: problem.requestId,
1030
- path: c.req.path,
1031
- method: c.req.method
1032
- });
1033
- return c.json(problem, problem.status, {
1034
- "Content-Type": "application/problem+json"
1035
- });
1036
- }
1037
- var problems = {
1038
- /**
1039
- * 400 Bad Request - Generic client error
1040
- */
1041
- badRequest(c, detail) {
1042
- return problemJson(
1043
- c,
1044
- createProblemDetails(ProblemTypes.BAD_REQUEST, "Bad Request", 400, {
1045
- detail: detail || "The request could not be understood by the server",
1046
- instance: `${c.req.method} ${c.req.path}`,
1047
- requestId: c.get("requestId"),
1048
- traceId: c.get("traceId")
1049
- })
1050
- );
1051
- },
1052
- /**
1053
- * 400 Bad Request - Validation errors with field-specific details
1054
- */
1055
- validationError(c, detail, errors) {
1056
- return problemJson(
1057
- c,
1058
- createProblemDetails(ProblemTypes.VALIDATION_ERROR, "Validation Error", 400, {
1059
- detail,
1060
- instance: `${c.req.method} ${c.req.path}`,
1061
- requestId: c.get("requestId"),
1062
- traceId: c.get("traceId"),
1063
- extensions: errors ? { errors } : void 0
1064
- })
1065
- );
1066
- },
1067
- /**
1068
- * 401 Unauthorized - Authentication required
1069
- */
1070
- unauthorized(c, detail) {
1071
- return problemJson(
1072
- c,
1073
- createProblemDetails(ProblemTypes.UNAUTHORIZED, "Unauthorized", 401, {
1074
- detail: detail || "Authentication is required to access this resource",
1075
- instance: `${c.req.method} ${c.req.path}`,
1076
- requestId: c.get("requestId"),
1077
- traceId: c.get("traceId")
1078
- })
1079
- );
1080
- },
1081
- /**
1082
- * 403 Forbidden - Insufficient permissions
1083
- */
1084
- forbidden(c, detail) {
1085
- return problemJson(
1086
- c,
1087
- createProblemDetails(ProblemTypes.FORBIDDEN, "Forbidden", 403, {
1088
- detail: detail || "You do not have permission to access this resource",
1089
- instance: `${c.req.method} ${c.req.path}`,
1090
- requestId: c.get("requestId"),
1091
- traceId: c.get("traceId")
1092
- })
1093
- );
1094
- },
1095
- /**
1096
- * 404 Not Found
1097
- */
1098
- notFound(c, detail) {
1099
- return problemJson(
1100
- c,
1101
- createProblemDetails(ProblemTypes.NOT_FOUND, "Not Found", 404, {
1102
- detail: detail || "The requested resource was not found",
1103
- instance: `${c.req.method} ${c.req.path}`,
1104
- requestId: c.get("requestId"),
1105
- traceId: c.get("traceId")
1106
- })
1107
- );
1108
- },
1109
- /**
1110
- * 409 Conflict - Resource conflict (e.g., duplicate email)
1111
- */
1112
- conflict(c, detail) {
1113
- return problemJson(
1114
- c,
1115
- createProblemDetails(ProblemTypes.CONFLICT, "Conflict", 409, {
1116
- detail,
1117
- instance: `${c.req.method} ${c.req.path}`,
1118
- requestId: c.get("requestId"),
1119
- traceId: c.get("traceId")
1120
- })
1121
- );
1122
- },
1123
- /**
1124
- * 410 Gone - Resource is no longer available
1125
- */
1126
- gone(c, detail) {
1127
- return problemJson(
1128
- c,
1129
- createProblemDetails(ProblemTypes.GONE, "Gone", 410, {
1130
- detail: detail || "The requested resource is no longer available",
1131
- instance: `${c.req.method} ${c.req.path}`,
1132
- requestId: c.get("requestId"),
1133
- traceId: c.get("traceId")
1134
- })
1135
- );
1136
- },
1137
- /**
1138
- * 429 Too Many Requests - Rate limit exceeded
1139
- */
1140
- rateLimitExceeded(c, retryAfter) {
1141
- const extensions = retryAfter ? { retryAfter } : void 0;
1142
- return problemJson(
1143
- c,
1144
- createProblemDetails(ProblemTypes.RATE_LIMIT_EXCEEDED, "Rate Limit Exceeded", 429, {
1145
- detail: retryAfter ? `Too many requests. Please retry after ${retryAfter} seconds` : "Too many requests. Please slow down",
1146
- instance: `${c.req.method} ${c.req.path}`,
1147
- requestId: c.get("requestId"),
1148
- traceId: c.get("traceId"),
1149
- extensions
1150
- })
1151
- );
1152
- },
1153
- /**
1154
- * 500 Internal Server Error - Generic server error
1155
- * SECURITY: Never include error details or stack traces
1156
- */
1157
- internalError(c, error) {
1158
- const traceId = c.get("traceId") || generateTraceId();
1159
- if (error) {
1160
- logError(error, {
1161
- context: "internal_error",
1162
- path: c.req.path,
1163
- method: c.req.method,
1164
- traceId,
1165
- requestId: c.get("requestId")
1166
- });
1167
- }
1168
- return problemJson(
1169
- c,
1170
- createProblemDetails(ProblemTypes.INTERNAL_ERROR, "Internal Server Error", 500, {
1171
- detail: "An unexpected error occurred. Please try again later",
1172
- instance: `${c.req.method} ${c.req.path}`,
1173
- traceId,
1174
- requestId: c.get("requestId")
1175
- })
1176
- );
1177
- },
1178
- /**
1179
- * 503 Service Unavailable - Service is temporarily unavailable
1180
- */
1181
- serviceUnavailable(c, detail) {
1182
- return problemJson(
1183
- c,
1184
- createProblemDetails(ProblemTypes.SERVICE_UNAVAILABLE, "Service Unavailable", 503, {
1185
- detail: detail || "The service is temporarily unavailable. Please try again later",
1186
- instance: `${c.req.method} ${c.req.path}`,
1187
- requestId: c.get("requestId"),
1188
- traceId: c.get("traceId")
1189
- })
1190
- );
1191
- }
1192
- };
1193
-
1194
- // src/middleware/auth.ts
1195
- var SESSION_MAX_LIFETIME_MS = AUTH_DEFAULTS.SESSION_MAX_LIFETIME_DAYS * 24 * 60 * 60 * 1e3;
1196
- async function revokeIfPastMaxLifetime(db, sessions, sessionId, session) {
1197
- if (Date.now() - session.createdAt <= SESSION_MAX_LIFETIME_MS) return false;
1198
- logger_default.info("Session exceeded absolute lifetime - revoked", {
1199
- type: "security",
1200
- event: "session_max_lifetime_exceeded",
1201
- severity: "low",
1202
- sessionId: sessionId.slice(0, 8),
1203
- userId: session.userId
1204
- });
1205
- await deleteSession(db, { sessions }, sessionId);
1206
- return true;
1207
- }
1208
- async function getSessionFromCookie(db, sessions, cookieHeader, request, env) {
1209
- const cookieName = getSessionCookieName(env);
1210
- const cookiePattern = new RegExp(`${cookieName}=([^;]+)`);
1211
- const sessionIdMatch = cookieHeader.match(cookiePattern);
1212
- if (!sessionIdMatch) return null;
1213
- const sessionId = sessionIdMatch[1];
1214
- const foundSessions = await db.select({
1215
- userId: sessions.userId,
1216
- expiresAt: sessions.expiresAt,
1217
- createdAt: sessions.createdAt,
1218
- fingerprint: sessions.fingerprint
1219
- }).from(sessions).where(and2(eq4(sessions.id, sessionId), gt2(sessions.expiresAt, Date.now()))).limit(1);
1220
- if (foundSessions.length === 0) return null;
1221
- const session = foundSessions[0];
1222
- if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
1223
- await refreshSession(db, { sessions }, sessionId);
1224
- const userAgent = request.headers.get("user-agent") || "";
1225
- const cfWorker = request.headers.get("cf-worker") || "";
1226
- const clientIp = request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
1227
- const isLocalSSR = userAgent.toLowerCase().includes("node");
1228
- const isSvelteKitSSR = userAgent.toLowerCase().includes("sveltekit");
1229
- const isCloudflareSSR = !userAgent && cfWorker.includes(".pages.dev");
1230
- const isSSR = isLocalSSR || isSvelteKitSSR || isCloudflareSSR;
1231
- if (isSSR) {
1232
- logger_default.info("SSR request detected, using IP validation", {
1233
- type: "security",
1234
- event: "ssr_session_validation",
1235
- sessionId: sessionId.slice(0, 8),
1236
- userId: session.userId,
1237
- clientIp,
1238
- cfWorker,
1239
- isLocalSSR,
1240
- isSvelteKitSSR,
1241
- isCloudflareSSR
1242
- });
1243
- } else {
1244
- if (session.fingerprint) {
1245
- const currentFingerprint = await generateFingerprint(request);
1246
- if (session.fingerprint !== currentFingerprint) {
1247
- logger_default.info("Session fingerprint changed (not enforced)", {
1248
- type: "security",
1249
- event: "fingerprint_changed",
1250
- sessionId: sessionId.slice(0, 8),
1251
- userId: session.userId,
1252
- clientIp
1253
- });
1254
- }
1255
- }
1256
- }
1257
- return {
1258
- userId: session.userId,
1259
- expiresAt: session.expiresAt
1260
- };
1261
- }
1262
- async function getSessionFromBearerToken(db, sessions, authHeader) {
1263
- if (!authHeader.startsWith("Bearer ")) return null;
1264
- const sessionId = authHeader.slice(7);
1265
- const foundSessions = await db.select({
1266
- userId: sessions.userId,
1267
- expiresAt: sessions.expiresAt,
1268
- createdAt: sessions.createdAt
1269
- }).from(sessions).where(and2(eq4(sessions.id, sessionId), gt2(sessions.expiresAt, Date.now()))).limit(1);
1270
- if (foundSessions.length === 0) return null;
1271
- const session = foundSessions[0];
1272
- if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
1273
- await refreshSession(db, { sessions }, sessionId);
1274
- return {
1275
- userId: session.userId,
1276
- expiresAt: session.expiresAt
1277
- };
1278
- }
1279
- var requireAuth = createMiddleware(
1280
- async (c, next) => {
1281
- const { db, schema } = getAuthContext(c);
1282
- const authHeader = c.req.header("Authorization");
1283
- if (authHeader) {
1284
- const session2 = await getSessionFromBearerToken(db, schema.sessions, authHeader);
1285
- if (session2) {
1286
- c.set("userId", session2.userId);
1287
- await next();
1288
- return;
1289
- }
1290
- }
1291
- const cookieHeader = c.req.header("cookie");
1292
- if (!cookieHeader) {
1293
- return problems.unauthorized(c, "Please log in to continue");
1294
- }
1295
- const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
1296
- if (!session) {
1297
- c.header("Set-Cookie", clearSessionCookie(c.env));
1298
- return problems.unauthorized(c, "Your session has expired. Please log in again.");
1299
- }
1300
- c.set("userId", session.userId);
1301
- await next();
1302
- }
1303
- );
1304
- var optionalAuth = createMiddleware(
1305
- async (c, next) => {
1306
- const { db, schema } = getAuthContext(c);
1307
- const authHeader = c.req.header("Authorization");
1308
- if (authHeader) {
1309
- const session = await getSessionFromBearerToken(db, schema.sessions, authHeader);
1310
- if (session) {
1311
- c.set("userId", session.userId);
1312
- await next();
1313
- return;
1314
- }
1315
- }
1316
- const cookieHeader = c.req.header("cookie");
1317
- if (cookieHeader) {
1318
- const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
1319
- if (session) {
1320
- c.set("userId", session.userId);
1321
- }
1322
- }
1323
- await next();
1324
- }
1325
- );
1326
-
1327
- // src/middleware/csrf.ts
1328
- import { createMiddleware as createMiddleware2 } from "hono/factory";
1329
- var csrf = createMiddleware2(async (c, next) => {
1330
- const method = c.req.method;
1331
- const path = c.req.path;
1332
- const isWebhook = path.startsWith("/v1/webhooks/");
1333
- if (isWebhook) {
1334
- await next();
1335
- return;
1336
- }
1337
- const isMcpEndpoint = path === "/v1/mcp";
1338
- if (isMcpEndpoint) {
1339
- await next();
1340
- return;
1341
- }
1342
- const isOAuthMcpEndpoint = path.startsWith("/v1/oauth/");
1343
- if (isOAuthMcpEndpoint) {
1344
- await next();
1345
- return;
1346
- }
1347
- const isToolsApi = path.startsWith("/v1/tools/") && !path.startsWith("/v1/tools/catalog");
1348
- if (isToolsApi) {
1349
- await next();
1350
- return;
1351
- }
1352
- const isAdminApi = path.startsWith("/v1/admin/");
1353
- if (isAdminApi) {
1354
- await next();
1355
- return;
1356
- }
1357
- const isMobileAuthEndpoint = path === "/v1/auth/login" || path === "/v1/auth/signup" || path === "/v1/auth/refresh";
1358
- const authHeader = c.req.header("Authorization");
1359
- const hasBearerToken = authHeader?.startsWith("Bearer ");
1360
- const hasNoOrigin = !c.req.header("origin") && !c.req.header("referer");
1361
- if (isMobileAuthEndpoint && hasNoOrigin || hasBearerToken) {
1362
- await next();
1363
- return;
1364
- }
1365
- const isInternalSync = path.startsWith("/v1/internal/sync/");
1366
- if (isInternalSync) {
1367
- await next();
1368
- return;
1369
- }
1370
- if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
1371
- const appUrl = c.env.APP_URL || "http://localhost:5173";
1372
- const allowedOrigins = getAllowedOrigins(appUrl);
1373
- const request = c.req.raw;
1374
- const isTestEndpoint = c.req.path.startsWith("/test");
1375
- if (isTestEndpoint) {
1376
- const origin = request.headers.get("origin");
1377
- const referer = request.headers.get("referer");
1378
- logger_default.debug("CSRF validation for test endpoint", {
1379
- path: c.req.path,
1380
- method,
1381
- origin,
1382
- referer,
1383
- appUrl,
1384
- allowedOrigins
1385
- });
1386
- }
1387
- const pagesPattern = c.env.CORS_PAGES_PATTERN;
1388
- if (!validateOrigin(request, allowedOrigins, pagesPattern)) {
1389
- return problems.forbidden(c, "Request blocked for security reasons. Please try again.");
1390
- }
1391
- }
1392
- await next();
1393
- });
1394
-
1395
- // src/middleware/rateLimit.ts
1396
- import { createMiddleware as createMiddleware3 } from "hono/factory";
1397
-
1398
- // src/lib/rate-limit-kv.ts
1399
- async function checkRateLimitKV(kv, key, maxAttempts, windowSeconds) {
1400
- try {
1401
- const rateLimitKey = `rate_limit:${key}`;
1402
- const currentAttemptsStr = await kv.get(rateLimitKey);
1403
- const currentAttempts = currentAttemptsStr ? parseInt(currentAttemptsStr, 10) : 0;
1404
- if (currentAttempts >= maxAttempts) {
1405
- return {
1406
- allowed: false,
1407
- remaining: 0
1408
- };
1409
- }
1410
- const newAttempts = currentAttempts + 1;
1411
- await kv.put(rateLimitKey, newAttempts.toString(), {
1412
- expirationTtl: windowSeconds
1413
- });
1414
- return {
1415
- allowed: true,
1416
- remaining: maxAttempts - newAttempts
1417
- };
1418
- } catch (error) {
1419
- logger_default.error("Rate limit KV error, failing open", {
1420
- key,
1421
- error: error instanceof Error ? error.message : "Unknown error"
1422
- });
1423
- return {
1424
- allowed: true,
1425
- remaining: maxAttempts
1426
- };
1427
- }
1428
- }
1429
-
1430
- // src/middleware/rateLimit.ts
1431
- var rateLimit = (config) => createMiddleware3(async (c, next) => {
1432
- const kv = c.env.RATE_LIMIT_KV;
1433
- const identifier = await config.identifier(c);
1434
- const key = `${config.action}:${identifier}`;
1435
- const windowSeconds = Math.ceil(config.windowMs / 1e3);
1436
- const { allowed, remaining } = await checkRateLimitKV(
1437
- kv,
1438
- key,
1439
- config.maxAttempts,
1440
- windowSeconds
1441
- );
1442
- const resetTimestamp = Math.ceil(Date.now() / 1e3) + windowSeconds;
1443
- c.header("X-RateLimit-Limit", config.maxAttempts.toString());
1444
- c.header("X-RateLimit-Remaining", Math.max(0, remaining).toString());
1445
- c.header("X-RateLimit-Reset", resetTimestamp.toString());
1446
- if (!allowed) {
1447
- c.header("Retry-After", windowSeconds.toString());
1448
- return problems.rateLimitExceeded(c, windowSeconds);
1449
- }
1450
- await next();
1451
- });
1452
-
1453
- // src/middleware/require-verified-email.ts
1454
- import { createMiddleware as createMiddleware4 } from "hono/factory";
1455
- import { eq as eq5 } from "drizzle-orm";
1456
- var requireVerifiedEmail = createMiddleware4(
1457
- async (c, next) => {
1458
- const userId = c.get("userId");
1459
- if (!userId) {
1460
- return problems.unauthorized(c, "Not authenticated");
1461
- }
1462
- const { db, schema } = getAuthContext(c);
1463
- const database = db;
1464
- const users = schema.users;
1465
- const [user] = await database.select({ emailVerified: users.emailVerified }).from(users).where(eq5(users.id, userId)).limit(1);
1466
- if (!user || !user.emailVerified) {
1467
- return problems.forbidden(c, "Please verify your email address to continue");
1468
- }
1469
- await next();
1470
- }
1471
- );
1472
-
1473
- // src/routes/index.ts
1474
- import { OpenAPIHono as OpenAPIHono3 } from "@hono/zod-openapi";
1475
-
1476
- // src/routes/signup.ts
1477
- import { createRoute } from "@hono/zod-openapi";
1478
- import { eq as eq8 } from "drizzle-orm";
1479
-
1480
- // src/lib/email/email-service.ts
1481
- import { eq as eq6 } from "drizzle-orm";
1482
-
1483
- // src/lib/email/security.ts
1484
- function getAllowedDomains(appUrl) {
1485
- const base = ["localhost", "127.0.0.1"];
1486
- try {
1487
- const url = new URL(appUrl);
1488
- return [...base, url.hostname];
1489
- } catch {
1490
- return base;
1491
- }
1492
- }
1493
- function sanitizeEmailUrl(url, appUrl = "") {
1494
- let parsed;
1495
- try {
1496
- parsed = new URL(url);
1497
- } catch {
1498
- throw new Error("Invalid URL format");
1499
- }
1500
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1501
- throw new Error(`Disallowed protocol: ${parsed.protocol}`);
1502
- }
1503
- const allowedDomains = getAllowedDomains(appUrl);
1504
- const hostname = parsed.hostname.toLowerCase();
1505
- const isAllowed = allowedDomains.some((domain) => {
1506
- return hostname === domain || hostname.endsWith(`.${domain}`);
1507
- });
1508
- if (!isAllowed) {
1509
- throw new Error(`Disallowed domain: ${hostname}`);
1510
- }
1511
- return parsed.toString();
1512
- }
1513
- function escapeHtml(text) {
1514
- const htmlEscapeMap = {
1515
- "&": "&amp;",
1516
- "<": "&lt;",
1517
- ">": "&gt;",
1518
- '"': "&quot;",
1519
- "'": "&#39;"
1520
- };
1521
- return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char]);
985
+ function escapeHtml(text) {
986
+ const htmlEscapeMap = {
987
+ "&": "&amp;",
988
+ "<": "&lt;",
989
+ ">": "&gt;",
990
+ '"': "&quot;",
991
+ "'": "&#39;"
992
+ };
993
+ return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char]);
1522
994
  }
1523
995
 
1524
996
  // src/core/email-template.ts
@@ -1540,8 +1012,10 @@ function generateEmailTemplate(options) {
1540
1012
  buttonFontWeight = 500,
1541
1013
  buttonRadius = "6px",
1542
1014
  logoUrl,
1543
- footerColor = "#1e1b4b"
1015
+ footerColor = "#1e1b4b",
1016
+ headingColor = "#1e1b4b"
1544
1017
  } = options;
1018
+ const safeAppName = escapeHtml(appName);
1545
1019
  const safeButtonUrl = buttonUrl ? sanitizeEmailUrl(buttonUrl, appUrl) : "";
1546
1020
  const safeAppUrl = appUrl ? sanitizeEmailUrl(appUrl, appUrl) : "";
1547
1021
  const safeMarketingUrl = marketingUrl ? sanitizeEmailUrl(marketingUrl, appUrl) : "";
@@ -1685,7 +1159,7 @@ function generateEmailTemplate(options) {
1685
1159
  <td class="pad" style="padding-left:40px;padding-right:40px;width:100%;">
1686
1160
  <div class="alignment" align="center">
1687
1161
  <div class="fullWidth" style="max-width: 50%;">
1688
- <img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${appName}" title="${appName}" height="auto">
1162
+ <img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${safeAppName}" title="${safeAppName}" height="auto">
1689
1163
  </div>
1690
1164
  </div>
1691
1165
  </td>
@@ -1697,7 +1171,7 @@ function generateEmailTemplate(options) {
1697
1171
  <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1698
1172
  <tr>
1699
1173
  <td class="pad" style="padding-top:30px;padding-left:40px;padding-right:40px;">
1700
- <div style="color:#1e1b4b;font-family:'Montserrat',Arial,sans-serif;font-size:22px;font-weight:600;line-height:1.4;text-align:center;mso-line-height-alt:31px;">
1174
+ <div style="color:${headingColor};font-family:'Montserrat',Arial,sans-serif;font-size:22px;font-weight:600;line-height:1.4;text-align:center;mso-line-height-alt:31px;">
1701
1175
  <p style="margin: 0; word-break: break-word;">${welcomeMessage}</p>
1702
1176
  </div>
1703
1177
  </td>
@@ -1710,7 +1184,7 @@ function generateEmailTemplate(options) {
1710
1184
  <table class="paragraph_block block-4" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1711
1185
  <tr>
1712
1186
  <td class="pad" style="padding-bottom:10px;padding-left:40px;padding-right:40px;padding-top:10px;">
1713
- <div style="color:#1e1b4b;font-family:'Montserrat',Arial,sans-serif;font-size:30px;font-weight:700;line-height:1.2;text-align:center;mso-line-height-alt:36px;">
1187
+ <div style="color:${headingColor};font-family:'Montserrat',Arial,sans-serif;font-size:30px;font-weight:700;line-height:1.2;text-align:center;mso-line-height-alt:36px;">
1714
1188
  <p style="margin: 0; word-break: break-word;">${heading}</p>
1715
1189
  </div>
1716
1190
  </td>
@@ -1766,122 +1240,785 @@ function generateEmailTemplate(options) {
1766
1240
  </td>
1767
1241
  </tr>
1768
1242
  </table>
1769
- ` : ""}
1243
+ ` : ""}
1244
+
1245
+ ${securityWarning ? `
1246
+ <!-- Security Warning -->
1247
+ <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1248
+ <tr>
1249
+ <td class="pad" style="padding-left:40px;padding-right:40px;padding-bottom:20px;">
1250
+ <div style="background-color: #a1f1ff; border-radius: 8px; padding: 16px; display: flex; align-items: center; justify-content: center;">
1251
+ <p style="margin: 0; color: #01001b; font-family: 'Montserrat', Arial, sans-serif; font-size: 13px; line-height: 1.5; text-align: center;">
1252
+ ${securityWarning}
1253
+ </p>
1254
+ </div>
1255
+ </td>
1256
+ </tr>
1257
+ </table>
1258
+ ` : ""}
1259
+
1260
+ <!-- Footer Note -->
1261
+ <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1262
+ <tr>
1263
+ <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
1264
+ <div style="color:#737373;font-family:'Montserrat',Arial,sans-serif;font-size:13px;line-height:1.5;text-align:center;mso-line-height-alt:20px;">
1265
+ <p style="margin: 0; word-break: break-word;">${footerNote}</p>
1266
+ </div>
1267
+ </td>
1268
+ </tr>
1269
+ </table>
1270
+
1271
+ </td>
1272
+ </tr>
1273
+ </tbody>
1274
+ </table>
1275
+ </td>
1276
+ </tr>
1277
+ </tbody>
1278
+ </table>
1279
+
1280
+ <!-- Spacer Row -->
1281
+ <table class="row row-2" align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1282
+ <tbody>
1283
+ <tr>
1284
+ <td>
1285
+ <table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f8f9fa; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
1286
+ <tbody>
1287
+ <tr>
1288
+ <td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; padding-bottom: 5px; vertical-align: top;">
1289
+ <table class="empty_block block-1" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1290
+ <tr>
1291
+ <td class="pad">
1292
+ <div></div>
1293
+ </td>
1294
+ </tr>
1295
+ </table>
1296
+ </td>
1297
+ </tr>
1298
+ </tbody>
1299
+ </table>
1300
+ </td>
1301
+ </tr>
1302
+ </tbody>
1303
+ </table>
1304
+
1305
+ <!-- Footer Row -->
1306
+ <table class="row row-3" align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1307
+ <tbody>
1308
+ <tr>
1309
+ <td>
1310
+ <table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: ${footerColor}; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
1311
+ <tbody>
1312
+ <tr>
1313
+ <td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; vertical-align: top; padding: 20px;">
1314
+
1315
+ <!-- Policy Links -->
1316
+ <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1317
+ <tr>
1318
+ <td class="pad" style="padding-bottom:20px;padding-left:40px;padding-right:40px;padding-top:20px;">
1319
+ <div style="color:#ffffff;font-family:'Montserrat',Arial,sans-serif;font-size:13px;line-height:1.5;text-align:center;mso-line-height-alt:20px;">
1320
+ <p style="margin: 0; word-break: break-word;">
1321
+ <a href="${safeMarketingUrl}/privacy" style="color: #ffffff; text-decoration: underline;">Privacy Policy</a> | <a href="${safeMarketingUrl}/contact" style="color: #ffffff; text-decoration: underline;">Get in Touch</a>
1322
+ </p>
1323
+ </div>
1324
+ </td>
1325
+ </tr>
1326
+ </table>
1327
+
1328
+ <!-- Copyright & Disclaimer -->
1329
+ <table class="paragraph_block block-1" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1330
+ <tr>
1331
+ <td class="pad" style="padding-bottom:15px;padding-left:40px;padding-right:40px;">
1332
+ <div style="color:#a3a3a3;font-family:'Montserrat',Arial,sans-serif;font-size:12px;line-height:1.5;text-align:center;mso-line-height-alt:18px;">
1333
+ <p style="margin: 0 0 12px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${safeAppName}. All rights reserved.</p>
1334
+ <p style="margin: 0; font-size: 11px; line-height: 1.5; word-break: break-word;">
1335
+ Please do not reply to this email as this address does not accept incoming messages. This service email contains essential information relating to your ${safeAppName} account.
1336
+ </p>
1337
+ </div>
1338
+ </td>
1339
+ </tr>
1340
+ </table>
1341
+
1342
+ </td>
1343
+ </tr>
1344
+ </tbody>
1345
+ </table>
1346
+ </td>
1347
+ </tr>
1348
+ </tbody>
1349
+ </table>
1350
+
1351
+ </td>
1352
+ </tr>
1353
+ </tbody>
1354
+ </table>
1355
+ </body>
1356
+ </html>
1357
+ `;
1358
+ }
1359
+
1360
+ // src/core/passwordless.ts
1361
+ var PASSWORDLESS_TTL_MS = 10 * 60 * 1e3;
1362
+ var PASSWORDLESS_MAX_ATTEMPTS = 5;
1363
+ var CODE_SPACE = 1e6;
1364
+ var CODE_LIMIT = Math.floor(4294967296 / CODE_SPACE) * CODE_SPACE;
1365
+ var CLEANUP_GRACE_MS = 24 * 60 * 60 * 1e3;
1366
+ var normalizeEmail = (email) => email.trim().toLowerCase();
1367
+ function generateCode() {
1368
+ const buffer = new Uint32Array(1);
1369
+ for (; ; ) {
1370
+ crypto.getRandomValues(buffer);
1371
+ if (buffer[0] < CODE_LIMIT) return String(buffer[0] % CODE_SPACE).padStart(6, "0");
1372
+ }
1373
+ }
1374
+ var codeHash = (challengeId, code) => hashToken(`${challengeId}:${code}`);
1375
+ function constantTimeEqual(a, b) {
1376
+ if (a.length !== b.length) return false;
1377
+ let diff = 0;
1378
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1379
+ return diff === 0;
1380
+ }
1381
+ async function consumeOpen(db, t, email, now) {
1382
+ await db.update(t).set({ consumedAt: now }).where(and2(eq4(t.email, email), isNull(t.consumedAt)));
1383
+ }
1384
+ async function createPasswordlessChallenge(db, tables2, input) {
1385
+ const t = tables2.passwordlessChallenges;
1386
+ const email = normalizeEmail(input.email);
1387
+ const now = Date.now();
1388
+ const challengeId = crypto.randomUUID();
1389
+ const linkToken = generateSecureToken(32);
1390
+ const browserSecret = generateSecureToken(32);
1391
+ const code = generateCode();
1392
+ await consumeOpen(db, t, email, now);
1393
+ await db.insert(t).values({
1394
+ id: challengeId,
1395
+ email,
1396
+ subjectId: input.subjectId ?? null,
1397
+ linkTokenHash: await hashToken(linkToken),
1398
+ codeHash: await codeHash(challengeId, code),
1399
+ browserHash: await hashToken(browserSecret),
1400
+ attempts: 0,
1401
+ payload: input.payload ?? null,
1402
+ expiresAt: now + (input.ttlMs ?? PASSWORDLESS_TTL_MS),
1403
+ createdAt: now
1404
+ });
1405
+ return { challengeId, linkToken, code, browserSecret };
1406
+ }
1407
+ async function verifyPasswordlessLink(db, tables2, input) {
1408
+ const t = tables2.passwordlessChallenges;
1409
+ const now = Date.now();
1410
+ const hash = await hashToken(input.linkToken);
1411
+ const [row] = await db.update(t).set({ consumedAt: now }).where(and2(eq4(t.linkTokenHash, hash), isNull(t.consumedAt), gt2(t.expiresAt, now))).returning();
1412
+ if (!row) {
1413
+ const [stale] = await db.select({ expiresAt: t.expiresAt, consumedAt: t.consumedAt }).from(t).where(eq4(t.linkTokenHash, hash)).limit(1);
1414
+ const expired = stale && stale.consumedAt === null && stale.expiresAt <= now;
1415
+ return { ok: false, reason: expired ? "expired" : "invalid" };
1416
+ }
1417
+ await consumeOpen(db, t, row.email, now);
1418
+ return { ok: true, email: row.email, subjectId: row.subjectId, payload: row.payload };
1419
+ }
1420
+ async function verifyPasswordlessCode(db, tables2, input) {
1421
+ const t = tables2.passwordlessChallenges;
1422
+ const max = input.maxAttempts ?? PASSWORDLESS_MAX_ATTEMPTS;
1423
+ const email = normalizeEmail(input.email);
1424
+ const now = Date.now();
1425
+ const [row] = await db.select().from(t).where(and2(eq4(t.email, email), isNull(t.consumedAt))).orderBy(desc(t.createdAt)).limit(1);
1426
+ if (!row) return { ok: false, reason: "invalid" };
1427
+ if (!constantTimeEqual(row.browserHash, await hashToken(input.browserSecret))) {
1428
+ return { ok: false, reason: "invalid" };
1429
+ }
1430
+ if (row.expiresAt <= now) return { ok: false, reason: "expired" };
1431
+ if (row.attempts >= max) return { ok: false, reason: "locked" };
1432
+ if (!constantTimeEqual(row.codeHash, await codeHash(row.id, input.code.trim()))) {
1433
+ const [counted] = await db.update(t).set({ attempts: sql2`${t.attempts} + 1` }).where(and2(eq4(t.id, row.id), lt(t.attempts, max))).returning({ attempts: t.attempts });
1434
+ return { ok: false, reason: counted && counted.attempts < max ? "invalid" : "locked" };
1435
+ }
1436
+ const [won] = await db.update(t).set({ consumedAt: now }).where(and2(eq4(t.id, row.id), isNull(t.consumedAt), lt(t.attempts, max))).returning();
1437
+ if (!won) return { ok: false, reason: "invalid" };
1438
+ await consumeOpen(db, t, email, now);
1439
+ return { ok: true, email, subjectId: won.subjectId, payload: won.payload };
1440
+ }
1441
+ async function recentPasswordlessFailures(db, tables2, input) {
1442
+ const t = tables2.passwordlessChallenges;
1443
+ const [row] = await db.select({ total: sql2`coalesce(sum(${t.attempts}), 0)::int` }).from(t).where(and2(eq4(t.email, normalizeEmail(input.email)), gt2(t.createdAt, Date.now() - input.windowMs)));
1444
+ return Number(row?.total ?? 0);
1445
+ }
1446
+ async function cleanupExpiredPasswordlessChallenges(db, tables2) {
1447
+ const t = tables2.passwordlessChallenges;
1448
+ await db.delete(t).where(lt(t.expiresAt, Date.now() - CLEANUP_GRACE_MS));
1449
+ }
1450
+ function passwordlessEmail(input) {
1451
+ const { appName, appUrl, linkUrl, code } = input;
1452
+ const minutes = Math.max(1, Math.round((input.ttlMs ?? PASSWORDLESS_TTL_MS) / 6e4));
1453
+ const lifetime = `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
1454
+ const safeLink = sanitizeEmailUrl(linkUrl, appUrl);
1455
+ const html = generateEmailTemplate({
1456
+ heading: `Sign in to ${escapeHtml(appName)}`,
1457
+ body: `<p>Use this code, or the button below, to sign in:</p>
1458
+ <p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
1459
+ <p>The code and the link expire in ${lifetime}.</p>`,
1460
+ buttonText: "Sign in",
1461
+ buttonUrl: safeLink,
1462
+ footerNote: "If you didn't ask to sign in, you can ignore this email.",
1463
+ appUrl,
1464
+ appName
1465
+ });
1466
+ return {
1467
+ subject: `${code} is your sign-in code`,
1468
+ html,
1469
+ text: `Your ${appName} sign-in code is ${code}.
1470
+
1471
+ Or sign in with this link:
1472
+ ${safeLink}
1473
+
1474
+ The code and the link expire in ${lifetime}.
1475
+
1476
+ If you didn't ask to sign in, you can ignore this email.`
1477
+ };
1478
+ }
1479
+
1480
+ // src/middleware/auth.ts
1481
+ import { createMiddleware } from "hono/factory";
1482
+ import { eq as eq5, and as and3, gt as gt3 } from "drizzle-orm";
1483
+
1484
+ // src/factory.ts
1485
+ function createAuth(config) {
1486
+ const { db, schema, getEnv, emailBranding, invitations, signup = "open" } = config;
1487
+ if (signup === "invitation" && !invitations) {
1488
+ throw new Error("createAuth: signup: 'invitation' requires the invitations hooks");
1489
+ }
1490
+ const getContext = (c) => ({
1491
+ db,
1492
+ schema,
1493
+ env: getEnv(c),
1494
+ emailBranding,
1495
+ invitations,
1496
+ signup
1497
+ });
1498
+ return {
1499
+ db,
1500
+ schema,
1501
+ getContext
1502
+ };
1503
+ }
1504
+ function createAuthMiddleware(auth2) {
1505
+ return async (c, next) => {
1506
+ const ctx = auth2.getContext(c);
1507
+ c.set("authContext", ctx);
1508
+ c.set("db", auth2.db);
1509
+ await next();
1510
+ };
1511
+ }
1512
+ function getAuthContext(c) {
1513
+ const ctx = c.get("authContext");
1514
+ if (!ctx) {
1515
+ throw new Error(
1516
+ "AuthContext not found. Did you apply createAuthMiddleware before the auth routes?"
1517
+ );
1518
+ }
1519
+ return ctx;
1520
+ }
1521
+
1522
+ // src/lib/problem-json.ts
1523
+ var ProblemTypes = {
1524
+ // 4xx Client Errors
1525
+ BAD_REQUEST: "/errors/bad-request",
1526
+ UNAUTHORIZED: "/errors/unauthorized",
1527
+ FORBIDDEN: "/errors/forbidden",
1528
+ NOT_FOUND: "/errors/not-found",
1529
+ CONFLICT: "/errors/conflict",
1530
+ GONE: "/errors/gone",
1531
+ VALIDATION_ERROR: "/errors/validation-error",
1532
+ RATE_LIMIT_EXCEEDED: "/errors/rate-limit-exceeded",
1533
+ // 5xx Server Errors
1534
+ INTERNAL_ERROR: "/errors/internal-error",
1535
+ SERVICE_UNAVAILABLE: "/errors/service-unavailable"
1536
+ };
1537
+ function generateTraceId() {
1538
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
1539
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
1540
+ }
1541
+ function createProblemDetails(type, title, status, options) {
1542
+ const problem = {
1543
+ type,
1544
+ title,
1545
+ status
1546
+ };
1547
+ if (options?.detail) {
1548
+ problem.detail = options.detail;
1549
+ }
1550
+ if (options?.instance) {
1551
+ problem.instance = options.instance;
1552
+ }
1553
+ problem.traceId = options?.traceId || generateTraceId();
1554
+ if (options?.requestId) {
1555
+ problem.requestId = options.requestId;
1556
+ }
1557
+ if (options?.extensions) {
1558
+ Object.assign(problem, options.extensions);
1559
+ }
1560
+ return problem;
1561
+ }
1562
+ function problemJson(c, problem) {
1563
+ logger_default.warn("API error response", {
1564
+ type: problem.type,
1565
+ status: problem.status,
1566
+ traceId: problem.traceId,
1567
+ requestId: problem.requestId,
1568
+ path: c.req.path,
1569
+ method: c.req.method
1570
+ });
1571
+ return c.json(problem, problem.status, {
1572
+ "Content-Type": "application/problem+json"
1573
+ });
1574
+ }
1575
+ var problems = {
1576
+ /**
1577
+ * 400 Bad Request - Generic client error
1578
+ */
1579
+ badRequest(c, detail) {
1580
+ return problemJson(
1581
+ c,
1582
+ createProblemDetails(ProblemTypes.BAD_REQUEST, "Bad Request", 400, {
1583
+ detail: detail || "The request could not be understood by the server",
1584
+ instance: `${c.req.method} ${c.req.path}`,
1585
+ requestId: c.get("requestId"),
1586
+ traceId: c.get("traceId")
1587
+ })
1588
+ );
1589
+ },
1590
+ /**
1591
+ * 400 Bad Request - Validation errors with field-specific details
1592
+ */
1593
+ validationError(c, detail, errors) {
1594
+ return problemJson(
1595
+ c,
1596
+ createProblemDetails(ProblemTypes.VALIDATION_ERROR, "Validation Error", 400, {
1597
+ detail,
1598
+ instance: `${c.req.method} ${c.req.path}`,
1599
+ requestId: c.get("requestId"),
1600
+ traceId: c.get("traceId"),
1601
+ extensions: errors ? { errors } : void 0
1602
+ })
1603
+ );
1604
+ },
1605
+ /**
1606
+ * 401 Unauthorized - Authentication required
1607
+ */
1608
+ unauthorized(c, detail) {
1609
+ return problemJson(
1610
+ c,
1611
+ createProblemDetails(ProblemTypes.UNAUTHORIZED, "Unauthorized", 401, {
1612
+ detail: detail || "Authentication is required to access this resource",
1613
+ instance: `${c.req.method} ${c.req.path}`,
1614
+ requestId: c.get("requestId"),
1615
+ traceId: c.get("traceId")
1616
+ })
1617
+ );
1618
+ },
1619
+ /**
1620
+ * 403 Forbidden - Insufficient permissions
1621
+ */
1622
+ forbidden(c, detail) {
1623
+ return problemJson(
1624
+ c,
1625
+ createProblemDetails(ProblemTypes.FORBIDDEN, "Forbidden", 403, {
1626
+ detail: detail || "You do not have permission to access this resource",
1627
+ instance: `${c.req.method} ${c.req.path}`,
1628
+ requestId: c.get("requestId"),
1629
+ traceId: c.get("traceId")
1630
+ })
1631
+ );
1632
+ },
1633
+ /**
1634
+ * 404 Not Found
1635
+ */
1636
+ notFound(c, detail) {
1637
+ return problemJson(
1638
+ c,
1639
+ createProblemDetails(ProblemTypes.NOT_FOUND, "Not Found", 404, {
1640
+ detail: detail || "The requested resource was not found",
1641
+ instance: `${c.req.method} ${c.req.path}`,
1642
+ requestId: c.get("requestId"),
1643
+ traceId: c.get("traceId")
1644
+ })
1645
+ );
1646
+ },
1647
+ /**
1648
+ * 409 Conflict - Resource conflict (e.g., duplicate email)
1649
+ */
1650
+ conflict(c, detail) {
1651
+ return problemJson(
1652
+ c,
1653
+ createProblemDetails(ProblemTypes.CONFLICT, "Conflict", 409, {
1654
+ detail,
1655
+ instance: `${c.req.method} ${c.req.path}`,
1656
+ requestId: c.get("requestId"),
1657
+ traceId: c.get("traceId")
1658
+ })
1659
+ );
1660
+ },
1661
+ /**
1662
+ * 410 Gone - Resource is no longer available
1663
+ */
1664
+ gone(c, detail) {
1665
+ return problemJson(
1666
+ c,
1667
+ createProblemDetails(ProblemTypes.GONE, "Gone", 410, {
1668
+ detail: detail || "The requested resource is no longer available",
1669
+ instance: `${c.req.method} ${c.req.path}`,
1670
+ requestId: c.get("requestId"),
1671
+ traceId: c.get("traceId")
1672
+ })
1673
+ );
1674
+ },
1675
+ /**
1676
+ * 429 Too Many Requests - Rate limit exceeded
1677
+ */
1678
+ rateLimitExceeded(c, retryAfter) {
1679
+ const extensions = retryAfter ? { retryAfter } : void 0;
1680
+ return problemJson(
1681
+ c,
1682
+ createProblemDetails(ProblemTypes.RATE_LIMIT_EXCEEDED, "Rate Limit Exceeded", 429, {
1683
+ detail: retryAfter ? `Too many requests. Please retry after ${retryAfter} seconds` : "Too many requests. Please slow down",
1684
+ instance: `${c.req.method} ${c.req.path}`,
1685
+ requestId: c.get("requestId"),
1686
+ traceId: c.get("traceId"),
1687
+ extensions
1688
+ })
1689
+ );
1690
+ },
1691
+ /**
1692
+ * 500 Internal Server Error - Generic server error
1693
+ * SECURITY: Never include error details or stack traces
1694
+ */
1695
+ internalError(c, error) {
1696
+ const traceId = c.get("traceId") || generateTraceId();
1697
+ if (error) {
1698
+ logError(error, {
1699
+ context: "internal_error",
1700
+ path: c.req.path,
1701
+ method: c.req.method,
1702
+ traceId,
1703
+ requestId: c.get("requestId")
1704
+ });
1705
+ }
1706
+ return problemJson(
1707
+ c,
1708
+ createProblemDetails(ProblemTypes.INTERNAL_ERROR, "Internal Server Error", 500, {
1709
+ detail: "An unexpected error occurred. Please try again later",
1710
+ instance: `${c.req.method} ${c.req.path}`,
1711
+ traceId,
1712
+ requestId: c.get("requestId")
1713
+ })
1714
+ );
1715
+ },
1716
+ /**
1717
+ * 503 Service Unavailable - Service is temporarily unavailable
1718
+ */
1719
+ serviceUnavailable(c, detail) {
1720
+ return problemJson(
1721
+ c,
1722
+ createProblemDetails(ProblemTypes.SERVICE_UNAVAILABLE, "Service Unavailable", 503, {
1723
+ detail: detail || "The service is temporarily unavailable. Please try again later",
1724
+ instance: `${c.req.method} ${c.req.path}`,
1725
+ requestId: c.get("requestId"),
1726
+ traceId: c.get("traceId")
1727
+ })
1728
+ );
1729
+ }
1730
+ };
1770
1731
 
1771
- ${securityWarning ? `
1772
- <!-- Security Warning -->
1773
- <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1774
- <tr>
1775
- <td class="pad" style="padding-left:40px;padding-right:40px;padding-bottom:20px;">
1776
- <div style="background-color: #a1f1ff; border-radius: 8px; padding: 16px; display: flex; align-items: center; justify-content: center;">
1777
- <p style="margin: 0; color: #01001b; font-family: 'Montserrat', Arial, sans-serif; font-size: 13px; line-height: 1.5; text-align: center;">
1778
- ${securityWarning}
1779
- </p>
1780
- </div>
1781
- </td>
1782
- </tr>
1783
- </table>
1784
- ` : ""}
1732
+ // src/middleware/auth.ts
1733
+ var SESSION_MAX_LIFETIME_MS = AUTH_DEFAULTS.SESSION_MAX_LIFETIME_DAYS * 24 * 60 * 60 * 1e3;
1734
+ async function revokeIfPastMaxLifetime(db, sessions, sessionId, session) {
1735
+ if (Date.now() - session.createdAt <= SESSION_MAX_LIFETIME_MS) return false;
1736
+ logger_default.info("Session exceeded absolute lifetime - revoked", {
1737
+ type: "security",
1738
+ event: "session_max_lifetime_exceeded",
1739
+ severity: "low",
1740
+ sessionId: sessionId.slice(0, 8),
1741
+ userId: session.userId
1742
+ });
1743
+ await deleteSession(db, { sessions }, sessionId);
1744
+ return true;
1745
+ }
1746
+ async function getSessionFromCookie(db, sessions, cookieHeader, request, env) {
1747
+ const cookieName = getSessionCookieName(env);
1748
+ const cookiePattern = new RegExp(`${cookieName}=([^;]+)`);
1749
+ const sessionIdMatch = cookieHeader.match(cookiePattern);
1750
+ if (!sessionIdMatch) return null;
1751
+ const sessionId = sessionIdMatch[1];
1752
+ const foundSessions = await db.select({
1753
+ userId: sessions.userId,
1754
+ expiresAt: sessions.expiresAt,
1755
+ createdAt: sessions.createdAt,
1756
+ lastActiveAt: sessions.lastActiveAt,
1757
+ fingerprint: sessions.fingerprint
1758
+ }).from(sessions).where(and3(eq5(sessions.id, sessionId), gt3(sessions.expiresAt, Date.now()))).limit(1);
1759
+ if (foundSessions.length === 0) return null;
1760
+ const session = foundSessions[0];
1761
+ if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
1762
+ if (sessionRefreshDue(session.lastActiveAt)) await refreshSession(db, { sessions }, sessionId);
1763
+ const userAgent = request.headers.get("user-agent") || "";
1764
+ const cfWorker = request.headers.get("cf-worker") || "";
1765
+ const clientIp = request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
1766
+ const isLocalSSR = userAgent.toLowerCase().includes("node");
1767
+ const isSvelteKitSSR = userAgent.toLowerCase().includes("sveltekit");
1768
+ const isCloudflareSSR = !userAgent && cfWorker.includes(".pages.dev");
1769
+ const isSSR = isLocalSSR || isSvelteKitSSR || isCloudflareSSR;
1770
+ if (isSSR) {
1771
+ logger_default.info("SSR request detected, using IP validation", {
1772
+ type: "security",
1773
+ event: "ssr_session_validation",
1774
+ sessionId: sessionId.slice(0, 8),
1775
+ userId: session.userId,
1776
+ clientIp,
1777
+ cfWorker,
1778
+ isLocalSSR,
1779
+ isSvelteKitSSR,
1780
+ isCloudflareSSR
1781
+ });
1782
+ } else {
1783
+ if (session.fingerprint) {
1784
+ const currentFingerprint = await generateFingerprint(request);
1785
+ if (session.fingerprint !== currentFingerprint) {
1786
+ logger_default.info("Session fingerprint changed (not enforced)", {
1787
+ type: "security",
1788
+ event: "fingerprint_changed",
1789
+ sessionId: sessionId.slice(0, 8),
1790
+ userId: session.userId,
1791
+ clientIp
1792
+ });
1793
+ }
1794
+ }
1795
+ }
1796
+ return {
1797
+ userId: session.userId,
1798
+ expiresAt: session.expiresAt
1799
+ };
1800
+ }
1801
+ async function getSessionFromBearerToken(db, sessions, authHeader) {
1802
+ if (!authHeader.startsWith("Bearer ")) return null;
1803
+ const sessionId = authHeader.slice(7);
1804
+ const foundSessions = await db.select({
1805
+ userId: sessions.userId,
1806
+ expiresAt: sessions.expiresAt,
1807
+ createdAt: sessions.createdAt,
1808
+ lastActiveAt: sessions.lastActiveAt
1809
+ }).from(sessions).where(and3(eq5(sessions.id, sessionId), gt3(sessions.expiresAt, Date.now()))).limit(1);
1810
+ if (foundSessions.length === 0) return null;
1811
+ const session = foundSessions[0];
1812
+ if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
1813
+ if (sessionRefreshDue(session.lastActiveAt)) await refreshSession(db, { sessions }, sessionId);
1814
+ return {
1815
+ userId: session.userId,
1816
+ expiresAt: session.expiresAt
1817
+ };
1818
+ }
1819
+ var requireAuth = createMiddleware(
1820
+ async (c, next) => {
1821
+ const { db, schema } = getAuthContext(c);
1822
+ const authHeader = c.req.header("Authorization");
1823
+ if (authHeader) {
1824
+ const session2 = await getSessionFromBearerToken(db, schema.sessions, authHeader);
1825
+ if (session2) {
1826
+ c.set("userId", session2.userId);
1827
+ await next();
1828
+ return;
1829
+ }
1830
+ }
1831
+ const cookieHeader = c.req.header("cookie");
1832
+ if (!cookieHeader) {
1833
+ return problems.unauthorized(c, "Please log in to continue");
1834
+ }
1835
+ const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
1836
+ if (!session) {
1837
+ c.header("Set-Cookie", clearSessionCookie(c.env));
1838
+ return problems.unauthorized(c, "Your session has expired. Please log in again.");
1839
+ }
1840
+ c.set("userId", session.userId);
1841
+ await next();
1842
+ }
1843
+ );
1844
+ var optionalAuth = createMiddleware(
1845
+ async (c, next) => {
1846
+ const { db, schema } = getAuthContext(c);
1847
+ const authHeader = c.req.header("Authorization");
1848
+ if (authHeader) {
1849
+ const session = await getSessionFromBearerToken(db, schema.sessions, authHeader);
1850
+ if (session) {
1851
+ c.set("userId", session.userId);
1852
+ await next();
1853
+ return;
1854
+ }
1855
+ }
1856
+ const cookieHeader = c.req.header("cookie");
1857
+ if (cookieHeader) {
1858
+ const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
1859
+ if (session) {
1860
+ c.set("userId", session.userId);
1861
+ }
1862
+ }
1863
+ await next();
1864
+ }
1865
+ );
1785
1866
 
1786
- <!-- Footer Note -->
1787
- <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1788
- <tr>
1789
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
1790
- <div style="color:#737373;font-family:'Montserrat',Arial,sans-serif;font-size:13px;line-height:1.5;text-align:center;mso-line-height-alt:20px;">
1791
- <p style="margin: 0; word-break: break-word;">${footerNote}</p>
1792
- </div>
1793
- </td>
1794
- </tr>
1795
- </table>
1867
+ // src/middleware/csrf.ts
1868
+ import { createMiddleware as createMiddleware2 } from "hono/factory";
1869
+ var csrf = createMiddleware2(async (c, next) => {
1870
+ const method = c.req.method;
1871
+ const path = c.req.path;
1872
+ const isWebhook = path.startsWith("/v1/webhooks/");
1873
+ if (isWebhook) {
1874
+ await next();
1875
+ return;
1876
+ }
1877
+ const isMcpEndpoint = path === "/v1/mcp";
1878
+ if (isMcpEndpoint) {
1879
+ await next();
1880
+ return;
1881
+ }
1882
+ const isOAuthMcpEndpoint = path.startsWith("/v1/oauth/");
1883
+ if (isOAuthMcpEndpoint) {
1884
+ await next();
1885
+ return;
1886
+ }
1887
+ const isToolsApi = path.startsWith("/v1/tools/") && !path.startsWith("/v1/tools/catalog");
1888
+ if (isToolsApi) {
1889
+ await next();
1890
+ return;
1891
+ }
1892
+ const isAdminApi = path.startsWith("/v1/admin/");
1893
+ if (isAdminApi) {
1894
+ await next();
1895
+ return;
1896
+ }
1897
+ const isMobileAuthEndpoint = path === "/v1/auth/login" || path === "/v1/auth/signup" || path === "/v1/auth/refresh";
1898
+ const authHeader = c.req.header("Authorization");
1899
+ const hasBearerToken = authHeader?.startsWith("Bearer ");
1900
+ const hasNoOrigin = !c.req.header("origin") && !c.req.header("referer");
1901
+ if (isMobileAuthEndpoint && hasNoOrigin || hasBearerToken) {
1902
+ await next();
1903
+ return;
1904
+ }
1905
+ const isInternalSync = path.startsWith("/v1/internal/sync/");
1906
+ if (isInternalSync) {
1907
+ await next();
1908
+ return;
1909
+ }
1910
+ if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
1911
+ const appUrl = c.env.APP_URL || "http://localhost:5173";
1912
+ const allowedOrigins = getAllowedOrigins(appUrl);
1913
+ const request = c.req.raw;
1914
+ const isTestEndpoint = c.req.path.startsWith("/test");
1915
+ if (isTestEndpoint) {
1916
+ const origin = request.headers.get("origin");
1917
+ const referer = request.headers.get("referer");
1918
+ logger_default.debug("CSRF validation for test endpoint", {
1919
+ path: c.req.path,
1920
+ method,
1921
+ origin,
1922
+ referer,
1923
+ appUrl,
1924
+ allowedOrigins
1925
+ });
1926
+ }
1927
+ const pagesPattern = c.env.CORS_PAGES_PATTERN;
1928
+ if (!validateOrigin(request, allowedOrigins, pagesPattern)) {
1929
+ return problems.forbidden(c, "Request blocked for security reasons. Please try again.");
1930
+ }
1931
+ }
1932
+ await next();
1933
+ });
1796
1934
 
1797
- </td>
1798
- </tr>
1799
- </tbody>
1800
- </table>
1801
- </td>
1802
- </tr>
1803
- </tbody>
1804
- </table>
1935
+ // src/middleware/rateLimit.ts
1936
+ import { createMiddleware as createMiddleware3 } from "hono/factory";
1805
1937
 
1806
- <!-- Spacer Row -->
1807
- <table class="row row-2" align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1808
- <tbody>
1809
- <tr>
1810
- <td>
1811
- <table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f8f9fa; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
1812
- <tbody>
1813
- <tr>
1814
- <td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; padding-bottom: 5px; vertical-align: top;">
1815
- <table class="empty_block block-1" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1816
- <tr>
1817
- <td class="pad">
1818
- <div></div>
1819
- </td>
1820
- </tr>
1821
- </table>
1822
- </td>
1823
- </tr>
1824
- </tbody>
1825
- </table>
1826
- </td>
1827
- </tr>
1828
- </tbody>
1829
- </table>
1938
+ // src/lib/rate-limit-kv.ts
1939
+ async function checkRateLimitKV(kv, key, maxAttempts, windowSeconds) {
1940
+ try {
1941
+ const rateLimitKey = `rate_limit:${key}`;
1942
+ const currentAttemptsStr = await kv.get(rateLimitKey);
1943
+ const currentAttempts = currentAttemptsStr ? parseInt(currentAttemptsStr, 10) : 0;
1944
+ if (currentAttempts >= maxAttempts) {
1945
+ return {
1946
+ allowed: false,
1947
+ remaining: 0
1948
+ };
1949
+ }
1950
+ const newAttempts = currentAttempts + 1;
1951
+ await kv.put(rateLimitKey, newAttempts.toString(), {
1952
+ expirationTtl: windowSeconds
1953
+ });
1954
+ return {
1955
+ allowed: true,
1956
+ remaining: maxAttempts - newAttempts
1957
+ };
1958
+ } catch (error) {
1959
+ logger_default.error("Rate limit KV error, failing open", {
1960
+ key,
1961
+ error: error instanceof Error ? error.message : "Unknown error"
1962
+ });
1963
+ return {
1964
+ allowed: true,
1965
+ remaining: maxAttempts
1966
+ };
1967
+ }
1968
+ }
1830
1969
 
1831
- <!-- Footer Row -->
1832
- <table class="row row-3" align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1833
- <tbody>
1834
- <tr>
1835
- <td>
1836
- <table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: ${footerColor}; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
1837
- <tbody>
1838
- <tr>
1839
- <td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; vertical-align: top; padding: 20px;">
1970
+ // src/middleware/rateLimit.ts
1971
+ var rateLimit = (config) => createMiddleware3(async (c, next) => {
1972
+ const kv = c.env.RATE_LIMIT_KV;
1973
+ const identifier = await config.identifier(c);
1974
+ const key = `${config.action}:${identifier}`;
1975
+ const windowSeconds = Math.ceil(config.windowMs / 1e3);
1976
+ const { allowed, remaining } = await checkRateLimitKV(
1977
+ kv,
1978
+ key,
1979
+ config.maxAttempts,
1980
+ windowSeconds
1981
+ );
1982
+ const resetTimestamp = Math.ceil(Date.now() / 1e3) + windowSeconds;
1983
+ c.header("X-RateLimit-Limit", config.maxAttempts.toString());
1984
+ c.header("X-RateLimit-Remaining", Math.max(0, remaining).toString());
1985
+ c.header("X-RateLimit-Reset", resetTimestamp.toString());
1986
+ if (!allowed) {
1987
+ c.header("Retry-After", windowSeconds.toString());
1988
+ return problems.rateLimitExceeded(c, windowSeconds);
1989
+ }
1990
+ await next();
1991
+ });
1840
1992
 
1841
- <!-- Policy Links -->
1842
- <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1843
- <tr>
1844
- <td class="pad" style="padding-bottom:20px;padding-left:40px;padding-right:40px;padding-top:20px;">
1845
- <div style="color:#ffffff;font-family:'Montserrat',Arial,sans-serif;font-size:13px;line-height:1.5;text-align:center;mso-line-height-alt:20px;">
1846
- <p style="margin: 0; word-break: break-word;">
1847
- <a href="${safeMarketingUrl}/privacy" style="color: #ffffff; text-decoration: underline;">Privacy Policy</a> | <a href="${safeMarketingUrl}/contact" style="color: #ffffff; text-decoration: underline;">Get in Touch</a>
1848
- </p>
1849
- </div>
1850
- </td>
1851
- </tr>
1852
- </table>
1993
+ // src/middleware/require-verified-email.ts
1994
+ import { createMiddleware as createMiddleware4 } from "hono/factory";
1995
+ import { eq as eq6 } from "drizzle-orm";
1996
+ var requireVerifiedEmail = createMiddleware4(
1997
+ async (c, next) => {
1998
+ const userId = c.get("userId");
1999
+ if (!userId) {
2000
+ return problems.unauthorized(c, "Not authenticated");
2001
+ }
2002
+ const { db, schema } = getAuthContext(c);
2003
+ const database = db;
2004
+ const users = schema.users;
2005
+ const [user] = await database.select({ emailVerified: users.emailVerified }).from(users).where(eq6(users.id, userId)).limit(1);
2006
+ if (!user || !user.emailVerified) {
2007
+ return problems.forbidden(c, "Please verify your email address to continue");
2008
+ }
2009
+ await next();
2010
+ }
2011
+ );
1853
2012
 
1854
- <!-- Copyright & Disclaimer -->
1855
- <table class="paragraph_block block-1" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1856
- <tr>
1857
- <td class="pad" style="padding-bottom:15px;padding-left:40px;padding-right:40px;">
1858
- <div style="color:#a3a3a3;font-family:'Montserrat',Arial,sans-serif;font-size:12px;line-height:1.5;text-align:center;mso-line-height-alt:18px;">
1859
- <p style="margin: 0 0 12px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${appName}. All rights reserved.</p>
1860
- <p style="margin: 0; font-size: 11px; line-height: 1.5; word-break: break-word;">
1861
- Please do not reply to this email as this address does not accept incoming messages. This service email contains essential information relating to your ${appName} account.
1862
- </p>
1863
- </div>
1864
- </td>
1865
- </tr>
1866
- </table>
2013
+ // src/routes/index.ts
2014
+ import { OpenAPIHono as OpenAPIHono3 } from "@hono/zod-openapi";
1867
2015
 
1868
- </td>
1869
- </tr>
1870
- </tbody>
1871
- </table>
1872
- </td>
1873
- </tr>
1874
- </tbody>
1875
- </table>
2016
+ // src/routes/signup.ts
2017
+ import { createRoute } from "@hono/zod-openapi";
2018
+ import { eq as eq9 } from "drizzle-orm";
1876
2019
 
1877
- </td>
1878
- </tr>
1879
- </tbody>
1880
- </table>
1881
- </body>
1882
- </html>
1883
- `;
1884
- }
2020
+ // src/lib/email/email-service.ts
2021
+ import { eq as eq7 } from "drizzle-orm";
1885
2022
 
1886
2023
  // src/lib/email/templates.ts
1887
2024
  function extractAppUrl(url) {
@@ -1905,6 +2042,7 @@ function generateVerificationEmail(data, appName = "Your App", configuredAppUrl
1905
2042
  welcomeMessage: safeFirstName ? `Hi ${safeFirstName}, Welcome to ${escapeHtml(appName)}!` : void 0,
1906
2043
  footerNote: "",
1907
2044
  appUrl,
2045
+ appName,
1908
2046
  ...branding
1909
2047
  });
1910
2048
  return {
@@ -1940,6 +2078,7 @@ function generatePasswordResetEmail(data, appName = "Your App", configuredAppUrl
1940
2078
  `,
1941
2079
  footerNote: "If you didn't request this change, no action is needed. Your password will remain unchanged.",
1942
2080
  appUrl,
2081
+ appName,
1943
2082
  ...branding
1944
2083
  });
1945
2084
  return {
@@ -1975,6 +2114,7 @@ function generateEmailChangeConfirmation(data, appName = "Your App", configuredA
1975
2114
  `,
1976
2115
  footerNote: "If you didn't request this change, you can safely ignore this email.",
1977
2116
  appUrl,
2117
+ appName,
1978
2118
  ...branding
1979
2119
  });
1980
2120
  return {
@@ -2013,6 +2153,7 @@ function generateEmailChangeNotification(data, appName = "Your App", configuredA
2013
2153
  `,
2014
2154
  footerNote: "If you didn't request this change, click the button above to cancel it.",
2015
2155
  appUrl,
2156
+ appName,
2016
2157
  ...branding
2017
2158
  });
2018
2159
  return {
@@ -2049,6 +2190,7 @@ function generate2faCodeEmail(data, appName = "Your App", appUrl = "", branding)
2049
2190
  buttonUrl: "",
2050
2191
  footerNote: "",
2051
2192
  appUrl,
2193
+ appName,
2052
2194
  ...branding
2053
2195
  });
2054
2196
  return {
@@ -2084,6 +2226,7 @@ function generate2faEnabledEmail(data, appName = "Your App", appUrl = "", brandi
2084
2226
  buttonUrl: "",
2085
2227
  footerNote: "",
2086
2228
  appUrl,
2229
+ appName,
2087
2230
  ...branding
2088
2231
  });
2089
2232
  return {
@@ -2114,6 +2257,7 @@ function generate2faDisabledEmail(data, appName = "Your App", appUrl = "", brand
2114
2257
  buttonUrl: appUrl ? `${appUrl}/forgot-password` : "",
2115
2258
  footerNote: "",
2116
2259
  appUrl,
2260
+ appName,
2117
2261
  ...branding
2118
2262
  });
2119
2263
  return {
@@ -2330,7 +2474,7 @@ var EmailService = class {
2330
2474
  const [user] = await db.select({
2331
2475
  emailBounced: usersTable.emailBounced,
2332
2476
  emailComplained: usersTable.emailComplained
2333
- }).from(usersTable).where(eq6(usersTable.email, emailAddress.toLowerCase())).limit(1);
2477
+ }).from(usersTable).where(eq7(usersTable.email, emailAddress.toLowerCase())).limit(1);
2334
2478
  if (!user) {
2335
2479
  return { blocked: false };
2336
2480
  }
@@ -2779,7 +2923,7 @@ var EmailService = class {
2779
2923
  import { Resend as Resend2 } from "resend";
2780
2924
 
2781
2925
  // src/lib/email/webhook-handler.ts
2782
- import { eq as eq7 } from "drizzle-orm";
2926
+ import { eq as eq8 } from "drizzle-orm";
2783
2927
  async function handleWebhookEvent(payload, db, tables2) {
2784
2928
  const { type, data } = payload;
2785
2929
  const emailAddress = data.to[0];
@@ -2838,7 +2982,7 @@ async function handleBounce(emailAddress, emailId, bounce, db, usersTable) {
2838
2982
  await db.update(usersTable).set({
2839
2983
  emailBounced: true,
2840
2984
  emailBouncedAt: /* @__PURE__ */ new Date()
2841
- }).where(eq7(usersTable.email, emailAddress.toLowerCase()));
2985
+ }).where(eq8(usersTable.email, emailAddress.toLowerCase()));
2842
2986
  logError(new Error("Email bounced"), {
2843
2987
  context: "email_webhook",
2844
2988
  emailAddress,
@@ -2854,7 +2998,7 @@ async function handleComplaint(emailAddress, emailId, db, usersTable) {
2854
2998
  await db.update(usersTable).set({
2855
2999
  emailComplained: true,
2856
3000
  emailComplainedAt: /* @__PURE__ */ new Date()
2857
- }).where(eq7(usersTable.email, emailAddress.toLowerCase()));
3001
+ }).where(eq8(usersTable.email, emailAddress.toLowerCase()));
2858
3002
  logError(new Error("Email spam complaint received"), {
2859
3003
  context: "email_webhook",
2860
3004
  emailAddress,
@@ -2876,15 +3020,15 @@ async function handleFailure(emailAddress, emailId, failed) {
2876
3020
  }
2877
3021
 
2878
3022
  // src/lib/email/monitoring.ts
2879
- import { sql as sql2 } from "drizzle-orm";
3023
+ import { sql as sql3 } from "drizzle-orm";
2880
3024
  async function calculateBounceRate(db, emailEventsTable, hoursAgo = 24) {
2881
3025
  const cutoff = new Date(Date.now() - hoursAgo * 60 * 60 * 1e3);
2882
3026
  const [stats] = await db.select({
2883
- totalSent: sql2`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
2884
- totalBounced: sql2`count(*) filter (where event_type = 'email.bounced')`.as(
3027
+ totalSent: sql3`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
3028
+ totalBounced: sql3`count(*) filter (where event_type = 'email.bounced')`.as(
2885
3029
  "total_bounced"
2886
3030
  )
2887
- }).from(emailEventsTable).where(sql2`created_at > ${cutoff}`);
3031
+ }).from(emailEventsTable).where(sql3`created_at > ${cutoff}`);
2888
3032
  if (!stats || stats.totalSent === 0) {
2889
3033
  return 0;
2890
3034
  }
@@ -2903,11 +3047,11 @@ async function calculateBounceRate(db, emailEventsTable, hoursAgo = 24) {
2903
3047
  async function calculateComplaintRate(db, emailEventsTable, hoursAgo = 24) {
2904
3048
  const cutoff = new Date(Date.now() - hoursAgo * 60 * 60 * 1e3);
2905
3049
  const [stats] = await db.select({
2906
- totalSent: sql2`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
2907
- totalComplained: sql2`count(*) filter (where event_type = 'email.complained')`.as(
3050
+ totalSent: sql3`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
3051
+ totalComplained: sql3`count(*) filter (where event_type = 'email.complained')`.as(
2908
3052
  "total_complained"
2909
3053
  )
2910
- }).from(emailEventsTable).where(sql2`created_at > ${cutoff}`);
3054
+ }).from(emailEventsTable).where(sql3`created_at > ${cutoff}`);
2911
3055
  if (!stats || stats.totalSent === 0) {
2912
3056
  return 0;
2913
3057
  }
@@ -2924,16 +3068,34 @@ async function calculateComplaintRate(db, emailEventsTable, hoursAgo = 24) {
2924
3068
  return complaintRate;
2925
3069
  }
2926
3070
 
3071
+ // src/core/invitations.ts
3072
+ async function verifyInvitation(invitations, args) {
3073
+ if (!invitations || !args.token) return null;
3074
+ const email = args.email.toLowerCase();
3075
+ try {
3076
+ const match = await invitations.verify({ token: args.token, email, db: args.db, env: args.env });
3077
+ return match && match.email.toLowerCase() === email ? args.token : null;
3078
+ } catch (err) {
3079
+ logError(err, { context: "invitation_verify_failed", email });
3080
+ return null;
3081
+ }
3082
+ }
3083
+ async function acceptInvitation(invitations, args) {
3084
+ if (!invitations?.onAccepted) return;
3085
+ try {
3086
+ await invitations.onAccepted({ token: args.token, user: args.user, db: args.db, env: args.env });
3087
+ } catch (err) {
3088
+ logError(err, { context: args.context, userId: args.user.id });
3089
+ }
3090
+ }
3091
+
2927
3092
  // src/routes/schemas.ts
2928
3093
  import { z } from "@hono/zod-openapi";
2929
3094
  var signupRequestSchema = z.object({
2930
3095
  email: z.string().email().openapi({ example: "user@example.com" }),
2931
- // Password and turnstileToken are required for standard signup; optional when oauthToken is provided
2932
- password: z.string().min(8).optional().openapi({ example: "SecurePass123!" }),
3096
+ password: z.string().min(8).openapi({ example: "SecurePass123!" }),
2933
3097
  name: z.string().min(1).optional().openapi({ example: "John Doe" }),
2934
- turnstileToken: z.string().optional().openapi({ example: "XXXX.DUMMY.TOKEN.XXXX" }),
2935
- // OAuth signup completion token (from /signup?oauth_token=...)
2936
- oauthToken: z.string().optional().openapi({ example: "a1b2c3d4-..." }),
3098
+ turnstileToken: z.string().openapi({ example: "XXXX.DUMMY.TOKEN.XXXX" }),
2937
3099
  // App invitation token (from /signup?token=...). When it validates for this
2938
3100
  // email, the account is created already-verified with no verification email.
2939
3101
  invitationToken: z.string().optional().openapi({ example: "iIB8tDJJ..." })
@@ -3059,76 +3221,22 @@ var signupRoute = createRoute({
3059
3221
  description: "Invalid input",
3060
3222
  content: { "application/json": { schema: errorResponseSchema } }
3061
3223
  },
3224
+ 403: {
3225
+ description: "Sign-up is by invitation only and no valid invitation was given",
3226
+ content: { "application/json": { schema: errorResponseSchema } }
3227
+ },
3062
3228
  409: {
3063
3229
  description: "Email already registered",
3064
3230
  content: { "application/json": { schema: errorResponseSchema } }
3065
3231
  }
3066
3232
  }
3067
3233
  });
3234
+ var INVITATION_ONLY = "Sign-up is by invitation only";
3235
+ var INVITATION_INVALID = "This invitation is invalid, expired, or for a different email address";
3068
3236
  var signupHandler = async (c) => {
3069
- const { email, password, name, turnstileToken, oauthToken, invitationToken } = c.req.valid("json");
3070
- const { db: database, schema, emailBranding, invitations } = getAuthContext(c);
3237
+ const { email, password, name, turnstileToken, invitationToken } = c.req.valid("json");
3238
+ const { db: database, schema, env: authEnv, emailBranding, invitations, signup } = getAuthContext(c);
3071
3239
  const env = c.env;
3072
- if (oauthToken) {
3073
- const stored = await env.OAUTH_STATES.get(`oauth:signup:${oauthToken}`);
3074
- if (!stored) {
3075
- return problems.badRequest(c, "Invalid or expired OAuth token");
3076
- }
3077
- const {
3078
- email: tokenEmail,
3079
- provider,
3080
- providerUserId
3081
- } = JSON.parse(stored);
3082
- if (email.toLowerCase() !== tokenEmail.toLowerCase()) {
3083
- return problems.badRequest(c, "Email does not match OAuth token");
3084
- }
3085
- await env.OAUTH_STATES.delete(`oauth:signup:${oauthToken}`);
3086
- const existingUser2 = await database.query.users.findFirst({
3087
- where: eq8(schema.users.email, tokenEmail.toLowerCase())
3088
- });
3089
- if (existingUser2) {
3090
- return problems.conflict(c, "Email already registered");
3091
- }
3092
- let hashedPassword2 = null;
3093
- if (password) {
3094
- const passwordValidation2 = await validatePasswordWithBreachCheck(password);
3095
- if (!passwordValidation2.valid) {
3096
- return problems.badRequest(c, passwordValidation2.error || "Invalid password");
3097
- }
3098
- hashedPassword2 = await hashPassword(password, env.PASSWORD_PEPPER_V1);
3099
- }
3100
- const [newUser2] = await database.insert(schema.users).values({
3101
- email: tokenEmail.toLowerCase(),
3102
- name: name || null,
3103
- hashedPassword: hashedPassword2,
3104
- pepperKid: hashedPassword2 ? "v1" : null,
3105
- emailVerified: true,
3106
- // Provider verified the email
3107
- activatedAt: /* @__PURE__ */ new Date()
3108
- }).returning();
3109
- await database.insert(schema.oauthAccounts).values({ userId: newUser2.id, provider, providerUserId, email: tokenEmail.toLowerCase() }).onConflictDoNothing();
3110
- const fingerprint2 = await generateFingerprint(c.req.raw);
3111
- const ipAddress2 = getClientIp(c.req.raw);
3112
- const sessionId2 = await createSession(database, { sessions: schema.sessions }, newUser2.id, fingerprint2, ipAddress2);
3113
- logSecurityEvent("user_signup_oauth", "low", {
3114
- userId: newUser2.id,
3115
- email: newUser2.email,
3116
- provider
3117
- });
3118
- c.header("Set-Cookie", setSessionCookie(sessionId2, env), { append: true });
3119
- return c.json({
3120
- user: {
3121
- id: newUser2.id,
3122
- email: newUser2.email,
3123
- name: newUser2.name,
3124
- emailVerified: true
3125
- },
3126
- redirect: "/"
3127
- });
3128
- }
3129
- if (!password || !turnstileToken) {
3130
- return problems.badRequest(c, "Password and captcha are required");
3131
- }
3132
3240
  const turnstileValid = await verifyTurnstileToken(
3133
3241
  turnstileToken,
3134
3242
  env.TURNSTILE_SECRET_KEY,
@@ -3138,8 +3246,22 @@ var signupHandler = async (c) => {
3138
3246
  if (!turnstileValid) {
3139
3247
  return problems.badRequest(c, "Invalid captcha");
3140
3248
  }
3249
+ const invited = await verifyInvitation(invitations, {
3250
+ token: invitationToken,
3251
+ email,
3252
+ db: database,
3253
+ env: authEnv
3254
+ });
3255
+ if (invitationToken && invitations && !invited && signup !== "invitation") {
3256
+ return problems.badRequest(c, INVITATION_INVALID);
3257
+ }
3258
+ if (signup === "invitation" && !invited) {
3259
+ logSecurityEvent("signup_refused_no_invitation", "medium", { email: email.toLowerCase() });
3260
+ return problems.forbidden(c, invitationToken ? INVITATION_INVALID : INVITATION_ONLY);
3261
+ }
3262
+ const invitedVerified = invited !== null;
3141
3263
  const existingUser = await database.query.users.findFirst({
3142
- where: eq8(schema.users.email, email.toLowerCase())
3264
+ where: eq9(schema.users.email, email.toLowerCase())
3143
3265
  });
3144
3266
  if (existingUser) {
3145
3267
  return problems.conflict(c, "Email already registered");
@@ -3149,22 +3271,6 @@ var signupHandler = async (c) => {
3149
3271
  return problems.badRequest(c, passwordValidation.error || "Invalid password");
3150
3272
  }
3151
3273
  const hashedPassword = await hashPassword(password, env.PASSWORD_PEPPER_V1);
3152
- let invitedVerified = false;
3153
- if (invitationToken && invitations) {
3154
- const match = await invitations.verify({
3155
- token: invitationToken,
3156
- email: email.toLowerCase(),
3157
- db: database,
3158
- env
3159
- });
3160
- if (!match || match.email.toLowerCase() !== email.toLowerCase()) {
3161
- return problems.badRequest(
3162
- c,
3163
- "This invitation is invalid, expired, or for a different email address"
3164
- );
3165
- }
3166
- invitedVerified = true;
3167
- }
3168
3274
  const [newUser] = await database.insert(schema.users).values({
3169
3275
  email: email.toLowerCase(),
3170
3276
  name: name || null,
@@ -3197,17 +3303,14 @@ var signupHandler = async (c) => {
3197
3303
  schema.users
3198
3304
  );
3199
3305
  }
3200
- if (invitedVerified && invitationToken && invitations?.onAccepted) {
3201
- try {
3202
- await invitations.onAccepted({
3203
- token: invitationToken,
3204
- user: { id: newUser.id, email: newUser.email, name: newUser.name },
3205
- db: database,
3206
- env
3207
- });
3208
- } catch (err) {
3209
- logError(err, { context: "signup_invitation_onAccepted_failed", userId: newUser.id });
3210
- }
3306
+ if (invited) {
3307
+ await acceptInvitation(invitations, {
3308
+ token: invited,
3309
+ user: { id: newUser.id, email: newUser.email, name: newUser.name },
3310
+ db: database,
3311
+ env: authEnv,
3312
+ context: "signup_invitation_onAccepted_failed"
3313
+ });
3211
3314
  }
3212
3315
  const fingerprint = await generateFingerprint(c.req.raw);
3213
3316
  const ipAddress = getClientIp(c.req.raw);
@@ -3240,24 +3343,34 @@ var signupMiddleware = [
3240
3343
 
3241
3344
  // src/routes/login.ts
3242
3345
  import { createRoute as createRoute2 } from "@hono/zod-openapi";
3243
- import { eq as eq10 } from "drizzle-orm";
3346
+ import { eq as eq11 } from "drizzle-orm";
3244
3347
  import { getCookie } from "hono/cookie";
3245
3348
 
3246
3349
  // src/routes/2fa/helpers.ts
3247
- import { eq as eq9, and as and3, isNull } from "drizzle-orm";
3350
+ import { eq as eq10, and as and4, isNull as isNull2 } from "drizzle-orm";
3248
3351
  async function getUserEnabled2faMethods(database, userId, user2faMethodsTable) {
3249
3352
  return database.select({
3250
3353
  method: user2faMethodsTable.method,
3251
3354
  isPrimary: user2faMethodsTable.isPrimary,
3252
3355
  totpSecret: user2faMethodsTable.totpSecret,
3253
3356
  lastTotpCounter: user2faMethodsTable.lastTotpCounter
3254
- }).from(user2faMethodsTable).where(eq9(user2faMethodsTable.userId, userId));
3357
+ }).from(user2faMethodsTable).where(eq10(user2faMethodsTable.userId, userId));
3255
3358
  }
3256
3359
  function generateEmailOtp() {
3257
3360
  const bytes = crypto.getRandomValues(new Uint8Array(4));
3258
3361
  const num = (bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0;
3259
3362
  return String(num % 1e6).padStart(6, "0");
3260
3363
  }
3364
+ var EMAIL_CODE_TTL_SECONDS = 300;
3365
+ async function sendEmailChallengeCode(env, database, usersTable, user, emailBranding) {
3366
+ const code = generateEmailOtp();
3367
+ await env.OAUTH_STATES.put(`email_2fa_challenge:${user.id}`, await hashToken(code), {
3368
+ expirationTtl: EMAIL_CODE_TTL_SECONDS
3369
+ });
3370
+ const emailService = new EmailService(env, void 0, emailBranding);
3371
+ const firstName = user.name?.split(" ")[0] || null;
3372
+ await emailService.send2faCodeEmail({ email: user.email, firstName, code }, database, usersTable);
3373
+ }
3261
3374
  async function verifyAny2faCode(database, env, userId, code, method, tables2) {
3262
3375
  const { user2faMethods: user2faMethodsTable, userBackupCodes: userBackupCodesTable } = tables2;
3263
3376
  if (method === "totp") {
@@ -3275,7 +3388,7 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
3275
3388
  const secret = await decryptTotpSecret(totpMethod.totpSecret, env.TOTP_ENCRYPTION_KEY);
3276
3389
  const result = verifyTotpCode(secret, code, totpMethod.lastTotpCounter);
3277
3390
  if (result.valid) {
3278
- await database.update(user2faMethodsTable).set({ lastTotpCounter: result.counter }).where(and3(eq9(user2faMethodsTable.userId, userId), eq9(user2faMethodsTable.method, "totp")));
3391
+ await database.update(user2faMethodsTable).set({ lastTotpCounter: result.counter }).where(and4(eq10(user2faMethodsTable.userId, userId), eq10(user2faMethodsTable.method, "totp")));
3279
3392
  return { valid: true };
3280
3393
  }
3281
3394
  return { valid: false };
@@ -3287,18 +3400,18 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
3287
3400
  const kvKey = `email_2fa_challenge:${userId}`;
3288
3401
  const storedHash = await env.OAUTH_STATES.get(kvKey);
3289
3402
  if (!storedHash) return { valid: false };
3290
- const codeHash = await hashToken(code);
3291
- if (codeHash === storedHash) {
3403
+ const codeHash2 = await hashToken(code);
3404
+ if (codeHash2 === storedHash) {
3292
3405
  await env.OAUTH_STATES.delete(kvKey);
3293
3406
  return { valid: true };
3294
3407
  }
3295
3408
  return { valid: false };
3296
3409
  }
3297
3410
  if (method === "backup") {
3298
- const backupCodes = await database.select({ id: userBackupCodesTable.id, codeHash: userBackupCodesTable.codeHash }).from(userBackupCodesTable).where(and3(eq9(userBackupCodesTable.userId, userId), isNull(userBackupCodesTable.usedAt)));
3411
+ const backupCodes = await database.select({ id: userBackupCodesTable.id, codeHash: userBackupCodesTable.codeHash }).from(userBackupCodesTable).where(and4(eq10(userBackupCodesTable.userId, userId), isNull2(userBackupCodesTable.usedAt)));
3299
3412
  for (const bc of backupCodes) {
3300
3413
  if (await verifyBackupCode(code, bc.codeHash)) {
3301
- await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where(eq9(userBackupCodesTable.id, bc.id));
3414
+ await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where(eq10(userBackupCodesTable.id, bc.id));
3302
3415
  logSecurityEvent("2fa_backup_code_used", "medium", { userId, codeId: bc.id });
3303
3416
  return { valid: true };
3304
3417
  }
@@ -3342,7 +3455,7 @@ var loginRoute = createRoute2({
3342
3455
  });
3343
3456
  var loginHandler = async (c) => {
3344
3457
  const { email, password, turnstileToken } = c.req.valid("json");
3345
- const { db: database, schema } = getAuthContext(c);
3458
+ const { db: database, schema, emailBranding } = getAuthContext(c);
3346
3459
  const env = c.env;
3347
3460
  const turnstileValid = await verifyTurnstileToken(
3348
3461
  turnstileToken,
@@ -3354,7 +3467,7 @@ var loginHandler = async (c) => {
3354
3467
  return problems.badRequest(c, "Invalid captcha");
3355
3468
  }
3356
3469
  const user = await database.query.users.findFirst({
3357
- where: eq10(schema.users.email, email.toLowerCase())
3470
+ where: eq11(schema.users.email, email.toLowerCase())
3358
3471
  });
3359
3472
  if (!user) {
3360
3473
  return problems.unauthorized(c, "Invalid email or password");
@@ -3427,6 +3540,10 @@ var loginHandler = async (c) => {
3427
3540
  };
3428
3541
  await storeChallengeToken(env.OAUTH_STATES, challengeToken, challengePayload);
3429
3542
  setChallengeCookie(c, env, challengeToken);
3543
+ const emailMethod = enabled2faMethods.find((m) => m.method === "email");
3544
+ if (emailMethod && (emailMethod.isPrimary || enabled2faMethods.length === 1)) {
3545
+ await sendEmailChallengeCode(env, database, schema.users, user, emailBranding);
3546
+ }
3430
3547
  return c.json({
3431
3548
  requires2fa: true,
3432
3549
  methods: enabled2faMethods.map((m) => m.method)
@@ -3459,7 +3576,7 @@ var loginMiddleware = [
3459
3576
 
3460
3577
  // src/routes/logout.ts
3461
3578
  import { createRoute as createRoute3 } from "@hono/zod-openapi";
3462
- import { eq as eq11 } from "drizzle-orm";
3579
+ import { eq as eq12 } from "drizzle-orm";
3463
3580
  var logoutRoute = createRoute3({
3464
3581
  method: "post",
3465
3582
  path: "/logout",
@@ -3482,7 +3599,7 @@ var logoutHandler = async (c) => {
3482
3599
  const sessionIdMatch = cookieHeader.match(cookiePattern);
3483
3600
  if (sessionIdMatch) {
3484
3601
  const sessionId = sessionIdMatch[1];
3485
- await database.delete(schema.sessions).where(eq11(schema.sessions.id, sessionId));
3602
+ await database.delete(schema.sessions).where(eq12(schema.sessions.id, sessionId));
3486
3603
  }
3487
3604
  }
3488
3605
  c.header("Set-Cookie", clearSessionCookie(c.env), { append: true });
@@ -3491,7 +3608,7 @@ var logoutHandler = async (c) => {
3491
3608
 
3492
3609
  // src/routes/me.ts
3493
3610
  import { createRoute as createRoute4 } from "@hono/zod-openapi";
3494
- import { eq as eq12 } from "drizzle-orm";
3611
+ import { eq as eq13 } from "drizzle-orm";
3495
3612
  var meRoute = createRoute4({
3496
3613
  method: "get",
3497
3614
  path: "/me",
@@ -3519,7 +3636,7 @@ var meHandler = async (c) => {
3519
3636
  if (!userId) return problems.unauthorized(c, "Not authenticated");
3520
3637
  const { db: database, schema } = getAuthContext(c);
3521
3638
  const user = await database.query.users.findFirst({
3522
- where: eq12(schema.users.id, userId)
3639
+ where: eq13(schema.users.id, userId)
3523
3640
  });
3524
3641
  if (!user) {
3525
3642
  return problems.notFound(c, "User not found");
@@ -3545,7 +3662,7 @@ var meMiddleware = [requireAuth];
3545
3662
 
3546
3663
  // src/routes/verify-email.ts
3547
3664
  import { createRoute as createRoute5 } from "@hono/zod-openapi";
3548
- import { eq as eq13 } from "drizzle-orm";
3665
+ import { eq as eq14 } from "drizzle-orm";
3549
3666
  var verifyEmailRoute = createRoute5({
3550
3667
  method: "post",
3551
3668
  path: "/verify-email",
@@ -3574,17 +3691,17 @@ var verifyEmailHandler = async (c) => {
3574
3691
  const { db: database, schema } = getAuthContext(c);
3575
3692
  const tokenHash = await hashToken(token);
3576
3693
  const verificationToken = await database.query.emailVerificationTokens.findFirst({
3577
- where: eq13(schema.emailVerificationTokens.tokenHash, tokenHash)
3694
+ where: eq14(schema.emailVerificationTokens.tokenHash, tokenHash)
3578
3695
  });
3579
3696
  if (!verificationToken) {
3580
3697
  return problems.badRequest(c, "Invalid or expired verification token");
3581
3698
  }
3582
3699
  if (verificationToken.expiresAt < Date.now()) {
3583
- await database.delete(schema.emailVerificationTokens).where(eq13(schema.emailVerificationTokens.tokenHash, tokenHash));
3700
+ await database.delete(schema.emailVerificationTokens).where(eq14(schema.emailVerificationTokens.tokenHash, tokenHash));
3584
3701
  return problems.badRequest(c, "Verification token has expired");
3585
3702
  }
3586
- await database.update(schema.users).set({ emailVerified: true }).where(eq13(schema.users.id, verificationToken.userId));
3587
- await database.delete(schema.emailVerificationTokens).where(eq13(schema.emailVerificationTokens.tokenHash, tokenHash));
3703
+ await database.update(schema.users).set({ emailVerified: true }).where(eq14(schema.users.id, verificationToken.userId));
3704
+ await database.delete(schema.emailVerificationTokens).where(eq14(schema.emailVerificationTokens.tokenHash, tokenHash));
3588
3705
  logSecurityEvent("email_verified", "low", { userId: verificationToken.userId });
3589
3706
  return c.json({ success: true, redirect: "/" });
3590
3707
  };
@@ -3593,18 +3710,18 @@ var verifyEmailHandler = async (c) => {
3593
3710
  import { createRoute as createRoute6 } from "@hono/zod-openapi";
3594
3711
 
3595
3712
  // src/core/password-reset.ts
3596
- import { eq as eq14, and as and4, gt as gt3, lt, sql as sql3 } from "drizzle-orm";
3713
+ import { eq as eq15, and as and5, gt as gt4, lt as lt2, sql as sql4 } from "drizzle-orm";
3597
3714
  var TOKEN_EXPIRATION_MS = AUTH_DEFAULTS.PASSWORD_RESET_TTL_MINUTES * 60 * 1e3;
3598
3715
  async function createPasswordResetToken(db, tables2, email) {
3599
3716
  const { users, passwordResetTokens } = tables2;
3600
- const [user] = await db.select({ id: users.id, emailVerified: users.emailVerified }).from(users).where(eq14(users.email, email.toLowerCase())).limit(1);
3717
+ const [user] = await db.select({ id: users.id, emailVerified: users.emailVerified }).from(users).where(eq15(users.email, email.toLowerCase())).limit(1);
3601
3718
  if (!user) {
3602
3719
  return null;
3603
3720
  }
3604
3721
  if (!user.emailVerified) {
3605
3722
  return null;
3606
3723
  }
3607
- await db.delete(passwordResetTokens).where(eq14(passwordResetTokens.userId, user.id));
3724
+ await db.delete(passwordResetTokens).where(eq15(passwordResetTokens.userId, user.id));
3608
3725
  const token = generateSecureToken(64);
3609
3726
  const tokenHash = await hashToken(token);
3610
3727
  const expiresAt = Date.now() + TOKEN_EXPIRATION_MS;
@@ -3626,10 +3743,10 @@ async function validateResetToken(db, tables2, token) {
3626
3743
  used: passwordResetTokens.used,
3627
3744
  expiresAt: passwordResetTokens.expiresAt
3628
3745
  }).from(passwordResetTokens).where(
3629
- and4(
3630
- eq14(passwordResetTokens.tokenHash, tokenHash),
3631
- eq14(passwordResetTokens.used, false),
3632
- gt3(passwordResetTokens.expiresAt, Date.now())
3746
+ and5(
3747
+ eq15(passwordResetTokens.tokenHash, tokenHash),
3748
+ eq15(passwordResetTokens.used, false),
3749
+ gt4(passwordResetTokens.expiresAt, Date.now())
3633
3750
  )
3634
3751
  ).limit(1);
3635
3752
  if (!resetToken) {
@@ -3650,11 +3767,11 @@ async function resetPassword(db, tables2, token, newPassword, pepper, pepperKid
3650
3767
  await db.update(users).set({
3651
3768
  hashedPassword,
3652
3769
  pepperKid,
3653
- sessionVersion: sql3`${users.sessionVersion} + 1`
3654
- }).where(eq14(users.id, userId));
3770
+ sessionVersion: sql4`${users.sessionVersion} + 1`
3771
+ }).where(eq15(users.id, userId));
3655
3772
  const tokenHash = await hashToken(token);
3656
- await db.update(passwordResetTokens).set({ used: true }).where(eq14(passwordResetTokens.tokenHash, tokenHash));
3657
- await db.delete(sessions).where(eq14(sessions.userId, userId));
3773
+ await db.update(passwordResetTokens).set({ used: true }).where(eq15(passwordResetTokens.tokenHash, tokenHash));
3774
+ await db.delete(sessions).where(eq15(sessions.userId, userId));
3658
3775
  logger_default.info("Password reset successful", { userId });
3659
3776
  return { success: true };
3660
3777
  }
@@ -3802,14 +3919,14 @@ var resetPasswordMiddleware = [
3802
3919
  import { createRoute as createRoute8 } from "@hono/zod-openapi";
3803
3920
 
3804
3921
  // src/core/change-password.ts
3805
- import { eq as eq15, and as and5, ne } from "drizzle-orm";
3922
+ import { eq as eq16, and as and6, ne } from "drizzle-orm";
3806
3923
  async function changePassword(params) {
3807
3924
  const { userId, currentPassword, newPassword, currentSessionId, pepper, db, tables: tables2 } = params;
3808
3925
  const { users, sessions } = tables2;
3809
3926
  const [user] = await db.select({
3810
3927
  id: users.id,
3811
3928
  hashedPassword: users.hashedPassword
3812
- }).from(users).where(eq15(users.id, userId)).limit(1);
3929
+ }).from(users).where(eq16(users.id, userId)).limit(1);
3813
3930
  if (!user || !user.hashedPassword) {
3814
3931
  return { success: false, error: "User not found" };
3815
3932
  }
@@ -3825,8 +3942,8 @@ async function changePassword(params) {
3825
3942
  await db.update(users).set({
3826
3943
  hashedPassword: newHashedPassword,
3827
3944
  updatedAt: /* @__PURE__ */ new Date()
3828
- }).where(eq15(users.id, userId));
3829
- await db.delete(sessions).where(and5(eq15(sessions.userId, userId), ne(sessions.id, currentSessionId)));
3945
+ }).where(eq16(users.id, userId));
3946
+ await db.delete(sessions).where(and6(eq16(sessions.userId, userId), ne(sessions.id, currentSessionId)));
3830
3947
  logSecurityEvent("password_changed", "low", {
3831
3948
  userId
3832
3949
  });
@@ -4099,7 +4216,7 @@ var cancelEmailChangeHandler = async (c) => {
4099
4216
  // src/routes/delete-account.ts
4100
4217
  import { createRoute as createRoute13 } from "@hono/zod-openapi";
4101
4218
  import { z as z5 } from "@hono/zod-openapi";
4102
- import { eq as eq16 } from "drizzle-orm";
4219
+ import { eq as eq17 } from "drizzle-orm";
4103
4220
  var deleteAccountRequestSchema = z5.object({
4104
4221
  password: z5.string().min(1).openapi({ example: "CurrentPass123!" })
4105
4222
  }).openapi("DeleteAccountRequest");
@@ -4147,7 +4264,7 @@ var deleteAccountHandler = async (c) => {
4147
4264
  const [user] = await database.select({
4148
4265
  id: schema.users.id,
4149
4266
  hashedPassword: schema.users.hashedPassword
4150
- }).from(schema.users).where(eq16(schema.users.id, userId)).limit(1);
4267
+ }).from(schema.users).where(eq17(schema.users.id, userId)).limit(1);
4151
4268
  if (!user || !user.hashedPassword) {
4152
4269
  return problems.unauthorized(c, "Invalid credentials");
4153
4270
  }
@@ -4162,8 +4279,8 @@ var deleteAccountHandler = async (c) => {
4162
4279
  }
4163
4280
  const now = /* @__PURE__ */ new Date();
4164
4281
  const purgeAt = new Date(now.getTime() + PURGE_WINDOW_DAYS * 24 * 60 * 60 * 1e3);
4165
- await database.update(schema.users).set({ deletedAt: now, scheduledPurgeAt: purgeAt, updatedAt: now }).where(eq16(schema.users.id, userId));
4166
- await database.delete(schema.sessions).where(eq16(schema.sessions.userId, userId));
4282
+ await database.update(schema.users).set({ deletedAt: now, scheduledPurgeAt: purgeAt, updatedAt: now }).where(eq17(schema.users.id, userId));
4283
+ await database.delete(schema.sessions).where(eq17(schema.sessions.userId, userId));
4167
4284
  logSecurityEvent("account_deletion_requested", "high", { userId });
4168
4285
  return c.json(
4169
4286
  {
@@ -4184,7 +4301,7 @@ var deleteAccountMiddleware = [requireAuth];
4184
4301
  // src/routes/refresh.ts
4185
4302
  import { createRoute as createRoute14 } from "@hono/zod-openapi";
4186
4303
  import { z as z6 } from "@hono/zod-openapi";
4187
- import { eq as eq17, and as and6, gt as gt4 } from "drizzle-orm";
4304
+ import { eq as eq18, and as and7, gt as gt5 } from "drizzle-orm";
4188
4305
  var refreshRequestSchema = z6.object({
4189
4306
  refreshToken: z6.string().min(1).openapi({ example: "abc123..." })
4190
4307
  }).openapi("RefreshRequest");
@@ -4224,7 +4341,7 @@ var refreshHandler = async (c) => {
4224
4341
  expiresAt: schema.sessions.expiresAt,
4225
4342
  fingerprint: schema.sessions.fingerprint,
4226
4343
  ipAddress: schema.sessions.ipAddress
4227
- }).from(schema.sessions).where(and6(eq17(schema.sessions.id, refreshToken), gt4(schema.sessions.expiresAt, Date.now()))).limit(1);
4344
+ }).from(schema.sessions).where(and7(eq18(schema.sessions.id, refreshToken), gt5(schema.sessions.expiresAt, Date.now()))).limit(1);
4228
4345
  if (!session) {
4229
4346
  return problems.unauthorized(c, "Invalid or expired refresh token");
4230
4347
  }
@@ -4247,7 +4364,7 @@ var refreshMiddleware = [];
4247
4364
  // src/routes/resend-verification.ts
4248
4365
  import { createRoute as createRoute15 } from "@hono/zod-openapi";
4249
4366
  import { z as z7 } from "@hono/zod-openapi";
4250
- import { eq as eq18 } from "drizzle-orm";
4367
+ import { eq as eq19 } from "drizzle-orm";
4251
4368
  var resendVerificationResponseSchema = z7.object({
4252
4369
  success: z7.boolean().openapi({ example: true }),
4253
4370
  message: z7.string().openapi({ example: "Verification email sent" })
@@ -4292,14 +4409,14 @@ var resendVerificationHandler = async (c) => {
4292
4409
  email: schema.users.email,
4293
4410
  name: schema.users.name,
4294
4411
  emailVerified: schema.users.emailVerified
4295
- }).from(schema.users).where(eq18(schema.users.id, userId)).limit(1);
4412
+ }).from(schema.users).where(eq19(schema.users.id, userId)).limit(1);
4296
4413
  if (!user) {
4297
4414
  return problems.unauthorized(c, "User not found");
4298
4415
  }
4299
4416
  if (user.emailVerified) {
4300
4417
  return problems.badRequest(c, "Email is already verified");
4301
4418
  }
4302
- await db.delete(schema.emailVerificationTokens).where(eq18(schema.emailVerificationTokens.userId, userId));
4419
+ await db.delete(schema.emailVerificationTokens).where(eq19(schema.emailVerificationTokens.userId, userId));
4303
4420
  const emailToken = generateSecureToken(32);
4304
4421
  const tokenHash = await hashToken(emailToken);
4305
4422
  const expiresAt = Date.now() + 24 * 60 * 60 * 1e3;
@@ -4344,7 +4461,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
4344
4461
 
4345
4462
  // src/routes/2fa/status.ts
4346
4463
  import { createRoute as createRoute16 } from "@hono/zod-openapi";
4347
- import { eq as eq19, and as and7, isNull as isNull2 } from "drizzle-orm";
4464
+ import { eq as eq20, and as and8, isNull as isNull3 } from "drizzle-orm";
4348
4465
 
4349
4466
  // src/routes/2fa/schemas.ts
4350
4467
  import { z as z8 } from "@hono/zod-openapi";
@@ -4505,7 +4622,7 @@ var statusHandler = async (c) => {
4505
4622
  if (!userId) return problems.unauthorized(c);
4506
4623
  const { db, schema } = getAuthContext(c);
4507
4624
  const methods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
4508
- const backupCodes = await db.select({ id: schema.userBackupCodes.id }).from(schema.userBackupCodes).where(and7(eq19(schema.userBackupCodes.userId, userId), isNull2(schema.userBackupCodes.usedAt)));
4625
+ const backupCodes = await db.select({ id: schema.userBackupCodes.id }).from(schema.userBackupCodes).where(and8(eq20(schema.userBackupCodes.userId, userId), isNull3(schema.userBackupCodes.usedAt)));
4509
4626
  c.header("Cache-Control", "private, max-age=60");
4510
4627
  c.header("Vary", "Cookie");
4511
4628
  return c.json({
@@ -4517,7 +4634,7 @@ var statusHandler = async (c) => {
4517
4634
 
4518
4635
  // src/routes/2fa/totp-setup.ts
4519
4636
  import { createRoute as createRoute17 } from "@hono/zod-openapi";
4520
- import { eq as eq20, and as and8 } from "drizzle-orm";
4637
+ import { eq as eq21, and as and9 } from "drizzle-orm";
4521
4638
  var totpSetupRoute = createRoute17({
4522
4639
  method: "post",
4523
4640
  path: "/totp/setup",
@@ -4547,11 +4664,11 @@ var totpSetupHandler = async (c) => {
4547
4664
  const userId = c.get("userId");
4548
4665
  if (!userId) return problems.unauthorized(c);
4549
4666
  const { db, schema, env } = getAuthContext(c);
4550
- const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(and8(eq20(schema.user2faMethods.userId, userId), eq20(schema.user2faMethods.method, "totp"))).limit(1);
4667
+ const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(and9(eq21(schema.user2faMethods.userId, userId), eq21(schema.user2faMethods.method, "totp"))).limit(1);
4551
4668
  if (existing.length > 0) {
4552
4669
  return problems.badRequest(c, "TOTP already enabled");
4553
4670
  }
4554
- const [user] = await db.select({ email: schema.users.email }).from(schema.users).where(eq20(schema.users.id, userId)).limit(1);
4671
+ const [user] = await db.select({ email: schema.users.email }).from(schema.users).where(eq21(schema.users.id, userId)).limit(1);
4555
4672
  if (!user) return problems.notFound(c, "User not found");
4556
4673
  const secret = generateTotpSecret();
4557
4674
  const qrCodeUri = generateQrCodeUri(secret, user.email);
@@ -4562,7 +4679,7 @@ var totpSetupHandler = async (c) => {
4562
4679
 
4563
4680
  // src/routes/2fa/totp-verify.ts
4564
4681
  import { createRoute as createRoute18 } from "@hono/zod-openapi";
4565
- import { eq as eq21 } from "drizzle-orm";
4682
+ import { eq as eq22 } from "drizzle-orm";
4566
4683
  var totpVerifyRoute = createRoute18({
4567
4684
  method: "post",
4568
4685
  path: "/totp/verify",
@@ -4608,7 +4725,7 @@ var totpVerifyHandler = async (c) => {
4608
4725
  logSecurityEvent("2fa_totp_setup_failed", "medium", { userId });
4609
4726
  return problems.badRequest(c, "Invalid code");
4610
4727
  }
4611
- const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq21(schema.users.id, userId)).limit(1);
4728
+ const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq22(schema.users.id, userId)).limit(1);
4612
4729
  const encryptionKey = env.TOTP_ENCRYPTION_KEY;
4613
4730
  if (!encryptionKey) {
4614
4731
  logger_default.error("TOTP_ENCRYPTION_KEY not configured");
@@ -4656,7 +4773,7 @@ var totpVerifyHandler = async (c) => {
4656
4773
 
4657
4774
  // src/routes/2fa/totp-disable.ts
4658
4775
  import { createRoute as createRoute19 } from "@hono/zod-openapi";
4659
- import { eq as eq22, and as and9 } from "drizzle-orm";
4776
+ import { eq as eq23, and as and10 } from "drizzle-orm";
4660
4777
  var totpDisableRoute = createRoute19({
4661
4778
  method: "post",
4662
4779
  path: "/totp/disable",
@@ -4699,13 +4816,13 @@ var totpDisableHandler = async (c) => {
4699
4816
  logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "totp" });
4700
4817
  return problems.badRequest(c, "Invalid verification code");
4701
4818
  }
4702
- await db.delete(schema.user2faMethods).where(and9(eq22(schema.user2faMethods.userId, userId), eq22(schema.user2faMethods.method, "totp")));
4819
+ await db.delete(schema.user2faMethods).where(and10(eq23(schema.user2faMethods.userId, userId), eq23(schema.user2faMethods.method, "totp")));
4703
4820
  const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
4704
4821
  if (remaining.length === 0) {
4705
- await db.delete(schema.userBackupCodes).where(eq22(schema.userBackupCodes.userId, userId));
4706
- await db.delete(schema.userTrustedDevices).where(eq22(schema.userTrustedDevices.userId, userId));
4822
+ await db.delete(schema.userBackupCodes).where(eq23(schema.userBackupCodes.userId, userId));
4823
+ await db.delete(schema.userTrustedDevices).where(eq23(schema.userTrustedDevices.userId, userId));
4707
4824
  await invalidateAllUserSessions(db, { sessions: schema.sessions, users: schema.users }, userId);
4708
- const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq22(schema.users.id, userId)).limit(1);
4825
+ const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq23(schema.users.id, userId)).limit(1);
4709
4826
  if (user) {
4710
4827
  const emailService = new EmailService(env, void 0, emailBranding);
4711
4828
  const firstName = user.name?.split(" ")[0] || null;
@@ -4724,7 +4841,7 @@ var totpDisableHandler = async (c) => {
4724
4841
 
4725
4842
  // src/routes/2fa/email-setup.ts
4726
4843
  import { createRoute as createRoute20 } from "@hono/zod-openapi";
4727
- import { eq as eq23, and as and10 } from "drizzle-orm";
4844
+ import { eq as eq24, and as and11 } from "drizzle-orm";
4728
4845
  var emailSetupRoute = createRoute20({
4729
4846
  method: "post",
4730
4847
  path: "/email/setup",
@@ -4754,15 +4871,15 @@ var emailSetupHandler = async (c) => {
4754
4871
  const userId = c.get("userId");
4755
4872
  if (!userId) return problems.unauthorized(c);
4756
4873
  const { db, schema, env, emailBranding } = getAuthContext(c);
4757
- const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(and10(eq23(schema.user2faMethods.userId, userId), eq23(schema.user2faMethods.method, "email"))).limit(1);
4874
+ const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(and11(eq24(schema.user2faMethods.userId, userId), eq24(schema.user2faMethods.method, "email"))).limit(1);
4758
4875
  if (existing.length > 0) {
4759
4876
  return problems.badRequest(c, "Email 2FA already enabled");
4760
4877
  }
4761
- const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq23(schema.users.id, userId)).limit(1);
4878
+ const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq24(schema.users.id, userId)).limit(1);
4762
4879
  if (!user) return problems.notFound(c, "User not found");
4763
4880
  const code = generateEmailOtp();
4764
- const codeHash = await hashToken(code);
4765
- await env.OAUTH_STATES.put(`email_2fa_setup:${userId}`, codeHash, { expirationTtl: 300 });
4881
+ const codeHash2 = await hashToken(code);
4882
+ await env.OAUTH_STATES.put(`email_2fa_setup:${userId}`, codeHash2, { expirationTtl: 300 });
4766
4883
  const emailService = new EmailService(env, void 0, emailBranding);
4767
4884
  const firstName = user.name?.split(" ")[0] || null;
4768
4885
  await emailService.send2faCodeEmail({ email: user.email, firstName, code }, db, schema.users);
@@ -4772,7 +4889,7 @@ var emailSetupHandler = async (c) => {
4772
4889
 
4773
4890
  // src/routes/2fa/email-verify.ts
4774
4891
  import { createRoute as createRoute21 } from "@hono/zod-openapi";
4775
- import { eq as eq24 } from "drizzle-orm";
4892
+ import { eq as eq25 } from "drizzle-orm";
4776
4893
  var emailVerifyRoute = createRoute21({
4777
4894
  method: "post",
4778
4895
  path: "/email/verify",
@@ -4809,12 +4926,12 @@ var emailVerifyHandler = async (c) => {
4809
4926
  if (!storedHash) {
4810
4927
  return problems.badRequest(c, "Code expired. Please request a new one.");
4811
4928
  }
4812
- const codeHash = await hashToken(code);
4813
- if (codeHash !== storedHash) {
4929
+ const codeHash2 = await hashToken(code);
4930
+ if (codeHash2 !== storedHash) {
4814
4931
  logSecurityEvent("2fa_email_setup_failed", "medium", { userId });
4815
4932
  return problems.badRequest(c, "Invalid code");
4816
4933
  }
4817
- const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq24(schema.users.id, userId)).limit(1);
4934
+ const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq25(schema.users.id, userId)).limit(1);
4818
4935
  const now = Date.now();
4819
4936
  const existingMethods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
4820
4937
  const isFirstMethod = existingMethods.length === 0;
@@ -4856,7 +4973,7 @@ var emailVerifyHandler = async (c) => {
4856
4973
 
4857
4974
  // src/routes/2fa/email-send-code.ts
4858
4975
  import { createRoute as createRoute22 } from "@hono/zod-openapi";
4859
- import { eq as eq25, and as and11 } from "drizzle-orm";
4976
+ import { eq as eq26, and as and12 } from "drizzle-orm";
4860
4977
  var emailSendCodeRoute = createRoute22({
4861
4978
  method: "post",
4862
4979
  path: "/email/send-code",
@@ -4886,15 +5003,15 @@ var emailSendCodeHandler = async (c) => {
4886
5003
  const userId = c.get("userId");
4887
5004
  if (!userId) return problems.unauthorized(c);
4888
5005
  const { db, schema, env, emailBranding } = getAuthContext(c);
4889
- const [emailMethod] = await db.select().from(schema.user2faMethods).where(and11(eq25(schema.user2faMethods.userId, userId), eq25(schema.user2faMethods.method, "email"))).limit(1);
5006
+ const [emailMethod] = await db.select().from(schema.user2faMethods).where(and12(eq26(schema.user2faMethods.userId, userId), eq26(schema.user2faMethods.method, "email"))).limit(1);
4890
5007
  if (!emailMethod) {
4891
5008
  return problems.badRequest(c, "Email 2FA not enabled");
4892
5009
  }
4893
- const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq25(schema.users.id, userId)).limit(1);
5010
+ const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq26(schema.users.id, userId)).limit(1);
4894
5011
  if (!user) return problems.notFound(c, "User not found");
4895
5012
  const code = generateEmailOtp();
4896
- const codeHash = await hashToken(code);
4897
- await env.OAUTH_STATES.put(`email_2fa_challenge:${userId}`, codeHash, {
5013
+ const codeHash2 = await hashToken(code);
5014
+ await env.OAUTH_STATES.put(`email_2fa_challenge:${userId}`, codeHash2, {
4898
5015
  expirationTtl: 300
4899
5016
  });
4900
5017
  const emailService = new EmailService(env, void 0, emailBranding);
@@ -4916,7 +5033,7 @@ var emailSendCodeHandler = async (c) => {
4916
5033
 
4917
5034
  // src/routes/2fa/email-disable.ts
4918
5035
  import { createRoute as createRoute23 } from "@hono/zod-openapi";
4919
- import { eq as eq26, and as and12 } from "drizzle-orm";
5036
+ import { eq as eq27, and as and13 } from "drizzle-orm";
4920
5037
  var emailDisableRoute = createRoute23({
4921
5038
  method: "post",
4922
5039
  path: "/email/disable",
@@ -4959,13 +5076,13 @@ var emailDisableHandler = async (c) => {
4959
5076
  logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "email" });
4960
5077
  return problems.badRequest(c, "Invalid verification code");
4961
5078
  }
4962
- await db.delete(schema.user2faMethods).where(and12(eq26(schema.user2faMethods.userId, userId), eq26(schema.user2faMethods.method, "email")));
5079
+ await db.delete(schema.user2faMethods).where(and13(eq27(schema.user2faMethods.userId, userId), eq27(schema.user2faMethods.method, "email")));
4963
5080
  const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
4964
5081
  if (remaining.length === 0) {
4965
- await db.delete(schema.userBackupCodes).where(eq26(schema.userBackupCodes.userId, userId));
4966
- await db.delete(schema.userTrustedDevices).where(eq26(schema.userTrustedDevices.userId, userId));
5082
+ await db.delete(schema.userBackupCodes).where(eq27(schema.userBackupCodes.userId, userId));
5083
+ await db.delete(schema.userTrustedDevices).where(eq27(schema.userTrustedDevices.userId, userId));
4967
5084
  await invalidateAllUserSessions(db, { sessions: schema.sessions, users: schema.users }, userId);
4968
- const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq26(schema.users.id, userId)).limit(1);
5085
+ const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq27(schema.users.id, userId)).limit(1);
4969
5086
  if (user) {
4970
5087
  const emailService = new EmailService(env, void 0, emailBranding);
4971
5088
  const firstName = user.name?.split(" ")[0] || null;
@@ -4984,7 +5101,7 @@ var emailDisableHandler = async (c) => {
4984
5101
 
4985
5102
  // src/routes/2fa/trusted-devices.ts
4986
5103
  import { createRoute as createRoute24, z as z9 } from "@hono/zod-openapi";
4987
- import { eq as eq27, and as and13, gt as gt5 } from "drizzle-orm";
5104
+ import { eq as eq28, and as and14, gt as gt6 } from "drizzle-orm";
4988
5105
  var trustedDevicesGetRoute = createRoute24({
4989
5106
  method: "get",
4990
5107
  path: "/trusted-devices",
@@ -5023,7 +5140,7 @@ var trustedDevicesGetHandler = async (c) => {
5023
5140
  ipAddress: schema.userTrustedDevices.ipAddress,
5024
5141
  lastUsedAt: schema.userTrustedDevices.lastUsedAt,
5025
5142
  createdAt: schema.userTrustedDevices.createdAt
5026
- }).from(schema.userTrustedDevices).where(and13(eq27(schema.userTrustedDevices.userId, userId), gt5(schema.userTrustedDevices.expiresAt, now)));
5143
+ }).from(schema.userTrustedDevices).where(and14(eq28(schema.userTrustedDevices.userId, userId), gt6(schema.userTrustedDevices.expiresAt, now)));
5027
5144
  c.header("Cache-Control", "private, max-age=60");
5028
5145
  c.header("Vary", "Cookie");
5029
5146
  return c.json({ devices });
@@ -5059,7 +5176,7 @@ var trustedDevicesDeleteHandler = async (c) => {
5059
5176
  if (!userId) return problems.unauthorized(c);
5060
5177
  const deviceId = c.req.param("id");
5061
5178
  const { db, schema } = getAuthContext(c);
5062
- const result = await db.delete(schema.userTrustedDevices).where(and13(eq27(schema.userTrustedDevices.id, deviceId), eq27(schema.userTrustedDevices.userId, userId))).returning({ id: schema.userTrustedDevices.id });
5179
+ const result = await db.delete(schema.userTrustedDevices).where(and14(eq28(schema.userTrustedDevices.id, deviceId), eq28(schema.userTrustedDevices.userId, userId))).returning({ id: schema.userTrustedDevices.id });
5063
5180
  if (result.length === 0) {
5064
5181
  return problems.notFound(c, "Device not found");
5065
5182
  }
@@ -5069,7 +5186,7 @@ var trustedDevicesDeleteHandler = async (c) => {
5069
5186
 
5070
5187
  // src/routes/2fa/backup-codes.ts
5071
5188
  import { createRoute as createRoute25 } from "@hono/zod-openapi";
5072
- import { eq as eq28 } from "drizzle-orm";
5189
+ import { eq as eq29 } from "drizzle-orm";
5073
5190
  var backupCodesRegenerateRoute = createRoute25({
5074
5191
  method: "post",
5075
5192
  path: "/backup-codes/regenerate",
@@ -5110,7 +5227,7 @@ var backupCodesRegenerateHandler = async (c) => {
5110
5227
  logSecurityEvent("2fa_backup_regenerate_failed", "medium", { userId });
5111
5228
  return problems.badRequest(c, "Invalid verification code");
5112
5229
  }
5113
- await db.delete(schema.userBackupCodes).where(eq28(schema.userBackupCodes.userId, userId));
5230
+ await db.delete(schema.userBackupCodes).where(eq29(schema.userBackupCodes.userId, userId));
5114
5231
  const codes = generateBackupCodes();
5115
5232
  const now = Date.now();
5116
5233
  const hashedCodes = await Promise.all(
@@ -5127,7 +5244,7 @@ var backupCodesRegenerateHandler = async (c) => {
5127
5244
 
5128
5245
  // src/routes/2fa/challenge.ts
5129
5246
  import { createRoute as createRoute26 } from "@hono/zod-openapi";
5130
- import { eq as eq29 } from "drizzle-orm";
5247
+ import { eq as eq30 } from "drizzle-orm";
5131
5248
  import { getCookie as getCookie2 } from "hono/cookie";
5132
5249
  var challengeRoute = createRoute26({
5133
5250
  method: "post",
@@ -5199,7 +5316,7 @@ var challengeHandler = async (c) => {
5199
5316
  }
5200
5317
  await deleteChallengeToken(c.env.OAUTH_STATES, challengeToken);
5201
5318
  clearChallengeCookie(c, c.env);
5202
- const [user] = await db.select().from(schema.users).where(eq29(schema.users.id, payload.userId)).limit(1);
5319
+ const [user] = await db.select().from(schema.users).where(eq30(schema.users.id, payload.userId)).limit(1);
5203
5320
  if (!user) {
5204
5321
  logger_default.error("User not found during 2FA challenge", { userId: payload.userId });
5205
5322
  return problems.notFound(c, "User not found");
@@ -5240,7 +5357,7 @@ var challengeHandler = async (c) => {
5240
5357
 
5241
5358
  // src/routes/2fa/challenge-resend.ts
5242
5359
  import { createRoute as createRoute27 } from "@hono/zod-openapi";
5243
- import { eq as eq30 } from "drizzle-orm";
5360
+ import { eq as eq31 } from "drizzle-orm";
5244
5361
  import { getCookie as getCookie3 } from "hono/cookie";
5245
5362
  var challengeResendRoute = createRoute27({
5246
5363
  method: "post",
@@ -5278,19 +5395,12 @@ var challengeResendHandler = async (c) => {
5278
5395
  if (!payload.methods.includes("email")) {
5279
5396
  return problems.badRequest(c, "Email verification not available");
5280
5397
  }
5281
- const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq30(schema.users.id, payload.userId)).limit(1);
5398
+ const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq31(schema.users.id, payload.userId)).limit(1);
5282
5399
  if (!user) {
5283
5400
  logger_default.error("User not found during 2FA resend", { userId: payload.userId });
5284
5401
  return problems.notFound(c, "User not found");
5285
5402
  }
5286
- const code = generateEmailOtp();
5287
- const codeHash = await hashToken(code);
5288
- await c.env.OAUTH_STATES.put(`email_2fa_challenge:${payload.userId}`, codeHash, {
5289
- expirationTtl: 300
5290
- });
5291
- const emailService = new EmailService(c.env, void 0, emailBranding);
5292
- const firstName = user.name?.split(" ")[0] || null;
5293
- await emailService.send2faCodeEmail({ email: user.email, firstName, code }, db, schema.users);
5403
+ await sendEmailChallengeCode(c.env, db, schema.users, { id: payload.userId, ...user }, emailBranding);
5294
5404
  logger_default.info("Email 2FA code resent", { userId: payload.userId });
5295
5405
  return c.json({ success: true });
5296
5406
  };
@@ -5516,7 +5626,7 @@ var authorizeHandler = async (c) => {
5516
5626
 
5517
5627
  // src/routes/oauth/callback.ts
5518
5628
  import { createRoute as createRoute29 } from "@hono/zod-openapi";
5519
- import { eq as eq31 } from "drizzle-orm";
5629
+ import { eq as eq32 } from "drizzle-orm";
5520
5630
  var callbackRoute = createRoute29({
5521
5631
  method: "get",
5522
5632
  path: "/{provider}/callback",
@@ -5642,10 +5752,24 @@ var callbackHandler = async (c) => {
5642
5752
  logger_default.warn("No user ID returned from OAuth provider", { provider });
5643
5753
  return c.redirect(`${appUrl}/login?error=oauth_failed`);
5644
5754
  }
5645
- const { db, schema } = getAuthContext(c);
5646
- const [existingUser] = await db.select({ id: schema.users.id, email: schema.users.email }).from(schema.users).where(eq31(schema.users.email, email)).limit(1);
5755
+ const { db, schema, env, invitations, signup } = getAuthContext(c);
5756
+ const [existingUser] = await db.select({ id: schema.users.id, email: schema.users.email }).from(schema.users).where(eq32(schema.users.email, email)).limit(1);
5647
5757
  let authUser = existingUser;
5648
5758
  if (!authUser) {
5759
+ const invited = await verifyInvitation(invitations, {
5760
+ token: invitationToken,
5761
+ email,
5762
+ db,
5763
+ env
5764
+ });
5765
+ if (signup === "invitation" && !invited) {
5766
+ logSecurityEvent("oauth_signup_refused_no_invitation", "medium", {
5767
+ email,
5768
+ provider,
5769
+ hadInvitation: !!invitationToken
5770
+ });
5771
+ return c.redirect(`${appUrl}/login?error=invitation_required`);
5772
+ }
5649
5773
  const [createdUser] = await db.insert(schema.users).values({
5650
5774
  email,
5651
5775
  name: name || null,
@@ -5656,6 +5780,15 @@ var callbackHandler = async (c) => {
5656
5780
  activatedAt: /* @__PURE__ */ new Date()
5657
5781
  }).returning({ id: schema.users.id, email: schema.users.email });
5658
5782
  authUser = createdUser;
5783
+ if (invited) {
5784
+ await acceptInvitation(invitations, {
5785
+ token: invited,
5786
+ user: { id: createdUser.id, email: createdUser.email, name: name || null },
5787
+ db,
5788
+ env,
5789
+ context: "oauth_invitation_onAccepted_failed"
5790
+ });
5791
+ }
5659
5792
  logSecurityEvent("user_signup_oauth", "low", {
5660
5793
  userId: createdUser.id,
5661
5794
  email: createdUser.email,
@@ -5695,7 +5828,7 @@ var callbackHandler = async (c) => {
5695
5828
 
5696
5829
  // src/routes/oauth/complete.ts
5697
5830
  import { createRoute as createRoute30 } from "@hono/zod-openapi";
5698
- import { eq as eq32 } from "drizzle-orm";
5831
+ import { eq as eq33 } from "drizzle-orm";
5699
5832
  var completeRoute = createRoute30({
5700
5833
  method: "post",
5701
5834
  path: "/complete",
@@ -5743,7 +5876,7 @@ var completeHandler = async (c) => {
5743
5876
  redirect: storedRedirect
5744
5877
  } = JSON.parse(stored);
5745
5878
  const { db, schema } = getAuthContext(c);
5746
- await db.update(schema.users).set({ emailVerified: true }).where(eq32(schema.users.id, userId));
5879
+ await db.update(schema.users).set({ emailVerified: true }).where(eq33(schema.users.id, userId));
5747
5880
  await db.insert(schema.oauthAccounts).values({ userId, provider, providerUserId, email }).onConflictDoNothing();
5748
5881
  const fingerprint = await generateFingerprint(c.req.raw);
5749
5882
  const ipAddress = getClientIp(c.req.raw);
@@ -5788,19 +5921,12 @@ var routes_default = authRoutes;
5788
5921
 
5789
5922
  // src/lib/email/webhook-verifier.ts
5790
5923
  import { Webhook } from "svix";
5791
- function verifyWebhookSignature(payload, signature, secret) {
5924
+ function verifyWebhookSignature(payload, headers, secret) {
5792
5925
  if (!secret || secret.trim() === "") {
5793
5926
  throw new Error("Webhook secret is required");
5794
5927
  }
5795
5928
  try {
5796
- const wh = new Webhook(secret);
5797
- wh.verify(payload, {
5798
- "svix-id": "",
5799
- // Not used by Resend
5800
- "svix-timestamp": "",
5801
- // Not used by Resend
5802
- "svix-signature": signature
5803
- });
5929
+ new Webhook(secret).verify(payload, { ...headers });
5804
5930
  return true;
5805
5931
  } catch (error) {
5806
5932
  logger_default.warn("Webhook signature verification failed", {
@@ -5815,6 +5941,8 @@ export {
5815
5941
  CloudflareEmailAdapter,
5816
5942
  EmailService,
5817
5943
  MAX_CHALLENGE_ATTEMPTS,
5944
+ PASSWORDLESS_MAX_ATTEMPTS,
5945
+ PASSWORDLESS_TTL_MS,
5818
5946
  ProblemTypes,
5819
5947
  ResendAdapter,
5820
5948
  SCHEMA_VERSION,
@@ -5826,6 +5954,7 @@ export {
5826
5954
  cancelEmailChange,
5827
5955
  checkAccountLocked,
5828
5956
  checkBreachedPassword,
5957
+ cleanupExpiredPasswordlessChallenges,
5829
5958
  clearAccountLockout,
5830
5959
  clearChallengeCookie,
5831
5960
  clearSessionCookie,
@@ -5836,6 +5965,7 @@ export {
5836
5965
  createChallengeToken,
5837
5966
  createDeviceToken,
5838
5967
  createEmailAdapter,
5968
+ createPasswordlessChallenge,
5839
5969
  createProblemDetails,
5840
5970
  createSession,
5841
5971
  csrf,
@@ -5852,6 +5982,7 @@ export {
5852
5982
  generate2faEnabledEmail,
5853
5983
  generateApiKey,
5854
5984
  generateBackupCodes,
5985
+ generateCode,
5855
5986
  generateCodeChallenge,
5856
5987
  generateCodeVerifier,
5857
5988
  generateEmailChangeConfirmation,
@@ -5890,14 +6021,17 @@ export {
5890
6021
  optionalAuth,
5891
6022
  parseDeviceName,
5892
6023
  parseDeviceType,
6024
+ passwordlessEmail,
5893
6025
  problemJson,
5894
6026
  problems,
5895
6027
  rateLimit,
6028
+ recentPasswordlessFailures,
5896
6029
  refreshSession,
5897
6030
  requestEmailChange,
5898
6031
  requireAuth,
5899
6032
  requireVerifiedEmail,
5900
6033
  retrieveChallengeToken,
6034
+ sessionRefreshDue,
5901
6035
  setChallengeCookie,
5902
6036
  setSessionCookie,
5903
6037
  setTrustedDeviceCookie,
@@ -5915,6 +6049,8 @@ export {
5915
6049
  verifyCodeChallenge,
5916
6050
  verifyPassword,
5917
6051
  verifyPasswordWithRotation,
6052
+ verifyPasswordlessCode,
6053
+ verifyPasswordlessLink,
5918
6054
  verifyTotpCode,
5919
6055
  verifyTurnstileToken,
5920
6056
  verifyWebhookSignature