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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +71 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
- data/app/jobs/loam/event_delivery_job.rb +26 -0
- data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
- data/app/jobs/loam/webhook_delivery_job.rb +59 -0
- data/app/models/loam/api_token.rb +40 -0
- data/app/models/loam/audit_record.rb +18 -0
- data/app/models/loam/auth_attempt.rb +13 -0
- data/app/models/loam/business_rule.rb +49 -0
- data/app/models/loam/business_rule_run.rb +27 -0
- data/app/models/loam/comment.rb +28 -0
- data/app/models/loam/config.rb +27 -0
- data/app/models/loam/custom_field_value.rb +18 -0
- data/app/models/loam/dashboard_widget.rb +16 -0
- data/app/models/loam/dictionary.rb +20 -0
- data/app/models/loam/dictionary_entry.rb +31 -0
- data/app/models/loam/event_delivery.rb +38 -0
- data/app/models/loam/field_definition.rb +48 -0
- data/app/models/loam/inbound_webhook_delivery.rb +24 -0
- data/app/models/loam/inbound_webhook_source.rb +55 -0
- data/app/models/loam/membership.rb +27 -0
- data/app/models/loam/mfa_credential.rb +114 -0
- data/app/models/loam/notification.rb +36 -0
- data/app/models/loam/pending_action.rb +208 -0
- data/app/models/loam/perspective.rb +104 -0
- data/app/models/loam/progress_job.rb +103 -0
- data/app/models/loam/record_lock.rb +19 -0
- data/app/models/loam/scheduled_job.rb +42 -0
- data/app/models/loam/search_token.rb +9 -0
- data/app/models/loam/sso_identity.rb +13 -0
- data/app/models/loam/sso_provider.rb +35 -0
- data/app/models/loam/tenant.rb +20 -0
- data/app/models/loam/translation.rb +17 -0
- data/app/models/loam/webhook_endpoint.rb +26 -0
- data/app/views/loam/attachments/_attachments.html.erb +16 -0
- data/app/views/loam/comments/_comments.html.erb +24 -0
- data/app/views/loam/custom_fields/_fields.html.erb +35 -0
- data/lib/generators/loam/entity/entity_generator.rb +160 -0
- data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
- data/lib/generators/loam/entity/templates/controller.rb +181 -0
- data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
- data/lib/generators/loam/entity/templates/migration.rb +26 -0
- data/lib/generators/loam/entity/templates/model.rb +27 -0
- data/lib/generators/loam/entity/templates/policy.rb +10 -0
- data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
- data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
- data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
- data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
- data/lib/generators/loam/install/install_generator.rb +288 -0
- data/lib/generators/loam/install/templates/AGENTS.md +341 -0
- data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
- data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
- data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
- data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
- data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
- data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
- data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
- data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
- data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
- data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
- data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
- data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
- data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
- data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
- data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
- data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
- data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
- data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
- data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
- data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
- data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
- data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
- data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin.css +29 -0
- data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
- data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
- data/lib/generators/loam/install/templates/import_job.rb +25 -0
- data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/initializer.rb +218 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
- data/lib/generators/loam/install/templates/user.rb +21 -0
- data/lib/generators/loam/primary_key_options.rb +101 -0
- data/lib/loam/attachable.rb +27 -0
- data/lib/loam/auditable.rb +67 -0
- data/lib/loam/auth_throttle.rb +83 -0
- data/lib/loam/base32.rb +23 -0
- data/lib/loam/bulk.rb +59 -0
- data/lib/loam/business_rules/actions.rb +92 -0
- data/lib/loam/business_rules/condition.rb +102 -0
- data/lib/loam/business_rules.rb +123 -0
- data/lib/loam/commentable.rb +22 -0
- data/lib/loam/configs.rb +115 -0
- data/lib/loam/cron.rb +98 -0
- data/lib/loam/csv.rb +18 -0
- data/lib/loam/current.rb +21 -0
- data/lib/loam/custom_field_index.rb +262 -0
- data/lib/loam/custom_fields.rb +114 -0
- data/lib/loam/dashboard.rb +17 -0
- data/lib/loam/dictionaries.rb +55 -0
- data/lib/loam/durable_events.rb +174 -0
- data/lib/loam/encryptable.rb +145 -0
- data/lib/loam/encryption/cipher.rb +75 -0
- data/lib/loam/encryption/key_provider.rb +43 -0
- data/lib/loam/encryption.rb +125 -0
- data/lib/loam/engine.rb +57 -0
- data/lib/loam/enrichers.rb +105 -0
- data/lib/loam/errors.rb +49 -0
- data/lib/loam/eval.rb +47 -0
- data/lib/loam/event_stream.rb +80 -0
- data/lib/loam/eventful.rb +45 -0
- data/lib/loam/events.rb +51 -0
- data/lib/loam/export.rb +74 -0
- data/lib/loam/features.rb +75 -0
- data/lib/loam/generated_key.rb +37 -0
- data/lib/loam/import.rb +168 -0
- data/lib/loam/inbound_webhooks.rb +125 -0
- data/lib/loam/lifecycle.rb +180 -0
- data/lib/loam/locales/loam.en.yml +80 -0
- data/lib/loam/mcp/server.rb +60 -0
- data/lib/loam/mcp.rb +304 -0
- data/lib/loam/notifications.rb +36 -0
- data/lib/loam/open_api.rb +194 -0
- data/lib/loam/overrides.rb +110 -0
- data/lib/loam/pending_actions.rb +60 -0
- data/lib/loam/permissions.rb +79 -0
- data/lib/loam/perspectives.rb +62 -0
- data/lib/loam/policy.rb +106 -0
- data/lib/loam/progress.rb +31 -0
- data/lib/loam/record_locks.rb +89 -0
- data/lib/loam/scheduler.rb +153 -0
- data/lib/loam/search/driver.rb +31 -0
- data/lib/loam/search/like_driver.rb +29 -0
- data/lib/loam/search/token_driver.rb +88 -0
- data/lib/loam/search.rb +27 -0
- data/lib/loam/searchable.rb +90 -0
- data/lib/loam/soft_deletable.rb +72 -0
- data/lib/loam/sso/claims.rb +10 -0
- data/lib/loam/sso/fake_provider.rb +51 -0
- data/lib/loam/sso/http_client.rb +42 -0
- data/lib/loam/sso/oidc_provider.rb +64 -0
- data/lib/loam/sso.rb +129 -0
- data/lib/loam/telemetry.rb +43 -0
- data/lib/loam/tenant_record.rb +36 -0
- data/lib/loam/test_helpers.rb +15 -0
- data/lib/loam/totp.rb +63 -0
- data/lib/loam/translatable.rb +88 -0
- data/lib/loam/undo.rb +126 -0
- data/lib/loam/version.rb +3 -0
- data/lib/loam/webhooks.rb +34 -0
- data/lib/loam/widgets.rb +77 -0
- data/lib/loam/workflow.rb +256 -0
- data/lib/loam.rb +122 -0
- data/lib/open-loam.rb +5 -0
- data/lib/tasks/loam.rake +171 -0
- data/lib/tasks/loam_eval.rake +32 -0
- data/lib/tasks/loam_mcp.rake +20 -0
- 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 <token></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 <token></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>
|