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,194 @@
1
+ require "json"
2
+
3
+ module Loam
4
+ # Auto-generates an OpenAPI 3.1 document for the app's JSON API by
5
+ # INTROSPECTING what Loam already knows — the generated `Api::<Plural>Controller`s,
6
+ # each entity's columns/types, its exposed `FIELDS`, its custom fields, and that
7
+ # every endpoint is bearer-authenticated and tenant-scoped. No hand-written
8
+ # annotations, no external gem.
9
+ #
10
+ # Loam::OpenApi.document # => a Hash conforming to OpenAPI 3.1
11
+ # Loam::OpenApi.markdown # => a Markdown rendering
12
+ #
13
+ # The document describes the SHAPE of the API only — never tenant data. It is
14
+ # served at /admin/api_docs and exported by `bin/rails loam:openapi:export`.
15
+ module OpenApi
16
+ TYPE_MAP = {
17
+ "string" => { "type" => "string" }, "text" => { "type" => "string" },
18
+ "integer" => { "type" => "integer" }, "bigint" => { "type" => "integer" },
19
+ "float" => { "type" => "number" }, "decimal" => { "type" => "number" },
20
+ "boolean" => { "type" => "boolean" },
21
+ "date" => { "type" => "string", "format" => "date" },
22
+ "datetime" => { "type" => "string", "format" => "date-time" },
23
+ "json" => { "type" => "object" }, "jsonb" => { "type" => "object" }
24
+ }.freeze
25
+
26
+ TENANCY_NOTE = "Every endpoint is tenant-scoped by the bearer token: a caller only ever reads or " \
27
+ "writes data in the token's OWN tenant — cross-tenant access is impossible.".freeze
28
+
29
+ module_function
30
+
31
+ def document
32
+ {
33
+ "openapi" => "3.1.0",
34
+ "info" => info,
35
+ "servers" => [ { "url" => "/api" } ],
36
+ "security" => [ { "bearerAuth" => [] } ],
37
+ "components" => { "securitySchemes" => security_schemes, "schemas" => schemas },
38
+ "paths" => paths,
39
+ "x-tenancy" => TENANCY_NOTE
40
+ }
41
+ end
42
+
43
+ # The Loam entities that have a generated JSON API controller. Anonymous
44
+ # subclasses (Class.new(Loam::TenantRecord), common in tests) have no name,
45
+ # so model_name would raise "Class name cannot be blank" — skip them: a
46
+ # nameless class has no controller or route to document anyway.
47
+ def api_entities
48
+ Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application)
49
+ Loam::TenantRecord.descendants
50
+ .reject { |model| model.name.blank? }
51
+ .select { |model| controller_for(model) }
52
+ .sort_by(&:name)
53
+ end
54
+
55
+ def controller_for(model)
56
+ # model_name.plural handles uncountables ("equipment"), unlike route_key
57
+ # (which becomes "equipment_index").
58
+ "Api::#{model.model_name.plural.camelize}Controller".safe_constantize
59
+ end
60
+
61
+ def info
62
+ name = defined?(Rails) ? Rails.application.class.module_parent_name : "Loam"
63
+ {
64
+ "title" => "#{name} API",
65
+ "version" => "1.0.0",
66
+ "description" => "Auto-generated from the Loam entities. Bearer-token authenticated. #{TENANCY_NOTE}"
67
+ }
68
+ end
69
+
70
+ def security_schemes
71
+ { "bearerAuth" => { "type" => "http", "scheme" => "bearer",
72
+ "description" => "A Loam::ApiToken — identifies one user in one tenant." } }
73
+ end
74
+
75
+ def schemas
76
+ api_entities.each_with_object({}) do |model, out|
77
+ out[model.name] = entity_schema(model)
78
+ out["#{model.name}Input"] = input_schema(model)
79
+ end
80
+ end
81
+
82
+ def entity_schema(model)
83
+ props = { "id" => { "type" => "integer", "readOnly" => true } }
84
+ exposed_fields(model).each { |field| props[field] = column_schema(model, field) }
85
+ custom_field_names(model).each { |name| props[name] = { "type" => "string", "description" => "custom field" } }
86
+ %w[created_at updated_at].each { |ts| props[ts] = { "type" => "string", "format" => "date-time", "readOnly" => true } }
87
+ { "type" => "object", "properties" => props }
88
+ end
89
+
90
+ # Request body: the writable, declared fields only — never id/tenant_id/
91
+ # timestamps. Field-level write access is enforced per the token's role at
92
+ # runtime (Loam::Policy), which a structural schema can't express per-role;
93
+ # noted in the description rather than emitting a schema per role.
94
+ def input_schema(model)
95
+ props = {}
96
+ exposed_fields(model).each { |field| props[field] = column_schema(model, field) }
97
+ custom_field_names(model).each { |name| props[name] = { "type" => "string" } }
98
+ {
99
+ "type" => "object",
100
+ "properties" => props,
101
+ "description" => "Field-level write access applies per the token's role — a field the role may not write is ignored."
102
+ }
103
+ end
104
+
105
+ def paths
106
+ api_entities.each_with_object({}) do |model, out|
107
+ plural = model.model_name.plural
108
+ ref = { "$ref" => "#/components/schemas/#{model.name}" }
109
+ input = { "$ref" => "#/components/schemas/#{model.name}Input" }
110
+
111
+ out["/#{plural}"] = {
112
+ "get" => operation("List #{plural}", "200" => array_response(ref)),
113
+ "post" => operation("Create a #{model.name}", body: input, "201" => object_response(ref), "422" => error_response("validation failed"))
114
+ }
115
+ out["/#{plural}/{id}"] = {
116
+ "parameters" => [ id_param ],
117
+ "get" => operation("Fetch a #{model.name}", "200" => object_response(ref), "404" => error_response("not found")),
118
+ "patch" => operation("Update a #{model.name}", body: input, "200" => object_response(ref), "422" => error_response("validation failed"), "404" => error_response("not found")),
119
+ "delete" => operation("Soft-delete a #{model.name}", "204" => { "description" => "deleted" }, "404" => error_response("not found"))
120
+ }
121
+ end
122
+ end
123
+
124
+ # ---- markdown ----
125
+
126
+ def markdown(doc = document)
127
+ lines = [ "# #{doc['info']['title']}", "", doc["info"]["description"], "", "**Auth:** bearer token. **Tenancy:** #{doc['x-tenancy']}", "" ]
128
+ doc["paths"].sort.each do |path, ops|
129
+ ops.each do |method, op|
130
+ next unless op.is_a?(Hash) && op["summary"]
131
+ lines << "## `#{method.upcase} /api#{path}` — #{op['summary']}"
132
+ lines << "Requires a bearer token. Responses: #{op['responses'].keys.join(', ')}."
133
+ lines << ""
134
+ end
135
+ end
136
+ lines.join("\n")
137
+ end
138
+
139
+ # ---- internals ----
140
+
141
+ def exposed_fields(model)
142
+ controller = controller_for(model)
143
+ return controller::FIELDS.map(&:to_s) if controller.const_defined?(:FIELDS)
144
+
145
+ # No declared FIELDS: fall back to columns, but NEVER surface an encrypted
146
+ # column or its blind-index `_hash` (same exclusion as Loam::Export).
147
+ encrypted = model.respond_to?(:loam_encrypted_attributes) ? model.loam_encrypted_attributes : []
148
+ blind = model.respond_to?(:loam_searchable_encrypted_attributes) ? model.loam_searchable_encrypted_attributes.map { |a| "#{a}_hash" } : []
149
+ model.column_names - plumbing(model) - encrypted - blind
150
+ end
151
+
152
+ def plumbing(model)
153
+ %w[id tenant_id created_at updated_at lock_version deleted_at custom_fields]
154
+ end
155
+
156
+ def column_schema(model, field)
157
+ column = model.columns_hash[field.to_s]
158
+ return { "type" => "string" } unless column # a custom field or virtual
159
+
160
+ TYPE_MAP.fetch(column.type.to_s, { "type" => "string" }).dup
161
+ end
162
+
163
+ def custom_field_names(model)
164
+ return [] unless model.respond_to?(:custom_field_definitions) && Loam::Current.tenant
165
+
166
+ model.custom_field_definitions.map(&:name)
167
+ rescue StandardError
168
+ []
169
+ end
170
+
171
+ def operation(summary, body: nil, **responses)
172
+ responses = { "401" => error_response("missing or invalid token") }.merge(responses)
173
+ op = { "summary" => summary, "responses" => responses }
174
+ if body
175
+ op["requestBody"] = { "required" => true, "content" => { "application/json" => { "schema" => body } } }
176
+ end
177
+ op
178
+ end
179
+
180
+ def object_response(ref) = { "description" => "ok", "content" => { "application/json" => { "schema" => ref } } }
181
+
182
+ def array_response(ref)
183
+ { "description" => "ok", "content" => { "application/json" => { "schema" => { "type" => "array", "items" => ref } } } }
184
+ end
185
+
186
+ def error_response(desc)
187
+ { "description" => desc, "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "error" => { "type" => "string" } } } } } }
188
+ end
189
+
190
+ def id_param
191
+ { "name" => "id", "in" => "path", "required" => true, "schema" => { "type" => "integer" } }
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,110 @@
1
+ module Loam
2
+ # Disable or replace an entry in one of Loam's OWN keyed registries from an
3
+ # initializer — customization without forking the gem, and without
4
+ # monkeypatching. This is deliberately SMALL: Rails already handles structural
5
+ # overriding (shadow a view/controller by path, prepend a module). Overrides
6
+ # only covers the in-gem keyed registries, where path-shadowing doesn't reach:
7
+ #
8
+ # Loam::Overrides.disable(:widgets, "open_progress") # drop a built-in widget
9
+ # Loam::Overrides.replace(:widgets, "audit_recent") { |actor| { kind: "count", value: 0 } }
10
+ # Loam::Overrides.disable(:broadcast_events, "loam.progress.") # stop pushing an event over SSE
11
+ #
12
+ # THE VALUE-ADD over raw monkeypatching: a stale override (a typo, or an entry
13
+ # that no longer exists) is caught at boot by `check!` and warned about, so a
14
+ # silently-ineffective override is visible instead of a mystery.
15
+ #
16
+ # Registries with a keyed API — :widgets, :broadcast_events — honor disable AND
17
+ # replace. Seams that are a single swappable object (Search.driver,
18
+ # EventStream.broadcaster) are replaced by assigning them directly; Overrides
19
+ # doesn't wrap those. Controllers/views/routes are NOT here — that's Rails'
20
+ # path-shadowing job (see docs).
21
+ module Overrides
22
+ DISABLED = :__loam_disabled__
23
+
24
+ class << self
25
+ def disable(registry, key)
26
+ store[registry.to_sym][key.to_s] = DISABLED
27
+ end
28
+
29
+ def replace(registry, key, &block)
30
+ raise ArgumentError, "replace needs a block" unless block
31
+
32
+ store[registry.to_sym][key.to_s] = block
33
+ end
34
+
35
+ def disabled?(registry, key)
36
+ store[registry.to_sym][key.to_s] == DISABLED
37
+ end
38
+
39
+ # The replacement block for a key, or nil (also nil when disabled).
40
+ def replacement(registry, key)
41
+ value = store[registry.to_sym][key.to_s]
42
+ value unless value == DISABLED || value.nil?
43
+ end
44
+
45
+ def entries(registry)
46
+ store[registry.to_sym]
47
+ end
48
+
49
+ def all
50
+ store
51
+ end
52
+
53
+ def reset!
54
+ @store = Hash.new { |hash, key| hash[key] = {} }
55
+ end
56
+
57
+ # For tests: capture and restore the process-global override state so a
58
+ # test's overrides don't leak (and the app's boot-time overrides survive).
59
+ def snapshot
60
+ store.each_with_object({}) { |(registry, keys), out| out[registry] = keys.dup }
61
+ end
62
+
63
+ def restore(snapshot)
64
+ reset!
65
+ snapshot.each { |registry, keys| store[registry].merge!(keys) }
66
+ end
67
+
68
+ # Overrides whose key isn't present in the live registry — a typo or a
69
+ # removed entry. Only registries we can introspect are checked; others are
70
+ # skipped (returned as safe). Returns [[registry, key], ...].
71
+ def stale
72
+ store.flat_map do |registry, keys|
73
+ known = known_keys(registry)
74
+ next [] if known.nil?
75
+
76
+ keys.keys.reject { |key| known.include?(key) }.map { |key| [ registry, key ] }
77
+ end
78
+ end
79
+
80
+ # Warn (once, at boot) about every stale override, and return them.
81
+ def check!
82
+ found = stale
83
+ found.each do |registry, key|
84
+ message = "[loam] stale override: #{registry} has no entry #{key.inspect} to disable/replace — the override is doing nothing."
85
+ logger ? logger.warn(message) : warn(message)
86
+ end
87
+ found
88
+ end
89
+
90
+ private
91
+
92
+ def store
93
+ @store ||= Hash.new { |hash, key| hash[key] = {} }
94
+ end
95
+
96
+ # The live keys of an introspectable registry, or nil when we can't tell.
97
+ def known_keys(registry)
98
+ case registry.to_sym
99
+ when :widgets then Loam::Widgets.keys
100
+ when :broadcast_events then Loam.broadcast_events
101
+ when :scheduler then defined?(Loam::Scheduler) ? Loam::Scheduler.registered.map { |d| d[:key] } : nil
102
+ end
103
+ end
104
+
105
+ def logger
106
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,60 @@
1
+ module Loam
2
+ # Staging and reviewing gated mutations (see Loam::PendingAction).
3
+ #
4
+ # pending = Loam::PendingActions.stage(
5
+ # summary: "Raise the excavator's daily rate to 1100",
6
+ # on: equipment, action: :update, changes: { daily_rate: 1100 }
7
+ # )
8
+ # pending.preview # => { "daily_rate" => { "from" => 950, "to" => 1100 } }
9
+ # pending.approve!(by: manager) # role-gated; executes the change
10
+ #
11
+ # `stage` NEVER touches the target — it only records the intent. Loam does not
12
+ # intercept Active Record globally (that would be fragile and out of scope);
13
+ # this is the primitive a confirm-mode write path calls instead of saving.
14
+ module PendingActions
15
+ class << self
16
+ def stage(summary:, on:, action:, changes: {}, idempotency_key: nil, actor: Loam::Current.actor)
17
+ target_type, target_id = resolve_target(on)
18
+ changes = changes.transform_keys(&:to_s)
19
+ key = idempotency_key || compute_key(target_type, target_id, action, changes)
20
+
21
+ # The same proposal staged twice collapses to one row — but only a still
22
+ # PENDING one. A rejected or executed proposal with this key may be
23
+ # re-staged as a fresh pending row (the partial index allows the coexist).
24
+ existing = Loam::PendingAction.pending.find_by(idempotency_key: key)
25
+ return existing if existing
26
+
27
+ Loam::PendingAction.create!(
28
+ actor_id: actor&.id,
29
+ action_type: action.to_s,
30
+ target_type: target_type,
31
+ target_id: target_id,
32
+ changeset: changes,
33
+ summary: summary,
34
+ idempotency_key: key
35
+ )
36
+ rescue ActiveRecord::RecordNotUnique
37
+ # Lost a concurrent double-stage race — return the pending row that won.
38
+ Loam::PendingAction.pending.find_by!(idempotency_key: key)
39
+ end
40
+
41
+ private
42
+
43
+ # A class stages a create (no target id yet); a record stages a change to
44
+ # itself.
45
+ def resolve_target(on)
46
+ on.is_a?(Class) ? [ on.name, nil ] : [ on.class.name, on.id ]
47
+ end
48
+
49
+ # A KEYED (per-tenant HMAC), not a plain, digest of the proposal: a raw
50
+ # SHA-256 of a low-entropy value (a tax id, an SSN) in an indexed column
51
+ # would be a brute-force oracle from a DB dump. Reuses L-901's blind index.
52
+ # Determinism relies on `changes` insertion order — the same caller yields
53
+ # the same key; a reordered hash is treated as a different proposal.
54
+ def compute_key(target_type, target_id, action, changes)
55
+ payload = [ target_type, target_id, action, changes.to_json ].join("|")
56
+ Loam::Encryption.blind_index_scoped(payload, "tenant/#{Loam.tenant!.id}")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,79 @@
1
+ module Loam
2
+ # Feature-string permissions with wildcards (L-705) — a fine-grained capability
3
+ # layer that sits UNDER the coarse role. Roles answer "manager or clerk";
4
+ # permissions answer "may this role do `equipment.edit`" without inventing a new
5
+ # role for every distinction. Orthogonal to Loam::Policy (which gates field-level
6
+ # writes on a record) and to Loam::Features (a per-tenant capability switch).
7
+ #
8
+ # Declared once, in the initializer (like broadcast_events / scheduler defaults):
9
+ #
10
+ # Loam::Permissions.configure do
11
+ # role :admin, allow: "*" # everything
12
+ # role :manager, allow: %w[equipment.* damage_report.* billing.read]
13
+ # role :clerk, allow: %w[equipment.read damage_report.create]
14
+ # end
15
+ #
16
+ # Loam::Permissions.allow?(:clerk, "equipment.read") # => true
17
+ # Loam::Permissions.allow?(:clerk, "equipment.edit") # => false
18
+ # Loam.can?("equipment.edit") # for the current actor's role
19
+ #
20
+ # DENY BY DEFAULT: a role with no matching grant (or no grants at all) is denied.
21
+ #
22
+ # Wildcards: `*` grants everything; a trailing `.*` is a prefix ("equipment.*"
23
+ # matches "equipment.read" and "equipment.anything.deep", and "equipment"
24
+ # itself); anything else is an exact match. (Deliberately not a full glob —
25
+ # prefix + all covers the real cases; a mid-string `*` is not special.)
26
+ module Permissions
27
+ class << self
28
+ def configure(&block)
29
+ DSL.new.instance_eval(&block)
30
+ registry
31
+ end
32
+
33
+ # Grant one role a pattern or list of patterns (additive).
34
+ def role(name, allow:)
35
+ registry[name.to_s] ||= []
36
+ registry[name.to_s].concat(Array(allow).map(&:to_s)).uniq!
37
+ registry[name.to_s]
38
+ end
39
+
40
+ def granted(role) = registry[role.to_s] || []
41
+
42
+ def allow?(role, permission)
43
+ return false if role.nil?
44
+
45
+ granted(role).any? { |pattern| matches?(pattern, permission.to_s) }
46
+ end
47
+
48
+ # THE wildcard rule, in one place.
49
+ def matches?(pattern, permission)
50
+ pattern = pattern.to_s
51
+ return true if pattern == "*"
52
+
53
+ if pattern.end_with?(".*")
54
+ prefix = pattern[0..-2] # "equipment." (keep the dot)
55
+ permission == pattern[0..-3] || permission.start_with?(prefix)
56
+ else
57
+ permission == pattern
58
+ end
59
+ end
60
+
61
+ def reset!
62
+ @registry = {}
63
+ end
64
+
65
+ private
66
+
67
+ def registry
68
+ @registry ||= {}
69
+ end
70
+ end
71
+
72
+ # Tiny DSL so `configure { role ... }` reads well without a receiver.
73
+ class DSL
74
+ def role(name, allow:)
75
+ Loam::Permissions.role(name, allow: allow)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,62 @@
1
+ module Loam
2
+ # Reading the saved views (Loam::Perspective) a user may see for an entity.
3
+ #
4
+ # Loam::Perspectives.visible_to("Equipment", user: current_actor) # pick list
5
+ # Loam::Perspectives.default_for("Equipment", user: current_actor) # the applicable default
6
+ # Loam::Perspectives.resolve("Equipment", user:, id: params[:perspective_id])
7
+ #
8
+ # Every query is tenant-scoped by Loam::Perspective. The membership role used
9
+ # for role-shared views is the user's role in the CURRENT tenant.
10
+ module Perspectives
11
+ class << self
12
+ # The views this user may see for an entity: their own private ones, the
13
+ # role-shared ones matching their membership role, and the tenant-wide
14
+ # ones. Default(s) first, then by name. Named `visible_to` rather than
15
+ # `for` so it does not read like Loam::Policy.for (which builds a policy).
16
+ def visible_to(entity_type, user:)
17
+ base = Loam::Perspective.where(entity_type: entity_type.to_s)
18
+ role = membership_role(user)
19
+
20
+ # Build the OR chain on UNORDERED relations — `.or` refuses to combine
21
+ # relations that carry an order — then order once at the end.
22
+ visible = base.where(visibility: "private", owner_id: user&.id)
23
+ .or(base.where(visibility: "tenant"))
24
+ visible = visible.or(base.where(visibility: "role", role: role)) if role
25
+
26
+ visible.order(is_default: :desc, name: :asc)
27
+ end
28
+
29
+ # The applicable default, most specific audience first: the user's own
30
+ # private default, else a role default, else a tenant default, else nil.
31
+ def default_for(entity_type, user:)
32
+ defaults = visible_to(entity_type, user: user).select(&:is_default?)
33
+ defaults.min_by { |perspective| VISIBILITY_PRIORITY.fetch(perspective.visibility, 9) }
34
+ end
35
+
36
+ # The view to apply for an index request: the one explicitly picked (if the
37
+ # user may see it), otherwise the default. nil means "no saved view".
38
+ def resolve(entity_type, user:, id: nil)
39
+ # "none" is the picker's explicit "show everything" — it must bypass the
40
+ # default, or a tenant default would be inescapable from the UI.
41
+ return nil if id == "none"
42
+
43
+ if id.present?
44
+ picked = visible_to(entity_type, user: user).find_by(id: id)
45
+ return picked if picked
46
+ end
47
+
48
+ default_for(entity_type, user: user)
49
+ end
50
+
51
+ private
52
+
53
+ VISIBILITY_PRIORITY = { "private" => 0, "role" => 1, "tenant" => 2 }.freeze
54
+
55
+ def membership_role(user)
56
+ return nil unless user
57
+
58
+ Loam::Membership.find_by(user_id: user.id)&.role
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,106 @@
1
+ module Loam
2
+ # Base policy. One policy class per entity, one instance per (actor, record)
3
+ # pair. Action checks (read?/create?/update?/destroy?) default to "any member
4
+ # of the current tenant"; field-level write access is declared, not coded:
5
+ #
6
+ # class EquipmentPolicy < Loam::Policy
7
+ # field :daily_rate, writable: [:manager]
8
+ # end
9
+ #
10
+ # Roles come from Loam::Membership (actor + current tenant -> role).
11
+ class Policy
12
+ class << self
13
+ def field_rules
14
+ @field_rules ||= {}
15
+ end
16
+
17
+ def field(name, writable: nil, readable: nil)
18
+ field_rules[name.to_sym] = { writable: writable, readable: readable }
19
+ end
20
+
21
+ def for(record)
22
+ policy_class = "#{record.class.name}Policy".safe_constantize
23
+ raise Error, "No policy defined for #{record.class.name} (expected #{record.class.name}Policy)" unless policy_class
24
+
25
+ policy_class.new(Loam::Current.actor, record)
26
+ end
27
+ end
28
+
29
+ attr_reader :actor, :record
30
+
31
+ def initialize(actor, record)
32
+ @actor = actor
33
+ @record = record
34
+ end
35
+
36
+ def role
37
+ return nil unless actor
38
+
39
+ @role ||= Loam::Membership.find_by(user_id: actor.id)&.role&.to_sym
40
+ end
41
+
42
+ def member? = role.present?
43
+
44
+ def read? = member?
45
+ def create? = member?
46
+ def update? = member?
47
+ def destroy? = member?
48
+
49
+ # Field-level check: fields without a declared rule are writable by any
50
+ # member; fields with `writable:` only by the listed roles.
51
+ def writable?(field_name)
52
+ rule = self.class.field_rules[field_name.to_sym]
53
+ return member? if rule.nil? || rule[:writable].nil?
54
+
55
+ Array(rule[:writable]).map(&:to_sym).include?(role)
56
+ end
57
+
58
+ def permitted_fields(field_names)
59
+ field_names.select { |f| writable?(f) }
60
+ end
61
+
62
+ # Field-level READ check (used by CSV export): a field with no `readable:`
63
+ # rule is readable by any member; with one, only by the listed roles.
64
+ def readable?(field_name)
65
+ rule = self.class.field_rules[field_name.to_sym]
66
+ return member? if rule.nil? || rule[:readable].nil?
67
+
68
+ Array(rule[:readable]).map(&:to_sym).include?(role)
69
+ end
70
+
71
+ def readable_fields(field_names)
72
+ field_names.select { |f| readable?(f) }
73
+ end
74
+
75
+ # Same semantics as the static `field writable:` declaration, but for a
76
+ # runtime Loam::FieldDefinition (see Loam::CustomFields): no
77
+ # writable_roles recorded means any member may write it.
78
+ def custom_field_writable?(field_name)
79
+ return false unless member?
80
+
81
+ definition = record.class.custom_field_definitions.find_by(name: field_name.to_s)
82
+ return false unless definition
83
+
84
+ definition.writable_roles.blank? || definition.writable_roles.map(&:to_sym).include?(role)
85
+ end
86
+
87
+ def permitted_custom_fields(field_names)
88
+ field_names.select { |f| custom_field_writable?(f) }
89
+ end
90
+
91
+ # Read counterpart of custom_field_writable?: a runtime field with no
92
+ # readable_roles is readable by any member; with some, only by those roles.
93
+ def custom_field_readable?(field_name)
94
+ return false unless member?
95
+
96
+ definition = record.class.custom_field_definitions.find_by(name: field_name.to_s)
97
+ return false unless definition
98
+
99
+ definition.readable_by?(role)
100
+ end
101
+
102
+ def readable_custom_fields(field_names)
103
+ field_names.select { |f| custom_field_readable?(f) }
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,31 @@
1
+ module Loam
2
+ # Entry point for progress tracking. Start a job, then drive it through the
3
+ # returned Loam::ProgressJob:
4
+ #
5
+ # progress = Loam::Progress.start(name: "Reindex", total: equipment.count)
6
+ # equipment.find_each do |record|
7
+ # break if progress.cancelled? # cooperative cancel
8
+ # do_work(record)
9
+ # progress.advance # throttled SSE push
10
+ # end
11
+ # progress.complete!
12
+ #
13
+ # The job is created in the current tenant (and stamped with the current
14
+ # actor), so it is only visible and streamable there. In a background job,
15
+ # establish the tenant with Loam.as_tenant first, like the webhook/digest jobs.
16
+ module Progress
17
+ module_function
18
+
19
+ def start(name:, total:, key: nil)
20
+ Loam::ProgressJob.create!(
21
+ key: key,
22
+ name: name,
23
+ total: total.to_i,
24
+ completed: 0,
25
+ status: "running",
26
+ actor_id: Loam.actor&.id,
27
+ started_at: Time.current
28
+ )
29
+ end
30
+ end
31
+ end