@better-auth/infra 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.mts +279 -450
  2. package/dist/index.mjs +336 -324
  3. package/package.json +11 -11
package/dist/index.mjs CHANGED
@@ -815,7 +815,10 @@ const getOrganizationTriggerInfo = (user) => {
815
815
  const initTrackEvents = (options) => {
816
816
  const $fetch = createFetch({
817
817
  baseURL: options.apiUrl,
818
- headers: { "x-api-key": options.apiKey }
818
+ headers: {
819
+ "user-agent": "better-auth",
820
+ "x-api-key": options.apiKey
821
+ }
819
822
  });
820
823
  const trackEvent = (data) => {
821
824
  const track = async () => {
@@ -1523,6 +1526,7 @@ const paths = [
1523
1526
  "/email-otp/verify-email",
1524
1527
  "/sign-in/email-otp",
1525
1528
  "/sign-in/magic-link",
1529
+ "/sign-in/email",
1526
1530
  "/forget-password/email-otp",
1527
1531
  "/email-otp/reset-password",
1528
1532
  "/email-otp/create-verification-otp",
@@ -1555,25 +1559,6 @@ const signIn = new Set(paths.slice(1, 12));
1555
1559
  * @returns boolean
1556
1560
  */
1557
1561
  const allEmail = ({ path }) => !!path && all.has(path);
1558
- /**
1559
- * Path is one of `[
1560
- * '/email-otp/verify-email',
1561
- * '/sign-in/email-otp',
1562
- * '/sign-in/magic-link',
1563
- * '/sign-in/email',
1564
- * '/forget-password/email-otp',
1565
- * '/email-otp/reset-password',
1566
- * '/email-otp/create-verification-otp',
1567
- * '/email-otp/get-verification-otp',
1568
- * '/email-otp/send-verification-otp',
1569
- * '/forget-password',
1570
- * '/send-verification-email'
1571
- * ]`.
1572
- * @param context Request context
1573
- * @param context.path Request path
1574
- * @returns boolean
1575
- */
1576
- const allEmailSignIn = ({ path }) => !!path && signIn.has(path);
1577
1562
 
1578
1563
  //#endregion
1579
1564
  //#region src/validation/email.ts
@@ -1605,9 +1590,13 @@ const PLUS_ADDRESSING_DOMAINS = new Set([
1605
1590
  * - Remove dots from Gmail-like providers (they ignore dots)
1606
1591
  * - Remove plus addressing (user+tag@domain → user@domain)
1607
1592
  * - Normalize googlemail.com to gmail.com
1593
+ *
1594
+ * @param email - Raw email to normalize
1595
+ * @param context - Auth context with getPlugin (for sentinel policy). Pass undefined when context unavailable (e.g. server, hooks).
1608
1596
  */
1609
- function normalizeEmail(email) {
1597
+ function normalizeEmail(email, context) {
1610
1598
  if (!email || typeof email !== "string") return email;
1599
+ if ((context.getPlugin?.("sentinel"))?.options?.emailValidation?.enabled === false) return email;
1611
1600
  const trimmed = email.trim().toLowerCase();
1612
1601
  const atIndex = trimmed.lastIndexOf("@");
1613
1602
  if (atIndex === -1) return trimmed;
@@ -1700,45 +1689,42 @@ function isValidEmailFormatLocal(email) {
1700
1689
  if (domain.length > 253) return false;
1701
1690
  return true;
1702
1691
  }
1703
- const getEmail = (ctx) => ({
1704
- email: ctx.body?.email ?? ctx.query?.email,
1705
- container: ctx.body ? "body" : "query"
1706
- });
1692
+ const getEmail = (ctx) => {
1693
+ if (ctx.path === "/change-email") return {
1694
+ email: ctx.body?.newEmail,
1695
+ container: "body",
1696
+ field: "newEmail"
1697
+ };
1698
+ const body = ctx.body;
1699
+ const query = ctx.query;
1700
+ return {
1701
+ email: body?.email ?? query?.email,
1702
+ container: body ? "body" : "query",
1703
+ field: "email"
1704
+ };
1705
+ };
1707
1706
  /**
1708
1707
  * Create email normalization hook (shared between all configurations)
1709
1708
  */
1710
1709
  function createEmailNormalizationHook() {
1711
1710
  return {
1712
- matcher: allEmailSignIn,
1711
+ matcher: allEmail,
1713
1712
  handler: createAuthMiddleware(async (ctx) => {
1714
- const { email, container } = getEmail(ctx);
1713
+ const { email, container, field } = getEmail(ctx);
1715
1714
  if (typeof email !== "string") return;
1716
- const normalizedEmail = normalizeEmail(email);
1717
- if (normalizedEmail !== email) {
1718
- const user = await ctx.context.adapter.findOne({
1719
- model: "user",
1720
- where: [{
1721
- field: "normalizedEmail",
1722
- value: normalizedEmail
1723
- }]
1724
- });
1725
- if (!user) return;
1726
- return container === "query" ? { context: {
1727
- ...ctx,
1728
- query: {
1729
- ...ctx.query,
1730
- email: user.email,
1731
- normalizedEmail
1732
- }
1733
- } } : { context: {
1734
- ...ctx,
1735
- body: {
1736
- ...ctx.body,
1737
- email: user.email,
1738
- normalizedEmail
1739
- }
1740
- } };
1741
- }
1715
+ const normalized = normalizeEmail(email, ctx.context);
1716
+ if (normalized === email) return;
1717
+ const data = container === "query" ? {
1718
+ ...ctx.query,
1719
+ [field]: normalized
1720
+ } : {
1721
+ ...ctx.body,
1722
+ [field]: normalized
1723
+ };
1724
+ return { context: {
1725
+ ...ctx,
1726
+ [container]: data
1727
+ } };
1742
1728
  })
1743
1729
  };
1744
1730
  }
@@ -1749,7 +1735,7 @@ function createEmailValidationHook(validator, onDisposableEmail) {
1749
1735
  return {
1750
1736
  matcher: allEmail,
1751
1737
  handler: createAuthMiddleware(async (ctx) => {
1752
- const email = ctx.path === "/change-email" ? ctx.body?.newEmail : getEmail(ctx).email;
1738
+ const { email } = getEmail(ctx);
1753
1739
  if (typeof email !== "string") return;
1754
1740
  if (!isValidEmailFormatLocal(email)) throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
1755
1741
  if (validator) {
@@ -1758,7 +1744,7 @@ function createEmailValidationHook(validator, onDisposableEmail) {
1758
1744
  if (!policy?.enabled) return;
1759
1745
  const action = policy.action;
1760
1746
  if (!result.valid) {
1761
- if ((result.disposable || result.reason === "no_mx_records" || result.reason === "blocklist") && onDisposableEmail) {
1747
+ if ((result.disposable || result.reason === "no_mx_records" || result.reason === "blocklist" || result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short") && onDisposableEmail) {
1762
1748
  const ip = ctx.request?.headers?.get("x-forwarded-for")?.split(",")[0] || ctx.request?.headers?.get("cf-connecting-ip") || void 0;
1763
1749
  onDisposableEmail({
1764
1750
  email,
@@ -1770,7 +1756,7 @@ function createEmailValidationHook(validator, onDisposableEmail) {
1770
1756
  });
1771
1757
  }
1772
1758
  if (action === "allow") return;
1773
- throw new APIError$1("BAD_REQUEST", { message: result.reason === "no_mx_records" ? "This email domain cannot receive emails" : result.disposable || result.reason === "blocklist" ? "Disposable email addresses are not allowed" : result.reason === "fake_domain" || result.reason === "fake_pattern" ? "This email address appears to be invalid" : "Invalid email" });
1759
+ throw new APIError$1("BAD_REQUEST", { message: result.reason === "no_mx_records" ? "This email domain cannot receive emails" : result.disposable || result.reason === "blocklist" ? "Disposable email addresses are not allowed" : result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short" ? "This email address appears to be invalid" : "Invalid email" });
1774
1760
  }
1775
1761
  }
1776
1762
  })
@@ -1809,12 +1795,13 @@ function createEmailHooks(options = {}) {
1809
1795
  ...defaultConfig
1810
1796
  };
1811
1797
  if (!emailConfig.enabled) return { before: [] };
1812
- return { before: [createEmailValidationHook(useApi ? createEmailValidator({
1798
+ const validator = useApi ? createEmailValidator({
1813
1799
  apiUrl,
1814
1800
  kvUrl,
1815
1801
  apiKey,
1816
1802
  defaultConfig: emailConfig
1817
- }) : void 0, onDisposableEmail), createEmailNormalizationHook()] };
1803
+ }) : void 0;
1804
+ return { before: [createEmailNormalizationHook(), createEmailValidationHook(validator, onDisposableEmail)] };
1818
1805
  }
1819
1806
  /**
1820
1807
  * Default email hooks using local validation only
@@ -2084,100 +2071,107 @@ const sentinel = (options) => {
2084
2071
  return {
2085
2072
  id: "sentinel",
2086
2073
  init() {
2087
- return { options: { databaseHooks: {
2088
- user: { create: {
2089
- async before(_user, ctx) {
2090
- if (!ctx) return;
2091
- const visitorId = ctx.context.visitorId;
2092
- if (visitorId && opts.security?.freeTrialAbuse?.enabled) {
2093
- const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
2094
- if (abuseCheck.isAbuse && abuseCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2095
- }
2096
- },
2097
- async after(user, ctx) {
2098
- if (!ctx) return;
2099
- const visitorId = ctx.context.visitorId;
2100
- if (visitorId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.trackFreeTrialSignup(visitorId, user.id));
2101
- }
2102
- } },
2103
- session: { create: {
2104
- async before(session, ctx) {
2105
- if (!ctx) return;
2106
- const visitorId = ctx.context.visitorId;
2107
- const identification = ctx.context.identification;
2108
- if (session.userId && identification?.location && visitorId) {
2109
- const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId);
2110
- if (travelCheck?.isImpossible) {
2111
- if (travelCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Login blocked due to suspicious location change." });
2112
- if (travelCheck.action === "challenge" && travelCheck.challenge) throwChallengeError(travelCheck.challenge, "impossible_travel", "Unusual login location detected. Please complete a security check.");
2074
+ return { options: {
2075
+ emailValidation: opts.security?.emailValidation,
2076
+ databaseHooks: {
2077
+ user: { create: {
2078
+ async before(user, ctx) {
2079
+ if (!ctx) return;
2080
+ const visitorId = ctx.context.visitorId;
2081
+ if (visitorId && opts.security?.freeTrialAbuse?.enabled) {
2082
+ const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
2083
+ if (abuseCheck.isAbuse && abuseCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2113
2084
  }
2085
+ if (user.email && typeof user.email === "string") return { data: {
2086
+ ...user,
2087
+ email: normalizeEmail(user.email, ctx.context)
2088
+ } };
2089
+ },
2090
+ async after(user, ctx) {
2091
+ if (!ctx) return;
2092
+ const visitorId = ctx.context.visitorId;
2093
+ if (visitorId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.trackFreeTrialSignup(visitorId, user.id));
2114
2094
  }
2115
- },
2116
- async after(session, ctx) {
2117
- if (!ctx || !session.userId) return;
2118
- const visitorId = ctx.context.visitorId;
2119
- const identification = ctx.context.identification;
2120
- let user = null;
2121
- try {
2122
- user = await ctx.context.adapter.findOne({
2123
- model: "user",
2124
- select: [
2125
- "email",
2126
- "name",
2127
- "lastActiveAt"
2128
- ],
2129
- where: [{
2130
- field: "id",
2131
- value: session.userId
2132
- }]
2133
- });
2134
- } catch (error) {
2135
- logger.warn("[Sentinel] Failed to fetch user for security checks:", error);
2136
- }
2137
- if (visitorId) {
2138
- if (await securityService.checkUnknownDevice(session.userId, visitorId) && user?.email) await ctx.context.runInBackgroundOrAwait(securityService.notifyUnknownDevice(session.userId, user.email, identification));
2139
- }
2140
- if (opts.security?.staleUsers?.enabled && user) {
2141
- const staleCheck = await securityService.checkStaleUser(session.userId, user.lastActiveAt || null);
2142
- if (staleCheck.isStale) {
2143
- const staleOpts = opts.security.staleUsers;
2144
- const notificationPromises = [];
2145
- if (staleCheck.notifyUser && user.email) notificationPromises.push(securityService.notifyStaleAccountUser(user.email, user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2146
- if (staleCheck.notifyAdmin && staleOpts.adminEmail) notificationPromises.push(securityService.notifyStaleAccountAdmin(staleOpts.adminEmail, session.userId, user.email || "unknown", user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2147
- if (notificationPromises.length > 0) Promise.all(notificationPromises).catch((error) => {
2148
- logger.error("[Sentinel] Failed to send stale account notifications:", error);
2149
- });
2150
- trackEvent({
2151
- eventKey: session.userId,
2152
- eventType: "security_stale_account",
2153
- eventDisplayName: "Security: stale account reactivated",
2154
- eventData: {
2155
- action: staleCheck.action === "block" ? "blocked" : staleCheck.action === "challenge" ? "challenged" : "logged",
2156
- reason: "stale_account_reactivation",
2157
- userId: session.userId,
2158
- daysSinceLastActive: staleCheck.daysSinceLastActive,
2159
- staleDays: staleCheck.staleDays,
2160
- lastActiveAt: staleCheck.lastActiveAt,
2161
- notifyUser: staleCheck.notifyUser,
2162
- notifyAdmin: staleCheck.notifyAdmin,
2163
- detectionLabel: "Stale Account Reactivation",
2164
- description: `Dormant account (inactive for ${staleCheck.daysSinceLastActive} days) became active`
2165
- },
2166
- ipAddress: identification?.ip || void 0,
2167
- city: identification?.location?.city || void 0,
2168
- country: identification?.location?.country?.name || void 0,
2169
- countryCode: identification?.location?.country?.code || void 0
2170
- });
2171
- if (staleCheck.action === "block") throw new APIError("FORBIDDEN", {
2172
- message: "This account has been inactive for an extended period. Please contact support to reactivate.",
2173
- code: "STALE_ACCOUNT"
2095
+ } },
2096
+ session: { create: {
2097
+ async before(session, ctx) {
2098
+ if (!ctx) return;
2099
+ const visitorId = ctx.context.visitorId;
2100
+ const identification = ctx.context.identification;
2101
+ if (session.userId && identification?.location && visitorId) {
2102
+ const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId);
2103
+ if (travelCheck?.isImpossible) {
2104
+ if (travelCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Login blocked due to suspicious location change." });
2105
+ if (travelCheck.action === "challenge" && travelCheck.challenge) throwChallengeError(travelCheck.challenge, "impossible_travel", "Unusual login location detected. Please complete a security check.");
2106
+ }
2107
+ }
2108
+ },
2109
+ async after(session, ctx) {
2110
+ if (!ctx || !session.userId) return;
2111
+ const visitorId = ctx.context.visitorId;
2112
+ const identification = ctx.context.identification;
2113
+ let user = null;
2114
+ try {
2115
+ user = await ctx.context.adapter.findOne({
2116
+ model: "user",
2117
+ select: [
2118
+ "email",
2119
+ "name",
2120
+ "lastActiveAt"
2121
+ ],
2122
+ where: [{
2123
+ field: "id",
2124
+ value: session.userId
2125
+ }]
2174
2126
  });
2127
+ } catch (error) {
2128
+ logger.warn("[Sentinel] Failed to fetch user for security checks:", error);
2129
+ }
2130
+ if (visitorId) {
2131
+ if (await securityService.checkUnknownDevice(session.userId, visitorId) && user?.email) await ctx.context.runInBackgroundOrAwait(securityService.notifyUnknownDevice(session.userId, user.email, identification));
2132
+ }
2133
+ if (opts.security?.staleUsers?.enabled && user) {
2134
+ const staleCheck = await securityService.checkStaleUser(session.userId, user.lastActiveAt || null);
2135
+ if (staleCheck.isStale) {
2136
+ const staleOpts = opts.security.staleUsers;
2137
+ const notificationPromises = [];
2138
+ if (staleCheck.notifyUser && user.email) notificationPromises.push(securityService.notifyStaleAccountUser(user.email, user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2139
+ if (staleCheck.notifyAdmin && staleOpts.adminEmail) notificationPromises.push(securityService.notifyStaleAccountAdmin(staleOpts.adminEmail, session.userId, user.email || "unknown", user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2140
+ if (notificationPromises.length > 0) Promise.all(notificationPromises).catch((error) => {
2141
+ logger.error("[Sentinel] Failed to send stale account notifications:", error);
2142
+ });
2143
+ trackEvent({
2144
+ eventKey: session.userId,
2145
+ eventType: "security_stale_account",
2146
+ eventDisplayName: "Security: stale account reactivated",
2147
+ eventData: {
2148
+ action: staleCheck.action === "block" ? "blocked" : staleCheck.action === "challenge" ? "challenged" : "logged",
2149
+ reason: "stale_account_reactivation",
2150
+ userId: session.userId,
2151
+ daysSinceLastActive: staleCheck.daysSinceLastActive,
2152
+ staleDays: staleCheck.staleDays,
2153
+ lastActiveAt: staleCheck.lastActiveAt,
2154
+ notifyUser: staleCheck.notifyUser,
2155
+ notifyAdmin: staleCheck.notifyAdmin,
2156
+ detectionLabel: "Stale Account Reactivation",
2157
+ description: `Dormant account (inactive for ${staleCheck.daysSinceLastActive} days) became active`
2158
+ },
2159
+ ipAddress: identification?.ip || void 0,
2160
+ city: identification?.location?.city || void 0,
2161
+ country: identification?.location?.country?.name || void 0,
2162
+ countryCode: identification?.location?.country?.code || void 0
2163
+ });
2164
+ if (staleCheck.action === "block") throw new APIError("FORBIDDEN", {
2165
+ message: "This account has been inactive for an extended period. Please contact support to reactivate.",
2166
+ code: "STALE_ACCOUNT"
2167
+ });
2168
+ }
2175
2169
  }
2170
+ if (identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location));
2176
2171
  }
2177
- if (identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location));
2178
- }
2179
- } }
2180
- } } };
2172
+ } }
2173
+ }
2174
+ } };
2181
2175
  },
2182
2176
  hooks: {
2183
2177
  before: [
@@ -2633,7 +2627,7 @@ const initTeamEvents = (tracker) => {
2633
2627
  //#region src/jwt.ts
2634
2628
  /**
2635
2629
  * Hash the given value
2636
- * Note: Must match @infra/crypto hash()
2630
+ * Note: Must match @infra/utils/crypto hash()
2637
2631
  * @param value - The value to hash
2638
2632
  */
2639
2633
  async function hash(value) {
@@ -2687,13 +2681,16 @@ function isRecentlyIssued(payload) {
2687
2681
  }
2688
2682
  const jwtMiddleware = (options, schema, getJWT) => createAuthMiddleware(async (ctx) => {
2689
2683
  const jwsFromHeader = getJWT ? await getJWT(ctx) : ctx.headers?.get("Authorization")?.split(" ")[1];
2690
- if (!jwsFromHeader) throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2684
+ if (!jwsFromHeader) {
2685
+ ctx.context.logger.warn("[Dash] JWT is missing from header");
2686
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2687
+ }
2691
2688
  const { payload } = await jwtVerify(jwsFromHeader, await getJWKs(options.apiUrl), { maxTokenAge: "5m" }).catch((e) => {
2692
- ctx.context.logger.error("[Dash] JWT verification failed:", e);
2689
+ ctx.context.logger.warn("[Dash] JWT verification failed:", e);
2693
2690
  throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2694
2691
  });
2695
2692
  if (!isRecentlyIssued(payload)) {
2696
- if (!(await betterFetch("/api/auth/check-jti", {
2693
+ const { error, data } = await betterFetch("/api/auth/check-jti", {
2697
2694
  baseURL: options.apiUrl,
2698
2695
  method: "POST",
2699
2696
  headers: { "x-api-key": options.apiKey },
@@ -2701,18 +2698,52 @@ const jwtMiddleware = (options, schema, getJWT) => createAuthMiddleware(async (c
2701
2698
  jti: payload.jti,
2702
2699
  expiresAt: payload.exp
2703
2700
  }
2704
- })).data?.valid) throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2701
+ });
2702
+ if (error || !data?.valid) {
2703
+ ctx.context.logger.warn("[Dash] JTI check failed with error", error, data?.valid);
2704
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2705
+ }
2705
2706
  }
2706
2707
  const apiKeyHash = payload.apiKeyHash;
2707
- if (typeof apiKeyHash !== "string" || !options.apiKey) throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2708
- if (apiKeyHash !== await hash(options.apiKey)) throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2708
+ if (typeof apiKeyHash !== "string" || !options.apiKey) {
2709
+ ctx.context.logger.warn("[Dash] API key hash is missing or invalid", {
2710
+ apiKeyHash,
2711
+ apiKey: options.apiKey ? "present" : "missing"
2712
+ });
2713
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2714
+ }
2715
+ const expectedHash = await hash(options.apiKey);
2716
+ if (apiKeyHash !== expectedHash) {
2717
+ ctx.context.logger.warn("[Dash] API key hash is invalid", apiKeyHash, expectedHash);
2718
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2719
+ }
2709
2720
  if (schema) {
2710
2721
  const parsed = schema.safeParse(payload);
2711
- if (!parsed.success) throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2722
+ if (!parsed.success) {
2723
+ ctx.context.logger.warn("[Dash] JWT payload is invalid", parsed.error);
2724
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2725
+ }
2712
2726
  return { payload: parsed.data };
2713
2727
  }
2714
2728
  return { payload };
2715
2729
  });
2730
+ /**
2731
+ * Lightweight JWT middleware for /dash/validate. Verifies JWT signature and
2732
+ * apiKeyHash only—no JTI check. Used during onboarding when the org doesn't
2733
+ * exist yet.
2734
+ */
2735
+ const jwtValidateMiddleware = (options) => createAuthMiddleware(async (ctx) => {
2736
+ const jwsFromHeader = ctx.headers?.get("Authorization")?.split(" ")[1];
2737
+ if (!jwsFromHeader) throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2738
+ const { payload } = await jwtVerify(jwsFromHeader, await getJWKs(options.apiUrl), { maxTokenAge: "5m" }).catch((e) => {
2739
+ ctx.context.logger.error("[Dash] JWT verification failed:", e);
2740
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2741
+ });
2742
+ const apiKeyHash = payload.apiKeyHash;
2743
+ if (typeof apiKeyHash !== "string" || !options.apiKey) throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2744
+ if (apiKeyHash !== await hash(options.apiKey)) throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2745
+ return { payload };
2746
+ });
2716
2747
 
2717
2748
  //#endregion
2718
2749
  //#region src/routes/config.ts
@@ -2765,7 +2796,7 @@ const getConfig = (options) => {
2765
2796
  schema: plugin.schema,
2766
2797
  options: sanitizePluginOptions(plugin.id, plugin.options)
2767
2798
  };
2768
- }),
2799
+ }) ?? [],
2769
2800
  organization: {
2770
2801
  sendInvitationEmailEnabled: !!organizationPlugin?.options?.sendInvitationEmail,
2771
2802
  additionalFields: (() => {
@@ -2894,7 +2925,8 @@ async function getScimProviderOwner(ctx, organizationId, providerId) {
2894
2925
  });
2895
2926
  return (await ctx.context.adapter.findOne({
2896
2927
  model: "scimProvider",
2897
- where
2928
+ where,
2929
+ select: ["userId"]
2898
2930
  }))?.userId ?? null;
2899
2931
  }
2900
2932
  function getScimEndpoint(baseUrl) {
@@ -3367,7 +3399,8 @@ const acceptInvitation = (options) => {
3367
3399
  throw new APIError("BAD_REQUEST", { message: "This invitation has expired." });
3368
3400
  }
3369
3401
  }
3370
- const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitation.email).then((user$1) => user$1?.user);
3402
+ const invitationEmail = normalizeEmail(invitation.email, ctx.context);
3403
+ const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitationEmail).then((user$1) => user$1?.user);
3371
3404
  if (existingUser) {
3372
3405
  await $api("/api/internal/invitations/mark-accepted", {
3373
3406
  method: "POST",
@@ -3381,7 +3414,7 @@ const acceptInvitation = (options) => {
3381
3414
  user: existingUser
3382
3415
  });
3383
3416
  const redirectUrl$1 = invitation.redirectUrl || ctx.context.options.baseURL || "/";
3384
- return ctx.redirect(redirectUrl$1);
3417
+ return ctx.redirect(redirectUrl$1.toString());
3385
3418
  }
3386
3419
  if (invitation.authMode === "auth") {
3387
3420
  const platformUrl = options.apiUrl || INFRA_API_URL;
@@ -3392,7 +3425,7 @@ const acceptInvitation = (options) => {
3392
3425
  return ctx.redirect(acceptPageUrl.toString());
3393
3426
  }
3394
3427
  const user = await ctx.context.internalAdapter.createUser({
3395
- email: invitation.email,
3428
+ email: invitationEmail,
3396
3429
  name: invitation.name || invitation.email.split("@")[0] || "",
3397
3430
  emailVerified: true,
3398
3431
  createdAt: /* @__PURE__ */ new Date(),
@@ -3410,7 +3443,7 @@ const acceptInvitation = (options) => {
3410
3443
  user
3411
3444
  });
3412
3445
  const redirectUrl = invitation.redirectUrl || ctx.context.options.baseURL || "/";
3413
- return ctx.redirect(redirectUrl);
3446
+ return ctx.redirect(redirectUrl.toString());
3414
3447
  });
3415
3448
  };
3416
3449
  /**
@@ -3442,7 +3475,8 @@ const completeInvitation = (options) => {
3442
3475
  if (error || !invitation) throw new APIError("BAD_REQUEST", { message: "Invalid or expired invitation." });
3443
3476
  if (invitation.status !== "pending") throw new APIError("BAD_REQUEST", { message: `This invitation has already been ${invitation.status}.` });
3444
3477
  if (!ctx.context) throw new APIError("BAD_REQUEST", { message: "Context is required" });
3445
- const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitation.email).then((user$1) => user$1?.user);
3478
+ const invitationEmail = normalizeEmail(invitation.email, ctx.context);
3479
+ const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitationEmail).then((user$1) => user$1?.user);
3446
3480
  if (existingUser) {
3447
3481
  await $api("/api/internal/invitations/mark-accepted", {
3448
3482
  method: "POST",
@@ -3461,7 +3495,7 @@ const completeInvitation = (options) => {
3461
3495
  };
3462
3496
  }
3463
3497
  const user = await ctx.context.internalAdapter.createUser({
3464
- email: invitation.email,
3498
+ email: invitationEmail,
3465
3499
  name: invitation.name || invitation.email.split("@")[0] || "",
3466
3500
  emailVerified: true,
3467
3501
  createdAt: /* @__PURE__ */ new Date(),
@@ -3509,7 +3543,8 @@ const checkUserExists = (_options) => {
3509
3543
  }, async (ctx) => {
3510
3544
  const { email } = ctx.body;
3511
3545
  if (!ctx.request?.headers.get("Authorization")) throw new APIError("UNAUTHORIZED", { message: "Authorization required" });
3512
- const existingUser = await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase()).then((user) => user?.user);
3546
+ const normalizedEmail = normalizeEmail(email, ctx.context);
3547
+ const existingUser = await ctx.context.internalAdapter.findUserByEmail(normalizedEmail).then((user) => user?.user);
3513
3548
  return {
3514
3549
  exists: !!existingUser,
3515
3550
  userId: existingUser?.id || null
@@ -3719,7 +3754,7 @@ const deleteOrganizationLogDrain = (options) => {
3719
3754
  body: z$1.object({ logDrainId: z$1.string() })
3720
3755
  }, async (ctx) => {
3721
3756
  const { organizationId } = ctx.context.payload;
3722
- if (!await ctx.context.adapter.findOne({
3757
+ if (await ctx.context.adapter.count({
3723
3758
  model: "orgLogDrain",
3724
3759
  where: [{
3725
3760
  field: "id",
@@ -3728,7 +3763,7 @@ const deleteOrganizationLogDrain = (options) => {
3728
3763
  field: "organizationId",
3729
3764
  value: organizationId
3730
3765
  }]
3731
- })) throw ctx.error("NOT_FOUND", { message: "Log drain not found" });
3766
+ }) === 0) throw ctx.error("NOT_FOUND", { message: "Log drain not found" });
3732
3767
  await ctx.context.adapter.delete({
3733
3768
  model: "orgLogDrain",
3734
3769
  where: [{
@@ -3917,6 +3952,15 @@ const exportFactory = (input, options) => async (ctx) => {
3917
3952
 
3918
3953
  //#endregion
3919
3954
  //#region src/helper.ts
3955
+ /**
3956
+ * Checks whether a plugin is registered by its ID.
3957
+ * Prefers the native `hasPlugin` when available, otherwise
3958
+ * falls back to scanning `options.plugins`.
3959
+ */
3960
+ function hasPlugin(context, pluginId) {
3961
+ if (typeof context.hasPlugin === "function") return context.hasPlugin(pluginId);
3962
+ return context.options.plugins?.some((p) => p.id === pluginId) ?? false;
3963
+ }
3920
3964
  function* chunkArray(arr, options) {
3921
3965
  const batchSize = options?.batchSize || 200;
3922
3966
  for (let i = 0; i < arr.length; i += batchSize) yield arr.slice(i, i + batchSize);
@@ -4129,32 +4173,26 @@ const listOrganizations = (options) => {
4129
4173
  sortBy: {
4130
4174
  field: dbSortBy,
4131
4175
  direction: sortOrder
4132
- }
4176
+ },
4177
+ join: { member: { limit: 5 } }
4133
4178
  }), needsInMemoryProcessing ? Promise.resolve(0) : ctx.context.adapter.count({
4134
4179
  model: "organization",
4135
4180
  where
4136
4181
  })]);
4137
4182
  const orgIds = organizations.map((o) => o.id);
4138
- const allMembers = orgIds.length > 0 ? await ctx.context.adapter.findMany({
4183
+ const memberCounts = await withConcurrency(orgIds, (orgId) => ctx.context.adapter.count({
4139
4184
  model: "member",
4140
4185
  where: [{
4141
4186
  field: "organizationId",
4142
- value: orgIds,
4143
- operator: "in"
4187
+ value: orgId
4144
4188
  }]
4145
- }) : [];
4146
- const membersByOrg = /* @__PURE__ */ new Map();
4147
- for (const m of allMembers) {
4148
- const list = membersByOrg.get(m.organizationId) || [];
4149
- list.push(m);
4150
- membersByOrg.set(m.organizationId, list);
4151
- }
4189
+ }), { concurrency: 10 });
4190
+ const memberCountByOrg = new Map(orgIds.map((orgId, i) => [orgId, memberCounts[i]]));
4152
4191
  let withCounts = organizations.map((organization) => {
4153
- const orgMembers = membersByOrg.get(organization.id) || [];
4192
+ const memberCount = memberCountByOrg.get(organization.id) ?? 0;
4154
4193
  return {
4155
4194
  ...organization,
4156
- _members: orgMembers,
4157
- memberCount: orgMembers.length
4195
+ memberCount
4158
4196
  };
4159
4197
  });
4160
4198
  if (filterMembers) {
@@ -4174,25 +4212,26 @@ const listOrganizations = (options) => {
4174
4212
  const total = needsInMemoryProcessing ? withCounts.length : initialTotal;
4175
4213
  if (needsInMemoryProcessing) withCounts = withCounts.slice(offset, offset + limit);
4176
4214
  const allUserIds = /* @__PURE__ */ new Set();
4177
- for (const organization of withCounts) for (const member of organization._members.slice(0, 5)) allUserIds.add(member.userId);
4215
+ for (const organization of withCounts) for (const member of organization.member) allUserIds.add(member.userId);
4178
4216
  const users = allUserIds.size > 0 ? await ctx.context.adapter.findMany({
4179
4217
  model: "user",
4180
4218
  where: [{
4181
4219
  field: "id",
4182
4220
  value: Array.from(allUserIds),
4183
4221
  operator: "in"
4184
- }]
4222
+ }],
4223
+ limit: allUserIds.size
4185
4224
  }) : [];
4186
4225
  const userMap = new Map(users.map((u) => [u.id, u]));
4187
4226
  return {
4188
4227
  organizations: withCounts.map((organization) => {
4189
- const members = organization._members.slice(0, 5).map((m) => userMap.get(m.userId)).filter((u) => u !== void 0).map((u) => ({
4228
+ const members = organization.member.map((m) => userMap.get(m.userId)).filter((u) => u !== void 0).map((u) => ({
4190
4229
  id: u.id,
4191
4230
  name: u.name,
4192
4231
  email: u.email,
4193
4232
  image: u.image
4194
4233
  }));
4195
- const { _members, ...org } = organization;
4234
+ const { member: _members, ...org } = organization;
4196
4235
  return {
4197
4236
  ...org,
4198
4237
  members
@@ -4284,17 +4323,9 @@ const listOrganizationMembers = (options) => {
4284
4323
  where: [{
4285
4324
  field: "organizationId",
4286
4325
  value: ctx.params.id
4287
- }]
4326
+ }],
4327
+ join: { user: true }
4288
4328
  });
4289
- const userIds = members.map((m) => m.userId);
4290
- const users = userIds.length ? await ctx.context.adapter.findMany({
4291
- model: "user",
4292
- where: [{
4293
- field: "id",
4294
- value: userIds,
4295
- operator: "in"
4296
- }]
4297
- }) : [];
4298
4329
  const invitations = await ctx.context.adapter.findMany({
4299
4330
  model: "invitation",
4300
4331
  where: [{
@@ -4303,22 +4334,17 @@ const listOrganizationMembers = (options) => {
4303
4334
  }, {
4304
4335
  field: "status",
4305
4336
  value: "accepted"
4306
- }]
4337
+ }],
4338
+ join: { user: true }
4307
4339
  });
4308
- const inviterIds = [...new Set(invitations.map((i) => i.inviterId).filter(Boolean))];
4309
- const inviters = inviterIds.length ? await ctx.context.adapter.findMany({
4310
- model: "user",
4311
- where: [{
4312
- field: "id",
4313
- value: inviterIds,
4314
- operator: "in"
4315
- }]
4316
- }) : [];
4317
- const inviterById = new Map(inviters.map((u) => [u.id, u]));
4318
- const userById = new Map(users.map((u) => [u.id, u]));
4340
+ const inviterById = /* @__PURE__ */ new Map();
4341
+ for (const inv of invitations) {
4342
+ const inviter = Array.isArray(inv.user) ? inv.user[0] : inv.user;
4343
+ if (inviter && inv.inviterId) inviterById.set(inv.inviterId, inviter);
4344
+ }
4319
4345
  const invitationByEmail = new Map(invitations.map((i) => [i.email.toLowerCase(), i]));
4320
4346
  return members.map((m) => {
4321
- const user = userById.get(m.userId);
4347
+ const user = (Array.isArray(m.user) ? m.user[0] : m.user) ?? null;
4322
4348
  const invitation = user ? invitationByEmail.get(user.email.toLowerCase()) : null;
4323
4349
  const inviter = invitation ? inviterById.get(invitation.inviterId) : null;
4324
4350
  return {
@@ -4351,7 +4377,7 @@ const listOrganizationInvitations = (options) => {
4351
4377
  value: ctx.params.id
4352
4378
  }]
4353
4379
  });
4354
- const emails = [...new Set(invitations.map((i) => i.email.toLowerCase()))];
4380
+ const emails = [...new Set(invitations.map((i) => normalizeEmail(i.email, ctx.context)))];
4355
4381
  const users = emails.length ? await ctx.context.adapter.findMany({
4356
4382
  model: "user",
4357
4383
  where: [{
@@ -4360,9 +4386,10 @@ const listOrganizationInvitations = (options) => {
4360
4386
  operator: "in"
4361
4387
  }]
4362
4388
  }) : [];
4363
- const userByEmail = new Map(users.map((u) => [u.email.toLowerCase(), u]));
4389
+ const userByEmail = new Map(users.map((u) => [normalizeEmail(u.email, ctx.context), u]));
4364
4390
  return invitations.map((invitation) => {
4365
- const user = userByEmail.get(invitation.email.toLowerCase());
4391
+ const invitationEmail = normalizeEmail(invitation.email, ctx.context);
4392
+ const user = userByEmail.get(invitationEmail);
4366
4393
  return {
4367
4394
  ...invitation,
4368
4395
  user: user ? {
@@ -4398,17 +4425,12 @@ const deleteOrganization = (options) => {
4398
4425
  field: "createdAt",
4399
4426
  direction: "asc"
4400
4427
  },
4401
- limit: 1
4428
+ limit: 1,
4429
+ join: { user: true }
4402
4430
  });
4403
4431
  if (owners.length === 0) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
4404
4432
  const owner = owners[0];
4405
- const deletedByUser = await ctx.context.adapter.findOne({
4406
- model: "user",
4407
- where: [{
4408
- field: "id",
4409
- value: owner.userId
4410
- }]
4411
- });
4433
+ const deletedByUser = Array.isArray(owner.user) ? owner.user[0] : owner.user;
4412
4434
  if (!deletedByUser) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
4413
4435
  const organization = await ctx.context.adapter.findOne({
4414
4436
  model: "organization",
@@ -4555,17 +4577,12 @@ const updateTeam = (options) => {
4555
4577
  field: "createdAt",
4556
4578
  direction: "asc"
4557
4579
  },
4558
- limit: 1
4580
+ limit: 1,
4581
+ join: { user: true }
4559
4582
  });
4560
4583
  if (owners.length === 0) throw ctx.error("NOT_FOUND", { message: "Owner not found" });
4561
4584
  const owner = owners[0];
4562
- const user = await ctx.context.adapter.findOne({
4563
- model: "user",
4564
- where: [{
4565
- field: "id",
4566
- value: owner.userId
4567
- }]
4568
- });
4585
+ const user = Array.isArray(owner.user) ? owner.user[0] : owner.user;
4569
4586
  if (!user) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
4570
4587
  let updateData = { updatedAt: /* @__PURE__ */ new Date() };
4571
4588
  if (ctx.body.name) updateData.name = ctx.body.name;
@@ -4649,17 +4666,12 @@ const deleteTeam = (options) => {
4649
4666
  field: "createdAt",
4650
4667
  direction: "asc"
4651
4668
  },
4652
- limit: 1
4669
+ limit: 1,
4670
+ join: { user: true }
4653
4671
  });
4654
4672
  if (owners.length === 0) throw ctx.error("NOT_FOUND", { message: "Owner not found" });
4655
4673
  const owner = owners[0];
4656
- const user = await ctx.context.adapter.findOne({
4657
- model: "user",
4658
- where: [{
4659
- field: "id",
4660
- value: owner.userId
4661
- }]
4662
- });
4674
+ const user = Array.isArray(owner.user) ? owner.user[0] : owner.user;
4663
4675
  if (!user) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
4664
4676
  if (orgOptions?.organizationHooks?.beforeDeleteTeam) await orgOptions.organizationHooks.beforeDeleteTeam({
4665
4677
  team,
@@ -4727,17 +4739,12 @@ const createTeam = (options) => {
4727
4739
  field: "createdAt",
4728
4740
  direction: "asc"
4729
4741
  },
4730
- limit: 1
4742
+ limit: 1,
4743
+ join: { user: true }
4731
4744
  });
4732
4745
  if (owners.length === 0) throw ctx.error("NOT_FOUND", { message: "Owner not found" });
4733
4746
  const owner = owners[0];
4734
- const user = await ctx.context.adapter.findOne({
4735
- model: "user",
4736
- where: [{
4737
- field: "id",
4738
- value: owner.userId
4739
- }]
4740
- });
4747
+ const user = Array.isArray(owner.user) ? owner.user[0] : owner.user;
4741
4748
  if (!user) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
4742
4749
  let teamData = {
4743
4750
  name: ctx.body.name,
@@ -4773,7 +4780,7 @@ const listTeamMembers = (options) => {
4773
4780
  use: [jwtMiddleware(options)]
4774
4781
  }, async (ctx) => {
4775
4782
  try {
4776
- if (!await ctx.context.adapter.findOne({
4783
+ if (await ctx.context.adapter.count({
4777
4784
  model: "team",
4778
4785
  where: [{
4779
4786
  field: "id",
@@ -4782,22 +4789,16 @@ const listTeamMembers = (options) => {
4782
4789
  field: "organizationId",
4783
4790
  value: ctx.params.orgId
4784
4791
  }]
4785
- })) throw ctx.error("NOT_FOUND", { message: "Team not found" });
4786
- const teamMembers = await ctx.context.adapter.findMany({
4792
+ }) === 0) throw ctx.error("NOT_FOUND", { message: "Team not found" });
4793
+ return (await ctx.context.adapter.findMany({
4787
4794
  model: "teamMember",
4788
4795
  where: [{
4789
4796
  field: "teamId",
4790
4797
  value: ctx.params.teamId
4791
- }]
4792
- });
4793
- return await Promise.all(teamMembers.map(async (tm) => {
4794
- const user = await ctx.context.adapter.findOne({
4795
- model: "user",
4796
- where: [{
4797
- field: "id",
4798
- value: tm.userId
4799
- }]
4800
- });
4798
+ }],
4799
+ join: { user: true }
4800
+ })).map((tm) => {
4801
+ const user = Array.isArray(tm.user) ? tm.user[0] : tm.user;
4801
4802
  return {
4802
4803
  ...tm,
4803
4804
  user: user ? {
@@ -4807,7 +4808,7 @@ const listTeamMembers = (options) => {
4807
4808
  image: user.image
4808
4809
  } : null
4809
4810
  };
4810
- }));
4811
+ });
4811
4812
  } catch (e) {
4812
4813
  ctx.context.logger.warn("[Dash] Failed to list team members:", e);
4813
4814
  return [];
@@ -4865,7 +4866,7 @@ const addTeamMember = (options) => {
4865
4866
  value: organizationId
4866
4867
  }]
4867
4868
  })) throw ctx.error("BAD_REQUEST", { message: "User is not a member of this organization" });
4868
- if (await ctx.context.adapter.findOne({
4869
+ if (await ctx.context.adapter.count({
4869
4870
  model: "teamMember",
4870
4871
  where: [{
4871
4872
  field: "teamId",
@@ -4874,7 +4875,7 @@ const addTeamMember = (options) => {
4874
4875
  field: "userId",
4875
4876
  value: ctx.body.userId
4876
4877
  }]
4877
- })) throw ctx.error("BAD_REQUEST", { message: "User is already a member of this team" });
4878
+ }) > 0) throw ctx.error("BAD_REQUEST", { message: "User is already a member of this team" });
4878
4879
  if (orgOptions?.teams?.maximumMembersPerTeam) {
4879
4880
  const teamMemberCount = await ctx.context.adapter.count({
4880
4881
  model: "teamMember",
@@ -5023,13 +5024,13 @@ const createOrganization = (options) => {
5023
5024
  });
5024
5025
  if (!user) throw ctx.error("BAD_REQUEST", { message: "User not found" });
5025
5026
  const orgOptions = organizationPlugin.options || {};
5026
- if (await ctx.context.adapter.findOne({
5027
+ if (await ctx.context.adapter.count({
5027
5028
  model: "organization",
5028
5029
  where: [{
5029
5030
  field: "slug",
5030
5031
  value: ctx.body.slug
5031
5032
  }]
5032
- })) throw ctx.error("BAD_REQUEST", { message: "Organization already exists" });
5033
+ }) > 0) throw ctx.error("BAD_REQUEST", { message: "Organization already exists" });
5033
5034
  let orgData = {
5034
5035
  ...ctx.body,
5035
5036
  defaultTeamName: void 0
@@ -5178,14 +5179,15 @@ const updateOrganization = (options) => {
5178
5179
  const { organizationId } = ctx.context.payload;
5179
5180
  const orgOptions = ctx.context.getPlugin("organization")?.options || {};
5180
5181
  if (ctx.body.slug) {
5181
- const existingOrg = await ctx.context.adapter.findOne({
5182
+ const orgWithSlug = await ctx.context.adapter.findOne({
5182
5183
  model: "organization",
5183
5184
  where: [{
5184
5185
  field: "slug",
5185
5186
  value: ctx.body.slug
5186
- }]
5187
+ }],
5188
+ select: ["id"]
5187
5189
  });
5188
- if (existingOrg && existingOrg.id !== organizationId) throw ctx.error("BAD_REQUEST", { message: "Slug already exists" });
5190
+ if (orgWithSlug && orgWithSlug.id !== organizationId) throw ctx.error("BAD_REQUEST", { message: "Slug already exists" });
5189
5191
  }
5190
5192
  const owners = await ctx.context.adapter.findMany({
5191
5193
  model: "member",
@@ -5200,17 +5202,12 @@ const updateOrganization = (options) => {
5200
5202
  field: "createdAt",
5201
5203
  direction: "asc"
5202
5204
  },
5203
- limit: 1
5205
+ limit: 1,
5206
+ join: { user: true }
5204
5207
  });
5205
5208
  if (owners.length === 0) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
5206
5209
  const owner = owners[0];
5207
- const updatedByUser = await ctx.context.adapter.findOne({
5208
- model: "user",
5209
- where: [{
5210
- field: "id",
5211
- value: owner.userId
5212
- }]
5213
- });
5210
+ const updatedByUser = Array.isArray(owner.user) ? owner.user[0] : owner.user;
5214
5211
  if (!updatedByUser) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
5215
5212
  let updateData = { ...ctx.body };
5216
5213
  if (typeof updateData.metadata === "string") try {
@@ -5472,10 +5469,11 @@ const inviteMember = (options) => {
5472
5469
  }]
5473
5470
  });
5474
5471
  if (!invitedBy) throw ctx.error("BAD_REQUEST", { message: "Invited by user not found" });
5472
+ const invitationEmail = normalizeEmail(ctx.body.email, ctx.context);
5475
5473
  return await organizationPlugin.endpoints.createInvitation({
5476
5474
  headers: ctx.request?.headers ?? new Headers(),
5477
5475
  body: {
5478
- email: ctx.body.email,
5476
+ email: invitationEmail,
5479
5477
  role: ctx.body.role,
5480
5478
  organizationId
5481
5479
  },
@@ -5497,11 +5495,12 @@ const checkUserByEmail = (options) => {
5497
5495
  use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))]
5498
5496
  }, async (ctx) => {
5499
5497
  const { organizationId } = ctx.context.payload;
5498
+ const email = normalizeEmail(ctx.body.email, ctx.context);
5500
5499
  const user = await ctx.context.adapter.findOne({
5501
5500
  model: "user",
5502
5501
  where: [{
5503
5502
  field: "email",
5504
- value: ctx.body.email
5503
+ value: email
5505
5504
  }]
5506
5505
  });
5507
5506
  if (!user) return {
@@ -6095,10 +6094,11 @@ const resendInvitation = (options) => {
6095
6094
  });
6096
6095
  if (!invitedByUser) throw ctx.error("BAD_REQUEST", { message: "Inviter user not found" });
6097
6096
  if (!organizationPlugin.endpoints?.createInvitation) throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Organization plugin endpoints not available" });
6097
+ const invitationEmail = normalizeEmail(invitation.email, ctx.context);
6098
6098
  await organizationPlugin.endpoints.createInvitation({
6099
6099
  headers: ctx.request?.headers ?? new Headers(),
6100
6100
  body: {
6101
- email: invitation.email,
6101
+ email: invitationEmail,
6102
6102
  role: invitation.role,
6103
6103
  organizationId,
6104
6104
  resend: true
@@ -6137,28 +6137,31 @@ const listAllSessions = (options) => {
6137
6137
  }).optional()
6138
6138
  }, async (ctx) => {
6139
6139
  const sessionsCount = await ctx.context.adapter.count({ model: "session" });
6140
+ const limit = ctx.query?.limit || sessionsCount;
6141
+ const offset = ctx.query?.offset || 0;
6140
6142
  const sessions = await ctx.context.adapter.findMany({
6141
6143
  model: "session",
6142
- limit: ctx.query?.limit || sessionsCount,
6143
- offset: ctx.query?.offset || 0,
6144
+ limit,
6145
+ offset,
6144
6146
  sortBy: {
6145
6147
  field: "createdAt",
6146
6148
  direction: "desc"
6147
- }
6148
- });
6149
- return (await ctx.context.adapter.findMany({
6150
- model: "user",
6151
- where: [{
6152
- field: "id",
6153
- value: sessions.map((s) => s.userId),
6154
- operator: "in"
6155
- }]
6156
- })).map((u) => {
6157
- return {
6158
- ...u,
6159
- sessions: sessions.filter((s) => s.userId === u.id)
6160
- };
6161
- });
6149
+ },
6150
+ join: { user: true }
6151
+ });
6152
+ const userMap = /* @__PURE__ */ new Map();
6153
+ for (const s of sessions) {
6154
+ const user = Array.isArray(s.user) ? s.user[0] : s.user;
6155
+ if (!user) continue;
6156
+ const { user: _u, ...sessionData } = s;
6157
+ const session = sessionData;
6158
+ if (!userMap.has(user.id)) userMap.set(user.id, {
6159
+ ...user,
6160
+ sessions: []
6161
+ });
6162
+ userMap.get(user.id).sessions.push(session);
6163
+ }
6164
+ return Array.from(userMap.values());
6162
6165
  });
6163
6166
  };
6164
6167
  const revokeSession = (options) => createAuthEndpoint("/dash/sessions/revoke", {
@@ -6267,7 +6270,7 @@ const getUsers = (options) => {
6267
6270
  totalQuery,
6268
6271
  onlineUsersQuery
6269
6272
  ]);
6270
- const hasAdminPlugin = ctx.context.hasPlugin("admin");
6273
+ const hasAdminPlugin = hasPlugin(ctx.context, "admin");
6271
6274
  return {
6272
6275
  users: users.map((user) => {
6273
6276
  const u = user;
@@ -6292,7 +6295,7 @@ const exportUsers = (options) => {
6292
6295
  use: [jwtMiddleware(options)],
6293
6296
  query: getUsersQuerySchema
6294
6297
  }, async (ctx) => {
6295
- const hasAdminPlugin = ctx.context.hasPlugin("admin");
6298
+ const hasAdminPlugin = hasPlugin(ctx.context, "admin");
6296
6299
  return exportFactory({
6297
6300
  model: "user",
6298
6301
  limit: ctx.query?.limit,
@@ -6428,7 +6431,8 @@ const createUser = (options) => {
6428
6431
  }).passthrough()
6429
6432
  }, async (ctx) => {
6430
6433
  const userData = ctx.body;
6431
- if (await ctx.context.internalAdapter.findUserByEmail(userData.email)) throw new APIError("BAD_REQUEST", { message: "User with this email already exist" });
6434
+ const email = normalizeEmail(userData.email, ctx.context);
6435
+ if (await ctx.context.internalAdapter.findUserByEmail(email)) throw new APIError("BAD_REQUEST", { message: "User with this email already exist" });
6432
6436
  let password = null;
6433
6437
  if (userData.generatePassword && !userData.password) password = generateId(12);
6434
6438
  else if (userData.password && userData.password.trim() !== "") password = userData.password;
@@ -6449,6 +6453,7 @@ const createUser = (options) => {
6449
6453
  }
6450
6454
  const user = await ctx.context.internalAdapter.createUser({
6451
6455
  ...userData,
6456
+ email,
6452
6457
  emailVerified: userData.emailVerified,
6453
6458
  createdAt: /* @__PURE__ */ new Date(),
6454
6459
  updatedAt: /* @__PURE__ */ new Date()
@@ -6562,7 +6567,7 @@ const getUserDetails = (options) => {
6562
6567
  }, async (ctx) => {
6563
6568
  const { userId } = ctx.context.payload;
6564
6569
  const minimal = !!ctx.query?.minimal;
6565
- const hasAdminPlugin = ctx.context.hasPlugin("admin");
6570
+ const hasAdminPlugin = hasPlugin(ctx.context, "admin");
6566
6571
  const user = await ctx.context.adapter.findOne({
6567
6572
  model: "user",
6568
6573
  where: [{
@@ -6632,47 +6637,39 @@ const getUserOrganizations = (options) => {
6632
6637
  const isOrgEnabled = ctx.context.getPlugin("organization");
6633
6638
  if (!isOrgEnabled) return { organizations: [] };
6634
6639
  const isTeamEnabled = isOrgEnabled.options?.teams?.enabled;
6635
- const [member, teamMembers] = await Promise.all([ctx.context.adapter.findMany({
6640
+ const [membersWithOrg, teamMembersWithTeam] = await Promise.all([ctx.context.adapter.findMany({
6636
6641
  model: "member",
6637
6642
  where: [{
6638
6643
  field: "userId",
6639
6644
  value: userId
6640
- }]
6645
+ }],
6646
+ join: { organization: true }
6641
6647
  }), isTeamEnabled ? ctx.context.adapter.findMany({
6642
6648
  model: "teamMember",
6643
6649
  where: [{
6644
6650
  field: "userId",
6645
6651
  value: userId
6646
- }]
6652
+ }],
6653
+ join: { team: true }
6647
6654
  }).catch((e) => {
6648
6655
  ctx.context.logger.error("[Dash] Failed to fetch team members:", e);
6649
6656
  return [];
6650
6657
  }) : Promise.resolve([])]);
6651
- if (member.length === 0) return { organizations: [] };
6652
- const [organizations, teams] = await Promise.all([ctx.context.adapter.findMany({
6653
- model: "organization",
6654
- where: [{
6655
- field: "id",
6656
- value: member.map((m) => m.organizationId),
6657
- operator: "in"
6658
- }]
6659
- }), isTeamEnabled && teamMembers.length > 0 ? ctx.context.adapter.findMany({
6660
- model: "team",
6661
- where: [{
6662
- field: "id",
6663
- value: teamMembers.map((tm) => tm.teamId),
6664
- operator: "in"
6665
- }]
6666
- }) : Promise.resolve([])]);
6667
- return { organizations: organizations.map((organization) => ({
6668
- id: organization.id,
6669
- name: organization.name,
6670
- logo: organization.logo,
6671
- createdAt: organization.createdAt,
6672
- slug: organization.slug,
6673
- role: member.find((m) => m.organizationId === organization.id)?.role,
6674
- teams: teams.filter((team) => team.organizationId === organization.id)
6675
- })) };
6658
+ if (membersWithOrg.length === 0) return { organizations: [] };
6659
+ const teams = teamMembersWithTeam.map((tm) => Array.isArray(tm.team) ? tm.team[0] : tm.team).filter((t) => t != null);
6660
+ return { organizations: membersWithOrg.map((m) => {
6661
+ const organization = Array.isArray(m.organization) ? m.organization[0] : m.organization;
6662
+ if (!organization) return null;
6663
+ return {
6664
+ id: organization.id,
6665
+ name: organization.name,
6666
+ logo: organization.logo,
6667
+ createdAt: organization.createdAt,
6668
+ slug: organization.slug,
6669
+ role: m.role,
6670
+ teams: teams.filter((team) => team.organizationId === organization.id)
6671
+ };
6672
+ }).filter(Boolean) };
6676
6673
  });
6677
6674
  };
6678
6675
  const updateUser = (options) => createAuthEndpoint("/dash/update-user", {
@@ -7462,6 +7459,20 @@ const generateBackupCodes = (options) => createAuthEndpoint("/dash/generate-back
7462
7459
  return { backupCodes: newBackupCodes };
7463
7460
  });
7464
7461
 
7462
+ //#endregion
7463
+ //#region src/routes/validate.ts
7464
+ /**
7465
+ * Lightweight endpoint to verify API key ownership during onboarding
7466
+ */
7467
+ const getValidate = (options) => {
7468
+ return createAuthEndpoint("/dash/validate", {
7469
+ method: "GET",
7470
+ use: [jwtValidateMiddleware(options)]
7471
+ }, async () => {
7472
+ return { valid: true };
7473
+ });
7474
+ };
7475
+
7465
7476
  //#endregion
7466
7477
  //#region src/pow.ts
7467
7478
  /** Default difficulty in bits (18 = ~500ms solve time) */
@@ -7965,6 +7976,7 @@ const dash = (options) => {
7965
7976
  },
7966
7977
  endpoints: {
7967
7978
  getDashConfig: getConfig(opts),
7979
+ getDashValidate: getValidate(opts),
7968
7980
  getDashUsers: getUsers(opts),
7969
7981
  exportDashUsers: exportUsers(opts),
7970
7982
  getOnlineUsersCount: getOnlineUsersCount(opts),
@@ -8051,4 +8063,4 @@ const dash = (options) => {
8051
8063
  };
8052
8064
 
8053
8065
  //#endregion
8054
- export { CHALLENGE_TTL, DEFAULT_DIFFICULTY, EMAIL_TEMPLATES, SMS_TEMPLATES, USER_EVENT_TYPES, createEmailSender, createSMSSender, dash, decodePoWChallenge, encodePoWSolution, sendBulkEmails, sendEmail, sendSMS, sentinel, solvePoWChallenge, verifyPoWSolution };
8066
+ export { CHALLENGE_TTL, DEFAULT_DIFFICULTY, EMAIL_TEMPLATES, SMS_TEMPLATES, USER_EVENT_TYPES, createEmailSender, createSMSSender, dash, decodePoWChallenge, encodePoWSolution, normalizeEmail, sendBulkEmails, sendEmail, sendSMS, sentinel, solvePoWChallenge, verifyPoWSolution };