@better-auth/infra 0.2.8 → 0.2.10

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.10";
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 {
@@ -3810,6 +3825,23 @@ const checkUserExists = (_options) => {
3810
3825
  });
3811
3826
  };
3812
3827
  //#endregion
3828
+ //#region src/routes/organizations/roles.ts
3829
+ const DEFAULT_ORG_MEMBER_ROLES = [
3830
+ "member",
3831
+ "admin",
3832
+ "owner"
3833
+ ];
3834
+ function getOrganizationRoleKeys(orgOptions) {
3835
+ const roles = orgOptions?.roles;
3836
+ if (roles && typeof roles === "object" && Object.keys(roles).length > 0) return Object.keys(roles);
3837
+ return [...DEFAULT_ORG_MEMBER_ROLES];
3838
+ }
3839
+ const organizationMemberRoleInputSchema = z$1.string().trim().min(1, "Role is required").max(64, "Role is too long").regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, "Role must start with a letter and contain only letters, numbers, hyphens, and underscores");
3840
+ function validateOrganizationMemberRole(ctx, role, orgOptions) {
3841
+ const allowedRoles = getOrganizationRoleKeys(orgOptions);
3842
+ if (!allowedRoles.includes(role)) throw ctx.error("BAD_REQUEST", { message: `Invalid role. Allowed roles: ${allowedRoles.join(", ")}` });
3843
+ }
3844
+ //#endregion
3813
3845
  //#region src/routes/organizations/invitations.ts
