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,18 @@
1
+ module Loam
2
+ # A read-model projection of ONE custom field of ONE record into typed,
3
+ # INDEXED columns — so filtering/sorting/searching on a custom field is
4
+ # index-backed instead of a per-row JSON extraction over the `custom_fields`
5
+ # column (which can't be indexed and full-scans at scale). One row per
6
+ # (record, custom field); maintained by Loam::CustomFieldIndex from the
7
+ # Loam::CustomFields save/destroy hooks. Tenant-scoped.
8
+ #
9
+ # The value is written into the column matching the field's declared type
10
+ # (value_number / value_boolean / value_datetime) AND always into value_text
11
+ # (its string form) for text ops (contains/present) and canonical equality.
12
+ class CustomFieldValue < Loam::TenantRecord
13
+ self.table_name = "loam_custom_field_values"
14
+
15
+ validates :indexable_type, :indexable_id, :field_key, presence: true
16
+ validates :field_key, uniqueness: { scope: %i[indexable_type indexable_id] }
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ module Loam
2
+ # A tenant's dashboard layout: which registered widgets appear, in what order.
3
+ # A manager arranges these on the Dashboard settings screen; the dashboard
4
+ # renders the active ones by position (see Loam::Dashboard). Tenant-scoped and
5
+ # audited.
6
+ class DashboardWidget < Loam::TenantRecord
7
+ self.table_name = "loam_dashboard_widgets"
8
+
9
+ include Loam::Auditable
10
+
11
+ validates :widget_key, presence: true, uniqueness: { scope: :tenant_id }
12
+
13
+ scope :active, -> { where(active: true) }
14
+ scope :ordered, -> { order(:position, :id) }
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ module Loam
2
+ # A per-tenant managed lookup list — a named set of entries (e.g.
3
+ # "damage_severity" → minor/major/critical). Admins curate the values without a
4
+ # code deploy, and a Loam::FieldDefinition of type "dictionary" can point a
5
+ # custom field at one. Tenant-scoped and audited like every Loam entity; see
6
+ # Loam::Dictionaries for the read API.
7
+ class Dictionary < Loam::TenantRecord
8
+ self.table_name = "loam_dictionaries"
9
+
10
+ include Loam::Auditable
11
+
12
+ has_many :entries, class_name: "Loam::DictionaryEntry",
13
+ foreign_key: :dictionary_id, dependent: :delete_all
14
+
15
+ validates :key, presence: true, uniqueness: { scope: :tenant_id }
16
+ validates :name, presence: true
17
+
18
+ normalizes :key, with: ->(key) { key.to_s.strip.presence }
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ module Loam
2
+ # One option in a Loam::Dictionary: a stored `value` (the code that lands in a
3
+ # record) plus display metadata (label, color, icon), an ordering `position`,
4
+ # a `is_default` flag, and an `active` switch (a retired option stops being
5
+ # offered without deleting the historical values already stored). Tenant-scoped
6
+ # and audited.
7
+ class DictionaryEntry < Loam::TenantRecord
8
+ self.table_name = "loam_dictionary_entries"
9
+
10
+ include Loam::Auditable
11
+
12
+ belongs_to :dictionary, class_name: "Loam::Dictionary"
13
+
14
+ validates :value, presence: true, uniqueness: { scope: :dictionary_id }
15
+ validates :label, presence: true
16
+
17
+ scope :active, -> { where(active: true) }
18
+ scope :ordered, -> { order(:position, :id) }
19
+
20
+ after_commit :clear_dictionary_cache
21
+ after_destroy_commit :clear_dictionary_cache
22
+
23
+ private
24
+
25
+ # A within-request edit must not read a stale cached list (the cache also
26
+ # resets per request, so this only matters when a write and a read share one).
27
+ def clear_dictionary_cache
28
+ Loam::Dictionaries.clear_cache
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ module Loam
2
+ # One durable delivery of one event to one persistent subscriber
3
+ # (see Loam::DurableEvents). The ROW is the durable record of intent: it is
4
+ # committed in the event's tenant at publish time, and the background job is
5
+ # only an accelerator. If the job is lost, the sweep re-enqueues from this row.
6
+ #
7
+ # Lifecycle: pending -> delivered (handler ran) | dead (handler removed, or
8
+ # MAX_ATTEMPTS exhausted). A pending row with next_attempt_at in the future is
9
+ # simply waiting out its backoff.
10
+ class EventDelivery < Loam::TenantRecord
11
+ self.table_name = "loam_event_deliveries"
12
+
13
+ STATUSES = %w[pending delivered dead].freeze
14
+
15
+ validates :subscriber_key, :event_name, presence: true
16
+ validates :status, inclusion: { in: STATUSES }
17
+
18
+ scope :pending, -> { where(status: "pending") }
19
+ scope :delivered, -> { where(status: "delivered") }
20
+ scope :dead, -> { where(status: "dead") }
21
+
22
+ # Rows ready for a (re)delivery attempt: pending and past their backoff gate.
23
+ scope :due, ->(now = Time.current) {
24
+ pending.where("next_attempt_at IS NULL OR next_attempt_at <= ?", now)
25
+ }
26
+
27
+ # The stored payload as a Hash regardless of adapter (Postgres jsonb returns
28
+ # a Hash; a text/json column may hand back a String).
29
+ def payload_hash
30
+ value = self[:payload]
31
+ return value if value.is_a?(Hash)
32
+
33
+ JSON.parse(value.to_s.presence || "{}")
34
+ rescue JSON::ParserError
35
+ {}
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ module Loam
2
+ # Runtime declaration of a migration-free field on a tenant-scoped entity —
3
+ # "entity_type Equipment gets a field called serial_number of type string,
4
+ # writable by managers only." Tenant-scoped like everything else: a field
5
+ # defined in one tenant is invisible to another's records of the same
6
+ # entity_type, and definitions are the ONLY way values in an entity's
7
+ # `custom_fields` json column get read/written (see Loam::CustomFields).
8
+ class FieldDefinition < Loam::TenantRecord
9
+ self.table_name = "loam_field_definitions"
10
+
11
+ FIELD_TYPES = %w[string text integer decimal boolean date datetime dictionary].freeze
12
+
13
+ validates :entity_type, presence: true
14
+ validates :name, presence: true, uniqueness: { scope: %i[tenant_id entity_type] }
15
+ validates :field_type, presence: true, inclusion: { in: FIELD_TYPES }
16
+ validate :dictionary_key_resolves, if: -> { field_type == "dictionary" }
17
+
18
+ # Field-level READ gate for the runtime field, mirroring writable_roles: an
19
+ # empty readable_roles means any member may read it; otherwise only the listed
20
+ # roles. Used by Loam::CustomFieldIndex (so a filter can't be an oracle on a
21
+ # restricted field) and Loam::Policy#custom_field_readable?.
22
+ def readable_by?(role)
23
+ roles = readable_roles
24
+ roles.blank? || (role.present? && roles.map(&:to_sym).include?(role.to_sym))
25
+ end
26
+
27
+ # The `config` json holds type-specific settings. For a "dictionary" field it
28
+ # carries the key of the Loam::Dictionary whose entries populate the select.
29
+ def dictionary_key
30
+ config.is_a?(Hash) ? config["dictionary"] : nil
31
+ end
32
+
33
+ def dictionary_key=(value)
34
+ base = config.is_a?(Hash) ? config : {}
35
+ self.config = base.merge("dictionary" => value.to_s.strip.presence)
36
+ end
37
+
38
+ private
39
+
40
+ def dictionary_key_resolves
41
+ if dictionary_key.blank?
42
+ errors.add(:dictionary_key, "is required for a dictionary field")
43
+ elsif Loam::Dictionaries.get(dictionary_key).nil?
44
+ errors.add(:dictionary_key, "must name an existing dictionary in this tenant")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,24 @@
1
+ module Loam
2
+ # One received inbound webhook. Two jobs: an idempotency ledger (a unique
3
+ # (source_id, external_id) index turns a replayed delivery into a no-op) and an
4
+ # audit/payload store — the raw body is kept here so an event subscriber reads
5
+ # it, keeping the published event payload scalar-clean (ids only).
6
+ class InboundWebhookDelivery < Loam::TenantRecord
7
+ self.table_name = "loam_inbound_webhook_deliveries"
8
+
9
+ belongs_to :source, class_name: "Loam::InboundWebhookSource", inverse_of: :deliveries
10
+
11
+ validates :external_id, presence: true
12
+ # (source_id, external_id) uniqueness is enforced by a DB index — the ledger
13
+ # must be race-safe, so we rely on the constraint, not a validation.
14
+
15
+ def payload_hash
16
+ value = self[:payload]
17
+ return value if value.is_a?(Hash)
18
+
19
+ JSON.parse(value.to_s.presence || "{}")
20
+ rescue JSON::ParserError
21
+ {}
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,55 @@
1
+ module Loam
2
+ # A registered external system allowed to POST webhooks INTO this tenant — the
3
+ # inbound sibling of Loam::WebhookEndpoint (which delivers events OUT). The
4
+ # `token` is the unguessable URL id (`/webhooks/:token`) that identifies the
5
+ # source; the `secret` is the HMAC key that AUTHENTICATES each call. Identity is
6
+ # not authority: rotating either is supported from the admin.
7
+ #
8
+ # On a verified delivery, Loam publishes `event_name` onto the domain event bus
9
+ # with a reference to the stored Loam::InboundWebhookDelivery, so durable
10
+ # subscribers (Loam::DurableEvents) react — the payload itself lives on the row.
11
+ class InboundWebhookSource < Loam::TenantRecord
12
+ self.table_name = "loam_inbound_webhook_sources"
13
+
14
+ DEFAULT_SIGNATURE_HEADER = "X-Loam-Signature".freeze
15
+ DEFAULT_TOLERANCE = 300 # seconds
16
+
17
+ has_many :deliveries, class_name: "Loam::InboundWebhookDelivery",
18
+ foreign_key: :source_id, dependent: :destroy, inverse_of: :source
19
+
20
+ validates :name, presence: true
21
+ validates :token, presence: true, uniqueness: true
22
+ validates :secret, presence: true
23
+ validates :event_name, presence: true,
24
+ format: { with: Loam::Events::NAME_FORMAT, message: "must follow domain.thing.happened" }
25
+
26
+ scope :active, -> { where(active: true) }
27
+
28
+ before_validation on: :create do
29
+ self.token ||= SecureRandom.hex(24)
30
+ self.secret ||= SecureRandom.hex(32)
31
+ self.signature_header = DEFAULT_SIGNATURE_HEADER if signature_header.blank?
32
+ self.active = true if active.nil?
33
+ end
34
+
35
+ # THE blessed cross-tenant lookup (see Loam::ApiToken.authenticate): a public
36
+ # inbound request arrives with no tenant context — the token in the URL is how
37
+ # it discovers its tenant. Establishes Loam::Current.tenant (never an actor:
38
+ # the sender is a machine, not a user) and returns the ACTIVE source, or nil.
39
+ def self.resolve(raw_token)
40
+ return nil if raw_token.blank?
41
+
42
+ source = unscoped.find_by(token: raw_token)
43
+ return nil unless source&.active?
44
+
45
+ Loam::Current.tenant = source.tenant
46
+ source
47
+ end
48
+
49
+ def signature_header_key = signature_header.presence || DEFAULT_SIGNATURE_HEADER
50
+ def tolerance = (timestamp_tolerance.presence || DEFAULT_TOLERANCE).to_i
51
+
52
+ def rotate_token! = update!(token: SecureRandom.hex(24))
53
+ def rotate_secret! = update!(secret: SecureRandom.hex(32))
54
+ end
55
+ end
@@ -0,0 +1,27 @@
1
+ module Loam
2
+ # Connects an actor (the host app's User) to a tenant with a role. Roles are
3
+ # plain strings ("manager", "employee", ...) — policies interpret them.
4
+ # Tenant-scoped like everything else: asking for someone's role always means
5
+ # "their role in the CURRENT tenant".
6
+ class Membership < Loam::TenantRecord
7
+ self.table_name = "loam_memberships"
8
+
9
+ belongs_to :user
10
+
11
+ validates :role, presence: true
12
+ validates :user_id, uniqueness: { scope: :tenant_id }
13
+
14
+ # The other blessed cross-tenant lookup (see Loam::ApiToken.authenticate).
15
+ # "Which tenants may this person enter?" is asked at login, before any
16
+ # tenant is chosen, so it cannot be answered from inside one — which is why
17
+ # it lives in the gem rather than in host app code, where reaching across
18
+ # tenants is a guardrail failure.
19
+ #
20
+ # Returns a Loam::Tenant relation, so callers can order/filter it further.
21
+ def self.tenants_for(user)
22
+ user_id = user.respond_to?(:id) ? user.id : user
23
+
24
+ Loam::Tenant.where(id: unscoped.where(user_id: user_id).select(:tenant_id)).order(:name)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,114 @@
1
+ require "bcrypt"
2
+
3
+ module Loam
4
+ # A user's multi-factor credential: a TOTP secret plus single-use recovery
5
+ # codes. Deliberately NOT tenant-scoped — MFA belongs to the PERSON, who spans
6
+ # tenants, and the second-factor challenge runs at login BEFORE any tenant is
7
+ # chosen. So the secret is encrypted under a USER-scoped key (Loam::Encryptable
8
+ # `scope:`), which decrypts in any tenant and with no tenant at all — the whole
9
+ # reason a per-tenant key would be a lockout bug here.
10
+ class MfaCredential < ApplicationRecord
11
+ include Loam::GeneratedKey
12
+ self.table_name = "loam_mfa_credentials"
13
+
14
+ RECOVERY_CODE_COUNT = 10
15
+
16
+ belongs_to :user
17
+
18
+ include Loam::Encryptable
19
+ encrypts :totp_secret, scope: ->(credential) { "user/#{credential.user_id}" }
20
+
21
+ # Recovery codes are stored HASHED (BCrypt), never in the clear: [{ "digest",
22
+ # "used_at" }]. The plaintext is shown once, at generation, and then only the
23
+ # user has it.
24
+ serialize :recovery_codes, coder: JSON, type: Array
25
+
26
+ validates :user_id, uniqueness: true
27
+
28
+ # The active credential for a user, or nil — nil while enrollment is pending
29
+ # (a secret exists but was never confirmed) so an un-activated credential
30
+ # never gates login.
31
+ def self.active_for(user)
32
+ return nil unless user
33
+
34
+ credential = find_by(user_id: user.id)
35
+ credential&.activated? ? credential : nil
36
+ end
37
+
38
+ def activated?
39
+ activated_at.present?
40
+ end
41
+
42
+ # Confirm enrollment against a CANDIDATE secret (held in the session, never
43
+ # written until proven) with a live code, then activate: adopt the secret,
44
+ # mint recovery codes, and record the confirming step so it cannot be
45
+ # replayed at the next login. Returns the plaintext codes (shown ONCE) or nil
46
+ # if the code is wrong. The old secret stays valid until this succeeds, so a
47
+ # half-finished re-enrollment never downgrades an active credential.
48
+ def activate_with!(candidate_secret, code)
49
+ step = Loam::Totp.matching_step(candidate_secret, code)
50
+ return nil unless step
51
+
52
+ self.totp_secret = candidate_secret
53
+ self.activated_at = Time.current
54
+ self.last_totp_step = step
55
+ plaintext = mint_recovery_codes!
56
+ save!
57
+ plaintext
58
+ end
59
+
60
+ # Verify a TOTP code AND consume its timestep, so a captured code cannot be
61
+ # replayed within its ~90s validity window (at login or at sudo). The lock +
62
+ # last_totp_step check closes the read-modify-write race of two concurrent
63
+ # submits. On SQLite `FOR UPDATE` is dropped (writer serialization + the
64
+ # re-check still hold); Postgres takes a real row lock.
65
+ def verify_totp(code)
66
+ return false unless activated?
67
+
68
+ with_lock do
69
+ step = Loam::Totp.matching_step(totp_secret, code)
70
+ if step && (last_totp_step.nil? || step > last_totp_step)
71
+ update!(last_totp_step: step)
72
+ true
73
+ else
74
+ false
75
+ end
76
+ end
77
+ end
78
+
79
+ # Consume a recovery code: valid exactly once. with_lock reloads and
80
+ # re-checks inside the transaction, so two concurrent submits of the same
81
+ # code cannot both succeed. Constant-time per candidate via BCrypt.
82
+ def consume_recovery_code(code)
83
+ code = code.to_s.strip.downcase
84
+ return false if code.empty?
85
+
86
+ with_lock do
87
+ entry = recovery_codes.find { |e| e["used_at"].nil? && BCrypt::Password.new(e["digest"]) == code }
88
+ if entry
89
+ entry["used_at"] = Time.current.iso8601
90
+ save!
91
+ true
92
+ else
93
+ false
94
+ end
95
+ end
96
+ end
97
+
98
+ def unused_recovery_code_count
99
+ recovery_codes.count { |e| e["used_at"].nil? }
100
+ end
101
+
102
+ def provisioning_uri(issuer:)
103
+ Loam::Totp.provisioning_uri(totp_secret, account: user.email, issuer: issuer)
104
+ end
105
+
106
+ private
107
+
108
+ def mint_recovery_codes!
109
+ plaintext = Array.new(RECOVERY_CODE_COUNT) { SecureRandom.alphanumeric(10).downcase }
110
+ self.recovery_codes = plaintext.map { |code| { "digest" => BCrypt::Password.create(code), "used_at" => nil } }
111
+ plaintext
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,36 @@
1
+ module Loam
2
+ # One in-app message for one recipient (`user`) in one tenant. The same
3
+ # person in two tenants has two separate inboxes — like everything else in
4
+ # Loam, "your notifications" always means "in the current tenant".
5
+ #
6
+ # Plumbing, not business data: not audited, the same way Loam::AuditRecord
7
+ # isn't. It IS evented, though — creating one publishes "loam.notification.created"
8
+ # (carrying the recipient's user_id) so Loam::EventStream can push it to that
9
+ # user's browser and the bell updates live. Created through
10
+ # Loam::Notifications.notify, read through the admin bell.
11
+ class Notification < Loam::TenantRecord
12
+ self.table_name = "loam_notifications"
13
+
14
+ belongs_to :user
15
+
16
+ validates :title, presence: true
17
+
18
+ scope :unread, -> { where(read_at: nil) }
19
+
20
+ # The one signal the real-time bell listens for. Only id + recipient ride the
21
+ # event (Loam::Events also stamps tenant_id); no message content is broadcast.
22
+ after_create_commit do
23
+ Loam::Events.publish("loam.notification.created", id: id, user_id: user_id)
24
+ end
25
+
26
+ # What the notification is about, if anything (source_type/source_id) —
27
+ # stored rather than associated, so a notification survives its subject.
28
+
29
+ def read? = read_at.present?
30
+
31
+ def mark_read!
32
+ update!(read_at: Time.current) unless read?
33
+ self
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,208 @@
1
+ module Loam
2
+ # A staged mutation awaiting human approval. When a caller runs under
3
+ # confirm-mode (an MCP tool acting for an AI agent — see Loam.mutation_mode), a
4
+ # write is staged HERE as a preview instead of committing; a manager approves
5
+ # or rejects, and only approval executes it. This is Loam's thesis made
6
+ # concrete: agent-triggered writes are gate-able.
7
+ #
8
+ # The approval gate IS a workflow (Loam::Workflow): pending → (manager) approve
9
+ # / reject → executed / failed. "Who may approve" lives in one declarative,
10
+ # role-gated place. The proposed changes are encrypted at rest (Loam::Encryptable),
11
+ # because a staged change to an encrypted target field would otherwise sit here
12
+ # — and in this row's own audit — as plaintext, reopening the leak L-901 closed.
13
+ class PendingAction < Loam::TenantRecord
14
+ self.table_name = "loam_pending_actions"
15
+
16
+ include Loam::Auditable
17
+ include Loam::Encryptable
18
+ include Loam::Workflow
19
+
20
+ belongs_to :actor, class_name: "User", optional: true
21
+ belongs_to :reviewer, class_name: "User", foreign_key: :reviewed_by_id, optional: true
22
+
23
+ # Encrypted JSON. Encryptable also makes Auditable redact `changeset` to
24
+ # "[encrypted]" automatically, so the proposal never lands in the audit trail.
25
+ encrypts :changeset
26
+
27
+ validates :action_type, :summary, :idempotency_key, presence: true
28
+ # Only ONE pending row per proposal per tenant — a rejected/executed row with
29
+ # the same key may coexist so the proposal can be re-staged later. `conditions`
30
+ # scopes the existence check to pending rows (so a rejected row does not block
31
+ # a re-stage); `if: :pending?` skips the check on a non-pending row's own saves.
32
+ # Both mirror the partial DB index (WHERE status = 'pending').
33
+ validates :idempotency_key,
34
+ uniqueness: { scope: :tenant_id, conditions: -> { where(status: "pending") } },
35
+ if: :pending?
36
+
37
+ # The review queue: still awaiting a decision.
38
+ scope :pending, -> { where(status: "pending") }
39
+
40
+ # status is a workflow column, but a plain column underneath — guard it so a
41
+ # direct `update!(status: "executed")` cannot skip the manager role gate and
42
+ # execute_change!. Only a `to_*` transition (which sets the flag below) may
43
+ # move it. `update_column` / raw SQL remain the deliberate low-level escape
44
+ # hatch, like `.unscoped`.
45
+ validate :status_changes_only_through_a_transition, on: :update
46
+ # ...and a new row must START pending — otherwise create!(status: "executed")
47
+ # would forge an already-approved/executed record without any transition.
48
+ validate :status_starts_at_the_initial_state, on: :create
49
+
50
+ # Transitions are prefixed `to_` so their generated bang methods do not
51
+ # collide with the public approve!/reject!(by:) API below.
52
+ workflow :status, initial: "pending" do
53
+ state "pending"
54
+ state "approved"
55
+ state "rejected"
56
+ state "executed"
57
+ state "failed"
58
+
59
+ transition :to_approved, from: "pending", to: "approved", roles: [ :manager ]
60
+ transition :to_rejected, from: "pending", to: "rejected", roles: [ :manager ]
61
+ transition :to_executed, from: "approved", to: "executed"
62
+ transition :to_failed, from: "approved", to: "failed"
63
+ end
64
+
65
+ # `changeset` is a Hash in Ruby but an encrypted JSON string at rest. The
66
+ # class methods win over Encryptable's included module and reach it via super.
67
+ def changeset
68
+ raw = super
69
+ raw.present? ? JSON.parse(raw) : {}
70
+ end
71
+
72
+ def changeset=(value)
73
+ super(value.nil? ? nil : value.to_json)
74
+ end
75
+
76
+ # A structured before/after diff. An encrypted target field shows
77
+ # "[encrypted]" on BOTH sides — a reviewer sees THAT a secret changes, never
78
+ # its value.
79
+ def preview
80
+ target = load_target
81
+ encrypted = encrypted_target_fields
82
+
83
+ changeset.each_with_object({}) do |(field, proposed), diff|
84
+ field = field.to_s
85
+ diff[field] =
86
+ if encrypted.include?(field)
87
+ { "from" => (target ? "[encrypted]" : nil), "to" => "[encrypted]" }
88
+ else
89
+ { "from" => target&.read_attribute(field), "to" => proposed }
90
+ end
91
+ end
92
+ end
93
+
94
+ # Approve and execute, atomically enough: the transition is role-gated to a
95
+ # manager; execution runs in a transaction so a failure leaves NO partial
96
+ # write; the record ends "executed" or "failed". Executes as `by`, so the
97
+ # TARGET's audit names the approving human — the person owns the change.
98
+ def approve!(by:)
99
+ Loam.as_tenant(tenant, actor: by) do
100
+ reject_self_approval!(by)
101
+ self.reviewed_by_id = by.id
102
+ self.reviewed_at = Time.current
103
+ to_approved! # NotAuthorizedError if not a manager; InvalidTransitionError if not pending
104
+ execute_and_record!
105
+ end
106
+ self
107
+ end
108
+
109
+ def reject!(by:, reason: nil)
110
+ Loam.as_tenant(tenant, actor: by) do
111
+ self.reviewed_by_id = by.id
112
+ self.reviewed_at = Time.current
113
+ self.result = [ "rejected", reason.presence ].compact.join(": ")
114
+ to_rejected!
115
+ end
116
+ self
117
+ end
118
+
119
+ private
120
+
121
+ # Segregation of duties: the person who staged a change must not be the one
122
+ # who approves it — normally the proposer is an AI agent and the approver a
123
+ # human. Opt out per tenant with the "approvals.allow_self_approve" flag. A
124
+ # proposal with no recorded actor (actor_id nil) bypasses the check.
125
+ # (reject! is deliberately NOT gated: rejecting your own proposal is a
126
+ # withdrawal, not a segregation-of-duties concern.)
127
+ def reject_self_approval!(by)
128
+ return unless by.id == actor_id
129
+ return if Loam::Configs.get("approvals.allow_self_approve", default: false)
130
+
131
+ raise Loam::NotAuthorizedError, "self-approval is not permitted; a different person must approve this change"
132
+ end
133
+
134
+ # Execution AND its status/result recording share ONE transaction with the
135
+ # target write: if recording the outcome fails after the target committed,
136
+ # the whole thing rolls back rather than leaving the change applied but the
137
+ # status stuck at "approved".
138
+ def execute_and_record!
139
+ ActiveRecord::Base.transaction do
140
+ outcome = execute_change!
141
+ to_executed!
142
+ update!(result: outcome)
143
+ end
144
+ rescue StandardError => error
145
+ # The transaction rolled back, but Active Record leaves the in-memory
146
+ # attributes as they were mid-transaction — reload to the real DB state
147
+ # ("approved") before transitioning to "failed".
148
+ reload
149
+ to_failed!
150
+ update!(error: error.message)
151
+ end
152
+
153
+ def execute_change!
154
+ case action_type
155
+ when "create"
156
+ record = target_class.create!(changeset)
157
+ "created #{target_type}##{record.id}"
158
+ when "update"
159
+ target_class.find(target_id).update!(changeset)
160
+ "updated #{target_type}##{target_id}"
161
+ when "destroy"
162
+ target = target_class.find(target_id)
163
+ target.respond_to?(:soft_delete!) ? target.soft_delete! : target.destroy!
164
+ "deleted #{target_type}##{target_id}"
165
+ else
166
+ raise Loam::Error, "unknown action_type #{action_type.inspect}"
167
+ end
168
+ end
169
+
170
+ def target_class
171
+ target_type.constantize
172
+ end
173
+
174
+ def load_target
175
+ return nil if target_id.nil?
176
+
177
+ target_class.find_by(id: target_id)
178
+ end
179
+
180
+ def encrypted_target_fields
181
+ return [] unless target_class.respond_to?(:loam_encrypted_attributes)
182
+
183
+ target_class.loam_encrypted_attributes.map(&:to_s)
184
+ end
185
+
186
+ # Set while a workflow transition is performing its save, so the validation
187
+ # below can tell a legitimate status move from a direct assignment.
188
+ def loam_perform_transition!(transition)
189
+ @loam_status_via_transition = true
190
+ super
191
+ ensure
192
+ @loam_status_via_transition = false
193
+ end
194
+
195
+ def status_changes_only_through_a_transition
196
+ return unless will_save_change_to_status?
197
+ return if @loam_status_via_transition
198
+
199
+ errors.add(:status, "may only change through an approval transition (approve!/reject!), not a direct write")
200
+ end
201
+
202
+ def status_starts_at_the_initial_state
203
+ return if status == self.class.loam_workflow.initial
204
+
205
+ errors.add(:status, "must start at #{self.class.loam_workflow.initial.inspect} — a staged action begins pending")
206
+ end
207
+ end
208
+ end