open-loam 0.1.1 → 0.3.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 (287) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +148 -8
  3. data/LICENSE +1 -1
  4. data/README.md +27 -25
  5. data/app/jobs/{loam → open_loam}/custom_field_reindex_job.rb +6 -6
  6. data/app/jobs/{loam → open_loam}/event_delivery_job.rb +8 -8
  7. data/app/jobs/open_loam/event_log_prune_job.rb +15 -0
  8. data/app/jobs/{loam → open_loam}/event_redelivery_sweep_job.rb +4 -4
  9. data/app/jobs/{loam → open_loam}/webhook_delivery_job.rb +17 -8
  10. data/app/models/open_loam/api_token.rb +63 -0
  11. data/app/models/{loam → open_loam}/audit_record.rb +3 -3
  12. data/app/models/{loam → open_loam}/auth_attempt.rb +5 -5
  13. data/app/models/{loam → open_loam}/business_rule.rb +10 -10
  14. data/app/models/{loam → open_loam}/business_rule_run.rb +5 -5
  15. data/app/models/{loam → open_loam}/comment.rb +9 -9
  16. data/app/models/{loam → open_loam}/config.rb +7 -7
  17. data/app/models/{loam → open_loam}/custom_field_value.rb +5 -5
  18. data/app/models/{loam → open_loam}/dashboard_widget.rb +5 -5
  19. data/app/models/open_loam/dictionary.rb +20 -0
  20. data/app/models/{loam → open_loam}/dictionary_entry.rb +7 -7
  21. data/app/models/{loam → open_loam}/event_delivery.rb +4 -4
  22. data/app/models/open_loam/event_record.rb +42 -0
  23. data/app/models/{loam → open_loam}/field_definition.rb +8 -8
  24. data/app/models/{loam → open_loam}/inbound_webhook_delivery.rb +4 -4
  25. data/app/models/{loam → open_loam}/inbound_webhook_source.rb +13 -13
  26. data/app/models/{loam → open_loam}/membership.rb +6 -6
  27. data/app/models/{loam → open_loam}/mfa_credential.rb +8 -8
  28. data/app/models/{loam → open_loam}/notification.rb +10 -10
  29. data/app/models/{loam → open_loam}/pending_action.rb +70 -26
  30. data/app/models/{loam → open_loam}/perspective.rb +5 -5
  31. data/app/models/{loam → open_loam}/progress_job.rb +7 -7
  32. data/app/models/{loam → open_loam}/record_lock.rb +5 -5
  33. data/app/models/{loam → open_loam}/scheduled_job.rb +7 -7
  34. data/app/models/open_loam/search_token.rb +9 -0
  35. data/app/models/{loam → open_loam}/sso_identity.rb +4 -4
  36. data/app/models/open_loam/sso_provider.rb +66 -0
  37. data/app/models/open_loam/tenant.rb +20 -0
  38. data/app/models/{loam → open_loam}/translation.rb +5 -5
  39. data/app/models/open_loam/webhook_endpoint.rb +39 -0
  40. data/app/views/{loam → open_loam}/attachments/_attachments.html.erb +1 -1
  41. data/app/views/{loam → open_loam}/comments/_comments.html.erb +1 -1
  42. data/app/views/{loam → open_loam}/custom_fields/_fields.html.erb +6 -2
  43. data/lib/generators/{loam → open_loam}/entity/entity_generator.rb +9 -9
  44. data/lib/generators/{loam → open_loam}/entity/templates/api_controller.rb +2 -1
  45. data/lib/generators/{loam → open_loam}/entity/templates/controller.rb +32 -22
  46. data/lib/generators/{loam → open_loam}/entity/templates/entity_test.rb +12 -12
  47. data/lib/generators/{loam → open_loam}/entity/templates/migration.rb +5 -5
  48. data/lib/generators/{loam → open_loam}/entity/templates/model.rb +10 -10
  49. data/lib/generators/{loam → open_loam}/entity/templates/policy.rb +1 -1
  50. data/lib/generators/{loam → open_loam}/entity/templates/views/_form.html.erb +8 -5
  51. data/lib/generators/{loam → open_loam}/entity/templates/views/deleted.html.erb +7 -7
  52. data/lib/generators/open_loam/entity/templates/views/edit.html.erb +3 -0
  53. data/lib/generators/open_loam/entity/templates/views/index.html.erb +94 -0
  54. data/lib/generators/open_loam/entity/templates/views/new.html.erb +3 -0
  55. data/lib/generators/open_loam/entity/templates/views/show.html.erb +38 -0
  56. data/lib/generators/{loam → open_loam}/install/install_generator.rb +58 -47
  57. data/lib/generators/open_loam/install/templates/AGENTS.md +345 -0
  58. data/lib/generators/{loam → open_loam}/install/templates/admin/api_docs_controller.rb +2 -2
  59. data/lib/generators/{loam → open_loam}/install/templates/admin/api_docs_index.html.erb +1 -1
  60. data/lib/generators/open_loam/install/templates/admin/api_tokens_controller.rb +48 -0
  61. data/lib/generators/{loam → open_loam}/install/templates/admin/api_tokens_index.html.erb +23 -0
  62. data/lib/generators/{loam → open_loam}/install/templates/admin/base_controller.rb +101 -38
  63. data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_controller.rb +7 -7
  64. data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_form.html.erb +2 -2
  65. data/lib/generators/{loam → open_loam}/install/templates/admin/comments_controller.rb +4 -4
  66. data/lib/generators/{loam → open_loam}/install/templates/admin/configs_controller.rb +9 -9
  67. data/lib/generators/open_loam/install/templates/admin/dashboard_controller.rb +12 -0
  68. data/lib/generators/{loam → open_loam}/install/templates/admin/dashboard_widgets_controller.rb +5 -5
  69. data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_controller.rb +5 -5
  70. data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_form.html.erb +1 -1
  71. data/lib/generators/{loam → open_loam}/install/templates/admin/dictionary_entries_controller.rb +2 -2
  72. data/lib/generators/{loam → open_loam}/install/templates/admin/event_deliveries_controller.rb +5 -5
  73. data/lib/generators/{loam → open_loam}/install/templates/admin/event_deliveries_index.html.erb +1 -1
  74. data/lib/generators/{loam → open_loam}/install/templates/admin/events_controller.rb +7 -5
  75. data/lib/generators/{loam → open_loam}/install/templates/admin/features_controller.rb +9 -9
  76. data/lib/generators/{loam → open_loam}/install/templates/admin/field_definitions_controller.rb +5 -5
  77. data/lib/generators/{loam → open_loam}/install/templates/admin/field_definitions_new.html.erb +2 -2
  78. data/lib/generators/open_loam/install/templates/admin/history_controller.rb +64 -0
  79. data/lib/generators/{loam → open_loam}/install/templates/admin/history_index.html.erb +2 -2
  80. data/lib/generators/{loam → open_loam}/install/templates/admin/imports_controller.rb +25 -13
  81. data/lib/generators/{loam → open_loam}/install/templates/admin/inbound_webhook_sources_controller.rb +7 -7
  82. data/lib/generators/{loam → open_loam}/install/templates/admin/inbound_webhook_sources_new.html.erb +2 -4
  83. data/lib/generators/open_loam/install/templates/admin/layout.html.erb +76 -0
  84. data/lib/generators/{loam → open_loam}/install/templates/admin/mfa_controller.rb +11 -6
  85. data/lib/generators/{loam → open_loam}/install/templates/admin/notifications_controller.rb +5 -3
  86. data/lib/generators/{loam → open_loam}/install/templates/admin/overrides_controller.rb +3 -3
  87. data/lib/generators/{loam → open_loam}/install/templates/admin/overrides_index.html.erb +2 -2
  88. data/lib/generators/{loam → open_loam}/install/templates/admin/pending_actions_controller.rb +3 -3
  89. data/lib/generators/{loam → open_loam}/install/templates/admin/perspectives_controller.rb +13 -7
  90. data/lib/generators/{loam → open_loam}/install/templates/admin/perspectives_index.html.erb +1 -1
  91. data/lib/generators/open_loam/install/templates/admin/progress_jobs_controller.rb +23 -0
  92. data/lib/generators/{loam → open_loam}/install/templates/admin/progress_jobs_index.html.erb +1 -1
  93. data/lib/generators/{loam → open_loam}/install/templates/admin/record_locks_controller.rb +4 -4
  94. data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_controller.rb +7 -7
  95. data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_form.html.erb +1 -1
  96. data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_index.html.erb +1 -1
  97. data/lib/generators/{loam → open_loam}/install/templates/admin/search_controller.rb +7 -5
  98. data/lib/generators/{loam → open_loam}/install/templates/admin/sessions_controller.rb +33 -34
  99. data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_controller.rb +6 -6
  100. data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_form.html.erb +2 -2
  101. data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_index.html.erb +8 -1
  102. data/lib/generators/{loam → open_loam}/install/templates/admin/sudo_controller.rb +7 -5
  103. data/lib/generators/{loam → open_loam}/install/templates/admin/sudo_new.html.erb +1 -1
  104. data/lib/generators/{loam → open_loam}/install/templates/admin/translations_controller.rb +7 -7
  105. data/lib/generators/{loam → open_loam}/install/templates/admin/translations_index.html.erb +3 -3
  106. data/lib/generators/{loam → open_loam}/install/templates/admin/webhook_endpoints_controller.rb +4 -4
  107. data/lib/generators/{loam → open_loam}/install/templates/admin/webhook_endpoints_index.html.erb +1 -1
  108. data/lib/generators/{loam → open_loam}/install/templates/admin.css +1 -1
  109. data/lib/generators/open_loam/install/templates/api_base_controller.rb +127 -0
  110. data/lib/generators/{loam → open_loam}/install/templates/guardrails_test.rb +17 -17
  111. data/lib/generators/open_loam/install/templates/import_job.rb +38 -0
  112. data/lib/generators/{loam → open_loam}/install/templates/inbound_webhooks_controller.rb +4 -4
  113. data/lib/generators/{loam → open_loam}/install/templates/initializer.rb +87 -75
  114. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_api_tokens.rb +15 -0
  115. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_audit_records.rb +14 -0
  116. data/lib/generators/{loam/install/templates/migrations/create_loam_auth_attempts.rb → open_loam/install/templates/migrations/create_open_loam_auth_attempts.rb} +3 -3
  117. data/lib/generators/{loam/install/templates/migrations/create_loam_business_rules.rb → open_loam/install/templates/migrations/create_open_loam_business_rules.rb} +10 -10
  118. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_comments.rb +13 -0
  119. data/lib/generators/{loam/install/templates/migrations/create_loam_configs.rb → open_loam/install/templates/migrations/create_open_loam_configs.rb} +7 -7
  120. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_custom_field_values.rb +19 -0
  121. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_dashboard_widgets.rb +12 -0
  122. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_dictionaries.rb +11 -0
  123. data/lib/generators/{loam/install/templates/migrations/create_loam_dictionary_entries.rb → open_loam/install/templates/migrations/create_open_loam_dictionary_entries.rb} +6 -6
  124. data/lib/generators/{loam/install/templates/migrations/create_loam_event_deliveries.rb → open_loam/install/templates/migrations/create_open_loam_event_deliveries.rb} +5 -5
  125. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_event_records.rb +13 -0
  126. data/lib/generators/{loam/install/templates/migrations/create_loam_field_definitions.rb → open_loam/install/templates/migrations/create_open_loam_field_definitions.rb} +4 -4
  127. data/lib/generators/{loam/install/templates/migrations/create_loam_inbound_webhooks.rb → open_loam/install/templates/migrations/create_open_loam_inbound_webhooks.rb} +9 -10
  128. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_memberships.rb +11 -0
  129. data/lib/generators/{loam/install/templates/migrations/create_loam_mfa_credentials.rb → open_loam/install/templates/migrations/create_open_loam_mfa_credentials.rb} +4 -4
  130. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_notifications.rb +15 -0
  131. data/lib/generators/{loam/install/templates/migrations/create_loam_pending_actions.rb → open_loam/install/templates/migrations/create_open_loam_pending_actions.rb} +9 -9
  132. data/lib/generators/{loam/install/templates/migrations/create_loam_perspectives.rb → open_loam/install/templates/migrations/create_open_loam_perspectives.rb} +5 -5
  133. data/lib/generators/{loam/install/templates/migrations/create_loam_progress_jobs.rb → open_loam/install/templates/migrations/create_open_loam_progress_jobs.rb} +5 -5
  134. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_record_locks.rb +16 -0
  135. data/lib/generators/{loam/install/templates/migrations/create_loam_scheduled_jobs.rb → open_loam/install/templates/migrations/create_open_loam_scheduled_jobs.rb} +5 -5
  136. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_search_tokens.rb +18 -0
  137. data/lib/generators/{loam/install/templates/migrations/create_loam_sso_providers.rb → open_loam/install/templates/migrations/create_open_loam_sso_providers.rb} +15 -12
  138. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_tenants.rb +9 -0
  139. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_translations.rb +15 -0
  140. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_webhook_endpoints.rb +13 -0
  141. data/lib/generators/{loam → open_loam}/install/templates/migrations/create_users.rb +1 -1
  142. data/lib/generators/{loam → open_loam}/install/templates/user.rb +5 -5
  143. data/lib/generators/{loam → open_loam}/primary_key_options.rb +23 -23
  144. data/lib/open-loam.rb +2 -2
  145. data/lib/{loam → open_loam}/attachable.rb +4 -4
  146. data/lib/{loam → open_loam}/auditable.rb +23 -23
  147. data/lib/{loam → open_loam}/auth_throttle.rb +20 -12
  148. data/lib/{loam → open_loam}/base32.rb +1 -1
  149. data/lib/{loam → open_loam}/bulk.rb +7 -7
  150. data/lib/{loam → open_loam}/business_rules/actions.rb +7 -7
  151. data/lib/{loam → open_loam}/business_rules/condition.rb +5 -5
  152. data/lib/{loam → open_loam}/business_rules.rb +19 -19
  153. data/lib/{loam → open_loam}/commentable.rb +6 -6
  154. data/lib/{loam → open_loam}/configs.rb +19 -19
  155. data/lib/{loam → open_loam}/cron.rb +2 -2
  156. data/lib/{loam → open_loam}/csv.rb +2 -2
  157. data/lib/{loam → open_loam}/current.rb +5 -5
  158. data/lib/{loam → open_loam}/custom_field_index.rb +33 -30
  159. data/lib/{loam → open_loam}/custom_fields.rb +17 -17
  160. data/lib/{loam → open_loam}/dashboard.rb +5 -5
  161. data/lib/{loam → open_loam}/dictionaries.rb +12 -12
  162. data/lib/{loam → open_loam}/durable_events.rb +19 -19
  163. data/lib/{loam → open_loam}/encryptable.rb +39 -34
  164. data/lib/{loam → open_loam}/encryption/cipher.rb +3 -3
  165. data/lib/{loam → open_loam}/encryption/key_provider.rb +19 -3
  166. data/lib/{loam → open_loam}/encryption.rb +58 -15
  167. data/lib/open_loam/engine.rb +65 -0
  168. data/lib/{loam → open_loam}/enrichers.rb +5 -5
  169. data/lib/{loam → open_loam}/errors.rb +20 -10
  170. data/lib/{loam → open_loam}/eval.rb +2 -2
  171. data/lib/open_loam/event_log.rb +77 -0
  172. data/lib/{loam → open_loam}/event_stream.rb +14 -14
  173. data/lib/{loam → open_loam}/eventful.rb +12 -12
  174. data/lib/{loam → open_loam}/events.rb +5 -5
  175. data/lib/{loam → open_loam}/export.rb +12 -12
  176. data/lib/{loam → open_loam}/features.rb +15 -15
  177. data/lib/{loam → open_loam}/generated_key.rb +6 -6
  178. data/lib/{loam → open_loam}/import.rb +12 -15
  179. data/lib/{loam → open_loam}/inbound_webhooks.rb +19 -18
  180. data/lib/{loam → open_loam}/lifecycle.rb +54 -25
  181. data/lib/{loam/locales/loam.en.yml → open_loam/locales/open_loam.en.yml} +5 -5
  182. data/lib/{loam → open_loam}/mcp/server.rb +9 -9
  183. data/lib/{loam → open_loam}/mcp.rb +31 -22
  184. data/lib/{loam → open_loam}/notifications.rb +5 -5
  185. data/lib/{loam → open_loam}/open_api.rb +16 -16
  186. data/lib/open_loam/outbound_url.rb +100 -0
  187. data/lib/{loam → open_loam}/overrides.rb +10 -10
  188. data/lib/{loam → open_loam}/pending_actions.rb +9 -9
  189. data/lib/{loam → open_loam}/permissions.rb +8 -8
  190. data/lib/{loam → open_loam}/perspectives.rb +9 -9
  191. data/lib/{loam → open_loam}/policy.rb +21 -8
  192. data/lib/{loam → open_loam}/progress.rb +6 -6
  193. data/lib/{loam → open_loam}/record_locks.rb +9 -9
  194. data/lib/{loam → open_loam}/scheduler.rb +20 -20
  195. data/lib/{loam → open_loam}/search/driver.rb +3 -3
  196. data/lib/{loam → open_loam}/search/like_driver.rb +5 -5
  197. data/lib/{loam → open_loam}/search/token_driver.rb +16 -16
  198. data/lib/{loam → open_loam}/search.rb +5 -5
  199. data/lib/{loam → open_loam}/searchable.rb +22 -22
  200. data/lib/{loam → open_loam}/soft_deletable.rb +10 -10
  201. data/lib/{loam → open_loam}/sso/claims.rb +1 -1
  202. data/lib/{loam → open_loam}/sso/fake_provider.rb +1 -1
  203. data/lib/open_loam/sso/http_client.rb +51 -0
  204. data/lib/{loam → open_loam}/sso/oidc_provider.rb +2 -2
  205. data/lib/{loam → open_loam}/sso.rb +39 -18
  206. data/lib/{loam → open_loam}/telemetry.rb +6 -6
  207. data/lib/{loam → open_loam}/tenant_record.rb +10 -10
  208. data/lib/{loam → open_loam}/test_helpers.rb +4 -4
  209. data/lib/{loam → open_loam}/totp.rb +3 -3
  210. data/lib/{loam → open_loam}/translatable.rb +24 -24
  211. data/lib/{loam → open_loam}/undo.rb +11 -11
  212. data/lib/open_loam/version.rb +3 -0
  213. data/lib/{loam → open_loam}/webhooks.rb +4 -4
  214. data/lib/{loam → open_loam}/widgets.rb +9 -9
  215. data/lib/{loam → open_loam}/workflow.rb +45 -45
  216. data/lib/open_loam.rb +124 -0
  217. data/lib/tasks/open_loam.rake +203 -0
  218. data/lib/tasks/{loam_eval.rake → open_loam_eval.rake} +7 -7
  219. data/lib/tasks/open_loam_mcp.rake +20 -0
  220. metadata +247 -242
  221. data/app/models/loam/api_token.rb +0 -40
  222. data/app/models/loam/dictionary.rb +0 -20
  223. data/app/models/loam/search_token.rb +0 -9
  224. data/app/models/loam/sso_provider.rb +0 -35
  225. data/app/models/loam/tenant.rb +0 -20
  226. data/app/models/loam/webhook_endpoint.rb +0 -26
  227. data/lib/generators/loam/entity/templates/views/edit.html.erb +0 -3
  228. data/lib/generators/loam/entity/templates/views/index.html.erb +0 -90
  229. data/lib/generators/loam/entity/templates/views/new.html.erb +0 -3
  230. data/lib/generators/loam/entity/templates/views/show.html.erb +0 -36
  231. data/lib/generators/loam/install/templates/AGENTS.md +0 -341
  232. data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +0 -30
  233. data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +0 -10
  234. data/lib/generators/loam/install/templates/admin/history_controller.rb +0 -38
  235. data/lib/generators/loam/install/templates/admin/layout.html.erb +0 -76
  236. data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +0 -19
  237. data/lib/generators/loam/install/templates/api_base_controller.rb +0 -90
  238. data/lib/generators/loam/install/templates/import_job.rb +0 -25
  239. data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +0 -15
  240. data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +0 -14
  241. data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +0 -13
  242. data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +0 -19
  243. data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +0 -12
  244. data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +0 -11
  245. data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +0 -11
  246. data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +0 -15
  247. data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +0 -16
  248. data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +0 -18
  249. data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +0 -9
  250. data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +0 -15
  251. data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +0 -13
  252. data/lib/loam/engine.rb +0 -57
  253. data/lib/loam/sso/http_client.rb +0 -42
  254. data/lib/loam/version.rb +0 -3
  255. data/lib/loam.rb +0 -122
  256. data/lib/tasks/loam.rake +0 -171
  257. data/lib/tasks/loam_mcp.rake +0 -20
  258. /data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_edit.html.erb +0 -0
  259. /data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_index.html.erb +0 -0
  260. /data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_new.html.erb +0 -0
  261. /data/lib/generators/{loam → open_loam}/install/templates/admin/configs_edit.html.erb +0 -0
  262. /data/lib/generators/{loam → open_loam}/install/templates/admin/configs_index.html.erb +0 -0
  263. /data/lib/generators/{loam → open_loam}/install/templates/admin/dashboard_index.html.erb +0 -0
  264. /data/lib/generators/{loam → open_loam}/install/templates/admin/dashboard_widgets_index.html.erb +0 -0
  265. /data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_edit.html.erb +0 -0
  266. /data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_index.html.erb +0 -0
  267. /data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_new.html.erb +0 -0
  268. /data/lib/generators/{loam → open_loam}/install/templates/admin/features_index.html.erb +0 -0
  269. /data/lib/generators/{loam → open_loam}/install/templates/admin/field_definitions_index.html.erb +0 -0
  270. /data/lib/generators/{loam → open_loam}/install/templates/admin/imports_new.html.erb +0 -0
  271. /data/lib/generators/{loam → open_loam}/install/templates/admin/imports_preview.html.erb +0 -0
  272. /data/lib/generators/{loam → open_loam}/install/templates/admin/imports_summary.html.erb +0 -0
  273. /data/lib/generators/{loam → open_loam}/install/templates/admin/inbound_webhook_sources_index.html.erb +0 -0
  274. /data/lib/generators/{loam → open_loam}/install/templates/admin/mfa_activated.html.erb +0 -0
  275. /data/lib/generators/{loam → open_loam}/install/templates/admin/mfa_new.html.erb +0 -0
  276. /data/lib/generators/{loam → open_loam}/install/templates/admin/mfa_show.html.erb +0 -0
  277. /data/lib/generators/{loam → open_loam}/install/templates/admin/notifications_index.html.erb +0 -0
  278. /data/lib/generators/{loam → open_loam}/install/templates/admin/pagination.rb +0 -0
  279. /data/lib/generators/{loam → open_loam}/install/templates/admin/pending_actions_index.html.erb +0 -0
  280. /data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_edit.html.erb +0 -0
  281. /data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_new.html.erb +0 -0
  282. /data/lib/generators/{loam → open_loam}/install/templates/admin/search_index.html.erb +0 -0
  283. /data/lib/generators/{loam → open_loam}/install/templates/admin/sessions_mfa_challenge.html.erb +0 -0
  284. /data/lib/generators/{loam → open_loam}/install/templates/admin/sessions_new.html.erb +0 -0
  285. /data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_edit.html.erb +0 -0
  286. /data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_new.html.erb +0 -0
  287. /data/lib/generators/{loam → open_loam}/install/templates/admin/webhook_endpoints_new.html.erb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc673a0615dde0e5fb30f18d5b3f463fde57a87528e20d26ce8a97455240de5d
