better_auth 0.1.1 → 0.3.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +441 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +211 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +142 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +694 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +995 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,489 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def open_api(options = {})
8
+ config = {path: "/reference", theme: "default"}.merge(normalize_hash(options))
9
+
10
+ Plugin.new(
11
+ id: "open-api",
12
+ endpoints: {
13
+ generate_open_api_schema: Endpoint.new(path: "/open-api/generate-schema", method: "GET") do |ctx|
14
+ ctx.json(open_api_schema(ctx.context))
15
+ end,
16
+ open_api_reference: Endpoint.new(path: config[:path], method: "GET", metadata: {hide: true}) do |ctx|
17
+ raise APIError.new("NOT_FOUND") if config[:disable_default_reference]
18
+
19
+ [200, {"content-type" => "text/html"}, [open_api_html(open_api_schema(ctx.context), config)]]
20
+ end
21
+ },
22
+ options: config
23
+ )
24
+ end
25
+
26
+ def open_api_schema(context)
27
+ {
28
+ openapi: "3.1.1",
29
+ info: {
30
+ title: "Better Auth",
31
+ description: "API Reference for your Better Auth Instance",
32
+ version: "1.1.0"
33
+ },
34
+ components: {
35
+ schemas: open_api_components(context.options),
36
+ securitySchemes: open_api_security_schemes
37
+ },
38
+ security: [
39
+ {
40
+ apiKeyCookie: [],
41
+ bearerAuth: []
42
+ }
43
+ ],
44
+ servers: [
45
+ {
46
+ url: context.base_url
47
+ }
48
+ ],
49
+ tags: [
50
+ {
51
+ name: "Default",
52
+ description: "Default endpoints that are included with Better Auth by default. These endpoints are not part of any plugin."
53
+ }
54
+ ],
55
+ paths: open_api_paths(open_api_endpoints(context.options), context.options)
56
+ }
57
+ end
58
+
59
+ def open_api_endpoints(options)
60
+ Core.base_endpoints.map { |key, endpoint| [key, endpoint, "Default"] } +
61
+ options.plugins.flat_map do |plugin|
62
+ next [] if plugin.id == "open-api"
63
+
64
+ tag = plugin.id.to_s.split("-").map(&:capitalize).join("-")
65
+ plugin.endpoints.map { |key, endpoint| [key, endpoint, tag] }
66
+ end
67
+ end
68
+
69
+ def open_api_security_schemes
70
+ {
71
+ apiKeyCookie: {
72
+ type: "apiKey",
73
+ in: "cookie",
74
+ name: "apiKeyCookie",
75
+ description: "API Key authentication via cookie"
76
+ },
77
+ bearerAuth: {
78
+ type: "http",
79
+ scheme: "bearer",
80
+ description: "Bearer token authentication"
81
+ }
82
+ }
83
+ end
84
+
85
+ def open_api_paths(endpoints, options)
86
+ disabled_paths = Array(options.disabled_paths).map(&:to_s)
87
+ endpoints.each_with_object({}) do |(_key, endpoint, tag), paths|
88
+ next unless endpoint.path
89
+ next if endpoint.metadata[:hide] || endpoint.metadata[:SERVER_ONLY] || endpoint.metadata[:server_only]
90
+ next if disabled_paths.include?(endpoint.path)
91
+
92
+ path = open_api_path(endpoint.path)
93
+ paths[path] ||= {}
94
+ endpoint.methods.reject { |method| method == "*" }.each do |method|
95
+ paths[path][method.downcase.to_sym] = open_api_operation(endpoint, method, tag)
96
+ end
97
+ end
98
+ end
99
+
100
+ def open_api_path(path)
101
+ path.split("/").map { |part| part.start_with?(":") ? "{#{part.delete_prefix(":")}}" : part }.join("/")
102
+ end
103
+
104
+ def open_api_operation(endpoint, method, tag)
105
+ metadata = endpoint.metadata[:openapi] || {}
106
+ operation = {
107
+ tags: Array(metadata[:tags] || [tag]),
108
+ description: metadata[:description] || route_description(endpoint.path, method),
109
+ operationId: metadata.key?(:operationId) ? metadata[:operationId] : route_operation_id(endpoint.path, method),
110
+ security: [
111
+ {
112
+ bearerAuth: []
113
+ }
114
+ ],
115
+ parameters: metadata[:parameters] || [],
116
+ responses: open_api_responses(metadata[:responses] || route_responses(endpoint.path, method))
117
+ }
118
+
119
+ if %w[POST PATCH PUT].include?(method)
120
+ operation[:requestBody] = metadata[:requestBody] || route_request_body(endpoint.path, method) || empty_request_body
121
+ end
122
+
123
+ operation
124
+ end
125
+
126
+ def route_description(path, method)
127
+ route_open_api_metadata(path, method)[:description]
128
+ end
129
+
130
+ def route_operation_id(path, method)
131
+ route_open_api_metadata(path, method)[:operationId]
132
+ end
133
+
134
+ def route_request_body(path, method)
135
+ route_open_api_metadata(path, method)[:requestBody]
136
+ end
137
+
138
+ def route_responses(path, method)
139
+ route_open_api_metadata(path, method)[:responses]
140
+ end
141
+
142
+ def route_open_api_metadata(path, method)
143
+ case [path, method.to_s.upcase]
144
+ when ["/change-email", "POST"]
145
+ {
146
+ operationId: "changeEmail",
147
+ requestBody: {
148
+ required: true,
149
+ content: {
150
+ "application/json" => {
151
+ schema: object_schema(
152
+ {
153
+ callbackURL: {type: ["string", "null"], description: "The URL to redirect to after email verification"},
154
+ newEmail: {type: "string", description: "The new email address to set must be a valid email address"}
155
+ },
156
+ required: ["newEmail"]
157
+ )
158
+ }
159
+ }
160
+ },
161
+ responses: {
162
+ "200" => {
163
+ description: "Email change request processed successfully",
164
+ content: {
165
+ "application/json" => {
166
+ schema: object_schema(
167
+ {
168
+ message: {
169
+ type: "string",
170
+ nullable: true,
171
+ enum: ["Email updated", "Verification email sent"],
172
+ description: "Status message of the email change process"
173
+ },
174
+ status: {type: "boolean", description: "Indicates if the request was successful"},
175
+ user: {type: "object", "$ref": "#/components/schemas/User"}
176
+ },
177
+ required: ["status"]
178
+ )
179
+ }
180
+ }
181
+ },
182
+ "422" => error_response("Unprocessable Entity. Email already exists")
183
+ }
184
+ }
185
+ when ["/change-password", "POST"]
186
+ {
187
+ description: "Change the password of the user",
188
+ operationId: "changePassword",
189
+ requestBody: {
190
+ required: true,
191
+ content: {
192
+ "application/json" => {
193
+ schema: object_schema(
194
+ {
195
+ newPassword: {type: "string", description: "The new password to set"},
196
+ currentPassword: {type: "string", description: "The current password is required"},
197
+ revokeOtherSessions: {type: ["boolean", "null"], description: "Must be a boolean value"}
198
+ },
199
+ required: ["newPassword", "currentPassword"]
200
+ )
201
+ }
202
+ }
203
+ },
204
+ responses: {
205
+ "200" => {
206
+ description: "Password successfully changed",
207
+ content: {
208
+ "application/json" => {
209
+ schema: object_schema(
210
+ {
211
+ token: {type: "string", nullable: true, description: "New session token if other sessions were revoked"},
212
+ user: open_api_user_response_schema
213
+ },
214
+ required: ["user"]
215
+ )
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ when ["/sign-in/email", "POST"]
222
+ {
223
+ description: "Sign in with email and password",
224
+ operationId: "signInEmail",
225
+ requestBody: {
226
+ required: true,
227
+ content: {
228
+ "application/json" => {
229
+ schema: object_schema(
230
+ {
231
+ email: {type: "string", description: "Email of the user"},
232
+ password: {type: "string", description: "Password of the user"},
233
+ callbackURL: {type: ["string", "null"], description: "Callback URL to use as a redirect for email verification"},
234
+ rememberMe: {type: ["boolean", "null"], default: true, description: "If this is false, the session will not be remembered. Default is `true`."}
235
+ },
236
+ required: ["email", "password"]
237
+ )
238
+ }
239
+ }
240
+ },
241
+ responses: {
242
+ "200" => {
243
+ description: "Success - Returns either session details or redirect URL",
244
+ content: {
245
+ "application/json" => {
246
+ schema: session_response_schema(description: "Session response when idToken is provided", nullable_url: true)
247
+ }
248
+ }
249
+ }
250
+ }
251
+ }
252
+ when ["/sign-in/social", "POST"]
253
+ {
254
+ description: "Sign in with a social provider",
255
+ operationId: "socialSignIn",
256
+ requestBody: {
257
+ required: true,
258
+ content: {
259
+ "application/json" => {
260
+ schema: object_schema(
261
+ {
262
+ provider: {type: "string"},
263
+ callbackURL: {type: ["string", "null"], description: "Callback URL to redirect to after the user has signed in"},
264
+ errorCallbackURL: {type: ["string", "null"], description: "Callback URL to redirect to if an error happens"},
265
+ newUserCallbackURL: {type: ["string", "null"]},
266
+ disableRedirect: {type: ["boolean", "null"], description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself"},
267
+ requestSignUp: {type: ["boolean", "null"], description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"},
268
+ loginHint: {type: ["string", "null"], description: "The login hint to use for the authorization code request"},
269
+ additionalData: {type: ["string", "null"]},
270
+ scopes: {type: ["array", "null"], description: "Array of scopes to request from the provider. This will override the default scopes passed."},
271
+ idToken: {
272
+ type: ["object", "null"],
273
+ properties: {
274
+ token: {type: "string", description: "ID token from the provider"},
275
+ accessToken: {type: ["string", "null"], description: "Access token from the provider"},
276
+ refreshToken: {type: ["string", "null"], description: "Refresh token from the provider"},
277
+ expiresAt: {type: ["number", "null"], description: "Expiry date of the token"},
278
+ nonce: {type: ["string", "null"], description: "Nonce used to generate the token"}
279
+ },
280
+ required: ["token"]
281
+ }
282
+ },
283
+ required: ["provider"]
284
+ )
285
+ }
286
+ }
287
+ },
288
+ responses: {
289
+ "200" => {
290
+ description: "Success - Returns either session details or redirect URL",
291
+ content: {
292
+ "application/json" => {
293
+ schema: session_response_schema(description: "Session response when idToken is provided")
294
+ }
295
+ }
296
+ }
297
+ }
298
+ }
299
+ when ["/sign-up/email", "POST"]
300
+ {
301
+ description: "Sign up a user using email and password",
302
+ operationId: "signUpWithEmailAndPassword",
303
+ requestBody: {
304
+ content: {
305
+ "application/json" => {
306
+ schema: object_schema(
307
+ {
308
+ name: {type: "string", description: "The name of the user"},
309
+ email: {type: "string", description: "The email of the user"},
310
+ password: {type: "string", description: "The password of the user"},
311
+ image: {type: "string", description: "The profile image URL of the user"},
312
+ callbackURL: {type: "string", description: "The URL to use for email verification callback"},
313
+ rememberMe: {type: "boolean", description: "If this is false, the session will not be remembered. Default is `true`."}
314
+ },
315
+ required: ["name", "email", "password"]
316
+ )
317
+ }
318
+ }
319
+ },
320
+ responses: {
321
+ "200" => {
322
+ description: "Successfully created user",
323
+ content: {
324
+ "application/json" => {
325
+ schema: object_schema(
326
+ {
327
+ token: {type: "string", nullable: true, description: "Authentication token for the session"},
328
+ user: {type: "object", "$ref": "#/components/schemas/User"}
329
+ },
330
+ required: ["user"]
331
+ )
332
+ }
333
+ }
334
+ },
335
+ "422" => error_response("Unprocessable Entity. User already exists or failed to create user.")
336
+ }
337
+ }
338
+ else
339
+ {}
340
+ end
341
+ end
342
+
343
+ def object_schema(properties, required: [])
344
+ {
345
+ type: "object",
346
+ properties: properties,
347
+ required: required
348
+ }
349
+ end
350
+
351
+ def session_response_schema(description:, nullable_url: false)
352
+ object_schema(
353
+ {
354
+ redirect: {type: "boolean", enum: [false]},
355
+ token: {type: "string", description: "Session token"},
356
+ url: nullable_url ? {type: "string", nullable: true} : {type: "string"},
357
+ user: {type: "object", "$ref": "#/components/schemas/User"}
358
+ },
359
+ required: ["redirect", "token", "user"]
360
+ ).merge(description: description)
361
+ end
362
+
363
+ def open_api_user_response_schema
364
+ object_schema(
365
+ {
366
+ id: {type: "string", description: "The unique identifier of the user"},
367
+ email: {type: "string", format: "email", description: "The email address of the user"},
368
+ name: {type: "string", description: "The name of the user"},
369
+ image: {type: "string", format: "uri", nullable: true, description: "The profile image URL of the user"},
370
+ emailVerified: {type: "boolean", description: "Whether the email has been verified"},
371
+ createdAt: {type: "string", format: "date-time", description: "When the user was created"},
372
+ updatedAt: {type: "string", format: "date-time", description: "When the user was last updated"}
373
+ },
374
+ required: ["id", "email", "name", "emailVerified", "createdAt", "updatedAt"]
375
+ )
376
+ end
377
+
378
+ def empty_request_body
379
+ {
380
+ content: {
381
+ "application/json" => {
382
+ schema: {
383
+ type: "object",
384
+ properties: {}
385
+ }
386
+ }
387
+ }
388
+ }
389
+ end
390
+
391
+ def open_api_responses(responses = nil)
392
+ {"200" => success_response}.merge(default_error_responses).merge(responses || {})
393
+ end
394
+
395
+ def success_response
396
+ {
397
+ description: "Success",
398
+ content: {
399
+ "application/json" => {
400
+ schema: {
401
+ type: "object",
402
+ properties: {}
403
+ }
404
+ }
405
+ }
406
+ }
407
+ end
408
+
409
+ def default_error_responses
410
+ {
411
+ "400" => error_response("Bad Request. Usually due to missing parameters, or invalid parameters.", required: true),
412
+ "401" => error_response("Unauthorized. Due to missing or invalid authentication.", required: true),
413
+ "403" => error_response("Forbidden. You do not have permission to access this resource or to perform this action."),
414
+ "404" => error_response("Not Found. The requested resource was not found."),
415
+ "429" => error_response("Too Many Requests. You have exceeded the rate limit. Try again later."),
416
+ "500" => error_response("Internal Server Error. This is a problem with the server that you cannot fix.")
417
+ }
418
+ end
419
+
420
+ def error_response(description, required: false)
421
+ schema = {
422
+ type: "object",
423
+ properties: {
424
+ message: {
425
+ type: "string"
426
+ }
427
+ }
428
+ }
429
+ schema[:required] = ["message"] if required
430
+ {
431
+ description: description,
432
+ content: {
433
+ "application/json" => {
434
+ schema: schema
435
+ }
436
+ }
437
+ }
438
+ end
439
+
440
+ def open_api_components(options)
441
+ Schema.auth_tables(options).each_with_object({}) do |(model, table), schemas|
442
+ name = model.to_s.split(/[_-]/).map(&:capitalize).join
443
+ schemas[name.to_sym] = schema_for_table(table)
444
+ end
445
+ end
446
+
447
+ def schema_for_table(table)
448
+ required = []
449
+ properties = table[:fields].each_with_object({}) do |(field, attributes), result|
450
+ result[field.to_sym] = field_schema(attributes)
451
+ required << field if attributes[:required] && attributes[:input] != false && field != "id"
452
+ end
453
+ {type: "object", properties: properties, required: required}
454
+ end
455
+
456
+ def field_schema(attributes)
457
+ type = case attributes[:type].to_s
458
+ when "date" then "string"
459
+ when "number" then "number"
460
+ when "boolean" then "boolean"
461
+ else "string"
462
+ end
463
+ schema = {type: type}
464
+ schema[:format] = "date-time" if attributes[:type].to_s == "date"
465
+ schema[:default] = attributes[:default_value].respond_to?(:call) ? "Generated at runtime" : attributes[:default_value] if attributes.key?(:default_value)
466
+ schema[:readOnly] = true if attributes[:input] == false
467
+ schema
468
+ end
469
+
470
+ def open_api_html(schema, config)
471
+ nonce = config[:nonce] ? " nonce=\"#{config[:nonce]}\"" : ""
472
+ <<~HTML
473
+ <!doctype html>
474
+ <html>
475
+ <head>
476
+ <title>Scalar API Reference</title>
477
+ <meta charset="utf-8" />
478
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
479
+ </head>
480
+ <body>
481
+ <script id="api-reference" type="application/json">#{JSON.generate(schema)}</script>
482
+ <script#{nonce}>window.scalarTheme = "#{config[:theme]}";</script>
483
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"#{nonce}></script>
484
+ </body>
485
+ </html>
486
+ HTML
487
+ end
488
+ end
489
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module OrganizationSchema
6
+ module_function
7
+
8
+ def build(config)
9
+ schema = {
10
+ organization: {
11
+ model_name: "organizations",
12
+ fields: {
13
+ name: {type: "string", required: true, sortable: true},
14
+ slug: {type: "string", required: true, unique: true, sortable: true, index: true},
15
+ logo: {type: "string", required: false},
16
+ metadata: {type: "string", required: false},
17
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
18
+ updatedAt: {type: "date", required: false, on_update: -> { Time.now }}
19
+ }
20
+ },
21
+ member: {
22
+ model_name: "members",
23
+ fields: {
24
+ organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
25
+ userId: {type: "string", required: true, references: {model: "user", field: "id"}, index: true},
26
+ role: {type: "string", required: true, default_value: "member", sortable: true},
27
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }}
28
+ }
29
+ },
30
+ invitation: {
31
+ model_name: "invitations",
32
+ fields: {
33
+ organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
34
+ email: {type: "string", required: true, sortable: true, index: true},
35
+ role: {type: "string", required: true, sortable: true},
36
+ status: {type: "string", required: true, sortable: true, default_value: "pending"},
37
+ expiresAt: {type: "date", required: true},
38
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
39
+ inviterId: {type: "string", required: true, references: {model: "user", field: "id"}}
40
+ }
41
+ },
42
+ session: {
43
+ fields: {
44
+ activeOrganizationId: {type: "string", required: false}
45
+ }
46
+ }
47
+ }
48
+
49
+ if truthy?(config.dig(:teams, :enabled))
50
+ schema[:team] = {
51
+ model_name: "teams",
52
+ fields: {
53
+ name: {type: "string", required: true},
54
+ organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
55
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
56
+ updatedAt: {type: "date", required: false, on_update: -> { Time.now }}
57
+ }
58
+ }
59
+ schema[:teamMember] = {
60
+ model_name: "team_members",
61
+ fields: {
62
+ teamId: {type: "string", required: true, references: {model: "team", field: "id"}, index: true},
63
+ userId: {type: "string", required: true, references: {model: "user", field: "id"}, index: true},
64
+ createdAt: {type: "date", required: false, default_value: -> { Time.now }}
65
+ }
66
+ }
67
+ schema[:invitation][:fields][:teamId] = {type: "string", required: false, sortable: true}
68
+ schema[:session][:fields][:activeTeamId] = {type: "string", required: false}
69
+ end
70
+
71
+ if truthy?(config.dig(:dynamic_access_control, :enabled))
72
+ schema[:organizationRole] = {
73
+ model_name: "organization_roles",
74
+ fields: {
75
+ organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
76
+ role: {type: "string", required: true, index: true},
77
+ permission: {type: "string", required: true},
78
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
79
+ updatedAt: {type: "date", required: false, on_update: -> { Time.now }}
80
+ }
81
+ }
82
+ end
83
+
84
+ merge_custom_schema(schema, config[:schema])
85
+ end
86
+
87
+ def merge_custom_schema(base, custom)
88
+ return base unless custom.is_a?(Hash)
89
+
90
+ custom.each_with_object(base) do |(raw_model, raw_table), result|
91
+ model = Schema.storage_key(raw_model).to_sym
92
+ table = raw_table || {}
93
+ result[model] ||= {fields: {}}
94
+ result[model][:model_name] = table[:model_name] || table["modelName"] || table["model_name"] if table[:model_name] || table["modelName"] || table["model_name"]
95
+ fields = table[:fields] || table["fields"] || {}
96
+ additional = table[:additional_fields] || table["additionalFields"] || table["additional_fields"] || {}
97
+ result[model][:fields] = (result[model][:fields] || {}).merge(Plugins.storage_fields(fields)).merge(Plugins.storage_fields(additional))
98
+ end
99
+ end
100
+
101
+ def truthy?(value)
102
+ value == true || value.to_s == "true"
103
+ end
104
+ end
105
+ end
106
+ end