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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +141 -7
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +36 -30
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +15 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +27 -9
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +137 -4
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +202 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +8 -25
- data/app/models/collavre/user.rb +4 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +16 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +13 -0
- data/config/locales/channels.ko.yml +13 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +5 -0
- data/config/locales/comments.ko.yml +5 -0
- data/config/locales/creatives.en.yml +11 -0
- data/config/locales/creatives.ko.yml +10 -0
- data/config/locales/integrations.en.yml +55 -0
- data/config/locales/integrations.ko.yml +55 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +50 -0
- data/lib/collavre/integration_settings/key_definition.rb +35 -0
- data/lib/collavre/integration_settings/registry.rb +60 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +82 -2
- 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
|
data/lib/collavre/engine.rb
CHANGED
|
@@ -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
|
data/lib/collavre/version.rb
CHANGED
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
|
|