4
- data.tar.gz: 62413655b212a0fcdd2ded4ae0eafb249e2fa1060b70b96f0d6e66ad1a2f6480
3
+ metadata.gz: 48bb3940900ff3d8ee4f6a8dc499779b3d00ed9ff37ceeab4849d36662377a47
4
+ data.tar.gz: 65440f973fc99d793ae2439cb1a1b373cd7f79d41c82398fd8d59c6e4dcba784
5
5
  SHA512:
6
- metadata.gz: 78bda4ab675f9d4e47c4fafa538cc340c21109c4fa82af6ca203596e41d57091c833d3e8868122640ffef5158f30188ffaf7d305a8f4e72e194525ac0e754e9b
7
- data.tar.gz: bb22f492bca6bba9de01bea58c57465a67d0b5f6ef1b92ca8cb8bb6ffadc9c533d8ce3915ca8fe51e3c0278da9d4aa4a48501dd3a4d94dabadc4547a4667efd8
6
+ metadata.gz: a091472e49805c7fffe3aa95a6ddfd0ffc02fea5c7e8d368443be195854e5bdd87b4b7b7635d4c7024842d4a62015d55e6a63064009d13cce8158fff3b7bb207
7
+ data.tar.gz: 10546c0bc5b0e6facacf2ede136cd930aa3851c833ae05aa908f990bc60ea02db3efccf1483b5577c0ba4d5cf7136b369c31956a3d1c989444824a1f87836c08
data/CHANGELOG.md CHANGED
@@ -1,10 +1,150 @@
1
1
  # Changelog
