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.
Files changed (245) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +71 -0
  3. data/LICENSE +21 -0
  4. data/README.md +177 -0
  5. data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
  6. data/app/jobs/loam/event_delivery_job.rb +26 -0
  7. data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
  8. data/app/jobs/loam/webhook_delivery_job.rb +59 -0
  9. data/app/models/loam/api_token.rb +40 -0
  10. data/app/models/loam/audit_record.rb +18 -0
  11. data/app/models/loam/auth_attempt.rb +13 -0
  12. data/app/models/loam/business_rule.rb +49 -0
  13. data/app/models/loam/business_rule_run.rb +27 -0
  14. data/app/models/loam/comment.rb +28 -0
  15. data/app/models/loam/config.rb +27 -0
  16. data/app/models/loam/custom_field_value.rb +18 -0
  17. data/app/models/loam/dashboard_widget.rb +16 -0
  18. data/app/models/loam/dictionary.rb +20 -0
  19. data/app/models/loam/dictionary_entry.rb +31 -0
  20. data/app/models/loam/event_delivery.rb +38 -0
  21. data/app/models/loam/field_definition.rb +48 -0
  22. data/app/models/loam/inbound_webhook_delivery.rb +24 -0
  23. data/app/models/loam/inbound_webhook_source.rb +55 -0
  24. data/app/models/loam/membership.rb +27 -0
  25. data/app/models/loam/mfa_credential.rb +114 -0
  26. data/app/models/loam/notification.rb +36 -0
  27. data/app/models/loam/pending_action.rb +208 -0
  28. data/app/models/loam/perspective.rb +104 -0
  29. data/app/models/loam/progress_job.rb +103 -0
  30. data/app/models/loam/record_lock.rb +19 -0
  31. data/app/models/loam/scheduled_job.rb +42 -0
  32. data/app/models/loam/search_token.rb +9 -0
  33. data/app/models/loam/sso_identity.rb +13 -0
  34. data/app/models/loam/sso_provider.rb +35 -0
  35. data/app/models/loam/tenant.rb +20 -0
  36. data/app/models/loam/translation.rb +17 -0
  37. data/app/models/loam/webhook_endpoint.rb +26 -0
  38. data/app/views/loam/attachments/_attachments.html.erb +16 -0
  39. data/app/views/loam/comments/_comments.html.erb +24 -0
  40. data/app/views/loam/custom_fields/_fields.html.erb +35 -0
  41. data/lib/generators/loam/entity/entity_generator.rb +160 -0
  42. data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
  43. data/lib/generators/loam/entity/templates/controller.rb +181 -0
  44. data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
  45. data/lib/generators/loam/entity/templates/migration.rb +26 -0
  46. data/lib/generators/loam/entity/templates/model.rb +27 -0
  47. data/lib/generators/loam/entity/templates/policy.rb +10 -0
  48. data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
  49. data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
  50. data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
  51. data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
  52. data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
  53. data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
  54. data/lib/generators/loam/install/install_generator.rb +288 -0
  55. data/lib/generators/loam/install/templates/AGENTS.md +341 -0
  56. data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
  57. data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
  58. data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
  59. data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
  60. data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
  61. data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
  62. data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
  63. data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
  64. data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
  65. data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
  66. data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
  67. data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
  68. data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
  69. data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
  70. data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
  71. data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
  72. data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
  73. data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
  74. data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
  75. data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
  76. data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
  77. data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
  78. data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
  79. data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
  80. data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
  81. data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
  82. data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
  83. data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
  84. data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
  85. data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
  86. data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
  87. data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
  88. data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
  89. data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
  90. data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
  91. data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
  92. data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
  93. data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
  94. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
  95. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
  96. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
  97. data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
  98. data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
  99. data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
  100. data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
  101. data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
  102. data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
  103. data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
  104. data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
  105. data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
  106. data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
  107. data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
  108. data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
  109. data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
  110. data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
  111. data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
  112. data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
  113. data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
  114. data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
  115. data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
  116. data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
  117. data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
  118. data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
  119. data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
  120. data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
  121. data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
  122. data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
  123. data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
  124. data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
  125. data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
  126. data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
  127. data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
  128. data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
  129. data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
  130. data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
  131. data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
  132. data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
  133. data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
  134. data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
  135. data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
  136. data/lib/generators/loam/install/templates/admin.css +29 -0
  137. data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
  138. data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
  139. data/lib/generators/loam/install/templates/import_job.rb +25 -0
  140. data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
  141. data/lib/generators/loam/install/templates/initializer.rb +218 -0
  142. data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
  143. data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
  144. data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
  145. data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
  146. data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
  147. data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
  148. data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
  149. data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
  150. data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
  151. data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
  152. data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
  153. data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
  154. data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
  155. data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
  156. data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
  157. data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
  158. data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
  159. data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
  160. data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
  161. data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
  162. data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
  163. data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
  164. data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
  165. data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
  166. data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
  167. data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
  168. data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
  169. data/lib/generators/loam/install/templates/user.rb +21 -0
  170. data/lib/generators/loam/primary_key_options.rb +101 -0
  171. data/lib/loam/attachable.rb +27 -0
  172. data/lib/loam/auditable.rb +67 -0
  173. data/lib/loam/auth_throttle.rb +83 -0
  174. data/lib/loam/base32.rb +23 -0
  175. data/lib/loam/bulk.rb +59 -0
  176. data/lib/loam/business_rules/actions.rb +92 -0
  177. data/lib/loam/business_rules/condition.rb +102 -0
  178. data/lib/loam/business_rules.rb +123 -0
  179. data/lib/loam/commentable.rb +22 -0
  180. data/lib/loam/configs.rb +115 -0
  181. data/lib/loam/cron.rb +98 -0
  182. data/lib/loam/csv.rb +18 -0
  183. data/lib/loam/current.rb +21 -0
  184. data/lib/loam/custom_field_index.rb +262 -0
  185. data/lib/loam/custom_fields.rb +114 -0
  186. data/lib/loam/dashboard.rb +17 -0
  187. data/lib/loam/dictionaries.rb +55 -0
  188. data/lib/loam/durable_events.rb +174 -0
  189. data/lib/loam/encryptable.rb +145 -0
  190. data/lib/loam/encryption/cipher.rb +75 -0
  191. data/lib/loam/encryption/key_provider.rb +43 -0
  192. data/lib/loam/encryption.rb +125 -0
  193. data/lib/loam/engine.rb +57 -0
  194. data/lib/loam/enrichers.rb +105 -0
  195. data/lib/loam/errors.rb +49 -0
  196. data/lib/loam/eval.rb +47 -0
  197. data/lib/loam/event_stream.rb +80 -0
  198. data/lib/loam/eventful.rb +45 -0
  199. data/lib/loam/events.rb +51 -0
  200. data/lib/loam/export.rb +74 -0
  201. data/lib/loam/features.rb +75 -0
  202. data/lib/loam/generated_key.rb +37 -0
  203. data/lib/loam/import.rb +168 -0
  204. data/lib/loam/inbound_webhooks.rb +125 -0
  205. data/lib/loam/lifecycle.rb +180 -0
  206. data/lib/loam/locales/loam.en.yml +80 -0
  207. data/lib/loam/mcp/server.rb +60 -0
  208. data/lib/loam/mcp.rb +304 -0
  209. data/lib/loam/notifications.rb +36 -0
  210. data/lib/loam/open_api.rb +194 -0
  211. data/lib/loam/overrides.rb +110 -0
  212. data/lib/loam/pending_actions.rb +60 -0
  213. data/lib/loam/permissions.rb +79 -0
  214. data/lib/loam/perspectives.rb +62 -0
  215. data/lib/loam/policy.rb +106 -0
  216. data/lib/loam/progress.rb +31 -0
  217. data/lib/loam/record_locks.rb +89 -0
  218. data/lib/loam/scheduler.rb +153 -0
  219. data/lib/loam/search/driver.rb +31 -0
  220. data/lib/loam/search/like_driver.rb +29 -0
  221. data/lib/loam/search/token_driver.rb +88 -0
  222. data/lib/loam/search.rb +27 -0
  223. data/lib/loam/searchable.rb +90 -0
  224. data/lib/loam/soft_deletable.rb +72 -0
  225. data/lib/loam/sso/claims.rb +10 -0
  226. data/lib/loam/sso/fake_provider.rb +51 -0
  227. data/lib/loam/sso/http_client.rb +42 -0
  228. data/lib/loam/sso/oidc_provider.rb +64 -0
  229. data/lib/loam/sso.rb +129 -0
  230. data/lib/loam/telemetry.rb +43 -0
  231. data/lib/loam/tenant_record.rb +36 -0
  232. data/lib/loam/test_helpers.rb +15 -0
  233. data/lib/loam/totp.rb +63 -0
  234. data/lib/loam/translatable.rb +88 -0
  235. data/lib/loam/undo.rb +126 -0
  236. data/lib/loam/version.rb +3 -0
  237. data/lib/loam/webhooks.rb +34 -0
  238. data/lib/loam/widgets.rb +77 -0
  239. data/lib/loam/workflow.rb +256 -0
  240. data/lib/loam.rb +122 -0
  241. data/lib/open-loam.rb +5 -0
  242. data/lib/tasks/loam.rake +171 -0
  243. data/lib/tasks/loam_eval.rake +32 -0
  244. data/lib/tasks/loam_mcp.rake +20 -0
  245. 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