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
data/lib/loam/widgets.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A registry of dashboard widgets — small, module-provided tiles (a metric, a
|
|
3
|
+
# short list) shown on the admin home. A widget is a DATA PROVIDER plus a
|
|
4
|
+
# title and an optional role filter; it never renders arbitrary code:
|
|
5
|
+
#
|
|
6
|
+
# Loam::Widgets.register(key: "open_rentals", title: "Open rentals", roles: %w[manager]) do |actor|
|
|
7
|
+
# { kind: "count", value: Rental.where(status: "open").count } # tenant-scoped query
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# The provider returns a small data hash (`{ kind: "count", value: }` or
|
|
11
|
+
# `{ kind: "list", items: [...] }`); the dashboard renders it generically.
|
|
12
|
+
# Widgets run tenant-scoped (query through tenant-scoped models), the `roles:`
|
|
13
|
+
# filter is enforced server-side (a hidden widget's data is NOT computed), and
|
|
14
|
+
# a raising provider is isolated into an error tile — the dashboard never breaks.
|
|
15
|
+
module Widgets
|
|
16
|
+
Widget = Struct.new(:key, :title, :roles, :provider, keyword_init: true) do
|
|
17
|
+
def visible_to?(role)
|
|
18
|
+
roles.nil? || Array(roles).map(&:to_s).include?(role.to_s)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def register(key:, title:, roles: nil, &block)
|
|
24
|
+
registry[key.to_s] = Widget.new(key: key.to_s, title: title, roles: roles, provider: block)
|
|
25
|
+
key.to_s
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def registered = registry.values
|
|
29
|
+
def keys = registry.keys
|
|
30
|
+
def find(key) = registry[key.to_s]
|
|
31
|
+
|
|
32
|
+
def reset!
|
|
33
|
+
@registry = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Resolve a widget for (actor, role): nil when it doesn't exist or the role
|
|
37
|
+
# can't see it (so its data is never computed), otherwise
|
|
38
|
+
# { key, title, data } — or { key, title, error: } when the provider raises.
|
|
39
|
+
def resolve(key, actor:, role:)
|
|
40
|
+
return nil if Loam::Overrides.disabled?(:widgets, key) # customization without forking
|
|
41
|
+
|
|
42
|
+
widget = find(key)
|
|
43
|
+
return nil unless widget&.visible_to?(role)
|
|
44
|
+
|
|
45
|
+
provider = Loam::Overrides.replacement(:widgets, key) || widget.provider
|
|
46
|
+
{ key: widget.key, title: widget.title, data: provider.call(actor) }
|
|
47
|
+
rescue StandardError => error
|
|
48
|
+
{ key: widget&.key, title: widget&.title, error: error.message }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The widgets every Loam app starts with — registered at boot from the
|
|
52
|
+
# engine, so a fresh install has a useful default dashboard.
|
|
53
|
+
def register_builtins!
|
|
54
|
+
register(key: "audit_recent", title: "Recent activity") do |_actor|
|
|
55
|
+
items = Loam::AuditRecord.order(created_at: :desc).limit(8)
|
|
56
|
+
.map { |a| "#{a.action} #{a.auditable_type} ##{a.auditable_id}" }
|
|
57
|
+
{ kind: "list", items: items }
|
|
58
|
+
end
|
|
59
|
+
register(key: "notifications_unread", title: "Unread notifications") do |actor|
|
|
60
|
+
{ kind: "count", value: Loam::Notification.unread.where(user_id: actor&.id).count }
|
|
61
|
+
end
|
|
62
|
+
register(key: "pending_approvals", title: "Pending approvals", roles: %w[manager]) do |_actor|
|
|
63
|
+
{ kind: "count", value: Loam::PendingAction.pending.count }
|
|
64
|
+
end
|
|
65
|
+
register(key: "open_progress", title: "Running tasks") do |_actor|
|
|
66
|
+
{ kind: "count", value: Loam::ProgressJob.where(status: "running").count }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def registry
|
|
73
|
+
@registry ||= {}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Declarative state machine for a tenant-scoped entity: the states a record
|
|
3
|
+
# can be in, the legal moves between them, and who may make each move.
|
|
4
|
+
#
|
|
5
|
+
# class PurchaseOrder < Loam::TenantRecord
|
|
6
|
+
# include Loam::Workflow
|
|
7
|
+
#
|
|
8
|
+
# workflow :status, initial: "draft" do
|
|
9
|
+
# state "draft"
|
|
10
|
+
# state "pending_approval"
|
|
11
|
+
# state "approved"
|
|
12
|
+
#
|
|
13
|
+
# transition :submit, from: "draft", to: "pending_approval"
|
|
14
|
+
# transition :approve, from: "pending_approval", to: "approved", roles: [:manager]
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# order.submit! # draft -> pending_approval, publishes
|
|
19
|
+
# # "<domain>.purchase_order.submit"
|
|
20
|
+
# order.workflow_transitions_available # => [:approve] for a manager, [] for anyone else
|
|
21
|
+
# PurchaseOrder.loam_workflow # the frozen definition, for agents and admin UI
|
|
22
|
+
#
|
|
23
|
+
# What this buys over hand-rolled `if status == "draft"`: an illegal move
|
|
24
|
+
# raises instead of silently writing a bad value, the roles that may move a
|
|
25
|
+
# record are declared in one readable place instead of scattered across
|
|
26
|
+
# controllers, and the whole machine is introspectable.
|
|
27
|
+
#
|
|
28
|
+
# The state change itself is an ordinary attribute write, so Loam::Auditable
|
|
29
|
+
# records it like any other change — a workflow adds no second audit trail.
|
|
30
|
+
module Workflow
|
|
31
|
+
extend ActiveSupport::Concern
|
|
32
|
+
|
|
33
|
+
# One declared move. `from` is a list because several states may share a
|
|
34
|
+
# move (e.g. cancel from draft OR pending_approval); `roles` empty means
|
|
35
|
+
# "not role-gated here" — the policy layer still gates the controller.
|
|
36
|
+
Transition = Struct.new(:name, :from, :to, :roles, keyword_init: true)
|
|
37
|
+
|
|
38
|
+
# The whole machine, frozen at class-definition time.
|
|
39
|
+
Definition = Struct.new(:column, :initial, :states, :transitions, keyword_init: true) do
|
|
40
|
+
def transitions_from(state)
|
|
41
|
+
transitions.each_value.select { |transition| transition.from.include?(state.to_s) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Collects the DSL block and validates the machine as a whole before
|
|
46
|
+
# freezing it. Validation runs at the end, so `state` and `transition` may
|
|
47
|
+
# appear in any order inside the block.
|
|
48
|
+
class Builder
|
|
49
|
+
def initialize(column, initial)
|
|
50
|
+
@column = column.to_s
|
|
51
|
+
@initial = initial&.to_s
|
|
52
|
+
@states = []
|
|
53
|
+
@transitions = {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def state(name)
|
|
57
|
+
@states << name.to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def transition(name, from:, to:, roles: [])
|
|
61
|
+
@transitions[name.to_sym] = Transition.new(
|
|
62
|
+
name: name.to_sym,
|
|
63
|
+
from: Array(from).map(&:to_s).freeze,
|
|
64
|
+
to: to.to_s,
|
|
65
|
+
roles: Array(roles).map(&:to_sym).freeze
|
|
66
|
+
).freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build(&block)
|
|
70
|
+
instance_eval(&block)
|
|
71
|
+
validate!
|
|
72
|
+
|
|
73
|
+
Definition.new(
|
|
74
|
+
column: @column,
|
|
75
|
+
initial: @initial || @states.first,
|
|
76
|
+
states: @states.freeze,
|
|
77
|
+
transitions: @transitions.freeze
|
|
78
|
+
).freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# A typo in a state name is a bug that would otherwise surface much
|
|
84
|
+
# later, as a transition that can never fire. Fail at class load.
|
|
85
|
+
def validate!
|
|
86
|
+
raise Error, "workflow #{@column} declares no states" if @states.empty?
|
|
87
|
+
|
|
88
|
+
undeclared = @transitions.each_value.flat_map { |t| t.from + [t.to] }.uniq - @states
|
|
89
|
+
if undeclared.any?
|
|
90
|
+
raise Error, "workflow #{@column} moves to/from undeclared states: #{undeclared.join(', ')} " \
|
|
91
|
+
"(declare each one with `state \"name\"`)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return if @initial.nil? || @states.include?(@initial)
|
|
95
|
+
|
|
96
|
+
raise Error, "workflow #{@column} initial state #{@initial.inspect} is not a declared state"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
included do
|
|
101
|
+
# nil until `workflow` is called; inherited by subclasses.
|
|
102
|
+
class_attribute :loam_workflow, instance_writer: false, default: nil
|
|
103
|
+
|
|
104
|
+
# THE gate: the workflow column may only change through a transition
|
|
105
|
+
# (loam_perform_transition!), which enforces from-state and role. A direct
|
|
106
|
+
# write — the edit form, Bulk.set_field, an import, a business-rule
|
|
107
|
+
# set_field — would otherwise let a member self-"approve", skipping the
|
|
108
|
+
# transition's roles:. Closing it here closes ALL paths at once. Creation
|
|
109
|
+
# (the initial state) is exempt (`on: :update`).
|
|
110
|
+
validate :loam_workflow_column_only_via_transition, on: :update
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class_methods do
|
|
114
|
+
# The DSL. `initial:` defaults to the first declared state.
|
|
115
|
+
def workflow(column, initial: nil, &block)
|
|
116
|
+
definition = Builder.new(column, initial).build(&block)
|
|
117
|
+
self.loam_workflow = definition
|
|
118
|
+
|
|
119
|
+
before_validation on: :create do
|
|
120
|
+
self[definition.column] = definition.initial if self[definition.column].blank?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A workflow column may only ever hold a declared state, whoever writes
|
|
124
|
+
# it — a transition, a form, a console.
|
|
125
|
+
validates definition.column,
|
|
126
|
+
inclusion: { in: definition.states, message: "is not one of: #{definition.states.join(', ')}" }
|
|
127
|
+
|
|
128
|
+
include loam_workflow_module(definition)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Transition and predicate methods live in their own module rather than
|
|
132
|
+
# on the class, so an app can override `submit!` and still call `super`.
|
|
133
|
+
def loam_workflow_module(definition)
|
|
134
|
+
model = self
|
|
135
|
+
|
|
136
|
+
Module.new do
|
|
137
|
+
definition.transitions.each_value do |transition|
|
|
138
|
+
define_method("#{transition.name}!") { loam_perform_transition!(transition) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
definition.states.each do |state|
|
|
142
|
+
predicate = "#{state}?"
|
|
143
|
+
next unless model.loam_workflow_predicate_free?(predicate)
|
|
144
|
+
|
|
145
|
+
# A state predicate must never shadow a real column: DamageReport
|
|
146
|
+
# has both a workflow state "approved" and an `approved` boolean
|
|
147
|
+
# column, and `record.approved?` has to stay the column's. The
|
|
148
|
+
# column check happens at CALL time, not class-load time — at load
|
|
149
|
+
# the schema may not be readable yet (fresh CI database, db:create),
|
|
150
|
+
# and a load-time decision made Loam behave differently on CI than
|
|
151
|
+
# on a warmed-up dev machine.
|
|
152
|
+
define_method(predicate) do
|
|
153
|
+
if self.class.columns_hash.key?(state)
|
|
154
|
+
query_attribute(state)
|
|
155
|
+
else
|
|
156
|
+
self[definition.column].to_s == state
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Explicitly defined methods still win — only truly free names get a
|
|
164
|
+
# workflow predicate. Column collisions are handled inside the predicate
|
|
165
|
+
# itself (see loam_workflow_module), where the schema is reliably known.
|
|
166
|
+
def loam_workflow_predicate_free?(predicate)
|
|
167
|
+
!method_defined?(predicate) && !private_method_defined?(predicate)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Transition names that are legal right now: from this record's state, for
|
|
172
|
+
# the actor in Loam::Current. Never raises — an admin screen asks this to
|
|
173
|
+
# decide which buttons to render.
|
|
174
|
+
def workflow_transitions_available
|
|
175
|
+
role = loam_workflow_role
|
|
176
|
+
|
|
177
|
+
self.class.loam_workflow.transitions_from(loam_workflow_state)
|
|
178
|
+
.select { |transition| transition.roles.empty? || transition.roles.include?(role) }
|
|
179
|
+
.map(&:name)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def loam_workflow_state
|
|
183
|
+
self[self.class.loam_workflow.column].to_s
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def loam_workflow_column_only_via_transition
|
|
189
|
+
return unless self.class.loam_workflow
|
|
190
|
+
|
|
191
|
+
column = self.class.loam_workflow.column
|
|
192
|
+
return unless attribute_changed?(column)
|
|
193
|
+
return if @loam_in_transition
|
|
194
|
+
|
|
195
|
+
errors.add(column, "can only change through a workflow transition, not a direct write")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def loam_perform_transition!(transition)
|
|
199
|
+
from = loam_workflow_state
|
|
200
|
+
|
|
201
|
+
unless transition.from.include?(from)
|
|
202
|
+
raise InvalidTransitionError,
|
|
203
|
+
"#{self.class.name}##{transition.name}! moves #{transition.from.join('/')} -> #{transition.to}, " \
|
|
204
|
+
"but this record is #{from.inspect}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
loam_authorize_transition!(transition)
|
|
208
|
+
|
|
209
|
+
self[self.class.loam_workflow.column] = transition.to
|
|
210
|
+
begin
|
|
211
|
+
@loam_in_transition = true # tells the guard THIS column change is blessed
|
|
212
|
+
save!
|
|
213
|
+
ensure
|
|
214
|
+
@loam_in_transition = false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
Loam::Events.publish(
|
|
218
|
+
"#{loam_workflow_event_domain}.#{model_name.param_key}.#{transition.name}",
|
|
219
|
+
id: id, from: from, to: transition.to
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
self
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def loam_authorize_transition!(transition)
|
|
226
|
+
return if transition.roles.empty?
|
|
227
|
+
|
|
228
|
+
allowed = transition.roles.join(", ")
|
|
229
|
+
unless Loam::Current.actor
|
|
230
|
+
raise NotAuthorizedError,
|
|
231
|
+
"#{self.class.name}##{transition.name}! is restricted to #{allowed}, but there is no actor in " \
|
|
232
|
+
"Loam::Current — wrap the call in Loam.as_tenant(tenant, actor: user) { ... }"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
return if transition.roles.include?(loam_workflow_role)
|
|
236
|
+
|
|
237
|
+
raise NotAuthorizedError,
|
|
238
|
+
"#{self.class.name}##{transition.name}! is restricted to #{allowed}; " \
|
|
239
|
+
"this actor's role is #{loam_workflow_role.inspect}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Role resolution deliberately goes through Loam::Policy: "your role" means
|
|
243
|
+
# exactly one thing — your membership role in the current tenant — here, in
|
|
244
|
+
# policies, and in the admin.
|
|
245
|
+
def loam_workflow_role
|
|
246
|
+
actor = Loam::Current.actor
|
|
247
|
+
actor && Loam::Policy.new(actor, self).role
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Transitions publish into the model's own event domain when it has one
|
|
251
|
+
# (Loam::Eventful), so workflow events sit beside the lifecycle events.
|
|
252
|
+
def loam_workflow_event_domain
|
|
253
|
+
self.class.respond_to?(:loam_event_domain) ? self.class.loam_event_domain : "app"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
data/lib/loam.rb
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require "loam/version"
|
|
2
|
+
require "loam/errors"
|
|
3
|
+
require "loam/current"
|
|
4
|
+
require "loam/events"
|
|
5
|
+
require "loam/policy"
|
|
6
|
+
require "loam/lifecycle"
|
|
7
|
+
require "loam/configs"
|
|
8
|
+
require "loam/features"
|
|
9
|
+
require "loam/auth_throttle"
|
|
10
|
+
require "loam/dictionaries"
|
|
11
|
+
require "loam/custom_field_index"
|
|
12
|
+
require "loam/undo"
|
|
13
|
+
require "loam/permissions"
|
|
14
|
+
require "loam/telemetry"
|
|
15
|
+
require "loam/eval"
|
|
16
|
+
require "loam/mcp"
|
|
17
|
+
require "loam/mcp/server"
|
|
18
|
+
require "loam/progress"
|
|
19
|
+
require "loam/cron"
|
|
20
|
+
require "loam/scheduler"
|
|
21
|
+
require "loam/csv"
|
|
22
|
+
require "loam/export"
|
|
23
|
+
require "loam/import"
|
|
24
|
+
require "loam/bulk"
|
|
25
|
+
require "loam/widgets"
|
|
26
|
+
require "loam/dashboard"
|
|
27
|
+
require "loam/overrides"
|
|
28
|
+
require "loam/open_api"
|
|
29
|
+
require "loam/encryption"
|
|
30
|
+
require "loam/base32"
|
|
31
|
+
require "loam/totp"
|
|
32
|
+
require "loam/pending_actions"
|
|
33
|
+
require "loam/perspectives"
|
|
34
|
+
require "loam/record_locks"
|
|
35
|
+
require "loam/event_stream"
|
|
36
|
+
require "loam/enrichers"
|
|
37
|
+
require "loam/search"
|
|
38
|
+
require "loam/search/driver"
|
|
39
|
+
require "loam/search/like_driver"
|
|
40
|
+
require "loam/search/token_driver"
|
|
41
|
+
require "loam/notifications"
|
|
42
|
+
require "loam/sso"
|
|
43
|
+
require "loam/business_rules"
|
|
44
|
+
require "loam/webhooks"
|
|
45
|
+
require "loam/inbound_webhooks"
|
|
46
|
+
require "loam/durable_events"
|
|
47
|
+
require "loam/engine" if defined?(Rails::Engine)
|
|
48
|
+
|
|
49
|
+
module Loam
|
|
50
|
+
# The current tenant, or a loud failure. This is THE guardrail: tenant-scoped
|
|
51
|
+
# code paths call this, so a missing tenant context can never silently widen
|
|
52
|
+
# a query to all tenants.
|
|
53
|
+
def self.tenant!
|
|
54
|
+
Current.tenant or raise MissingTenantError
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.tenant
|
|
58
|
+
Current.tenant
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.actor
|
|
62
|
+
Current.actor
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Feature-string permission check for the current actor's role (see
|
|
66
|
+
# Loam::Permissions) — deny-by-default, wildcard-aware. `role:` overrides the
|
|
67
|
+
# actor's role. Returns false with no actor/role.
|
|
68
|
+
def self.can?(permission, role: nil)
|
|
69
|
+
role ||= Current.actor && Loam::Membership.find_by(user_id: Current.actor.id)&.role
|
|
70
|
+
Loam::Permissions.allow?(role, permission)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The approval-gate seam. When a caller runs under :confirm (an MCP tool acting
|
|
74
|
+
# for an AI agent), a write should be STAGED for human approval via
|
|
75
|
+
# Loam::PendingActions.stage instead of committed. Loam does not intercept
|
|
76
|
+
# Active Record globally — this is the documented hook a write path checks.
|
|
77
|
+
def self.mutation_mode
|
|
78
|
+
Current.mutation_mode || :direct
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.require_confirmation?
|
|
82
|
+
mutation_mode == :confirm
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Run a block with writes gated for approval. The one blessed way to enter
|
|
86
|
+
# confirm-mode; restores the previous mode afterwards.
|
|
87
|
+
def self.with_confirmation
|
|
88
|
+
previous = Current.mutation_mode
|
|
89
|
+
Current.mutation_mode = :confirm
|
|
90
|
+
yield
|
|
91
|
+
ensure
|
|
92
|
+
Current.mutation_mode = previous
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Run a block inside a tenant (and optional actor) context, restoring the
|
|
96
|
+
# previous context afterwards. The one blessed way to switch tenants.
|
|
97
|
+
def self.as_tenant(tenant, actor: nil)
|
|
98
|
+
previous_tenant = Current.tenant
|
|
99
|
+
previous_actor = Current.actor
|
|
100
|
+
Current.tenant = tenant
|
|
101
|
+
Current.actor = actor if actor
|
|
102
|
+
yield
|
|
103
|
+
ensure
|
|
104
|
+
Current.tenant = previous_tenant
|
|
105
|
+
Current.actor = previous_actor
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
ActiveSupport.on_load(:active_record) do
|
|
110
|
+
require "loam/generated_key"
|
|
111
|
+
require "loam/tenant_record"
|
|
112
|
+
require "loam/auditable"
|
|
113
|
+
require "loam/soft_deletable"
|
|
114
|
+
require "loam/eventful"
|
|
115
|
+
require "loam/custom_fields"
|
|
116
|
+
require "loam/workflow"
|
|
117
|
+
require "loam/searchable"
|
|
118
|
+
require "loam/encryptable"
|
|
119
|
+
require "loam/translatable"
|
|
120
|
+
require "loam/commentable"
|
|
121
|
+
require "loam/attachable"
|
|
122
|
+
end
|
data/lib/open-loam.rb
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Bundler auto-requires the gem's name ("open-loam") on load. The library's
|
|
2
|
+
# entry point and whole namespace are `loam` / `Loam::` — the gem is only
|
|
3
|
+
# *distributed* as open-loam (the plain `loam` name is taken on RubyGems). This
|
|
4
|
+
# shim bridges the two so `gem "open-loam"` works with the default require.
|
|
5
|
+
require_relative "loam"
|
data/lib/tasks/loam.rake
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
namespace :loam do
|
|
2
|
+
desc "Re-run every Loam.on_tenant_created callback for every existing tenant (idempotent)"
|
|
3
|
+
task sync: :environment do
|
|
4
|
+
synced = Loam.sync_tenants!
|
|
5
|
+
puts "loam:sync — ran #{Loam.tenant_created_callbacks.size} tenant callback(s) across #{synced} tenant(s)."
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
namespace :openapi do
|
|
9
|
+
# Write the OpenAPI 3.1 document + a Markdown rendering to disk, for CI or
|
|
10
|
+
# publishing. Introspection only — no server, no network.
|
|
11
|
+
#
|
|
12
|
+
# bin/rails loam:openapi:export
|
|
13
|
+
desc "Export the API's OpenAPI JSON + Markdown to doc/"
|
|
14
|
+
task export: :environment do
|
|
15
|
+
require "json"
|
|
16
|
+
dir = ENV["DIR"].presence || "doc"
|
|
17
|
+
FileUtils.mkdir_p(dir)
|
|
18
|
+
File.write(File.join(dir, "openapi.json"), JSON.pretty_generate(Loam::OpenApi.document))
|
|
19
|
+
File.write(File.join(dir, "openapi.md"), Loam::OpenApi.markdown)
|
|
20
|
+
puts "loam:openapi:export — wrote #{dir}/openapi.json and #{dir}/openapi.md."
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
namespace :scheduler do
|
|
25
|
+
# Fire every due recurring job once. Wire to system cron, every minute:
|
|
26
|
+
# * * * * * cd /app && bin/rails loam:scheduler:tick
|
|
27
|
+
# The claim is atomic, so running this from several hosts never double-fires
|
|
28
|
+
# a job (Postgres SKIP LOCKED; SQLite serializes the single-process claim).
|
|
29
|
+
desc "Enqueue every due Loam::ScheduledJob (run from cron)"
|
|
30
|
+
task tick: :environment do
|
|
31
|
+
fired = Loam::Scheduler.tick
|
|
32
|
+
puts "loam:scheduler:tick — fired #{fired} scheduled job(s)."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
namespace :index do
|
|
37
|
+
# Rebuild the custom-field read-model index (Loam::CustomFieldIndex) for every
|
|
38
|
+
# entity with custom fields, in every tenant. Run once after enabling it so
|
|
39
|
+
# existing rows are projected; new/updated records index themselves on save.
|
|
40
|
+
#
|
|
41
|
+
# bin/rails loam:index:reindex
|
|
42
|
+
desc "Rebuild the custom-field read-model index for every model in every tenant"
|
|
43
|
+
task reindex: :environment do
|
|
44
|
+
Rails.application.eager_load!
|
|
45
|
+
models = Loam::TenantRecord.descendants.select do |model|
|
|
46
|
+
model.name.present? && model.respond_to?(:custom_field_definitions)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
tenants = 0
|
|
50
|
+
Loam::Tenant.find_each do |tenant|
|
|
51
|
+
Loam.as_tenant(tenant) { models.each { |model| Loam::CustomFieldIndex.reindex(model) } }
|
|
52
|
+
tenants += 1
|
|
53
|
+
end
|
|
54
|
+
puts "loam:index:reindex — rebuilt #{models.size} model(s) across #{tenants} tenant(s)."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Report custom-field index coverage (indexed vs expected) per model/field
|
|
58
|
+
# per tenant — the trust signal for whether the index is complete or drifting.
|
|
59
|
+
#
|
|
60
|
+
# bin/rails loam:index:coverage
|
|
61
|
+
desc "Report custom-field index coverage per model/field per tenant"
|
|
62
|
+
task coverage: :environment do
|
|
63
|
+
Rails.application.eager_load!
|
|
64
|
+
models = Loam::TenantRecord.descendants.select do |model|
|
|
65
|
+
model.name.present? && model.respond_to?(:custom_field_definitions)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Loam::Tenant.find_each do |tenant|
|
|
69
|
+
Loam.as_tenant(tenant) do
|
|
70
|
+
models.each do |model|
|
|
71
|
+
model.custom_field_definitions.find_each do |definition|
|
|
72
|
+
c = Loam::CustomFieldIndex.coverage(model, definition.name)
|
|
73
|
+
flag = c[:complete] ? "ok" : "GAP"
|
|
74
|
+
puts " [#{flag}] #{tenant.slug} #{model.name}.#{definition.name}: #{c[:indexed]}/#{c[:expected]} indexed"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
namespace :search do
|
|
83
|
+
# Rebuild the active driver's search index for every searchable model in
|
|
84
|
+
# every tenant. Needed once after switching to a driver that keeps an index
|
|
85
|
+
# (Loam::Search::TokenDriver): existing rows have no tokens until reindexed,
|
|
86
|
+
# while new and updated records index themselves on save. A no-op under the
|
|
87
|
+
# default LikeDriver (its reindex does nothing), so it is always safe to run.
|
|
88
|
+
#
|
|
89
|
+
# bin/rails loam:search:reindex
|
|
90
|
+
desc "Rebuild the search index for every searchable model in every tenant"
|
|
91
|
+
task reindex: :environment do
|
|
92
|
+
Rails.application.eager_load!
|
|
93
|
+
models = Loam::TenantRecord.descendants.select do |model|
|
|
94
|
+
model.name.present? && model.respond_to?(:loam_searchable?) && model.loam_searchable?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
tenants = 0
|
|
98
|
+
Loam::Tenant.find_each do |tenant|
|
|
99
|
+
Loam.as_tenant(tenant) { models.each { |model| Loam::Search.reindex(model) } }
|
|
100
|
+
tenants += 1
|
|
101
|
+
end
|
|
102
|
+
puts "loam:search:reindex — rebuilt #{models.size} model(s) across #{tenants} tenant(s) using #{Loam::Search.driver}."
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
namespace :encryption do
|
|
107
|
+
# Rotate a tenant's encrypted data under the current key: read each record's
|
|
108
|
+
# encrypted fields (old key) and re-seal them (new key), one record at a
|
|
109
|
+
# time. The version tag in the stored format means old and new ciphertext
|
|
110
|
+
# coexist, so this can run incrementally without downtime. Each record is an
|
|
111
|
+
# ordinary audited "[encrypted]" update.
|
|
112
|
+
#
|
|
113
|
+
# bin/rails loam:encryption:rotate[Customer,42]
|
|
114
|
+
desc "Re-encrypt a model's encrypted fields for one tenant (rotation step)"
|
|
115
|
+
task :rotate, %i[model tenant_id] => :environment do |_task, args|
|
|
116
|
+
model = fetch_encryptable_model(args[:model])
|
|
117
|
+
tenant = Loam::Tenant.find(args[:tenant_id])
|
|
118
|
+
|
|
119
|
+
count = 0
|
|
120
|
+
Loam.as_tenant(tenant) do
|
|
121
|
+
each_record(model) do |record|
|
|
122
|
+
record.loam_reencrypt!
|
|
123
|
+
count += 1
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
puts "loam:encryption:rotate — re-encrypted #{count} #{model.name} record(s) in tenant #{tenant.slug}."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Decrypt and print one tenant's encrypted fields, e.g. for a GDPR data
|
|
130
|
+
# export or a migration. OPERATIONAL CAUTION: this prints plaintext PII to
|
|
131
|
+
# stdout — run it only where that output is safe (never into shared logs or
|
|
132
|
+
# a shell history that syncs), and only for a tenant you are authorized to
|
|
133
|
+
# export.
|
|
134
|
+
#
|
|
135
|
+
# bin/rails loam:encryption:decrypt_dump[Customer,42]
|
|
136
|
+
desc "Print decrypted encrypted-field values for one tenant (GDPR export; handle with care)"
|
|
137
|
+
task :decrypt_dump, %i[model tenant_id] => :environment do |_task, args|
|
|
138
|
+
require "json"
|
|
139
|
+
model = fetch_encryptable_model(args[:model])
|
|
140
|
+
tenant = Loam::Tenant.find(args[:tenant_id])
|
|
141
|
+
fields = model.loam_encrypted_attributes
|
|
142
|
+
|
|
143
|
+
Loam.as_tenant(tenant) do
|
|
144
|
+
each_record(model) do |record|
|
|
145
|
+
row = { id: record.id }.merge(fields.index_with { |field| record.public_send(field) })
|
|
146
|
+
puts row.to_json
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Resolve a model name to a class that actually uses Loam::Encryptable — a typo
|
|
154
|
+
# or a plain model should fail loudly, not silently dump/rotate nothing.
|
|
155
|
+
def fetch_encryptable_model(name)
|
|
156
|
+
# Force the app's classes (and Loam::TenantRecord, required lazily via the
|
|
157
|
+
# active_record on_load hook) to load before we constantize a model name.
|
|
158
|
+
Rails.application.eager_load!
|
|
159
|
+
model = name.to_s.constantize
|
|
160
|
+
unless model.respond_to?(:loam_encrypted_attributes) && model.loam_encrypted_attributes.any?
|
|
161
|
+
abort "#{name} does not `include Loam::Encryptable` with any `encrypts` fields."
|
|
162
|
+
end
|
|
163
|
+
model
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Iterate every record, soft-deleted ones included (a GDPR export must not miss
|
|
167
|
+
# data hidden in the recycle bin), staying inside the current tenant scope.
|
|
168
|
+
def each_record(model, &block)
|
|
169
|
+
scope = model.respond_to?(:with_deleted) ? model.with_deleted : model.all
|
|
170
|
+
scope.find_each(&block)
|
|
171
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
namespace :loam do
|
|
5
|
+
# Score the app's current test suite as a golden-task eval result and record it
|
|
6
|
+
# under ai/benchmark_runs/ (L-303). Run this in a Loam app AFTER an agent (or
|
|
7
|
+
# you) has implemented a golden task:
|
|
8
|
+
#
|
|
9
|
+
# bin/rails "loam:eval[2]" # task 2, tests only
|
|
10
|
+
# bin/rails "loam:eval[2,tenancy_leak:unscoped]" # note an invariant breach
|
|
11
|
+
#
|
|
12
|
+
# Exits non-zero if the task did not pass, so it drops straight into CI.
|
|
13
|
+
desc "Score the current test suite as a golden-task eval result (loam:eval[task,violation,violation,...])"
|
|
14
|
+
task :eval, [ :task ] => :environment do |_task, args|
|
|
15
|
+
task_id = args[:task] || "unspecified"
|
|
16
|
+
violations = args.extras # any positional args after the task id are violations
|
|
17
|
+
|
|
18
|
+
output = `bin/rails test 2>&1`
|
|
19
|
+
summary = Loam::Eval.parse_summary(output)
|
|
20
|
+
card = Loam::Eval.scorecard(task: task_id, summary: summary, violations: violations)
|
|
21
|
+
|
|
22
|
+
dir = Rails.root.join("ai", "benchmark_runs")
|
|
23
|
+
FileUtils.mkdir_p(dir)
|
|
24
|
+
stamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
25
|
+
file = dir.join("eval-#{task_id}-#{stamp}.json")
|
|
26
|
+
File.write(file, JSON.pretty_generate(card))
|
|
27
|
+
|
|
28
|
+
puts JSON.pretty_generate(card)
|
|
29
|
+
puts "Recorded #{file}"
|
|
30
|
+
abort("Eval FAILED for task #{task_id}") unless card[:passed]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
|
|
3
|
+
namespace :loam do
|
|
4
|
+
namespace :mcp do
|
|
5
|
+
# Serve the Loam MCP server over stdio. Point an MCP client (Claude Code,
|
|
6
|
+
# Cursor, Codex) at `bin/rails loam:mcp:serve` with LOAM_MCP_TOKEN set to a
|
|
7
|
+
# Loam API token — the agent then acts as that token's user, in that tenant.
|
|
8
|
+
#
|
|
9
|
+
# stdout carries JSON-RPC only; every log is sent to stderr so it can't
|
|
10
|
+
# corrupt the stream.
|
|
11
|
+
desc "Serve the Loam MCP server over stdio (auth via LOAM_MCP_TOKEN)"
|
|
12
|
+
task serve: :environment do
|
|
13
|
+
Rails.logger = Logger.new($stderr)
|
|
14
|
+
ActiveRecord::Base.logger = Rails.logger
|
|
15
|
+
$stdout.sync = true
|
|
16
|
+
|
|
17
|
+
Loam::Mcp::Server.new.run
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|