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,341 @@
1
+ # AGENTS.md — how to extend this Loam app
2
+
3
+ This app is built on [Loam](https://github.com/DeliveristsIO/open-loam): a Rails
4
+ foundation where tenancy, permissions, audit, events, and admin are already
5
+ decided. There is ONE way to do each thing. Follow it and your change is small,
6
+ reviewable, and safe. Improvise and the guardrail tests will fail.
7
+
8
+ Before a non-trivial change, record a substantial decision as an ADR (a
9
+ `docs/adr/NNNN-*.md` — [the convention](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_adr/index.md))
10
+ and note what you learned in a `ai/lessons.md` afterward. Loam's own
11
+ [lessons](https://github.com/DeliveristsIO/open-loam/blob/main/ai/lessons.md) list
12
+ the gotchas in the framework itself.
13
+
14
+ ## The map
15
+
16
+ | Thing | Lives in | Added by |
17
+ |-------|----------|----------|
18
+ | Business entity | `app/models/<name>.rb` | `bin/rails g loam:entity Name field:type ... --domain <domain>` |
19
+ | Permissions | `app/policies/<name>_policy.rb` | generated with the entity; edit to declare rules |
20
+ | Admin screen | `app/controllers/admin/` + `app/views/admin/` | generated with the entity |
21
+ | Domain events | published from models/services via `Loam::Events.publish` | ephemeral `Loam::Events.subscribe` (inline, best-effort) or durable `Loam::DurableEvents.register(key:, to:, call:)` (persisted, retried, at-least-once — handlers MUST be idempotent) in `config/initializers/loam.rb` ([details](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_agents/events.md)) |
22
+ | Audit trail | automatic (`Loam::Auditable`) | nothing — it is on by default |
23
+ | MCP server | `Loam::Mcp` + `bin/rails loam:mcp:serve` (stdio, auth via `LOAM_MCP_TOKEN`) | expose Loam to an agent: `list_entities` / `describe_entity` / `query_entity` (read, policy-aware, tenant-scoped) / `stage_write` (proposes an update — staged as a PendingAction for human approval, never committed); tools-only v1 ([details](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_agents/mcp.md)) |
24
+ | Observability | `Loam::Telemetry.span(name, **attrs) { }`; default emits `loam.span.*` notifications | wrap work in a span (Loam already wraps scheduler tick / durable delivery / inbound ingest); plug a tracer via `Loam::Telemetry.backend = ->(name, attrs, work){ ... }` — no hard OTel dependency |
25
+ | Feature permissions | `Loam::Permissions` (wildcard capability strings per role) | declare in the initializer (`role :manager, allow: %w[equipment.*]`); check with `Loam.can?("equipment.edit")`, `require_permission!("...")` in a controller, or the `can?` view helper; deny-by-default, `*` = all, trailing `.*` = prefix; orthogonal to roles/policies/features |
26
+ | Undo / history | `Loam::Undo` over the audit trail; per-record History screen (`/admin/history`) | `Loam::Undo.undo(audit, policy:)` reverts a change and records itself (undo an `undo` = redo); only the latest change is undoable; encrypted fields + the workflow column are skipped (undo state via the reverse transition) ([details](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_agents/undo.md)) |
27
+ | Delete / recycle bin | soft-delete via `Loam::SoftDeletable` | `record.soft_delete` hides it (excluded by default, still tenant-scoped, audited); `Model.only_deleted` + `record.restore` bring it back; `destroy` still hard-erases |
28
+ | Settings / config | `Loam::Configs` (a `key` + JSON value, global or per-tenant) | `Loam::Configs.get("billing.currency")`; `set(k, v)` overrides for the current tenant, `set(k, v, scope: :global)` sets the app-wide row, `reset(k)` drops the override; declare defaults in the initializer; admin at `/admin/configs` |
29
+ | Feature flags | `Loam::Features` (a capability on/off per tenant, over `Loam::Configs`) | `Loam::Features.on?(:beta)`; `enable(:beta)`/`disable(:beta)` override for the current tenant, `enable(:beta, scope: :global)` app-wide, `reset(:beta)` drops it; declare in `Loam.feature_defaults`; guard via `require_feature!`/`feature_on?`; admin at `/admin/features` |
30
+ | Encryption at rest | `Loam::Encryptable` (`encrypts :field`, per-tenant AES-256-GCM) | generate with `--encrypt ssn --encrypt-searchable email`, or add `encrypts :ssn` / `encrypts :email, searchable: true` to the model; read/write is transparent, `find_by_email` matches the blind index; set `LOAM_MASTER_KEY`; NEVER `searchable_by` an encrypted field ([details](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_agents/encryption.md)) |
31
+ | MFA & step-up | `Loam::MfaCredential` + `Loam::Totp` (per-user TOTP + recovery codes) | second factor at login, automatic once a user enrolls at `/admin/mfa`; gate a sensitive action with `require_sudo!` (re-auth within 5 min); require MFA per role via `security.mfa_required_roles`; failed password/TOTP/sudo attempts are rate-limited + locked out (Loam::AuthThrottle, per-identifier, enumeration-safe) |
32
+ | AI approval gate | `Loam::PendingActions` + `Loam::PendingAction` (stage → manager approves → executes) | under confirm-mode, `Loam::PendingActions.stage(summary:, on:, action:, changes:)` records a write for review instead of committing; a manager approves at `/admin/pending_actions`; nothing mutates until then ([details](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_agents/confirm-mode.md)) |
33
+ | Saved views | `Loam::Perspectives` + `Loam::Perspective` (private / role / tenant) | a named index view (filters/sort/columns) saved from the entity index; `Loam::Perspectives.visible_to(entity, user:)` / `default_for` / `resolve`; managed at `/admin/perspectives?entity_type=Name`; `perspective.apply(scope)` filters/sorts only whitelisted columns |
34
+ | Concurrent-edit safety | `lock_version` (optimistic) + `Loam::RecordLocks` (advisory) | every generated entity has `lock_version`; a stale update re-renders a conflict diff, never a clobber; `RecordLocks.acquire/holder/release/force_release` warns "who's editing" with a TTL and manager take-over |
35
+ | Real-time updates | `Loam::EventStream` (SSE push, default off) | declare patterns in `Loam.broadcast_events` (e.g. `"loam.notification."`); matching events, filtered to the connection's tenant + audience, stream to the browser at `/admin/events/stream`; the bell updates live |
36
+ | Response enrichers | `Loam::Enrichers` (computed cross-module blocks) | `register(entity_type, key:, batch:)` in the initializer to attach a computed value onto another entity's response; shown on the admin show screen and under `enrichments` in the API; use `batch:` to avoid N+1 |
37
+ | Business rules | `Loam::BusinessRules` + `Loam::BusinessRule` (admin-editable WHEN/THEN) | declare at `/admin/business_rules`: a `trigger` event pattern + a safe `{field, op, value}` condition + typed actions (notify / emit_event / set_field / block_transition); fires tenant-scoped in priority order on matching events; the run log shows why it acted |
38
+ | Migration-free field | `custom_fields` jsonb column, read/written via `Loam::CustomFields` | a `Loam::FieldDefinition` row, created via the admin "Field definitions" screen (`/admin/field_definitions`) — never a migration; filter/sort is index-backed via `Loam::CustomFieldIndex.filter(model, key, op, value)` (the entity index takes `cf_field`/`cf_op`/`cf_value`); a gappy index still returns CORRECT results (JSON fallback) and self-heals in the background — `coverage(model, key)` / `loam:index:coverage` show completeness; a field's `readable_roles` gate filter/sort (a role that can't read a field can't use it as a filter oracle) |
39
+ | States & approvals | a `workflow` block in the model (`Loam::Workflow`) | `include Loam::Workflow`; add a string column for the state |
40
+ | Notifications | `Loam::Notification` rows, read at `/admin/notifications` | `Loam::Notifications.notify(user, title:)` / `notify_role(:manager, title:)`, normally from an event subscriber |
41
+ | Search | `searchable_by :col, :col` in the model (`Loam::Searchable`) | declared with the entity for its string/text columns; `Model.search(q)` and the admin's global box at `/admin/search`. HOW it matches is a swappable driver (`Loam::Search.driver`): substring LIKE (default) or the portable word-level TokenDriver — call sites never change |
42
+ | SSO (OIDC) | `Loam::Sso` + `Loam::SsoProvider` (per-tenant, admin-configured) | configure at `/admin/sso_providers` (issuer, client_id, client_secret, email domain, JIT role); a matching-domain email is routed to the IdP, verified, and JIT-provisioned/linked; the client_secret is encrypted (needs `LOAM_MASTER_KEY`); SAML/SCIM are seams ([details](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_agents/sso.md)) |
43
+ | Dictionaries | `Loam::Dictionary` + `Loam::Dictionaries` (managed lookup lists) | curate at `/admin/dictionaries`; a `FieldDefinition` of type "dictionary" makes a custom field a select of its entries; read via `Loam::Dictionaries.entries`/`default`/`label_for` |
44
+ | Task progress | `Loam::Progress` + `Loam::ProgressJob` (live over SSE) | `start(name:, total:)` then `advance`/`complete!`/`fail!`/`cancel!`; percent/ETA push to the `/admin/progress_jobs` bar live; broadcast throttled per-percent; `cancelled?` for a cooperative stop |
45
+ | Scheduler | `Loam::Scheduler` + `Loam::ScheduledJob` (recurring cron/interval jobs) | `register(key:, job_class:, schedule:, scope:)` a schedule (or add one at `/admin/scheduled_jobs`); `loam:scheduler:tick` (system cron) fires due ones with an atomic no-double-fire claim; job_class must be a real ActiveJob ([details](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_agents/scheduler.md)) |
46
+ | Override registry | `Loam::Overrides` (customization without forking) | `Loam::Overrides.disable(:widgets, "open_progress")` / `.replace(:widgets, key) { ... }` in the initializer to drop/swap a Loam registry entry (widgets, broadcast_events); `check!` warns about stale overrides at boot; VIEWS/CONTROLLERS/ROUTES use Rails path-shadowing, not this |
47
+ | Content translations | `Loam::Translatable` + `Loam::Translation` | `translates :name` overlays `record.name` with the current locale's value (else the base column); `set_translation(field, locale, value)`; locale is request state (`Loam::Current.locale`, admin switcher over `Loam.locales`); NEVER `translates` an encrypted field (refused at load) — this is data, not Rails i18n |
48
+ | UI strings (i18n) | Rails i18n; the gem ships a `loam.*` base locale, the switcher sets `I18n.locale` too | in a view write `t("loam.nav.…")` / `t("something")`, NOT a bare English literal; add the key to `config/locales/*.yml`; translate a locale by adding `config/locales/loam.<locale>.yml` overriding `loam.*` (distinct from `Loam::Translatable`, which localizes record DATA) |
49
+ | Auto OpenAPI | `Loam::OpenApi` (introspected, no annotations) | `Loam::OpenApi.document`/`.markdown` describe the JSON API (bearer auth, per-entity schemas, writable-only request bodies, tenancy note); browse at `/admin/api_docs` (+`.json`), export with `loam:openapi:export`; it's automatic — add an entity and it appears |
50
+ | Dashboard widgets | `Loam::Widgets` + `Loam::Dashboard` + `Loam::DashboardWidget` | `Loam::Widgets.register(key:, title:, roles:, &block)` a tile (block returns `{kind:"count"/"list", ...}`, tenant-scoped); managers arrange them at `/admin/dashboard_widgets`; role-filtered server-side, a raising widget is an isolated error tile |
51
+ | Bulk import / export | `Loam::Import` + `Loam::Export` + `Loam::Bulk` | every entity index has (manager) Export CSV / Import CSV / a bulk-action bar; import maps columns → writable fields (dry-run, error file, update-by-key, background progress); export & bulk are policy + encryption + tenant aware ([details](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_agents/bulk-import-export.md)) |
52
+ | Long lists | `paginate(scope)` from `Admin::Pagination` in `BaseController` | already wired into generated index screens — 25 a page, with a filter box |
53
+ | Comments | `Loam::Comment` rows via `Loam::Commentable` | `record.comment!("...")`, or the form on the entity's show screen; publishes `loam.comment.created` |
54
+ | Attachments | ActiveStorage `files` via `Loam::Attachable` | `record.files.attach(...)`, or the file field on the entity's form — uploading counts as an update, so the entity's policy decides |
55
+ | Sign-in | `app/controllers/admin/sessions_controller.rb` (`has_secure_password` on `User`) | email + password, then a tenant — the picker only ever lists tenants you hold a `Loam::Membership` in |
56
+ | JSON API | `app/controllers/api/<plural>_controller.rb` | generated with the entity; auth is `Authorization: Bearer <Loam::ApiToken>`, and the same policies apply |
57
+ | Webhooks (outbound) | `Loam::WebhookEndpoint` rows, managed at `/admin/webhook_endpoints` | add an endpoint with an event pattern; matching events POST signed JSON via `Loam::WebhookDeliveryJob` |
58
+ | Webhooks (inbound) | `Loam::InboundWebhookSource` at `/admin/inbound_webhook_sources`; public `POST /webhooks/:token` | register a source (token+secret generated); external systems POST HMAC-signed bodies; verified, replay-deduped, published on the bus as the source's `event_name` — a durable subscriber reads the body from `Loam::InboundWebhookDelivery` ([details](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_agents/inbound-webhooks.md)) |
59
+ | Tests | `test/entities/<name>_test.rb` | generated with the entity; extend, never delete |
60
+ | New-tenant defaults | `Loam.on_tenant_created` blocks in `config/initializers/loam.rb` | edit the initializer; backfill with `bin/rails loam:sync` |
61
+
62
+ ## The one way to add a feature
63
+
64
+ 1. Run the generator — never hand-create entity files:
65
+ `bin/rails g loam:entity DamageReport reservation_id:integer description:text approved:boolean --domain rental`
66
+ 2. `bin/rails db:migrate`
67
+ 3. Declare permissions in the generated policy, e.g.:
68
+ `field :approved, writable: [:manager]`
69
+ 4. Add business logic to the model; publish business events explicitly:
70
+ `Loam::Events.publish("billing.penalty.due", id: id)`
71
+ 5. Run `bin/rails test`. All green — including the generated isolation tests — before you finish.
72
+
73
+ ## Adding a field with no migration
74
+
75
+ If a field doesn't need a real column — an admin-configurable attribute, a
76
+ one-off value, something that varies per tenant — don't run the entity
77
+ generator again. Create a `Loam::FieldDefinition` instead (`entity_type`,
78
+ `name`, `field_type`, optional `writable_roles` / `readable_roles`), typically via the admin
79
+ "Field definitions" screen. Every generated entity already has a
80
+ `custom_fields` jsonb column and `include Loam::CustomFields`, so the field is
81
+ immediately readable/writable via `record.custom_field(:name)` /
82
+ `record.set_custom_field(:name, value)` and renders on the generated admin
83
+ form/show screens automatically. Reading or writing a name with no matching
84
+ `Loam::FieldDefinition` raises `Loam::UnknownCustomFieldError` — that means
85
+ the field definition doesn't exist yet, not that you should rescue it.
86
+
87
+ ## States and approvals
88
+
89
+ A record that moves through stages — draft → pending → approved — declares a
90
+ workflow instead of hand-rolled `if status ==` checks. Add a string column for
91
+ the state, then:
92
+
93
+ ```ruby
94
+ include Loam::Workflow
95
+
96
+ workflow :status, initial: "draft" do
97
+ state "draft"; state "pending_approval"; state "approved"
98
+ transition :submit, from: "draft", to: "pending_approval"
99
+ transition :approve, from: "pending_approval", to: "approved", roles: [:manager]
100
+ end
101
+ ```
102
+
103
+ `order.submit!` moves the record, saves it, and publishes
104
+ `<domain>.<entity>.submit` with `from`/`to`; an illegal move raises
105
+ `Loam::InvalidTransitionError` and a `roles:`-gated one raises
106
+ `Loam::NotAuthorizedError`. `order.workflow_transitions_available` lists what
107
+ this actor may do next, and `Model.loam_workflow` is the whole machine, frozen
108
+ and readable.
109
+
110
+ ## Deleting a record
111
+
112
+ There is ONE way to delete a business record: `record.soft_delete` (the admin
113
+ delete button and the JSON `DELETE` already call it). It sets `deleted_at`, so
114
+ the record is hidden from every ordinary query — excluded by default, never a
115
+ filter you must remember. It stays tenant-scoped in the recycle bin
116
+ (`Model.only_deleted`, `Model.with_deleted`), `record.restore` brings it back,
117
+ and both are recorded in the audit trail as `soft_delete` / `restore`. Real
118
+ `destroy` still hard-erases the row (also audited) — reach for it only for a
119
+ genuine "forget me".
120
+
121
+ ## Settings
122
+
123
+ Configurable values — a currency, a fee, a threshold — go through
124
+ `Loam::Configs`, never a hand-rolled constant or a column. `Loam::Configs.get(key)`
125
+ resolves, most specific first: the current tenant's override → the global row →
126
+ the default declared in `Loam.config_defaults` → `nil`. `set(key, value)` writes
127
+ the current tenant's override, `set(key, value, scope: :global)` the app-wide
128
+ row, and `reset(key)` drops the override so the key falls back. Values keep their
129
+ JSON type (bool, number, string, hash) and an override never leaks to another
130
+ tenant. Declare app-wide defaults in `config/initializers/loam.rb`; managers edit
131
+ per-tenant values at `/admin/configs`.
132
+
133
+ ## Feature flags
134
+
135
+ A feature flag answers "is this capability turned ON for this tenant right now",
136
+ independent of who is signed in — for a gradual rollout or a kill-switch. This is
137
+ NOT permissions: a policy gates a PERSON, a flag gates a CAPABILITY, and the two
138
+ coexist. Declare flags in `Loam.feature_defaults` (name → default state +
139
+ description); `Loam::Features.on?(:name)` resolves override → global → declared
140
+ default → false. `enable`/`disable` set the current tenant's override (add
141
+ `scope: :global` for app-wide), `reset` drops it. Guard a controller action with
142
+ `require_feature!(:name)` (raises → 404 when off) and hide view UI with
143
+ `feature_on?(:name)`; managers flip per-tenant flags at `/admin/features`.
144
+ Storage is shared with Settings under the reserved `features.` key prefix, but
145
+ flags have their own screen.
146
+
147
+ ## Second factor & step-up (sudo)
148
+
149
+ Admin login gains a TOTP second factor the moment a user enrolls at `/admin/mfa`
150
+ (the secret is encrypted per-user; recovery codes are single-use). You do NOT
151
+ wire the login step — it is automatic once a credential is active. For a
152
+ genuinely sensitive action (revoking access, a bulk change), call `require_sudo!`
153
+ in the controller: it re-challenges when the user's last authentication is older
154
+ than 5 minutes, then returns them to the action. Step-up gates by RECENCY of
155
+ auth and is orthogonal to role — even a manager re-confirms. To force MFA for a
156
+ role, set `security.mfa_required_roles` (a `Loam::Configs` array, global or
157
+ per-tenant); an un-enrolled user with that role is sent to enrollment at login.
158
+ Never store a TOTP secret or recovery code in the clear — `Loam::MfaCredential`
159
+ already encrypts / hashes them. Recovery codes are for LOGIN only — step-up
160
+ (`require_sudo!`) takes a TOTP code, never a single-use recovery code.
161
+
162
+ ## Saved views (perspectives)
163
+
164
+ A user saves a named view of an entity's admin index — its filters, sort, and
165
+ columns — from the index itself ("Save current view"), and manages them at
166
+ `/admin/perspectives?entity_type=Name`. Three visibility tiers: `private` (owner
167
+ only), `role` (a membership role), `tenant` (everyone). `Loam::Perspectives.visible_to(entity, user:)`
168
+ lists what a user may see, `default_for` resolves the applicable default
169
+ (private > role > tenant), and the entity index applies the picked/default one.
170
+ `perspective.apply(scope)` is SAFE: a filter or sort is honored only if it names
171
+ a real, non-plumbing column — a crafted key (arbitrary SQL, or `tenant_id`) is
172
+ skipped, never run. Only the owner (or a manager, for shared views) may edit or
173
+ delete one, and rows are optimistic-locked against concurrent edits.
174
+
175
+ ## Concurrent-edit safety
176
+
177
+ `lock_version` (on every generated entity) is the GUARANTEE: a stale update
178
+ raises `ActiveRecord::StaleObjectError`, which the generated controller turns into
179
+ a "changed since you opened it" conflict — a diff and a retry, never a 500 or a
180
+ clobber. Keep the hidden `lock_version` field in the edit form and permit it.
181
+ `Loam::RecordLocks.acquire(record, by:)` is the COURTESY: an advisory, TTL'd
182
+ "someone is editing this" banner (heartbeat on re-acquire, auto-frees on
183
+ soft-delete, manager `force_release`). It warns; it does not block.
184
+
185
+ ## Real-time updates (SSE)
186
+
187
+ Push events to the browser instead of polling. OPT-IN and default-off: only an
188
+ event whose name matches a `Loam.broadcast_events` pattern (set in the
189
+ initializer) is eligible, and each is filtered to the connected tenant AND
190
+ audience (a payload `user_id` is the sole recipient) before it leaves the server.
191
+ The bell already streams `loam.notification.created`; add a pattern to stream
192
+ your own events to a live widget. Fan-out is single-process in the prototype
193
+ (Redis/SolidCable is the seam — see [How Loam works](https://github.com/DeliveristsIO/open-loam/blob/main/docs/_foundation/overview.md)).
194
+
195
+ ## Response enrichers
196
+
197
+ `Loam::Enrichers.register(entity_type, key:, batch:)` (in the initializer)
198
+ attaches a computed block onto ANOTHER module's entity — shown on its admin show
199
+ screen and under an `enrichments` key in the API, never mixed into the record's
200
+ own attributes. A `batch:` resolver (array → `{ id => value }`) keeps an index one
201
+ query, not N. A resolver runs in the current tenant scope and a raising one is
202
+ isolated (its key omitted). Caution: don't surface ANOTHER record's encrypted
203
+ plaintext through an enricher.
204
+
205
+ ## Business rules
206
+
207
+ A manager wires automation without a deploy: WHEN an event fires and a condition
208
+ holds on the triggering record, THEN run actions — at `/admin/business_rules`.
209
+ The condition is DATA, never code: a `{field, op, value}` tree (`and`/`or`/`not`)
210
+ over a WHITELIST of real columns + custom fields — no `eval`/`send`, `tenant_id`
211
+ and encrypted columns refused, values literal. Actions are a fixed set: `notify`,
212
+ `emit_event`, `set_field` (a whitelisted field — NEVER the workflow status column,
213
+ which would skip the transition gate), `block_transition`. Rules run tenant-scoped
214
+ in priority order, each isolated (a raising rule is logged, not fatal); the run log
215
+ shows why each fired. Add a verb by extending `BusinessRules::Actions`/`Condition`,
216
+ never by evaluating a rule string.
217
+
218
+ ## Search backends
219
+
220
+ `searchable_by :col, :col` declares the columns; `Model.search(q)` returns a
221
+ tenant-scoped relation. HOW a query matches is a swappable driver
222
+ (`Loam::Search.driver`): the default `LikeDriver` is a substring LIKE; the
223
+ `TokenDriver` keeps a portable word-level index (`loam_search_tokens`) for
224
+ order-independent, whole-word matching; an external engine is a third — all
225
+ behind one seam, so NO `searchable_by`/`Model.search` call site changes. Switch it
226
+ in the initializer, then `bin/rails loam:search:reindex` once to backfill
227
+ (new/updated records self-index). Never `searchable_by` an encrypted field — and
228
+ the TokenDriver never tokenizes one either (no plaintext leak into the index).
229
+
230
+ ## Dictionaries
231
+
232
+ Per-tenant managed lookup lists (`Loam::Dictionary`), curated at
233
+ `/admin/dictionaries` with no deploy. Use one as a custom-field type: a
234
+ `FieldDefinition` of `field_type: "dictionary"` (dictionary key in its `config`)
235
+ renders a select of active entries and stores the chosen value — read it with
236
+ `custom_field`, its label with `custom_field_label` / `Loam::Dictionaries.label_for`.
237
+
238
+ ## Task progress
239
+
240
+ A long-running job reports progress live (SSE, no polling): `progress =
241
+ Loam::Progress.start(name:, total:)`, then `progress.advance(by:, message:)` per
242
+ unit and `complete!`/`fail!`/`cancel!` at the end; check `progress.cancelled?` to
243
+ stop early. Pushes id/percent/status to the `/admin/progress_jobs` bar, throttled
244
+ per-percent. In a background job wrap the work in `Loam.as_tenant(tenant, actor:)`
245
+ (tenant-scoped, not audited; `stale?` flags a dead job).
246
+
247
+ ## Dashboard widgets
248
+
249
+ The admin home is a grid of registered widgets. Add one with
250
+ `Loam::Widgets.register(key:, title:, roles: nil) { |actor| { kind: "count",
251
+ value: ... } }` (or `kind: "list", items: [...]`) — the block is a DATA PROVIDER
252
+ run tenant-scoped, never arbitrary view code. `roles:` hides it server-side (its
253
+ data isn't computed for a role that can't see it); a raising widget becomes an
254
+ isolated error tile. Managers pick/reorder widgets per tenant at
255
+ `/admin/dashboard_widgets`; unconfigured tenants get the full registered set.
256
+
257
+ ## Auto OpenAPI
258
+
259
+ The JSON API self-documents — no annotations. `Loam::OpenApi.document` (OpenAPI
260
+ 3.1) and `.markdown` are introspected from the generated `Api::<Plural>Controller`s
261
+ (bearer-token auth, a schema per entity, request bodies of WRITABLE fields only —
262
+ never tenant_id, encrypted fields typed as plain strings, a tenancy note). Browse
263
+ it at `/admin/api_docs` (a plain server-rendered explorer, no external JS) or
264
+ `/admin/api_docs.json`; `bin/rails loam:openapi:export` writes it to disk. Add an
265
+ entity with the generator and it appears automatically.
266
+
267
+ ## Override registry
268
+
269
+ Customize Loam WITHOUT forking. `Loam::Overrides.disable(registry, key)` and
270
+ `.replace(registry, key) { ... }` in the initializer drop or swap an entry in one
271
+ of Loam's keyed registries — `:widgets`, `:broadcast_events`. A stale override (a
272
+ key that no longer exists) is warned about at boot (`check!`), so a typo isn't a
273
+ silent no-op. BOUNDARY: this is only for Loam's in-gem registries — override a
274
+ VIEW, CONTROLLER, or ROUTE the standard Rails way (shadow the file by path, or
275
+ `prepend`), never here.
276
+
277
+ ## Content translations
278
+
279
+ Translate DATA in record fields per locale (a product name) — NOT Rails i18n
280
+ (developer UI strings, which stay Rails-native). `include Loam::Translatable;
281
+ translates :name` makes `record.name` return the current locale's translation
282
+ (`Loam::Current.locale`, set by the admin switcher over `Loam.locales`) when one
283
+ exists, else the base column — the base value is authoritative and never lost.
284
+ `record.set_translation(:name, "de", "…")` writes; edit per-record at
285
+ `/admin/translations`. NEVER `translates` an encrypted field — it would store
286
+ plaintext, so it's refused at class load.
287
+
288
+ ## Seeding a new tenant
289
+
290
+ Anything every tenant should start with — roles, default field definitions,
291
+ starter records — belongs in a `Loam.on_tenant_created` block in
292
+ `config/initializers/loam.rb`, never in a one-off script. The block runs inside
293
+ `Loam.as_tenant(tenant)` when the tenant is created, and again for every
294
+ existing tenant when someone runs `bin/rails loam:sync`. That second path is
295
+ the point: it is how a default you add today reaches tenants created last year.
296
+ So the block MUST be idempotent — `find_or_create_by!`, never `create!`.
297
+
298
+ ## Invariants you MUST NOT break
299
+
300
+ - **Every business model inherits `Loam::TenantRecord`.** Never `ApplicationRecord`
301
+ for domain data. The guardrail test `test/loam_guardrails_test.rb` fails otherwise.
302
+ - **Never call `.unscoped` on a tenant-scoped model.** It is the only way to see
303
+ other tenants' data and it is reserved for vetted framework code.
304
+ - **Never rescue `Loam::MissingTenantError`.** It firing means a bug upstream —
305
+ fix the missing `Loam.as_tenant` context instead.
306
+ - **Never rescue `Loam::UnknownCustomFieldError`.** It firing means the
307
+ `Loam::FieldDefinition` doesn't exist — create it, don't swallow the error.
308
+ - **Never write raw SQL that touches tenant tables** without a `tenant_id` predicate.
309
+ - **Every controller action checks a policy** (`authorize!`); every form uses
310
+ `policy.permitted_fields` — no hand-rolled `params.permit` lists.
311
+ - **Event names are `domain.thing.happened`** — three+ dot-separated segments.
312
+ - **`Loam.on_tenant_created` callbacks are idempotent** — `loam:sync` re-runs them.
313
+ - **Never assign a workflow column directly** — call the transition, so the legal
314
+ moves and the roles that may make them stay in one place.
315
+ - **Delete with `soft_delete`, not `destroy`.** A business record should be
316
+ hidden and recoverable, not erased. `destroy` hard-deletes; keep it for a
317
+ deliberate, permanent "forget me", never as the default delete path.
318
+ - **Never LIKE-search, log, or hand-roll crypto for an encrypted field**, and
319
+ never commit `LOAM_MASTER_KEY`. Use `find_by_<field>` for lookup, let
320
+ `Loam::Encryptable` do the AES-256-GCM, and keep the master key in ENV/credentials.
321
+ - **Attachment URLs are capabilities, not addresses.** ActiveStorage blobs live in
322
+ global tables Loam does not tenant-scope: a signed blob URL is fetchable by
323
+ whoever holds it, with no tenant check. Gate files at the record that owns
324
+ them, through its policy, and never paste those URLs anywhere public.
325
+
326
+ ## Context helpers
327
+
328
+ - `Loam.as_tenant(tenant, actor: user) { ... }` — run code as a tenant/actor.
329
+ - `Loam.tenant!` — current tenant or raise. `Loam.actor` — current user.
330
+ - In tests: `with_tenant(tenant, actor: user) { ... }`.
331
+
332
+ ## Definition of done
333
+
334
+ `bin/rails test` fully green, `bin/rails db:migrate` clean, no `.unscoped`,
335
+ no new model outside the generator convention, policy declared for every new
336
+ entity, and the diff small enough that a human reviews it in minutes.
337
+
338
+ *This file is budgeted: ≤ 32 KB, enforced by `test/loam_guardrails_test.rb`.
339
+ Agent harnesses truncate oversized instruction files without warning, so
340
+ everything past the budget stops being read. Link out to `docs/` instead of
341
+ growing this file.*
@@ -0,0 +1,17 @@
1
+ module Admin
2
+ # A server-rendered OpenAPI explorer for the app's JSON API (Loam::OpenApi) —
3
+ # manager-only. HTML lists the endpoints, their auth, params, and schemas; the
4
+ # `.json` format serves the raw OpenAPI 3.1 document for tooling. No external
5
+ # JS / Swagger-UI (CSP-safe): the explorer is plain server-rendered HTML.
6
+ class ApiDocsController < BaseController
7
+ before_action { require_role!(:manager) }
8
+
9
+ def index
10
+ @doc = Loam::OpenApi.document
11
+ respond_to do |format|
12
+ format.html
13
+ format.json { render json: @doc }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,34 @@
1
+ <h1><%%= @doc["info"]["title"] %></h1>
2
+
3
+ <p><%%= @doc["info"]["description"] %></p>
4
+ <p><strong>Auth:</strong> bearer token (<code>Authorization: Bearer &lt;token&gt;</code>) — a <code>Loam::ApiToken</code>.
5
+ <strong>Tenancy:</strong> <%%= @doc["x-tenancy"] %></p>
6
+ <p><%%= link_to "Download OpenAPI JSON", admin_api_docs_path(format: :json) %></p>
7
+
8
+ <h2>Endpoints</h2>
9
+ <%% @doc["paths"].sort.each do |path, ops| %>
10
+ <%% ops.each do |method, op| %>
11
+ <%% next unless op.is_a?(Hash) && op["summary"] %>
12
+ <div style="border:1px solid #ccc; padding:0.5rem; margin-bottom:0.5rem">
13
+ <code><strong><%%= method.upcase %></strong> /api<%%= path %></code> — <%%= op["summary"] %>
14
+ <span style="color:#555">· 🔒 bearer · responses: <%%= op["responses"].keys.join(", ") %></span>
15
+ </div>
16
+ <%% end %>
17
+ <%% end %>
18
+
19
+ <h2>Schemas</h2>
20
+ <%% @doc["components"]["schemas"].sort.each do |name, schema| %>
21
+ <h3><%%= name %></h3>
22
+ <table>
23
+ <thead><tr><th>Field</th><th>Type</th><th></th></tr></thead>
24
+ <tbody>
25
+ <%% schema["properties"].each do |field, spec| %>
26
+ <tr>
27
+ <td><code><%%= field %></code></td>
28
+ <td><%%= spec["type"] %><%%= " (#{spec["format"]})" if spec["format"] %></td>
29
+ <td><%%= "read-only" if spec["readOnly"] %><%%= spec["description"] %></td>
30
+ </tr>
31
+ <%% end %>
32
+ </tbody>
33
+ </table>
34
+ <%% end %>
@@ -0,0 +1,30 @@
1
+ module Admin
2
+ # Your own API tokens, and only ever your own: every query is scoped to
3
+ # current_actor, so there is no path to another user's credentials. A token
4
+ # acts as its user in this tenant, which is precisely why nobody else may
5
+ # list, create or revoke one for you.
6
+ class ApiTokensController < BaseController
7
+ def index
8
+ @records = api_tokens.order(created_at: :desc)
9
+ end
10
+
11
+ def create
12
+ token = api_tokens.create!(label: params[:label].presence || "API token")
13
+
14
+ # Shown once, on the next screen — the habit that matters when tokens
15
+ # are eventually stored hashed rather than in the clear.
16
+ redirect_to admin_api_tokens_path, flash: { token: token.token }
17
+ end
18
+
19
+ def destroy
20
+ api_tokens.find(params[:id]).destroy!
21
+ redirect_to admin_api_tokens_path
22
+ end
23
+
24
+ private
25
+
26
+ def api_tokens
27
+ Loam::ApiToken.where(user_id: current_actor.id)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,39 @@
1
+ <h1>API tokens</h1>
2
+
3
+ <p>A token acts as <strong><%%= current_actor.name %></strong> in
4
+ <strong><%%= current_tenant.name %></strong> — same roles, same policies, same
5
+ audit trail. Send it as <code>Authorization: Bearer &lt;token&gt;</code>.</p>
6
+
7
+ <%% if flash[:token] %>
8
+ <p>
9
+ New token (copy it now — this is the only screen that shows it in full):<br>
10
+ <code><%%= flash[:token] %></code>
11
+ </p>
12
+ <%% end %>
13
+
14
+ <%%= form_with url: admin_api_tokens_path, method: :post do |f| %>
15
+ <%%= label_tag :label, "What is it for?" %>
16
+ <%%= text_field_tag :label, nil, placeholder: "nightly export" %>
17
+ <%%= f.submit "Generate token" %>
18
+ <%% end %>
19
+
20
+ <table>
21
+ <thead>
22
+ <tr>
23
+ <th>Label</th>
24
+ <th>Created</th>
25
+ <th>Last used</th>
26
+ <th></th>
27
+ </tr>
28
+ </thead>
29
+ <tbody>
30
+ <%% @records.each do |record| %>
31
+ <tr>
32
+ <td><%%= record.label %></td>
33
+ <td><%%= record.created_at.to_fs(:short) %></td>
34
+ <td><%%= record.last_used_at&.to_fs(:short) || "never" %></td>
35
+ <td><%%= button_to "Revoke", admin_api_token_path(record), method: :delete, form: { style: "display:inline" } %></td>
36
+ </tr>
37
+ <%% end %>
38
+ </tbody>
39
+ </table>