aha_builder_core 1.0.20 → 1.0.21

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04c418cb077b5736844ca605b1bb7e47dcc2782b9da7f4a54d68c1072837c278
4
- data.tar.gz: 5a67d06975d93e91652cceef6d423fcd5aeb05392ceee206efff028245429768
3
+ metadata.gz: c2d2cf22937791f0b77aa3dec4abf17939b99877059b17f4a461423ddd34ce50
4
+ data.tar.gz: 7dc28c0a6703d842b2cd499d828fdc77fc0d63301cca1c0ccd9d77f92cafb347
5
5
  SHA512:
6
- metadata.gz: 4328aebeeac2eb6c8418e41e4a80e1189c2a30056d5830b0838eb06dc492a7b72aeef7af82109588f5f2d8d8b932c8e279e3d31fdc2be05847a4b1b91487dcc0
7
- data.tar.gz: af6a7898fb682ea7e23da60a29873871952ec7a753bf75eb1a73ac8083a4e329587328eab4a424648cc88f096a61dfc32d88ac1c98706a8b2c304bcf773b9c10
6
+ metadata.gz: b04a59fedd4924fa5ce10a2a3c6781cbb29607adaffa9ef2640f3af9c7842b28f6caae196ca5f9f70445d4aeffceeaebd91e89effef124b0ba11d3ddb1a7bc1c
7
+ data.tar.gz: 64f4f61aabc84bacb316f1aaeae4623087b83d5a941060fc2022a74f65d714a6633efd1c761e28f76339c3e7e7c20b4a159a65f0171073a4e8631a4e89aa9251
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Aha Builder Core Client
2
2
 
3
- Ruby client for Aha! Builder core authentication services which provides login and signup using email/password, social logins (Google, Github, Microsoft), SAML SSO and password reset.
3
+ Ruby client for Aha! Builder core which provides application services:
4
+
5
+ * Authentication services for login and signup using email/password, social logins (Google, Github, Microsoft), SAML SSO and password reset.
6
+ * Email sending API.
7
+ * User guide content API.
4
8
 
5
9
  ## Installation
6
10
 
@@ -206,3 +210,20 @@ Each page hash contains:
206
210
  - `position` - Sort order (integer)
207
211
  - `children_count` - Number of child pages
208
212
  - `content_html` - HTML content (only in `load_page` response)