3814
3846
  const listOrganizationInvitations = (options) => {
3815
3847
  return createAuthEndpoint("/dash/organization/:id/invitations", {
@@ -3858,7 +3890,7 @@ const inviteMember = (options) => {
3858
3890
  method: "POST",
3859
3891
  body: z$1.object({
3860
3892
  email: z$1.string(),
3861
- role: z$1.string(),
3893
+ role: organizationMemberRoleInputSchema,
3862
3894
  invitedBy: z$1.string()
3863
3895
  }),
3864
3896
  use: [jwtMiddleware(options, z$1.object({
@@ -3868,6 +3900,7 @@ const inviteMember = (options) => {
3868
3900
  }, async (ctx) => {
3869
3901
  const { organizationId } = ctx.context.payload;
3870
3902
  const organizationPlugin = requireOrganizationPlugin(ctx);
3903
+ validateOrganizationMemberRole(ctx, ctx.body.role, organizationPlugin.options);
3871
3904
  if (!organizationPlugin.options?.sendInvitationEmail) throw ctx.error("BAD_REQUEST", { message: "Invitation email is not enabled" });
3872
3905
  const invitedBy = await ctx.context.adapter.findOne({
3873
3906
  model: "user",
@@ -4117,11 +4150,12 @@ const addMember = (options) => {
4117
4150
  use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
4118
4151
  body: z$1.object({
4119
4152
  userId: z$1.string(),
4120
- role: z$1.string()
4153
+ role: organizationMemberRoleInputSchema
4121
4154
  })
4122
4155
  }, async (ctx) => {
4123
4156
  const { organizationId } = ctx.context.payload;
4124
4157
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
4158
+ validateOrganizationMemberRole(ctx, ctx.body.role, orgOptions);
4125
4159
  const organization = await ctx.context.adapter.findOne({
4126
4160
  model: "organization",
4127
4161
  where: [{
@@ -4250,11 +4284,12 @@ const updateMemberRole = (options) => {
4250
4284
  use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
4251
4285
  body: z$1.object({
4252
4286
  memberId: z$1.string(),
4253
- role: z$1.string()
4287
+ role: organizationMemberRoleInputSchema
4254
4288
  })
4255
4289
  }, async (ctx) => {
4256
4290
  const { organizationId } = ctx.context.payload;
4257
4291
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
4292
+ validateOrganizationMemberRole(ctx, ctx.body.role, orgOptions);
4258
4293
  const existingMember = await ctx.context.adapter.findOne({
4259
4294
  model: "member",
4260
4295
  where: [{
@@ -4411,6 +4446,121 @@ async function withConcurrency(items, fn, options) {
4411
4446
  return results;
4412
4447
  }
4413
4448
  //#endregion
4449
+ //#region src/routes/organizations/schemas.ts
4450
+ const DENIED_ORG_WRITE_KEYS = new Set([
4451
+ "id",
4452
+ "createdAt",
4453
+ "updatedAt"
4454
+ ]);
4455
+ const CREATE_NON_PERSISTED_KEYS = new Set(["defaultTeamName"]);
4456
+ const CREATE_REQUEST_ONLY_KEYS = new Set(["userId", "skipDefaultTeam"]);
4457
+ const CORE_CREATE_KEYS = [
4458
+ "name",
4459
+ "slug",
4460
+ "logo"
4461
+ ];
4462
+ const CORE_UPDATE_KEYS = [
4463
+ "name",
4464
+ "slug",
4465
+ "logo",
4466
+ "metadata"
4467
+ ];
4468
+ function getOrganizationAdditionalFields(orgOptions) {
4469
+ return orgOptions?.schema?.organization?.additionalFields ?? {};
4470
+ }
4471
+ function getWritableOrganizationAdditionalFieldNames(orgOptions) {
4472
+ const additional = getOrganizationAdditionalFields(orgOptions);
4473
+ return Object.entries(additional).filter(([, field]) => {
4474
+ return field.input !== false;
4475
+ }).map(([name]) => name);
4476
+ }
4477
+ function getWritableOrganizationFieldNames(orgOptions, mode) {
4478
+ const keys = new Set(mode === "create" ? CORE_CREATE_KEYS : CORE_UPDATE_KEYS);
4479
+ for (const name of getWritableOrganizationAdditionalFieldNames(orgOptions)) keys.add(name);
4480
+ return keys;
4481
+ }
4482
+ /**
4483
+ * Returns only organization fields that may be persisted on create/update.
4484
+ * Strips unknown keys, denied keys, and create-only request fields.
4485
+ */
4486
+ function pickWritableOrganizationFields(body, orgOptions, mode) {
4487
+ const allowed = getWritableOrganizationFieldNames(orgOptions, mode);
4488
+ const skip = new Set([
4489
+ ...DENIED_ORG_WRITE_KEYS,
4490
+ ...mode === "create" ? CREATE_NON_PERSISTED_KEYS : [],
4491
+ ...mode === "create" ? CREATE_REQUEST_ONLY_KEYS : []
4492
+ ]);
4493
+ const result = {};
4494
+ for (const [key, value] of Object.entries(body)) {
4495
+ if (skip.has(key) || !allowed.has(key) || value === void 0) continue;
4496
+ result[key] = value;
4497
+ }
4498
+ return result;
4499
+ }
4500
+ const BaseCreateOrgCoreBodySchema = z$1.object({
4501
+ name: z$1.string(),
4502
+ slug: z$1.string(),
4503
+ logo: z$1.string().optional(),
4504
+ defaultTeamName: z$1.string().optional()
4505
+ });
4506
+ const BaseUpdateOrgCoreBodySchema = z$1.object({
4507
+ logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
4508
+ name: z$1.string().optional(),
4509
+ slug: z$1.string().optional(),
4510
+ metadata: z$1.string().optional()
4511
+ });
4512
+ const CreateOrganizationBodySchema = BaseCreateOrgCoreBodySchema.catchall(z$1.unknown());
4513
+ const UpdateOrganizationBodySchema = BaseUpdateOrgCoreBodySchema.catchall(z$1.unknown());
4514
+ function createSchemaForDBField(field) {
4515
+ switch (field.type) {
4516
+ case "number": return field.required ? z$1.coerce.number() : z$1.coerce.number().optional();
4517
+ case "boolean": return field.required ? z$1.coerce.boolean() : z$1.coerce.boolean().optional();
4518
+ 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();
4519
+ default: return field.required ? z$1.string().min(1) : z$1.string().optional();
4520
+ }
4521
+ }
4522
+ /**
4523
+ * Validates required core + configured additional fields on create.
4524
+ * Call after pickWritableOrgFields if you need required additional field checks.
4525
+ */
4526
+ function validateWritableCreateOrganizationFields(data, options) {
4527
+ const shape = {
4528
+ name: z$1.string().min(1),
4529
+ slug: z$1.string().min(1),
4530
+ logo: z$1.string().optional()
4531
+ };
4532
+ const additional = getOrganizationAdditionalFields(options);
4533
+ for (const [name, field] of Object.entries(additional)) {
4534
+ if (field.input === false) continue;
4535
+ shape[name] = createSchemaForDBField(field);
4536
+ }
4537
+ const result = z$1.object(shape).strict().safeParse(data);
4538
+ if (!result.success) throw result.error;
4539
+ }
4540
+ /**
4541
+ * Ensures at least one field is present on update and validates additional field types.
4542
+ */
4543
+ function validateWritableOrganizationUpdateFields(data, orgOptions) {
4544
+ if (Object.keys(data).length === 0) throw new z$1.ZodError([{
4545
+ code: "custom",
4546
+ message: "No valid fields to update",
4547
+ path: []
4548
+ }]);
4549
+ const shape = {
4550
+ name: z$1.string().min(1).optional(),
4551
+ slug: z$1.string().min(1).optional(),
4552
+ logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
4553
+ metadata: z$1.string().optional()
4554
+ };
4555
+ const additional = getOrganizationAdditionalFields(orgOptions);
4556
+ for (const [name, field] of Object.entries(additional)) {
4557
+ if (field.input === false) continue;
4558
+ shape[name] = createSchemaForDBField(field);
4559
+ }
4560
+ const result = z$1.object(shape).partial().strict().safeParse(data);
4561
+ if (!result.success) throw result.error;
4562
+ }
4563
+ //#endregion
4414
4564
  //#region src/routes/organizations/organization.ts
4415
4565
  const listOrganizations = (options) => {
4416
4566
  return createAuthEndpoint("/dash/list-organizations", {
@@ -4729,15 +4879,11 @@ const createOrganization = (options) => {
4729
4879
  userId: z$1.string(),
4730
4880
  skipDefaultTeam: z$1.boolean().optional().default(false)
4731
4881
  }))],
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
- })
4882
+ body: CreateOrganizationBodySchema
4738
4883
  }, async (ctx) => {
4739
4884
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
4740
4885
  const { userId } = ctx.context.payload;
4886
+ const defaultTeamName = ctx.body.defaultTeamName;
4741
4887
  const user = await ctx.context.adapter.findOne({
4742
4888
  model: "user",
4743
4889
  where: [{
@@ -4753,10 +4899,13 @@ const createOrganization = (options) => {
4753
4899
  value: ctx.body.slug
4754
4900
  }]
4755
4901
  }) > 0) throw ctx.error("BAD_REQUEST", { message: "Organization already exists" });
4756
- let orgData = {
4757
- ...ctx.body,
4758
- defaultTeamName: void 0
4759
- };
4902
+ let orgData = pickWritableOrganizationFields(ctx.body, orgOptions, "create");
4903
+ try {
4904
+ validateWritableCreateOrganizationFields(orgData, orgOptions);
4905
+ } catch (error) {
4906
+ if (error instanceof z$1.ZodError) throw ctx.error("BAD_REQUEST", { message: error.issues[0]?.message ?? "Invalid organization data" });
4907
+ throw error;
4908
+ }
4760
4909
  if (orgOptions.organizationCreation?.beforeCreate) {
4761
4910
  const response = await orgOptions.organizationCreation.beforeCreate({
4762
4911
  organization: {
@@ -4765,20 +4914,20 @@ const createOrganization = (options) => {
4765
4914
  },
4766
4915
  user
4767
4916
  }, ctx.request);
4768
- if (response && typeof response === "object" && "data" in response) orgData = {
4917
+ if (response && typeof response === "object" && "data" in response) orgData = pickWritableOrganizationFields({
4769
4918
  ...orgData,
4770
4919
  ...response.data
4771
- };
4920
+ }, orgOptions, "create");
4772
4921
  }
4773
4922
  if (orgOptions?.organizationHooks?.beforeCreateOrganization) {
4774
4923
  const response = await orgOptions?.organizationHooks.beforeCreateOrganization({
4775
4924
  organization: orgData,
4776
4925
  user
4777
4926
  });
4778
- if (response && typeof response === "object" && "data" in response) orgData = {
4927
+ if (response && typeof response === "object" && "data" in response) orgData = pickWritableOrganizationFields({
4779
4928
  ...orgData,
4780
4929
  ...response.data
4781
- };
4930
+ }, orgOptions, "create");
4782
4931
  }
4783
4932
  const organization = await ctx.context.adapter.create({
4784
4933
  model: "organization",
@@ -4827,7 +4976,7 @@ const createOrganization = (options) => {
4827
4976
  if (orgOptions?.teams?.enabled && orgOptions.teams.defaultTeam?.enabled !== false && !ctx.context.payload.skipDefaultTeam) {
4828
4977
  let teamData = {
4829
4978
  organizationId: organization.id,
4830
- name: ctx.body.defaultTeamName || `${organization.name}`,
4979
+ name: defaultTeamName || `${organization.name}`,
4831
4980
  createdAt: /* @__PURE__ */ new Date()
4832
4981
  };
4833
4982
  if (orgOptions?.organizationHooks?.beforeCreateTeam) {
@@ -4891,12 +5040,7 @@ const updateOrganization = (options) => {
4891
5040
  return createAuthEndpoint("/dash/organization/update", {
4892
5041
  method: "POST",
4893
5042
  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
- })
5043
+ body: UpdateOrganizationBodySchema
4900
5044
  }, async (ctx) => {
4901
5045
  const { organizationId } = ctx.context.payload;
4902
5046
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
@@ -4931,7 +5075,13 @@ const updateOrganization = (options) => {
4931
5075
  const owner = owners[0];
4932
5076
  const updatedByUser = Array.isArray(owner.user) ? owner.user[0] : owner.user;
4933
5077
  if (!updatedByUser) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
4934
- let updateData = { ...ctx.body };
5078
+ let updateData = pickWritableOrganizationFields(ctx.body, orgOptions, "update");
5079
+ try {
5080
+ validateWritableOrganizationUpdateFields(updateData, orgOptions);
5081
+ } catch (error) {
5082
+ if (error instanceof z$1.ZodError) throw ctx.error("BAD_REQUEST", { message: error.issues[0]?.message ?? "Invalid organization data" });
5083
+ throw error;
5084
+ }
4935
5085
  if (typeof updateData.metadata === "string") try {
4936
5086
  updateData.metadata = updateData.metadata === "" ? void 0 : JSON.parse(updateData.metadata);
4937
5087
  } catch (parseError) {
@@ -4944,10 +5094,10 @@ const updateOrganization = (options) => {
4944
5094
  user: updatedByUser,
4945
5095
  member: owner
4946
5096
  });
4947
- if (response && typeof response === "object" && "data" in response) updateData = {
5097
+ if (response && typeof response === "object" && "data" in response) updateData = pickWritableOrganizationFields({
4948
5098
  ...updateData,
4949
5099
  ...response.data
4950
- };
5100
+ }, orgOptions, "update");
4951
5101
  }
4952
5102
  const organization = await ctx.context.adapter.update({
4953
5103
  model: "organization",
@@ -5528,13 +5678,13 @@ const revokeManySessions = (options) => createAuthEndpoint("/dash/sessions/revok
5528
5678
  });
5529
5679
  });
5530
5680
  //#endregion
5531
- //#region src/validation/ssrf.ts
5681
+ //#region ../utils/dist/ip.mjs
5532
5682
  /**
5533
5683
  * SSRF (Server-Side Request Forgery) Protection
5534
5684
  *
5535
- * Validates URLs before server-side fetches to block requests to private/reserved
5536
- * networks. Covers IPv4 private ranges, IPv6 private ranges, and IPv4-mapped IPv6
5537
- * bypass vectors.
5685
+ * IP and URL utilities to validate hostnames and URLs before server-side fetches.
5686
+ * Blocks requests to private/reserved networks. Covers IPv4 private ranges,
5687
+ * IPv6 private ranges, and IPv4-mapped IPv6 bypass vectors.
5538
5688
  */
5539
5689
  function isPrivateIPv4(a, b) {
5540
5690
  if (a === 10) return true;
@@ -5543,6 +5693,24 @@ function isPrivateIPv4(a, b) {
5543
5693
  if (a === 127) return true;
5544
5694
  if (a === 169 && b === 254) return true;
5545
5695
  if (a === 0) return true;
5696
+ if (a === 100 && b >= 64 && b <= 127) return true;
5697
+ return false;
5698
+ }
5699
+ /**
5700
+ * Loopback, RFC1918, link-local, CGNAT, ULA, documentation, NAT64 well-known,
5701
+ * IPv4-mapped private, unspecified, and multicast (RFC 4291 section 2.7).
5702
+ */
5703
+ function ipv6GroupsAreNonPublic(groups) {
5704
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
5705
+ if (groups.every((g) => g === 0)) return true;
5706
+ if ((groups[0] & 65472) === 65152) return true;
5707
+ if ((groups[0] & 65024) === 64512) return true;
5708
+ if ((groups[0] & 65280) === 65280) return true;
5709
+ if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5710
+ if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 65535 && groups[5] === 0) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5711
+ if (groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0)) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5712
+ if (groups[0] === 8193 && groups[1] === 3512) return true;
5713
+ if (groups[0] === 256 && groups.slice(1, 4).every((g) => g === 0)) return true;
5546
5714
  return false;
5547
5715
  }
5548
5716
  function parseIPv6(addr) {
@@ -5564,36 +5732,174 @@ function parseIPv6(addr) {
5564
5732
  if (left.length !== 8) return null;
5565
5733
  return left;
5566
5734
  }
5735
+ /** Returns true if the hostname resolves to a private/reserved IP or is a local hostname. */
5567
5736
  function isPrivateHost(hostname) {
5568
5737
  if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) return true;
5569
5738
  const bare = hostname.replace(/^\[|\]$/g, "");
5570
5739
  const v4parts = bare.split(".").map(Number);
5571
5740
  if (v4parts.length === 4 && v4parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) return isPrivateIPv4(v4parts[0], v4parts[1]);
5572
5741
  const groups = parseIPv6(bare);
5573
- if (groups && groups.length === 8) {
5574
- if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
5575
- if (groups.every((g) => g === 0)) return true;
5576
- if ((groups[0] & 65472) === 65152) return true;
5577
- if ((groups[0] & 65024) === 64512) return true;
5578
- if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5579
- if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 65535 && groups[5] === 0) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5580
- if (groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0)) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5581
- if (groups[0] === 8193 && groups[1] === 3512) return true;
5582
- if (groups[0] === 256 && groups.slice(1, 4).every((g) => g === 0)) return true;
5583
- }
5742
+ if (groups && groups.length === 8) return ipv6GroupsAreNonPublic(groups);
5584
5743
  return false;
5585
5744
  }
5586
- function parseAndValidateUrl(url) {
5745
+ //#endregion
5746
+ //#region ../utils/dist/url.mjs
5747
+ /**
5748
+ * URL helpers: normalization, SSRF-safe parsing/validation, and path/query segments.
5749
+ */
5750
+ const HTTP_PROTOCOLS = ["http:", "https:"];
5751
+ function isAllowedProtocol(protocol, allowedProtocols) {
5752
+ return (allowedProtocols?.length ? allowedProtocols : HTTP_PROTOCOLS).includes(protocol);
5753
+ }
5754
+ function toAllowedOriginSet(allowedOrigins) {
5755
+ const set = /* @__PURE__ */ new Set();
5756
+ for (const raw of allowedOrigins) try {
5757
+ const parsed = new URL(raw.trim());
5758
+ if (isAllowedProtocol(parsed.protocol, HTTP_PROTOCOLS)) set.add(parsed.origin);
5759
+ } catch {}
5760
+ return set;
5761
+ }
5762
+ function isAllowedOrigin(origin, allowedOrigins) {
5763
+ if (!allowedOrigins.length) return false;
5764
+ return toAllowedOriginSet(allowedOrigins).has(origin);
5765
+ }
5766
+ async function resolveHostnameAddresses(hostname) {
5767
+ try {
5768
+ const dns = await import("node:dns").catch((e) => {
5769
+ console.warn("Failed to load node:dns for DNS resolution", e);
5770
+ return null;
5771
+ });
5772
+ if (!dns?.promises?.resolve) return null;
5773
+ return await dns.promises.resolve(hostname);
5774
+ } catch {
5775
+ return null;
5776
+ }
5777
+ }
5778
+ function validateUrlSync(rawUrl, options = {}) {
5779
+ const trimmed = rawUrl.trim();
5780
+ let parsed;
5781
+ try {
5782
+ parsed = new URL(trimmed);
5783
+ } catch {
5784
+ return {
5785
+ ok: false,
5786
+ reason: "invalid_url",
5787
+ url: trimmed
5788
+ };
5789
+ }
5790
+ if (!isAllowedProtocol(parsed.protocol, options.allowedProtocols)) return {
5791
+ ok: false,
5792
+ reason: "disallowed_protocol",
5793
+ url: trimmed,
5794
+ protocol: parsed.protocol
5795
+ };
5796
+ if (parsed.username !== "" || parsed.password !== "") return {
5797
+ ok: false,
5798
+ reason: "embedded_credentials",
5799
+ url: trimmed
5800
+ };
5801
+ if (isPrivateHost(parsed.hostname)) return {
5802
+ ok: false,
5803
+ reason: "private_host",
5804
+ url: trimmed
5805
+ };
5806
+ if (options.allowedOrigins?.length) {
5807
+ if (!isAllowedOrigin(parsed.origin, options.allowedOrigins)) return {
5808
+ ok: false,
5809
+ reason: "disallowed_origin",
5810
+ url: trimmed,
5811
+ hostname: parsed.hostname,
5812
+ origin: parsed.origin
5813
+ };
5814
+ }
5815
+ return {
5816
+ ok: true,
5817
+ url: parsed
5818
+ };
5819
+ }
5820
+ /** SSRF checks with DNS resolution when available. */
5821
+ async function validateUrl(rawUrl, options = {}) {
5822
+ const sync = validateUrlSync(rawUrl, options);
5823
+ if (!sync.ok) return sync;
5824
+ if (options.dns === false) return sync;
5825
+ const addresses = await resolveHostnameAddresses(sync.url.hostname);
5826
+ if (addresses) {
5827
+ for (const addr of addresses) if (isPrivateHost(addr)) return {
5828
+ ok: false,
5829
+ reason: "private_dns",
5830
+ url: sync.url.href,
5831
+ hostname: sync.url.hostname,
5832
+ address: addr
5833
+ };
5834
+ }
5835
+ return sync;
5836
+ }
5837
+ function safeResolveUrl(raw, baseURL) {
5838
+ const trim = raw.trim();
5839
+ if (!trim) return null;
5840
+ if (trim.startsWith("//") || trim.includes("\\")) return null;
5841
+ if (/[\u0000-\u001F\u007F]/.test(trim)) return null;
5587
5842
  try {
5588
- const parsed = new URL(url);
5589
- if (!["http:", "https:"].includes(parsed.protocol)) return null;
5590
- if (isPrivateHost(parsed.hostname)) return null;
5591
- return parsed;
5843
+ if (trim.startsWith("/")) return new URL(trim, baseURL);
5844
+ return new URL(trim);
5592
5845
  } catch {
5593
5846
  return null;
5594
5847
  }
5595
5848
  }
5596
5849
  //#endregion
5850
+ //#region src/validation/ssrf.ts
5851
+ /**
5852
+ * SSRF protection for dash plugin outbound fetches.
5853
+ *
5854
+ * Uses @infra/utils at build time; tsdown bundles it into published dist so
5855
+ * consumers do not install the private workspace package.
5856
+ */
5857
+ const REDIRECT_STATUSES = new Set([
5858
+ 301,
5859
+ 302,
5860
+ 303,
5861
+ 307,
5862
+ 308
5863
+ ]);
5864
+ const MAX_REDIRECTS = 10;
5865
+ var SsrfBlockedError = class extends Error {
5866
+ name = "SsrfBlockedError";
5867
+ };
5868
+ function isSsrfBlockedError(error) {
5869
+ return error instanceof SsrfBlockedError;
5870
+ }
5871
+ /** Validates URL hostname literals and resolved DNS addresses. */
5872
+ async function parseAndValidateUrl(url) {
5873
+ const result = await validateUrl(url);
5874
+ return result.ok ? result.url : null;
5875
+ }
5876
+ /**
5877
+ * Fetch with SSRF checks on the initial URL and every redirect target.
5878
+ */
5879
+ async function $outbound(url, options = {}) {
5880
+ const { timeout, signal: callerSignal, ...fetchInit } = options;
5881
+ let currentUrl = url;
5882
+ for (let hop = 0;; hop++) {
5883
+ const validated = await parseAndValidateUrl(currentUrl);
5884
+ if (!validated) throw new SsrfBlockedError("Invalid or blocked URL");
5885
+ const signals = [];
5886
+ if (callerSignal) signals.push(callerSignal);
5887
+ if (timeout !== void 0 && timeout > 0) signals.push(AbortSignal.timeout(timeout));
5888
+ const response = await fetch(validated.href, {
5889
+ ...fetchInit,
5890
+ redirect: "manual",
5891
+ signal: signals.length === 0 ? void 0 : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
5892
+ });
5893
+ if (!REDIRECT_STATUSES.has(response.status)) return response;
5894
+ if (hop >= MAX_REDIRECTS) throw new SsrfBlockedError("Too many redirects");
5895
+ await response.body?.cancel().catch(() => {});
5896
+ const location = response.headers.get("location");
5897
+ const next = location ? safeResolveUrl(location, validated.href) : null;
5898
+ if (!next) throw new SsrfBlockedError("Invalid redirect location");
5899
+ currentUrl = next.href;
5900
+ }
5901
+ }
5902
+ //#endregion
5597
5903
  //#region src/routes/plugin-session.ts
5598
5904
  /**
5599
5905
  * Builds an in-memory session context for calling plugin endpoints
@@ -5706,13 +6012,19 @@ const oidcConfigSchema = z$1.object({
5706
6012
  async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
5707
6013
  let idpMetadataXml = samlConfig.idpMetadata?.metadata;
5708
6014
  if (!idpMetadataXml && samlConfig.idpMetadata?.metadataUrl) {
5709
- const validatedMetadataUrl = parseAndValidateUrl(samlConfig.idpMetadata.metadataUrl);
6015
+ const validatedMetadataUrl = await parseAndValidateUrl(samlConfig.idpMetadata.metadataUrl);
5710
6016
  if (!validatedMetadataUrl) throw ctx.error("BAD_REQUEST", { message: "Invalid or blocked IdP metadata URL" });
5711
6017
  try {
5712
- const metadataResponse = await fetch(validatedMetadataUrl.toString());
6018
+ const metadataResponse = await $outbound(validatedMetadataUrl.href, {
6019
+ method: "GET",
6020
+ headers: { Accept: "application/xml, text/xml" },
6021
+ timeout: 15e3
6022
+ });
5713
6023
  if (!metadataResponse.ok) throw ctx.error("BAD_REQUEST", { message: `Failed to fetch IdP metadata from URL: ${metadataResponse.status} ${metadataResponse.statusText}` });
5714
6024
  idpMetadataXml = await metadataResponse.text();
5715
6025
  } catch (e) {
6026
+ if (isSsrfBlockedError(e)) throw ctx.error("BAD_REQUEST", { message: "Invalid or blocked IdP metadata URL" });
6027
+ if (e instanceof APIError$1) throw e;
5716
6028
  ctx.context.logger.error("[Dash] Failed to fetch IdP metadata from URL:", e);
5717
6029
  throw ctx.error("BAD_REQUEST", { message: "Failed to fetch IdP metadata from URL" });
5718
6030
  }