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,89 @@
1
+ module Loam
2
+ # Advisory record locks (Loam::RecordLock) — a "who's editing this" courtesy
3
+ # for the multi-user admin.
4
+ #
5
+ # Loam::RecordLocks.acquire(record, by: current_actor) # take/refresh, or nil if held
6
+ # Loam::RecordLocks.holder(record) # the user holding it, or nil
7
+ # Loam::RecordLocks.release(record, by: current_actor) # give it up
8
+ # Loam::RecordLocks.force_release(record) # manager override
9
+ #
10
+ # THIS IS ADVISORY: a held lock warns, it does not hard-block — optimistic
11
+ # locking (lock_version) is the actual guarantee that two edits can't clobber.
12
+ # Every query is tenant-scoped by Loam::RecordLock, so a lock is invisible and
13
+ # unaffectable from another tenant.
14
+ module RecordLocks
15
+ DEFAULT_TTL = 5.minutes
16
+
17
+ class << self
18
+ # Take or refresh the lock if it is free (or already yours). The same
19
+ # holder re-acquiring extends the TTL (heartbeat). Returns the lock, or nil
20
+ # when someone else holds a live one. A record that no longer exists
21
+ # (hard- or soft-deleted) cannot be locked.
22
+ def acquire(record, by:, ttl: DEFAULT_TTL)
23
+ return nil unless present?(record)
24
+
25
+ lock = lock_row(record)
26
+ return nil if lock && !lock.expired? && lock.locked_by_id != by.id
27
+
28
+ lock ||= Loam::RecordLock.new(lockable_type: type_of(record), lockable_id: record.id)
29
+ # A fresh session token when the lock is newly created or taken over from
30
+ # an expired holder; kept as-is on a heartbeat by the same user.
31
+ lock.token = SecureRandom.hex(16) if lock.new_record? || lock.locked_by_id != by.id
32
+ lock.locked_by_id = by.id
33
+ lock.expires_at = Time.current + ttl
34
+ lock.save!
35
+ lock
36
+ rescue ActiveRecord::RecordNotUnique
37
+ # Lost the create race — someone else got it first, which is exactly the
38
+ # nil contract. Advisory honesty over cleverness.
39
+ nil
40
+ end
41
+
42
+ # The live lock on a record, or nil — cleaning up a lock that is expired or
43
+ # whose record is gone (an auto-free), so orphaned rows don't linger.
44
+ def active_lock(record)
45
+ lock = lock_row(record)
46
+ return nil unless lock
47
+
48
+ if lock.expired? || !present?(record)
49
+ lock.destroy
50
+ return nil
51
+ end
52
+ lock
53
+ end
54
+
55
+ def holder(record)
56
+ active_lock(record)&.locked_by
57
+ end
58
+
59
+ def release(record, by:)
60
+ lock = lock_row(record)
61
+ lock.destroy if lock && lock.locked_by_id == by.id
62
+ true
63
+ end
64
+
65
+ # Manager override: drop whoever holds it. The controller gates the role.
66
+ def force_release(record)
67
+ lock_row(record)&.destroy
68
+ true
69
+ end
70
+
71
+ private
72
+
73
+ def lock_row(record)
74
+ Loam::RecordLock.find_by(lockable_type: type_of(record), lockable_id: record.id)
75
+ end
76
+
77
+ def type_of(record)
78
+ record.class.base_class.name
79
+ end
80
+
81
+ # Does the record still exist in the ordinary (non-soft-deleted) scope? A
82
+ # soft-deleted or hard-deleted record is "gone", so its lock auto-frees.
83
+ # Runs under the tenant default scope, which every call site has.
84
+ def present?(record)
85
+ record.class.base_class.where(id: record.id).exists?
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,153 @@
1
+ module Loam
2
+ # Per-tenant recurring jobs. Modules self-register default schedules; an app
3
+ # also creates them from the admin. A runner calls Loam::Scheduler.tick
4
+ # periodically (wire `loam:scheduler:tick` to system cron — every minute) and
5
+ # each DUE schedule enqueues its ActiveJob.
6
+ #
7
+ # NO DOUBLE-FIRE — the whole correctness story. tick ATOMICALLY CLAIMS due
8
+ # jobs so two worker processes never enqueue the same one:
9
+ # * PostgreSQL: `SELECT ... FOR UPDATE SKIP LOCKED` — one worker wins each row.
10
+ # * SQLite (demo/harness): a transactional claim; SQLite serializes writers,
11
+ # which is correct for the single-process prototype.
12
+ # The mechanism is behind `claim_due`, chosen per adapter. A claim also stamps
13
+ # `locked_until` so a crashed worker's rows free themselves after LOCK_TTL.
14
+ module Scheduler
15
+ class UnknownJobError < Loam::Error; end
16
+
17
+ class << self
18
+ # --- declarative registry (like broadcast_events / feature_defaults) ---
19
+
20
+ def register(key:, job_class:, schedule:, scope: "tenant", name: nil)
21
+ registry[key.to_s] = {
22
+ key: key.to_s, job_class: job_class.to_s, schedule: schedule.to_s,
23
+ scope: scope.to_s, name: (name || key).to_s
24
+ }
25
+ end
26
+
27
+ def registered = registry.values
28
+
29
+ def reset_registry!
30
+ @registry = {}
31
+ end
32
+
33
+ # Materialize the registered TENANT-scope schedules as rows in `tenant`
34
+ # (idempotent — safe from on_tenant_created and `loam:sync`). System-scope
35
+ # schedules are created explicitly by the app (they are not per-tenant).
36
+ def sync_tenant(tenant)
37
+ Loam.as_tenant(tenant) do
38
+ registered.each do |default|
39
+ next unless default[:scope] == "tenant"
40
+
41
+ job = Loam::ScheduledJob.find_or_initialize_by(key: default[:key])
42
+ job.name = default[:name]
43
+ job.job_class = default[:job_class]
44
+ job.schedule = default[:schedule]
45
+ job.scope = "tenant"
46
+ job.active = true if job.new_record?
47
+ job.next_run_at ||= job.compute_next_run
48
+ job.save!
49
+ end
50
+ end
51
+ end
52
+
53
+ # --- the tick ---
54
+
55
+ # Claim and enqueue every due schedule. Returns how many fired. One job's
56
+ # failure never blocks the others (its lock is released so a later tick
57
+ # retries it).
58
+ def tick(now: Time.current)
59
+ Loam::Telemetry.span("scheduler_tick") { run_tick(now) }
60
+ end
61
+
62
+ def run_tick(now)
63
+ fired = 0
64
+ claim_due(now).each do |job|
65
+ begin
66
+ enqueue_target(job)
67
+ job.update_columns(last_run_at: now, next_run_at: job.compute_next_run(now),
68
+ locked_until: nil, updated_at: now)
69
+ fired += 1
70
+ rescue StandardError => error
71
+ job.update_columns(locked_until: nil, updated_at: now)
72
+ logger&.error("[loam scheduler] #{job.key} failed: #{error.class}: #{error.message}")
73
+ end
74
+ end
75
+ fired
76
+ end
77
+
78
+ # Enqueue a schedule's job right now without touching its schedule (the
79
+ # admin "run now" button). Re-validates job_class.
80
+ def run_now(job)
81
+ enqueue_target(job)
82
+ end
83
+
84
+ # THE code-execution guard: a job_class must resolve to a real ActiveJob
85
+ # subclass AND be ALLOWLISTED — either registered here or listed in
86
+ # Loam.schedulable_jobs. "Any ActiveJob" would let a tenant admin schedule
87
+ # ActiveStorage::PurgeJob, a mailer's delivery job, etc.
88
+ def resolve_job_class(name)
89
+ name = name.to_s
90
+ return nil unless allowed_job_class?(name)
91
+
92
+ klass = name.safe_constantize
93
+ klass if klass.is_a?(Class) && defined?(ActiveJob::Base) && klass < ActiveJob::Base
94
+ end
95
+
96
+ def allowed_job_class?(name)
97
+ registered.any? { |default| default[:job_class] == name } || Loam.schedulable_jobs.include?(name)
98
+ end
99
+
100
+ def resolve_job_class!(name)
101
+ resolve_job_class(name) || raise(UnknownJobError, "#{name.inspect} is not a known ActiveJob")
102
+ end
103
+
104
+ private
105
+
106
+ def registry
107
+ @registry ||= {}
108
+ end
109
+
110
+ def enqueue_target(job)
111
+ klass = resolve_job_class!(job.job_class)
112
+
113
+ if job.scope == "system"
114
+ klass.perform_later
115
+ else
116
+ # Carry the tenant explicitly (ActiveJob doesn't serialize Loam::Current),
117
+ # and enqueue inside the tenant so any enqueue-time scoping is correct.
118
+ Loam.as_tenant(job.tenant) { klass.perform_later(tenant_id: job.tenant_id) }
119
+ end
120
+ end
121
+
122
+ # Atomically claim due, unlocked schedules across ALL tenants (a blessed
123
+ # cross-tenant scan — the runner has no tenant context, like
124
+ # Loam::Membership.tenants_for), stamping locked_until so a concurrent tick
125
+ # skips them.
126
+ def claim_due(now)
127
+ lock_until = now + Loam::ScheduledJob::LOCK_TTL
128
+ due = Loam::ScheduledJob.unscoped.active
129
+ .where("next_run_at <= ?", now)
130
+ .where("locked_until IS NULL OR locked_until < ?", now)
131
+
132
+ claimed = []
133
+ Loam::ScheduledJob.transaction do
134
+ relation = postgres? ? due.lock("FOR UPDATE SKIP LOCKED") : due
135
+ ids = relation.pluck(:id)
136
+ break if ids.empty?
137
+
138
+ Loam::ScheduledJob.unscoped.where(id: ids).update_all(locked_until: lock_until)
139
+ claimed = Loam::ScheduledJob.unscoped.where(id: ids).to_a
140
+ end
141
+ claimed
142
+ end
143
+
144
+ def postgres?
145
+ Loam::ScheduledJob.connection.adapter_name.to_s.downcase.include?("postgres")
146
+ end
147
+
148
+ def logger
149
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,31 @@
1
+ module Loam
2
+ module Search
3
+ # The driver contract. Subclass and override the class methods.
4
+ #
5
+ # search(model, query, scope:) -> a relation
6
+ # Return a subset of `scope` (already tenant-scoped, and any saved-view
7
+ # filter already applied) matching `query`. MUST return a relation so it
8
+ # composes with the caller's own `.where`/`.order`/`.limit`. A blank
9
+ # query returns `scope` unchanged (an empty search box is "no filter").
10
+ #
11
+ # index(record) / remove(record)
12
+ # Maintain the driver's index as records are saved and destroyed. Called
13
+ # from Loam::Searchable's after_save / after_destroy.
14
+ #
15
+ # reindex(model)
16
+ # Rebuild the index for a model (used by `loam:search:reindex` and seeds).
17
+ #
18
+ # A driver that needs no index (LIKE) leaves index/remove/reindex as no-ops.
19
+ class Driver
20
+ class << self
21
+ def search(model, query, scope:)
22
+ raise NotImplementedError, "#{name} must implement .search(model, query, scope:)"
23
+ end
24
+
25
+ def index(record) = nil
26
+ def remove(record) = nil
27
+ def reindex(model) = nil
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ module Loam
2
+ module Search
3
+ # The default driver: a substring LIKE across the declared columns — exactly
4
+ # the original Loam::Searchable behavior, now behind the seam. Portable
5
+ # (Arel's `matches` emits ILIKE on PostgreSQL, case-insensitive LIKE on
6
+ # SQLite for ASCII) and needs no index, so index/remove/reindex are no-ops.
7
+ #
8
+ # What it does NOT do — ignore accents, split on words, rank — is the reason
9
+ # the seam exists: swap in Loam::Search::TokenDriver (or an external engine)
10
+ # and every `Model.search(q)` call site keeps working unchanged.
11
+ class LikeDriver < Driver
12
+ class << self
13
+ def search(model, query, scope:)
14
+ query = query.to_s.strip
15
+ return scope if query.blank? || !model.loam_searchable?
16
+
17
+ # sanitize_sql_like escapes the LIKE wildcards so a literal % or _ in
18
+ # the query stays literal; the second arg to `matches` declares that
19
+ # escape character to the database (SQLite has none by default).
20
+ pattern = "%#{model.sanitize_sql_like(query)}%"
21
+ conditions = model.loam_searchable_columns.map do |column|
22
+ model.arel_table[column].matches(pattern, "\\")
23
+ end
24
+ scope.where(conditions.inject(:or))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,88 @@
1
+ module Loam
2
+ module Search
3
+ # A portable, word-level index (the flagship second driver). Each record's
4
+ # searchable text is normalized into tokens stored in loam_search_tokens;
5
+ # a query is tokenized the same way and matched with AND semantics — a record
6
+ # must carry EVERY query token — so "excavator cat" finds "CAT 320 Excavator"
7
+ # regardless of word order or position. Plain SQL: works on SQLite and
8
+ # PostgreSQL, no external service, and genuinely better than LIKE (word-level,
9
+ # order-independent). When an app outgrows even this, the same seam takes a
10
+ # Meilisearch/Elasticsearch driver with no call-site change.
11
+ #
12
+ # Tenant isolation is free: Loam::SearchToken is a TenantRecord, so both the
13
+ # match subquery and every write are scoped to the current tenant.
14
+ class TokenDriver < Driver
15
+ class << self
16
+ def search(model, query, scope:)
17
+ tokens = tokenize(query)
18
+ return scope if tokens.empty? || !model.loam_searchable?
19
+
20
+ # AND semantics: keep only records carrying all N distinct query
21
+ # tokens. Under AND every hit has the full match count, so ranking is
22
+ # degenerate — ordering is left to the caller.
23
+ matches = Loam::SearchToken
24
+ .where(searchable_type: type_for(model), token: tokens)
25
+ .group(:searchable_id)
26
+ .having("COUNT(DISTINCT token) = #{tokens.size}")
27
+ .select(:searchable_id)
28
+
29
+ scope.where(id: matches)
30
+ end
31
+
32
+ # Rebuild one record's tokens in place. Called from after_save; runs in
33
+ # the record's tenant, so the token rows land in the right tenant.
34
+ def index(record)
35
+ model = record.class
36
+ return unless model.loam_searchable?
37
+
38
+ type = type_for(model)
39
+ Loam::SearchToken.where(searchable_type: type, searchable_id: record.id).delete_all
40
+
41
+ tokens = tokenize(searchable_text(record))
42
+ return if tokens.empty?
43
+
44
+ rows = tokens.map do |token|
45
+ { tenant_id: record.tenant_id, searchable_type: type, searchable_id: record.id, token: token }
46
+ end
47
+ Loam::SearchToken.insert_all(rows)
48
+ end
49
+
50
+ def remove(record)
51
+ Loam::SearchToken.where(searchable_type: type_for(record.class), searchable_id: record.id).delete_all
52
+ end
53
+
54
+ # Rebuild every current-tenant record of a model — run inside
55
+ # Loam.as_tenant per tenant (the loam:search:reindex task does that).
56
+ def reindex(model)
57
+ Loam::SearchToken.where(searchable_type: type_for(model)).delete_all
58
+ model.find_each { |record| index(record) }
59
+ end
60
+
61
+ private
62
+
63
+ def type_for(model) = model.name
64
+
65
+ # THE SAFETY BOUNDARY: an encrypted column is skipped. Its stored value
66
+ # is ciphertext (meaningless to tokenize), and tokenizing the PLAINTEXT
67
+ # into a searchable table would recreate exactly the leak
68
+ # Loam::Encryptable closes — an encrypted field stays exact-match-only via
69
+ # its blind index. `searchable_by` already refuses to declare an
70
+ # encrypted column; this subtraction is the second line of that defense.
71
+ def searchable_text(record)
72
+ columns = record.class.loam_searchable_columns
73
+ if record.class.respond_to?(:loam_encrypted_attributes)
74
+ columns -= record.class.loam_encrypted_attributes
75
+ end
76
+ columns.map { |column| record.public_send(column) }.join(" ")
77
+ end
78
+
79
+ # Downcase, split on any run of non-alphanumeric (Unicode-aware, so
80
+ # "Kraków" stays one token), drop blanks, de-dup. Deliberately NOT
81
+ # linguistic — no stemming — for the prototype.
82
+ def tokenize(text)
83
+ text.to_s.downcase.split(/[^[:alnum:]]+/).reject(&:blank?).uniq
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,27 @@
1
+ module Loam
2
+ # The search seam. `Model.search(q)` and the `searchable_by` declaration never
3
+ # change; the STRATEGY behind them is a swappable driver:
4
+ #
5
+ # Loam::Search.driver = Loam::Search::TokenDriver # in an initializer
6
+ #
7
+ # The default is the LIKE driver — portable and zero-setup — so nothing
8
+ # changes out of the box. A driver turns a text query into a subset of an
9
+ # already-tenant-scoped relation and maintains whatever index it needs; see
10
+ # Loam::Search::Driver for the contract. This is the same shape as
11
+ # Loam::EventStream.broadcaster and Loam::Enrichers: one seam, drivers behind
12
+ # it, callers untouched.
13
+ module Search
14
+ class << self
15
+ attr_writer :driver
16
+
17
+ def driver
18
+ @driver ||= Loam::Search::LikeDriver
19
+ end
20
+
21
+ def search(model, query, scope:) = driver.search(model, query, scope: scope)
22
+ def index(record) = driver.index(record)
23
+ def remove(record) = driver.remove(record)
24
+ def reindex(model) = driver.reindex(model)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,90 @@
1
+ module Loam
2
+ # Text search over an entity's own columns:
3
+ #
4
+ # class Equipment < Loam::TenantRecord
5
+ # include Loam::Searchable
6
+ # searchable_by :name, :status
7
+ # end
8
+ #
9
+ # Equipment.search("cat") # => a relation, still tenant-scoped
10
+ #
11
+ # `search` returns a relation, so tenant isolation is not something this has
12
+ # to remember: the default scope is already on it, and the result composes
13
+ # with ordering, pagination and anything else.
14
+ #
15
+ # HOW a query matches is a swappable driver (Loam::Search.driver): the default
16
+ # LikeDriver is a substring LIKE; the TokenDriver is a portable word-level
17
+ # index; an external engine is a third. This concern is only the DECLARATION
18
+ # (`searchable_by`) and the untouched call site (`Model.search(q)`) — swapping
19
+ # the driver changes neither. See Loam::Search.
20
+ module Searchable
21
+ extend ActiveSupport::Concern
22
+
23
+ included do
24
+ class_attribute :loam_searchable_columns, default: [].freeze, instance_writer: false
25
+
26
+ # Keep the active driver's index current. A no-op under the default
27
+ # LikeDriver; the TokenDriver maintains loam_search_tokens here. Guarded so
28
+ # non-searchable models pay nothing, and skipped when no searchable column
29
+ # actually changed — so a soft-delete (which touches only deleted_at)
30
+ # leaves the tokens in place, and the base scope hides the row anyway.
31
+ #
32
+ # `respond_to?` guard: Searchable is includable in a plain class purely to
33
+ # exercise the `searchable_by` DSL (its encrypted-field check), with no
34
+ # ActiveRecord underneath — only a real model gets the callbacks.
35
+ if respond_to?(:after_save)
36
+ after_save :loam_update_search_index, if: :loam_search_index_stale?
37
+ after_destroy :loam_remove_from_search_index, if: -> { self.class.loam_searchable? }
38
+ end
39
+ end
40
+
41
+ class_methods do
42
+ # Declared columns are code, not input — no runtime validation here,
43
+ # because reading the schema at class-definition time would need a
44
+ # database connection just to boot.
45
+ def searchable_by(*columns)
46
+ columns = columns.map(&:to_s)
47
+
48
+ # The mirror of Loam::Encryptable's check: an encrypted column is
49
+ # ciphertext at rest, which LIKE cannot match. Caught here when
50
+ # `searchable_by` is the later declaration (encrypts catches the reverse).
51
+ if respond_to?(:loam_encrypted_attributes)
52
+ conflict = columns & loam_encrypted_attributes
53
+ if conflict.any?
54
+ raise Loam::Error,
55
+ "#{name}: cannot `searchable_by` encrypted field(s) #{conflict.join(', ')} — " \
56
+ "ciphertext cannot be LIKE-searched. Use `encrypts :field, searchable: true` " \
57
+ "for exact-match lookup instead."
58
+ end
59
+ end
60
+
61
+ self.loam_searchable_columns = columns.freeze
62
+ end
63
+
64
+ def loam_searchable?
65
+ loam_searchable_columns.any?
66
+ end
67
+
68
+ # Delegates to the active driver, handing it the current relation as the
69
+ # base scope — so `Model.search(q)` and `some_scope.search(q)` both work
70
+ # (a class method called on a relation runs with `all` == that relation).
71
+ # A blank query returns everything: an empty search box means "no filter".
72
+ def search(query)
73
+ Loam::Search.search(self, query, scope: all)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def loam_update_search_index = Loam::Search.index(self)
80
+ def loam_remove_from_search_index = Loam::Search.remove(self)
81
+
82
+ # On create every column counts as changed, so a new record is always
83
+ # indexed; on update, reindex only when a searchable column actually moved.
84
+ def loam_search_index_stale?
85
+ return false unless self.class.loam_searchable?
86
+
87
+ saved_changes.keys.intersect?(self.class.loam_searchable_columns)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,72 @@
1
+ module Loam
2
+ # Soft-delete: destroying a business record hides it instead of erasing it.
3
+ # A recycle bin — for GDPR retention, for undo, for audits that still read
4
+ # cleanly after the fact. Loam is "safe by default", so a soft-deleted record
5
+ # is EXCLUDED from every ordinary query; seeing it is something you opt INTO,
6
+ # never a filter you must remember to add.
7
+ #
8
+ # equipment.soft_delete # deleted_at = now; gone from Equipment.all
9
+ # Equipment.with_deleted # includes it — but STILL tenant-scoped
10
+ # Equipment.only_deleted # just the recycle bin
11
+ # equipment.restore # deleted_at = nil; back in every query
12
+ # equipment.destroy # the real erase — the row leaves the database
13
+ #
14
+ # `destroy` still hard-deletes, for a genuine "forget me". The generated admin
15
+ # calls `soft_delete`, so its delete button is undoable; reach for `destroy`
16
+ # only when the row must actually leave the database.
17
+ module SoftDeletable
18
+ extend ActiveSupport::Concern
19
+
20
+ included do
21
+ # A second default_scope: Rails ANDs it with TenantRecord's tenant scope,
22
+ # so a hidden record is invisible AND still tenant-isolated.
23
+ default_scope { where(deleted_at: nil) }
24
+
25
+ # `unscope`, never `unscoped`: this drops ONLY the deleted_at predicate and
26
+ # keeps the tenant_id one, so the recycle bin can never surface another
27
+ # tenant's rows. `unscoped` would drop both and is forbidden here.
28
+ scope :with_deleted, -> { unscope(where: :deleted_at) }
29
+ scope :only_deleted, -> { with_deleted.where.not(deleted_at: nil) }
30
+ end
31
+
32
+ def deleted?
33
+ deleted_at.present?
34
+ end
35
+
36
+ def soft_delete
37
+ loam_toggle_deleted(deleted: true, bang: false)
38
+ end
39
+
40
+ def soft_delete!
41
+ loam_toggle_deleted(deleted: true, bang: true)
42
+ end
43
+
44
+ def restore
45
+ loam_toggle_deleted(deleted: false, bang: false)
46
+ end
47
+
48
+ def restore!
49
+ loam_toggle_deleted(deleted: false, bang: true)
50
+ end
51
+
52
+ private
53
+
54
+ # One write for all four entry points. Idempotent by design: toggling a
55
+ # record to the state it is already in (a double-clicked button) is a no-op,
56
+ # so the trail never fills with deleted_at -> deleted_at noise.
57
+ #
58
+ # A soft-delete is an UPDATE, so Loam::Auditable's after_destroy never fires
59
+ # for it. Rather than write our own audit row we borrow Auditable's single
60
+ # audit path via `loam_audit_as`, which relabels this save's audit
61
+ # "soft_delete"/"restore" instead of the "update" it would otherwise record.
62
+ def loam_toggle_deleted(deleted:, bang:)
63
+ return true if deleted == deleted?
64
+
65
+ self.deleted_at = deleted ? Time.current : nil
66
+ action = deleted ? "soft_delete" : "restore"
67
+ write = -> { bang ? save! : save }
68
+
69
+ respond_to?(:loam_audit_as, true) ? loam_audit_as(action, &write) : write.call
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,10 @@
1
+ module Loam
2
+ module Sso
3
+ # Normalized identity claims, protocol-independent. Every provider's
4
+ # `exchange` returns one of these so the rest of the flow never knows whether
5
+ # it came from OIDC, SAML, or a test fake.
6
+ Claims = Data.define(:sub, :email, :email_verified, :name, :groups) do
7
+ def email_verified? = email_verified == true || email_verified.to_s == "true"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,51 @@
1
+ require "base64"
2
+
3
+ module Loam
4
+ module Sso
5
+ # An OFFLINE stand-in for a real IdP — for the demo and the test suite ONLY,
6
+ # NEVER production. `authorization_url` loops straight back to our own
7
+ # callback carrying the login email (as the "code"), so the whole SSO
8
+ # round-trip runs with no network. `exchange` returns verified claims for
9
+ # that email.
10
+ #
11
+ # Tests drive the security paths through two class-level overrides (reset in
12
+ # teardown): `force_email_verified = false` to prove an unverified email is
13
+ # refused, and `claims_override` to inject arbitrary claims (a fixed `sub`,
14
+ # IdP groups for role mapping, a different email).
15
+ class FakeProvider
16
+ class << self
17
+ attr_accessor :claims_override, :force_email_verified
18
+
19
+ def reset!
20
+ self.claims_override = nil
21
+ self.force_email_verified = nil
22
+ end
23
+ end
24
+
25
+ def initialize(record, redirect_uri:)
26
+ @record = record
27
+ @redirect_uri = redirect_uri
28
+ end
29
+
30
+ def authorization_url(state:, login_hint: nil)
31
+ uri = URI(@redirect_uri)
32
+ uri.query = { code: Base64.urlsafe_encode64(login_hint.to_s), state: state }.to_query
33
+ uri.to_s
34
+ end
35
+
36
+ def exchange(code:)
37
+ return self.class.claims_override if self.class.claims_override
38
+
39
+ email = Base64.urlsafe_decode64(code.to_s)
40
+ verified = self.class.force_email_verified.nil? ? true : self.class.force_email_verified
41
+ Claims.new(
42
+ sub: "fake|#{email}",
43
+ email: email,
44
+ email_verified: verified,
45
+ name: email.split("@").first,
46
+ groups: []
47
+ )
48
+ end
49
+ end
50
+ end
51
+ end