better_auth 0.3.0 → 0.5.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +10 -7
  5. data/lib/better_auth/adapters/memory.rb +57 -11
  6. data/lib/better_auth/adapters/sql.rb +123 -20
  7. data/lib/better_auth/api.rb +114 -9
  8. data/lib/better_auth/async.rb +70 -0
  9. data/lib/better_auth/configuration.rb +97 -7
  10. data/lib/better_auth/context.rb +165 -12
  11. data/lib/better_auth/cookies.rb +6 -4
  12. data/lib/better_auth/core.rb +2 -0
  13. data/lib/better_auth/crypto/jwe.rb +27 -5
  14. data/lib/better_auth/crypto.rb +32 -0
  15. data/lib/better_auth/database_hooks.rb +8 -8
  16. data/lib/better_auth/deprecate.rb +28 -0
  17. data/lib/better_auth/endpoint.rb +92 -5
  18. data/lib/better_auth/error.rb +8 -1
  19. data/lib/better_auth/host.rb +166 -0
  20. data/lib/better_auth/instrumentation.rb +74 -0
  21. data/lib/better_auth/logger.rb +31 -0
  22. data/lib/better_auth/middleware/origin_check.rb +2 -2
  23. data/lib/better_auth/oauth2.rb +94 -0
  24. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  25. data/lib/better_auth/plugins/admin.rb +344 -16
  26. data/lib/better_auth/plugins/anonymous.rb +37 -3
  27. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  28. data/lib/better_auth/plugins/dub.rb +148 -0
  29. data/lib/better_auth/plugins/email_otp.rb +261 -19
  30. data/lib/better_auth/plugins/expo.rb +17 -1
  31. data/lib/better_auth/plugins/generic_oauth.rb +67 -35
  32. data/lib/better_auth/plugins/jwt.rb +37 -4
  33. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  34. data/lib/better_auth/plugins/magic_link.rb +66 -3
  35. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  36. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  37. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  38. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  39. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  40. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  41. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  42. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  43. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  44. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  45. data/lib/better_auth/plugins/mcp.rb +111 -263
  46. data/lib/better_auth/plugins/multi_session.rb +61 -3
  47. data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
  48. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  49. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  50. data/lib/better_auth/plugins/one_tap.rb +7 -2
  51. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  52. data/lib/better_auth/plugins/open_api.rb +163 -318
  53. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  54. data/lib/better_auth/plugins/organization.rb +186 -56
  55. data/lib/better_auth/plugins/phone_number.rb +141 -6
  56. data/lib/better_auth/plugins/siwe.rb +69 -3
  57. data/lib/better_auth/plugins/two_factor.rb +118 -41
  58. data/lib/better_auth/plugins/username.rb +57 -2
  59. data/lib/better_auth/rate_limiter.rb +38 -0
  60. data/lib/better_auth/request_state.rb +44 -0
  61. data/lib/better_auth/response.rb +42 -0
  62. data/lib/better_auth/router.rb +7 -1
  63. data/lib/better_auth/routes/account.rb +220 -42
  64. data/lib/better_auth/routes/email_verification.rb +98 -14
  65. data/lib/better_auth/routes/password.rb +126 -8
  66. data/lib/better_auth/routes/session.rb +128 -13
  67. data/lib/better_auth/routes/sign_in.rb +26 -2
  68. data/lib/better_auth/routes/sign_out.rb +13 -1
  69. data/lib/better_auth/routes/sign_up.rb +70 -4
  70. data/lib/better_auth/routes/social.rb +132 -7
  71. data/lib/better_auth/routes/user.rb +228 -20
  72. data/lib/better_auth/routes/validation.rb +50 -0
  73. data/lib/better_auth/secret_config.rb +115 -0
  74. data/lib/better_auth/session.rb +13 -2
  75. data/lib/better_auth/url_helpers.rb +206 -0
  76. data/lib/better_auth/version.rb +1 -1
  77. data/lib/better_auth.rb +12 -0
  78. metadata +23 -1
@@ -156,7 +156,7 @@ module BetterAuth
156
156
  end
157
157
 
158
158
  def organization_create_endpoint(config)
159
- Endpoint.new(path: "/organization/create", method: "POST") do |ctx|
159
+ Endpoint.new(path: "/organization/create", method: "POST", metadata: organization_openapi("createOrganization", "Create an organization", response: organization_ref_schema("Organization"))) do |ctx|
160
160
  body = normalize_hash(ctx.body)
161
161
  session = Routes.current_session(ctx, allow_nil: true)
