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,104 @@
1
+ module Loam
2
+ # A saved view of an entity's admin index — which columns show, active filters,
3
+ # sort, and page size — that a user can name, make their default, or share.
4
+ #
5
+ # Three visibility tiers, widening in audience:
6
+ # * "private" — only its owner sees it (owner_id).
7
+ # * "role" — everyone with the named membership role in the tenant.
8
+ # * "tenant" — everyone in the tenant.
9
+ # A private view is invisible to others by construction (see
10
+ # Loam::Perspectives.visible_to), not merely by a permission check.
11
+ #
12
+ # Tenant-scoped like everything else, and audited. Optimistic-locked
13
+ # (lock_version) so two people editing a shared view don't silently clobber
14
+ # each other — the admin controller rescues ActiveRecord::StaleObjectError.
15
+ class Perspective < Loam::TenantRecord
16
+ self.table_name = "loam_perspectives"
17
+
18
+ include Loam::Auditable
19
+
20
+ VISIBILITIES = %w[private role tenant].freeze
21
+
22
+ # Columns a saved filter/sort may NEVER touch: tenant_id is enforced by the
23
+ # default scope (a filter on it is a footgun, not a leak), and the rest are
24
+ # plumbing, not data. Everything else on the entity is fair game.
25
+ NON_FILTERABLE = %w[id tenant_id lock_version deleted_at created_at updated_at].freeze
26
+
27
+ belongs_to :owner, class_name: "User", optional: true
28
+
29
+ validates :entity_type, :name, presence: true
30
+ validates :visibility, inclusion: { in: VISIBILITIES }
31
+ validates :role, presence: true, if: -> { visibility == "role" }
32
+
33
+ # Apply this view's stored filters and sort to a base scope, returning a
34
+ # relation (columns and page_size are read by the controller/view). SAFE BY
35
+ # CONSTRUCTION: a filter or sort key is honored only if it names a real,
36
+ # non-plumbing column of the entity — a crafted key (arbitrary SQL, or
37
+ # tenant_id) is skipped, never executed. Values ride as hash conditions, so
38
+ # Active Record quotes them.
39
+ def apply(scope)
40
+ allowed = filterable_columns(scope.klass)
41
+ filters = config_hash("filters")
42
+
43
+ # A free-text "q" runs the entity's own Searchable search when it has one;
44
+ # on a non-searchable entity `search` returns `all`, so this safely no-ops.
45
+ if filters["q"].present? && scope.klass.respond_to?(:search)
46
+ scope = scope.search(filters["q"])
47
+ end
48
+
49
+ filters.each do |column, value|
50
+ next if column == "q"
51
+ next unless allowed.include?(column.to_s)
52
+
53
+ scope = scope.where(column => value)
54
+ end
55
+
56
+ sort = config_hash("sort")
57
+ if allowed.include?(sort["field"].to_s)
58
+ # reorder (not order) so the saved sort REPLACES the base order; id keeps
59
+ # pagination stable when the sort column has ties.
60
+ direction = sort["dir"].to_s == "desc" ? :desc : :asc
61
+ scope = scope.reorder(sort["field"] => direction, id: :desc)
62
+ end
63
+
64
+ scope
65
+ end
66
+
67
+ def columns
68
+ Array(config_hash_root["columns"])
69
+ end
70
+
71
+ def page_size
72
+ config_hash_root["page_size"]
73
+ end
74
+
75
+ # Make this the default for its audience, unsetting the sibling default it
76
+ # would otherwise compete with (same entity + visibility, and same owner/role
77
+ # where those narrow the audience).
78
+ def make_default!
79
+ siblings = self.class.where(entity_type: entity_type, visibility: visibility).where.not(id: id)
80
+ siblings = siblings.where(owner_id: owner_id) if visibility == "private"
81
+ siblings = siblings.where(role: role) if visibility == "role"
82
+
83
+ transaction do
84
+ siblings.update_all(is_default: false)
85
+ update!(is_default: true)
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def filterable_columns(klass)
92
+ klass.column_names - NON_FILTERABLE
93
+ end
94
+
95
+ def config_hash_root
96
+ config.is_a?(Hash) ? config : {}
97
+ end
98
+
99
+ def config_hash(key)
100
+ value = config_hash_root[key]
101
+ value.is_a?(Hash) ? value : {}
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,103 @@
1
+ module Loam
2
+ # Progress of a long-running job (a bulk import, a reindex, a report) so the
3
+ # admin can watch it live instead of guessing. Tenant-scoped; a job started in
4
+ # one tenant is only visible and streamable there.
5
+ #
6
+ # Deliberately NOT audited: progress is high-frequency churn (many `advance`
7
+ # ticks) and the audit trail would fill with noise. The terminal status and
8
+ # any error are captured on the row itself, which is the summary that matters.
9
+ #
10
+ # Each meaningful change publishes `loam.progress.updated` (broadcastable → SSE)
11
+ # carrying only id/percent/status — no record contents. See Loam::Progress for
12
+ # the entry point.
13
+ class ProgressJob < Loam::TenantRecord
14
+ self.table_name = "loam_progress_jobs"
15
+
16
+ STATUSES = %w[running completed failed cancelled].freeze
17
+ STALE_AFTER = 5.minutes
18
+
19
+ validates :name, presence: true
20
+ validates :status, inclusion: { in: STATUSES }
21
+
22
+ scope :recent, -> { order(started_at: :desc, id: :desc) }
23
+
24
+ # Integer 0..100. A zero total is treated as 0% (an unknown-size job).
25
+ def percent
26
+ return 0 if total.to_i <= 0
27
+
28
+ [ (completed.to_f / total * 100).floor, 100 ].min
29
+ end
30
+
31
+ def running? = status == "running"
32
+ def terminal? = !running?
33
+
34
+ # Seconds remaining, extrapolated from the rate so far, or nil when it can't
35
+ # be estimated yet (no progress, or already finished).
36
+ def eta_seconds
37
+ return nil unless running? && completed.to_i.positive? && started_at
38
+
39
+ elapsed = Time.current - started_at
40
+ rate = completed / elapsed # items per second
41
+ return nil unless rate.positive?
42
+
43
+ ((total - completed) / rate).round
44
+ end
45
+
46
+ # A running job whose process died leaves the row "running" forever; it is
47
+ # stale once its heartbeat (updated_at, bumped on every advance) goes quiet.
48
+ # The prototype exposes the predicate and a manual mark-failed; a reaper
49
+ # daemon is the roadmap.
50
+ def stale?
51
+ running? && updated_at < STALE_AFTER.ago
52
+ end
53
+
54
+ # Increment progress. Persists every tick (so the count and heartbeat stay
55
+ # current) but THROTTLES the SSE broadcast to once per whole percent (or a
56
+ # message change) — a 10k-item job pushes ~100 frames, not 10k.
57
+ def advance(by: 1, message: nil)
58
+ before = percent
59
+ self.completed = completed.to_i + by
60
+ self.message = message if message
61
+ save!
62
+ broadcast if percent != before || message
63
+ self
64
+ end
65
+
66
+ def complete!
67
+ finish!("completed") { self.completed = total if total.to_i.positive? }
68
+ end
69
+
70
+ def fail!(error_message = nil)
71
+ finish!("failed") { self.error = error_message.to_s.presence }
72
+ end
73
+
74
+ def cancel!
75
+ finish!("cancelled")
76
+ end
77
+
78
+ # A cooperative cancel: a long job calls this periodically and stops early.
79
+ # Re-reads the status column so an admin's cancel in ANOTHER request is seen
80
+ # without clobbering the in-memory counters.
81
+ def cancelled?
82
+ self.class.where(id: id).pick(:status) == "cancelled"
83
+ end
84
+
85
+ private
86
+
87
+ def finish!(new_status)
88
+ yield if block_given?
89
+ self.status = new_status
90
+ self.finished_at = Time.current
91
+ save!
92
+ broadcast
93
+ self
94
+ end
95
+
96
+ # Only ever id/percent/status leave the server (Loam::Events stamps tenant_id
97
+ # for the SSE deliverable filter; the frame's safe_payload drops everything
98
+ # else).
99
+ def broadcast
100
+ Loam::Events.publish("loam.progress.updated", id: id, percent: percent, status: status)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,19 @@
1
+ module Loam
2
+ # An advisory "someone is editing this" lock on a record — the COURTESY layer.
3
+ # The real guarantee against a silent clobber is optimistic locking
4
+ # (lock_version, see Loam::RecordLocks docs); this only warns the second
5
+ # editor. One lock per record, with a TTL: an expired lock is treated as free,
6
+ # and re-acquiring as the same holder extends it (a heartbeat). Plumbing, so
7
+ # not audited — like Loam::ApiToken.
8
+ class RecordLock < Loam::TenantRecord
9
+ self.table_name = "loam_record_locks"
10
+
11
+ belongs_to :locked_by, class_name: "User"
12
+
13
+ validates :lockable_type, :lockable_id, :token, :expires_at, presence: true
14
+
15
+ def expired?
16
+ expires_at.nil? || expires_at <= Time.current
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,42 @@
1
+ module Loam
2
+ # A recurring job that runs on a schedule (cron or interval). Tenant-scoped:
3
+ # a "tenant"-scope schedule enqueues its job in this tenant on schedule; a
4
+ # "system"-scope schedule enqueues once with no tenant context. Audited on
5
+ # config changes (not per run — runs are tracked by last_run_at/next_run_at).
6
+ #
7
+ # SECURITY: job_class is validated to be a known ActiveJob (see
8
+ # Loam::Scheduler.resolve_job_class) at save AND at enqueue — a scheduler that
9
+ # constantized and ran arbitrary user input would be a code-execution hole.
10
+ class ScheduledJob < Loam::TenantRecord
11
+ self.table_name = "loam_scheduled_jobs"
12
+
13
+ include Loam::Auditable
14
+
15
+ SCOPES = %w[tenant system].freeze
16
+ LOCK_TTL = 5.minutes # how long a claim holds a due job before another tick may retry it
17
+ STALE_LOCK = LOCK_TTL
18
+
19
+ validates :key, presence: true, uniqueness: { scope: :tenant_id }
20
+ validates :name, :schedule, presence: true
21
+ validates :scope, inclusion: { in: SCOPES }
22
+ validate :job_class_is_a_known_job
23
+
24
+ scope :active, -> { where(active: true) }
25
+
26
+ # The next fire time strictly after `from`, in this job's timezone (UTC when
27
+ # unset). Stored in next_run_at as UTC.
28
+ def compute_next_run(from = Time.current)
29
+ Loam::Cron.next_after(schedule, from: from, zone: timezone.presence || "UTC")
30
+ end
31
+
32
+ def job_class_constant
33
+ Loam::Scheduler.resolve_job_class(job_class)
34
+ end
35
+
36
+ private
37
+
38
+ def job_class_is_a_known_job
39
+ errors.add(:job_class, "must name a known ActiveJob") unless job_class_constant
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ module Loam
2
+ # The word-level search index maintained by Loam::Search::TokenDriver: one row
3
+ # per (record, token), tenant-scoped like every Loam entity. Plumbing — not
4
+ # audited, not soft-deletable. Only the TokenDriver reads or writes it; under
5
+ # the default LikeDriver this table simply stays empty.
6
+ class SearchToken < Loam::TenantRecord
7
+ self.table_name = "loam_search_tokens"
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ module Loam
2
+ # Links an external IdP identity (a provider's stable `sub`) to a local User.
3
+ # The durable key SSO callbacks resolve on — an email can change at the IdP,
4
+ # `sub` does not. Tenant-scoped (created and read inside the provider's tenant).
5
+ class SsoIdentity < Loam::TenantRecord
6
+ self.table_name = "loam_sso_identities"
7
+
8
+ belongs_to :user
9
+ belongs_to :sso_provider, class_name: "Loam::SsoProvider"
10
+
11
+ validates :sub, presence: true, uniqueness: { scope: :sso_provider_id }
12
+ end
13
+ end
@@ -0,0 +1,35 @@
1
+ module Loam
2
+ # A tenant's connection to an external identity provider (OIDC). Per-tenant
3
+ # config: each tenant/org wires its OWN IdP, so the client_secret is encrypted
4
+ # under the tenant key (the default Encryptable scope) — unlike MFA, which is
5
+ # user-keyed. Home-realm discovery matches an email `domain` to the owning
6
+ # provider at the sign-in page (see Loam::Sso.provider_for).
7
+ class SsoProvider < Loam::TenantRecord
8
+ self.table_name = "loam_sso_providers"
9
+
10
+ include Loam::Auditable # config changes are audited; the secret is redacted
11
+ include Loam::Encryptable
12
+
13
+ # Tenant-scoped key (default): SSO config belongs to the tenant, and the
14
+ # secret is only ever read inside that tenant's context on callback.
15
+ encrypts :client_secret
16
+
17
+ has_many :identities, class_name: "Loam::SsoIdentity",
18
+ foreign_key: :sso_provider_id, dependent: :delete_all
19
+
20
+ validates :name, :protocol, :domain, :jit_role, presence: true
21
+ # Globally unique so HRD resolves exactly one owning IdP per domain. Rails'
22
+ # uniqueness validator queries WITHOUT the default tenant scope, so this
23
+ # catches a collision across tenants too.
24
+ validates :domain, uniqueness: true
25
+
26
+ normalizes :domain, with: ->(domain) { domain.to_s.strip.downcase.presence }
27
+
28
+ scope :active, -> { where(active: true) }
29
+
30
+ # IdP group -> Loam role, first match wins; falls back to jit_role.
31
+ def group_roles
32
+ group_role_map.is_a?(Hash) ? group_role_map : {}
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,20 @@
1
+ module Loam
2
+ # The tenant itself is deliberately NOT tenant-scoped — it is the axis the
3
+ # rest of the system is scoped by.
4
+ class Tenant < ActiveRecord::Base
5
+ include Loam::GeneratedKey
6
+ self.table_name = "loam_tenants"
7
+
8
+ validates :name, presence: true
9
+ validates :slug, presence: true, uniqueness: true
10
+
11
+ has_many :memberships, class_name: "Loam::Membership", dependent: :delete_all
12
+
13
+ # A new tenant is never empty: whatever the app registered via
14
+ # Loam.on_tenant_created runs here, inside this tenant's context. After
15
+ # commit, so callbacks see a persisted tenant and may enqueue jobs.
16
+ # `bin/rails loam:sync` re-runs the same callbacks for existing tenants —
17
+ # which is why they must be idempotent (see Loam::Lifecycle).
18
+ after_create_commit { Loam::Lifecycle.run_tenant_created(self) }
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ module Loam
2
+ # A per-locale override for ONE field of ONE record — the storage behind
3
+ # Loam::Translatable. The record's own column holds the base (default-locale)
4
+ # value; a translation row overrides it for a specific locale. Additive:
5
+ # writing a translation never touches the base column, so the base value is
6
+ # never lost. Tenant-scoped and audited.
7
+ class Translation < Loam::TenantRecord
8
+ self.table_name = "loam_translations"
9
+
10
+ include Loam::Auditable
11
+
12
+ belongs_to :translatable, polymorphic: true
13
+
14
+ validates :locale, :field, presence: true
15
+ validates :field, uniqueness: { scope: %i[translatable_type translatable_id locale] }
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ module Loam
2
+ # Where one tenant wants its domain events delivered. `event_pattern` uses
3
+ # the same rule as Loam::Events.subscribe — a trailing dot is a domain prefix
4
+ # ("rental."), anything else is an exact event name.
5
+ #
6
+ # The secret signs every delivery (X-Loam-Signature), so a receiver can tell
7
+ # a real call from a forged one.
8
+ class WebhookEndpoint < Loam::TenantRecord
9
+ self.table_name = "loam_webhook_endpoints"
10
+
11
+ validates :url, presence: true, format: { with: %r{\Ahttps?://}, message: "must start with http:// or https://" }
12
+ validates :event_pattern, presence: true
13
+
14
+ scope :active, -> { where(active: true) }
15
+
16
+ before_validation on: :create do
17
+ self.secret ||= SecureRandom.hex(32)
18
+ end
19
+
20
+ # One matcher, shared with the event bus, so "rental." means the same thing
21
+ # to a subscriber and to a webhook.
22
+ def matches?(event_name)
23
+ Loam::Events.pattern_matches?(event_pattern, event_name)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ <%# locals: record — shared by every generated entity's show.html.erb.
2
+ Uploading happens through the entity's own form (it is an update, and the
3
+ entity's policy decides who may do it); this partial only lists what is
4
+ already attached. Note that these links are signed URLs, not tenant-checked
5
+ addresses — see Loam::Attachable. %>
6
+ <h2>Files</h2>
7
+
8
+ <% if record.files.attached? %>
9
+ <ul>
10
+ <% record.files.each do |file| %>
11
+ <li><%= link_to file.filename.to_s, url_for(file) %> (<%= number_to_human_size(file.byte_size) %>)</li>
12
+ <% end %>
13
+ </ul>
14
+ <% else %>
15
+ <p>No files attached.</p>
16
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <%# locals: record — shared by every generated entity's show.html.erb so the
2
+ discussion is rendered once, in the gem, instead of per entity. Posting is
3
+ open to any member: BaseController has already proven that whoever is here
4
+ holds a membership in this tenant. %>
5
+ <h2>Comments</h2>
6
+
7
+ <% if flash[:alert] %>
8
+ <p style="color:#b00"><%= flash[:alert] %></p>
9
+ <% end %>
10
+
11
+ <% record.loam_comments.oldest_first.each do |comment| %>
12
+ <p>
13
+ <strong><%= User.find_by(id: comment.author_id)&.name || "—" %></strong>
14
+ <em><%= comment.created_at.to_fs(:short) %></em><br>
15
+ <%= comment.body %>
16
+ </p>
17
+ <% end %>
18
+
19
+ <%= form_with url: admin_comments_path, method: :post do |f| %>
20
+ <%= hidden_field_tag :commentable_type, record.class.name %>
21
+ <%= hidden_field_tag :commentable_id, record.id %>
22
+ <%= text_area_tag :body, nil, rows: 3, placeholder: "Add a comment" %>
23
+ <%= f.submit "Comment" %>
24
+ <% end %>
@@ -0,0 +1,35 @@
1
+ <%# locals: record, policy, mode (:edit or :show, default :edit) — shared by every
2
+ generated entity's _form.html.erb and show.html.erb so runtime custom-field
3
+ rendering lives once in the gem instead of being inlined per entity. %>
4
+ <% mode = local_assigns.fetch(:mode, :edit) %>
5
+ <% record.class.custom_field_definitions.each do |definition| %>
6
+ <% param_name = "#{record.model_name.param_key}[custom_fields][#{definition.name}]" %>
7
+ <% value = record.custom_field(definition.name) %>
8
+ <p>
9
+ <% if mode == :edit && policy.custom_field_writable?(definition.name) %>
10
+ <%= label_tag param_name, definition.name.humanize %><br>
11
+ <% case definition.field_type %>
12
+ <% when "boolean" %>
13
+ <%= check_box_tag param_name, "true", value, id: param_name %>
14
+ <% when "text" %>
15
+ <%= text_area_tag param_name, value %>
16
+ <% when "date" %>
17
+ <%= date_field_tag param_name, value %>
18
+ <% when "datetime" %>
19
+ <%= datetime_field_tag param_name, value %>
20
+ <% when "integer", "decimal" %>
21
+ <%= number_field_tag param_name, value %>
22
+ <% when "dictionary" %>
23
+ <% options = Loam::Dictionaries.entries(definition.dictionary_key).map { |e| [e.label, e.value] } %>
24
+ <%= select_tag param_name, options_for_select(options, value), include_blank: true, id: param_name %>
25
+ <% else %>
26
+ <%= text_field_tag param_name, value %>
27
+ <% end %>
28
+ <% else %>
29
+ <%# A dictionary field shows its entry label, not the raw stored code. %>
30
+ <% display = definition.field_type == "dictionary" ? Loam::Dictionaries.label_for(definition.dictionary_key, value) : value %>
31
+ <%= definition.name.humanize %>: <%= display %>
32
+ <% if mode == :edit %><em>(read-only for your role)</em><% end %>
33
+ <% end %>
34
+ </p>
35
+ <% end %>
@@ -0,0 +1,160 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ require "generators/loam/primary_key_options"
4
+
5
+ module Loam
6
+ module Generators
7
+ # `rails g loam:entity Equipment name:string daily_rate:decimal --domain rental`
8
+ #
9
+ # THE interface for adding a business entity — for humans and AI agents
10
+ # alike. One command produces a tenant-scoped, audited, evented model, its
11
+ # policy, an admin screen, and the isolation tests that prove the
12
+ # guardrails hold. No decisions about how tenancy/permissions work: Loam
13
+ # already decided.
14
+ class EntityGenerator < Rails::Generators::NamedBase
15
+ include ActiveRecord::Generators::Migration
16
+ include Loam::Generators::PrimaryKeyOptions
17
+
18
+ source_root File.expand_path("templates", __dir__)
19
+
20
+ argument :attributes, type: :array, default: [], banner: "field:type field:type"
21
+ class_option :domain, type: :string, default: "app",
22
+ desc: "Event domain prefix (-> domain.entity.created)"
23
+ class_option :encrypt, type: :array, default: [], banner: "field field",
24
+ desc: "Encrypt these fields at rest, per tenant (Loam::Encryptable)"
25
+ class_option :encrypt_searchable, type: :array, default: [], banner: "field field",
26
+ desc: "Encrypt + add a blind index for exact-match lookup"
27
+
28
+ def create_migration_file
29
+ migration_template "migration.rb", "db/migrate/create_#{table_name}.rb"
30
+ end
31
+
32
+ def create_model
33
+ template "model.rb", "app/models/#{file_name}.rb"
34
+ end
35
+
36
+ def create_policy
37
+ template "policy.rb", "app/policies/#{file_name}_policy.rb"
38
+ end
39
+
40
+ def create_admin_controller
41
+ template "controller.rb", "app/controllers/admin/#{plural_file_name}_controller.rb"
42
+ end
43
+
44
+ def create_api_controller
45
+ template "api_controller.rb", "app/controllers/api/#{plural_file_name}_controller.rb"
46
+ end
47
+
48
+ def create_admin_views
49
+ template "views/index.html.erb", "app/views/admin/#{plural_file_name}/index.html.erb"
50
+ template "views/deleted.html.erb", "app/views/admin/#{plural_file_name}/deleted.html.erb"
51
+ template "views/show.html.erb", "app/views/admin/#{plural_file_name}/show.html.erb"
52
+ template "views/new.html.erb", "app/views/admin/#{plural_file_name}/new.html.erb"
53
+ template "views/edit.html.erb", "app/views/admin/#{plural_file_name}/edit.html.erb"
54
+ template "views/_form.html.erb", "app/views/admin/#{plural_file_name}/_form.html.erb"
55
+ end
56
+
57
+ def create_entity_test
58
+ template "entity_test.rb", "test/entities/#{file_name}_test.rb"
59
+ end
60
+
61
+ # `namespace:` makes Rails inject the route INTO the existing
62
+ # `namespace :admin do` block (the one loam:install wrote) instead of
63
+ # stacking one admin block per entity, and falls back to creating the
64
+ # block when there is none. Re-running the generator is a no-op: the
65
+ # injection is `force: false`, so identical routing code is skipped.
66
+ # The API line carries `defaults: { format: :json }` — it is what an API
67
+ # namespace wants anyway, and it also has to differ textually from the
68
+ # admin line above: Rails' route injection skips code the file already
69
+ # contains (that is what makes re-running this generator a no-op), so two
70
+ # identical `resources :gadgets` lines would leave the second one out.
71
+ #
72
+ # The admin resource carries the soft-delete recycle bin: `deleted` lists
73
+ # `only_deleted`, `restore` brings one back (see Loam::SoftDeletable).
74
+ def add_route
75
+ route <<~RUBY.strip, namespace: :admin
76
+ resources :#{plural_file_name} do
77
+ get :deleted, on: :collection
78
+ patch :restore, on: :member
79
+ get :export, on: :collection # CSV of the current view
80
+ post :bulk, on: :collection # datatable bulk actions
81
+ end
82
+ RUBY
83
+ route "resources :#{plural_file_name}, defaults: { format: :json }", namespace: :api
84
+ end
85
+
86
+ def print_next_steps
87
+ say ""
88
+ say "Entity #{class_name} created. Next:", :green
89
+ say " 1. bin/rails db:migrate"
90
+ say " 2. Declare field-level permissions in app/policies/#{file_name}_policy.rb"
91
+ say " 3. bin/rails test"
92
+ end
93
+
94
+ private
95
+
96
+ def domain
97
+ options[:domain]
98
+ end
99
+
100
+ def field_names
101
+ attributes.map(&:name)
102
+ end
103
+
104
+ # Only text-ish columns are worth a LIKE (see Loam::Searchable), so an
105
+ # entity with none gets no `searchable_by` declaration and stays out of
106
+ # the global search. Encrypted columns are excluded: ciphertext is
107
+ # meaningless to LIKE, and declaring both would raise at class load.
108
+ def searchable_attributes
109
+ attributes.select do |attribute|
110
+ %i[string text].include?(attribute.type) && !encrypted_field_names.include?(attribute.name)
111
+ end
112
+ end
113
+
114
+ # Fields to encrypt at rest. `--encrypt-searchable` also gets a blind index
115
+ # (a `<field>_hash` column) for exact-match lookup; plain `--encrypt` does not.
116
+ def encrypt_searchable_names
117
+ options[:encrypt_searchable]
118
+ end
119
+
120
+ def encrypted_field_names
121
+ options[:encrypt] + options[:encrypt_searchable]
122
+ end
123
+
124
+ def encrypted?(attribute)
125
+ encrypted_field_names.include?(attribute.name)
126
+ end
127
+
128
+ # A plausible literal per attribute type, used by the generated test.
129
+ def sample_value(attribute, variant = 0)
130
+ case attribute.type
131
+ when :integer, :bigint, :references then 1 + variant
132
+ when :decimal, :float then "%.2f" % (9.99 + variant)
133
+ when :boolean then (variant.zero? ? "true" : "false")
134
+ when :date then "Date.new(2026, 1, #{1 + variant})"
135
+ when :datetime, :time, :timestamp then "Time.utc(2026, 1, #{1 + variant})"
136
+ else "\"Sample #{attribute.name} #{variant}\""
137
+ end
138
+ end
139
+
140
+ def sample_attributes(variant = 0)
141
+ attributes.map { |a| "#{a.name}: #{sample_value(a, variant)}" }.join(", ")
142
+ end
143
+
144
+ def first_field
145
+ field_names.first || "tenant_id"
146
+ end
147
+
148
+ def form_field_helper(attribute)
149
+ case attribute.type
150
+ when :boolean then "check_box"
151
+ when :text then "text_area"
152
+ when :date then "date_field"
153
+ when :datetime, :time, :timestamp then "datetime_field"
154
+ when :integer, :bigint, :decimal, :float, :references then "number_field"
155
+ else "text_field"
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end