collavre 0.20.3 → 0.22.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/assets/stylesheets/collavre/popup.css +148 -0
  7. data/app/channels/collavre/agent_channel.rb +205 -0
  8. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  9. data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
  10. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  11. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  12. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  13. data/app/controllers/collavre/application_controller.rb +27 -0
  14. data/app/controllers/collavre/attachments_controller.rb +30 -2
  15. data/app/controllers/collavre/channels_controller.rb +23 -0
  16. data/app/controllers/collavre/comments_controller.rb +1 -1
  17. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  18. data/app/controllers/collavre/creatives_controller.rb +141 -7
  19. data/app/controllers/collavre/landing_controller.rb +8 -0
  20. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  21. data/app/controllers/collavre/tasks_controller.rb +12 -4
  22. data/app/controllers/collavre/topics_controller.rb +36 -30
  23. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  24. data/app/helpers/collavre/comments_helper.rb +7 -0
  25. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  26. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  27. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  28. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  29. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  30. data/app/javascript/controllers/comment_controller.js +15 -1
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  32. data/app/javascript/controllers/comments/form_controller.js +4 -0
  33. data/app/javascript/controllers/comments/list_controller.js +27 -9
  34. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  35. data/app/javascript/controllers/comments/presence_controller.js +137 -4
  36. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  37. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  38. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  39. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  40. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  41. data/app/javascript/controllers/index.js +4 -1
  42. data/app/javascript/controllers/landing_video_controller.js +53 -0
  43. data/app/javascript/controllers/link_creative_controller.js +451 -29
  44. data/app/javascript/creatives/tree_renderer.js +6 -0
  45. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  46. data/app/javascript/lib/api/creatives.js +13 -0
  47. data/app/javascript/lib/api/queue_manager.js +17 -5
  48. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  49. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  50. data/app/javascript/lib/lexical/color_import.js +186 -0
  51. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  52. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  53. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  54. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  55. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  56. data/app/javascript/modules/command_args_form.js +22 -4
  57. data/app/javascript/modules/command_menu.js +27 -0
  58. data/app/javascript/modules/creative_row_editor.js +227 -17
  59. data/app/javascript/modules/html_content_empty.js +12 -0
  60. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  61. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  62. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  63. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  64. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  65. data/app/mailers/collavre/application_mailer.rb +1 -1
  66. data/app/models/collavre/agent_subscription.rb +52 -0
  67. data/app/models/collavre/channel/injected_message.rb +5 -0
  68. data/app/models/collavre/channel.rb +87 -0
  69. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  70. data/app/models/collavre/comment.rb +70 -5
  71. data/app/models/collavre/creative/describable.rb +202 -3
  72. data/app/models/collavre/creative.rb +2 -0
  73. data/app/models/collavre/creative_share.rb +1 -0
  74. data/app/models/collavre/integration_setting.rb +35 -0
  75. data/app/models/collavre/preview_channel.rb +93 -0
  76. data/app/models/collavre/system_setting.rb +13 -2
  77. data/app/models/collavre/task.rb +34 -5
  78. data/app/models/collavre/topic.rb +8 -25
  79. data/app/models/collavre/user.rb +4 -0
  80. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  81. data/app/services/collavre/agent_session_abort.rb +28 -0
  82. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  83. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  84. data/app/services/collavre/ai_agent_service.rb +68 -49
  85. data/app/services/collavre/ai_client.rb +3 -3
  86. data/app/services/collavre/attachment_backfill.rb +26 -0
  87. data/app/services/collavre/channel_attacher.rb +58 -0
  88. data/app/services/collavre/comments/mcp_command.rb +31 -1
  89. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  90. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  91. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  92. data/app/services/collavre/creatives/index_query.rb +110 -8
  93. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  94. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  95. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  96. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  97. data/app/services/collavre/google_calendar_service.rb +4 -2
  98. data/app/services/collavre/markdown_converter.rb +130 -15
  99. data/app/services/collavre/markdown_importer.rb +7 -2
  100. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  101. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  102. data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
  103. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  104. data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
  105. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  106. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  107. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  108. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  109. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  110. data/app/services/collavre/topic_branch_service.rb +34 -26
  111. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  112. data/app/views/admin/shared/_tabs.html.erb +1 -0
  113. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  114. data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
  115. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  116. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  117. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  118. data/app/views/collavre/comments/_comment.html.erb +16 -2
  119. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  120. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  121. data/app/views/collavre/creatives/index.html.erb +10 -2
  122. data/app/views/collavre/landing/show.html.erb +130 -0
  123. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  124. data/app/views/layouts/collavre/landing.html.erb +33 -0
  125. data/config/locales/admin.en.yml +4 -2
  126. data/config/locales/admin.ko.yml +4 -2
  127. data/config/locales/channels.en.yml +13 -0
  128. data/config/locales/channels.ko.yml +13 -0
  129. data/config/locales/claude_channel.en.yml +16 -0
  130. data/config/locales/claude_channel.ko.yml +16 -0
  131. data/config/locales/comments.en.yml +5 -0
  132. data/config/locales/comments.ko.yml +5 -0
  133. data/config/locales/creatives.en.yml +11 -0
  134. data/config/locales/creatives.ko.yml +10 -0
  135. data/config/locales/integrations.en.yml +55 -0
  136. data/config/locales/integrations.ko.yml +55 -0
  137. data/config/locales/landing.en.yml +51 -0
  138. data/config/locales/landing.ko.yml +51 -0
  139. data/config/routes.rb +30 -0
  140. data/db/migrate/20260526000000_create_channels.rb +42 -0
  141. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  142. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  143. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  144. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  145. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  146. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  147. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  148. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  149. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  150. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  151. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  152. data/db/seeds.rb +19 -0
  153. data/lib/collavre/aws_credentials.rb +75 -0
  154. data/lib/collavre/engine.rb +50 -0
  155. data/lib/collavre/integration_settings/key_definition.rb +35 -0
  156. data/lib/collavre/integration_settings/registry.rb +60 -0
  157. data/lib/collavre/integration_settings/resolver.rb +71 -0
  158. data/lib/collavre/integration_settings.rb +46 -0
  159. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  160. data/lib/collavre/version.rb +1 -1
  161. data/lib/collavre.rb +3 -0
  162. metadata +82 -2
  163. data/app/services/collavre/openclaw_abort_service.rb +0 -45
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Returns source-coherent AWS credential pairs (S3 access key id + secret,
5
+ # SES SMTP username + password). Both halves of a pair come from the same
6
+ # source — DB > ENV > Rails credentials — so a partial admin save cannot
7
+ # combine a DB-saved value with an ENV-only sibling and produce a
8
+ # mismatched pair that breaks every upload or every SMTP delivery.
9
+ #
10
+ # Each entry is `[registry_key, label, env_var, credentials_path]`.
11
+ # `credentials_path` may be nil when the pair has no Rails.credentials
12
+ # fallback (S3 keys aren't carried in credentials by convention).
13
+ module AwsCredentials
14
+ module_function
15
+
16
+ # @param boot_safe [Boolean] when true, swallow encryption errors so
17
+ # `storage.yml` / env configs can boot before
18
+ # `active_record_encryption.rb` runs. Runtime callers (e.g.
19
+ # `SesSettingsInterceptor`) MUST leave this false so a decryption
20
+ # failure surfaces instead of silently falling back to ENV.
21
+ # @return [Hash{Symbol => String}] coherent S3 credential pair or `{}`
22
+ def s3(boot_safe: false)
23
+ coherent_pair(
24
+ boot_safe,
25
+ [ :aws_s3_access_key_id, :access_key_id, "AWS_S3_ACCESS_KEY_ID", nil ],
26
+ [ :aws_s3_secret_access_key, :secret_access_key, "AWS_S3_SECRET_ACCESS_KEY", nil ]
27
+ )
28
+ end
29
+
30
+ # @return [Hash{Symbol => String}] coherent SES SMTP credential pair or `{}`
31
+ def ses_smtp(boot_safe: false)
32
+ coherent_pair(
33
+ boot_safe,
34
+ [ :aws_ses_smtp_username, :user_name, "AWS_SES_SMTP_USERNAME", %i[aws smtp_username] ],
35
+ [ :aws_ses_smtp_password, :password, "AWS_SES_SMTP_PASSWORD", %i[aws smtp_password] ]
36
+ )
37
+ end
38
+
39
+ def coherent_pair(boot_safe, *entries)
40
+ [ db_pair(entries, boot_safe), env_pair(entries), credentials_pair(entries) ]
41
+ .find { |pair| pair.values.all?(&:present?) } || {}
42
+ end
43
+
44
+ def db_pair(entries, boot_safe)
45
+ entries.to_h { |entry| [ entry[1], read_db(entry[0], boot_safe: boot_safe) ] }
46
+ end
47
+
48
+ def env_pair(entries)
49
+ entries.to_h { |entry| [ entry[1], ENV[entry[2]].presence ] }
50
+ end
51
+
52
+ def credentials_pair(entries)
53
+ entries.to_h do |entry|
54
+ path = entry[3]
55
+ value = path ? Rails.application.credentials.dig(*path).presence : nil
56
+ [ entry[1], value ]
57
+ end
58
+ end
59
+
60
+ def read_db(key, boot_safe: false)
61
+ return nil unless defined?(Collavre::IntegrationSetting)
62
+ Collavre::IntegrationSetting.find_by(key: key.to_s)&.value.presence
63
+ rescue ActiveRecord::StatementInvalid,
64
+ ActiveRecord::NoDatabaseError,
65
+ ActiveRecord::ConnectionNotEstablished,
66
+ NameError
67
+ nil
68
+ rescue StandardError => e
69
+ raise unless boot_safe &&
70
+ defined?(ActiveRecord::Encryption::Errors::Base) &&
71
+ e.is_a?(ActiveRecord::Encryption::Errors::Base)
72
+ nil
73
+ end
74
+ end
75
+ end
@@ -16,6 +16,56 @@ module Collavre
16
16
  root.join("app/assets/stylesheets")
17
17
  end
18
18
 
19
+ # Register engine-internal integration settings keys with the central
20
+ # registry. These keys are consumed by engine code (mailer `from`, public
21
+ # assets helper, MCP upload service), so the engine itself must own their
22
+ # registration — host apps may not include the app-level
23
+ # `integration_settings_app.rb` initializer when mounting the engine as a gem.
24
+ # `register` is idempotent, so host app re-registration remains safe.
25
+ initializer "collavre.integration_settings_registry", before: :load_config_initializers do
26
+ if defined?(Collavre::IntegrationSettings::Registry)
27
+ registry = Collavre::IntegrationSettings::Registry.instance
28
+ registry.register(:default_mailer_from, category: "mail", sensitive: false, requires_restart: true)
29
+ registry.register(:public_assets_host, category: "mail", sensitive: false, requires_restart: false)
30
+ # LLM keys consumed by Collavre::AiClient (engine service). Owned by the
31
+ # engine so gem-mounted host apps don't depend on the app-level
32
+ # `ruby_llm.rb` initializer for ENV fallback.
33
+ registry.register(:gemini_api_key, category: "llm", sensitive: true, requires_restart: true)
34
+ registry.register(:openai_api_key, category: "llm", sensitive: true, requires_restart: false)
35
+ registry.register(:anthropic_api_key, category: "llm", sensitive: true, requires_restart: false)
36
+ registry.register(:gemini_api_base, category: "llm", sensitive: false, requires_restart: true)
37
+ end
38
+ end
39
+
40
+ # AWS keys are consumed by `config/environments/*.rb` (active_storage.service
41
+ # decision, SMTP scaffold) and `config/storage.yml` ERB, both of which evaluate
42
+ # during the `:load_environment_config` initializer — earlier than
43
+ # `:load_config_initializers`. Register them in their own block so
44
+ # `IntegrationSettings.fetch` can resolve the keys at that earlier point.
45
+ # S3 keys are `requires_restart: true` because Rails resolves the storage
46
+ # service once at boot; SES keys are runtime-injected by SesSettingsInterceptor.
47
+ initializer "collavre.integration_settings_registry.aws", before: :load_environment_config do
48
+ if defined?(Collavre::IntegrationSettings::Registry)
49
+ registry = Collavre::IntegrationSettings::Registry.instance
50
+ registry.register(:aws_s3_access_key_id, category: "aws_s3", sensitive: true, requires_restart: true)
51
+ registry.register(:aws_s3_secret_access_key, category: "aws_s3", sensitive: true, requires_restart: true)
52
+ registry.register(:aws_s3_bucket, category: "aws_s3", sensitive: false, requires_restart: true)
53
+ registry.register(:aws_region, category: "aws_s3", sensitive: false, requires_restart: true)
54
+ registry.register(:aws_ses_smtp_username, category: "aws_ses", sensitive: true, requires_restart: false)
55
+ registry.register(:aws_ses_smtp_password, category: "aws_ses", sensitive: true, requires_restart: false)
56
+ end
57
+ end
58
+
59
+ # Register the SES SMTP settings interceptor so each outgoing mail picks up
60
+ # the current DB > ENV > credentials value for SES creds at send time.
61
+ # Hooked after ActionMailer loads to ensure Mail::SMTP is defined.
62
+ initializer "collavre.ses_settings_interceptor" do
63
+ ActiveSupport.on_load(:action_mailer) do
64
+ require "mail"
65
+ ::Mail.register_interceptor(Collavre::SesSettingsInterceptor)
66
+ end
67
+ end
68
+
19
69
  # Add engine migrations to main app's migration path
20
70
  # This allows migrations to live in the engine but be run from the host app
21
71
  initializer "collavre.migrations" do |app|
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module IntegrationSettings
5
+ # Value object describing a registered integration setting key.
6
+ #
7
+ # @!attribute [r] key
8
+ # @return [Symbol] canonical key identifier (e.g. :slack_client_id)
9
+ # @!attribute [r] category
10
+ # @return [String] grouping for the admin UI (e.g. "slack", "google_oauth")
11
+ # @!attribute [r] sensitive
12
+ # @return [Boolean] when true the value is masked in the admin UI
13
+ # @!attribute [r] requires_restart
14
+ # @return [Boolean] when true the admin UI surfaces a "restart required" badge
15
+ # @!attribute [r] env_var
16
+ # @return [String] name of the ENV variable used as fallback / seed source
17
+ # @!attribute [r] default
18
+ # @return [String, nil] static default returned when neither DB nor ENV has a value
19
+ # @!attribute [r] input_type
20
+ # @return [Symbol] admin UI input widget: :string (default, single line) or :textarea
21
+ # @!attribute [r] admin_visible
22
+ # @return [Boolean] when false the key is resolvable but hidden from the admin UI
23
+ KeyDefinition = Struct.new(
24
+ :key,
25
+ :category,
26
+ :sensitive,
27
+ :requires_restart,
28
+ :env_var,
29
+ :default,
30
+ :input_type,
31
+ :admin_visible,
32
+ keyword_init: true
33
+ )
34
+ end
35
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Collavre
6
+ module IntegrationSettings
7
+ # Singleton registry of integration setting key definitions.
8
+ # Engines register their keys at boot via `to_prepare`, then the
9
+ # admin UI and {Resolver} consult this registry.
10
+ class Registry
11
+ include Singleton
12
+
13
+ def initialize
14
+ @definitions = {}
15
+ end
16
+
17
+ # Register a key definition.
18
+ #
19
+ # @param key [Symbol, String] canonical identifier (e.g. :slack_client_id)
20
+ # @param category [String] grouping label (e.g. "slack")
21
+ # @param sensitive [Boolean] when true, value is masked in admin UI
22
+ # @param requires_restart [Boolean] when true, surfaces restart-required badge
23
+ # @param env_var [String, nil] ENV variable name (defaults to key.upcase)
24
+ # @param default [String, nil] static fallback used when DB & ENV blank
25
+ # @param input_type [Symbol] admin UI widget: :string (default) or :textarea
26
+ # @param admin_visible [Boolean] when false the key is resolvable but hidden from admin UI
27
+ # @return [KeyDefinition]
28
+ def register(key, category:, sensitive: true, requires_restart: false, env_var: nil, default: nil,
29
+ input_type: :string, admin_visible: true)
30
+ key = key.to_sym
31
+ @definitions[key] = KeyDefinition.new(
32
+ key: key,
33
+ category: category,
34
+ sensitive: sensitive,
35
+ requires_restart: requires_restart,
36
+ env_var: env_var || key.to_s.upcase,
37
+ default: default,
38
+ input_type: input_type,
39
+ admin_visible: admin_visible
40
+ )
41
+ end
42
+
43
+ # @return [Array<KeyDefinition>] all registered definitions (frozen snapshot)
44
+ def all
45
+ @definitions.values.freeze
46
+ end
47
+
48
+ # @param key [Symbol, String]
49
+ # @return [KeyDefinition, nil]
50
+ def find(key)
51
+ @definitions[key.to_sym]
52
+ end
53
+
54
+ # @return [Hash{String => Array<KeyDefinition>}] definitions grouped by category
55
+ def by_category
56
+ @definitions.values.group_by(&:category)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module ::Collavre::IntegrationSettings
5
+ # Resolves the live value for a registered integration setting key.
6
+ #
7
+ # Precedence: **DB > ENV > registered default.**
8
+ # - DB value wins as long as an `::Collavre::IntegrationSetting` row exists with a
9
+ # non-blank value (encrypted at rest via `::Collavre::IntegrationSetting`).
10
+ # - ENV is consulted only when no DB row exists.
11
+ # - The registry's static default is the final fallback.
12
+ #
13
+ # Caching:
14
+ # - **Sensitive** definitions are NEVER cached. `Rails.cache` may resolve
15
+ # to a durable store (e.g. `:solid_cache_store` in production), which
16
+ # would persist decrypted secrets outside the encrypted `value` column
17
+ # and defeat at-rest encryption. Sensitive reads hit the DB every time.
18
+ # - **Non-sensitive** definitions are memoized in `Rails.cache` for 5
19
+ # minutes under `::Collavre::IntegrationSetting.cache_key_for(key)`. The model's
20
+ # `after_commit` hook invalidates this key on any write.
21
+ class Resolver
22
+ # Raised by {.get} when the key has not been registered.
23
+ class UnknownKeyError < StandardError; end
24
+
25
+ CACHE_TTL = 5.minutes
26
+
27
+ class << self
28
+ # Resolve the current value for a registered key.
29
+ #
30
+ # @param key [Symbol, String]
31
+ # @return [String, nil]
32
+ # @raise [UnknownKeyError] if the key is not registered
33
+ def get(key)
34
+ definition = Registry.instance.find(key) or
35
+ raise UnknownKeyError, "Unknown integration setting: #{key}"
36
+
37
+ return resolve(definition) if definition.sensitive
38
+
39
+ Rails.cache.fetch(::Collavre::IntegrationSetting.cache_key_for(definition.key), expires_in: CACHE_TTL) do
40
+ resolve(definition)
41
+ end
42
+ end
43
+
44
+ # Report which source currently provides the value for a key.
45
+ #
46
+ # @param key [Symbol, String]
47
+ # @return [Symbol] one of `:db`, `:env`, `:default`, or `:unknown`
48
+ def source_for(key)
49
+ definition = Registry.instance.find(key) or return :unknown
50
+ return :db if ::Collavre::IntegrationSetting.find_by(key: definition.key)&.value.present?
51
+ return :env if ENV[definition.env_var].present?
52
+ :default
53
+ end
54
+
55
+ private
56
+
57
+ def resolve(definition)
58
+ db_value(definition) || env_value(definition) || definition.default
59
+ end
60
+
61
+ def db_value(definition)
62
+ ::Collavre::IntegrationSetting.find_by(key: definition.key)&.value.presence
63
+ end
64
+
65
+ def env_value(definition)
66
+ ENV[definition.env_var].presence
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for the Collavre integration settings subsystem.
4
+ # Loads the registry value-object, registry singleton, and resolver
5
+ # (when present). Loaded from `engines/collavre/lib/collavre.rb` so
6
+ # constants are available before any engine `to_prepare` blocks run.
7
+
8
+ require_relative "integration_settings/key_definition"
9
+ require_relative "integration_settings/registry"
10
+ require_relative "integration_settings/resolver"
11
+
12
+ module Collavre
13
+ module IntegrationSettings
14
+ # Resolve a registered key with a safety net for boot-time consumers.
15
+ # Returns `Resolver.get(key)` when the registry+DB are reachable, otherwise
16
+ # falls back to `ENV[key.upcase]`. After the next boot, the DB value wins —
17
+ # matching the `requires_restart` semantics callers register.
18
+ #
19
+ # `boot_safe: true` additionally rescues `ActiveRecord::Encryption::Errors::Base`
20
+ # so callers reached before `config/initializers/active_record_encryption.rb`
21
+ # runs (e.g. `storage.yml`, `config/environments/*.rb`) cannot crash the boot
22
+ # if an encrypted row is unreadable. Runtime callers MUST leave this false so
23
+ # decryption failures surface instead of silently treating values as blank.
24
+ def self.fetch(key, default: nil, boot_safe: false)
25
+ return ENV[key.to_s.upcase].presence || default unless defined?(Resolver)
26
+
27
+ value =
28
+ begin
29
+ Resolver.get(key)
30
+ rescue Resolver::UnknownKeyError
31
+ nil
32
+ rescue ActiveRecord::StatementInvalid,
33
+ ActiveRecord::NoDatabaseError,
34
+ ActiveRecord::ConnectionNotEstablished,
35
+ NameError
36
+ ENV[key.to_s.upcase]
37
+ rescue StandardError => e
38
+ raise unless boot_safe &&
39
+ defined?(ActiveRecord::Encryption::Errors::Base) &&
40
+ e.is_a?(ActiveRecord::Encryption::Errors::Base)
41
+ ENV[key.to_s.upcase]
42
+ end
43
+ value.presence || default
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # ActionMailer interceptor that injects AWS SES SMTP settings (address,
5
+ # user_name, password) into each outgoing message at send time, sourced
6
+ # from `IntegrationSettings` (DB > ENV) with a Rails credentials fallback.
7
+ #
8
+ # Registered via `Collavre::Engine` so admins can rotate SES credentials
9
+ # through `/admin/integrations` without redeploying. Only acts on SMTP
10
+ # delivery — other delivery methods (`:test`, `:letter_opener`, etc.) pass
11
+ # through untouched.
12
+ class SesSettingsInterceptor
13
+ SES_ADDRESS_PATTERN = /\Aemail-smtp\.[a-z0-9-]+\.amazonaws\.com\z/
14
+
15
+ class << self
16
+ def delivering_email(message)
17
+ return unless message.delivery_method.is_a?(::Mail::SMTP)
18
+
19
+ settings = message.delivery_method.settings
20
+ # Scope to SES-bound messages only. The engine registers this
21
+ # interceptor globally, so host apps using a non-SES SMTP provider
22
+ # (SendGrid, Mailgun, custom relay) must pass through untouched —
23
+ # otherwise we'd clobber their address/user_name/password.
24
+ return unless ses_target?(settings)
25
+
26
+ region = resolve_region
27
+ creds = Collavre::AwsCredentials.ses_smtp
28
+
29
+ settings[:address] = "email-smtp.#{region}.amazonaws.com" if region.present?
30
+ # `AwsCredentials.ses_smtp` returns only source-coherent pairs (both DB,
31
+ # both ENV, or both credentials). When admins save just one half via
32
+ # /admin/integrations while the other still lives in ENV, the helper
33
+ # drops the partial DB write and falls through to a coherent ENV/cred
34
+ # pair, avoiding mismatched (db-user, env-pass) injections that would
35
+ # break every SMTP delivery.
36
+ if creds[:user_name].present? && creds[:password].present?
37
+ settings[:user_name] = creds[:user_name]
38
+ settings[:password] = creds[:password]
39
+ else
40
+ # No coherent pair available (admin reset DB and no ENV/credentials
41
+ # fallback). Clear stale settings so we don't keep using boot-time
42
+ # or previously-injected credentials — these keys are registered
43
+ # with requires_restart: false, so runtime reset must take effect.
44
+ settings.delete(:user_name)
45
+ settings.delete(:password)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # SES intent is signaled exclusively by an SES-shaped SMTP address
52
+ # (`email-smtp.<region>.amazonaws.com`). The boot scaffold in
53
+ # `config/environments/production.rb` only emits that shape when
54
+ # `ses_region` is resolvable, so a non-SES address — including
55
+ # `Mail::SMTP`'s hard-coded `"localhost"` default and explicit relays
56
+ # like `smtp.sendgrid.net` — means the host is using a different
57
+ # provider (or a real local relay). Bail in those cases so we don't
58
+ # clobber the host's address or wipe authenticated credentials.
59
+ def ses_target?(settings)
60
+ address = settings[:address].to_s
61
+ return true if address.empty?
62
+
63
+ address.match?(SES_ADDRESS_PATTERN)
64
+ end
65
+
66
+ def resolve_region
67
+ value = Collavre::IntegrationSettings.fetch(:aws_region, default: ENV["AWS_REGION"])
68
+ value.presence || Rails.application.credentials.dig(:aws, :region)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,3 +1,3 @@
1
1
  module Collavre
2
- VERSION = "0.20.3"
2
+ VERSION = "0.22.0"
3
3
  end
data/lib/collavre.rb CHANGED
@@ -3,6 +3,9 @@ require "collavre/configuration"
3
3
  require "collavre/engine"
4
4
  require "collavre/user_extensions"
5
5
  require "collavre/integration_registry"
6
+ require "collavre/integration_settings"
7
+ require "collavre/aws_credentials"
8
+ require "collavre/ses_settings_interceptor"
6
9
  require "collavre/view_extensions"
7
10
  require "navigation/registry"
8
11