162
162
  user = session ? session[:user] : ctx.context.internal_adapter.find_user_by_id(body[:user_id])
@@ -204,7 +204,8 @@ module BetterAuth
204
204
  end
205
205
 
206
206
  def organization_check_slug_endpoint
207
- Endpoint.new(path: "/organization/check-slug", method: "POST") do |ctx|
207
+ Endpoint.new(path: "/organization/check-slug", method: "POST", metadata: organization_openapi("checkOrganizationSlug", "Check if an organization slug is available", response: OpenAPI.status_response_schema)) do |ctx|
208
+ Routes.request_only_session(ctx)
208
209
  slug = normalize_hash(ctx.body)[:slug].to_s
209
210
  if slug.empty? || organization_by_slug(ctx, slug)
210
211
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_SLUG_ALREADY_TAKEN"))
@@ -214,7 +215,7 @@ module BetterAuth
214
215
  end
215
216
 
216
217
  def organization_list_endpoint
217
- Endpoint.new(path: "/organization/list", method: "GET") do |ctx|
218
+ Endpoint.new(path: "/organization/list", method: "GET", metadata: organization_openapi("listOrganizations", "List organizations", response: organization_array_schema("Organization"))) do |ctx|
218
219
  session = Routes.current_session(ctx)
219
220
  members = ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: session[:user]["id"]}])
220
221
  organizations = members.filter_map { |member| organization_by_id(ctx, member["organizationId"]) }
@@ -223,7 +224,7 @@ module BetterAuth
223
224
  end
224
225
 
225
226
  def organization_update_endpoint(config)
226
- Endpoint.new(path: "/organization/update", method: "POST") do |ctx|
227
+ Endpoint.new(path: "/organization/update", method: "POST", metadata: organization_openapi("updateOrganization", "Update an organization", response: organization_ref_schema("Organization"))) do |ctx|
227
228
  session = Routes.current_session(ctx)
228
229
  body = normalize_hash(ctx.body)
229
230
  id = body[:organization_id] || body[:organizationId]
@@ -248,7 +249,7 @@ module BetterAuth
248
249
  end
249
250
 
250
251
  def organization_delete_endpoint(config)
251
- Endpoint.new(path: "/organization/delete", method: "POST") do |ctx|
252
+ Endpoint.new(path: "/organization/delete", method: "POST", metadata: organization_openapi("deleteOrganization", "Delete an organization", response: OpenAPI.status_response_schema)) do |ctx|
252
253
  session = Routes.current_session(ctx)
253
254
  body = normalize_hash(ctx.body)
254
255
  organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug])
@@ -269,9 +270,15 @@ module BetterAuth
269
270
  end
270
271
 
271
272
  def organization_set_active_endpoint
272
- Endpoint.new(path: "/organization/set-active", method: "POST") do |ctx|
273
+ Endpoint.new(path: "/organization/set-active", method: "POST", metadata: organization_openapi("setActiveOrganization", "Set the active organization", response: organization_nullable_schema("Organization"))) do |ctx|
273
274
  session = Routes.current_session(ctx, sensitive: true)
274
275
  body = normalize_hash(ctx.body)
276
+ if body.key?(:organization_id) && body[:organization_id].nil?
277
+ updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: nil, activeTeamId: nil})
278
+ Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => nil, "activeTeamId" => nil), user: session[:user]})
279
+ next ctx.json(nil)
280
+ end
281
+
275
282
  organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug])
276
283
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
277
284
  require_member!(ctx, session[:user]["id"], organization["id"])
@@ -282,14 +289,19 @@ module BetterAuth
282
289
  end
283
290
 
284
291
  def organization_get_full_endpoint(config)
285
- Endpoint.new(path: "/organization/get-full-organization", method: "GET") do |ctx|
292
+ Endpoint.new(path: "/organization/get-full-organization", method: "GET", metadata: organization_openapi("getOrganization", "Get the full organization", response: organization_nullable_schema("Organization"))) do |ctx|
286
293
  session = Routes.current_session(ctx)
287
294
  query = normalize_hash(ctx.query)
295
+ explicit_lookup = query.key?(:organization_slug) || query.key?(:organization_id)
288
296
  organization = organization_by_slug(ctx, query[:organization_slug]) || organization_by_id(ctx, query[:organization_id] || session[:session]["activeOrganizationId"])
289
- next ctx.json(nil) unless organization
297
+ unless organization
298
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) if explicit_lookup
299
+
300
+ next ctx.json(nil)
301
+ end
290
302
 
