@better-auth/infra 0.2.8 → 0.2.9

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
@@ -1210,7 +1210,7 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1210
1210
  return "";
1211
1211
  }
1212
1212
  },
1213
- async checkImpossibleTravel(userId, currentLocation, visitorId) {
1213
+ async checkImpossibleTravel(userId, currentLocation, visitorId, ip = null) {
1214
1214
  if (!options.impossibleTravel?.enabled || !currentLocation) return null;
1215
1215
  try {
1216
1216
  const data = await $api("/security/impossible-travel", {
@@ -1219,6 +1219,7 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1219
1219
  userId,
1220
1220
  visitorId,
1221
1221
  location: currentLocation,
1222
+ ip,
1222
1223
  config: options
1223
1224
  }
1224
1225
  });
@@ -1249,14 +1250,15 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1249
1250
  /**
1250
1251
  * Store user's last known location for impossible travel detection
1251
1252
  */
1252
- async storeLastLocation(userId, location) {
1253
- if (!location) return;
1253
+ async storeLastLocation(userId, location, ip = null) {
1254
+ if (!options.impossibleTravel?.enabled || !location) return;
1254
1255
  try {
1255
1256
  await $api("/security/store-last-login", {
1256
1257
  method: "POST",
1257
1258
  body: {
1258
1259
  userId,
1259
- location
1260
+ location,
1261
+ ip
1260
1262
  }
1261
1263
  });
1262
1264
  } catch (error) {
@@ -1497,7 +1499,9 @@ const all = new Set([
1497
1499
  "/send-verification-email",
1498
1500
  "/change-email",
1499
1501
  "/organization/invite-member",
1500
- "/dash/organization/invite-member"
1502
+ "/dash/organization/invite-member",
1503
+ "/dash/create-user",
1504
+ "/dash/update-user"
1501
1505
  ]);
1502
1506
  /**
1503
1507
  * Path is one of `[
@@ -1516,7 +1520,9 @@ const all = new Set([
1516
1520
  * '/send-verification-email',
1517
1521
  * '/change-email',
1518
1522
  * '/organization/invite-member',
1519
- * '/dash/organization/invite-member'
1523
+ * '/dash/organization/invite-member',
1524
+ * '/dash/create-user',
1525
+ * '/dash/update-user'
1520
1526
  * ]`.
1521
1527
  * @param context Request context
1522
1528
  * @param context.path Request path
@@ -2088,33 +2094,42 @@ const sentinel = (options) => {
2088
2094
  emailValidation: opts.security?.emailValidation,
2089
2095
  emailNormalization: opts.security?.emailNormalization,
2090
2096
  databaseHooks: {
2091
- user: { create: {
2092
- async before(user, ctx) {
2093
- if (!ctx) return;
2094
- const visitorId = ctx.context.visitorId;
2095
- if (visitorId && opts.security?.freeTrialAbuse?.enabled) {
2096
- recordCheck(ctx, "free_trial_abuse");
2097
- const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
2098
- if (abuseCheck.isAbuse && abuseCheck.action === "block") {
2099
- setOutcome(ctx, "blocked", "free_trial_abuse", {
2100
- accountCount: abuseCheck.accountCount,
2101
- maxAccounts: abuseCheck.maxAccounts
2102
- });
2103
- emitEvaluation(ctx, trackEvent);
2104
- throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2097
+ user: {
2098
+ create: {
2099
+ async before(user, ctx) {
2100
+ if (!ctx) return;
2101
+ const visitorId = ctx.context.visitorId;
2102
+ if (visitorId && opts.security?.freeTrialAbuse?.enabled) {
2103
+ recordCheck(ctx, "free_trial_abuse");
2104
+ const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
2105
+ if (abuseCheck.isAbuse && abuseCheck.action === "block") {
2106
+ setOutcome(ctx, "blocked", "free_trial_abuse", {
2107
+ accountCount: abuseCheck.accountCount,
2108
+ maxAccounts: abuseCheck.maxAccounts
2109
+ });
2110
+ emitEvaluation(ctx, trackEvent);
2111
+ throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2112
+ }
2105
2113
  }
2114
+ if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
2115
+ ...user,
2116
+ email: normalizeEmail(user.email, ctx.context)
2117
+ } };
2118
+ },
2119
+ async after(user, ctx) {
2120
+ if (!ctx) return;
2121
+ const visitorId = ctx.context.visitorId;
2122
+ if (visitorId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.trackFreeTrialSignup(visitorId, user.id));
2106
2123
  }
2124
+ },
2125
+ update: { async before(user, ctx) {
2126
+ if (!ctx) return;
2107
2127
  if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
2108
2128
  ...user,
2109
2129
  email: normalizeEmail(user.email, ctx.context)
2110
2130
  } };
2111
- },
2112
- async after(user, ctx) {
2113
- if (!ctx) return;
2114
- const visitorId = ctx.context.visitorId;
2115
- if (visitorId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.trackFreeTrialSignup(visitorId, user.id));
2116
- }
2117
- } },
2131
+ } }
2132
+ },
2118
2133
  session: { create: {
2119
2134
  async before(session, ctx) {
2120
2135
  if (!ctx) return;
@@ -2122,7 +2137,7 @@ const sentinel = (options) => {
2122
2137
  const identification = ctx.context.identification;
2123
2138
  if (session.userId && identification?.location && visitorId) {
2124
2139
  recordCheck(ctx, "impossible_travel");
2125
- const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId);
2140
+ const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId, identification.ip);
2126
2141
  if (travelCheck?.isImpossible) {
2127
2142
  if (travelCheck.action === "block") {
2128
2143
  setOutcome(ctx, "blocked", "impossible_travel", {
@@ -2201,7 +2216,7 @@ const sentinel = (options) => {
2201
2216
  }
2202
2217
  }
2203
2218
  }
2204
- if (identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location));
2219
+ if (opts.security?.impossibleTravel?.enabled && identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location, identification.ip));
2205
2220
  }
2206
2221
  } }
2207
2222
  }
@@ -2779,7 +2794,7 @@ const jwtValidateMiddleware = (options) => {
2779
2794
  };
2780
2795
  //#endregion
2781
2796
  //#region src/version.ts
2782
- const PLUGIN_VERSION = "0.2.8";
2797
+ const PLUGIN_VERSION = "0.2.9";
2783
2798
  //#endregion
2784
2799
  //#region src/routes/auth/config.ts
2785
2800
  const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
@@ -3794,13 +3809,13 @@ const completeInvitation = (options) => {
3794
3809
  * Used by the platform to verify before sending invitation
3795
3810
  * This is different from /dash/organization/check-user-by-email which also checks membership
3796
3811
  */
3797
- const checkUserExists = (_options) => {
3812
+ const checkUserExists = (options) => {
3798
3813
  return createAuthEndpoint("/dash/check-user-exists", {
3799
3814
  method: "POST",
3815
+ use: [jwtMiddleware(options)],
3800
3816
  body: z$1.object({ email: z$1.email() })
3801
3817
  }, async (ctx) => {
3802
3818
  const { email } = ctx.body;
3803
- if (!ctx.request?.headers.get("Authorization")) throw new APIError("UNAUTHORIZED", { message: "Authorization required" });
3804
3819
  const normalizedEmail = normalizeEmail(email, ctx.context);
3805
3820
  const existingUser = await ctx.context.internalAdapter.findUserByEmail(normalizedEmail).then((user) => user?.user);
3806
3821
  return {
@@ -4411,6 +4426,121 @@ async function withConcurrency(items, fn, options) {
4411
4426
  return results;
4412
4427
  }
4413
4428
  //#endregion
4429
+ //#region src/routes/organizations/schemas.ts
4430
+ const DENIED_ORG_WRITE_KEYS = new Set([
4431
+ "id",
4432
+ "createdAt",
4433
+ "updatedAt"
4434
+ ]);
4435
+ const CREATE_NON_PERSISTED_KEYS = new Set(["defaultTeamName"]);
4436
+ const CREATE_REQUEST_ONLY_KEYS = new Set(["userId", "skipDefaultTeam"]);
4437
+ const CORE_CREATE_KEYS = [
4438
+ "name",
4439
+ "slug",
4440
+ "logo"
4441
+ ];
4442
+ const CORE_UPDATE_KEYS = [
4443
+ "name",
4444
+ "slug",
4445
+ "logo",
4446
+ "metadata"
4447
+ ];
4448
+ function getOrganizationAdditionalFields(orgOptions) {
4449
+ return orgOptions?.schema?.organization?.additionalFields ?? {};
4450
+ }
4451
+ function getWritableOrganizationAdditionalFieldNames(orgOptions) {
4452
+ const additional = getOrganizationAdditionalFields(orgOptions);
4453
+ return Object.entries(additional).filter(([, field]) => {
4454
+ return field.input !== false;
4455
+ }).map(([name]) => name);
4456
+ }
4457
+ function getWritableOrganizationFieldNames(orgOptions, mode) {
4458
+ const keys = new Set(mode === "create" ? CORE_CREATE_KEYS : CORE_UPDATE_KEYS);
4459
+ for (const name of getWritableOrganizationAdditionalFieldNames(orgOptions)) keys.add(name);
4460
+ return keys;
4461
+ }
4462
+ /**
4463
+ * Returns only organization fields that may be persisted on create/update.
4464
+ * Strips unknown keys, denied keys, and create-only request fields.
4465
+ */
4466
+ function pickWritableOrganizationFields(body, orgOptions, mode) {
4467
+ const allowed = getWritableOrganizationFieldNames(orgOptions, mode);
4468
+ const skip = new Set([
4469
+ ...DENIED_ORG_WRITE_KEYS,
4470
+ ...mode === "create" ? CREATE_NON_PERSISTED_KEYS : [],
4471
+ ...mode === "create" ? CREATE_REQUEST_ONLY_KEYS : []
4472
+ ]);
4473
+ const result = {};
4474
+ for (const [key, value] of Object.entries(body)) {
4475
+ if (skip.has(key) || !allowed.has(key) || value === void 0) continue;
4476
+ result[key] = value;
4477
+ }
4478
+ return result;
4479
+ }
4480
+ const BaseCreateOrgCoreBodySchema = z$1.object({
4481
+ name: z$1.string(),
4482
+ slug: z$1.string(),
4483
+ logo: z$1.string().optional(),
4484
+ defaultTeamName: z$1.string().optional()
4485
+ });
4486
+ const BaseUpdateOrgCoreBodySchema = z$1.object({
4487
+ logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
4488
+ name: z$1.string().optional(),
4489
+ slug: z$1.string().optional(),
4490
+ metadata: z$1.string().optional()
4491
+ });
4492
+ const CreateOrganizationBodySchema = BaseCreateOrgCoreBodySchema.catchall(z$1.unknown());
4493
+ const UpdateOrganizationBodySchema = BaseUpdateOrgCoreBodySchema.catchall(z$1.unknown());
4494
+ function createSchemaForDBField(field) {
4495
+ switch (field.type) {
4496
+ case "number": return field.required ? z$1.coerce.number() : z$1.coerce.number().optional();
4497
+ case "boolean": return field.required ? z$1.coerce.boolean() : z$1.coerce.boolean().optional();
4498
+ case "date": return field.required ? z$1.union([z$1.string().min(1), z$1.coerce.date()]) : z$1.union([z$1.string(), z$1.coerce.date()]).optional();
4499
+ default: return field.required ? z$1.string().min(1) : z$1.string().optional();
4500
+ }
4501
+ }
4502
+ /**
4503
+ * Validates required core + configured additional fields on create.
4504
+ * Call after pickWritableOrgFields if you need required additional field checks.
4505
+ */
4506
+ function validateWritableCreateOrganizationFields(data, options) {
4507
+ const shape = {
4508
+ name: z$1.string().min(1),
4509
+ slug: z$1.string().min(1),
4510
+ logo: z$1.string().optional()
4511
+ };
4512
+ const additional = getOrganizationAdditionalFields(options);
4513
+ for (const [name, field] of Object.entries(additional)) {
4514
+ if (field.input === false) continue;
4515
+ shape[name] = createSchemaForDBField(field);
4516
+ }
4517
+ const result = z$1.object(shape).strict().safeParse(data);
4518
+ if (!result.success) throw result.error;
4519
+ }
4520
+ /**
4521
+ * Ensures at least one field is present on update and validates additional field types.
4522
+ */
4523
+ function validateWritableOrganizationUpdateFields(data, orgOptions) {
4524
+ if (Object.keys(data).length === 0) throw new z$1.ZodError([{
4525
+ code: "custom",
4526
+ message: "No valid fields to update",
4527
+ path: []
4528
+ }]);
4529
+ const shape = {
4530
+ name: z$1.string().min(1).optional(),
4531
+ slug: z$1.string().min(1).optional(),
4532
+ logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
4533
+ metadata: z$1.string().optional()
4534
+ };
4535
+ const additional = getOrganizationAdditionalFields(orgOptions);
4536
+ for (const [name, field] of Object.entries(additional)) {
4537
+ if (field.input === false) continue;
4538
+ shape[name] = createSchemaForDBField(field);
4539
+ }
4540
+ const result = z$1.object(shape).partial().strict().safeParse(data);
4541
+ if (!result.success) throw result.error;
4542
+ }
4543
+ //#endregion
4414
4544
  //#region src/routes/organizations/organization.ts
4415
4545
  const listOrganizations = (options) => {
4416
4546
  return createAuthEndpoint("/dash/list-organizations", {
@@ -4729,15 +4859,11 @@ const createOrganization = (options) => {
4729
4859
  userId: z$1.string(),
4730
4860
  skipDefaultTeam: z$1.boolean().optional().default(false)
4731
4861
  }))],
4732
- body: z$1.looseObject({
4733
- name: z$1.string(),
4734
- slug: z$1.string(),
4735
- logo: z$1.string().optional(),
4736
- defaultTeamName: z$1.string().optional()
4737
- })
4862
+ body: CreateOrganizationBodySchema
4738
4863
  }, async (ctx) => {
4739
4864
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
4740
4865
  const { userId } = ctx.context.payload;
4866
+ const defaultTeamName = ctx.body.defaultTeamName;
4741
4867
  const user = await ctx.context.adapter.findOne({
4742
4868
  model: "user",
4743
4869
  where: [{
@@ -4753,10 +4879,13 @@ const createOrganization = (options) => {
4753
4879
  value: ctx.body.slug
4754
4880
  }]
4755
4881
  }) > 0) throw ctx.error("BAD_REQUEST", { message: "Organization already exists" });
4756
- let orgData = {
4757
- ...ctx.body,
4758
- defaultTeamName: void 0
4759
- };
4882
+ let orgData = pickWritableOrganizationFields(ctx.body, orgOptions, "create");
4883
+ try {
4884
+ validateWritableCreateOrganizationFields(orgData, orgOptions);
4885
+ } catch (error) {
4886
+ if (error instanceof z$1.ZodError) throw ctx.error("BAD_REQUEST", { message: error.issues[0]?.message ?? "Invalid organization data" });
4887
+ throw error;
4888
+ }
4760
4889
  if (orgOptions.organizationCreation?.beforeCreate) {
4761
4890
  const response = await orgOptions.organizationCreation.beforeCreate({
4762
4891
  organization: {
@@ -4765,20 +4894,20 @@ const createOrganization = (options) => {
4765
4894
  },
4766
4895
  user
4767
4896
  }, ctx.request);
4768
- if (response && typeof response === "object" && "data" in response) orgData = {
4897
+ if (response && typeof response === "object" && "data" in response) orgData = pickWritableOrganizationFields({
4769
4898
  ...orgData,
4770
4899
  ...response.data
4771
- };
4900
+ }, orgOptions, "create");
4772
4901
  }
4773
4902
  if (orgOptions?.organizationHooks?.beforeCreateOrganization) {
4774
4903
  const response = await orgOptions?.organizationHooks.beforeCreateOrganization({
4775
4904
  organization: orgData,
4776
4905
  user
4777
4906
  });
4778
- if (response && typeof response === "object" && "data" in response) orgData = {
4907
+ if (response && typeof response === "object" && "data" in response) orgData = pickWritableOrganizationFields({
4779
4908
  ...orgData,
4780
4909
  ...response.data
4781
- };
4910
+ }, orgOptions, "create");
4782
4911
  }
4783
4912
  const organization = await ctx.context.adapter.create({
4784
4913
  model: "organization",
@@ -4827,7 +4956,7 @@ const createOrganization = (options) => {
4827
4956
  if (orgOptions?.teams?.enabled && orgOptions.teams.defaultTeam?.enabled !== false && !ctx.context.payload.skipDefaultTeam) {
4828
4957
  let teamData = {
4829
4958
  organizationId: organization.id,
4830
- name: ctx.body.defaultTeamName || `${organization.name}`,
4959
+ name: defaultTeamName || `${organization.name}`,
4831
4960
  createdAt: /* @__PURE__ */ new Date()
4832
4961
  };
4833
4962
  if (orgOptions?.organizationHooks?.beforeCreateTeam) {
@@ -4891,12 +5020,7 @@ const updateOrganization = (options) => {
4891
5020
  return createAuthEndpoint("/dash/organization/update", {
4892
5021
  method: "POST",
4893
5022
  use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
4894
- body: z$1.looseObject({
4895
- logo: z$1.url().optional(),
4896
- name: z$1.string().optional(),
4897
- slug: z$1.string().optional(),
4898
- metadata: z$1.string().optional()
4899
- })
5023
+ body: UpdateOrganizationBodySchema
4900
5024
  }, async (ctx) => {
4901
5025
  const { organizationId } = ctx.context.payload;
4902
5026
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
@@ -4931,7 +5055,13 @@ const updateOrganization = (options) => {
4931
5055
  const owner = owners[0];
4932
5056
  const updatedByUser = Array.isArray(owner.user) ? owner.user[0] : owner.user;
4933
5057
  if (!updatedByUser) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
4934
- let updateData = { ...ctx.body };
5058
+ let updateData = pickWritableOrganizationFields(ctx.body, orgOptions, "update");
5059
+ try {
5060
+ validateWritableOrganizationUpdateFields(updateData, orgOptions);
5061
+ } catch (error) {
5062
+ if (error instanceof z$1.ZodError) throw ctx.error("BAD_REQUEST", { message: error.issues[0]?.message ?? "Invalid organization data" });
5063
+ throw error;
5064
+ }
4935
5065
  if (typeof updateData.metadata === "string") try {
4936
5066
  updateData.metadata = updateData.metadata === "" ? void 0 : JSON.parse(updateData.metadata);
4937
5067
  } catch (parseError) {
@@ -4944,10 +5074,10 @@ const updateOrganization = (options) => {
4944
5074
  user: updatedByUser,
4945
5075
  member: owner
4946
5076
  });
4947
- if (response && typeof response === "object" && "data" in response) updateData = {
5077
+ if (response && typeof response === "object" && "data" in response) updateData = pickWritableOrganizationFields({
4948
5078
  ...updateData,
4949
5079
  ...response.data
4950
- };
5080
+ }, orgOptions, "update");
4951
5081
  }
4952
5082
  const organization = await ctx.context.adapter.update({
4953
5083
  model: "organization",
package/dist/native.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as DashGetAuditLogsInput, i as DashGetAllAuditLogsInput, n as DashAuditLogsResponse, o as dashClient, r as DashClientOptions, t as DashAuditLog } from "./dash-client-B6U89e1S.mjs";
1
+ import { a as DashGetAuditLogsInput, i as DashGetAllAuditLogsInput, n as DashAuditLogsResponse, o as dashClient, r as DashClientOptions, t as DashAuditLog } from "./dash-client-CZzJyRrV.mjs";
2
2
  import { BetterAuthClientPlugin } from "better-auth";
3
3
 
4
4
  //#region src/sentinel/native/client.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/infra",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Dashboard and analytics plugin for Better Auth",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -64,19 +64,19 @@
64
64
  "@better-auth/scim": "catalog:",
65
65
  "@better-auth/sso": "catalog:",
66
66
  "@infra/mocks": "workspace:*",
67
- "@types/bun": "latest",
67
+ "@types/bun": "catalog:",
68
68
  "@types/node": "catalog:",
69
69
  "better-auth": "catalog:",
70
70
  "expo-crypto": "^14.0.2",
71
71
  "happy-dom": "^20.9.0",
72
- "msw": "^2.14.3",
73
- "tsdown": "^0.21.10",
72
+ "msw": "catalog:",
73
+ "tsdown": "^0.22.0",
74
74
  "typescript": "catalog:",
75
75
  "zod": "catalog:"
76
76
  },
77
77
  "dependencies": {
78
- "@better-fetch/fetch": "^1.1.21",
79
- "better-call": "^1.3.3",
78
+ "@better-fetch/fetch": "catalog:",
79
+ "better-call": "catalog:",
80
80
  "jose": "^6.1.0",
81
81
  "libphonenumber-js": "^1.13.1"
82
82
  },