2
2
 
3
- Notable changes to Loam. This project follows [semantic versioning](https://semver.org),
3
+ Notable changes to OpenLoam. This project follows [semantic versioning](https://semver.org),
4
4
  with the caveat that 0.x releases may break the public surface; the
5
5
  [backward-compatibility contract](BACKWARD_COMPATIBILITY.md) names what is
6
6
  frozen and what is not.
7
7
 
8
+ ## 0.3.0 — 2026-09-05
9
+
10
+ ### Security
11
+
12
+ A full-repo audit found 16 issues. Fifteen are fixed here, each with a regression
13
+ test verified by reverting the fix and confirming the test fails; the sixteenth
14
+ is the demo app's committed `master.key`, which is accepted — the demo holds no
15
+ real data and must never be deployed. **Anyone running 0.2.0 or earlier should
16
+ upgrade**, and the migration below is not optional: three steps change stored
17
+ data.
18
+
19
+ Worst first:
20
+
21
+ - **Cross-tenant account takeover through SSO.** A tenant could register a domain
22
+ it did not own and be handed the matching global account, then select any
23
+ tenant that account belonged to. A provider now does nothing until an operator
24
+ confirms ownership out of band (`rake open_loam:sso:verify_domain`), and
25
+ editing `domain` revokes the confirmation.
26
+ - **The MFA lockout was bypassable** — a password success cleared the TOTP
27
+ counter, so a 6-digit code could be brute-forced without limit. Each factor now
28
+ clears only its own attempts.
29
+ - **`readable:` field rules were ignored on most read paths** — the JSON API, the
30
+ generated admin index/show/form, the custom-fields partial, the CSV export,
31
+ the history screen and the edit-conflict diff all served restricted values.
32
+ Sorting by an unreadable column is refused too: ordering leaks a ranking even
33
+ when the values are hidden. A model with no policy class no longer falls back
34
+ to the base policy, which answered "any member" to everything.
35
+ - **API tokens were stored in plaintext.** A SHA-256 digest is persisted now and
36
+ the value is shown once. Offboarding a user ends their machine access, and
37
+ managers can revoke another member's token — previously nobody could.
38
+ - **SSRF through tenant-supplied URLs** — webhook endpoints and the SSO issuer
39
+ were both fetched unvalidated. `OpenLoam::OutboundUrl` checks shape at save and
40
+ pins the resolved address at fetch, so DNS rebinding cannot swap the target
41
+ after the check. The issuer must now be `https`.
42
+ - **Inbound webhook replay** — the dedupe key came from an unsigned header, so
43
+ one captured delivery could re-publish its event without limit. `external_id`
44
+ is now the body hash, the only signed material.
45
+ - **A staged change was applied without checks.** The target must now be a
46
+ `TenantRecord`, and approval runs the approver's own policy — both the action
47
+ (`create?`/`update?`/`destroy?`) and the fields the changeset writes.
48
+ - **`security.mfa_required_roles` was advisory** — enrollment was redirected to
49
+ at login and never checked again. Now enforced on every request.
50
+ - **Master-key rotation was impossible**, despite being the documented answer to
51
+ key compromise. Set `OPEN_LOAM_PREVIOUS_MASTER_KEY` to the outgoing key and
52
+ `open_loam:encryption:rotate` works.
53
+ - **The blind-index key ignored table and column**, so one value hashed alike
54
+ across every searchable encrypted column. It now binds both, like the AAD.
55
+ - **The uploaded import CSV** sat in the clear in the queue backend and the
56
+ request log. It goes to blob storage now, and the job purges it.
57
+ - **Brakeman never scanned the gem**, only `demo/`. CI now scans both, which
58
+ surfaced four warnings, all fixed. CI's `GITHUB_TOKEN` is read-only, and the
59
+ repository has secret scanning, push protection and private vulnerability
60
+ reporting enabled.
61
+
62
+ Report vulnerabilities through [private reporting](https://github.com/DeliveristsIO/open-loam/security/advisories/new),
63
+ not a public issue.
64
+
65
+ #### Migration
66
+
67
+ - `open_loam_api_tokens`: add `token_digest`, backfill `sha256(token)` per row,
68
+ drop `token`. Tokens in use keep working. See the demo's
69
+ `hash_open_loam_api_tokens`.
70
+ - `open_loam_sso_providers`: add `domain_verified_at`, then run
71
+ `rake open_loam:sso:verify_domain[<id>]` for each provider you actually own.
72
+ Until you do, SSO will not resolve for it.
73
+ - Searchable encrypted fields: re-index by running
74
+ `rake open_loam:encryption:rotate[Model,tenant_id]` for each model that has
75
+ one. The blind-index derivation changed, so existing `<field>_hash` values no
76
+ longer match.
77
+ - `delivery_id_header` on inbound webhook sources is gone; the column can be
78
+ dropped.
79
+
80
+ ### Added
81
+
82
+ - **The event log — `OpenLoam::EventLog` + `OpenLoam::EventRecord`.** Every
83
+ published event is captured as an append-only, tenant-scoped row, so a
84
+ tenant's history is queryable and replayable rather than only observable live.
85
+ `EventLog.read(name_or_prefix, since:, limit:)` and `EventLog.replay(...)` take
86
+ the same patterns as `Events.subscribe`. `OpenLoam::DurableEvents` had made
87
+ *delivery* durable; this closes *capture*, which its contract explicitly did
88
+ not cover.
89
+
90
+ Capture is on by default and captures everything except
91
+ `OpenLoam.uncaptured_events` (default: `["open_loam.progress."]`). It runs
92
+ inline, so a failed insert propagates into the publishing operation. Retention
93
+ is `OpenLoam.event_log_retention` (90 days), swept per tenant by
94
+ `OpenLoam::EventLogPruneJob`. New apps get the table from
95
+ `rails g open_loam:install`; existing apps need the
96
+ `create_open_loam_event_records` migration.
97
+
98
+ - **An authorization-called guard in the generated base controllers** (L-202).
99
+ `verify_authorized!` fails any admin or API action that finished without
100
+ calling `authorize!`, `require_role!` or `require_permission!`. Forgetting the
101
+ check was previously silent — the screen just rendered. Screens authorized
102
+ structurally declare `skip_authorization! "<reason>"`, and the reason is
103
+ required, so every exemption is a documented claim.
104
+
105
+ It runs *after* the action, so it is a development and test guard, not a
106
+ runtime access-control layer. It raises `OpenLoam::AuthorizationNotPerformedError`,
107
+ deliberately not a `NotAuthorizedError` — a developer bug must not render as a
108
+ polite 403.
109
+
110
+ Turning it on found two generated actions that never checked `read?` while
111
+ their `show`/`deleted` siblings did: the admin and JSON `index`. Both now
112
+ authorize. Existing apps will see the guard fire on any action of their own
113
+ that never authorized.
114
+
115
+ ### Changed
116
+
117
+ - **The four "wrap a proven gem" roadmap items are resolved**, recorded in
118
+ [ADR 0007](docs/_adr/0007-proven-gem-swaps-resolved.md). Tenancy (L-201) and
119
+ audit (L-203) stay in-gem for good; the Pundit swap (L-202) is declined, but
120
+ the gap it exposed — no `verify_authorized`-equivalent guard — is tracked
121
+ separately. L-204 is the event log above. No public `OpenLoam::` contract
122
+ changed.
123
+
124
+ ## 0.2.0 — 2026-09-05
125
+
126
+ ### BREAKING
127
+
128
+ - **Ruby namespace renamed `Loam` → `OpenLoam`**, and every dependent
129
+ surface with it: the CLI (`rails g open_loam:install`,
130
+ `rails g open_loam:entity`, `bin/rails open_loam:mcp:serve`, etc.), all
131
+ `loam_*`-prefixed database tables and columns, `LOAM_*` env vars (now
132
+ `OPEN_LOAM_*`), and the outbound webhook signature header
133
+ (`X-Loam-Signature` → `X-OpenLoam-Signature`, tracked in
134
+ [BACKWARD_COMPATIBILITY.md](BACKWARD_COMPATIBILITY.md)). The RubyGems
135
+ package name is unaffected — it stays `open-loam`, as it already was.
136
+ - No back-compat shim is provided for any of this. Per 0.1.0/0.1.1 above, no
137
+ production deployment exists yet, so there is nothing running against the
138
+ old names to break in place — an app installed from an 0.1.x generator
139
+ would need its own table/env-var renames to upgrade, which is why this
140
+ ships as a breaking 0.x release rather than a deprecation cycle.
141
+ - One literal is deliberately **not** renamed, ever: the encryption
142
+ key-derivation inputs in `lib/open_loam/encryption/key_provider.rb`
143
+ (the HKDF `SALT` and `info` strings) and the AAD prefix in
144
+ `lib/open_loam/encryption.rb#aad`. These are inputs to key/tag
145
+ derivation, not identifiers — changing any of them would silently
146
+ break decryption of already-encrypted data.
147
+
8
148
  ## 0.1.1 — 2026-09-04
9
149
 
10
150
  No functional change. The packaged code is byte-identical to 0.1.0 — only CI
@@ -23,14 +163,14 @@ matters.
23
163
 
24
164
  ## 0.1.0 — 2026-09-04
25
165
 
26
- First public release. Loam is a working, tested prototype: the foundation and
166
+ First public release. OpenLoam is a working, tested prototype: the foundation and
27
167
  the agent workflow are complete end to end, but no production deployment has
28
168
  used it yet. Evaluate it as a prototype, not as proven infrastructure.
29
169
 
30
170
  ### The foundation
31
171
 
32
- - **Multi-tenancy** — `Loam::TenantRecord` scopes every query, job and event to
33
- the current tenant. A missing tenant context raises `Loam::MissingTenantError`
172
+ - **Multi-tenancy** — `OpenLoam::TenantRecord` scopes every query, job and event to
173
+ the current tenant. A missing tenant context raises `OpenLoam::MissingTenantError`
34
174
  rather than silently widening a query.
35
175
  - **Authorization** — roles, policies, field-level write access, and
36
176
  deny-by-default wildcard feature permissions (`equipment.*`).
@@ -56,13 +196,13 @@ used it yet. Evaluate it as a prototype, not as proven infrastructure.
56
196
 
57
197
  - **`AGENTS.md`** — the contract, generated into the host app, telling an agent
58
198
  where code belongs and which boundaries it must preserve.
59
- - **Generators as the interface** — `loam:install` and `loam:entity` are the
199
+ - **Generators as the interface** — `open_loam:install` and `open_loam:entity` are the
60
200
  supported way to add a feature, for humans and agents alike.
61
201
  - **Structural guardrails** — tests that fail the build on a missing tenant
62
202
  scope, a stray `.unscoped`, or an oversized `AGENTS.md`.
63
- - **A human-approval gate** — `Loam::PendingActions` stages an agent-proposed
203
+ - **A human-approval gate** — `OpenLoam::PendingActions` stages an agent-proposed
64
204
  mutation for review before it touches business data.
65
- - **The golden-tasks benchmark** and `Loam::Eval` scorer. Results, methodology
205
+ - **The golden-tasks benchmark** and `OpenLoam::Eval` scorer. Results, methodology
66
206
  and caveats are published at
67
207
  <https://deliveristsio.github.io/open-loam/agents/golden-tasks/>; they come
68
208
  from a single internal run and are not independently reproduced.
@@ -74,7 +214,7 @@ used it yet. Evaluate it as a prototype, not as proven infrastructure.
74
214
  `config.generators` setting, then `bigint`. This covers `create_table`,
75
215
  `t.references`, and the polymorphic `*_id` columns that cannot use
76
216
  `t.references`.
77
- - `Loam::GeneratedKey` assigns a UUID before create when the key is not an
217
+ - `OpenLoam::GeneratedKey` assigns a UUID before create when the key is not an
78
218
  integer, since a string primary key has no database default. Integer keys are
79
219
  still generated by the database.
80
220
 
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Grzegorz Smajdor and Loam contributors
3
+ Copyright (c) 2026 Grzegorz Smajdor and OpenLoam contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Loam 🌱
1
+ # OpenLoam 🌱
2
2
 
3
- **In short:** Loam is a Rails starter kit for business apps — multi-tenancy,
3
+ **In short:** OpenLoam is a Rails starter kit for business apps — multi-tenancy,
4
4
  permissions, audit trails, workflows, and an event bus already built in, so you
5
5
  skip months of plumbing and start on real features. It's also AI-native:
6
6
  convention-driven code that AI coding agents (Claude Code, Codex, etc.) can
@@ -12,7 +12,7 @@ safely extend, with human-approval gates before any agent write takes effect.
12
12
 
13
13
  ---
14
14
 
15
- ## Why Loam exists
15
+ ## Why OpenLoam exists
16
16
 
17
17
  Every serious back-office app — CRM, ERP, ops console, internal tool — re-derives
18
18
  the same ~80%: who's the tenant, who's allowed, what changed and when, how do
@@ -24,13 +24,13 @@ write real features — *if* the codebase is legible to them. Sprawling, snowfla
24
24
  architectures confuse agents as much as they confuse new hires. Convention is
25
25
  what makes a codebase safe for an agent to extend.
26
26
 
27
- Loam fuses the two: **a pre-built business foundation that is deliberately
27
+ OpenLoam fuses the two: **a pre-built business foundation that is deliberately
28
28
  agent-legible.** Rails already leans convention-over-configuration — arguably the
29
- most agent-friendly framework there is. Loam extends that philosophy from "how to
29
+ most agent-friendly framework there is. OpenLoam extends that philosophy from "how to
30
30
  structure a controller" up to "how a multi-tenant, permissioned, audited business
31
31
  domain is built" — and ships the agent conventions to match.
32
32
 
33
- ## Where Loam sits
33
+ ## Where OpenLoam sits
34
34
 
35
35
  The pieces exist in Rails, but scattered — foundation shape in Bullet Train and
36
36
  the commerce products, an event backbone in Rails Event Store, custom-entity
@@ -39,7 +39,7 @@ agent-legible business foundation. The closest structural analogs live in other
39
39
  stacks: Frappe/ERPNext in Python, and
40
40
  **[Open Mercato](https://github.com/open-mercato/open-mercato)** in TypeScript —
41
41
  whose module system and convention-first, agent-legible approach directly
42
- inspired Loam. Loam brings that idea to Rails, the substrate it always suited.
42
+ inspired OpenLoam. OpenLoam brings that idea to Rails, the substrate it always suited.
43
43
 
44
44
  ---
45
45
 
@@ -51,21 +51,21 @@ need to — never a blank page.
51
51
  | Pillar | What you get, out of the box |
52
52
  |--------|------------------------------|
53
53
  | 🏢 **Multi-tenancy** | Tenant isolation baked into every query, background job, and event. New models are tenant-scoped by default; a missing tenant context raises, never silently widens a query. |
54
- | 🔐 **Permissions & auth** | Password login, roles, policies, and field-level write access — declared, not hand-rolled per controller. Tenant selection limited to a user's memberships. Plus **feature-string permissions** (`Loam::Permissions`): grant a role wildcard capability strings (`equipment.*`) and check `Loam.can?("equipment.edit")` / `require_permission!` — deny-by-default, a finer layer under the coarse role. |
55
- | 🌾 **Custom fields** | Define fields at runtime (a `custom_fields` JSON column + a `Loam::FieldDefinition` row), so agents and admins extend a model without a migration for every idea. Filtering and sorting on a custom field is **index-backed** at scale via a typed read-model projection (`Loam::CustomFieldIndex`), not a per-row JSON scan — with **coverage accounting** (is the index complete or drifting?), a read-time gap that **falls back to the authoritative source for correctness** and **self-heals** in the background (deduped), and an honest "results may be incomplete" signal while it does. A field can declare `readable_roles`: filtering or sorting on a field a role **may not read** is refused, so a filter can't become an **inference oracle** on a restricted value. |
54
+ | 🔐 **Permissions & auth** | Password login, roles, policies, and field-level write access — declared, not hand-rolled per controller. Tenant selection limited to a user's memberships. Plus **feature-string permissions** (`OpenLoam::Permissions`): grant a role wildcard capability strings (`equipment.*`) and check `OpenLoam.can?("equipment.edit")` / `require_permission!` — deny-by-default, a finer layer under the coarse role. |
55
+ | 🌾 **Custom fields** | Define fields at runtime (a `custom_fields` JSON column + a `OpenLoam::FieldDefinition` row), so agents and admins extend a model without a migration for every idea. Filtering and sorting on a custom field is **index-backed** at scale via a typed read-model projection (`OpenLoam::CustomFieldIndex`), not a per-row JSON scan — with **coverage accounting** (is the index complete or drifting?), a read-time gap that **falls back to the authoritative source for correctness** and **self-heals** in the background (deduped), and an honest "results may be incomplete" signal while it does. A field can declare `readable_roles`: filtering or sorting on a field a role **may not read** is refused, so a filter can't become an **inference oracle** on a restricted value. |
56
56
  | 🔀 **Workflow** | Declared states, transitions, and role-gated approvals on any entity; each transition emits an event and is audited. |
57
- | 📡 **Event backbone** | A first-class domain event bus (`domain.thing.happened`, publish/subscribe) so modules stay decoupled and workflows are legible. Two subscriber tiers with a **formal contract**: *ephemeral* (`Loam::Events.subscribe`, in-process, synchronous, best-effort) for cheap fan-out, and *durable* (`Loam::DurableEvents.register`) which **persists each delivery as a row** in the event's tenant and hands it to a background job — **at-least-once with retry + backoff**, a **dead-letter** view with manual requeue, and a periodic **sweep** that redelivers a lost job (row state, not the queue, is the source of truth). A handler is resolved from a boot-time registry, never constantized from the stored row. |
57
+ | 📡 **Event backbone** | A first-class domain event bus (`domain.thing.happened`, publish/subscribe) so modules stay decoupled and workflows are legible. Two subscriber tiers with a **formal contract**: *ephemeral* (`OpenLoam::Events.subscribe`, in-process, synchronous, best-effort) for cheap fan-out, and *durable* (`OpenLoam::DurableEvents.register`) which **persists each delivery as a row** in the event's tenant and hands it to a background job — **at-least-once with retry + backoff**, a **dead-letter** view with manual requeue, and a periodic **sweep** that redelivers a lost job (row state, not the queue, is the source of truth). A handler is resolved from a boot-time registry, never constantized from the stored row. |
58
58
  | 🔔 **Notifications** | Tenant-scoped in-app notifications, created from events, surfaced in the admin. |
59
59
  | 🔌 **API & webhooks** | Token-authenticated JSON API per entity (policy-aware) and per-tenant signed **outbound** webhooks on domain events. **Inbound** webhooks too: a public `/webhooks/:token` receiver that HMAC-verifies each call over the raw body, resists replays (a `(source, delivery-id)` idempotency ledger), answers every auth failure with a uniform `401`, and publishes the verified event onto the bus so durable subscribers react. |
60
60
  | 🧾 **Audit** | Every change — who, what, when, in which tenant — recorded by default. |
61
61
  | ↩️ **Undo / history** | The audit trail is an **undo stack**: each record's History screen reverts a change with one click, and the undo is itself recorded — so undoing an `undo` is redo. Walks back one step at a time (never clobbers a newer edit); **encrypted fields and workflow state are never reverted** here (state changes undo via the reverse transition). |
62
62
  | 🗑️ **Soft-delete** | Deleting a record hides it instead of erasing it — excluded from every query by default, still tenant-scoped in the recycle bin, restorable, and recorded in the audit trail. |
63
63
  | ⚙️ **Settings** | A key-value settings store with a global default and a per-tenant override — typed values, resolved override → global → default, cached per request, and never leaking between tenants. |
64
- | 🚩 **Feature flags** | Runtime on/off capabilities per tenant for rollout or kill-switch — a global default plus per-tenant override, a `Loam::Features.on?` guard, and an admin screen. Gates a **capability**, not a person — distinct from roles and policies. |
64
+ | 🚩 **Feature flags** | Runtime on/off capabilities per tenant for rollout or kill-switch — a global default plus per-tenant override, a `OpenLoam::Features.on?` guard, and an admin screen. Gates a **capability**, not a person — distinct from roles and policies. |
65
65
  | 🔒 **Encryption at rest** | Mark a field `encrypts` and it is transparently AES-256-GCM encrypted with a **per-tenant** key (HKDF, KMS-pluggable) and decrypted on read — a DB dump leaks nothing and tenant A's key never opens tenant B's data. A keyed blind index keeps an encrypted email/phone findable by exact match; the audit trail records the change, never the value. |
66
66
  | 🔑 **MFA & step-up auth** | TOTP second factor for admin login (RFC 6238, no dependency), with single-use recovery codes; the secret is encrypted per-user so it verifies in any tenant. `require_sudo!` re-challenges for sensitive actions within a short window — orthogonal to role. MFA can be required per role. Failed password / TOTP / sudo attempts are **rate-limited and locked out** (per-identifier, configurable), so an online brute-force of a 6-digit code is throttled — and a lockout is enumeration-safe (a locked known and unknown identifier respond identically). |
67
67
  | 🚦 **AI approval gate** | An agent running under confirm-mode **stages** a write as a `PendingAction` with a before/after preview instead of committing it; a manager approves (a role-gated workflow transition) and only then does it execute — audited to the approver. The human-in-the-loop primitive for agent writes; encrypted fields never appear in the preview or audit. |
68
- | 🤖 **MCP server** | An [MCP](https://modelcontextprotocol.io) server (`bin/rails loam:mcp:serve`, stdio) exposes Loam to an AI agent: discover entities/schema/policy, **read** tenant-scoped records (only fields the role may see), and **propose** writes that are *staged for human approval* — never committed. Every gate is a Loam gate reused (tenancy, read-ACL, the approval gate); the agent acts as its API token's user, no more. |
68
+ | 🤖 **MCP server** | An [MCP](https://modelcontextprotocol.io) server (`bin/rails open_loam:mcp:serve`, stdio) exposes OpenLoam to an AI agent: discover entities/schema/policy, **read** tenant-scoped records (only fields the role may see), and **propose** writes that are *staged for human approval* — never committed. Every gate is a OpenLoam gate reused (tenancy, read-ACL, the approval gate); the agent acts as its API token's user, no more. |
69
69
  | 👓 **Saved views** | A user names a view of an entity's admin index — filters, sort, columns — and keeps it private, shares it to a role, or makes it the tenant default. Filters only ever touch whitelisted data columns; a stored view is optimistic-locked so shared edits don't clobber. |
70
70
  | 🔏 **Concurrent-edit safety** | Optimistic locking (`lock_version`) turns a stale save into a clean "this changed since you opened it" conflict — with a diff and a retry, never a silent clobber — and an advisory `RecordLock` shows "Anna is editing this" with a manager take-over. The version check is the guarantee; the lock is the courtesy. |
71
71
  | 📡 **Real-time updates** | A per-tenant Server-Sent-Events stream pushes selected events to the browser — the notification bell increments live, no polling. Opt-in per event pattern (default off, tenant- and audience-filtered), behind a broadcaster seam so Redis/SolidCable drops in for multi-process. |
@@ -74,24 +74,24 @@ need to — never a blank page.
74
74
  | 🔎 **Pluggable search** | `searchable_by` and `Model.search(q)` stay put; the strategy behind them is a swappable **driver**. Ships two: a portable substring **LIKE** (default, zero-setup) and a **word-level token index** (order-independent, AND-semantics, still plain SQL — no external service), with the seam ready for Meilisearch/Elasticsearch. Swapping is a one-line initializer change, no call-site edits. Tenant-scoped, and an encrypted field's plaintext is never tokenized. |
75
75
  | 🪪 **SSO (OIDC)** | Per-tenant single sign-on: each tenant connects its own identity provider. **Home-realm discovery** routes a user to their IdP by email domain; a verified identity is **just-in-time provisioned** (or linked to an existing account), with IdP group → role mapping. The client secret is encrypted at rest (per-tenant key). An unverified email is refused — no silent account takeover. OIDC ships end-to-end; SAML and SCIM are documented seams behind a protocol interface. |
76
76
  | 📚 **Dictionaries** | Per-tenant managed lookup lists — named sets of entries (value/label/color/icon/position/default) an admin curates without a deploy. Usable as a **custom-field type**: a `dictionary` field renders a select of the list's active entries and stores the chosen value, showing its label on read. Tenant-scoped and cached per request. |
77
- | ⏳ **Task progress** | A long-running job (import, reindex, report) reports percent / counts / ETA to the admin, pushed **live over SSE** — no polling. `Loam::Progress.start`/`advance`/`complete!`; the browser bar moves as the job runs. The broadcast is throttled to once per whole percent, the job supports a cooperative cancel, and a stalled job (dead heartbeat) is flagged. Tenant-scoped; the frame carries only id/percent/status. |
78
- | 🕰️ **Scheduler** | Per-tenant recurring jobs — cron (`0 7 * * *`) or interval — that enqueue an ActiveJob on schedule. A runner (`loam:scheduler:tick`, wired to system cron) claims due jobs **atomically** (Postgres `SKIP LOCKED`; SQLite a transactional claim), so multiple workers **never double-fire** one. `job_class` is whitelisted to a real ActiveJob (no arbitrary code). Tenant-scope jobs run per tenant; system-scope once. A stdlib cron-next calculator (no gem), timezone-aware. |
77
+ | ⏳ **Task progress** | A long-running job (import, reindex, report) reports percent / counts / ETA to the admin, pushed **live over SSE** — no polling. `OpenLoam::Progress.start`/`advance`/`complete!`; the browser bar moves as the job runs. The broadcast is throttled to once per whole percent, the job supports a cooperative cancel, and a stalled job (dead heartbeat) is flagged. Tenant-scoped; the frame carries only id/percent/status. |
78
+ | 🕰️ **Scheduler** | Per-tenant recurring jobs — cron (`0 7 * * *`) or interval — that enqueue an ActiveJob on schedule. A runner (`open_loam:scheduler:tick`, wired to system cron) claims due jobs **atomically** (Postgres `SKIP LOCKED`; SQLite a transactional claim), so multiple workers **never double-fire** one. `job_class` is whitelisted to a real ActiveJob (no arbitrary code). Tenant-scope jobs run per tenant; system-scope once. A stdlib cron-next calculator (no gem), timezone-aware. |
79
79
  | 📥 **Bulk import / export** | CSV **export** of any entity's current filtered view — policy- and encryption-aware (an encrypted field is redacted, never a plaintext dump). CSV **import** with a column-mapping engine: dedupe by a key (update-or-create), per-row validation with a skipped-row error log and a **downloadable error file**, a **dry-run** that commits nothing, and live progress (backgrounded, reported via the task bar). The mapping only targets policy-permitted fields — no crafted column reaches `tenant_id` or a field a role can't write. Plus **datatable bulk actions** (select rows → soft-delete / set-field / export), policy-checked per record and tenant-scoped. |
80
80
  | 📊 **Configurable dashboard** | The admin home is a grid of module-provided **widgets** on a registry — a metric or short list each. A manager picks which widgets appear and in what order, per tenant; a widget's `roles:` filter is enforced server-side (a hidden widget's data is never even computed). Widgets query tenant-scoped models (no cross-tenant leak), and a raising widget is isolated into an error tile — the dashboard never breaks. Ships built-ins (recent activity, unread notifications, pending approvals, running tasks). |
81
- | 📜 **Auto OpenAPI** | The JSON API documents itself. `Loam::OpenApi` introspects the generated per-entity API controllers — columns/types, exposed fields, custom fields, the bearer-token security scheme, and the tenancy guarantee — into an **OpenAPI 3.1** document, with **no hand-written annotations and no external gem**. A server-rendered explorer at `/admin/api_docs` (no Swagger-UI/external JS), a `.json` endpoint for tooling, and `loam:openapi:export` for CI. Request schemas expose only writable fields (never `tenant_id`); encrypted fields are typed as plain strings — the doc describes shape, never data. |
81
+ | 📜 **Auto OpenAPI** | The JSON API documents itself. `OpenLoam::OpenApi` introspects the generated per-entity API controllers — columns/types, exposed fields, custom fields, the bearer-token security scheme, and the tenancy guarantee — into an **OpenAPI 3.1** document, with **no hand-written annotations and no external gem**. A server-rendered explorer at `/admin/api_docs` (no Swagger-UI/external JS), a `.json` endpoint for tooling, and `open_loam:openapi:export` for CI. Request schemas expose only writable fields (never `tenant_id`); encrypted fields are typed as plain strings — the doc describes shape, never data. |
82
82
  | 🌐 **Content translations** | Translate the DATA in a record's fields per locale — a product name, a category label — distinct from Rails i18n (developer UI strings, still Rails-native). `translates :name` adds a read-time **overlay**: `record.name` returns the current locale's translation when one exists, else the record's own column (the base value, never lost). Locale is request state (a `/admin` switcher); translations are tenant-scoped, audited, additive rows. An **encrypted field can't be translated** — that would store plaintext, so it's refused at load. |
83
- | 🧬 **Override registry** | Disable or replace an entry in one of Loam's keyed registries — a built-in dashboard widget, a default broadcast pattern — from an initializer, **without forking or monkeypatching**: `Loam::Overrides.disable(:widgets, "open_progress")` / `.replace(:widgets, "audit_recent") { … }`. The value over a raw monkeypatch: a **stale override** (a key that no longer exists) is warned about at boot, so a typo isn't a silent no-op. Deliberately small — structural pieces (views, controllers, routes) still use standard Rails path-shadowing; this fills the gap for the in-gem registries. |
83
+ | 🧬 **Override registry** | Disable or replace an entry in one of OpenLoam's keyed registries — a built-in dashboard widget, a default broadcast pattern — from an initializer, **without forking or monkeypatching**: `OpenLoam::Overrides.disable(:widgets, "open_progress")` / `.replace(:widgets, "audit_recent") { … }`. The value over a raw monkeypatch: a **stale override** (a key that no longer exists) is warned about at boot, so a typo isn't a silent no-op. Deliberately small — structural pieces (views, controllers, routes) still use standard Rails path-shadowing; this fills the gap for the in-gem registries. |
84
84
  | 🖥️ **Admin surface** | An internal console generated from your models — comments, attachments, global search, filtering, pagination — not a second app to maintain. |
85
85
  | 🤖 **Agent conventions** | An `AGENTS.md` (byte-budgeted), generators as the one interface, and structural guardrails so an AI agent can add a domain feature **safely** — and a human can read what it did. |
86
86
 
87
- You write the **20% that is your business**. Loam is the 80% that every business
87
+ You write the **20% that is your business**. OpenLoam is the 80% that every business
88
88
  app shares.
89
89
 
90
90
  ---
91
91
 
92
92
  ## Agent-native by design
93
93
 
94
- Loam treats "an AI agent will extend this" as a first-class constraint:
94
+ OpenLoam treats "an AI agent will extend this" as a first-class constraint:
95
95
 
96
96
  - **One obvious way** to add an entity, a permission, an event, a screen — so an
97
97
  agent's output is predictable and reviewable.
@@ -111,7 +111,7 @@ for an agent or a human.
111
111
 
112
112
  **Working prototype — every pillar in the table above is built, tested, and
113
113
  exercised end to end** by a demo app, a generator harness, and an agent
114
- benchmark. Some two dozen `Loam::` modules, from tenancy through SSO, each
114
+ benchmark. Some two dozen `OpenLoam::` modules, from tenancy through SSO, each
115
115
  added the same way: a small in-gem implementation behind a convention, wired to
116
116
  agree with the rest.
117
117
 
@@ -119,7 +119,7 @@ agree with the rest.
119
119
 
120
120
  | Path | What it is |
121
121
  |------|-----------|
122
- | `lib/` | The `loam` gem — every pillar as a small `Loam::` module, plus the `loam:install` and `loam:entity` generators that are the whole interface. |
122
+ | `lib/` | The `open_loam` gem — every pillar as a small `OpenLoam::` module, plus the `open_loam:install` and `open_loam:entity` generators that are the whole interface. |
123
123
  | `demo/` | An equipment-rental app built with those generators (454 tests / 1,534 assertions as of 2026-08-22), carrying the generated guardrail tests: tenant isolation, no-context-raises, a lint against `.unscoped` in `app/`, and a 32 KB `AGENTS.md` budget. |
124
124
  | `ai/` | The agent benchmark — `golden_tasks.md` and recorded runs. First run: **10/10 tasks, zero isolation or authorization violations**; a vanilla-Rails control under the same prompts enforced isolation in **1/10**. |
125
125
  | `docs/_agents/` | Deep-dive conventions (encryption, SSO, scheduler, …) linked from `AGENTS.md`, so the agent contract stays inside its byte budget. |
@@ -136,10 +136,12 @@ whole thesis in miniature.
136
136
  **How honest the "prototype" label is** — deliberately, each pillar is a
137
137
  *minimal in-gem implementation* rather than a wrapper around
138
138
  `acts_as_tenant`/`pundit`/`paper_trail`/Rails Event Store: the smallest surface
139
- that proves the conventions and the agent flow. Swapping the proven gems back in
140
- *behind the same `Loam::` conventions* is the roadmap, not a reversal. Custom
139
+ that proves the conventions and the agent flow. Those swaps have since been
140
+ evaluated one at a time and settled — the in-gem versions stay, and the one real
141
+ gap they exposed (event capture) was closed in-gem too, in
142
+ [ADR 0007](docs/_adr/0007-proven-gem-swaps-resolved.md). Custom
141
143
  fields use the portable Rails `json` column (not Postgres `jsonb`/GIN) because
142
- the demo runs on SQLite. See [How Loam works](docs/_foundation/overview.md) for
144
+ the demo runs on SQLite. See [How OpenLoam works](docs/_foundation/overview.md) for
143
145
  the pillar-by-pillar breakdown and the decisions behind them.
144
146
 
145
147
  **Try it**
@@ -161,14 +163,14 @@ showing the real commands and exactly what you *didn't* have to write.
161
163
  - [Getting started](https://deliveristsio.github.io/open-loam/getting-started/) — hands-on, install to first feature
162
164
  - [Overview](OVERVIEW.md) — plain-language product, use cases, evidence, and risks
163
165
  - [Concept & positioning](https://deliveristsio.github.io/open-loam/concept/)
164
- - [How Loam works](https://deliveristsio.github.io/open-loam/foundation/overview/) — the diagrams above + how every pillar is built
166
+ - [How OpenLoam works](https://deliveristsio.github.io/open-loam/foundation/overview/) — the diagrams above + how every pillar is built
165
167
  - [Tenant isolation](https://deliveristsio.github.io/open-loam/foundation/tenant-isolation/) & [Authorization](https://deliveristsio.github.io/open-loam/foundation/authorization/) — the two flagship guarantees, in depth
166
168
  - [Agents](https://deliveristsio.github.io/open-loam/agents/) — the agent contract, guardrails, the golden-tasks benchmark, and subsystem deep-dives (encryption, SSO, scheduler, events, inbound webhooks, bulk, confirm-mode)
167
169
  - Reference — [configuration](https://deliveristsio.github.io/open-loam/reference/configuration/), [generators](https://deliveristsio.github.io/open-loam/reference/generators/), [backward compatibility](https://deliveristsio.github.io/open-loam/reference/compatibility/)
168
170
  - [Roadmap](ROADMAP.md) — ordered backlog, Mercato-informed
169
171
  - [Backward-compatibility contract](BACKWARD_COMPATIBILITY.md) — the frozen public surfaces
170
172
  - [Architecture decisions](https://deliveristsio.github.io/open-loam/adr/) & [lessons](ai/lessons.md) — why things are the way they are, and the gotchas
171
- - [Agent pack](.loam/agents/) — everything an AI agent should load to extend a Loam app correctly
173
+ - [Agent pack](.open_loam/agents/) — everything an AI agent should load to extend a OpenLoam app correctly
172
174
  - [Manifesto](https://deliveristsio.github.io/open-loam/manifesto/)
173
175
  - [Contributing](CONTRIBUTING.md)
174
176
 
@@ -1,6 +1,6 @@
1
- module Loam
1
+ module OpenLoam
2
2
  # Heals a custom-field index gap in the background: rebuilds one model's
3
- # read-model index (Loam::CustomFieldIndex) for one tenant. Enqueued (deduped)
3
+ # read-model index (OpenLoam::CustomFieldIndex) for one tenant. Enqueued (deduped)
4
4
  # by CustomFieldIndex when a filter/order runs over an incomplete index, so a
5
5
  # gap self-heals without an operator running a rake task. Inherits
6
6
  # ActiveJob::Base, not the app's ApplicationRecord-owned base (the gem must not
@@ -9,14 +9,14 @@ module Loam
9
9
  queue_as :default
10
10
 
11
11
  def perform(tenant_id, model_name)
12
- tenant = Loam::Tenant.find_by(id: tenant_id)
12
+ tenant = OpenLoam::Tenant.find_by(id: tenant_id)
13
13
  model = model_name.to_s.safe_constantize
14
- return unless tenant && model.is_a?(Class) && model < Loam::TenantRecord
14
+ return unless tenant && model.is_a?(Class) && model < OpenLoam::TenantRecord
15
15
 
16
- Loam.as_tenant(tenant) { Loam::CustomFieldIndex.reindex(model) }
16
+ OpenLoam.as_tenant(tenant) { OpenLoam::CustomFieldIndex.reindex(model) }
17
17
  ensure
18
18
  # Release the dedup marker so a later real gap can enqueue again.
19
- Loam::CustomFieldIndex.clear_pending(tenant_id, model_name.to_s)
19
+ OpenLoam::CustomFieldIndex.clear_pending(tenant_id, model_name.to_s)
20
20
  end
21
21
  end
22
22
  end
@@ -1,25 +1,25 @@
1
- module Loam
2
- # Runs one durable event delivery (Loam::DurableEvents). Inherits
1
+ module OpenLoam
2
+ # Runs one durable event delivery (OpenLoam::DurableEvents). Inherits
3
3
  # ActiveJob::Base rather than the host app's ApplicationJob — the gem must not
4
4
  # depend on a class the app owns and may have configured for its own retries —
5
- # and carries the tenant explicitly (ActiveJob doesn't serialize Loam::Current).
5
+ # and carries the tenant explicitly (ActiveJob doesn't serialize OpenLoam::Current).
6
6
  #
7
7
  # A missing row is an ANSWER, not an error: with an async queue adapter the job
8
8
  # can start before the creating transaction commits, so the row isn't visible
9
9
  # yet. The job no-ops; the redelivery sweep picks the row up once it commits.
10
- # Retry state lives in the ROW (Loam::DurableEvents.deliver), never here.
10
+ # Retry state lives in the ROW (OpenLoam::DurableEvents.deliver), never here.
11
11
  class EventDeliveryJob < ActiveJob::Base
12
12
  queue_as :default
13
13
 
14
14
  def perform(tenant_id, delivery_id)
15
- tenant = Loam::Tenant.find_by(id: tenant_id)
15
+ tenant = OpenLoam::Tenant.find_by(id: tenant_id)
16
16
  return if tenant.nil?
17
17
 
18
- Loam.as_tenant(tenant) do
19
- delivery = Loam::EventDelivery.find_by(id: delivery_id)
18
+ OpenLoam.as_tenant(tenant) do
19
+ delivery = OpenLoam::EventDelivery.find_by(id: delivery_id)
20
20
  return if delivery.nil? # not visible yet (txn race) or gone — the sweep covers it
21
21
 
22
- Loam::DurableEvents.deliver(delivery)
22
+ OpenLoam::DurableEvents.deliver(delivery)
23
23
  end
24
24
  end
25
25
  end
@@ -0,0 +1,15 @@
1
+ module OpenLoam
2
+ # Enforces the event log's retention window (OpenLoam::EventLog.prune).
3
+ # Registered per-tenant in OpenLoam::Engine, so it runs inside one tenant and
4
+ # never deletes across the boundary.
5
+ class EventLogPruneJob < ActiveJob::Base
6
+ queue_as :default
7
+
8
+ def perform(tenant_id: nil)
9
+ tenant = OpenLoam::Tenant.find_by(id: tenant_id)
10
+ return if tenant.nil?
11
+
12
+ OpenLoam.as_tenant(tenant) { OpenLoam::EventLog.prune }
13
+ end
14
+ end
15
+ end
@@ -1,10 +1,10 @@
1
- module Loam
1
+ module OpenLoam
2
2
  # Periodic durability sweep: re-enqueues due-but-undelivered event deliveries
3
3
  # whose accelerator job was lost (worker crash, dropped message, an async
4
4
  # adapter racing the creating transaction). This — not perform_later at publish
5
5
  # — is what makes persistent delivery durable.
6
6
  #
7
- # Registered per-tenant via Loam::Scheduler (see Loam::Engine), so `sync_tenant`
7
+ # Registered per-tenant via OpenLoam::Scheduler (see OpenLoam::Engine), so `sync_tenant`
8
8
  # materializes a schedule row per tenant and the scheduler allowlist already
9
9
  # covers the class. Tenant-scoped: the scheduler enqueues it with tenant_id and
10
10
  # the sweep runs inside that tenant, so there is no cross-tenant scan.
@@ -12,10 +12,10 @@ module Loam
12
12
  queue_as :default
13
13
 
14
14
  def perform(tenant_id: nil)
15
- tenant = Loam::Tenant.find_by(id: tenant_id)
15
+ tenant = OpenLoam::Tenant.find_by(id: tenant_id)
16
16
  return if tenant.nil?
17
17
 
18
- Loam.as_tenant(tenant) { Loam::DurableEvents.redeliver_stuck }
18
+ OpenLoam.as_tenant(tenant) { OpenLoam::DurableEvents.redeliver_stuck }
19
19
  end
20
20
  end
21
21
  end
@@ -1,18 +1,22 @@
1
1
  require "net/http"
2
2
  require "openssl"
3
3
 
4
- module Loam
4
+ module OpenLoam
5
5
  # Delivers one event to one endpoint. Inherits ActiveJob::Base rather than
6
6
  # the host app's ApplicationJob: the gem must not depend on a class the app
7
7
  # owns and may have configured for its own retries.
8
8
  #
9
9
  # Signing: the receiver recomputes HMAC-SHA256 of the exact body with the
10
- # endpoint's secret and compares it to X-Loam-Signature. Body building and
10
+ # endpoint's secret and compares it to X-OpenLoam-Signature. Body building and
11
11
  # signing are class methods so both sides — and the tests — can call them
12
12
  # without a network.
13
13
  class WebhookDeliveryJob < ActiveJob::Base
14
14
  queue_as :default
15
15
 
16
+ # A URL that points inside the perimeter will never become deliverable by
17
+ # retrying, and retrying is itself the SSRF attempt repeated.
18
+ discard_on OpenLoam::OutboundUrl::BlockedError
19
+
16
20
  TIMEOUT_SECONDS = 5
17
21
 
18
22
  def self.body_for(event_name, payload, tenant_id)
@@ -24,11 +28,11 @@ module Loam
24
28
  end
25
29
 
26
30
  def perform(tenant_id, endpoint_id, event_name, payload)
27
- tenant = Loam::Tenant.find_by(id: tenant_id)
31
+ tenant = OpenLoam::Tenant.find_by(id: tenant_id)
28
32
  return if tenant.nil?
29
33
 
30
- Loam.as_tenant(tenant) do
31
- endpoint = Loam::WebhookEndpoint.find_by(id: endpoint_id)
34
+ OpenLoam.as_tenant(tenant) do
35
+ endpoint = OpenLoam::WebhookEndpoint.find_by(id: endpoint_id)
32
36
  # The endpoint may have been deleted or switched off between enqueue
33
37
  # and delivery — that is an answer, not an error.
34
38
  next if endpoint.nil? || !endpoint.active?
@@ -40,17 +44,22 @@ module Loam
40
44
  private
41
45
 
42
46
  def deliver(endpoint, body, event_name)
43
- uri = URI.parse(endpoint.url)
47
+ # Re-checked at delivery, not just at save: the URL was validated when the
48
+ # tenant entered it, but DNS can be repointed at an internal address any
49
+ # time afterwards. Connecting to the address we just checked closes the
50
+ # window between the check and the connection.
51
+ uri, address = OpenLoam::OutboundUrl.resolve!(endpoint.url)
44
52
 
45
53
  http = Net::HTTP.new(uri.host, uri.port)
54
+ http.ipaddr = address
46
55
  http.use_ssl = uri.scheme == "https"
47
56
  http.open_timeout = TIMEOUT_SECONDS
48
57
  http.read_timeout = TIMEOUT_SECONDS
49
58
 
50
59
  request = Net::HTTP::Post.new(uri.request_uri)
51
60
  request["Content-Type"] = "application/json"
52
- request["X-Loam-Event"] = event_name
53
- request["X-Loam-Signature"] = self.class.signature(endpoint.secret, body)
61
+ request["X-OpenLoam-Event"] = event_name
62
+ request["X-OpenLoam-Signature"] = self.class.signature(endpoint.secret, body)
54
63
  request.body = body
55
64
 
56
65
  http.request(request)
@@ -0,0 +1,63 @@
1
+ require "openssl"
2
+
3
+ module OpenLoam
4
+ # A bearer token that lets a machine act as one user in one tenant. Same
5
+ # rules as a human session: whatever the token's user may do in that tenant,
6
+ # no more. Plumbing, so not audited and not evented.
7
+ #
8
+ # Only the SHA-256 digest is stored. The plaintext is returned once, from the
9
+ # instance that generated it, and is unrecoverable afterwards — a dump of this
10
+ # table is not a set of working credentials. A 24-byte random token needs no
11
+ # slow KDF; it is not guessable the way a password is.
12
+ class ApiToken < OpenLoam::TenantRecord
13
+ self.table_name = "open_loam_api_tokens"
14
+
15
+ belongs_to :user
16
+
17
+ validates :token_digest, presence: true, uniqueness: true
18
+
19
+ # Present only on the instance that just created it — never after a reload.
20
+ attr_reader :token
21
+
22
+ before_validation on: :create do
23
+ @token ||= SecureRandom.hex(24)
24
+ self.token_digest = self.class.digest(@token)
25
+ end
26
+
27
+ def self.digest(raw_token)
28
+ OpenSSL::Digest::SHA256.hexdigest(raw_token.to_s)
29
+ end
30
+
31
+ # THE blessed cross-tenant lookup, and the reason it lives in the gem.
32
+ #
33
+ # A bearer token arrives with no tenant context — the token IS how the
34
+ # request discovers which tenant it belongs to, so this one query must
35
+ # bypass the tenant scope. That is exactly what host apps are forbidden to
36
+ # do (`test/open_loam_guardrails_test.rb` fails the build on it), so the escape
37
+ # hatch is vetted framework code here, used once, at the edge: find the
38
+ # token, establish OpenLoam::Current, and everything downstream is ordinary
39
+ # tenant-scoped code again.
40
+ #
41
+ # Returns the token, or nil for an unknown/blank one — callers render 401.
42
+ def self.authenticate(raw_token)
43
+ return nil if raw_token.blank?
44
+
45
+ api_token = unscoped.find_by(token_digest: digest(raw_token))
46
+ return nil unless api_token
47
+
48
+ OpenLoam::Current.tenant = api_token.tenant
49
+ OpenLoam::Current.actor = api_token.user
50
+
51
+ # A token outlives the membership that justified it, so offboarding a user
52
+ # from a tenant would otherwise leave their machine access intact. Checked
53
+ # here rather than at revoke time: the membership is the authority.
54
+ unless OpenLoam::Membership.exists?(user_id: api_token.user_id)
55
+ OpenLoam::Current.reset
56
+ return nil
57
+ end
58
+
59
+ api_token.update_column(:last_used_at, Time.current)
60
+ api_token
61
+ end
62
+ end
63
+ end