291
303
  require_member!(ctx, session[:user]["id"], organization["id"])
292
- members = list_members_for(ctx, organization["id"])
304
+ members = list_members_for(ctx, organization["id"], {limit: query[:members_limit] || config[:membership_limit]})
293
305
  invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}])
294
306
  result = organization_wire(ctx, organization).merge(
295
307
  members: members.fetch(:members),
@@ -304,10 +316,10 @@ module BetterAuth
304
316
  end
305
317
 
306
318
  def organization_invite_endpoint(config)
307
- Endpoint.new(path: "/organization/invite-member", method: "POST") do |ctx|
319
+ Endpoint.new(path: "/organization/invite-member", method: "POST", metadata: organization_openapi("createOrganizationInvitation", "Create an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
308
320
  session = Routes.current_session(ctx)
309
321
  body = normalize_hash(ctx.body)
310
- organization = organization_by_id(ctx, body[:organization_id])
322
+ organization = organization_by_id(ctx, body[:organization_id] || session[:session]["activeOrganizationId"]) || organization_by_slug(ctx, body[:organization_slug])
311
323
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
312
324
  require_org_permission!(ctx, config, session, organization["id"], {invitation: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION"))
313
325
  email = body[:email].to_s.downcase
@@ -334,27 +346,32 @@ module BetterAuth
334
346
  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_LIMIT_REACHED"))
335
347
  end
336
348
  team_ids = organization_team_ids(body[:team_id] || body[:team_ids])
349
+ ensure_team_member_capacity!(ctx, config, team_ids)
350
+ invitation_data = {
351
+ organizationId: organization["id"],
352
+ email: email,
353
+ role: role,
354
+ status: "pending",
355
+ expiresAt: Time.now + config[:invitation_expires_in].to_i,
356
+ inviterId: session[:user]["id"],
357
+ teamId: team_ids.any? ? team_ids.join(",") : nil,
358
+ createdAt: Time.now
359
+ }.merge(additional_input(body, :organization_id, :organization_slug, :email, :role, :team_id, :team_ids))
360
+ merge_hook_data!(invitation_data, run_org_hook(config, :before_create_invitation, {invitation: invitation_data, inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx))
337
361
  invitation = ctx.context.adapter.create(
338
362
  model: "invitation",
339
- data: {
340
- organizationId: organization["id"],
341
- email: email,
342
- role: role,
343
- status: "pending",
344
- expiresAt: Time.now + config[:invitation_expires_in].to_i,
345
- inviterId: session[:user]["id"],
346
- teamId: team_ids.any? ? team_ids.join(",") : nil,
347
- createdAt: Time.now
348
- }
363
+ data: invitation_data,
364
+ force_allow_id: true
349
365
  )
350
366
  sender = config[:send_invitation_email]
351
367
  sender.call({id: invitation["id"], role: role, email: email, organization: organization_wire(ctx, organization), invitation: invitation_wire(ctx, invitation), inviter: require_member!(ctx, session[:user]["id"], organization["id"])}, ctx.request) if sender.respond_to?(:call)
368
+ run_org_hook(config, :after_create_invitation, {invitation: invitation_wire(ctx, invitation), inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx)
352
369
  ctx.json(invitation_wire(ctx, invitation))
353
370
  end
354
371
  end
355
372
 
356
373
  def organization_accept_invitation_endpoint(config)
357
- Endpoint.new(path: "/organization/accept-invitation", method: "POST") do |ctx|
374
+ Endpoint.new(path: "/organization/accept-invitation", method: "POST", metadata: organization_openapi("acceptOrganizationInvitation", "Accept an organization invitation", response: organization_accept_invitation_schema)) do |ctx|
358
375
  session = Routes.current_session(ctx)
359
376
  body = normalize_hash(ctx.body)
360
377
  invitation = invitation_by_id(ctx, body[:invitation_id] || body[:id])
@@ -365,6 +382,7 @@ module BetterAuth
365
382
  if config[:require_email_verification_on_invitation] && !session[:user]["emailVerified"]
366
383
  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION"))
367
384
  end
385
+ ensure_team_member_capacity!(ctx, config, organization_team_ids(invitation["teamId"]))
368
386
  member = ctx.context.adapter.create(model: "member", data: {organizationId: invitation["organizationId"], userId: session[:user]["id"], role: invitation["role"], createdAt: Time.now})
369
387
  organization_team_ids(invitation["teamId"]).each do |team_id|
370
388
  ctx.context.adapter.create(model: "teamMember", data: {teamId: team_id, userId: session[:user]["id"], createdAt: Time.now})
@@ -377,7 +395,7 @@ module BetterAuth
377
395
  end
378
396
 
379
397
  def organization_reject_invitation_endpoint(_config)
380
- Endpoint.new(path: "/organization/reject-invitation", method: "POST") do |ctx|
398
+ Endpoint.new(path: "/organization/reject-invitation", method: "POST", metadata: organization_openapi("rejectOrganizationInvitation", "Reject an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
381
399
  session = Routes.current_session(ctx)
382
400
  invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id])
383
401
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
@@ -388,7 +406,7 @@ module BetterAuth
388
406
  end
389
407
 
390
408
  def organization_cancel_invitation_endpoint(config)
391
- Endpoint.new(path: "/organization/cancel-invitation", method: "POST") do |ctx|
409
+ Endpoint.new(path: "/organization/cancel-invitation", method: "POST", metadata: organization_openapi("cancelOrganizationInvitation", "Cancel an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
392
410
  session = Routes.current_session(ctx)
393
411
  invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id])
394
412
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
@@ -399,7 +417,7 @@ module BetterAuth
399
417
  end
400
418
 
401
419
  def organization_get_invitation_endpoint
402
- Endpoint.new(path: "/organization/get-invitation", method: "GET") do |ctx|
420
+ Endpoint.new(path: "/organization/get-invitation", method: "GET", metadata: organization_openapi("getOrganizationInvitation", "Get an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
403
421
  invitation = invitation_by_id(ctx, normalize_hash(ctx.query)[:id] || normalize_hash(ctx.query)[:invitation_id])
404
422
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
405
423
  ctx.json(invitation_wire(ctx, invitation))
@@ -407,7 +425,7 @@ module BetterAuth
407
425
  end
408
426
 
409
427
  def organization_list_invitations_endpoint(config)
410
- Endpoint.new(path: "/organization/list-invitations", method: "GET") do |ctx|
428
+ Endpoint.new(path: "/organization/list-invitations", method: "GET", metadata: organization_openapi("listOrganizationInvitations", "List organization invitations", response: organization_array_schema("Invitation"))) do |ctx|
411
429
  session = Routes.current_session(ctx)
412
430
  organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
413
431
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
@@ -418,7 +436,7 @@ module BetterAuth
418
436
  end
419
437
 
420
438
  def organization_list_user_invitations_endpoint
421
- Endpoint.new(path: "/organization/list-user-invitations", method: "GET") do |ctx|
439
+ Endpoint.new(path: "/organization/list-user-invitations", method: "GET", metadata: organization_openapi("listUserInvitations", "List user invitations", response: organization_array_schema("Invitation"))) do |ctx|
422
440
  session = Routes.current_session(ctx)
423
441
  invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "email", value: session[:user]["email"].to_s.downcase}, {field: "status", value: "pending"}])
424
442
  ctx.json(invitations.map { |entry| invitation_wire(ctx, entry) })
@@ -426,7 +444,7 @@ module BetterAuth
426
444
  end
427
445
 
428
446
  def organization_add_member_endpoint(config)
429
- Endpoint.new(path: "/organization/add-member", method: "POST") do |ctx|
447
+ Endpoint.new(path: "/organization/add-member", method: "POST", metadata: organization_openapi("addOrganizationMember", "Add an organization member", response: organization_ref_schema("Member"))) do |ctx|
430
448
  session = Routes.current_session(ctx)
431
449
  body = normalize_hash(ctx.body)
432
450
  organization_id = body[:organization_id]
@@ -447,7 +465,7 @@ module BetterAuth
447
465
  end
448
466
 
449
467
  def organization_remove_member_endpoint(config)
450
- Endpoint.new(path: "/organization/remove-member", method: "POST") do |ctx|
468
+ Endpoint.new(path: "/organization/remove-member", method: "POST", metadata: organization_openapi("removeOrganizationMember", "Remove an organization member", response: OpenAPI.status_response_schema)) do |ctx|
451
469
  session = Routes.current_session(ctx)
452
470
  body = normalize_hash(ctx.body)
453
471
  member = member_by_id(ctx, body[:member_id]) || require_member(ctx, body[:user_id], body[:organization_id])
@@ -464,7 +482,7 @@ module BetterAuth
464
482
  end
465
483
 
466
484
  def organization_update_member_role_endpoint(config)
467
- Endpoint.new(path: "/organization/update-member-role", method: "POST") do |ctx|
485
+ Endpoint.new(path: "/organization/update-member-role", method: "POST", metadata: organization_openapi("updateOrganizationMemberRole", "Update an organization member role", response: organization_ref_schema("Member"))) do |ctx|
468
486
  session = Routes.current_session(ctx)
469
487
  body = normalize_hash(ctx.body)
470
488
  member = member_by_id(ctx, body[:member_id]) || require_member(ctx, body[:user_id], body[:organization_id])
@@ -476,7 +494,7 @@ module BetterAuth
476
494
  end
477
495
 
478
496
  def organization_get_active_member_endpoint(_config)
479
- Endpoint.new(path: "/organization/get-active-member", method: "GET") do |ctx|
497
+ Endpoint.new(path: "/organization/get-active-member", method: "GET", metadata: organization_openapi("getActiveOrganizationMember", "Get the active organization member", response: organization_ref_schema("Member"))) do |ctx|
480
498
  session = Routes.current_session(ctx)
481
499
  organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
482
500
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
@@ -486,17 +504,19 @@ module BetterAuth
486
504
  end
487
505
 
488
506
  def organization_get_active_member_role_endpoint(_config)
489
- Endpoint.new(path: "/organization/get-active-member-role", method: "GET") do |ctx|
507
+ Endpoint.new(path: "/organization/get-active-member-role", method: "GET", metadata: organization_openapi("getActiveOrganizationMemberRole", "Get the active organization member role", response: organization_active_member_role_schema)) do |ctx|
490
508
  session = Routes.current_session(ctx)
491
- organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
509
+ query = normalize_hash(ctx.query)
510
+ organization_id = query[:organization_id] || session[:session]["activeOrganizationId"]
492
511
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
493
- member = require_member!(ctx, session[:user]["id"], organization_id)
494
- ctx.json({role: member["role"]})
512
+ require_member!(ctx, session[:user]["id"], organization_id)
513
+ member = require_member!(ctx, query[:user_id] || session[:user]["id"], organization_id)
514
+ ctx.json({role: member["role"], member: member_wire(ctx, member)})
495
515
  end
496
516
  end
497
517
 
498
518
  def organization_leave_endpoint(config)
499
- Endpoint.new(path: "/organization/leave", method: "POST") do |ctx|
519
+ Endpoint.new(path: "/organization/leave", method: "POST", metadata: organization_openapi("leaveOrganization", "Leave an organization", response: OpenAPI.status_response_schema)) do |ctx|
500
520
  session = Routes.current_session(ctx)
501
521
  organization_id = normalize_hash(ctx.body)[:organization_id]
502
522
  member = require_member!(ctx, session[:user]["id"], organization_id)
@@ -508,10 +528,10 @@ module BetterAuth
508
528
  end
509
529
 
510
530
  def organization_list_members_endpoint(_config)
511
- Endpoint.new(path: "/organization/list-members", method: "GET") do |ctx|
531
+ Endpoint.new(path: "/organization/list-members", method: "GET", metadata: organization_openapi("listOrganizationMembers", "List organization members", response: organization_members_response_schema)) do |ctx|
512
532
  session = Routes.current_session(ctx)
513
533
  query = normalize_hash(ctx.query)
514
- organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id")
534
+ organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id") || session[:session]["activeOrganizationId"]
515
535
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
516
536
  require_member!(ctx, session[:user]["id"], organization_id)
517
537
  ctx.json(list_members_for(ctx, organization_id, query))
@@ -519,7 +539,7 @@ module BetterAuth
519
539
  end
520
540
 
521
541
  def organization_has_permission_endpoint(config)
522
- Endpoint.new(path: "/organization/has-permission", method: "POST") do |ctx|
542
+ Endpoint.new(path: "/organization/has-permission", method: "POST", metadata: organization_openapi("hasOrganizationPermission", "Check if the member has organization permission", response: organization_permission_response_schema)) do |ctx|
523
543
  session = Routes.current_session(ctx)
524
544
  body = normalize_hash(ctx.body)
525
545
  organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
@@ -531,7 +551,7 @@ module BetterAuth
531
551
  end
532
552
 
533
553
  def organization_create_team_endpoint(config)
534
- Endpoint.new(path: "/organization/create-team", method: "POST") do |ctx|
554
+ Endpoint.new(path: "/organization/create-team", method: "POST", metadata: organization_openapi("createOrganizationTeam", "Create an organization team", response: organization_ref_schema("Team"))) do |ctx|
535
555
  session = Routes.current_session(ctx)
536
556
  body = normalize_hash(ctx.body)
537
557
  organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
@@ -543,7 +563,7 @@ module BetterAuth
543
563
  end
544
564
  team_data = {organizationId: organization_id, name: body[:name].to_s, createdAt: Time.now}.merge(additional_input(body, :organization_id, :name))
545
565
  merge_hook_data!(team_data, run_org_hook(config, :before_create_team, {team: team_data, user: session[:user], organization: organization_wire(ctx, organization)}, ctx))
546
- team = ctx.context.adapter.create(model: "team", data: team_data)
566
+ team = ctx.context.adapter.create(model: "team", data: team_data, force_allow_id: true)
547
567
  ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now})
548
568
  run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
549
569
  ctx.json(team_wire(ctx, team))
@@ -551,7 +571,7 @@ module BetterAuth
551
571
  end
552
572
 
553
573
  def organization_list_teams_endpoint(_config)
554
- Endpoint.new(path: "/organization/list-teams", method: "GET") do |ctx|
574
+ Endpoint.new(path: "/organization/list-teams", method: "GET", metadata: organization_openapi("listOrganizationTeams", "List organization teams", response: organization_array_schema("Team"))) do |ctx|
555
575
  session = Routes.current_session(ctx)
556
576
  organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
557
577
  require_member!(ctx, session[:user]["id"], organization_id)
@@ -561,7 +581,7 @@ module BetterAuth
561
581
  end
562
582
 
563
583
  def organization_update_team_endpoint(config)
564
- Endpoint.new(path: "/organization/update-team", method: "POST") do |ctx|
584
+ Endpoint.new(path: "/organization/update-team", method: "POST", metadata: organization_openapi("updateOrganizationTeam", "Update an organization team", response: organization_ref_schema("Team"))) do |ctx|
565
585
  session = Routes.current_session(ctx)
566
586
  body = normalize_hash(ctx.body)
567
587
  team = team_by_id(ctx, body[:team_id])
@@ -573,7 +593,7 @@ module BetterAuth
573
593
  end
574
594
 
575
595
  def organization_remove_team_endpoint(config)
576
- Endpoint.new(path: "/organization/remove-team", method: "POST") do |ctx|
596
+ Endpoint.new(path: "/organization/remove-team", method: "POST", metadata: organization_openapi("removeOrganizationTeam", "Remove an organization team", response: OpenAPI.status_response_schema)) do |ctx|
577
597
  session = Routes.current_session(ctx)
578
598
  team = team_by_id(ctx, normalize_hash(ctx.body)[:team_id])
579
599
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
@@ -589,7 +609,7 @@ module BetterAuth
589
609
  end
590
610
 
591
611
  def organization_set_active_team_endpoint(_config)
592
- Endpoint.new(path: "/organization/set-active-team", method: "POST") do |ctx|
612
+ Endpoint.new(path: "/organization/set-active-team", method: "POST", metadata: organization_openapi("setActiveOrganizationTeam", "Set the active organization team", response: organization_nullable_schema("Team"))) do |ctx|
593
613
  session = Routes.current_session(ctx)
594
614
  body = normalize_hash(ctx.body)
595
615
  if body.key?(:team_id) && body[:team_id].nil?
@@ -607,7 +627,7 @@ module BetterAuth
607
627
  end
608
628
 
609
629
  def organization_list_user_teams_endpoint
610
- Endpoint.new(path: "/organization/list-user-teams", method: "GET") do |ctx|
630
+ Endpoint.new(path: "/organization/list-user-teams", method: "GET", metadata: organization_openapi("listUserTeams", "List user teams", response: organization_array_schema("Team"))) do |ctx|
611
631
  session = Routes.current_session(ctx)
612
632
  memberships = ctx.context.adapter.find_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}])
613
633
  ctx.json(memberships.filter_map { |entry| team_by_id(ctx, entry["teamId"]) }.map { |team| team_wire(ctx, team) })
@@ -615,7 +635,7 @@ module BetterAuth
615
635
  end
616
636
 
617
637
  def organization_list_team_members_endpoint(_config)
618
- Endpoint.new(path: "/organization/list-team-members", method: "GET") do |ctx|
638
+ Endpoint.new(path: "/organization/list-team-members", method: "GET", metadata: organization_openapi("listTeamMembers", "List team members", response: organization_array_schema("TeamMember"))) do |ctx|
619
639
  session = Routes.current_session(ctx)
620
640
  team_id = normalize_hash(ctx.query)[:team_id] || session[:session]["activeTeamId"]
621
641
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM")) unless team_id
@@ -627,7 +647,7 @@ module BetterAuth
627
647
  end
628
648
 
629
649
  def organization_add_team_member_endpoint(config)
630
- Endpoint.new(path: "/organization/add-team-member", method: "POST") do |ctx|
650
+ Endpoint.new(path: "/organization/add-team-member", method: "POST", metadata: organization_openapi("addTeamMember", "Add a team member", response: organization_ref_schema("TeamMember"))) do |ctx|
631
651
  session = Routes.current_session(ctx)
632
652
  body = normalize_hash(ctx.body)
633
653
  team = team_by_id(ctx, body[:team_id])
@@ -648,7 +668,7 @@ module BetterAuth
648
668
  end
649
669
 
650
670
  def organization_remove_team_member_endpoint(config)
651
- Endpoint.new(path: "/organization/remove-team-member", method: "POST") do |ctx|
671
+ Endpoint.new(path: "/organization/remove-team-member", method: "POST", metadata: organization_openapi("removeTeamMember", "Remove a team member", response: OpenAPI.status_response_schema)) do |ctx|
652
672
  session = Routes.current_session(ctx)
653
673
  body = normalize_hash(ctx.body)
654
674
  team = team_by_id(ctx, body[:team_id])
@@ -660,7 +680,7 @@ module BetterAuth
660
680
  end
661
681
 
662
682
  def organization_create_role_endpoint(config)
663
- Endpoint.new(path: "/organization/create-role", method: "POST") do |ctx|
683
+ Endpoint.new(path: "/organization/create-role", method: "POST", metadata: organization_openapi("createOrganizationRole", "Create an organization role", response: organization_role_action_schema)) do |ctx|
664
684
  session = Routes.current_session(ctx)
665
685
  body = normalize_hash(ctx.body)
666
686
  organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
@@ -679,7 +699,7 @@ module BetterAuth
679
699
  end
680
700
 
681
701
  def organization_list_roles_endpoint(config)
682
- Endpoint.new(path: "/organization/list-roles", method: "GET") do |ctx|
702
+ Endpoint.new(path: "/organization/list-roles", method: "GET", metadata: organization_openapi("listOrganizationRoles", "List organization roles", response: {type: "array", items: organization_role_schema})) do |ctx|
683
703
  session = Routes.current_session(ctx)
684
704
  organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
685
705
  require_org_permission!(ctx, config, session, organization_id, {ac: ["read"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE"))
@@ -690,7 +710,7 @@ module BetterAuth
690
710
  end
691
711
 
692
712
  def organization_get_role_endpoint(config)
693
- Endpoint.new(path: "/organization/get-role", method: "GET") do |ctx|
713
+ Endpoint.new(path: "/organization/get-role", method: "GET", metadata: organization_openapi("getOrganizationRole", "Get an organization role", response: organization_role_schema)) do |ctx|
694
714
  session = Routes.current_session(ctx)
695
715
  query = normalize_hash(ctx.query)
696
716
  organization_id = query[:organization_id] || session[:session]["activeOrganizationId"]
@@ -702,7 +722,7 @@ module BetterAuth
702
722
  end
703
723
 
704
724
  def organization_update_role_endpoint(config)
705
- Endpoint.new(path: "/organization/update-role", method: "POST") do |ctx|
725
+ Endpoint.new(path: "/organization/update-role", method: "POST", metadata: organization_openapi("updateOrganizationRole", "Update an organization role", response: organization_role_action_schema)) do |ctx|
706
726
  session = Routes.current_session(ctx)
707
727
  body = normalize_hash(ctx.body)
708
728
  organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
@@ -727,7 +747,7 @@ module BetterAuth
727
747
  end
728
748
 
729
749
  def organization_delete_role_endpoint(config)
730
- Endpoint.new(path: "/organization/delete-role", method: "POST") do |ctx|
750
+ Endpoint.new(path: "/organization/delete-role", method: "POST", metadata: organization_openapi("deleteOrganizationRole", "Delete an organization role", response: OpenAPI.success_response_schema)) do |ctx|
731
751
  session = Routes.current_session(ctx)
732
752
  body = normalize_hash(ctx.body)
733
753
  organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
@@ -747,6 +767,104 @@ module BetterAuth
747
767
  end
748
768
  end
749
769
 
770
+ def organization_openapi(operation_id, description, response:, response_description: "Success", request: nil, required: [], parameters: nil)
771
+ openapi = {
772
+ operationId: operation_id,
773
+ description: description,
774
+ responses: {
775
+ "200" => OpenAPI.json_response(response_description, response)
776
+ }
777
+ }
778
+ openapi[:requestBody] = OpenAPI.json_request_body(OpenAPI.object_schema(request, required: required)) if request
779
+ openapi[:parameters] = parameters if parameters
780
+
781
+ {openapi: openapi}
782
+ end
783
+
784
+ def organization_ref_schema(name)
785
+ {
786
+ type: "object",
787
+ "$ref": "#/components/schemas/#{name}"
788
+ }
789
+ end
790
+
791
+ def organization_nullable_schema(name)
792
+ {
793
+ type: ["object", "null"],
794
+ "$ref": "#/components/schemas/#{name}"
795
+ }
796
+ end
797
+
798
+ def organization_array_schema(name)
799
+ {
800
+ type: "array",
801
+ items: organization_ref_schema(name)
802
+ }
803
+ end
804
+
805
+ def organization_accept_invitation_schema
806
+ OpenAPI.object_schema(
807
+ {
808
+ invitation: organization_ref_schema("Invitation"),
809
+ member: organization_ref_schema("Member")
810
+ },
811
+ required: ["invitation", "member"]
812
+ )
813
+ end
814
+
815
+ def organization_active_member_role_schema
816
+ OpenAPI.object_schema(
817
+ {
818
+ role: {type: "string"},
819
+ member: organization_ref_schema("Member")
820
+ },
821
+ required: ["role", "member"]
822
+ )
823
+ end
824
+
825
+ def organization_members_response_schema
826
+ OpenAPI.object_schema(
827
+ {
828
+ members: organization_array_schema("Member"),
829
+ total: {type: "number"}
830
+ },
831
+ required: ["members", "total"]
832
+ )
833
+ end
834
+
835
+ def organization_permission_response_schema
836
+ OpenAPI.object_schema(
837
+ {
838
+ error: {type: ["string", "null"]},
839
+ success: {type: "boolean"}
840
+ },
841
+ required: ["success"]
842
+ )
843
+ end
844
+
845
+ def organization_role_schema
846
+ OpenAPI.object_schema(
847
+ {
848
+ id: {type: "string"},
849
+ organizationId: {type: "string"},
850
+ role: {type: "string"},
851
+ permission: {type: "object"}
852
+ },
853
+ required: ["role", "permission"]
854
+ )
855
+ end
856
+
857
+ def organization_role_action_schema
858
+ OpenAPI.object_schema(
859
+ {
860
+ success: {type: "boolean"},
861
+ roleData: organization_role_schema,
862
+ statements: {type: "object"}
863
+ },
864
+ required: ["success", "roleData"]
865
+ )
866
+ end
867
+
750
868
  def parse_roles(roles)
751
869
  Array(roles).join(",")
752
870
  end
@@ -864,7 +982,7 @@ module BetterAuth
864
982
  where: where,
865
983
  limit: query[:limit],
866
984
  offset: query[:offset],
867
- sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_order] || "asc"} : nil
985
+ sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_direction] || query[:sort_order] || "asc"} : nil
868
986
  )
