@better-auth/infra 0.3.6 → 0.4.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.mjs CHANGED
@@ -1,15 +1,15 @@
1
- import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-CQiIiY9L.mjs";
2
- import { a as createAPI, o as createKV, r as hmacSha256Hex } from "./crypto-Deqoay7H.mjs";
1
+ import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-CcJHk5SE.mjs";
2
+ import { a as createAPI, n as hash$1, o as createKV, r as hmacSha256Hex } from "./crypto-CAdWFWEz.mjs";
3
3
  import { EMAIL_TEMPLATES, createEmailSender, sendBulkEmails, sendEmail } from "./email.mjs";
4
- import { getCurrentAuthContext } from "@better-auth/core/context";
4
+ import { getCurrentAdapter, getCurrentAuthContext, getCurrentDBAdapterAsyncLocalStorage, runWithTransaction } from "@better-auth/core/context";
5
5
  import { APIError, generateId, getAuthTables, logger } from "better-auth";
6
6
  import { env } from "@better-auth/core/env";
7
7
  import { APIError as APIError$1, createAuthEndpoint, createAuthMiddleware, requestPasswordReset, sendVerificationEmailFn, sessionMiddleware } from "better-auth/api";
8
+ import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
8
9
  import { createFetch } from "@better-fetch/fetch";
9
10
  import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
10
- import { createLocalJWKSet, jwtVerify } from "jose";
11
11
  import z$1, { z } from "zod";
12
- import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
12
+ import { createLocalJWKSet, jwtVerify } from "jose";
13
13
  import { generateRandomString, symmetricEncrypt } from "better-auth/crypto";
14
14
  import { createOTP } from "@better-auth/utils/otp";
15
15
  //#region src/options.ts
@@ -18,25 +18,38 @@ function resolveConnectionOptions(options) {
18
18
  apiUrl: options?.apiUrl || INFRA_API_URL,
19
19
  kvUrl: options?.kvUrl || INFRA_KV_URL,
20
20
  apiKey: options?.apiKey || env.BETTER_AUTH_API_KEY || "",
21
- apiTimeout: options?.apiTimeout ?? 3e3,
22
- kvTimeout: options?.kvTimeout ?? 1e3
21
+ apiOptions: { timeout: options?.apiOptions?.timeout ?? options?.apiTimeout ?? 3e3 },
22
+ kvOptions: {
23
+ timeout: options?.kvOptions?.timeout ?? options?.kvTimeout ?? 1e3,
24
+ retry: {
25
+ attempts: options?.kvOptions?.retry?.attempts ?? 2,
26
+ baseDelay: options?.kvOptions?.retry?.baseDelay ?? 400,
27
+ maxDelay: options?.kvOptions?.retry?.maxDelay ?? 600
28
+ }
29
+ }
23
30
  };
24
31
  }
25
32
  function resolveDashOptions(options) {
26
- const activityUpdateInterval = options?.activityTracking?.updateInterval ?? 3e5;
27
33
  return {
28
34
  ...resolveConnectionOptions(options),
29
- ...options,
30
35
  activityTracking: {
31
36
  ...options?.activityTracking,
32
- updateInterval: activityUpdateInterval
37
+ updateInterval: options?.activityTracking?.updateInterval ?? 3e5
38
+ },
39
+ managedDirectorySync: {
40
+ enabled: options?.managedDirectorySync?.enabled ?? false,
41
+ ssoPairing: options?.managedDirectorySync?.ssoPairing ?? true,
42
+ membershipProjection: {
43
+ enabled: options?.managedDirectorySync?.membershipProjection?.enabled ?? true,
44
+ role: options?.managedDirectorySync?.membershipProjection?.role ?? "member"
45
+ }
33
46
  }
34
47
  };
35
48
  }
36
49
  function resolveSentinelOptions(options) {
37
50
  return {
38
51
  ...resolveConnectionOptions(options),
39
- ...options
52
+ security: options?.security
40
53
  };
41
54
  }
42
55
  //#endregion
