open-loam 0.1.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 +7 -0
- data/CHANGELOG.md +71 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
- data/app/jobs/loam/event_delivery_job.rb +26 -0
- data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
- data/app/jobs/loam/webhook_delivery_job.rb +59 -0
- data/app/models/loam/api_token.rb +40 -0
- data/app/models/loam/audit_record.rb +18 -0
- data/app/models/loam/auth_attempt.rb +13 -0
- data/app/models/loam/business_rule.rb +49 -0
- data/app/models/loam/business_rule_run.rb +27 -0
- data/app/models/loam/comment.rb +28 -0
- data/app/models/loam/config.rb +27 -0
- data/app/models/loam/custom_field_value.rb +18 -0
- data/app/models/loam/dashboard_widget.rb +16 -0
- data/app/models/loam/dictionary.rb +20 -0
- data/app/models/loam/dictionary_entry.rb +31 -0
- data/app/models/loam/event_delivery.rb +38 -0
- data/app/models/loam/field_definition.rb +48 -0
- data/app/models/loam/inbound_webhook_delivery.rb +24 -0
- data/app/models/loam/inbound_webhook_source.rb +55 -0
- data/app/models/loam/membership.rb +27 -0
- data/app/models/loam/mfa_credential.rb +114 -0
- data/app/models/loam/notification.rb +36 -0
- data/app/models/loam/pending_action.rb +208 -0
- data/app/models/loam/perspective.rb +104 -0
- data/app/models/loam/progress_job.rb +103 -0
- data/app/models/loam/record_lock.rb +19 -0
- data/app/models/loam/scheduled_job.rb +42 -0
- data/app/models/loam/search_token.rb +9 -0
- data/app/models/loam/sso_identity.rb +13 -0
- data/app/models/loam/sso_provider.rb +35 -0
- data/app/models/loam/tenant.rb +20 -0
- data/app/models/loam/translation.rb +17 -0
- data/app/models/loam/webhook_endpoint.rb +26 -0
- data/app/views/loam/attachments/_attachments.html.erb +16 -0
- data/app/views/loam/comments/_comments.html.erb +24 -0
- data/app/views/loam/custom_fields/_fields.html.erb +35 -0
- data/lib/generators/loam/entity/entity_generator.rb +160 -0
- data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
- data/lib/generators/loam/entity/templates/controller.rb +181 -0
- data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
- data/lib/generators/loam/entity/templates/migration.rb +26 -0
- data/lib/generators/loam/entity/templates/model.rb +27 -0
- data/lib/generators/loam/entity/templates/policy.rb +10 -0
- data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
- data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
- data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
- data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
- data/lib/generators/loam/install/install_generator.rb +288 -0
- data/lib/generators/loam/install/templates/AGENTS.md +341 -0
- data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
- data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
- data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
- data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
- data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
- data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
- data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
- data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
- data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
- data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
- data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
- data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
- data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
- data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
- data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
- data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
- data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
- data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
- data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
- data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
- data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
- data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
- data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin.css +29 -0
- data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
- data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
- data/lib/generators/loam/install/templates/import_job.rb +25 -0
- data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/initializer.rb +218 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
- data/lib/generators/loam/install/templates/user.rb +21 -0
- data/lib/generators/loam/primary_key_options.rb +101 -0
- data/lib/loam/attachable.rb +27 -0
- data/lib/loam/auditable.rb +67 -0
- data/lib/loam/auth_throttle.rb +83 -0
- data/lib/loam/base32.rb +23 -0
- data/lib/loam/bulk.rb +59 -0
- data/lib/loam/business_rules/actions.rb +92 -0
- data/lib/loam/business_rules/condition.rb +102 -0
- data/lib/loam/business_rules.rb +123 -0
- data/lib/loam/commentable.rb +22 -0
- data/lib/loam/configs.rb +115 -0
- data/lib/loam/cron.rb +98 -0
- data/lib/loam/csv.rb +18 -0
- data/lib/loam/current.rb +21 -0
- data/lib/loam/custom_field_index.rb +262 -0
- data/lib/loam/custom_fields.rb +114 -0
- data/lib/loam/dashboard.rb +17 -0
- data/lib/loam/dictionaries.rb +55 -0
- data/lib/loam/durable_events.rb +174 -0
- data/lib/loam/encryptable.rb +145 -0
- data/lib/loam/encryption/cipher.rb +75 -0
- data/lib/loam/encryption/key_provider.rb +43 -0
- data/lib/loam/encryption.rb +125 -0
- data/lib/loam/engine.rb +57 -0
- data/lib/loam/enrichers.rb +105 -0
- data/lib/loam/errors.rb +49 -0
- data/lib/loam/eval.rb +47 -0
- data/lib/loam/event_stream.rb +80 -0
- data/lib/loam/eventful.rb +45 -0
- data/lib/loam/events.rb +51 -0
- data/lib/loam/export.rb +74 -0
- data/lib/loam/features.rb +75 -0
- data/lib/loam/generated_key.rb +37 -0
- data/lib/loam/import.rb +168 -0
- data/lib/loam/inbound_webhooks.rb +125 -0
- data/lib/loam/lifecycle.rb +180 -0
- data/lib/loam/locales/loam.en.yml +80 -0
- data/lib/loam/mcp/server.rb +60 -0
- data/lib/loam/mcp.rb +304 -0
- data/lib/loam/notifications.rb +36 -0
- data/lib/loam/open_api.rb +194 -0
- data/lib/loam/overrides.rb +110 -0
- data/lib/loam/pending_actions.rb +60 -0
- data/lib/loam/permissions.rb +79 -0
- data/lib/loam/perspectives.rb +62 -0
- data/lib/loam/policy.rb +106 -0
- data/lib/loam/progress.rb +31 -0
- data/lib/loam/record_locks.rb +89 -0
- data/lib/loam/scheduler.rb +153 -0
- data/lib/loam/search/driver.rb +31 -0
- data/lib/loam/search/like_driver.rb +29 -0
- data/lib/loam/search/token_driver.rb +88 -0
- data/lib/loam/search.rb +27 -0
- data/lib/loam/searchable.rb +90 -0
- data/lib/loam/soft_deletable.rb +72 -0
- data/lib/loam/sso/claims.rb +10 -0
- data/lib/loam/sso/fake_provider.rb +51 -0
- data/lib/loam/sso/http_client.rb +42 -0
- data/lib/loam/sso/oidc_provider.rb +64 -0
- data/lib/loam/sso.rb +129 -0
- data/lib/loam/telemetry.rb +43 -0
- data/lib/loam/tenant_record.rb +36 -0
- data/lib/loam/test_helpers.rb +15 -0
- data/lib/loam/totp.rb +63 -0
- data/lib/loam/translatable.rb +88 -0
- data/lib/loam/undo.rb +126 -0
- data/lib/loam/version.rb +3 -0
- data/lib/loam/webhooks.rb +34 -0
- data/lib/loam/widgets.rb +77 -0
- data/lib/loam/workflow.rb +256 -0
- data/lib/loam.rb +122 -0
- data/lib/open-loam.rb +5 -0
- data/lib/tasks/loam.rake +171 -0
- data/lib/tasks/loam_eval.rake +32 -0
- data/lib/tasks/loam_mcp.rake +20 -0
- metadata +332 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Tenant lifecycle: what a brand-new tenant gets for free.
|
|
3
|
+
#
|
|
4
|
+
# An app declares seeding once, in config/initializers/loam.rb:
|
|
5
|
+
#
|
|
6
|
+
# Loam.on_tenant_created do |tenant|
|
|
7
|
+
# Loam::FieldDefinition.find_or_create_by!(entity_type: "Equipment", name: "asset_tag") { ... }
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# The block runs inside `Loam.as_tenant(tenant)`, so tenant-scoped writes
|
|
11
|
+
# need no extra ceremony.
|
|
12
|
+
#
|
|
13
|
+
# THE CONTRACT: callbacks MUST be idempotent. They fire once when a tenant is
|
|
14
|
+
# created, and again for EVERY existing tenant whenever `bin/rails loam:sync`
|
|
15
|
+
# runs — which is how a role/field/default added by a later release reaches
|
|
16
|
+
# tenants that already exist. Write `find_or_create_by!`, never `create!`.
|
|
17
|
+
module Lifecycle
|
|
18
|
+
# Registered blocks, in declaration order. Registration returns the block
|
|
19
|
+
# itself so a caller (typically a test) can deregister it again.
|
|
20
|
+
def self.tenant_created_callbacks
|
|
21
|
+
@tenant_created_callbacks ||= []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.on_tenant_created(&block)
|
|
25
|
+
tenant_created_callbacks << block
|
|
26
|
+
block
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The single execution path for a tenant's callbacks — used both by
|
|
30
|
+
# Loam::Tenant's after_create_commit and by sync_tenants!, so "runs inside
|
|
31
|
+
# as_tenant, in declaration order" can never drift between the two.
|
|
32
|
+
#
|
|
33
|
+
# Exceptions propagate: a failing callback fails the tenant creation (Rails
|
|
34
|
+
# re-raises from after_commit) or the sync run, loudly, like every other
|
|
35
|
+
# Loam guardrail.
|
|
36
|
+
def self.run_tenant_created(tenant)
|
|
37
|
+
Loam.as_tenant(tenant) do
|
|
38
|
+
tenant_created_callbacks.each { |callback| callback.call(tenant) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Re-runs every on_tenant_created callback for every existing tenant.
|
|
43
|
+
# Idempotent by contract (see above) — safe to run on every deploy.
|
|
44
|
+
# Returns the number of tenants synced.
|
|
45
|
+
def self.sync_tenants!
|
|
46
|
+
count = 0
|
|
47
|
+
Loam::Tenant.find_each do |tenant|
|
|
48
|
+
run_tenant_created(tenant)
|
|
49
|
+
count += 1
|
|
50
|
+
end
|
|
51
|
+
count
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Role names this app expects every tenant to have — declared in the
|
|
55
|
+
# initializer (`Loam.default_roles = %w[manager employee]`) and read by
|
|
56
|
+
# whatever seeds memberships. A registry, not a mechanism: Loam does not
|
|
57
|
+
# create roles for you, because who gets which role is business logic.
|
|
58
|
+
def self.default_roles
|
|
59
|
+
@default_roles ||= []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.default_roles=(roles)
|
|
63
|
+
@default_roles = Array(roles).map(&:to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# App-wide setting defaults: `{ "billing.currency" => "USD" }`, declared in
|
|
67
|
+
# the initializer and read by Loam::Configs as the baseline a key resolves
|
|
68
|
+
# to when no global row and no tenant override exist. A registry, like
|
|
69
|
+
# default_roles — declaring a default here needs no migration and no row.
|
|
70
|
+
def self.config_defaults
|
|
71
|
+
@config_defaults ||= {}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.config_defaults=(defaults)
|
|
75
|
+
@config_defaults = defaults.to_h.transform_keys(&:to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Event-name patterns (Loam::Events.pattern_matches?) whose events may be
|
|
79
|
+
# pushed to the browser over SSE (Loam::EventStream). DEFAULT OFF — an empty
|
|
80
|
+
# list means nothing reaches a browser; an app opts in explicitly, so a stray
|
|
81
|
+
# event never leaks by default.
|
|
82
|
+
def self.broadcast_events
|
|
83
|
+
@broadcast_events ||= []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.broadcast_events=(patterns)
|
|
87
|
+
@broadcast_events = Array(patterns).map(&:to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The locales content translations (Loam::Translatable) may be authored in —
|
|
91
|
+
# declared in the initializer (`Loam.locales = %w[en de pl]`), so the admin
|
|
92
|
+
# knows which languages to offer. A registry like the others; defaults to the
|
|
93
|
+
# single default locale.
|
|
94
|
+
def self.locales
|
|
95
|
+
@locales ||= [ default_locale ]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.locales=(codes)
|
|
99
|
+
@locales = Array(codes).map(&:to_s)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Job classes an app EXPLICITLY allows the scheduler to run, beyond the ones
|
|
103
|
+
# it registers via Loam::Scheduler.register. An allowlist (not "any
|
|
104
|
+
# ActiveJob") so a tenant admin can't schedule ActiveStorage::PurgeJob or a
|
|
105
|
+
# mailer. Declared in the initializer: `Loam.schedulable_jobs = %w[DigestJob]`.
|
|
106
|
+
def self.schedulable_jobs
|
|
107
|
+
@schedulable_jobs ||= []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.schedulable_jobs=(names)
|
|
111
|
+
@schedulable_jobs = Array(names).map(&:to_s)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.default_locale
|
|
115
|
+
(defined?(I18n) ? I18n.default_locale : :en).to_s
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The current request/job locale — content reads overlay onto it. Request
|
|
119
|
+
# state like the tenant (set in a before_action, reset with Loam::Current).
|
|
120
|
+
def self.locale
|
|
121
|
+
(Loam::Current.locale || default_locale).to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.locale=(code)
|
|
125
|
+
Loam::Current.locale = code&.to_s
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Known feature flags: `{ "beta_dashboard" => { default: false, description:
|
|
129
|
+
# "..." } }`, declared in the initializer and read by Loam::Features. A
|
|
130
|
+
# registry like the others — a flag with no row resolves to its declared
|
|
131
|
+
# default, and the admin can list EVERY known flag, not just toggled ones.
|
|
132
|
+
def self.feature_defaults
|
|
133
|
+
@feature_defaults ||= {}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Normalizes both levels: outer keys to strings, and each flag's own hash to
|
|
137
|
+
# symbol keys, so `{ "x" => { "default" => true } }` and
|
|
138
|
+
# `{ x: { default: true } }` behave identically.
|
|
139
|
+
def self.feature_defaults=(defaults)
|
|
140
|
+
@feature_defaults = defaults.to_h.each_with_object({}) do |(name, spec), out|
|
|
141
|
+
out[name.to_s] = spec.to_h.transform_keys(&:to_sym)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The public surface is on Loam itself — apps and agents write
|
|
147
|
+
# `Loam.on_tenant_created`, never `Loam::Lifecycle.on_tenant_created`.
|
|
148
|
+
def self.on_tenant_created(&block) = Lifecycle.on_tenant_created(&block)
|
|
149
|
+
def self.tenant_created_callbacks = Lifecycle.tenant_created_callbacks
|
|
150
|
+
def self.sync_tenants! = Lifecycle.sync_tenants!
|
|
151
|
+
def self.default_roles = Lifecycle.default_roles
|
|
152
|
+
def self.default_roles=(roles)
|
|
153
|
+
Lifecycle.default_roles = roles
|
|
154
|
+
end
|
|
155
|
+
def self.config_defaults = Lifecycle.config_defaults
|
|
156
|
+
def self.config_defaults=(defaults)
|
|
157
|
+
Lifecycle.config_defaults = defaults
|
|
158
|
+
end
|
|
159
|
+
def self.feature_defaults = Lifecycle.feature_defaults
|
|
160
|
+
def self.feature_defaults=(defaults)
|
|
161
|
+
Lifecycle.feature_defaults = defaults
|
|
162
|
+
end
|
|
163
|
+
def self.broadcast_events = Lifecycle.broadcast_events
|
|
164
|
+
def self.broadcast_events=(patterns)
|
|
165
|
+
Lifecycle.broadcast_events = patterns
|
|
166
|
+
end
|
|
167
|
+
def self.locales = Lifecycle.locales
|
|
168
|
+
def self.locales=(codes)
|
|
169
|
+
Lifecycle.locales = codes
|
|
170
|
+
end
|
|
171
|
+
def self.schedulable_jobs = Lifecycle.schedulable_jobs
|
|
172
|
+
def self.schedulable_jobs=(names)
|
|
173
|
+
Lifecycle.schedulable_jobs = names
|
|
174
|
+
end
|
|
175
|
+
def self.default_locale = Lifecycle.default_locale
|
|
176
|
+
def self.locale = Lifecycle.locale
|
|
177
|
+
def self.locale=(code)
|
|
178
|
+
Lifecycle.locale = code
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Loam's own UI strings (the framework chrome). Shipped inside lib/ so the gem
|
|
2
|
+
# packages it, and added to the app's I18n load path by Loam::Engine. An app
|
|
3
|
+
# localizes Loam by adding its own `config/locales/loam.<locale>.yml` overriding
|
|
4
|
+
# these keys — distinct from Loam::Translatable, which localizes record DATA.
|
|
5
|
+
# Entity and field names come from Rails' own activerecord.models /
|
|
6
|
+
# activerecord.attributes (via model_name.human / human_attribute_name).
|
|
7
|
+
en:
|
|
8
|
+
loam:
|
|
9
|
+
chrome:
|
|
10
|
+
tenant: "Tenant"
|
|
11
|
+
user: "User"
|
|
12
|
+
sign_out: "Sign out"
|
|
13
|
+
search: "Search"
|
|
14
|
+
nav:
|
|
15
|
+
dashboard: "Dashboard"
|
|
16
|
+
notifications: "Notifications"
|
|
17
|
+
api_tokens: "API tokens"
|
|
18
|
+
security: "Security"
|
|
19
|
+
approvals: "Approvals"
|
|
20
|
+
settings: "Settings"
|
|
21
|
+
features: "Features"
|
|
22
|
+
rules: "Rules"
|
|
23
|
+
sso: "SSO"
|
|
24
|
+
dictionaries: "Dictionaries"
|
|
25
|
+
tasks: "Tasks"
|
|
26
|
+
schedules: "Schedules"
|
|
27
|
+
deliveries: "Deliveries"
|
|
28
|
+
inbound: "Inbound"
|
|
29
|
+
api_docs: "API docs"
|
|
30
|
+
overrides: "Overrides"
|
|
31
|
+
actions:
|
|
32
|
+
new: "New %{name}"
|
|
33
|
+
new_title: "New %{name}"
|
|
34
|
+
edit_title: "Edit %{name} #%{id}"
|
|
35
|
+
show_title: "%{name} #%{id}"
|
|
36
|
+
deleted_title: "Deleted %{name}"
|
|
37
|
+
show: "Show"
|
|
38
|
+
edit: "Edit"
|
|
39
|
+
destroy: "Delete"
|
|
40
|
+
restore: "Restore"
|
|
41
|
+
back: "Back"
|
|
42
|
+
save: "Save"
|
|
43
|
+
translations: "Translations"
|
|
44
|
+
export_csv: "Export CSV"
|
|
45
|
+
import_csv: "Import CSV"
|
|
46
|
+
filter: "Filter"
|
|
47
|
+
deleted: "Deleted %{name}"
|
|
48
|
+
labels:
|
|
49
|
+
id: "ID"
|
|
50
|
+
actions: "Actions"
|
|
51
|
+
enrichments: "Enrichments"
|
|
52
|
+
bulk:
|
|
53
|
+
delete_selected: "Delete selected"
|
|
54
|
+
set_field: "Set field on selected"
|
|
55
|
+
export_selected: "Export selected"
|
|
56
|
+
apply: "Apply to selected"
|
|
57
|
+
confirm: "Apply to the selected rows?"
|
|
58
|
+
new_value: "new value (for Set field)"
|
|
59
|
+
views:
|
|
60
|
+
all: "All %{name} (no saved view)"
|
|
61
|
+
manage: "Manage views"
|
|
62
|
+
manage_saved: "Manage saved views"
|
|
63
|
+
save_current: "Save current view"
|
|
64
|
+
name_placeholder: "Name this view"
|
|
65
|
+
filter_placeholder: "Filter %{name}"
|
|
66
|
+
index:
|
|
67
|
+
incomplete: "Results may be incomplete — the custom-field index is rebuilding in the background."
|
|
68
|
+
empty: "No records yet."
|
|
69
|
+
pagination:
|
|
70
|
+
previous: "← Previous"
|
|
71
|
+
next: "Next →"
|
|
72
|
+
page: "Page %{page}"
|
|
73
|
+
flash:
|
|
74
|
+
created: "%{name} created."
|
|
75
|
+
updated: "%{name} updated."
|
|
76
|
+
destroyed: "%{name} deleted."
|
|
77
|
+
restored: "%{name} restored."
|
|
78
|
+
form:
|
|
79
|
+
read_only: "(read-only for your role)"
|
|
80
|
+
attach_files: "Attach files"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Loam
|
|
4
|
+
module Mcp
|
|
5
|
+
# The stdio transport for the Loam MCP server (L-302): newline-delimited
|
|
6
|
+
# JSON-RPC over stdin/stdout, the MCP-spec stdio binding. Authenticates ONCE
|
|
7
|
+
# from LOAM_MCP_TOKEN (a Loam API token — never a tool argument, so it can't
|
|
8
|
+
# land in an agent transcript), then runs every message inside that token's
|
|
9
|
+
# tenant + actor. Nothing but JSON-RPC is written to stdout; logs go to stderr
|
|
10
|
+
# (wired in the rake task) — a stray stdout line corrupts the stream.
|
|
11
|
+
class Server
|
|
12
|
+
def initialize(input: $stdin, output: $stdout, token: ENV["LOAM_MCP_TOKEN"])
|
|
13
|
+
@input = input
|
|
14
|
+
@output = output
|
|
15
|
+
@token = token
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
api_token = Loam::ApiToken.authenticate(@token)
|
|
20
|
+
return fail_auth unless api_token
|
|
21
|
+
|
|
22
|
+
tenant = api_token.tenant
|
|
23
|
+
actor = api_token.user
|
|
24
|
+
|
|
25
|
+
@input.each_line do |line|
|
|
26
|
+
line = line.strip
|
|
27
|
+
next if line.empty?
|
|
28
|
+
|
|
29
|
+
request = parse(line)
|
|
30
|
+
next write(Loam::Mcp.err(nil, -32700, "parse error")) if request.nil?
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
response = Loam.as_tenant(tenant, actor: actor) { Loam::Mcp.handle_jsonrpc(request) }
|
|
34
|
+
write(response) if response
|
|
35
|
+
ensure
|
|
36
|
+
Loam::Current.reset
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parse(line)
|
|
44
|
+
JSON.parse(line)
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def write(message)
|
|
50
|
+
@output.puts(JSON.generate(message))
|
|
51
|
+
@output.flush
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fail_auth
|
|
55
|
+
write(Loam::Mcp.err(nil, -32001, "authentication failed: set LOAM_MCP_TOKEN to a valid Loam API token"))
|
|
56
|
+
exit(1)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/loam/mcp.rb
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# An MCP (Model Context Protocol) server surface exposing Loam to an AI agent —
|
|
3
|
+
# tools-only v1 (L-302). It lets an agent DISCOVER the domain (entities, schema,
|
|
4
|
+
# policy, workflow) and READ tenant-scoped records, and PROPOSE writes that are
|
|
5
|
+
# STAGED for human approval — never committed. Everything runs inside the
|
|
6
|
+
# tenant + actor of the API token the server authenticated with; whatever that
|
|
7
|
+
# user may do, no more. Approval authority stays with a human in the admin.
|
|
8
|
+
#
|
|
9
|
+
# Two layers: the tool methods (pure, assume Loam::Current is established) and
|
|
10
|
+
# `handle_jsonrpc` (a pure JSON-RPC dispatcher for initialize / tools/list /
|
|
11
|
+
# tools/call). The stdio transport (newline-delimited JSON-RPC) lives in
|
|
12
|
+
# Loam::Mcp::Server.
|
|
13
|
+
#
|
|
14
|
+
# SECURITY posture, reusing Loam's existing gates:
|
|
15
|
+
# * entity names resolve against an allowlist (API-exposed TenantRecord
|
|
16
|
+
# descendants), never a bare constantize;
|
|
17
|
+
# * query filters/order are whitelisted to real columns (or a known custom
|
|
18
|
+
# field, which carries the L-711 read-ACL); a filter on a field the role
|
|
19
|
+
# can't read is refused (no inference oracle);
|
|
20
|
+
# * query output emits only policy-readable fields per record (encrypted
|
|
21
|
+
# values decrypted, blind-index columns dropped);
|
|
22
|
+
# * a staged write accepts only policy-writable columns, refuses the workflow
|
|
23
|
+
# column (that goes through a transition), and refuses id/tenant_id/
|
|
24
|
+
# lock_version.
|
|
25
|
+
module Mcp
|
|
26
|
+
class ToolError < StandardError; end
|
|
27
|
+
|
|
28
|
+
PROTOCOL_VERSION = "2025-06-18".freeze
|
|
29
|
+
MAX_LIMIT = 100
|
|
30
|
+
FILTER_OPS = %w[eq neq contains gt gte lt lte present].freeze
|
|
31
|
+
|
|
32
|
+
TOOLS = [
|
|
33
|
+
{
|
|
34
|
+
name: "list_entities",
|
|
35
|
+
description: "List the business entities available in this Loam tenant.",
|
|
36
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "describe_entity",
|
|
40
|
+
description: "Describe one entity: its columns and types, custom fields, workflow, and which fields the current role may read/write.",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: { entity: { type: "string", description: "Entity name, e.g. \"Equipment\"." } },
|
|
44
|
+
required: [ "entity" ], additionalProperties: false
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "query_entity",
|
|
49
|
+
description: "Read tenant-scoped records of an entity. Returns only fields the current role may read. Filters and sort are whitelisted; limit is capped at 100.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
entity: { type: "string" },
|
|
54
|
+
filters: {
|
|
55
|
+
type: "array",
|
|
56
|
+
items: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
field: { type: "string" },
|
|
60
|
+
op: { type: "string", enum: FILTER_OPS },
|
|
61
|
+
value: {}
|
|
62
|
+
},
|
|
63
|
+
required: [ "field" ], additionalProperties: false
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
order: { type: "string", description: "A real column name to sort by." },
|
|
67
|
+
dir: { type: "string", enum: [ "asc", "desc" ] },
|
|
68
|
+
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT }
|
|
69
|
+
},
|
|
70
|
+
required: [ "entity" ], additionalProperties: false
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "stage_write",
|
|
75
|
+
description: "PROPOSE an update to one record. It is staged as a Loam PendingAction for a human manager to approve — it does NOT take effect until approved. Only policy-writable columns are accepted; the workflow column is refused (use a transition).",
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
entity: { type: "string" },
|
|
80
|
+
id: { type: "integer" },
|
|
81
|
+
changes: { type: "object", description: "field => new value, real writable columns only." }
|
|
82
|
+
},
|
|
83
|
+
required: [ "entity", "id", "changes" ], additionalProperties: false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
].freeze
|
|
87
|
+
|
|
88
|
+
module_function
|
|
89
|
+
|
|
90
|
+
# --- entity allowlist ---
|
|
91
|
+
|
|
92
|
+
def entities
|
|
93
|
+
# Models are lazy-loaded (Zeitwerk), so `descendants` is only complete after
|
|
94
|
+
# an eager load — same as Loam::OpenApi's discovery.
|
|
95
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application)
|
|
96
|
+
Loam::TenantRecord.descendants
|
|
97
|
+
.reject { |model| model.name.blank? }
|
|
98
|
+
.select { |model| api_exposed?(model) }
|
|
99
|
+
.sort_by(&:name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def api_exposed?(model)
|
|
103
|
+
"Api::#{model.model_name.plural.camelize}Controller".safe_constantize.present?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def resolve!(entity)
|
|
107
|
+
name = entity.to_s
|
|
108
|
+
entities.find { |m| m.name == name || m.model_name.plural == name } ||
|
|
109
|
+
raise(ToolError, "unknown entity #{entity.inspect}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# --- tools ---
|
|
113
|
+
|
|
114
|
+
def list_entities
|
|
115
|
+
{ entities: entities.map { |m| { name: m.name, plural: m.model_name.plural } } }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def describe_entity(entity:)
|
|
119
|
+
model = resolve!(entity)
|
|
120
|
+
policy = Loam::Policy.for(model.new)
|
|
121
|
+
|
|
122
|
+
columns = model.columns.reject { |c| c.name.end_with?("_hash") }.map do |col|
|
|
123
|
+
{ name: col.name, type: col.sql_type_metadata.type.to_s,
|
|
124
|
+
readable: policy.readable?(col.name), writable: policy.writable?(col.name) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
custom = if model.respond_to?(:custom_field_definitions)
|
|
128
|
+
model.custom_field_definitions.map do |definition|
|
|
129
|
+
{ name: definition.name, type: definition.field_type,
|
|
130
|
+
readable: policy.custom_field_readable?(definition.name),
|
|
131
|
+
writable: policy.custom_field_writable?(definition.name) }
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
[]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
workflow = if model.respond_to?(:loam_workflow) && model.loam_workflow
|
|
138
|
+
wf = model.loam_workflow
|
|
139
|
+
{ column: wf.column, states: wf.states,
|
|
140
|
+
transitions: wf.transitions.values.map { |t| { name: t.name.to_s, from: t.from, to: t.to, roles: Array(t.roles).map(&:to_s) } } }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
{ name: model.name, columns: columns, custom_fields: custom, workflow: workflow }.compact
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def query_entity(entity:, filters: [], order: nil, dir: "asc", limit: MAX_LIMIT)
|
|
147
|
+
model = resolve!(entity)
|
|
148
|
+
policy = Loam::Policy.for(model.new)
|
|
149
|
+
scope = apply_filters(model, model.all, filters, policy)
|
|
150
|
+
scope = apply_order(model, scope, order, dir)
|
|
151
|
+
capped = [ [ limit.to_i, 1 ].max, MAX_LIMIT ].min
|
|
152
|
+
|
|
153
|
+
records = scope.limit(capped).map { |record| serialize(record, policy) }
|
|
154
|
+
{ entity: model.name, count: records.size, records: records }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def stage_write(entity:, id:, changes:)
|
|
158
|
+
model = resolve!(entity)
|
|
159
|
+
record = model.find(id) # tenant-scoped
|
|
160
|
+
policy = Loam::Policy.for(record)
|
|
161
|
+
workflow_column = model.respond_to?(:loam_workflow) ? model.loam_workflow&.column.to_s : nil
|
|
162
|
+
|
|
163
|
+
clean = {}
|
|
164
|
+
(changes || {}).each do |field, value|
|
|
165
|
+
field = field.to_s
|
|
166
|
+
raise ToolError, "#{field} cannot be set" if %w[id tenant_id lock_version].include?(field)
|
|
167
|
+
raise ToolError, "#{field} changes go through a workflow transition, not a direct write" if field == workflow_column
|
|
168
|
+
|
|
169
|
+
if model.column_names.include?(field)
|
|
170
|
+
raise ToolError, "#{field} is not writable for this role" unless policy.writable?(field)
|
|
171
|
+
clean[field] = value
|
|
172
|
+
elsif custom_field?(model, field)
|
|
173
|
+
raise ToolError, "custom-field writes over MCP are not supported in v1"
|
|
174
|
+
else
|
|
175
|
+
raise ToolError, "unknown field #{field.inspect}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
raise ToolError, "no writable changes" if clean.empty?
|
|
179
|
+
|
|
180
|
+
action = Loam::PendingActions.stage(
|
|
181
|
+
summary: "MCP proposal: update #{model.name}##{id}",
|
|
182
|
+
on: record, action: :update, changes: clean
|
|
183
|
+
)
|
|
184
|
+
{ staged: true, pending_action_id: action.id, summary: action.summary, changes: clean,
|
|
185
|
+
note: "Staged for human approval — not applied until a manager approves it." }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# --- protocol ---
|
|
189
|
+
|
|
190
|
+
def call_tool(name, args)
|
|
191
|
+
kwargs = (args || {}).transform_keys(&:to_sym)
|
|
192
|
+
case name
|
|
193
|
+
when "list_entities" then list_entities
|
|
194
|
+
when "describe_entity" then describe_entity(**kwargs.slice(:entity))
|
|
195
|
+
when "query_entity" then query_entity(**kwargs.slice(:entity, :filters, :order, :dir, :limit))
|
|
196
|
+
when "stage_write" then stage_write(**kwargs.slice(:entity, :id, :changes))
|
|
197
|
+
else raise ToolError, "unknown tool #{name.inspect}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Pure JSON-RPC dispatch. Returns a response Hash, or nil for a notification
|
|
202
|
+
# (no reply). Never raises for tool faults — those become an isError result.
|
|
203
|
+
def handle_jsonrpc(request)
|
|
204
|
+
id = request["id"]
|
|
205
|
+
case request["method"]
|
|
206
|
+
when "initialize"
|
|
207
|
+
ok(id, protocolVersion: request.dig("params", "protocolVersion") || PROTOCOL_VERSION,
|
|
208
|
+
capabilities: { tools: {} },
|
|
209
|
+
serverInfo: { name: "loam", version: Loam::VERSION })
|
|
210
|
+
when "tools/list"
|
|
211
|
+
ok(id, tools: TOOLS)
|
|
212
|
+
when "tools/call"
|
|
213
|
+
begin
|
|
214
|
+
data = call_tool(request.dig("params", "name"), request.dig("params", "arguments"))
|
|
215
|
+
ok(id, content: [ { type: "text", text: JSON.generate(data) } ])
|
|
216
|
+
rescue ToolError, Loam::Error, ActiveRecord::RecordNotFound => error
|
|
217
|
+
ok(id, isError: true, content: [ { type: "text", text: error.message } ])
|
|
218
|
+
end
|
|
219
|
+
when "notifications/initialized", "notifications/cancelled", nil
|
|
220
|
+
nil # notifications get no response
|
|
221
|
+
else
|
|
222
|
+
err(id, -32601, "method not found: #{request["method"]}")
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# --- internals ---
|
|
227
|
+
|
|
228
|
+
def apply_filters(model, scope, filters, policy)
|
|
229
|
+
Array(filters).each do |raw|
|
|
230
|
+
field = raw["field"].to_s
|
|
231
|
+
op = (raw["op"] || "eq").to_s
|
|
232
|
+
raise ToolError, "unknown op #{op.inspect}" unless FILTER_OPS.include?(op)
|
|
233
|
+
value = raw["value"]
|
|
234
|
+
|
|
235
|
+
if model.column_names.include?(field)
|
|
236
|
+
raise ToolError, "#{field} is not readable for this role" unless policy.readable?(field)
|
|
237
|
+
scope = column_filter(model, scope, field, op, value)
|
|
238
|
+
elsif custom_field?(model, field)
|
|
239
|
+
# CustomFieldIndex.filter carries its own L-711 read-ACL for the current
|
|
240
|
+
# actor; surface its refusal as a clean tool error.
|
|
241
|
+
begin
|
|
242
|
+
scope = scope.merge(Loam::CustomFieldIndex.filter(model, field, op, value))
|
|
243
|
+
rescue Loam::FieldAccessError => error
|
|
244
|
+
raise ToolError, error.message
|
|
245
|
+
end
|
|
246
|
+
else
|
|
247
|
+
raise ToolError, "unknown field #{field.inspect}"
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
scope
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def column_filter(model, scope, field, op, value)
|
|
254
|
+
column = model.connection.quote_column_name(field) # field is a real column name (whitelisted)
|
|
255
|
+
case op
|
|
256
|
+
when "eq" then scope.where(field => value)
|
|
257
|
+
when "neq" then scope.where.not(field => value)
|
|
258
|
+
when "contains" then scope.where("#{column} LIKE ?", "%#{value.to_s.gsub(/[\\%_]/) { |c| "\\#{c}" }}%")
|
|
259
|
+
when "present" then scope.where.not(field => [ nil, "" ])
|
|
260
|
+
when "gt" then scope.where("#{column} > ?", value)
|
|
261
|
+
when "gte" then scope.where("#{column} >= ?", value)
|
|
262
|
+
when "lt" then scope.where("#{column} < ?", value)
|
|
263
|
+
when "lte" then scope.where("#{column} <= ?", value)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def apply_order(model, scope, order, dir)
|
|
268
|
+
return scope.order(id: :asc) if order.blank?
|
|
269
|
+
raise ToolError, "unknown sort column #{order.inspect}" unless model.column_names.include?(order.to_s)
|
|
270
|
+
|
|
271
|
+
direction = dir.to_s.casecmp("desc").zero? ? :desc : :asc
|
|
272
|
+
scope.reorder(order.to_s => direction)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def serialize(record, policy)
|
|
276
|
+
model = record.class
|
|
277
|
+
encrypted = model.respond_to?(:loam_encrypted_attributes) ? model.loam_encrypted_attributes.map(&:to_s) : []
|
|
278
|
+
|
|
279
|
+
json = {}
|
|
280
|
+
policy.readable_fields(model.column_names).each do |col|
|
|
281
|
+
next if col.end_with?("_hash")
|
|
282
|
+
json[col] = encrypted.include?(col) ? record.public_send(col) : record[col]
|
|
283
|
+
end
|
|
284
|
+
if model.respond_to?(:custom_field_definitions)
|
|
285
|
+
model.custom_field_definitions.each do |definition|
|
|
286
|
+
json["cf_#{definition.name}"] = record.custom_field(definition.name) if policy.custom_field_readable?(definition.name)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
json
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def custom_field?(model, field)
|
|
293
|
+
model.respond_to?(:custom_field_definitions) && model.custom_field_definitions.exists?(name: field)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def ok(id, **result)
|
|
297
|
+
{ "jsonrpc" => "2.0", "id" => id, "result" => result }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def err(id, code, message)
|
|
301
|
+
{ "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# The one way to tell someone something inside the app.
|
|
3
|
+
#
|
|
4
|
+
# Loam::Notifications.notify(user, title: "Order approved", source: order)
|
|
5
|
+
# Loam::Notifications.notify_role(:manager, title: "New damage report")
|
|
6
|
+
#
|
|
7
|
+
# Records land in the CURRENT tenant (Loam::Notification is tenant-scoped),
|
|
8
|
+
# so a notification can never be delivered across a tenant boundary.
|
|
9
|
+
#
|
|
10
|
+
# The intended pattern is event -> notification: subscribe to a domain event
|
|
11
|
+
# in config/initializers/loam.rb and notify from there, instead of scattering
|
|
12
|
+
# delivery calls through models and controllers.
|
|
13
|
+
module Notifications
|
|
14
|
+
# Returns the created notifications. Accepts one user or many; `source` is
|
|
15
|
+
# any record the message is about, stored as type + id.
|
|
16
|
+
def self.notify(recipients, title:, body: nil, source: nil)
|
|
17
|
+
Array(recipients).map do |user|
|
|
18
|
+
Notification.create!(
|
|
19
|
+
user: user,
|
|
20
|
+
title: title,
|
|
21
|
+
body: body,
|
|
22
|
+
source_type: source&.class&.name,
|
|
23
|
+
source_id: source&.id
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Everyone holding `role` in the current tenant. Memberships are
|
|
29
|
+
# tenant-scoped, so this cannot reach into another tenant's staff.
|
|
30
|
+
def self.notify_role(role, title:, body: nil, source: nil)
|
|
31
|
+
recipients = Membership.where(role: role.to_s).includes(:user).map(&:user)
|
|
32
|
+
|
|
33
|
+
notify(recipients, title: title, body: body, source: source)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|