869
987
  {
870
988
  members: members.map { |entry| member_wire(ctx, entry) },
@@ -872,6 +990,18 @@ module BetterAuth
872
990
  }
873
991
  end
874
992
 
993
+ def ensure_team_member_capacity!(ctx, config, team_ids)
994
+ max_members = config.dig(:teams, :maximum_members_per_team)
995
+ return unless max_members && team_ids.any?
996
+
997
+ team_ids.each do |team_id|
998
+ count = ctx.context.adapter.count(model: "teamMember", where: [{field: "teamId", value: team_id}])
999
+ if count >= max_members.to_i
1000
+ raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_MEMBER_LIMIT_REACHED"))
1001
+ end
1002
+ end
1003
+ end
1004
+
875
1005
  def member_wire(ctx, member)
876
1006
  data = Schema.parse_output(ctx.context.options, "member", member)
877
1007
  user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
@@ -915,7 +1045,7 @@ module BetterAuth
915
1045
  team = if custom.respond_to?(:call)
916
1046
  custom.call(organization_wire(ctx, organization), ctx)
917
1047
  else
918
- ctx.context.adapter.create(model: "team", data: team_data)
1048
+ ctx.context.adapter.create(model: "team", data: team_data, force_allow_id: true)
919
1049
  end
920
1050
  ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now})
921
1051
  run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)