@@ -149,7 +162,12 @@ const USER_EVENT_TYPES = {
149
162
  //#endregion
150
163
  //#region src/events/core/adapter.ts
151
164
  function resolveUserFromContext(userId, ctx) {
152
- for (const candidate of [ctx.context.session?.user, ctx.context.newSession?.user]) if (candidate?.id === userId) return {
165
+ const returnedUser = ctx.context.returned?.user;
166
+ for (const candidate of [
167
+ ctx.context.session?.user,
168
+ ctx.context.newSession?.user,
169
+ returnedUser
170
+ ]) if (candidate?.id === userId) return {
153
171
  id: candidate.id,
154
172
  name: candidate.name,
155
173
  email: candidate.email ?? null,
@@ -341,8 +359,80 @@ const getLoginMethod = (ctx) => {
341
359
  return null;
342
360
  };
343
361
  //#endregion
362
+ //#region src/compat/account.ts
363
+ /**
364
+ * Mirrors better-auth 1.7+ `createLocalAccountIssuer` so infra can
365
+ * typecheck against older core packages while emitting the same issuer strings.
366
+ */
367
+ function createLocalAccountIssuer(providerId) {
368
+ return `local:${encodeURIComponent(providerId)}`;
369
+ }
370
+ /**
371
+ * Mirrors better-auth 1.7+ `createOAuthAccountIssuer`.
372
+ */
373
+ function createOAuthAccountIssuer(providerId) {
374
+ return `local:oauth:${encodeURIComponent(providerId)}`;
375
+ }
376
+ /** True when the installed better-auth build scopes accounts by issuer. */
377
+ function supportsIssuerScopedAccounts(adapter) {
378
+ return typeof adapter.findAccountByKey === "function";
379
+ }
380
+ /** Creates a credential account across pre/post issuer-scoped account schemas. */
381
+ async function createCredentialAccountCompat(adapter, params) {
382
+ if (supportsIssuerScopedAccounts(adapter)) {
383
+ await adapter.createAccount({
384
+ userId: params.userId,
385
+ providerId: "credential",
386
+ accountId: params.userId,
387
+ issuer: createLocalAccountIssuer("credential"),
388
+ password: params.password
389
+ });
390
+ return;
391
+ }
392
+ await adapter.createAccount({
393
+ userId: params.userId,
394
+ providerId: "credential",
395
+ accountId: params.userId,
396
+ password: params.password
397
+ });
398
+ }
399
+ /** Finds an unlink target by provider and local account row id. */
400
+ function resolveAccountForUnlink(accounts, selector) {
401
+ return accounts.find((account) => account.providerId === selector.providerId && account.id === selector.accountId);
402
+ }
403
+ //#endregion
344
404
  //#region src/events/core/oauth-callback-user.ts
345
405
  const OAUTH_CALLBACK_USER = Symbol.for("dash.oauthCallbackUser");
406
+ function normalizeProviderSubject(subject) {
407
+ if (!subject || subject === "undefined" || subject === "null") return;
408
+ return subject;
409
+ }
410
+ async function resolveAccountKey(provider, tokens, profile) {
411
+ const keyedProvider = provider;
412
+ const context = {
413
+ tokens,
414
+ profile
415
+ };
416
+ try {
417
+ let rawSubject;
418
+ if (keyedProvider.accountSubject) rawSubject = String(await keyedProvider.accountSubject(context));
419
+ else {
420
+ const { id, sub } = profile;
421
+ const value = id ?? sub;
422
+ rawSubject = value == null ? void 0 : String(value);
423
+ }
424
+ const accountId = rawSubject ? normalizeProviderSubject(rawSubject) : void 0;
425
+ if (!accountId) return void 0;
426
+ const issuer = typeof keyedProvider.accountIssuer === "function" ? await keyedProvider.accountIssuer(context) : keyedProvider.accountIssuer ?? createOAuthAccountIssuer(provider.id);
427
+ if (!issuer) return void 0;
428
+ return {
429
+ issuer,
430
+ accountId
431
+ };
432
+ } catch {
433
+ return;
434
+ }
435
+ }
346
436
  /**
347
437
  * Stash OAuth profile on the request context when better-auth calls getUserInfo
348
438
  * during the callback, before the authorization code is consumed.
@@ -355,19 +445,36 @@ function instrumentSocialProviders(providers) {
355
445
  const user = result?.user;
356
446
  if (user) try {
357
447
  const endpointCtx = await getCurrentAuthContext();
358
- endpointCtx.context[OAUTH_CALLBACK_USER] = user;
448
+ const adapter = endpointCtx.context.internalAdapter;
449
+ const accountKey = result.data && typeof adapter.findAccountOwnerByKey === "function" ? await resolveAccountKey(provider, token, result.data) : void 0;
450
+ endpointCtx.context[OAUTH_CALLBACK_USER] = {
451
+ user,
452
+ accountKey
453
+ };
359
454
  } catch {}
360
455
  return result;
361
456
  };
362
457
  }
363
458
  }
364
459
  async function resolveOAuthUser(providerId, ctx) {
365
- const oauthUser = ctx.context[OAUTH_CALLBACK_USER];
366
- if (!oauthUser) return null;
460
+ const stashed = ctx.context[OAUTH_CALLBACK_USER];
461
+ if (!stashed) return null;
462
+ const { user: oauthUser, accountKey } = stashed;
367
463
  const email = oauthUser.email?.toLowerCase();
464
+ const adapter = ctx.context.internalAdapter;
465
+ if (accountKey && typeof adapter.findAccountOwnerByKey === "function") try {
466
+ const owned = await adapter.findAccountOwnerByKey(accountKey);
467
+ if (owned?.kind === "owned") return {
468
+ id: owned.user.id,
469
+ email: owned.user.email,
470
+ name: owned.user.name
471
+ };
472
+ } catch (error) {
473
+ logger.debug("[Dash] Failed to find OAuth user by account key:", error);
474
+ }
368
475
  const accountId = oauthUser.id !== void 0 && oauthUser.id !== null ? String(oauthUser.id) : void 0;
369
- if (email && accountId) try {
370
- const result = await ctx.context.internalAdapter.findOAuthUser(email, accountId, providerId);
476
+ if (email && accountId && typeof adapter.findOAuthUser === "function") try {
477
+ const result = await adapter.findOAuthUser(email, accountId, providerId);
371
478
  if (result?.user) return {
372
479
  id: result.user.id,
373
480
  email: result.user.email,
@@ -941,7 +1048,7 @@ function resolveClientIpFromHeaders(headers, ipAddressHeaders) {
941
1048
  /**
942
1049
  * Identification Service
943
1050
  *
944
- * Fetches identification data from the durable-kv service
1051
+ * Fetches identification data from the identify API
945
1052
  * when a request includes an X-Request-Id header.
946
1053
  */
947
1054
  const IDENTIFICATION_COOKIE_NAME = "__infra-rid";
@@ -957,31 +1064,38 @@ function cleanupCache() {
957
1064
  function maybeCleanup() {
958
1065
  if (Date.now() - lastCleanup > CACHE_TTL_MS || identificationCache.size > CACHE_MAX_SIZE) cleanupCache();
959
1066
  }
960
- /** Retries 404 races with exponential backoff (≤1s total wait across retries). */
961
- const IDENTIFY_GET_RETRY = {
962
- type: "exponential",
1067
+ const DEFAULT_KV_RETRY = {
963
1068
  attempts: 2,
964
1069
  baseDelay: 400,
965
- maxDelay: 600,
966
- shouldRetry(response) {
967
- if (response === null) return true;
968
- return response.status === 404;
969
- }
1070
+ maxDelay: 600
970
1071
  };
971
- function identifyGetRetryDelay(attempt) {
972
- return Math.min(600, 400 * 2 ** attempt);
1072
+ function resolveIdentifyGetRetry(retry) {
1073
+ return {
1074
+ type: "exponential",
1075
+ attempts: retry.attempts,
1076
+ baseDelay: retry.baseDelay,
1077
+ maxDelay: retry.maxDelay,
1078
+ shouldRetry(response) {
1079
+ if (response === null) return true;
1080
+ return response.status === 404;
1081
+ }
1082
+ };
1083
+ }
1084
+ function identifyGetRetryDelay(retry, attempt) {
1085
+ return Math.min(retry.maxDelay, retry.baseDelay * 2 ** attempt);
973
1086
  }
974
1087
  /**
975
- * Fetch identification data from durable-kv by requestId
1088
+ * Fetch identification data from the identify API by requestId
976
1089
  */
977
- async function getIdentification(requestId, $kv) {
1090
+ async function getIdentification(requestId, $kv, retryOptions = DEFAULT_KV_RETRY) {
978
1091
  maybeCleanup();
979
1092
  const cached = identificationCache.get(requestId);
980
1093
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.data;
1094
+ const retry = resolveIdentifyGetRetry(retryOptions);
981
1095
  for (let networkAttempt = 0;; networkAttempt++) try {
982
1096
  const { data, error } = await $kv(`/identify/${requestId}`, {
983
1097
  method: "GET",
984
- retry: IDENTIFY_GET_RETRY
1098
+ retry
985
1099
  });
986
1100
  if (data && !error) {
987
1101
  identificationCache.set(requestId, {
@@ -996,11 +1110,11 @@ async function getIdentification(requestId, $kv) {
996
1110
  });
997
1111
  return null;
998
1112
  } catch (error) {
999
- if (networkAttempt >= IDENTIFY_GET_RETRY.attempts) {
1113
+ if (networkAttempt >= retry.attempts) {
1000
1114
  logger.error("[Dash] Failed to fetch identification:", error);
1001
1115
  return null;
1002
1116
  }
1003
- await new Promise((resolve) => setTimeout(resolve, identifyGetRetryDelay(networkAttempt)));
1117
+ await new Promise((resolve) => setTimeout(resolve, identifyGetRetryDelay(retryOptions, networkAttempt)));
1004
1118
  }
1005
1119
  }
1006
1120
  /**
@@ -1046,6 +1160,7 @@ function resolveUntrustedVisitorId(visitorId, ip, headerVisitorId) {
1046
1160
  * @param $kv — KV client from {@link createKV} for the same plugin options (one instance per plugin).
1047
1161
  */
1048
1162
  function createIdentificationMiddleware($kv, options) {
1163
+ const retry = options?.retry ?? DEFAULT_KV_RETRY;
1049
1164
  return createAuthMiddleware(async (ctx) => {
1050
1165
  const skipIdentification = options?.skipIdentification?.(ctx) ?? false;
1051
1166
  let headerVisitorId = null;
@@ -1058,7 +1173,7 @@ function createIdentificationMiddleware($kv, options) {
1058
1173
  ctx.context.requestId = requestId;
1059
1174
  if (skipIdentification) ctx.context.identification = null;
1060
1175
  else if (requestId) {
1061
- if (ctx.context.identification === void 0) ctx.context.identification = await getIdentification(requestId, $kv) ?? null;
1176
+ if (ctx.context.identification === void 0) ctx.context.identification = await getIdentification(requestId, $kv, retry) ?? null;
1062
1177
  } else ctx.context.identification = null;
1063
1178
  const identification = ctx.context.identification;
1064
1179
  const visitorId = resolveSecurityVisitorId(headerVisitorId, identification);
@@ -2188,9 +2303,24 @@ const sentinel = (options) => {
2188
2303
  const opts = resolveSentinelOptions(options);
2189
2304
  const $api = createAPI(opts);
2190
2305
  const $apiThrowing = createAPI(opts, { throw: true });
2191
- const $kv = createKV(opts);
2306
+ const $kv = createKV({
2307
+ kvUrl: opts.kvUrl,
2308
+ apiKey: opts.apiKey,
2309
+ timeout: opts.kvOptions.timeout
2310
+ });
2192
2311
  const { tracker } = initTrackEvents($api);
2193
2312
  const { trackEvent } = tracker;
2313
+ const STALE_ACCOUNT_BLOCK_ERROR = {
2314
+ message: "This account has been inactive for an extended period. Please contact support to reactivate.",
2315
+ code: "STALE_ACCOUNT"
2316
+ };
2317
+ function isStaleAccountError(returned) {
2318
+ if (!(returned instanceof Error)) return false;
2319
+ const err = returned;
2320
+ if (err.code === STALE_ACCOUNT_BLOCK_ERROR.code) return true;
2321
+ return typeof err.body === "object" && err.body !== null && "code" in err.body && err.body.code === STALE_ACCOUNT_BLOCK_ERROR.code;
2322
+ }
2323
+ let activityTrackingEnabled = false;
2194
2324
  if (!opts.apiKey) logger.warn("[Sentinel] Missing BETTER_AUTH_API_KEY. Security checks may fall back to allow mode when the Infra API rejects requests.");
2195
2325
  const securityService = createSecurityClient(opts, $apiThrowing, opts.security || {}, (event) => {
2196
2326
  trackEvent({
@@ -2220,7 +2350,7 @@ const sentinel = (options) => {
2220
2350
  return {
2221
2351
  id: "sentinel",
2222
2352
  init(ctx) {
2223
- const activityTrackingEnabled = (ctx.getPlugin("dash")?.options)?.activityTracking?.enabled === true;
2353
+ activityTrackingEnabled = (ctx.getPlugin("dash")?.options)?.activityTracking?.enabled === true;
2224
2354
  return { options: {
2225
2355
  emailValidation: opts.security?.emailValidation,
2226
2356
  emailNormalization: opts.security?.emailNormalization,
@@ -2324,35 +2454,6 @@ const sentinel = (options) => {
2324
2454
  if (visitorId) {
2325
2455
  if (await securityService.checkUnknownDevice(session.userId, visitorId) && user?.email) await ctx.context.runInBackgroundOrAwait(securityService.notifyUnknownDevice(session.userId, user.email, identification));
2326
2456
  }
2327
- if (opts.security?.staleUsers?.enabled && user) {
2328
- recordCheck(ctx, "stale_users");
2329
- const lastActiveAtForStale = activityTrackingEnabled ? user.lastActiveAt ?? null : null;
2330
- const staleCheck = await securityService.checkStaleUser(session.userId, lastActiveAtForStale);
2331
- if (staleCheck.isStale) {
2332
- const staleOpts = opts.security.staleUsers;
2333
- const notificationPromises = [];
2334
- if (staleCheck.notifyUser && user.email) notificationPromises.push(securityService.notifyStaleAccountUser(user.email, user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2335
- if (staleCheck.notifyAdmin && staleOpts.adminEmail) notificationPromises.push(securityService.notifyStaleAccountAdmin(staleOpts.adminEmail, session.userId, user.email || "unknown", user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2336
- if (notificationPromises.length > 0) Promise.all(notificationPromises).catch((error) => {
2337
- logger.error("[Sentinel] Failed to send stale account notifications:", error);
2338
- });
2339
- if (staleCheck.action === "block") {
2340
- setOutcome(ctx, "blocked", "stale_users", {
2341
- userId: session.userId,
2342
- daysSinceLastActive: staleCheck.daysSinceLastActive,
2343
- staleDays: staleCheck.staleDays,
2344
- lastActiveAt: staleCheck.lastActiveAt,
2345
- notifyUser: staleCheck.notifyUser,
2346
- notifyAdmin: staleCheck.notifyAdmin
2347
- });
2348
- emitEvaluation(ctx, trackEvent);
2349
- throw new APIError("FORBIDDEN", {
2350
- message: "This account has been inactive for an extended period. Please contact support to reactivate.",
2351
- code: "STALE_ACCOUNT"
2352
- });
2353
- }
2354
- }
2355
- }
2356
2457
  if (opts.security?.impossibleTravel?.enabled && identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location, identification.ip));
2357
2458
  }
2358
2459
  } }
@@ -2363,7 +2464,10 @@ const sentinel = (options) => {
2363
2464
  before: [
2364
2465
  {
2365
2466
  matcher: (ctx) => ctx.request?.method !== "GET",
2366
- handler: createIdentificationMiddleware($kv, { skipIdentification: (ctx) => isDashRoute(ctx.path) })
2467
+ handler: createIdentificationMiddleware($kv, {
2468
+ skipIdentification: (ctx) => isDashRoute(ctx.path),
2469
+ retry: opts.kvOptions.retry
2470
+ })
2367
2471
  },
2368
2472
  ...emailHooks.before,
2369
2473
  ...phoneValidationHooks.before,
@@ -2462,6 +2566,52 @@ const sentinel = (options) => {
2462
2566
  }
2463
2567
  ],
2464
2568
  after: [{
2569
+ matcher: (ctx) => !!opts.security?.staleUsers?.enabled && !isDashRoute(ctx.path),
2570
+ handler: createAuthMiddleware(async (ctx) => {
2571
+ if (ctx.context.returned instanceof Error) return;
2572
+ const created = ctx.context.newSession;
2573
+ const userId = created?.user?.id ?? created?.session?.userId;
2574
+ const sessionToken = created?.session?.token;
2575
+ if (!userId || !sessionToken) return;
2576
+ let user = created?.user ?? null;
2577
+ try {
2578
+ user = await getUserById(userId, ctx, { includeLastActiveAt: activityTrackingEnabled }) ?? user;
2579
+ } catch (error) {
2580
+ logger.warn("[Sentinel] Failed to fetch user for stale-account check:", error);
2581
+ if (!user) return;
2582
+ }
2583
+ if (!user) return;
2584
+ recordCheck(ctx, "stale_users");
2585
+ const staleCheck = await securityService.checkStaleUser(userId, activityTrackingEnabled ? user.lastActiveAt ?? null : null);
2586
+ if (!staleCheck.isStale) return;
2587
+ const identification = ctx.context.identification;
2588
+ const staleOpts = opts.security?.staleUsers;
2589
+ const notificationPromises = [];
2590
+ if (staleCheck.notifyUser && user.email) notificationPromises.push(securityService.notifyStaleAccountUser(user.email, user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2591
+ if (staleCheck.notifyAdmin && staleOpts?.adminEmail) notificationPromises.push(securityService.notifyStaleAccountAdmin(staleOpts.adminEmail, userId, user.email || "unknown", user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2592
+ if (notificationPromises.length > 0) Promise.all(notificationPromises).catch((error) => {
2593
+ logger.error("[Sentinel] Failed to send stale account notifications:", error);
2594
+ });
2595
+ if (staleCheck.action !== "block") return;
2596
+ setOutcome(ctx, "blocked", "stale_users", {
2597
+ userId,
2598
+ daysSinceLastActive: staleCheck.daysSinceLastActive,
2599
+ staleDays: staleCheck.staleDays,
2600
+ lastActiveAt: staleCheck.lastActiveAt,
2601
+ notifyUser: staleCheck.notifyUser,
2602
+ notifyAdmin: staleCheck.notifyAdmin
2603
+ });
2604
+ emitEvaluation(ctx, trackEvent);
2605
+ try {
2606
+ await ctx.context.internalAdapter.deleteSession(sessionToken);
2607
+ } catch (error) {
2608
+ logger.warn("[Sentinel] Failed to delete stale-blocked session:", error);
2609
+ }
2610
+ deleteSessionCookie(ctx);
2611
+ ctx.context.setNewSession(null);
2612
+ throw new APIError("FORBIDDEN", STALE_ACCOUNT_BLOCK_ERROR);
2613
+ })
2614
+ }, {
2465
2615
  matcher: (ctx) => ctx.request?.method !== "GET" && !isDashRoute(ctx.path),
2466
2616
  handler: createAuthMiddleware(async (ctx) => {
2467
2617
  const untrustedVisitorId = ctx.context.untrustedVisitorId;
@@ -2477,14 +2627,427 @@ const sentinel = (options) => {
2477
2627
  identifier: loginId,
2478
2628
  userAgent: ctx.headers?.get?.("user-agent") || ""
2479
2629
  });
2480
- if (isPasswordSignInRoute && ctx.context.returned instanceof Error && loginId && body?.password && untrustedVisitorId) await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(loginId, untrustedVisitorId, body.password, ip, ctx.context.requestId ?? null));
2481
- if (isPasswordSignInRoute && !(ctx.context.returned instanceof Error) && loginId) await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(loginId));
2630
+ const returned = ctx.context.returned;
2631
+ const staleBlocked = isStaleAccountError(returned);
2632
+ if (isPasswordSignInRoute && returned instanceof Error && !staleBlocked && loginId && body?.password && untrustedVisitorId) await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(loginId, untrustedVisitorId, body.password, ip, ctx.context.requestId ?? null));
2633
+ if (isPasswordSignInRoute && (!(returned instanceof Error) || staleBlocked) && loginId) await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(loginId));
2482
2634
  })
2483
2635
  }]
2484
2636
  }
2485
2637
  };
2486
2638
  };
2487
2639
  //#endregion
2640
+ //#region src/directory-sync/membership-projection.ts
2641
+ async function createMembershipKey(provisioningDomainId, userId) {
2642
+ return `directory-sync-membership:${await hash$1(JSON.stringify([provisioningDomainId, userId]))}`;
2643
+ }
2644
+ async function findDirectory(input, context) {
2645
+ const row = await context.database.findOne({
2646
+ model: "directorySyncConnection",
2647
+ where: [{
2648
+ field: "provisioningDomainId",
2649
+ value: input.provisioningDomainId
2650
+ }]
2651
+ });
2652
+ if (!row || row.status !== "active" && row.status !== "decommissioning" && row.status !== "decommissioned" || !row.connectionId) return null;
2653
+ return row;
2654
+ }
2655
+ async function findMembership(context, organizationId, userId) {
2656
+ const memberships = await context.database.findMany({
2657
+ model: "member",
2658
+ where: [{
2659
+ field: "organizationId",
2660
+ value: organizationId
2661
+ }, {
2662
+ field: "userId",
2663
+ value: userId
2664
+ }]
2665
+ });
2666
+ if (memberships.length > 1) throw new Error("Directory sync membership projection requires a unique organization and user membership");
2667
+ return memberships.at(0) ?? null;
2668
+ }
2669
+ async function findProvenance(context, membershipKey) {
2670
+ return await context.database.findOne({
2671
+ model: "directorySyncMembershipProvenance",
2672
+ where: [{
2673
+ field: "membershipKey",
2674
+ value: membershipKey
2675
+ }]
2676
+ });
2677
+ }
2678
+ async function createProvenance(context, input) {
2679
+ const now = /* @__PURE__ */ new Date();
2680
+ await context.database.create({
2681
+ model: "directorySyncMembershipProvenance",
2682
+ data: {
2683
+ ...input,
2684
+ createdAt: now,
2685
+ updatedAt: now
2686
+ }
2687
+ });
2688
+ }
2689
+ function createOrganizationMembershipProjection(options) {
2690
+ if (!options.role.trim()) throw new Error("Directory sync membership role must not be empty");
2691
+ return async (input, context) => {
2692
+ const directory = await findDirectory(input, context);
2693
+ if (!directory?.connectionId) return;
2694
+ const membershipKey = await createMembershipKey(input.provisioningDomainId, input.userId);
2695
+ const [membership, provenance] = await Promise.all([findMembership(context, directory.organizationId, input.userId), findProvenance(context, membershipKey)]);
2696
+ if (provenance && (provenance.ownership !== "created" && provenance.ownership !== "observed" || provenance.provisioningDomainId !== input.provisioningDomainId || provenance.organizationId !== directory.organizationId || provenance.userId !== input.userId)) throw new Error("Directory sync membership provenance does not match its provisioning domain");
2697
+ if (!input.active) {
2698
+ if (!provenance) return;
2699
+ if (provenance.ownership === "created" && membership?.id === provenance.memberId && membership.organizationId === provenance.organizationId && membership.userId === provenance.userId) await context.database.delete({
2700
+ model: "member",
2701
+ where: [
2702
+ {
2703
+ field: "id",
2704
+ value: provenance.memberId
2705
+ },
2706
+ {
2707
+ field: "organizationId",
2708
+ value: provenance.organizationId
2709
+ },
2710
+ {
2711
+ field: "userId",
2712
+ value: provenance.userId
2713
+ }
2714
+ ]
2715
+ });
2716
+ await context.database.delete({
2717
+ model: "directorySyncMembershipProvenance",
2718
+ where: [{
2719
+ field: "id",
2720
+ value: provenance.id
2721
+ }, {
2722
+ field: "membershipKey",
2723
+ value: membershipKey
2724
+ }]
2725
+ });
2726
+ return;
2727
+ }
2728
+ if (membership) {
2729
+ if (provenance?.memberId === membership.id) return;
2730
+ if (provenance) await context.database.delete({
2731
+ model: "directorySyncMembershipProvenance",
2732
+ where: [{
2733
+ field: "id",
2734
+ value: provenance.id
2735
+ }, {
2736
+ field: "membershipKey",
2737
+ value: membershipKey
2738
+ }]
2739
+ });
2740
+ await createProvenance(context, {
2741
+ membershipKey,
2742
+ organizationId: directory.organizationId,
2743
+ userId: input.userId,
2744
+ memberId: membership.id,
2745
+ ownership: "observed",
2746
+ provisioningDomainId: input.provisioningDomainId
2747
+ });
2748
+ return;
2749
+ }
2750
+ if (provenance) await context.database.delete({
2751
+ model: "directorySyncMembershipProvenance",
2752
+ where: [{
2753
+ field: "id",
2754
+ value: provenance.id
2755
+ }, {
2756
+ field: "membershipKey",
2757
+ value: membershipKey
2758
+ }]
2759
+ });
2760
+ const now = /* @__PURE__ */ new Date();
2761
+ const createdMembership = await context.database.create({
2762
+ model: "member",
2763
+ data: {
2764
+ organizationId: directory.organizationId,
2765
+ userId: input.userId,
2766
+ role: options.role,
2767
+ createdAt: now
2768
+ }
2769
+ });
2770
+ await createProvenance(context, {
2771
+ membershipKey,
2772
+ organizationId: directory.organizationId,
2773
+ userId: input.userId,
2774
+ memberId: createdMembership.id,
2775
+ ownership: "created",
2776
+ provisioningDomainId: input.provisioningDomainId
2777
+ });
2778
+ };
2779
+ }
2780
+ //#endregion
2781
+ //#region src/routes/directory-sync/contract.ts
2782
+ const DIRECTORY_SYNC_PURPOSE = "directory-sync-management";
2783
+ const ALL_SCIM_SCOPES = [
2784
+ "scim.users.read",
2785
+ "scim.users.write",
2786
+ "scim.groups.read",
2787
+ "scim.groups.write"
2788
+ ];
2789
+ const scimScopeSchema = z$1.enum(ALL_SCIM_SCOPES);
2790
+ const credentialPolicySchema = {
2791
+ scopes: z$1.array(scimScopeSchema).min(1).optional(),
2792
+ expiresAt: z$1.coerce.date().optional()
2793
+ };
2794
+ const directorySyncSSOPairingSchema = z$1.discriminatedUnion("protocol", [z$1.object({
2795
+ ssoProviderId: z$1.string().trim().min(1).max(255),
2796
+ protocol: z$1.literal("oidc"),
2797
+ externalIdSource: z$1.discriminatedUnion("kind", [z$1.object({ kind: z$1.literal("subject") }), z$1.object({
2798
+ kind: z$1.literal("verifiedIdTokenClaim"),
2799
+ name: z$1.string().trim().min(1).max(255)
2800
+ })])
2801
+ }), z$1.object({
2802
+ ssoProviderId: z$1.string().trim().min(1).max(255),
2803
+ protocol: z$1.literal("saml"),
2804
+ externalIdSource: z$1.discriminatedUnion("kind", [z$1.object({ kind: z$1.literal("nameId") }), z$1.object({
2805
+ kind: z$1.literal("attribute"),
2806
+ name: z$1.string().trim().min(1).max(255)
2807
+ })])
2808
+ })]);
2809
+ const createDirectoryBodySchema = z$1.object({
2810
+ providerId: z$1.string().trim().min(1).max(255),
2811
+ pairing: directorySyncSSOPairingSchema.optional(),
2812
+ ...credentialPolicySchema
2813
+ });
2814
+ const rotateCredentialBodySchema = z$1.object(credentialPolicySchema);
2815
+ const emptyBodySchema = z$1.object({});
2816
+ function setCredentialResponseSecurityHeaders(ctx) {
2817
+ ctx.setHeader("Cache-Control", "no-store, max-age=0");
2818
+ ctx.setHeader("Pragma", "no-cache");
2819
+ ctx.setHeader("Referrer-Policy", "no-referrer");
2820
+ }
2821
+ //#endregion
2822
+ //#region src/directory-sync/pairing.ts
2823
+ let samlPolicyModule;
2824
+ function loadSAMLPolicy() {
2825
+ samlPolicyModule ??= import("./saml-policy-BTVLoTyS.mjs");
2826
+ return samlPolicyModule;
2827
+ }
2828
+ function isRecord$1(value) {
2829
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2830
+ }
2831
+ function parseConfiguration(value) {
2832
+ if (isRecord$1(value)) return value;
2833
+ if (typeof value !== "string") return null;
2834
+ try {
2835
+ const parsed = JSON.parse(value);
2836
+ return isRecord$1(parsed) ? parsed : null;
2837
+ } catch {
2838
+ return null;
2839
+ }
2840
+ }
2841
+ function parseDirectorySyncSSOPairing(value) {
2842
+ if (!value) return null;
2843
+ let parsed;
2844
+ try {
2845
+ parsed = JSON.parse(value);
2846
+ } catch {
2847
+ return null;
2848
+ }
2849
+ const result = directorySyncSSOPairingSchema.safeParse(parsed);
2850
+ return result.success ? result.data : null;
2851
+ }
2852
+ async function createActiveSSOProviderKey(ssoProviderRecordId) {
2853
+ return `directory-sync-sso-active:${await hash$1(ssoProviderRecordId)}`;
2854
+ }
2855
+ function createInactiveSSOProviderKey(aliasKey) {
2856
+ return `directory-sync-sso-inactive:${aliasKey}`;
2857
+ }
2858
+ function createTerminalSSOProviderKey(aliasKey) {
2859
+ return `directory-sync-sso-terminal:${aliasKey}`;
2860
+ }
2861
+ async function resolveDirectorySyncSSOPairing(ctx, organizationId, pairing) {
2862
+ const exactProvider = await ctx.context.adapter.update({
2863
+ model: "ssoProvider",
2864
+ where: [{
2865
+ field: "providerId",
2866
+ value: pairing.ssoProviderId
2867
+ }, {
2868
+ field: "organizationId",
2869
+ value: organizationId
2870
+ }],
2871
+ update: { providerId: pairing.ssoProviderId }
2872
+ });
2873
+ if (!exactProvider || exactProvider.providerId !== pairing.ssoProviderId || exactProvider.organizationId !== organizationId) throw ctx.error("BAD_REQUEST", { message: "The selected SSO provider is not available" });
2874
+ const oidcConfiguration = parseConfiguration(exactProvider.oidcConfig);
2875
+ const samlConfiguration = parseConfiguration(exactProvider.samlConfig);
2876
+ if (pairing.protocol === "oidc") {
2877
+ if (!oidcConfiguration || samlConfiguration) throw ctx.error("BAD_REQUEST", { message: "The selected SSO provider is not an OIDC provider" });
2878
+ } else {
2879
+ if (!samlConfiguration || oidcConfiguration) throw ctx.error("BAD_REQUEST", { message: "The selected SSO provider is not a SAML provider" });
2880
+ const samlPolicy = await loadSAMLPolicy();
2881
+ let wantAssertionsSigned = false;
2882
+ try {
2883
+ wantAssertionsSigned = await samlPolicy.requiresSignedSAMLAssertions({
2884
+ spMetadata: isRecord$1(samlConfiguration.spMetadata) ? { metadata: typeof samlConfiguration.spMetadata.metadata === "string" ? samlConfiguration.spMetadata.metadata : void 0 } : void 0,
2885
+ wantAssertionsSigned: samlConfiguration.wantAssertionsSigned === true
2886
+ });
2887
+ } catch {
2888
+ throw ctx.error("BAD_REQUEST", { message: "The selected SAML provider metadata is invalid" });
2889
+ }
2890
+ if (!wantAssertionsSigned) throw ctx.error("BAD_REQUEST", { message: "Paired SAML providers must require cryptographically signed assertions" });
2891
+ }
2892
+ return {
2893
+ pairing,
2894
+ ssoProviderId: exactProvider.providerId,
2895
+ ssoProviderRecordId: exactProvider.id,
2896
+ activeSsoProviderKey: await createActiveSSOProviderKey(exactProvider.id)
2897
+ };
2898
+ }
2899
+ function serializePairingRow(row) {
2900
+ return parseDirectorySyncSSOPairing(row.serializedSsoPairing);
2901
+ }
2902
+ async function guardDirectorySyncSSOProviderMutation(input, context) {
2903
+ if (input.providerReference.source.type !== "persisted" || input.providerReference.source.recordId !== input.provider.id) throw new Error("SSO provider mutation reference is not exact");
2904
+ if (await context.database.findOne({
2905
+ model: "directorySyncConnection",
2906
+ where: [
2907
+ {
2908
+ field: "ssoProviderRecordId",
2909
+ value: input.provider.id
2910
+ },
2911
+ {
2912
+ field: "ssoProviderId",
2913
+ value: input.provider.providerId
2914
+ },
2915
+ {
2916
+ field: "organizationId",
2917
+ value: input.provider.organizationId
2918
+ },
2919
+ {
2920
+ field: "pairingEnforced",
2921
+ value: true
2922
+ }
2923
+ ]
2924
+ })) {
2925
+ if (input.action === "update" && "isAuthenticationBoundaryChange" in input && input.isAuthenticationBoundaryChange === false) return;
2926
+ throw new Error("SSO provider is paired with directory sync");
2927
+ }
2928
+ }
2929
+ //#endregion
2930
+ //#region src/directory-sync/sso-user-resolution.ts
2931
+ const GENERIC_REJECTION = {
2932
+ action: "reject",
2933
+ code: "DIRECTORY_SYNC_AUTHENTICATION_FAILED",
2934
+ message: "Unable to sign in with this SSO connection"
2935
+ };
2936
+ let scimCatalogModule;
2937
+ function loadSCIMCatalog() {
2938
+ scimCatalogModule ??= import("@better-auth/scim");
2939
+ return scimCatalogModule;
2940
+ }
2941
+ function readStringExternalId(value) {
2942
+ if (typeof value === "string" && value.length > 0) return value;
2943
+ return null;
2944
+ }
2945
+ function readSAMLExternalId(value) {
2946
+ const scalar = readStringExternalId(value);
2947
+ if (scalar) return scalar;
2948
+ if (Array.isArray(value) && value.length === 1 && typeof value[0] === "string" && value[0].length > 0) return value[0];
2949
+ return null;
2950
+ }
2951
+ function readExternalId(input, pairing) {
2952
+ if (input.protocol !== pairing.protocol) return null;
2953
+ if (pairing.protocol === "oidc" && input.protocol === "oidc") return readStringExternalId(pairing.externalIdSource.kind === "subject" ? input.accountKey.accountId : input.verifiedIdTokenClaims[pairing.externalIdSource.name]);
2954
+ if (pairing.protocol === "saml" && input.protocol === "saml") return readSAMLExternalId(pairing.externalIdSource.kind === "nameId" ? input.accountKey.accountId : input.providerAttributes[pairing.externalIdSource.name]);
2955
+ return null;
2956
+ }
2957
+ async function resolveOrganizationDirectorySyncUser(input, context) {
2958
+ if (input.providerReference.source.type !== "persisted") return { action: "continue" };
2959
+ const directories = await context.database.findMany({
2960
+ model: "directorySyncConnection",
2961
+ where: [
2962
+ {
2963
+ field: "ssoProviderRecordId",
2964
+ value: input.providerReference.source.recordId
2965
+ },
2966
+ {
2967
+ field: "ssoProviderId",
2968
+ value: input.providerId
2969
+ },
2970
+ {
2971
+ field: "pairingEnforced",
2972
+ value: true
2973
+ }
2974
+ ]
2975
+ });
2976
+ if (directories.length === 0) return { action: "continue" };
2977
+ if (directories.length !== 1) return GENERIC_REJECTION;
2978
+ const directory = directories[0];
2979
+ if (directory?.status !== "active") return GENERIC_REJECTION;
2980
+ if (directory.activeSsoProviderKey !== await createActiveSSOProviderKey(input.providerReference.source.recordId)) return GENERIC_REJECTION;
2981
+ const pairing = parseDirectorySyncSSOPairing(directory.serializedSsoPairing);
2982
+ if (!pairing || pairing.ssoProviderId !== input.providerId || pairing.protocol !== input.protocol || !directory.connectionId) return GENERIC_REJECTION;
2983
+ const externalId = readExternalId(input, pairing);
2984
+ if (!externalId) return GENERIC_REJECTION;
2985
+ try {
2986
+ const catalog = await loadSCIMCatalog();
2987
+ if (typeof catalog.acquireActiveSCIMUserLink !== "function") return GENERIC_REJECTION;
2988
+ const link = await catalog.acquireActiveSCIMUserLink({
2989
+ connectionId: directory.connectionId,
2990
+ externalId
2991
+ }, { database: context.database });
2992
+ if (!link) return GENERIC_REJECTION;
2993
+ return {
2994
+ action: "link",
2995
+ userId: link.userId,
2996
+ profile: "preserve"
2997
+ };
2998
+ } catch {
2999
+ return GENERIC_REJECTION;
3000
+ }
3001
+ }
3002
+ //#endregion
3003
+ //#region src/directory-sync/instrument.ts
3004
+ /**
3005
+ * When managed directory sync is enabled, optionally install SSO/SCIM
3006
+ * callbacks on sibling plugins (same pattern as organization hook
3007
+ * instrumentation).
3008
+ */
3009
+ function instrumentDirectorySyncIntegration(ctx, options) {
3010
+ if (!options.ssoPairing && !options.membershipProjection.enabled) {
3011
+ logger.debug("[Dash] Managed directory sync instrumentation skipped (ssoPairing and membershipProjection are both disabled)");
3012
+ return;
3013
+ }
3014
+ const ssoPlugin = ctx.getPlugin("sso");
3015
+ const scimPlugin = ctx.getPlugin("scim");
3016
+ if (!ssoPlugin && !scimPlugin) {
3017
+ logger.debug("[Dash] Managed directory sync enabled but SSO/SCIM plugins are not active. Skipping integration instrumentation");
3018
+ return;
3019
+ }
3020
+ if (options.ssoPairing) if (ssoPlugin) if (!ssoPlugin.options) logger.error("[Dash] Managed directory sync ssoPairing requires sso({ ... }) (at least sso({})) so pairing callbacks can be installed on the shared options object.");
3021
+ else {
3022
+ const ssoOptions = ssoPlugin.options;
3023
+ const previousResolveUser = ssoOptions.resolveUser;
3024
+ ssoOptions.resolveUser = async (input, context) => {
3025
+ const result = await resolveOrganizationDirectorySyncUser(input, context);
3026
+ if (result.action !== "continue") return result;
3027
+ if (previousResolveUser) return previousResolveUser(input, context);
3028
+ return { action: "continue" };
3029
+ };
3030
+ const previousGuard = ssoOptions.guardProviderMutation;
3031
+ ssoOptions.guardProviderMutation = async (input, context) => {
3032
+ await guardDirectorySyncSSOProviderMutation(input, context);
3033
+ if (previousGuard) await previousGuard(input, context);
3034
+ };
3035
+ }
3036
+ else logger.debug("[Dash] Managed directory sync ssoPairing enabled but SSO plugin is not active. Skipping SSO pairing instrumentation");
3037
+ if (options.membershipProjection.enabled) if (scimPlugin) if (!scimPlugin.options) logger.error("[Dash] Managed directory sync membershipProjection requires scim({ ... }) with an options object so membership projection can be installed.");
3038
+ else {
3039
+ const scimOptions = scimPlugin.options;
3040
+ const projection = scimOptions.projection ??= {};
3041
+ const previousReconcileUser = projection.reconcileUser;
3042
+ const reconcileUser = createOrganizationMembershipProjection({ role: options.membershipProjection.role });
3043
+ projection.reconcileUser = async (input, context) => {
3044
+ await reconcileUser(input, context);
3045
+ if (previousReconcileUser) await previousReconcileUser(input, context);
3046
+ };
3047
+ }
3048
+ else logger.debug("[Dash] Managed directory sync membershipProjection enabled but SCIM plugin is not active. Skipping membership projection instrumentation");
3049
+ }
3050
+ //#endregion
2488
3051
  //#region src/events/organization/events-invitation.ts
2489
3052
  const initInvitationEvents = (tracker) => {
2490
3053
  const { trackEvent } = tracker;
@@ -2840,61 +3403,58 @@ const initTeamEvents = (tracker) => {
2840
3403
  };
2841
3404
  //#endregion
2842
3405
  //#region ../utils/dist/redact.mjs
2843
- const EXACT_SENSITIVE_STRING_KEYS_LOWER = new Set([...new Set([
2844
- "accessToken",
2845
- "apiKey",
2846
- "apiSecret",
2847
- "authorization",
2848
- "authToken",
2849
- "bearerToken",
2850
- "backupCodes",
2851
- "clientSecret",
2852
- "consumerSecret",
2853
- "credentialID",
2854
- "deviceCode",
2855
- "encPrivateKey",
2856
- "encPrivateKeyPass",
2857
- "encryptionKey",
2858
- "encryptionSecret",
2859
- "forwardHeaders",
2860
- "idToken",
2861
- "oidcConfig",
2862
- "pass",
2863
- "passwd",
2864
- "password",
2865
- "privateKey",
2866
- "privateKeyPass",
2867
- "pwd",
2868
- "refreshToken",
2869
- "samlConfig",
2870
- "secret",
2871
- "secretAccessKey",
2872
- "secretKey",
2873
- "signingSecret",
2874
- "stripeWebhookSecret",
2875
- "userCode",
2876
- "webhookSecret"
2877
- ])].map((k) => k.toLowerCase()));
2878
- /**
2879
- * Case-insensitive suffixes for unknown property names.
2880
- */
2881
- const SENSITIVE_KEY_SUFFIXES_LOWER = [
2882
- "accesskey",
2883
- "secret",
2884
- "password",
2885
- "passphrase",
2886
- "privatekey",
2887
- "keypass",
2888
- "apikey",
2889
- "token",
2890
- "signingkey",
2891
- "credentials",
2892
- "authheader"
2893
- ];
2894
- /**
2895
- * Embedded secret substrings checked against the normalized (compact, lowercased) key.
2896
- */
2897
- const SENSITIVE_KEY_SUBSTRINGS_LOWER = ["secretaccess", "accesskeysecret"];
3406
+ /** Built-in sensitive key patterns for consumers that want the defaults. */
3407
+ const SENSITIVE_KEY_PATTERNS = {
3408
+ keys: [
3409
+ "accessToken",
3410
+ "apiKey",
3411
+ "apiSecret",
3412
+ "authorization",
3413
+ "authToken",
3414
+ "bearerToken",
3415
+ "backupCodes",
3416
+ "clientSecret",
3417
+ "consumerSecret",
3418
+ "credentialID",
3419
+ "deviceCode",
3420
+ "encPrivateKey",
3421
+ "encPrivateKeyPass",
3422
+ "encryptionKey",
3423
+ "encryptionSecret",
3424
+ "forwardHeaders",
3425
+ "idToken",
3426
+ "oidcConfig",
3427
+ "pass",
3428
+ "passwd",
3429
+ "password",
3430
+ "privateKey",
3431
+ "privateKeyPass",
3432
+ "pwd",
3433
+ "refreshToken",
3434
+ "samlConfig",
3435
+ "secret",
3436
+ "secretAccessKey",
3437
+ "secretKey",
3438
+ "signingSecret",
3439
+ "stripeWebhookSecret",
3440
+ "userCode",
3441
+ "webhookSecret"
3442
+ ],
3443
+ suffixes: [
3444
+ "accesskey",
3445
+ "secret",
3446
+ "password",
3447
+ "passphrase",
3448
+ "privatekey",
3449
+ "keypass",
3450
+ "apikey",
3451
+ "token",
3452
+ "signingkey",
3453
+ "credentials",
3454
+ "authheader"
3455
+ ],
3456
+ substrings: ["secretaccess", "accesskeysecret"]
3457
+ };
2898
3458
  const REDACTED_SIMPLE_STRING = "[REDACTED]";
2899
3459
  function snakeCaseToCamelCase(key) {
2900
3460
  return key.replace(/_([a-zA-Z])/g, (_, ch) => ch.toUpperCase());
@@ -2906,8 +3466,20 @@ function keyVariantsForMatching(key) {
2906
3466
  if (trimmed.includes("_")) out.add(snakeCaseToCamelCase(trimmed));
2907
3467
  return [...out];
2908
3468
  }
2909
- /** Whether a property name should have string values redacted in `redact()`. */
2910
- function isSensitiveStringKey(key) {
3469
+ function normalizePatterns(patterns) {
3470
+ const keysLower = /* @__PURE__ */ new Set();
3471
+ for (const key of patterns?.keys ?? []) keysLower.add(key.toLowerCase());
3472
+ return {
3473
+ keysLower,
3474
+ suffixesLower: [...patterns?.suffixes ?? []].map((s) => s.toLowerCase()),
3475
+ substringsLower: [...patterns?.substrings ?? []].map((s) => s.toLowerCase())
3476
+ };
3477
+ }
3478
+ function hasAnyPatterns(patterns) {
3479
+ return patterns.keysLower.size > 0 || patterns.suffixesLower.length > 0 || patterns.substringsLower.length > 0;
3480
+ }
3481
+ function matchesNormalizedPatterns(key, patterns) {
3482
+ if (!hasAnyPatterns(patterns)) return false;
2911
3483
  const variants = keyVariantsForMatching(key);
2912
3484
  const compactLower = key.replace(/[\s._-]/g, "").toLowerCase();
2913
3485
  const forms = new Set([compactLower]);
@@ -2917,9 +3489,9 @@ function isSensitiveStringKey(key) {
2917
3489
  }
2918
3490
  for (const form of forms) {
2919
3491
  const fl = form.toLowerCase();
2920
- if (EXACT_SENSITIVE_STRING_KEYS_LOWER.has(fl)) return true;
2921
- for (const suffix of SENSITIVE_KEY_SUFFIXES_LOWER) if (fl.endsWith(suffix)) return true;
2922
- for (const substring of SENSITIVE_KEY_SUBSTRINGS_LOWER) if (compactLower.includes(substring)) return true;
3492
+ if (patterns.keysLower.has(fl)) return true;
3493
+ for (const suffix of patterns.suffixesLower) if (fl.endsWith(suffix)) return true;
3494
+ for (const substring of patterns.substringsLower) if (compactLower.includes(substring)) return true;
2923
3495
  }
2924
3496
  return false;
2925
3497
  }
@@ -2931,7 +3503,7 @@ function isPlainSerializable(value) {
2931
3503
  if (constructor && constructor.name !== "Object" && constructor.name !== "Array") return false;
2932
3504
  return true;
2933
3505
  }
2934
- function redactInner(value, visiting, options) {
3506
+ function redactInner(value, visiting, options, patterns) {
2935
3507
  if (value === null || value === void 0) return value;
2936
3508
  if (typeof value === "function") return void 0;
2937
3509
  if (typeof value !== "object") return value;
@@ -2939,17 +3511,17 @@ function redactInner(value, visiting, options) {
2939
3511
  if (visiting.has(obj)) return void 0;
2940
3512
  visiting.add(obj);
2941
3513
  try {
2942
- if (Array.isArray(value)) return value.map((item) => redactInner(item, visiting, options)).filter((item) => item !== void 0);
3514
+ if (Array.isArray(value)) return value.map((item) => redactInner(item, visiting, options, patterns)).filter((item) => item !== void 0);
2943
3515
  const result = {};
2944
3516
  for (const [key, val] of Object.entries(value)) {
2945
3517
  if (options?.excludeKeys?.has(key)) continue;
2946
3518
  if (typeof val === "function") continue;
2947
- if (typeof val === "string" && isSensitiveStringKey(key)) {
3519
+ if (typeof val === "string" && matchesNormalizedPatterns(key, patterns) && !options?.ignoreKeys?.has(key)) {
2948
3520
  result[key] = REDACTED_SIMPLE_STRING;
2949
3521
  continue;
2950
3522
  }
2951
3523
  if (options?.skipNonPlainSerializable && val !== null && typeof val === "object" && !isPlainSerializable(val)) continue;
2952
- const redacted = redactInner(val, visiting, options);
3524
+ const redacted = redactInner(val, visiting, options, patterns);
2953
3525
  if (redacted !== void 0) result[key] = redacted;
2954
3526
  }
2955
3527
  return result;
@@ -2957,9 +3529,55 @@ function redactInner(value, visiting, options) {
2957
3529
  visiting.delete(obj);
2958
3530
  }
2959
3531
  }
2960
- /** Recursively redact sensitive string values in a JSON-like object tree. */
3532
+ /**
3533
+ * Recursively walk a JSON-like object tree.
3534
+ *
3535
+ * String values are redacted only when `patterns` is provided. With no patterns,
3536
+ * this only applies structural options (`excludeKeys`,
3537
+ * `skipNonPlainSerializable`, dropping functions).
3538
+ */
2961
3539
  function redact(value, options) {
2962
- return redactInner(value, /* @__PURE__ */ new WeakSet(), options);
3540
+ const patterns = normalizePatterns(options?.patterns);
3541
+ return redactInner(value, /* @__PURE__ */ new WeakSet(), options, patterns);
3542
+ }
3543
+ const DASH_PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
3544
+ /** Storage-strategy enums that match sensitive key suffixes but are not secrets. */
3545
+ const DASH_PLUGIN_OPTIONS_IGNORE_KEYS = new Set([
3546
+ "storeApiKey",
3547
+ "storeClientSecret",
3548
+ "storeSCIMToken",
3549
+ "storeToken"
3550
+ ]);
3551
+ function redactDashPluginOptions(pluginId, options, redactOptions) {
3552
+ return redact(options, {
3553
+ patterns: SENSITIVE_KEY_PATTERNS,
3554
+ ...redactOptions,
3555
+ excludeKeys: DASH_PLUGIN_OPTIONS_EXCLUDE_KEYS[pluginId],
3556
+ ignoreKeys: DASH_PLUGIN_OPTIONS_IGNORE_KEYS
3557
+ });
3558
+ }
3559
+ /**
3560
+ * Redact a dash settings payload: `emailAndPassword` and each plugin's `options`.
3561
+ */
3562
+ function redactDashSettings(data, redactOptions) {
3563
+ if (data === null || typeof data !== "object") return data;
3564
+ const d = data;
3565
+ const next = { ...d };
3566
+ if ("emailAndPassword" in d) next.emailAndPassword = redact(d.emailAndPassword, {
3567
+ patterns: SENSITIVE_KEY_PATTERNS,
3568
+ ...redactOptions
3569
+ });
3570
+ const plugins = d.plugins;
3571
+ if (Array.isArray(plugins)) next.plugins = plugins.map((plugin) => {
3572
+ if (plugin === null || typeof plugin !== "object") return plugin;
3573
+ const p = plugin;
3574
+ const id = typeof p.id === "string" ? p.id : "";
3575
+ return {
3576
+ ...p,
3577
+ options: redactDashPluginOptions(id, p.options, redactOptions)
3578
+ };
3579
+ });
3580
+ return next;
2963
3581
  }
2964
3582
  //#endregion
2965
3583
  //#region ../utils/dist/crypto/index.mjs
@@ -3128,13 +3746,61 @@ const jwtValidateMiddleware = (options) => {
3128
3746
  });
3129
3747
  };
3130
3748
  //#endregion
3131
- //#region src/lib/redact-plugin-options.ts
3132
- const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
3133
- function redactPluginOptions(pluginId, options, optionsConfig) {
3134
- return redact(options, {
3135
- ...optionsConfig,
3136
- excludeKeys: PLUGIN_OPTIONS_EXCLUDE_KEYS[pluginId]
3137
- });
3749
+ //#region ../utils/dist/semver.mjs
3750
+ /**
3751
+ * Parses a semver string into components.
3752
+ * Accepts `major.minor`, `major.minor.patch`, and optional prerelease (`-rc.2`).
3753
+ * Returns `null` when the input is missing or not a valid semver prefix.
3754
+ */
3755
+ function parseSemver(version) {
3756
+ if (!version) return null;
3757
+ const match = version.trim().match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/);
3758
+ if (!match) return null;
3759
+ return {
3760
+ major: Number(match[1]),
3761
+ minor: Number(match[2]),
3762
+ patch: Number(match[3] ?? 0),
3763
+ prerelease: match[4]
3764
+ };
3765
+ }
3766
+ function resolve(version) {
3767
+ if (typeof version === "string" || version == null) return parseSemver(version);
3768
+ return version;
3769
+ }
3770
+ /**
3771
+ * True when `version`'s core identity (`major.minor.patch`) is ≥ `target`.
3772
+ * Prerelease on `version` is ignored (e.g. `1.7.0-rc.1` ≥ `1.7.0`).
3773
+ * `target` may be `major.minor` or `major.minor.patch` (missing patch → 0).
3774
+ */
3775
+ function isSemverAtLeast(version, target) {
3776
+ const parsed = resolve(version);
3777
+ const targetParsed = parseSemver(target);
3778
+ if (!parsed || !targetParsed) return false;
3779
+ if (parsed.major !== targetParsed.major) return parsed.major > targetParsed.major;
3780
+ if (parsed.minor !== targetParsed.minor) return parsed.minor > targetParsed.minor;
3781
+ return parsed.patch >= targetParsed.patch;
3782
+ }
3783
+ //#endregion
3784
+ //#region src/semver.ts
3785
+ /** Compare semver strings (e.g. "1.7.0-beta.8" against "1.7" or "1.7.0"). */
3786
+ function isVersionAtLeast(version, target) {
3787
+ return isSemverAtLeast(version, target);
3788
+ }
3789
+ /**
3790
+ * True for better-auth 1.7+ (including 1.8+).
3791
+ * Marks the cutover for issuer-scoped accounts, protocol-defined SSO subjects,
3792
+ * `advanced.database.joins`, and post-legacy SCIM connections.
3793
+ * Keep in sync with `apps/web/lib/better-auth-compat.ts`.
3794
+ */
3795
+ function isBetterAuth17OrLater(version) {
3796
+ const parsed = parseSemver(version);
3797
+ if (!parsed) return false;
3798
+ if (isSemverAtLeast(parsed, "1.8.0")) return true;
3799
+ if (!isSemverAtLeast(parsed, "1.7.0")) return false;
3800
+ if (!parsed.prerelease || parsed.patch > 0) return true;
3801
+ const prereleaseMatch = parsed.prerelease.match(/^rc\.(\d+)/);
3802
+ if (prereleaseMatch) return Number(prereleaseMatch[1]) >= 4;
3803
+ return false;
3138
3804
  }
3139
3805
  //#endregion
3140
3806
  //#region src/routes/auth/config.ts
@@ -3150,16 +3816,16 @@ const getConfig = (options) => {
3150
3816
  }, async (ctx) => {
3151
3817
  const advancedOptions = ctx.context.options.advanced;
3152
3818
  const organizationPlugin = ctx.context.getPlugin("organization");
3153
- return {
3819
+ return redactDashSettings({
3154
3820
  version: ctx.context.version || null,
3155
3821
  socialProviders: Object.keys(ctx.context.options.socialProviders || {}),
3156
- emailAndPassword: redact(ctx.context.options.emailAndPassword, { skipNonPlainSerializable: true }),
3822
+ emailAndPassword: ctx.context.options.emailAndPassword,
3157
3823
  plugins: ctx.context.options.plugins?.map((plugin) => {
3158
3824
  const base = {
3159
3825
  id: plugin.id,
3160
3826
  schema: plugin.schema,
3161
3827
  version: plugin.version,
3162
- options: redactPluginOptions(plugin.id, plugin.options, { skipNonPlainSerializable: true })
3828
+ options: plugin.options
3163
3829
  };
3164
3830
  if (plugin.id === "dash" && !plugin.version) return {
3165
3831
  ...base,
@@ -3262,9 +3928,14 @@ const getConfig = (options) => {
3262
3928
  secure: typeof ctx.context.options.advanced?.defaultCookieAttributes?.secure !== "undefined" ? ctx.context.options.advanced?.defaultCookieAttributes?.secure : null
3263
3929
  } : null,
3264
3930
  appName: ctx.context.options.appName || null,
3265
- hasJoinsEnabled: ctx.context.options.experimental?.joins === true
3931
+ hasJoinsEnabled: (() => {
3932
+ const advancedJoins = (ctx.context.options.advanced?.database)?.joins === true;
3933
+ if (isBetterAuth17OrLater(ctx.context.version)) return advancedJoins;
3934
+ return advancedJoins || ctx.context.options.experimental?.joins === true;
3935
+ })(),
3936
+ hasErrorURLConfigured: !!ctx.context.options.onAPIError?.errorURL
3266
3937
  }
3267
- };
3938
+ }, { skipNonPlainSerializable: true });
3268
3939
  });
3269
3940
  };
3270
3941
  //#endregion
@@ -3279,58 +3950,948 @@ const getValidate = (options) => {
3279
3950
  }, async () => {
3280
3951
  return { valid: true };
3281
3952
  });
3282
- };
3283
- //#endregion
3284
- //#region src/semver.ts
3285
- function parseVersion(version) {
3286
- if (!version) return null;
3287
- const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
3288
- if (!match) return null;
3289
- return [
3290
- Number(match[1]),
3291
- Number(match[2]),
3292
- Number(match[3])
3293
- ];
3953
+ };
3954
+ //#endregion
3955
+ //#region src/routes/organization-guards.ts
3956
+ /** Returns true if organization plugin is enabled. */
3957
+ function isOrganizationEnabled(ctx) {
3958
+ return !!ctx.context.getPlugin("organization");
3959
+ }
3960
+ /** Returns the organization plugin, throws if not enabled. Use for write endpoints. */
3961
+ function requireOrganizationPlugin(ctx) {
3962
+ const plugin = ctx.context.getPlugin("organization");
3963
+ if (!plugin) throw ctx.error("BAD_REQUEST", { message: "Organization plugin not enabled" });
3964
+ return plugin;
3965
+ }
3966
+ /** Returns true if organization plugin and teams feature are enabled. */
3967
+ function isTeamsEnabled(ctx) {
3968
+ return !!ctx.context.getPlugin("organization")?.options?.teams?.enabled;
3969
+ }
3970
+ /**
3971
+ * Validates that the organization plugin is enabled and teams feature is enabled.
3972
+ *
3973
+ * @returns The organization options for use in team logic (maximumTeams, hooks, etc.)
3974
+ */
3975
+ function requireTeamsEnabled(ctx) {
3976
+ const orgOptions = requireOrganizationPlugin(ctx).options || {};
3977
+ if (!orgOptions?.teams?.enabled) throw ctx.error("BAD_REQUEST", { message: "Teams are not enabled" });
3978
+ return orgOptions;
3979
+ }
3980
+ //#endregion
3981
+ //#region src/routes/directory-sync/managed-catalog.ts
3982
+ function getRequestHeaders(ctx) {
3983
+ return ctx.request?.headers ?? new Headers();
3984
+ }
3985
+ function getManagedEndpoints(ctx) {
3986
+ const plugin = ctx.context.getPlugin("scim");
3987
+ if (!plugin?.endpoints) throw ctx.error("BAD_REQUEST", { message: "SCIM managed connections are unavailable. Install a Better Auth release that includes the managed SCIM catalog and configure managedConnections." });
3988
+ return plugin.endpoints;
3989
+ }
3990
+ function requireManagedEndpoint(ctx, endpoint, name) {
3991
+ if (!endpoint) throw ctx.error("BAD_REQUEST", { message: `SCIM managed connection operation "${name}" is unavailable. Publish and install the required Better Auth SCIM release.` });
3992
+ return endpoint;
3993
+ }
3994
+ function assertManagedConnectionLifecycleConfigured(ctx) {
3995
+ const endpoints = getManagedEndpoints(ctx);
3996
+ requireManagedEndpoint(ctx, endpoints.createSCIMManagedConnection, "create");
3997
+ requireManagedEndpoint(ctx, endpoints.listSCIMManagedConnections, "list");
3998
+ requireManagedEndpoint(ctx, endpoints.getSCIMManagedConnection, "get");
3999
+ requireManagedEndpoint(ctx, endpoints.rotateSCIMManagedCredential, "rotate");
4000
+ requireManagedEndpoint(ctx, endpoints.revokeSCIMManagedCredential, "revoke");
4001
+ requireManagedEndpoint(ctx, endpoints.decommissionSCIMManagedConnection, "decommission");
4002
+ return endpoints;
4003
+ }
4004
+ async function getManagedState(ctx, connection) {
4005
+ if (!connection.connectionId) return;
4006
+ return await requireManagedEndpoint(ctx, getManagedEndpoints(ctx).getSCIMManagedConnection, "get")({
4007
+ body: {
4008
+ connectionId: connection.connectionId,
4009
+ provisioningDomainId: connection.provisioningDomainId
4010
+ },
4011
+ context: ctx.context,
4012
+ headers: getRequestHeaders(ctx)
4013
+ });
4014
+ }
4015
+ //#endregion
4016
+ //#region src/routes/directory-sync/mode.ts
4017
+ function getSCIMPlugin(ctx) {
4018
+ return ctx.context.getPlugin("scim");
4019
+ }
4020
+ function getDashPluginOptions(ctx) {
4021
+ return ctx.context.getPlugin("dash")?.options;
4022
+ }
4023
+ /** True when dash was configured with `managedDirectorySync: { enabled: true }`. */
4024
+ function isManagedDirectorySyncEnabled(ctx) {
4025
+ return getDashPluginOptions(ctx)?.managedDirectorySync?.enabled === true;
4026
+ }
4027
+ function usesLegacyScimDirectorySync(scimPlugin) {
4028
+ return typeof scimPlugin?.endpoints.generateSCIMToken === "function";
4029
+ }
4030
+ /** True when the SCIM plugin exposes the 1.7+ managed connection endpoints. */
4031
+ function hasManagedScimSurface(scimPlugin) {
4032
+ return typeof scimPlugin?.endpoints.createSCIMManagedConnection === "function" && typeof scimPlugin?.endpoints.listSCIMManagedConnections === "function";
4033
+ }
4034
+ function usesManagedScimDirectorySync(scimPlugin) {
4035
+ return hasManagedScimSurface(scimPlugin) && scimPlugin?.options?.managedConnections != null;
4036
+ }
4037
+ /**
4038
+ * Resolve which directory-sync experience the dashboard should use.
4039
+ * Managed mode also requires dash `managedDirectorySync.enabled`.
4040
+ * Legacy SCIM mode ignores that option.
4041
+ */
4042
+ function resolveDirectorySyncMode(scimPlugin, managedDirectorySyncEnabled = false) {
4043
+ if (!scimPlugin) return "unavailable";
4044
+ if (hasManagedScimSurface(scimPlugin)) return usesManagedScimDirectorySync(scimPlugin) && managedDirectorySyncEnabled ? "managed" : "unavailable";
4045
+ if (usesLegacyScimDirectorySync(scimPlugin)) {
4046
+ const ownership = scimPlugin.options?.providerOwnership;
4047
+ if (ownership != null && ownership.enabled !== true) return "unavailable";
4048
+ return "legacy";
4049
+ }
4050
+ return "unavailable";
4051
+ }
4052
+ function assertManagedDirectorySyncEnabled(ctx) {
4053
+ if (isManagedDirectorySyncEnabled(ctx)) return;
4054
+ throw ctx.error("BAD_REQUEST", { message: "Managed directory sync is disabled. Enable dash({ managedDirectorySync: { enabled: true } }) and migrate the directory sync schema. Legacy SCIM directory sync does not use this option." });
4055
+ }
4056
+ //#endregion
4057
+ //#region src/routes/directory-sync/route-contract.ts
4058
+ const DIRECTORY_SYNC_CREDENTIAL_LIFETIME_MS = 365 * 24 * 60 * 60 * 1e3;
4059
+ const directorySyncClaimsSchema = z$1.object({
4060
+ purpose: z$1.literal(DIRECTORY_SYNC_PURPOSE),
4061
+ organizationId: z$1.string().trim().min(1),
4062
+ actorId: z$1.string().trim().min(1),
4063
+ setupOperationId: z$1.string().trim().min(16).max(255).optional()
4064
+ });
4065
+ function getScimEndpoint$1(baseUrl) {
4066
+ return `${baseUrl}/scim/v2`;
4067
+ }
4068
+ function assertDirectorySyncClaims(ctx, organizationId) {
4069
+ const claims = ctx.context.payload;
4070
+ if (claims.purpose !== "directory-sync-management" || claims.organizationId !== organizationId || !claims.actorId) throw ctx.error("UNAUTHORIZED", { message: "Invalid directory sync management authorization" });
4071
+ return claims;
4072
+ }
4073
+ function assertDirectorySyncManagementClaims(ctx, organizationId) {
4074
+ const claims = assertDirectorySyncClaims(ctx, organizationId);
4075
+ if (claims.setupOperationId) throw ctx.error("FORBIDDEN", { message: "Directory sync setup authorization cannot perform management operations" });
4076
+ const { setupOperationId: _, ...managementClaims } = claims;
4077
+ return managementClaims;
4078
+ }
4079
+ async function assertTargetOrganizationExists(ctx, organizationId) {
4080
+ if (!await ctx.context.adapter.findOne({
4081
+ model: "organization",
4082
+ where: [{
4083
+ field: "id",
4084
+ value: organizationId
4085
+ }],
4086
+ select: ["id"]
4087
+ })) throw ctx.error("NOT_FOUND", { message: "Target organization not found" });
4088
+ }
4089
+ function resolveCredentialPolicy(input) {
4090
+ const expiresAt = input.expiresAt ?? new Date(Date.now() + DIRECTORY_SYNC_CREDENTIAL_LIFETIME_MS);
4091
+ if (expiresAt.getTime() <= Date.now()) throw new APIError$1("BAD_REQUEST", { message: "Directory sync credential expiry must be in the future" });
4092
+ const scopes = input.scopes ?? ALL_SCIM_SCOPES;
4093
+ if (new Set(scopes).size !== scopes.length) throw new APIError$1("BAD_REQUEST", { message: "Directory sync credential scopes must be unique" });
4094
+ return {
4095
+ scopes,
4096
+ expiresAt
4097
+ };
4098
+ }
4099
+ function serializeDate(value) {
4100
+ return value instanceof Date ? value.toISOString() : value;
4101
+ }
4102
+ function serializeNullableDate(value) {
4103
+ return value == null ? null : serializeDate(value);
4104
+ }
4105
+ function serializeCredential(credential) {
4106
+ return {
4107
+ credentialId: credential.credentialId,
4108
+ status: credential.status,
4109
+ scopes: credential.scopes,
4110
+ expiresAt: serializeDate(credential.expiresAt),
4111
+ createdAt: serializeDate(credential.createdAt),
4112
+ createdBy: credential.createdBy,
4113
+ lastUsedAt: serializeNullableDate(credential.lastUsedAt),
4114
+ revokedAt: serializeNullableDate(credential.revokedAt),
4115
+ revokedBy: credential.revokedBy
4116
+ };
4117
+ }
4118
+ function serializeDirectory(row, scimEndpoint, state) {
4119
+ return {
4120
+ connectionId: row.connectionId ?? null,
4121
+ organizationId: row.organizationId,
4122
+ providerId: row.providerId,
4123
+ provisioningDomainId: row.provisioningDomainId,
4124
+ status: row.status,
4125
+ scimEndpoint,
4126
+ credentials: state?.credentials.map(serializeCredential) ?? [],
4127
+ createdAt: serializeDate(row.createdAt),
4128
+ updatedAt: serializeDate(row.updatedAt),
4129
+ pairing: serializePairingRow(row),
4130
+ pairingEnforced: row.pairingEnforced === true,
4131
+ unpairedAt: serializeNullableDate(row.unpairedAt ?? null),
4132
+ unpairedBy: row.unpairedBy ?? null,
4133
+ decommissionedAt: serializeNullableDate(row.decommissionedAt ?? null)
4134
+ };
4135
+ }
4136
+ //#endregion
4137
+ //#region src/routes/directory-sync/transaction.ts
4138
+ /**
4139
+ * `better-auth` and `@better-auth/core` can resolve to distinct physical
4140
+ * copies of the same version. Endpoint adapters are typed from the former;
4141
+ * `runWithTransaction` / `getCurrentAdapter` are typed from the latter.
4142
+ */
4143
+ function runWithTransaction$1(adapter, fn) {
4144
+ return runWithTransaction(adapter, fn);
4145
+ }
4146
+ function getCurrentAdapter$1(adapter) {
4147
+ return getCurrentAdapter(adapter);
4148
+ }
4149
+ //#endregion
4150
+ //#region src/routes/directory-sync/reservation.ts
4151
+ function withTransactionAdapter(ctx, adapter) {
4152
+ return {
4153
+ ...ctx,
4154
+ context: {
4155
+ ...ctx.context,
4156
+ adapter
4157
+ }
4158
+ };
4159
+ }
4160
+ async function createAliasKey(organizationId, providerId) {
4161
+ return `directory-sync-alias:${await hash$1(JSON.stringify([organizationId, providerId]))}`;
4162
+ }
4163
+ async function createProvisioningDomainId(organizationId, providerId) {
4164
+ return `dash_scim_domain_${await hash$1(JSON.stringify({
4165
+ purpose: DIRECTORY_SYNC_PURPOSE,
4166
+ organizationId,
4167
+ providerId
4168
+ }))}`;
4169
+ }
4170
+ async function createActiveOrganizationKey(organizationId) {
4171
+ return `directory-sync-active:${await hash$1(organizationId)}`;
4172
+ }
4173
+ function createInactiveOrganizationKey(aliasKey) {
4174
+ return `directory-sync-inactive:${aliasKey}`;
4175
+ }
4176
+ function createCreationRequestId() {
4177
+ return generateRandomString(32, "a-z", "A-Z", "0-9", "-_");
4178
+ }
4179
+ function isUniqueConstraintError(error) {
4180
+ return error instanceof Error && /unique|duplicate|constraint|P2002/i.test(error.message);
4181
+ }
4182
+ async function assertManagedDirectoryTransactionsConfigured(ctx) {
4183
+ if (typeof ctx.context.adapter.options?.adapterConfig.transaction !== "function") throw ctx.error("NOT_IMPLEMENTED", {
4184
+ code: "DIRECTORY_SYNC_REQUIRES_NATIVE_TRANSACTIONS",
4185
+ message: "Managed directory sync requires a database adapter with native transaction support"
4186
+ });
4187
+ try {
4188
+ await getCurrentDBAdapterAsyncLocalStorage();
4189
+ } catch {
4190
+ throw ctx.error("NOT_IMPLEMENTED", {
4191
+ code: "DIRECTORY_SYNC_REQUIRES_ASYNC_CONTEXT",
4192
+ message: "Managed directory sync requires database transaction async context support"
4193
+ });
4194
+ }
4195
+ }
4196
+ async function assertDirectorySyncSSOIntegrationConfigured(ctx) {
4197
+ const ssoPlugin = ctx.context.getPlugin("sso");
4198
+ if (typeof ssoPlugin?.options?.resolveUser !== "function" || typeof ssoPlugin.options.guardProviderMutation !== "function") throw ctx.error("BAD_REQUEST", { message: "Paired directory sync requires SSO resolveUser and guardProviderMutation callbacks. Enable dash({ managedDirectorySync: { enabled: true, ssoPairing: true } }) and pass sso({}) (or richer SSO options) so dash can install them." });
4199
+ await assertManagedDirectoryTransactionsConfigured(ctx);
4200
+ }
4201
+ async function findDirectorySyncConnection(ctx, organizationId, providerId) {
4202
+ return await (await getCurrentAdapter$1(ctx.context.adapter)).findOne({
4203
+ model: "directorySyncConnection",
4204
+ where: [{
4205
+ field: "organizationId",
4206
+ value: organizationId
4207
+ }, {
4208
+ field: "providerId",
4209
+ value: providerId
4210
+ }]
4211
+ });
4212
+ }
4213
+ async function getDirectorySyncConnection(ctx, organizationId, providerId) {
4214
+ const row = await findDirectorySyncConnection(ctx, organizationId, providerId);
4215
+ if (!row) throw ctx.error("NOT_FOUND", { message: "Directory sync connection not found" });
4216
+ return row;
4217
+ }
4218
+ function isExactManagedConnection(issued, input) {
4219
+ return issued.connection.creationRequestId === input.creationRequestId && issued.connection.provisioningDomainId === input.provisioningDomainId;
4220
+ }
4221
+ async function lockExactPairedSSOProvider(adapter, row) {
4222
+ if (!row.pairingEnforced) return;
4223
+ if (!row.ssoProviderRecordId || !row.ssoProviderId) throw new Error("Enforced directory sync pairing is incomplete");
4224
+ if (!await adapter.update({
4225
+ model: "ssoProvider",
4226
+ where: [
4227
+ {
4228
+ field: "id",
4229
+ value: row.ssoProviderRecordId
4230
+ },
4231
+ {
4232
+ field: "providerId",
4233
+ value: row.ssoProviderId
4234
+ },
4235
+ {
4236
+ field: "organizationId",
4237
+ value: row.organizationId
4238
+ }
4239
+ ],
4240
+ update: { providerId: row.ssoProviderId }
4241
+ })) throw new Error("Paired SSO provider changed during directory sync");
4242
+ }
4243
+ async function recoverManagedDirectoryConnection(ctx, row, input, policy) {
4244
+ if (row.organizationId !== input.organizationId || row.providerId !== input.providerId || row.creationRequestId !== input.creationRequestId || row.status !== "active" || !row.connectionId) throw ctx.error("CONFLICT", { message: "This directory sync alias belongs to a different setup operation" });
4245
+ const connectionId = row.connectionId;
4246
+ const serializedPairing = input.pairing ? JSON.stringify(input.pairing) : null;
4247
+ if ((row.serializedSsoPairing ?? null) !== serializedPairing) throw ctx.error("CONFLICT", { message: "Directory sync setup recovery must use the original SSO pairing" });
4248
+ const endpoints = getManagedEndpoints(ctx);
4249
+ const getManagedConnection = requireManagedEndpoint(ctx, endpoints.getSCIMManagedConnection, "get");
4250
+ const revokeManagedCredential = requireManagedEndpoint(ctx, endpoints.revokeSCIMManagedCredential, "revoke");
4251
+ const rotateManagedCredential = requireManagedEndpoint(ctx, endpoints.rotateSCIMManagedCredential, "rotate");
4252
+ return await runWithTransaction$1(ctx.context.adapter, async () => {
4253
+ const database = await getCurrentAdapter$1(ctx.context.adapter);
4254
+ await lockExactPairedSSOProvider(database, row);
4255
+ const locked = await database.update({
4256
+ model: "directorySyncConnection",
4257
+ where: [
4258
+ {
4259
+ field: "id",
4260
+ value: row.id
4261
+ },
4262
+ {
4263
+ field: "status",
4264
+ value: "active"
4265
+ },
4266
+ {
4267
+ field: "revision",
4268
+ value: row.revision
4269
+ },
4270
+ {
4271
+ field: "connectionId",
4272
+ value: connectionId
4273
+ },
4274
+ {
4275
+ field: "creationRequestId",
4276
+ value: input.creationRequestId
4277
+ }
4278
+ ],
4279
+ update: {
4280
+ revision: row.revision + 1,
4281
+ updatedAt: /* @__PURE__ */ new Date(),
4282
+ lastActorId: input.actorId,
4283
+ lastError: null
4284
+ }
4285
+ });
4286
+ if (!locked) throw ctx.error("CONFLICT", { message: "Directory sync connection changed before setup recovery started" });
4287
+ const state = await getManagedConnection({
4288
+ body: {
4289
+ connectionId,
4290
+ provisioningDomainId: row.provisioningDomainId
4291
+ },
4292
+ context: ctx.context,
4293
+ headers: getRequestHeaders(ctx)
4294
+ });
4295
+ if (state.connection.connectionId !== connectionId || state.connection.creationRequestId !== input.creationRequestId || state.connection.provisioningDomainId !== row.provisioningDomainId || state.connection.status !== "active") throw ctx.error("CONFLICT", { message: "Managed connection ownership changed before setup recovery" });
4296
+ for (const credential of state.credentials) {
4297
+ if (credential.status !== "active") continue;
4298
+ await revokeManagedCredential({
4299
+ body: {
4300
+ connectionId,
4301
+ provisioningDomainId: row.provisioningDomainId,
4302
+ credentialId: credential.credentialId,
4303
+ actorId: input.actorId
4304
+ },
4305
+ context: ctx.context,
4306
+ headers: getRequestHeaders(ctx)
4307
+ });
4308
+ }
4309
+ const issued = await rotateManagedCredential({
4310
+ body: {
4311
+ connectionId,
4312
+ provisioningDomainId: row.provisioningDomainId,
4313
+ actorId: input.actorId,
4314
+ ...policy
4315
+ },
4316
+ context: ctx.context,
4317
+ headers: getRequestHeaders(ctx)
4318
+ });
4319
+ if (issued.connection.connectionId !== connectionId || issued.connection.creationRequestId !== input.creationRequestId || issued.connection.provisioningDomainId !== row.provisioningDomainId) throw ctx.error("CONFLICT", { message: "Managed connection recovery returned mismatched ownership correlation" });
4320
+ return {
4321
+ row: locked,
4322
+ issued
4323
+ };
4324
+ });
4325
+ }
4326
+ async function createManagedDirectoryConnection(ctx, input, policy) {
4327
+ await assertManagedDirectoryTransactionsConfigured(ctx);
4328
+ if (input.pairing) await assertDirectorySyncSSOIntegrationConfigured(ctx);
4329
+ if (input.pairing?.protocol === "saml") try {
4330
+ await loadSAMLPolicy();
4331
+ } catch {
4332
+ throw ctx.error("NOT_IMPLEMENTED", {
4333
+ code: "DIRECTORY_SYNC_SAML_POLICY_UNAVAILABLE",
4334
+ message: "SAML pairing requires a compatible SSO package with service provider metadata policy support"
4335
+ });
4336
+ }
4337
+ const aliasKey = await createAliasKey(input.organizationId, input.providerId);
4338
+ const provisioningDomainId = await createProvisioningDomainId(input.organizationId, input.providerId);
4339
+ const activeOrganizationKey = await createActiveOrganizationKey(input.organizationId);
4340
+ const creationRequestId = input.creationRequestId ?? createCreationRequestId();
4341
+ const createManagedConnection = requireManagedEndpoint(ctx, getManagedEndpoints(ctx).createSCIMManagedConnection, "create");
4342
+ try {
4343
+ return await runWithTransaction$1(ctx.context.adapter, async () => {
4344
+ const database = await getCurrentAdapter$1(ctx.context.adapter);
4345
+ const transactionContext = withTransactionAdapter(ctx, database);
4346
+ const pairing = input.pairing ? await resolveDirectorySyncSSOPairing(transactionContext, input.organizationId, input.pairing) : null;
4347
+ const now = /* @__PURE__ */ new Date();
4348
+ const row = await database.create({
4349
+ model: "directorySyncConnection",
4350
+ data: {
4351
+ organizationId: input.organizationId,
4352
+ providerId: input.providerId,
4353
+ aliasKey,
4354
+ provisioningDomainId,
4355
+ activeOrganizationKey,
4356
+ creationRequestId,
4357
+ status: "active",
4358
+ revision: 0,
4359
+ createdAt: now,
4360
+ createdByActorId: input.actorId,
4361
+ updatedAt: now,
4362
+ lastActorId: input.actorId,
4363
+ activeSsoProviderKey: pairing?.activeSsoProviderKey ?? createInactiveSSOProviderKey(aliasKey),
4364
+ pairingEnforced: pairing != null,
4365
+ ...pairing ? {
4366
+ ssoProviderId: pairing.ssoProviderId,
4367
+ ssoProviderRecordId: pairing.ssoProviderRecordId,
4368
+ serializedSsoPairing: JSON.stringify(pairing.pairing)
4369
+ } : {}
4370
+ }
4371
+ });
4372
+ const issued = await createManagedConnection({
4373
+ body: {
4374
+ creationRequestId,
4375
+ provisioningDomainId,
4376
+ actorId: input.actorId,
4377
+ ...policy
4378
+ },
4379
+ context: ctx.context,
4380
+ headers: getRequestHeaders(ctx)
4381
+ });
4382
+ if (!isExactManagedConnection(issued, {
4383
+ creationRequestId,
4384
+ provisioningDomainId
4385
+ })) throw ctx.error("CONFLICT", { message: "Managed connection creation returned mismatched ownership correlation" });
4386
+ await lockExactPairedSSOProvider(database, row);
4387
+ const bound = await database.update({
4388
+ model: "directorySyncConnection",
4389
+ where: [
4390
+ {
4391
+ field: "id",
4392
+ value: row.id
4393
+ },
4394
+ {
4395
+ field: "status",
4396
+ value: "active"
4397
+ },
4398
+ {
4399
+ field: "revision",
4400
+ value: 0
4401
+ },
4402
+ {
4403
+ field: "connectionId",
4404
+ value: null
4405
+ }
4406
+ ],
4407
+ update: {
4408
+ connectionId: issued.connection.connectionId,
4409
+ revision: 1,
4410
+ updatedAt: /* @__PURE__ */ new Date()
4411
+ }
4412
+ });
4413
+ if (!bound) throw ctx.error("CONFLICT", { message: "Directory sync connection changed before catalog binding completed" });
4414
+ await supersedeTerminalDirectorySyncPairings(transactionContext, bound);
4415
+ return {
4416
+ row: bound,
4417
+ issued
4418
+ };
4419
+ });
4420
+ } catch (error) {
4421
+ if (isUniqueConstraintError(error)) {
4422
+ const aliasConflict = await ctx.context.adapter.findOne({
4423
+ model: "directorySyncConnection",
4424
+ where: [{
4425
+ field: "aliasKey",
4426
+ value: aliasKey
4427
+ }]
4428
+ });
4429
+ if (aliasConflict) {
4430
+ if (input.creationRequestId && aliasConflict.creationRequestId === input.creationRequestId && aliasConflict.organizationId === input.organizationId && aliasConflict.providerId === input.providerId && aliasConflict.provisioningDomainId === provisioningDomainId) return await recoverManagedDirectoryConnection(ctx, aliasConflict, {
4431
+ ...input,
4432
+ creationRequestId: input.creationRequestId
4433
+ }, policy);
4434
+ throw ctx.error("CONFLICT", { message: "This organization already has a directory sync connection for the requested provider" });
4435
+ }
4436
+ if (await ctx.context.adapter.findOne({
4437
+ model: "directorySyncConnection",
4438
+ where: [{
4439
+ field: "activeOrganizationKey",
4440
+ value: activeOrganizationKey
4441
+ }]
4442
+ })) throw ctx.error("CONFLICT", { message: "This organization already has an active directory sync connection" });
4443
+ throw error;
4444
+ }
4445
+ throw error;
4446
+ }
4447
+ }
4448
+ function createTerminalOrganizationKey(aliasKey) {
4449
+ return createInactiveOrganizationKey(aliasKey);
4450
+ }
4451
+ function createTerminalPairingKey(aliasKey) {
4452
+ return createTerminalSSOProviderKey(aliasKey);
4453
+ }
4454
+ async function supersedeTerminalDirectorySyncPairings(ctx, activeRow) {
4455
+ if (!activeRow.ssoProviderRecordId || !activeRow.pairingEnforced) return;
4456
+ const terminalRows = await ctx.context.adapter.findMany({
4457
+ model: "directorySyncConnection",
4458
+ where: [{
4459
+ field: "ssoProviderRecordId",
4460
+ value: activeRow.ssoProviderRecordId
4461
+ }, {
4462
+ field: "pairingEnforced",
4463
+ value: true
4464
+ }]
4465
+ });
4466
+ const now = /* @__PURE__ */ new Date();
4467
+ for (const row of terminalRows) {
4468
+ if (row.id === activeRow.id || row.status !== "decommissioned") continue;
4469
+ if (!await ctx.context.adapter.update({
4470
+ model: "directorySyncConnection",
4471
+ where: [
4472
+ {
4473
+ field: "id",
4474
+ value: row.id
4475
+ },
4476
+ {
4477
+ field: "status",
4478
+ value: "decommissioned"
4479
+ },
4480
+ {
4481
+ field: "revision",
4482
+ value: row.revision
4483
+ },
4484
+ {
4485
+ field: "pairingEnforced",
4486
+ value: true
4487
+ }
4488
+ ],
4489
+ update: {
4490
+ pairingEnforced: false,
4491
+ revision: row.revision + 1,
4492
+ unpairedAt: now,
4493
+ unpairedBy: activeRow.lastActorId,
4494
+ updatedAt: now,
4495
+ lastActorId: activeRow.lastActorId
4496
+ }
4497
+ })) throw ctx.error("CONFLICT", { message: "Directory sync SSO pairing changed before replacement activation" });
4498
+ }
4499
+ }
4500
+ async function startDirectorySyncDecommission(ctx, row, actorId) {
4501
+ if (row.status !== "active") return row;
4502
+ await assertManagedDirectoryTransactionsConfigured(ctx);
4503
+ return await runWithTransaction$1(ctx.context.adapter, async () => {
4504
+ const database = await getCurrentAdapter$1(ctx.context.adapter);
4505
+ await lockExactPairedSSOProvider(database, row);
4506
+ const updated = await database.update({
4507
+ model: "directorySyncConnection",
4508
+ where: [
4509
+ {
4510
+ field: "id",
4511
+ value: row.id
4512
+ },
4513
+ {
4514
+ field: "status",
4515
+ value: "active"
4516
+ },
4517
+ {
4518
+ field: "revision",
4519
+ value: row.revision
4520
+ }
4521
+ ],
4522
+ update: {
4523
+ status: "decommissioning",
4524
+ revision: row.revision + 1,
4525
+ decommissionStartedAt: /* @__PURE__ */ new Date(),
4526
+ updatedAt: /* @__PURE__ */ new Date(),
4527
+ lastActorId: actorId
4528
+ }
4529
+ });
4530
+ if (!updated) throw ctx.error("CONFLICT", { message: "Directory sync connection changed while decommissioning started" });
4531
+ return updated;
4532
+ });
3294
4533
  }
3295
- /** Compare semver strings (e.g. "1.7.0-beta.8" against "1.7" or "1.7.0"). */
3296
- function isVersionAtLeast(version, target) {
3297
- const parsed = parseVersion(version);
3298
- if (!parsed) return false;
3299
- const targetParts = target.split(".").map(Number);
3300
- const major = targetParts[0] ?? 0;
3301
- const minor = targetParts[1] ?? 0;
3302
- const patch = targetParts[2] ?? 0;
3303
- const [vMajor, vMinor, vPatch] = parsed;
3304
- if (vMajor !== major) return vMajor > major;
3305
- if (vMinor !== minor) return vMinor > minor;
3306
- return vPatch >= patch;
4534
+ async function unpairTerminalDirectorySyncConnection(ctx, row, actorId) {
4535
+ if (row.status !== "decommissioned") throw ctx.error("CONFLICT", { message: "Directory sync can only be unpaired after it has decommissioned" });
4536
+ if (!row.pairingEnforced) return row;
4537
+ await assertManagedDirectoryTransactionsConfigured(ctx);
4538
+ return await runWithTransaction$1(ctx.context.adapter, async () => {
4539
+ const database = await getCurrentAdapter$1(ctx.context.adapter);
4540
+ await lockExactPairedSSOProvider(database, row);
4541
+ const now = /* @__PURE__ */ new Date();
4542
+ const updated = await database.update({
4543
+ model: "directorySyncConnection",
4544
+ where: [
4545
+ {
4546
+ field: "id",
4547
+ value: row.id
4548
+ },
4549
+ {
4550
+ field: "status",
4551
+ value: "decommissioned"
4552
+ },
4553
+ {
4554
+ field: "revision",
4555
+ value: row.revision
4556
+ },
4557
+ {
4558
+ field: "pairingEnforced",
4559
+ value: true
4560
+ }
4561
+ ],
4562
+ update: {
4563
+ activeSsoProviderKey: createTerminalSSOProviderKey(row.aliasKey),
4564
+ pairingEnforced: false,
4565
+ revision: row.revision + 1,
4566
+ unpairedAt: now,
4567
+ unpairedBy: actorId,
4568
+ updatedAt: now,
4569
+ lastActorId: actorId
4570
+ }
4571
+ });
4572
+ if (!updated) throw ctx.error("CONFLICT", { message: "Directory sync SSO pairing changed before it was unpaired" });
4573
+ return updated;
4574
+ });
3307
4575
  }
3308
4576
  //#endregion
3309
- //#region src/routes/organization-guards.ts
3310
- /** Returns true if organization plugin is enabled. */
3311
- function isOrganizationEnabled(ctx) {
3312
- return !!ctx.context.getPlugin("organization");
3313
- }
3314
- /** Returns the organization plugin, throws if not enabled. Use for write endpoints. */
3315
- function requireOrganizationPlugin(ctx) {
3316
- const plugin = ctx.context.getPlugin("organization");
3317
- if (!plugin) throw ctx.error("BAD_REQUEST", { message: "Organization plugin not enabled" });
3318
- return plugin;
3319
- }
3320
- /** Returns true if organization plugin and teams feature are enabled. */
3321
- function isTeamsEnabled(ctx) {
3322
- return !!ctx.context.getPlugin("organization")?.options?.teams?.enabled;
4577
+ //#region src/routes/directory-sync/managed-directories.ts
4578
+ /** Shared list handler for mode-dispatch on GET /:id/directories. */
4579
+ async function listOrganizationDirectoriesManagedHandler(ctx) {
4580
+ requireOrganizationPlugin(ctx);
4581
+ assertManagedDirectorySyncEnabled(ctx);
4582
+ const organizationId = tryDecode(ctx.params?.id ?? "");
4583
+ assertDirectorySyncManagementClaims(ctx, organizationId);
4584
+ await assertTargetOrganizationExists(ctx, organizationId);
4585
+ const rows = await ctx.context.adapter.findMany({
4586
+ model: "directorySyncConnection",
4587
+ where: [{
4588
+ field: "organizationId",
4589
+ value: organizationId
4590
+ }],
4591
+ sortBy: {
4592
+ field: "createdAt",
4593
+ direction: "desc"
4594
+ }
4595
+ });
4596
+ return await Promise.all(rows.map(async (row) => serializeDirectory(row, getScimEndpoint$1(ctx.context.baseURL), await getManagedState(ctx, row))));
3323
4597
  }
3324
- /**
3325
- * Validates that the organization plugin is enabled and teams feature is enabled.
3326
- *
3327
- * @returns The organization options for use in team logic (maximumTeams, hooks, etc.)
3328
- */
3329
- function requireTeamsEnabled(ctx) {
3330
- const orgOptions = requireOrganizationPlugin(ctx).options || {};
3331
- if (!orgOptions?.teams?.enabled) throw ctx.error("BAD_REQUEST", { message: "Teams are not enabled" });
3332
- return orgOptions;
4598
+ const getOrganizationDirectory = (options) => createAuthEndpoint("/dash/organization/:id/directories/:providerId", {
4599
+ method: "GET",
4600
+ use: [jwtMiddleware(options, directorySyncClaimsSchema)]
4601
+ }, async (ctx) => {
4602
+ requireOrganizationPlugin(ctx);
4603
+ assertManagedDirectorySyncEnabled(ctx);
4604
+ const organizationId = tryDecode(ctx.params.id);
4605
+ assertDirectorySyncManagementClaims(ctx, organizationId);
4606
+ await assertTargetOrganizationExists(ctx, organizationId);
4607
+ const row = await getDirectorySyncConnection(ctx, organizationId, tryDecode(ctx.params.providerId));
4608
+ return serializeDirectory(row, getScimEndpoint$1(ctx.context.baseURL), await getManagedState(ctx, row));
4609
+ });
4610
+ const createOrganizationDirectory = (options) => createAuthEndpoint("/dash/organization/:id/directories", {
4611
+ method: "POST",
4612
+ use: [jwtMiddleware(options, directorySyncClaimsSchema)],
4613
+ body: createDirectoryBodySchema,
4614
+ metadata: { noStore: true }
4615
+ }, async (ctx) => {
4616
+ requireOrganizationPlugin(ctx);
4617
+ assertManagedDirectorySyncEnabled(ctx);
4618
+ const organizationId = tryDecode(ctx.params.id);
4619
+ const claims = assertDirectorySyncClaims(ctx, organizationId);
4620
+ await assertTargetOrganizationExists(ctx, organizationId);
4621
+ const policy = resolveCredentialPolicy(ctx.body);
4622
+ assertManagedConnectionLifecycleConfigured(ctx);
4623
+ const { row, issued } = await createManagedDirectoryConnection(ctx, {
4624
+ organizationId,
4625
+ providerId: ctx.body.providerId,
4626
+ actorId: claims.actorId,
4627
+ ...claims.setupOperationId ? { creationRequestId: claims.setupOperationId } : {},
4628
+ ...ctx.body.pairing ? { pairing: ctx.body.pairing } : {}
4629
+ }, policy);
4630
+ setCredentialResponseSecurityHeaders(ctx);
4631
+ return {
4632
+ ...serializeDirectory(row, getScimEndpoint$1(ctx.context.baseURL), {
4633
+ connection: issued.connection,
4634
+ credentials: [issued.credential]
4635
+ }),
4636
+ connectionId: issued.connection.connectionId,
4637
+ credential: serializeCredential(issued.credential),
4638
+ scimToken: issued.token
4639
+ };
4640
+ });
4641
+ const rotateDirectoryCredential = (options) => createAuthEndpoint("/dash/organization/:id/directories/:providerId/credentials/rotate", {
4642
+ method: "POST",
4643
+ use: [jwtMiddleware(options, directorySyncClaimsSchema)],
4644
+ body: rotateCredentialBodySchema,
4645
+ metadata: { noStore: true }
4646
+ }, async (ctx) => {
4647
+ requireOrganizationPlugin(ctx);
4648
+ assertManagedDirectorySyncEnabled(ctx);
4649
+ const organizationId = tryDecode(ctx.params.id);
4650
+ const claims = assertDirectorySyncManagementClaims(ctx, organizationId);
4651
+ await assertTargetOrganizationExists(ctx, organizationId);
4652
+ const policy = resolveCredentialPolicy(ctx.body);
4653
+ await assertManagedDirectoryTransactionsConfigured(ctx);
4654
+ const providerId = tryDecode(ctx.params.providerId);
4655
+ const endpoint = requireManagedEndpoint(ctx, getManagedEndpoints(ctx).rotateSCIMManagedCredential, "rotate");
4656
+ const { connectionId, issued } = await runWithTransaction$1(ctx.context.adapter, async () => {
4657
+ const database = await getCurrentAdapter$1(ctx.context.adapter);
4658
+ const row = await getDirectorySyncConnection(ctx, organizationId, providerId);
4659
+ if (row.status !== "active" || !row.connectionId) throw ctx.error("CONFLICT", { message: "Directory sync connection is not active" });
4660
+ if (!await database.update({
4661
+ model: "directorySyncConnection",
4662
+ where: [
4663
+ {
4664
+ field: "id",
4665
+ value: row.id
4666
+ },
4667
+ {
4668
+ field: "status",
4669
+ value: "active"
4670
+ },
4671
+ {
4672
+ field: "revision",
4673
+ value: row.revision
4674
+ },
4675
+ {
4676
+ field: "connectionId",
4677
+ value: row.connectionId
4678
+ }
4679
+ ],
4680
+ update: {
4681
+ revision: row.revision + 1,
4682
+ updatedAt: /* @__PURE__ */ new Date(),
4683
+ lastActorId: claims.actorId,
4684
+ lastError: null
4685
+ }
4686
+ })) throw ctx.error("CONFLICT", { message: "Directory sync connection changed before credential rotation started" });
4687
+ const issued = await endpoint({
4688
+ body: {
4689
+ connectionId: row.connectionId,
4690
+ provisioningDomainId: row.provisioningDomainId,
4691
+ actorId: claims.actorId,
4692
+ ...policy
4693
+ },
4694
+ context: ctx.context,
4695
+ headers: getRequestHeaders(ctx)
4696
+ });
4697
+ return {
4698
+ connectionId: row.connectionId,
4699
+ issued
4700
+ };
4701
+ });
4702
+ setCredentialResponseSecurityHeaders(ctx);
4703
+ return {
4704
+ connectionId,
4705
+ credential: serializeCredential(issued.credential),
4706
+ scimToken: issued.token,
4707
+ scimEndpoint: getScimEndpoint$1(ctx.context.baseURL)
4708
+ };
4709
+ });
4710
+ const revokeDirectoryCredential = (options) => createAuthEndpoint("/dash/organization/:id/directories/:providerId/credentials/:credentialId/revoke", {
4711
+ method: "POST",
4712
+ use: [jwtMiddleware(options, directorySyncClaimsSchema)],
4713
+ body: emptyBodySchema
4714
+ }, async (ctx) => {
4715
+ requireOrganizationPlugin(ctx);
4716
+ assertManagedDirectorySyncEnabled(ctx);
4717
+ const organizationId = tryDecode(ctx.params.id);
4718
+ const claims = assertDirectorySyncManagementClaims(ctx, organizationId);
4719
+ await assertTargetOrganizationExists(ctx, organizationId);
4720
+ await assertManagedDirectoryTransactionsConfigured(ctx);
4721
+ const providerId = tryDecode(ctx.params.providerId);
4722
+ const credentialId = tryDecode(ctx.params.credentialId);
4723
+ const endpoint = requireManagedEndpoint(ctx, getManagedEndpoints(ctx).revokeSCIMManagedCredential, "revoke");
4724
+ const { state, updated } = await runWithTransaction$1(ctx.context.adapter, async () => {
4725
+ const database = await getCurrentAdapter$1(ctx.context.adapter);
4726
+ const row = await getDirectorySyncConnection(ctx, organizationId, providerId);
4727
+ if (row.status !== "active" || !row.connectionId) throw ctx.error("CONFLICT", { message: "Directory sync connection is not active" });
4728
+ const updated = await database.update({
4729
+ model: "directorySyncConnection",
4730
+ where: [
4731
+ {
4732
+ field: "id",
4733
+ value: row.id
4734
+ },
4735
+ {
4736
+ field: "status",
4737
+ value: "active"
4738
+ },
4739
+ {
4740
+ field: "revision",
4741
+ value: row.revision
4742
+ },
4743
+ {
4744
+ field: "connectionId",
4745
+ value: row.connectionId
4746
+ }
4747
+ ],
4748
+ update: {
4749
+ revision: row.revision + 1,
4750
+ updatedAt: /* @__PURE__ */ new Date(),
4751
+ lastActorId: claims.actorId,
4752
+ lastError: null
4753
+ }
4754
+ });
4755
+ if (!updated) throw ctx.error("CONFLICT", { message: "Directory sync connection changed before credential revocation started" });
4756
+ return {
4757
+ state: await endpoint({
4758
+ body: {
4759
+ connectionId: row.connectionId,
4760
+ provisioningDomainId: row.provisioningDomainId,
4761
+ credentialId,
4762
+ actorId: claims.actorId
4763
+ },
4764
+ context: ctx.context,
4765
+ headers: getRequestHeaders(ctx)
4766
+ }),
4767
+ updated
4768
+ };
4769
+ });
4770
+ return serializeDirectory(updated, getScimEndpoint$1(ctx.context.baseURL), state);
4771
+ });
4772
+ const DIRECTORY_EVENTS_DEFAULT_LIMIT = 10;
4773
+ const DIRECTORY_PAGE_MAX_LIMIT = 100;
4774
+ const DIRECTORY_EVENTS_DEFAULT_SORT_DIRECTION = "desc";
4775
+ const directoryEventsQuerySchema = z$1.object({
4776
+ limit: z$1.number().or(z$1.string().transform(Number)).optional(),
4777
+ offset: z$1.number().or(z$1.string().transform(Number)).optional(),
4778
+ sortDirection: z$1.enum(["asc", "desc"]).optional()
4779
+ }).optional();
4780
+ function resolveDirectoryEventsPage(query) {
4781
+ const requestedLimit = query?.limit;
4782
+ const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(1, Math.floor(requestedLimit)), DIRECTORY_PAGE_MAX_LIMIT) : DIRECTORY_EVENTS_DEFAULT_LIMIT;
4783
+ const requestedOffset = query?.offset;
4784
+ return {
4785
+ limit,
4786
+ offset: Number.isFinite(requestedOffset) ? Math.max(0, Math.floor(requestedOffset)) : 0,
4787
+ sortDirection: query?.sortDirection ?? DIRECTORY_EVENTS_DEFAULT_SORT_DIRECTION
4788
+ };
3333
4789
  }
4790
+ const listDirectoryEvents = (options) => createAuthEndpoint("/dash/organization/:id/directories/:providerId/events", {
4791
+ method: "GET",
4792
+ use: [jwtMiddleware(options, directorySyncClaimsSchema)],
4793
+ query: directoryEventsQuerySchema
4794
+ }, async (ctx) => {
4795
+ requireOrganizationPlugin(ctx);
4796
+ assertManagedDirectorySyncEnabled(ctx);
4797
+ const organizationId = tryDecode(ctx.params.id);
4798
+ assertDirectorySyncManagementClaims(ctx, organizationId);
4799
+ await assertTargetOrganizationExists(ctx, organizationId);
4800
+ const { limit, offset, sortDirection } = resolveDirectoryEventsPage(ctx.query);
4801
+ const row = await getDirectorySyncConnection(ctx, organizationId, tryDecode(ctx.params.providerId));
4802
+ if (!row.connectionId) return {
4803
+ events: [],
4804
+ total: 0,
4805
+ limit,
4806
+ offset
4807
+ };
4808
+ const result = await requireManagedEndpoint(ctx, getManagedEndpoints(ctx).listSCIMManagedConnectionEvents, "events")({
4809
+ body: {
4810
+ connectionId: row.connectionId,
4811
+ provisioningDomainId: row.provisioningDomainId,
4812
+ limit,
4813
+ offset,
4814
+ sortDirection
4815
+ },
4816
+ context: ctx.context,
4817
+ headers: getRequestHeaders(ctx)
4818
+ });
4819
+ const events = result.events.map((event) => ({
4820
+ ...event,
4821
+ createdAt: event.createdAt instanceof Date ? event.createdAt.toISOString() : event.createdAt
4822
+ }));
4823
+ if (typeof result.total === "number" && typeof result.limit === "number" && typeof result.offset === "number") return {
4824
+ events,
4825
+ total: result.total,
4826
+ limit: result.limit,
4827
+ offset: result.offset
4828
+ };
4829
+ const sorted = [...events].sort((a, b) => sortDirection === "asc" ? a.sequence - b.sequence : b.sequence - a.sequence);
4830
+ return {
4831
+ events: sorted.slice(offset, offset + limit),
4832
+ total: sorted.length,
4833
+ limit,
4834
+ offset
4835
+ };
4836
+ });
4837
+ const decommissionOrganizationDirectory = (options) => createAuthEndpoint("/dash/organization/:id/directories/:providerId/decommission", {
4838
+ method: "POST",
4839
+ use: [jwtMiddleware(options, directorySyncClaimsSchema)],
4840
+ body: emptyBodySchema
4841
+ }, async (ctx) => {
4842
+ requireOrganizationPlugin(ctx);
4843
+ assertManagedDirectorySyncEnabled(ctx);
4844
+ const organizationId = tryDecode(ctx.params.id);
4845
+ const claims = assertDirectorySyncManagementClaims(ctx, organizationId);
4846
+ await assertTargetOrganizationExists(ctx, organizationId);
4847
+ let row = await getDirectorySyncConnection(ctx, organizationId, tryDecode(ctx.params.providerId));
4848
+ if (row.status === "decommissioned") return serializeDirectory(row, getScimEndpoint$1(ctx.context.baseURL), await getManagedState(ctx, row));
4849
+ if (!row.connectionId) throw ctx.error("CONFLICT", { message: "Directory sync connection has an invalid catalog binding" });
4850
+ const connectionId = row.connectionId;
4851
+ if (row.status === "active") row = await startDirectorySyncDecommission(ctx, row, claims.actorId);
4852
+ if (row.status !== "decommissioning") throw ctx.error("CONFLICT", { message: "Directory sync connection cannot be decommissioned" });
4853
+ const state = await requireManagedEndpoint(ctx, getManagedEndpoints(ctx).decommissionSCIMManagedConnection, "decommission")({
4854
+ body: {
4855
+ connectionId,
4856
+ provisioningDomainId: row.provisioningDomainId,
4857
+ actorId: claims.actorId
4858
+ },
4859
+ context: ctx.context,
4860
+ headers: getRequestHeaders(ctx)
4861
+ });
4862
+ if (state.connection.status === "decommissioned" || state.decommission.status === "complete") row = await ctx.context.adapter.update({
4863
+ model: "directorySyncConnection",
4864
+ where: [{
4865
+ field: "id",
4866
+ value: row.id
4867
+ }, {
4868
+ field: "status",
4869
+ value: "decommissioning"
4870
+ }],
4871
+ update: {
4872
+ activeOrganizationKey: createTerminalOrganizationKey(row.aliasKey),
4873
+ activeSsoProviderKey: createTerminalPairingKey(row.aliasKey),
4874
+ status: "decommissioned",
4875
+ decommissionedAt: /* @__PURE__ */ new Date(),
4876
+ updatedAt: /* @__PURE__ */ new Date(),
4877
+ lastActorId: claims.actorId
4878
+ }
4879
+ }) ?? row;
4880
+ return serializeDirectory(row, getScimEndpoint$1(ctx.context.baseURL), state);
4881
+ });
4882
+ const unpairOrganizationDirectory = (options) => createAuthEndpoint("/dash/organization/:id/directories/:providerId/unpair", {
4883
+ method: "POST",
4884
+ use: [jwtMiddleware(options, directorySyncClaimsSchema)],
4885
+ body: emptyBodySchema
4886
+ }, async (ctx) => {
4887
+ requireOrganizationPlugin(ctx);
4888
+ assertManagedDirectorySyncEnabled(ctx);
4889
+ const organizationId = tryDecode(ctx.params.id);
4890
+ const claims = assertDirectorySyncManagementClaims(ctx, organizationId);
4891
+ await assertTargetOrganizationExists(ctx, organizationId);
4892
+ const updated = await unpairTerminalDirectorySyncConnection(ctx, await getDirectorySyncConnection(ctx, organizationId, tryDecode(ctx.params.providerId)), claims.actorId);
4893
+ return serializeDirectory(updated, getScimEndpoint$1(ctx.context.baseURL), await getManagedState(ctx, updated));
4894
+ });
3334
4895
  //#endregion
3335
4896
  //#region src/routes/directory-sync/session.ts
3336
4897
  async function buildSyntheticSession(ctx, ownerUserId) {
@@ -3414,13 +4975,12 @@ async function scimProviderExists(ctx, organizationId, providerId) {
3414
4975
  function getScimEndpoint(baseUrl) {
3415
4976
  return `${baseUrl}/scim/v2`;
3416
4977
  }
3417
- function getSCIMPlugin(ctx) {
3418
- return ctx.context.getPlugin("scim");
4978
+ function resolveMode(ctx) {
4979
+ return resolveDirectorySyncMode(getSCIMPlugin(ctx), isManagedDirectorySyncEnabled(ctx));
3419
4980
  }
3420
- function isScimDirectorySyncEnabled(scimPlugin, authVersion) {
3421
- if (!scimPlugin) return false;
3422
- if (isVersionAtLeast(authVersion, "1.7.0")) return true;
3423
- return scimPlugin.options?.providerOwnership?.enabled === true;
4981
+ /** Legacy provider-ownership APIs. Never true when the 1.7+ managed surface exists. */
4982
+ function isLegacyDirectorySyncEnabled(ctx) {
4983
+ return resolveMode(ctx) === "legacy";
3424
4984
  }
3425
4985
  const DIRECTORY_SYNC_DUPLICATE_MESSAGE = "A directory sync connection with this provider ID already exists. Remove the existing connection or choose a different provider ID.";
3426
4986
  function isDuplicateDirectorySyncError(e) {
@@ -3433,14 +4993,15 @@ const listOrganizationDirectories = (options) => {
3433
4993
  method: "GET",
3434
4994
  use: [jwtMiddleware(options)]
3435
4995
  }, async (ctx) => {
4996
+ if (resolveMode(ctx) === "managed") return listOrganizationDirectoriesManagedHandler(ctx);
3436
4997
  if (!isOrganizationEnabled(ctx)) {
3437
4998
  ctx.context.logger.warn("[Dash] Organization plugin not enabled, returning empty directories list");
3438
4999
  return [];
3439
5000
  }
3440
5001
  const organizationId = tryDecode(ctx.params.id);
3441
5002
  const scimPlugin = getSCIMPlugin(ctx);
3442
- if (!scimPlugin?.endpoints.listSCIMProviderConnections || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) {
3443
- ctx.context.logger.warn("[Dash] SCIM plugin not available or provider ownership disabled, returning empty directories list", { organizationId });
5003
+ if (!isLegacyDirectorySyncEnabled(ctx) || !scimPlugin?.endpoints.listSCIMProviderConnections) {
5004
+ ctx.context.logger.warn("[Dash] Legacy SCIM directory sync is unavailable, returning empty directories list", { organizationId });
3444
5005
  return [];
3445
5006
  }
3446
5007
  const managerUserId = await resolveScimManagementUserId(ctx, organizationId);
@@ -3464,7 +5025,7 @@ const listOrganizationDirectories = (options) => {
3464
5025
  }
3465
5026
  });
3466
5027
  };
3467
- const createOrganizationDirectory = (options) => {
5028
+ const createOrganizationDirectoryLegacy = (options) => {
3468
5029
  return createAuthEndpoint("/dash/organization/directory/create", {
3469
5030
  method: "POST",
3470
5031
  use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
@@ -3476,7 +5037,7 @@ const createOrganizationDirectory = (options) => {
3476
5037
  requireOrganizationPlugin(ctx);
3477
5038
  const { organizationId } = ctx.context.payload;
3478
5039
  const scimPlugin = getSCIMPlugin(ctx);
3479
- if (!scimPlugin?.endpoints.generateSCIMToken || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
5040
+ if (!isLegacyDirectorySyncEnabled(ctx) || !scimPlugin?.endpoints.generateSCIMToken) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3480
5041
  const { providerId, ownerUserId } = ctx.body;
3481
5042
  if (await scimProviderExists(ctx, organizationId, providerId)) throw ctx.error("BAD_REQUEST", { message: DIRECTORY_SYNC_DUPLICATE_MESSAGE });
3482
5043
  let scimToken;
@@ -3504,7 +5065,7 @@ const createOrganizationDirectory = (options) => {
3504
5065
  };
3505
5066
  });
3506
5067
  };
3507
- const deleteOrganizationDirectory = (options) => {
5068
+ const deleteOrganizationDirectoryLegacy = (options) => {
3508
5069
  return createAuthEndpoint("/dash/organization/directory/delete", {
3509
5070
  method: "POST",
3510
5071
  use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
@@ -3512,7 +5073,7 @@ const deleteOrganizationDirectory = (options) => {
3512
5073
  }, async (ctx) => {
3513
5074
  requireOrganizationPlugin(ctx);
3514
5075
  const scimPlugin = getSCIMPlugin(ctx);
3515
- if (!scimPlugin?.endpoints.deleteSCIMProviderConnection || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
5076
+ if (!isLegacyDirectorySyncEnabled(ctx) || !scimPlugin?.endpoints.deleteSCIMProviderConnection) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3516
5077
  const { organizationId } = ctx.context.payload;
3517
5078
  const { providerId } = ctx.body;
3518
5079
  const managerUserId = await resolveScimManagementUserId(ctx, organizationId, providerId);
@@ -3528,7 +5089,7 @@ const deleteOrganizationDirectory = (options) => {
3528
5089
  return { success: true };
3529
5090
  });
3530
5091
  };
3531
- const regenerateDirectoryToken = (options) => {
5092
+ const regenerateDirectoryTokenLegacy = (options) => {
3532
5093
  return createAuthEndpoint("/dash/organization/directory/regenerate-token", {
3533
5094
  method: "POST",
3534
5095
  use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
@@ -3536,7 +5097,7 @@ const regenerateDirectoryToken = (options) => {
3536
5097
  }, async (ctx) => {
3537
5098
  requireOrganizationPlugin(ctx);
3538
5099
  const scimPlugin = getSCIMPlugin(ctx);
3539
- if (!scimPlugin?.endpoints.generateSCIMToken || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
5100
+ if (!isLegacyDirectorySyncEnabled(ctx) || !scimPlugin?.endpoints.generateSCIMToken) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3540
5101
  const { organizationId } = ctx.context.payload;
3541
5102
  const { providerId } = ctx.body;
3542
5103
  const managerUserId = await resolveScimManagementUserId(ctx, organizationId, providerId);
@@ -4124,10 +5685,8 @@ async function executePlatformInvitationCompletion(ctx, $api, invitation, args)
4124
5685
  };
4125
5686
  const adapter = ctx.context.internalAdapter;
4126
5687
  const user = isVersionAtLeast(ctx.context.version, "1.7.0") ? await adapter.createUser(userPayload, { method: "admin" }) : await adapter.createUser(userPayload);
4127
- if (password) await ctx.context.internalAdapter.createAccount({
5688
+ if (password) await createCredentialAccountCompat(ctx.context.internalAdapter, {
4128
5689
  userId: user.id,
4129
- providerId: "credential",
4130
- accountId: user.id,
4131
5690
  password: await ctx.context.password.hash(password)
4132
5691
  });
4133
5692
  const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
@@ -6232,6 +7791,40 @@ function validateSAMLMetadataAlgorithms(metadataXml) {
6232
7791
  return warnings;
6233
7792
  }
6234
7793
  //#endregion
7794
+ //#region src/routes/sso/verify-domain-error.ts
7795
+ const DOMAIN_ALREADY_VERIFIED = {
7796
+ verified: true,
7797
+ message: "Domain has already been verified"
7798
+ };
7799
+ const PROVIDER_CHANGED = {
7800
+ verified: false,
7801
+ message: "SSO provider changed while domain verification was in progress. Reload the provider and try again."
7802
+ };
7803
+ const DNS_RECORD_MISSING = {
7804
+ verified: false,
7805
+ message: "Unable to verify domain ownership. The TXT record was not found. It may take up to 48 hours for DNS changes to propagate."
7806
+ };
7807
+ function isRecord(value) {
7808
+ return typeof value === "object" && value !== null;
7809
+ }
7810
+ function getApiErrorCode(error) {
7811
+ const withExtras = error;
7812
+ if (typeof withExtras.code === "string") return withExtras.code;
7813
+ if (isRecord(withExtras.body) && typeof withExtras.body.code === "string") return withExtras.body.code;
7814
+ }
7815
+ /**
7816
+ * Maps SSO plugin `verifyDomain` API errors to dash responses.
7817
+ * Returns `null` when the caller should rethrow.
7818
+ */
7819
+ function mapSsoVerifyDomainApiError(error) {
7820
+ if (error.status === "CONFLICT") {
7821
+ if (getApiErrorCode(error) === "SSO_PROVIDER_CHANGED") return PROVIDER_CHANGED;
7822
+ return DOMAIN_ALREADY_VERIFIED;
7823
+ }
7824
+ if (error.status === "BAD_GATEWAY") return DNS_RECORD_MISSING;
7825
+ return null;
7826
+ }
7827
+ //#endregion
6235
7828
  //#region src/routes/sso/index.ts
6236
7829
  function requireOrganizationAccess(ctx) {
6237
7830
  const orgIdFromUrl = tryDecode(ctx.params.id);
@@ -6247,6 +7840,7 @@ const samlConfigSchema = z$1.object({
6247
7840
  cert: z$1.string().optional(),
6248
7841
  entityId: z$1.string().optional(),
6249
7842
  mapping: z$1.object({
7843
+ /** @deprecated Removed in better-auth 1.7+; SAML subject is NameID. */
6250
7844
  id: z$1.string().optional(),
6251
7845
  email: z$1.string().optional(),
6252
7846
  emailVerified: z$1.string().optional(),
@@ -6268,6 +7862,7 @@ const oidcConfigSchema = z$1.object({
6268
7862
  userInfoEndpoint: z$1.string().optional(),
6269
7863
  tokenEndpointAuthentication: z$1.enum(["client_secret_post", "client_secret_basic"]).optional(),
6270
7864
  mapping: z$1.object({
7865
+ /** @deprecated Removed in better-auth 1.7+; OIDC subject is `sub`. */
6271
7866
  id: z$1.string().optional(),
6272
7867
  email: z$1.string().optional(),
6273
7868
  emailVerified: z$1.string().optional(),
@@ -6293,20 +7888,24 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
6293
7888
  ctx.context.logger.warn("[Dash] SAML IdP metadata uses deprecated algorithms:", providerId, warnings);
6294
7889
  }
6295
7890
  }
7891
+ const m = samlConfig.mapping;
7892
+ const resolvedEntryPoint = samlConfig.entryPoint?.trim() || (idpMetadataXml ? extractEntryPointFromSAMLMetadata(idpMetadataXml) : void 0);
7893
+ const saml17OrNewer = isVersionAtLeast(ctx.context.version, "1.7.0");
7894
+ const ba17OrLater = isBetterAuth17OrLater(ctx.context.version);
7895
+ if (saml17OrNewer && !resolvedEntryPoint) throw ctx.error("BAD_REQUEST", { message: "SAML entry point URL is required; provide entryPoint or IdP metadata with SingleSignOnService Location" });
7896
+ if (ba17OrLater && !idpMetadataXml && !samlConfig.entityId?.trim()) throw ctx.error("BAD_REQUEST", { message: "IdP entity ID is required when IdP metadata XML is not provided" });
7897
+ const spIssuer = `${baseURL}/sso/saml2/sp/metadata?providerId=${providerId}`;
6296
7898
  const idpMetadata = idpMetadataXml ? { metadata: idpMetadataXml } : {
7899
+ ...ba17OrLater ? { entityID: samlConfig.entityId } : {},
6297
7900
  ...samlConfig.entryPoint ? { singleSignOnService: [{
6298
7901
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
6299
7902
  Location: samlConfig.entryPoint
6300
7903
  }] } : {},
6301
7904
  ...samlConfig.cert ? { cert: samlConfig.cert } : {}
6302
7905
  };
6303
- const m = samlConfig.mapping;
6304
- const resolvedEntryPoint = samlConfig.entryPoint?.trim() || (idpMetadataXml ? extractEntryPointFromSAMLMetadata(idpMetadataXml) : void 0);
6305
- const saml17OrNewer = isVersionAtLeast(ctx.context.version, "1.7.0");
6306
- if (saml17OrNewer && !resolvedEntryPoint) throw ctx.error("BAD_REQUEST", { message: "SAML entry point URL is required; provide entryPoint or IdP metadata with SingleSignOnService Location" });
6307
7906
  return {
6308
7907
  config: {
6309
- issuer: samlConfig.entityId ?? `${baseURL}/sso/saml2/sp/metadata?providerId=${providerId}`,
7908
+ issuer: ba17OrLater && !idpMetadataXml ? spIssuer : samlConfig.entityId ?? spIssuer,
6310
7909
  idpMetadata,
6311
7910
  ...saml17OrNewer ? {
6312
7911
  entryPoint: resolvedEntryPoint,
@@ -6318,7 +7917,7 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
6318
7917
  cert: samlConfig.cert ?? ""
6319
7918
  },
6320
7919
  ...m ? { mapping: {
6321
- id: m.id ?? "nameID",
7920
+ ...!ba17OrLater ? { id: m.id ?? "nameID" } : {},
6322
7921
  email: m.email ?? "email",
6323
7922
  name: m.name ?? "name",
6324
7923
  emailVerified: m.emailVerified,
@@ -6334,6 +7933,7 @@ async function resolveOIDCConfig(oidcConfig, _domain, ctx) {
6334
7933
  if (!oidcConfig.issuer || !oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint) throw ctx.error("BAD_REQUEST", { message: "OIDC discovery must be resolved before submitting; provide issuer, authorizationEndpoint, tokenEndpoint, and jwksEndpoint" });
6335
7934
  const om = oidcConfig.mapping;
6336
7935
  const discoveryEndpoint = oidcConfig.discoveryEndpoint ?? oidcConfig.discoveryUrl ?? oidcConfig.issuer;
7936
+ const ba17OrLater = isBetterAuth17OrLater(ctx.context.version);
6337
7937
  return {
6338
7938
  config: {
6339
7939
  clientId: oidcConfig.clientId,
@@ -6347,7 +7947,7 @@ async function resolveOIDCConfig(oidcConfig, _domain, ctx) {
6347
7947
  tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication,
6348
7948
  pkce: true,
6349
7949
  ...om ? { mapping: {
6350
- id: om.id ?? "sub",
7950
+ ...!ba17OrLater ? { id: om.id ?? "sub" } : {},
6351
7951
  email: om.email ?? "email",
6352
7952
  name: om.name ?? "name",
6353
7953
  emailVerified: om.emailVerified,
@@ -6414,7 +8014,7 @@ const createSsoProvider = (options) => {
6414
8014
  };
6415
8015
  if (protocol === "SAML" && samlConfig) {
6416
8016
  const samlResult = await resolveSAMLConfig(samlConfig, providerId, ctx.context.baseURL, ctx);
6417
- registerBody.issuer = samlResult.config.issuer ?? (samlConfig.entityId || `${ctx.context.baseURL}/sso/saml2/sp/metadata?providerId=${providerId}`);
8017
+ registerBody.issuer = samlResult.config.issuer;
6418
8018
  registerBody.samlConfig = samlResult.config;
6419
8019
  }
6420
8020
  if (protocol === "OIDC" && oidcConfig) {
@@ -6433,15 +8033,17 @@ const createSsoProvider = (options) => {
6433
8033
  });
6434
8034
  let verificationToken = null;
6435
8035
  if ("domainVerificationToken" in result && typeof result.domainVerificationToken === "string") verificationToken = result.domainVerificationToken;
8036
+ const tokenPrefix = ssoPlugin.options?.domainVerification?.tokenPrefix || "better-auth-token";
8037
+ const resolvedProviderId = result.providerId || providerId;
6436
8038
  return {
6437
8039
  success: true,
6438
8040
  provider: {
6439
8041
  id: result.providerId,
6440
- providerId: result.providerId || providerId,
8042
+ providerId: resolvedProviderId,
6441
8043
  domain: result.domain || domain
6442
8044
  },
6443
8045
  domainVerification: {
6444
- txtRecordName: `better-auth-token-${providerId}`,
8046
+ txtRecordName: `_${tokenPrefix}-${resolvedProviderId}`,
6445
8047
  verificationToken
6446
8048
  }
6447
8049
  };
@@ -6485,7 +8087,7 @@ const updateSsoProvider = (options) => {
6485
8087
  if (domain && domain !== existingProvider.domain) updateBody.domain = domain;
6486
8088
  if (protocol === "SAML" && samlConfig) {
6487
8089
  const samlResult = await resolveSAMLConfig(samlConfig, providerId, ctx.context.baseURL, ctx);
6488
- updateBody.issuer = samlResult.config.issuer ?? (samlConfig.entityId || `${ctx.context.baseURL}/sso/saml2/sp/metadata?providerId=${providerId}`);
8090
+ updateBody.issuer = samlResult.config.issuer;
6489
8091
  updateBody.samlConfig = samlResult.config;
6490
8092
  }
6491
8093
  if (protocol === "OIDC" && oidcConfig) {
@@ -6560,7 +8162,7 @@ const requestSsoVerificationToken = (options) => {
6560
8162
  }]
6561
8163
  });
6562
8164
  if (!provider) throw ctx.error("NOT_FOUND", { message: "SSO provider not found" });
6563
- const txtRecordName = `${ssoPlugin.options?.domainVerification?.tokenPrefix || "better-auth-token"}-${provider.providerId}`;
8165
+ const txtRecordName = `_${ssoPlugin.options?.domainVerification?.tokenPrefix || "better-auth-token"}-${provider.providerId}`;
6564
8166
  try {
6565
8167
  const result = await endpoints.requestDomainVerification({
6566
8168
  body: { providerId },
@@ -6622,14 +8224,8 @@ const verifySsoProviderDomain = (options) => {
6622
8224
  };
6623
8225
  } catch (e) {
6624
8226
  if (e instanceof APIError$1) {
6625
- if (e.status === "CONFLICT") return {
6626
- verified: true,
6627
- message: "Domain has already been verified"
6628
- };
6629
- if (e.status === "BAD_GATEWAY") return {
6630
- verified: false,
6631
- message: "Unable to verify domain ownership. The TXT record was not found. It may take up to 48 hours for DNS changes to propagate."
6632
- };
8227
+ const mapped = mapSsoVerifyDomainApiError(e);
8228
+ if (mapped) return mapped;
6633
8229
  throw e;
6634
8230
  }
6635
8231
  throw ctx.error("BAD_REQUEST", { message: e instanceof Error ? e.message : "Failed to verify domain" });
@@ -7401,10 +8997,8 @@ const createUser = (options) => {
7401
8997
  };
7402
8998
  const adapter = ctx.context.internalAdapter;
7403
8999
  const user = isVersionAtLeast(ctx.context.version, "1.7.0") ? await adapter.createUser(userPayload, { method: "admin" }) : await adapter.createUser(userPayload);
7404
- if (password) await ctx.context.internalAdapter.createAccount({
9000
+ if (password) await createCredentialAccountCompat(ctx.context.internalAdapter, {
7405
9001
  userId: user.id,
7406
- providerId: "credential",
7407
- accountId: user.id,
7408
9002
  password: await ctx.context.password.hash(password)
7409
9003
  });
7410
9004
  if (body.sendVerificationEmail && !emailVerified) {
@@ -7469,10 +9063,8 @@ const setPassword = (options) => {
7469
9063
  password: hashed,
7470
9064
  updatedAt: /* @__PURE__ */ new Date()
7471
9065
  });
7472
- else await ctx.context.internalAdapter.createAccount({
9066
+ else await createCredentialAccountCompat(ctx.context.internalAdapter, {
7473
9067
  userId,
7474
- providerId: "credential",
7475
- accountId: userId,
7476
9068
  password: hashed
7477
9069
  });
7478
9070
  return { success: true };
@@ -7484,7 +9076,7 @@ const unlinkAccount = (options) => {
7484
9076
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))],
7485
9077
  body: z$1.object({
7486
9078
  providerId: z$1.string(),
7487
- accountId: z$1.string().optional()
9079
+ accountId: z$1.string()
7488
9080
  })
7489
9081
  }, async (ctx) => {
7490
9082
  const { userId } = ctx.context.payload;
@@ -7493,7 +9085,10 @@ const unlinkAccount = (options) => {
7493
9085
  const accounts = await ctx.context.internalAdapter.findAccounts(userId);
7494
9086
  const allowUnlinkingAll = ctx.context.options.account?.accountLinking?.allowUnlinkingAll ?? false;
7495
9087
  if (accounts.length === 1 && !allowUnlinkingAll) throw new APIError("BAD_REQUEST", { message: "Cannot unlink the last account. This would lock the user out." });
7496
- const accountToUnlink = accounts.find((account) => accountId ? account.accountId === accountId && account.providerId === providerId : account.providerId === providerId);
9088
+ const accountToUnlink = resolveAccountForUnlink(accounts, {
9089
+ providerId,
9090
+ accountId
9091
+ });
7497
9092
  if (!accountToUnlink) throw new APIError("NOT_FOUND", { message: "Account not found" });
7498
9093
  await ctx.context.internalAdapter.deleteAccount(accountToUnlink.id);
7499
9094
  return { success: true };
@@ -8411,7 +10006,7 @@ function createSMSSender(config) {
8411
10006
  "user-agent": INFRA_USER_AGENT,
8412
10007
  Authorization: `Bearer ${apiKey}`
8413
10008
  },
8414
- timeout: config?.apiTimeout ?? 3e3
10009
+ timeout: config?.apiOptions?.timeout ?? config?.apiTimeout ?? 3e3
8415
10010
  });
8416
10011
  /**
8417
10012
  * Send an SMS with OTP code
@@ -8490,6 +10085,18 @@ async function sendSMS(options, config) {
8490
10085
  }
8491
10086
  //#endregion
8492
10087
  //#region src/index.ts
10088
+ /**
10089
+ * Stashed when session.create after runs before newSession/returned user is
10090
+ * available (1.7+ / better-auth#10473). Avoids a fallback user DB lookup by
10091
+ * flushing tracking once the endpoint after hook can resolve the user.
10092
+ */
10093
+ const PENDING_SESSION_TRACKING = Symbol.for("dash.pendingSessionTracking");
10094
+ function getPendingSessionTracking(ctx) {
10095
+ return ctx.context[PENDING_SESSION_TRACKING];
10096
+ }
10097
+ function setPendingSessionTracking(ctx, pending) {
10098
+ ctx.context[PENDING_SESSION_TRACKING] = pending;
10099
+ }
8493
10100
  async function getRequestLocation() {
8494
10101
  try {
8495
10102
  return (await getCurrentAuthContext()).context.location;
@@ -8505,7 +10112,11 @@ const dash = (options) => {
8505
10112
  ...opts,
8506
10113
  $api
8507
10114
  };
8508
- const $kv = createKV(opts);
10115
+ const $kv = createKV({
10116
+ kvUrl: opts.kvUrl,
10117
+ apiKey: opts.apiKey,
10118
+ timeout: opts.kvOptions.timeout
10119
+ });
8509
10120
  const activityUpdateInterval = opts.activityTracking?.updateInterval ?? 3e5;
8510
10121
  const scheduleLastActiveUpdate = async (ctx, userId) => {
8511
10122
  await ctx.context.runInBackgroundOrAwait(ctx.context.adapter.updateMany({
@@ -8522,6 +10133,21 @@ const dash = (options) => {
8522
10133
  const { tracker } = initTrackEvents($api);
8523
10134
  const { trackUserSignedUp, trackUserProfileUpdated, trackUserProfileImageUpdated, trackUserEmailVerified, trackUserBanned, trackUserUnBanned, trackUserDeleted } = initUserEvents(tracker);
8524
10135
  const { trackEmailVerificationSent, trackEmailSignInAttempt, trackUserSignedIn, trackUserSignedOut, trackSessionCreated, trackSocialSignInAttempt, trackSocialSignInRedirectionAttempt, trackUserImpersonated, trackUserImpersonationStop, trackSessionRevoked, trackSessionRevokedAll } = initSessionEvents(tracker);
10136
+ const trackSessionLifecycle = (enrichedSession, userId, ctx, location, eventUser, trackSignIn, impersonatedBy) => {
10137
+ let trigger = null;
10138
+ if (trackSignIn) {
10139
+ trigger = getTriggerInfo(ctx, userId, enrichedSession);
10140
+ trackUserSignedIn(enrichedSession, trigger, ctx, location, eventUser);
10141
+ } else trigger = getTriggerInfo(ctx, userId);
10142
+ trackSessionCreated(enrichedSession, trigger, ctx, location, eventUser);
10143
+ if (impersonatedBy) {
10144
+ trigger = {
10145
+ ...trigger,
10146
+ triggeredBy: impersonatedBy
10147
+ };
10148
+ trackUserImpersonated(enrichedSession, trigger, ctx, location, eventUser, resolveUserFromContext(impersonatedBy, ctx));
10149
+ }
10150
+ };
8525
10151
  const { trackAccountLinking, trackAccountUnlink, trackAccountPasswordChange } = initAccountEvents(tracker);
8526
10152
  const { trackPasswordResetRequest, trackPasswordResetRequestCompletion } = initVerificationEvents(tracker);
8527
10153
  const { trackOrganizationCreated, trackOrganizationUpdated } = initOrganizationEvents(tracker);
@@ -8625,6 +10251,14 @@ const dash = (options) => {
8625
10251
  };
8626
10252
  instrumentOrganizationHooks(organizationPlugin.options = organizationPlugin.options ?? {});
8627
10253
  } else logger.debug("[Dash] Organization plugin not active. Skipping instrumentation");
10254
+ const managedDirectorySync = opts.managedDirectorySync;
10255
+ if (managedDirectorySync?.enabled) instrumentDirectorySyncIntegration(ctx, {
10256
+ ssoPairing: managedDirectorySync.ssoPairing ?? true,
10257
+ membershipProjection: {
10258
+ enabled: managedDirectorySync.membershipProjection?.enabled ?? true,
10259
+ role: managedDirectorySync.membershipProjection?.role ?? "member"
10260
+ }
10261
+ });
8628
10262
  return { options: {
8629
10263
  databaseHooks: {
8630
10264
  user: {
@@ -8686,25 +10320,25 @@ const dash = (options) => {
8686
10320
  countryCode: location?.countryCode
8687
10321
  };
8688
10322
  const eventUser = resolveUserFromContext(session.userId, ctx);
8689
- let trigger = null;
8690
- if (matchesAnyRoute(ctx.path, [
10323
+ const trackSignIn = matchesAnyRoute(ctx.path, [
8691
10324
  routes.SIGN_IN,
8692
10325
  routes.SIGN_UP,
8693
10326
  routes.SIGN_IN_SOCIAL_CALLBACK,
8694
10327
  routes.SIGN_IN_OAUTH_CALLBACK
8695
- ])) {
8696
- trigger = getTriggerInfo(ctx, session.userId, enrichedSession);
8697
- trackUserSignedIn(enrichedSession, trigger, ctx, location, eventUser);
8698
- } else trigger = getTriggerInfo(ctx, session.userId);
8699
- trackSessionCreated(enrichedSession, trigger, ctx, location, eventUser);
8700
- if ("impersonatedBy" in session && session.impersonatedBy) {
8701
- trigger = {
8702
- ...trigger,
8703
- triggeredBy: session.impersonatedBy
8704
- };
8705
- const knownImpersonator = resolveUserFromContext(session.impersonatedBy, ctx);
8706
- trackUserImpersonated(enrichedSession, trigger, ctx, location, eventUser, knownImpersonator);
10328
+ ]);
10329
+ const impersonatedBy = "impersonatedBy" in session && session.impersonatedBy ? session.impersonatedBy : void 0;
10330
+ if (!eventUser) {
10331
+ setPendingSessionTracking(ctx, {
10332
+ enrichedSession,
10333
+ location,
10334
+ userId: session.userId,
10335
+ trackSignIn,
10336
+ impersonatedBy
10337
+ });
10338
+ if (opts.activityTracking?.enabled) await scheduleLastActiveUpdate(ctx, session.userId);
10339
+ return;
8707
10340
  }
10341
+ trackSessionLifecycle(enrichedSession, session.userId, ctx, location, eventUser, trackSignIn, impersonatedBy);
8708
10342
  if (opts.activityTracking?.enabled) await scheduleLastActiveUpdate(ctx, session.userId);
8709
10343
  }
8710
10344
  },
@@ -8803,53 +10437,67 @@ const dash = (options) => {
8803
10437
  routes.DASH_COMPLETE_INVITATION_SOCIAL
8804
10438
  ]);
8805
10439
  },
8806
- handler: createIdentificationMiddleware($kv, { skipIdentification: (ctx) => isDashRoute(ctx.path) })
10440
+ handler: createIdentificationMiddleware($kv, {
10441
+ skipIdentification: (ctx) => isDashRoute(ctx.path),
10442
+ retry: opts.kvOptions.retry
10443
+ })
8807
10444
  }],
8808
- after: [{
8809
- matcher: (ctx) => {
8810
- if (ctx.request?.method !== "GET") return true;
8811
- return matchesAnyRoute(ctx.path, [
8812
- routes.SIGN_IN_SOCIAL_CALLBACK,
8813
- routes.SIGN_IN_OAUTH_CALLBACK,
8814
- routes.DASH_IMPERSONATE_USER
8815
- ]);
10445
+ after: [
10446
+ {
10447
+ matcher: (ctx) => !!getPendingSessionTracking(ctx),
10448
+ handler: createAuthMiddleware(async (ctx) => {
10449
+ const pending = getPendingSessionTracking(ctx);
10450
+ if (!pending) return;
10451
+ trackSessionLifecycle(pending.enrichedSession, pending.userId, ctx, pending.location, resolveUserFromContext(pending.userId, ctx), pending.trackSignIn, pending.impersonatedBy);
10452
+ })
8816
10453
  },
8817
- handler: createAuthMiddleware(async (_ctx) => {
8818
- const ctx = _ctx;
8819
- const trigger = getTriggerInfo(ctx, ctx.context.session?.user.id ?? "unknown");
8820
- if (matchesAnyRoute(ctx.path, [routes.SEND_VERIFICATION_EMAIL, routes.DASH_SEND_VERIFICATION_EMAIL]) && ctx.context.session && !(ctx.context.returned instanceof Error)) trackEmailVerificationSent(ctx.context.session.session, ctx.context.session.user, trigger, ctx.context.location);
8821
- const body = ctx.body;
8822
- if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && ctx.context.returned instanceof Error && body?.email) trackEmailSignInAttempt(ctx, trigger, ctx.context.location);
8823
- if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL]) && ctx.context.returned instanceof Error && ctx.body.provider && ctx.body.idToken) trackSocialSignInAttempt(ctx, trigger, ctx.context.location);
8824
- if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL_CALLBACK, routes.SIGN_IN_OAUTH_CALLBACK]) && ctx.request?.method === "GET" && !ctx.context.newSession) trackSocialSignInRedirectionAttempt(ctx, trigger, ctx.context.location);
8825
- const headerRequestId = ctx.request?.headers.get("X-Request-Id");
8826
- if (headerRequestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, headerRequestId, {
8827
- maxAge: 600,
8828
- sameSite: "lax",
8829
- httpOnly: true,
8830
- path: "/"
8831
- });
8832
- else if (ctx.context.requestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, "", {
8833
- maxAge: 0,
8834
- path: "/"
8835
- });
8836
- })
8837
- }, {
8838
- handler: createAuthMiddleware(async (ctx) => {
8839
- if (!opts.activityTracking?.enabled) return;
8840
- if (activityUpdateInterval === 0) return;
8841
- const session = ctx.context.session || ctx.context.newSession;
8842
- if (!session?.user?.id) return;
8843
- const userId = session.user.id;
8844
- const now = Date.now();
8845
- const lastUpdate = session.user.lastActiveAt;
8846
- if (lastUpdate) {
8847
- if (now - new Date(lastUpdate).getTime() < activityUpdateInterval) return;
8848
- }
8849
- await scheduleLastActiveUpdate(ctx, userId);
8850
- }),
8851
- matcher: (ctx) => ctx.request?.method !== "GET"
8852
- }]
10454
+ {
10455
+ matcher: (ctx) => {
10456
+ if (ctx.request?.method !== "GET") return true;
10457
+ return matchesAnyRoute(ctx.path, [
10458
+ routes.SIGN_IN_SOCIAL_CALLBACK,
10459
+ routes.SIGN_IN_OAUTH_CALLBACK,
10460
+ routes.DASH_IMPERSONATE_USER
10461
+ ]);
10462
+ },
10463
+ handler: createAuthMiddleware(async (_ctx) => {
10464
+ const ctx = _ctx;
10465
+ const trigger = getTriggerInfo(ctx, ctx.context.session?.user.id ?? "unknown");
10466
+ if (matchesAnyRoute(ctx.path, [routes.SEND_VERIFICATION_EMAIL, routes.DASH_SEND_VERIFICATION_EMAIL]) && ctx.context.session && !(ctx.context.returned instanceof Error)) trackEmailVerificationSent(ctx.context.session.session, ctx.context.session.user, trigger, ctx.context.location);
10467
+ const body = ctx.body;
10468
+ if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && ctx.context.returned instanceof Error && body?.email) trackEmailSignInAttempt(ctx, trigger, ctx.context.location);
10469
+ if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL]) && ctx.context.returned instanceof Error && ctx.body.provider && ctx.body.idToken) trackSocialSignInAttempt(ctx, trigger, ctx.context.location);
10470
+ if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL_CALLBACK, routes.SIGN_IN_OAUTH_CALLBACK]) && ctx.request?.method === "GET" && !ctx.context.newSession) trackSocialSignInRedirectionAttempt(ctx, trigger, ctx.context.location);
10471
+ const headerRequestId = ctx.request?.headers.get("X-Request-Id");
10472
+ if (headerRequestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, headerRequestId, {
10473
+ maxAge: 600,
10474
+ sameSite: "lax",
10475
+ httpOnly: true,
10476
+ path: "/"
10477
+ });
10478
+ else if (ctx.context.requestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, "", {
10479
+ maxAge: 0,
10480
+ path: "/"
10481
+ });
10482
+ })
10483
+ },
10484
+ {
10485
+ handler: createAuthMiddleware(async (ctx) => {
10486
+ if (!opts.activityTracking?.enabled) return;
10487
+ if (activityUpdateInterval === 0) return;
10488
+ const session = ctx.context.session || ctx.context.newSession;
10489
+ if (!session?.user?.id) return;
10490
+ const userId = session.user.id;
10491
+ const now = Date.now();
10492
+ const lastUpdate = session.user.lastActiveAt;
10493
+ if (lastUpdate) {
10494
+ if (now - new Date(lastUpdate).getTime() < activityUpdateInterval) return;
10495
+ }
10496
+ await scheduleLastActiveUpdate(ctx, userId);
10497
+ }),
10498
+ matcher: (ctx) => ctx.request?.method !== "GET"
10499
+ }
10500
+ ]
8853
10501
  },
8854
10502
  endpoints: {
8855
10503
  getDashConfig: getConfig(settings),
@@ -8924,15 +10572,178 @@ const dash = (options) => {
8924
10572
  dashCompleteInvitationSocial: completeInvitationSocial(settings),
8925
10573
  dashCheckUserExists: checkUserExists(settings),
8926
10574
  listDashOrganizationDirectories: listOrganizationDirectories(settings),
8927
- createDashOrganizationDirectory: createOrganizationDirectory(settings),
8928
- deleteDashOrganizationDirectory: deleteOrganizationDirectory(settings),
8929
- regenerateDashDirectoryToken: regenerateDirectoryToken(settings),
10575
+ createDashOrganizationDirectory: createOrganizationDirectoryLegacy(settings),
10576
+ deleteDashOrganizationDirectory: deleteOrganizationDirectoryLegacy(settings),
10577
+ regenerateDashDirectoryToken: regenerateDirectoryTokenLegacy(settings),
10578
+ getDashManagedOrganizationDirectory: getOrganizationDirectory(settings),
10579
+ createDashManagedOrganizationDirectory: createOrganizationDirectory(settings),
10580
+ rotateDashManagedDirectoryCredential: rotateDirectoryCredential(settings),
10581
+ revokeDashManagedDirectoryCredential: revokeDirectoryCredential(settings),
10582
+ listDashManagedDirectoryEvents: listDirectoryEvents(settings),
10583
+ decommissionDashManagedOrganizationDirectory: decommissionOrganizationDirectory(settings),
10584
+ unpairDashManagedOrganizationDirectory: unpairOrganizationDirectory(settings),
8930
10585
  dashExecuteAdapter: executeAdapter(settings)
8931
10586
  },
8932
- schema: opts.activityTracking?.enabled ? { user: { fields: { lastActiveAt: {
8933
- type: "date",
8934
- required: false
8935
- } } } } : {}
10587
+ schema: {
10588
+ ...opts.activityTracking?.enabled ? { user: { fields: { lastActiveAt: {
10589
+ type: "date",
10590
+ required: false
10591
+ } } } } : {},
10592
+ ...opts.managedDirectorySync?.enabled ? {
10593
+ directorySyncConnection: { fields: {
10594
+ organizationId: {
10595
+ type: "string",
10596
+ required: true,
10597
+ index: true
10598
+ },
10599
+ providerId: {
10600
+ type: "string",
10601
+ required: true
10602
+ },
10603
+ aliasKey: {
10604
+ type: "string",
10605
+ required: true,
10606
+ unique: true,
10607
+ returned: false
10608
+ },
10609
+ provisioningDomainId: {
10610
+ type: "string",
10611
+ required: true,
10612
+ unique: true
10613
+ },
10614
+ activeOrganizationKey: {
10615
+ type: "string",
10616
+ required: true,
10617
+ unique: true,
10618
+ returned: false
10619
+ },
10620
+ connectionId: {
10621
+ type: "string",
10622
+ required: false,
10623
+ unique: true
10624
+ },
10625
+ creationRequestId: {
10626
+ type: "string",
10627
+ required: true,
10628
+ unique: true,
10629
+ returned: false
10630
+ },
10631
+ status: {
10632
+ type: "string",
10633
+ required: true
10634
+ },
10635
+ revision: {
10636
+ type: "number",
10637
+ required: true,
10638
+ defaultValue: 0,
10639
+ returned: false
10640
+ },
10641
+ createdAt: {
10642
+ type: "date",
10643
+ required: true
10644
+ },
10645
+ createdByActorId: {
10646
+ type: "string",
10647
+ required: true
10648
+ },
10649
+ updatedAt: {
10650
+ type: "date",
10651
+ required: true
10652
+ },
10653
+ lastActorId: {
10654
+ type: "string",
10655
+ required: true
10656
+ },
10657
+ ssoProviderId: {
10658
+ type: "string",
10659
+ required: false
10660
+ },
10661
+ ssoProviderRecordId: {
10662
+ type: "string",
10663
+ required: false,
10664
+ index: true
10665
+ },
10666
+ activeSsoProviderKey: {
10667
+ type: "string",
10668
+ required: true,
10669
+ unique: true,
10670
+ returned: false
10671
+ },
10672
+ serializedSsoPairing: {
10673
+ type: "string",
10674
+ required: false,
10675
+ returned: false
10676
+ },
10677
+ pairingEnforced: {
10678
+ type: "boolean",
10679
+ required: true,
10680
+ defaultValue: false
10681
+ },
10682
+ unpairedAt: {
10683
+ type: "date",
10684
+ required: false
10685
+ },
10686
+ unpairedBy: {
10687
+ type: "string",
10688
+ required: false
10689
+ },
10690
+ decommissionStartedAt: {
10691
+ type: "date",
10692
+ required: false
10693
+ },
10694
+ decommissionedAt: {
10695
+ type: "date",
10696
+ required: false
10697
+ },
10698
+ lastError: {
10699
+ type: "string",
10700
+ required: false,
10701
+ returned: false
10702
+ }
10703
+ } },
10704
+ directorySyncMembershipProvenance: { fields: {
10705
+ membershipKey: {
10706
+ type: "string",
10707
+ required: true,
10708
+ unique: true,
10709
+ returned: false
10710
+ },
10711
+ organizationId: {
10712
+ type: "string",
10713
+ required: true,
10714
+ index: true
10715
+ },
10716
+ userId: {
10717
+ type: "string",
10718
+ required: true,
10719
+ index: true
10720
+ },
10721
+ memberId: {
10722
+ type: "string",
10723
+ required: true,
10724
+ unique: true
10725
+ },
10726
+ ownership: {
10727
+ type: "string",
10728
+ required: true,
10729
+ returned: false
10730
+ },
10731
+ provisioningDomainId: {
10732
+ type: "string",
10733
+ required: true,
10734
+ index: true
10735
+ },
10736
+ createdAt: {
10737
+ type: "date",
10738
+ required: true
10739
+ },
10740
+ updatedAt: {
10741
+ type: "date",
10742
+ required: true
10743
+ }
10744
+ } }
10745
+ } : {}
10746
+ }
8936
10747
  };
8937
10748
  };
8938
10749
  //#endregion