213
+
214
+ ## Email Sending
215
+
216
+ Aha! Builder core provides a mail API that can be used as an ActionMailer delivery method.
217
+
218
+ ### ActionMailer Integration
219
+
220
+ Configure your Rails application to send emails through Aha! Builder core:
221
+
222
+ ```ruby
223
+ # config/initializers/action_mailer.rb
224
+ ActionMailer::Base.add_delivery_method :aha_mail, Aha::Mail::DeliveryMethod
225
+
226
+ # config/environments/production.rb
227
+ config.action_mailer.delivery_method = :aha_mail
228
+ ```
229
+
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Auth
5
+ # Discovers BuilderCore configuration via the secure.aha.io discovery endpoint.
6
+ # Resilient to account subdomain changes by resolving the current subdomain from account ID.
7
+ class BuilderDiscoveryService
8
+ CACHE_TTL = 300 # 5 minutes
9
+
10
+ def initialize(configuration)
11
+ @configuration = configuration
12
+ @cache = Concurrent::Atom.new({})
13
+ @refreshing = Concurrent::AtomicBoolean.new(false)
14
+ end
15
+
16
+ # Discover the server URL for the account.
17
+ # @param force [Boolean] Force refresh, bypassing cache
18
+ # @return [Hash] Discovery data with :server_url
19
+ def discover(force: false)
20
+ @cache.reset({}) if force
21
+
22
+ cached = @cache.value
23
+
24
+ # Return cached data if present, serving stale data while refreshing in the background if past threshold
25
+ if cached[:data]
26
+ schedule_background_refresh(cached) if should_refresh?(cached)
27
+ return cached[:data]
28
+ end
29
+
30
+ # Cache expired or empty - fetch synchronously with fallback
31
+ fetch_with_fallback
32
+ end
33
+
34
+ private
35
+
36
+ def should_refresh?(cached)
37
+ return true unless cached[:fetched_at]
38
+
39
+ Time.current > cached[:fetched_at] + CACHE_TTL
40
+ end
41
+
42
+ def schedule_background_refresh(cached)
43
+ return unless @refreshing.make_true
44
+
45
+ Concurrent::Future.execute do
46
+ data = fetch_discovery
47
+ @cache.reset(data: data, fetched_at: Time.current)
48
+ rescue StandardError => e
49
+ logger.warn { "[BuilderDiscovery] Background refresh failed: #{e.message}" }
50
+ ensure
51
+ @refreshing.make_false
52
+ end
53
+ end
54
+
55
+ def fetch_with_fallback
56
+ @refreshing.make_true
57
+ data = fetch_discovery
58
+ @refreshing.make_false
59
+ @cache.reset(data: data, fetched_at: Time.current)
60
+ data
61
+ rescue StandardError => e
62
+ logger.warn { "[BuilderDiscovery] Fetch failed: #{e.message}" }
63
+
64
+ fallback_data = build_fallback_data
65
+ if fallback_data
66
+ logger.warn { "[BuilderDiscovery] Using fallback from AHA_CORE_SERVER_URL" }
67
+ @cache.reset(data: fallback_data, fetched_at: Time.current)
68
+ return fallback_data
69
+ end
70
+
71
+ raise
72
+ end
73
+
74
+ def build_fallback_data
75
+ server_url = ENV.fetch("AHA_CORE_SERVER_URL", nil)
76
+ return nil if server_url.to_s.empty?
77
+
78
+ { server_url: server_url }
79
+ end
80
+
81
+ def fetch_discovery
82
+ account_id = @configuration.account_id
83
+ raise ConfigurationError, "account_id is required for discovery" if account_id.to_s.empty?
84
+
85
+ url = "#{@configuration.core_discovery_url}/.well-known/core-discovery/#{account_id}"
86
+ logger.debug { "[BuilderDiscovery] Fetching #{url}" }
87
+ response = http_client.get(url)
88
+ logger.debug { "[BuilderDiscovery] Response status: #{response.status}, body: #{response.body}" }
89
+ handle_response(response)
90
+ end
91
+
92
+ def logger
93
+ @logger ||= defined?(Rails) ? Rails.logger : Logger.new($stdout)
94
+ end
95
+
96
+ def http_client
97
+ @http_client ||= Faraday.new do |conn|
98
+ conn.request :retry, max: 2, interval: 0.5, backoff_factor: 2
99
+ conn.options.timeout = @configuration.timeout
100
+ conn.adapter Faraday.default_adapter
101
+ end
102
+ end
103
+
104
+ def handle_response(response)
105
+ case response.status
106
+ when 200
107
+ parsed = JSON.parse(response.body)
108
+ domain = parsed["domain"]
109
+ base_domain = @configuration.core_discovery_url.sub(%r{\Ahttps?://secure\.}, "")
110
+ { server_url: "https://#{domain}.#{base_domain}/api/core" }
111
+ when 400
112
+ raise BadRequestError.new("Invalid account ID", status: response.status)
113
+ when 404
114
+ raise NotFoundError.new("Account not found", status: response.status)
115
+ else
116
+ raise ApiError.new("Discovery failed (status: #{response.status})", status: response.status)
117
+ end
118
+ rescue Faraday::Error => e
119
+ raise NetworkError.new("Discovery network error", original_error: e)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -8,8 +8,11 @@ module Aha
8
8
  class Client
9
9
  ALGORITHM = "RS256"
10
10
 
11
- def initialize(configuration)
11
+ # @param configuration [Configuration] The client configuration
12
+ # @param server_url [String, nil] Optional server URL override (used for discovery)
13
+ def initialize(configuration, server_url: nil)
12
14
  @configuration = configuration
15
+ @server_url = server_url || configuration.server_url
13
16
  @token_cache = TokenCache.new(ttl: configuration.jwks_cache_ttl)
14
17
  end
15
18
 
@@ -194,7 +197,7 @@ module Aha
194
197
  end
195
198
 
196
199
  def http_client
197
- @http_client ||= Faraday.new(url: @configuration.server_url) do |conn|
200
+ @http_client ||= Faraday.new(url: @server_url) do |conn|
198
201
  if @configuration.enable_logging && @configuration.logger
199
202
  conn.response :logger, @configuration.logger, bodies: true
200
203
  end
@@ -3,7 +3,13 @@
3
3
  module Aha
4
4
  module Auth
5
5
  class Configuration
6
- # The base URL of the BuilderCore auth server
6
+ # Account ID for discovery-based URL resolution
7
+ attr_accessor :account_id
8
+
9
+ # Base URL for builder discovery endpoint (e.g., https://secure.aha.io)
10
+ attr_accessor :core_discovery_url
11
+
12
+ # The base URL of the BuilderCore auth server (discovered or explicit)
7
13
  attr_accessor :server_url
8
14
 
9
15
  # API key for server-to-server authentication
@@ -29,7 +35,8 @@ module Aha
29
35
  attr_accessor :logger
30
36
 
31
37
  def initialize
32
- @server_url = ENV.fetch("AHA_CORE_SERVER_URL", "https://secure.aha.io/api/core")
38
+ @account_id = ENV.fetch("AHA_ACCOUNT_ID", nil)
39
+ @core_discovery_url = ENV.fetch("AHA_SECURE_URL", "https://secure.aha.io")
33
40
  @api_key = ENV.fetch("AHA_CORE_API_KEY", nil)
34
41
  @client_id = ENV.fetch("APPLICATION_ID", nil)
35
42
  @jwks_cache_ttl = 3600 # 1 hour
@@ -40,7 +47,7 @@ module Aha
40
47
  end
41
48
 
42
49
  def validate!
43
- raise ConfigurationError, "server_url is required" if server_url.nil? || server_url.to_s.empty?
50
+ raise ConfigurationError, "account_id is required" if account_id.nil? || account_id.to_s.empty?
44
51
  raise ConfigurationError, "client_id is required" if client_id.nil? || client_id.to_s.empty?
45
52
  end
46
53
  end
data/lib/aha/auth.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "version"
11
11
  require_relative "auth/errors"
12
12
  require_relative "auth/configuration"
13
13
  require_relative "auth/token_cache"
14
+ require_relative "auth/builder_discovery_service"
14
15
  require_relative "auth/session"
15
16
  require_relative "auth/user"
16
17
  require_relative "auth/client"
@@ -20,6 +21,7 @@ require_relative "auth/sessions_resource"
20
21
  module Aha
21
22
  module Auth
22
23
  CONFIGURATION = Concurrent::Atom.new(Configuration.new)
24
+ DISCOVERY_SERVICE = Concurrent::Atom.new(nil)
23
25
  CLIENT = Concurrent::Atom.new(nil)
24
26
  USERS_RESOURCE = Concurrent::Atom.new(nil)
25
27
  SESSIONS_RESOURCE = Concurrent::Atom.new(nil)
@@ -35,11 +37,27 @@ module Aha
35
37
 
36
38
  def reset_configuration!
37
39
  CONFIGURATION.reset(Configuration.new)
40
+ DISCOVERY_SERVICE.reset(nil)
38
41
  CLIENT.reset(nil)
39
42
  USERS_RESOURCE.reset(nil)
40
43
  SESSIONS_RESOURCE.reset(nil)
41
44
  end
42
45
 
46
+ # Access the discovery service for resolving server URLs
47
+ #
48
+ # @return [BuilderDiscoveryService]
49
+ def discovery_service
50
+ DISCOVERY_SERVICE.compare_and_set(nil, BuilderDiscoveryService.new(configuration))
51
+ DISCOVERY_SERVICE.value
52
+ end
53
+
54
+ # Get the effective server URL (discovered or configured)
55
+ #
56
+ # @return [String] The server URL to use
57
+ def server_url
58
+ discovery_service.discover[:server_url]
59
+ end
60
+
43
61
  # Generate login URL for redirecting users to the auth server
44
62
  #
45
63
  # @param cookies [Hash] Cookies hash to store the nonce for CSRF verification
@@ -73,7 +91,7 @@ module Aha
73
91
  }
74
92
 
75
93
  query = URI.encode_www_form(params)
76
- "#{configuration.server_url}/auth_ui/start?#{query}"
94
+ "#{server_url}/auth_ui/start?#{query}"
77
95
  end
78
96
 
79
97
  # Exchange an authorization code for tokens
@@ -84,12 +102,12 @@ module Aha
84
102
  def authenticate_with_code(code:, cookies:)
85
103
  # Split the code and nonce if present
86
104
  actual_code, nonce = code.split(".", 2)
87
-
105
+
88
106
  raise "CSRF verification failed: unable to verify nonce" unless nonce
89
107
 
90
108
  cookie_nonce = cookies[:auth_nonce]
91
109
  cookies.delete(:auth_nonce)
92
- raise "CSRF verification failed: nonce missing in cookie" if cookie_nonce.blank?
110
+ raise "CSRF verification failed: nonce missing in cookie" if cookie_nonce.blank?
93
111
  raise "CSRF verification failed: nonce mismatch" if cookie_nonce != nonce
94
112
 
95
113
  client.authenticate_with_code(code: actual_code)
@@ -139,7 +157,7 @@ module Aha
139
157
  private
140
158
 
141
159
  def client
142
- CLIENT.compare_and_set(nil, Client.new(configuration))
160
+ CLIENT.compare_and_set(nil, Client.new(configuration, server_url: server_url))
143
161
  CLIENT.value
144
162
  end
145
163
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Mail
5
+ class DeliveryMethod
6
+ attr_accessor :settings
7
+
8
+ def initialize(settings = {})
9
+ @settings = settings
10
+ end
11
+
12
+ def deliver!(mail)
13
+ # Extract mail attributes
14
+ mail_params = {
15
+ from: format_addresses(mail.from).first,
16
+ to: format_addresses(mail.to),
17
+ cc: format_addresses(mail.cc),
18
+ bcc: format_addresses(mail.bcc),
19
+ reply_to: format_addresses(mail.reply_to),
20
+ subject: mail.subject,
21
+ }
22
+
23
+ # Determine content type and body
24
+ if mail.multipart?
25
+ # For multipart messages, prefer HTML over plain text
26
+ html_part = mail.html_part
27
+ text_part = mail.text_part
28
+
29
+ if html_part
30
+ mail_params[:body] = html_part.body.decoded
31
+ mail_params[:content_type] = "text/html"
32
+ elsif text_part
33
+ mail_params[:body] = text_part.body.decoded
34
+ mail_params[:content_type] = "text/plain"
35
+ else
36
+ mail_params[:body] = mail.body.decoded
37
+ mail_params[:content_type] = mail.content_type
38
+ end
39
+ else
40
+ mail_params[:body] = mail.body.decoded
41
+ mail_params[:content_type] = mail.content_type || "text/plain"
42
+ end
43
+
44
+ # Send via the Aha::Mail API
45
+ Aha::Mail.send_mail(mail_params)
46
+ rescue StandardError => e
47
+ # ActionMailer expects delivery methods to raise exceptions on failure
48
+ raise "Failed to deliver mail via Aha::Mail: #{e.message}"
49
+ end
50
+
51
+ private
52
+
53
+ def format_addresses(addresses)
54
+ return [] if addresses.nil?
55
+
56
+ Array(addresses).map do |address|
57
+ if address.is_a?(::Mail::Address)
58
+ address.address
59
+ else
60
+ address
61
+ end
62
+ end.compact
63
+ end
64
+ end
65
+ end
66
+ end
data/lib/aha/mail.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mail/delivery_method"
4
+
5
+ module Aha
6
+ module Mail
7
+ class << self
8
+ def send_mail(mail_params)
9
+ client.send(:post, "/api/core/mail/send", mail: mail_params)
10
+ end
11
+
12
+ private
13
+
14
+ def client
15
+ Aha::Auth.send(:client)
16
+ end
17
+ end
18
+ end
19
+ end
data/lib/aha/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aha
4
- VERSION = "1.0.20"
4
+ VERSION = "1.0.21"
5
5
  end
@@ -2,3 +2,4 @@
2
2
 
3
3
  require_relative "aha/auth"
4
4
  require_relative "aha/user_guide"
5
+ require_relative "aha/mail"
@@ -0,0 +1,5 @@
1
+ Description:
2
+ Configure ActionMailer to deliver via Aha::Mail.
3
+
4
+ Example:
5
+ bin/rails generate aha_builder_core:email
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module AhaBuilderCore
6
+ class EmailGenerator < Rails::Generators::Base
7
+ ACTION_MAILER_INITIALIZER_PATH = "config/initializers/action_mailer.rb"
8
+ DELIVERY_METHOD_REGISTRATION_LINE = "ActionMailer::Base.add_delivery_method :aha_mail, Aha::Mail::DeliveryMethod"
9
+ DELIVERY_METHOD_LINE = "config.action_mailer.delivery_method = :aha_mail"
10
+
11
+ def configure_action_mailer_initializer
12
+ if File.exist?(ACTION_MAILER_INITIALIZER_PATH)
13
+ ensure_line_present(ACTION_MAILER_INITIALIZER_PATH, DELIVERY_METHOD_REGISTRATION_LINE)
14
+ else
15
+ create_file ACTION_MAILER_INITIALIZER_PATH, <<~RUBY
16
+ # frozen_string_literal: true
17
+
18
+ #{DELIVERY_METHOD_REGISTRATION_LINE}
19
+ RUBY
20
+ end
21
+ end
22
+
23
+ def configure_environment_delivery_methods
24
+ environment "#{DELIVERY_METHOD_LINE}\n", env: %w[development production]
25
+ end
26
+
27
+ def display_instructions
28
+ say "\nActionMailer configured to use Aha::Mail delivery!", :green
29
+ end
30
+
31
+ private
32
+
33
+ def ensure_line_present(path, line)
34
+ contents = File.read(path)
35
+ return if contents.include?(line)
36
+
37
+ separator = contents.end_with?("\n") ? "" : "\n"
38
+ append_to_file path, "#{separator}#{line}\n"
39
+ end
40
+ end
41
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aha_builder_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.20
4
+ version: 1.0.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aha! Labs Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-24 00:00:00.000000000 Z
11
+ date: 2026-03-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -115,6 +115,7 @@ files:
115
115
  - LICENSE
116
116
  - README.md
117
117
  - lib/aha/auth.rb
118
+ - lib/aha/auth/builder_discovery_service.rb
118
119
  - lib/aha/auth/client.rb
119
120
  - lib/aha/auth/configuration.rb
120
121
  - lib/aha/auth/errors.rb
@@ -123,6 +124,8 @@ files:
123
124
  - lib/aha/auth/token_cache.rb
124
125
  - lib/aha/auth/user.rb
125
126
  - lib/aha/auth/users_resource.rb
127
+ - lib/aha/mail.rb
128
+ - lib/aha/mail/delivery_method.rb
126
129
  - lib/aha/user_guide.rb
127
130
  - lib/aha/version.rb
128
131
  - lib/aha_builder_core.rb
@@ -136,6 +139,8 @@ files:
136
139
  - lib/generators/aha_builder_core/blob_storage/USAGE
137
140
  - lib/generators/aha_builder_core/blob_storage/blob_storage_generator.rb
138
141
  - lib/generators/aha_builder_core/blob_storage/templates/storage.yml.tt
142
+ - lib/generators/aha_builder_core/email/USAGE
143
+ - lib/generators/aha_builder_core/email/email_generator.rb
139
144
  homepage: https://www.aha.io
140
145
  licenses:
141
146
  - MIT