seams 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +212 -12
- data/README.md +235 -82
- data/lib/generators/seams/accounts/accounts_generator.rb +31 -3
- data/lib/generators/seams/accounts/templates/app/controllers/memberships_controller.rb.tt +100 -0
- data/lib/generators/seams/accounts/templates/app/views/memberships/index.html.erb.tt +52 -0
- data/lib/generators/seams/accounts/templates/config/routes.rb.tt +10 -4
- data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +35 -2
- data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +13 -0
- data/lib/generators/seams/accounts/templates/spec/runtime/authorization_spec.rb.tt +102 -0
- data/lib/generators/seams/accounts/templates/spec/runtime/memberships_flow_spec.rb.tt +145 -0
- data/lib/generators/seams/admin/admin_generator.rb +51 -4
- data/lib/generators/seams/admin/templates/README.md.tt +33 -3
- data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +156 -8
- data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +9 -1
- data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +9 -1
- data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +7 -1
- data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +12 -2
- data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +7 -1
- data/lib/generators/seams/admin/templates/app/fields/admin/fields/belongs_to.rb.tt +12 -0
- data/lib/generators/seams/admin/templates/app/fields/admin/fields/dashboard_option.rb.tt +22 -0
- data/lib/generators/seams/admin/templates/app/fields/admin/fields/has_many.rb.tt +12 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +4 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +51 -10
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +4 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +7 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_index_header.html.erb.tt +57 -0
- data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_navigation.html.erb.tt +14 -0
- data/lib/generators/seams/admin/templates/config/routes.rb.tt +24 -14
- data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +3 -0
- data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +3 -1
- data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +4 -2
- data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +1 -2
- data/lib/generators/seams/auth/auth_generator.rb +1 -2
- data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +3 -0
- data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +4 -0
- data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +5 -0
- data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +5 -0
- data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +5 -1
- data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +12 -1
- data/lib/generators/seams/auth/templates/lib/engine.rb.tt +27 -0
- data/lib/generators/seams/billing/billing_generator.rb +4 -3
- data/lib/generators/seams/billing/templates/README.md.tt +26 -0
- data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +2 -0
- data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +2 -0
- data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +8 -7
- data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +9 -7
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +18 -3
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +7 -21
- data/lib/generators/seams/billing/templates/lib/engine.rb.tt +15 -0
- data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +10 -10
- data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +40 -14
- data/lib/generators/seams/billing/templates/lib/stripe/payload.rb.tt +90 -0
- data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +11 -1
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +15 -4
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +7 -1
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +18 -1
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +26 -5
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +17 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +47 -1
- data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +39 -1
- data/lib/generators/seams/design/design_generator.rb +647 -0
- data/lib/generators/seams/design/templates/README.md.tt +129 -0
- data/lib/generators/seams/design/templates/app/assets/tailwind/_tokens.css +322 -0
- data/lib/generators/seams/design/templates/app/assets/tailwind/themes/_quire.css +42 -0
- data/lib/generators/seams/design/templates/app/controllers/design/dashboard_controller.rb.tt +30 -0
- data/lib/generators/seams/design/templates/app/controllers/design/guide_controller.rb.tt +28 -0
- data/lib/generators/seams/design/templates/app/form_builders/design/form_builder.rb.tt +90 -0
- data/lib/generators/seams/design/templates/app/helpers/design/ui_helper.rb.tt +37 -0
- data/lib/generators/seams/design/templates/app/views/design/dashboard/index.html.erb.tt +38 -0
- data/lib/generators/seams/design/templates/app/views/design/guide/index.html.erb.tt +19 -0
- data/lib/generators/seams/design/templates/app/views/layouts/application.html.erb.tt +61 -0
- data/lib/generators/seams/design/templates/app/views/layouts/design/guide.html.erb.tt +21 -0
- data/lib/generators/seams/design/templates/app/views/ui/_banner.html.erb.tt +10 -0
- data/lib/generators/seams/design/templates/app/views/ui/_breadcrumb.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/_build_row.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/_button.html.erb.tt +4 -0
- data/lib/generators/seams/design/templates/app/views/ui/_card.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_chapter_row.html.erb.tt +11 -0
- data/lib/generators/seams/design/templates/app/views/ui/_checkbox.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_counter.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_data_table.html.erb.tt +25 -0
- data/lib/generators/seams/design/templates/app/views/ui/_dialog.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/_diff.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_drawer.html.erb.tt +7 -0
- data/lib/generators/seams/design/templates/app/views/ui/_empty.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/_field.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/_icon.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_icon_sprite.html.erb.tt +22 -0
- data/lib/generators/seams/design/templates/app/views/ui/_input_group.html.erb.tt +11 -0
- data/lib/generators/seams/design/templates/app/views/ui/_kbd.html.erb.tt +4 -0
- data/lib/generators/seams/design/templates/app/views/ui/_menu.html.erb.tt +18 -0
- data/lib/generators/seams/design/templates/app/views/ui/_meter.html.erb.tt +15 -0
- data/lib/generators/seams/design/templates/app/views/ui/_note.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_outline.html.erb.tt +9 -0
- data/lib/generators/seams/design/templates/app/views/ui/_pagination.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_panel.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_popover.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_radio.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/_savestate.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/_segmented.html.erb.tt +7 -0
- data/lib/generators/seams/design/templates/app/views/ui/_stepper.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_switch.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/_tag.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_toast.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_toolbar.html.erb.tt +15 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_banner.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_breadcrumb.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_build_row.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_button.html.erb.tt +9 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_card.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_chapter_row.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_checkbox.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_counter.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_data_table.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_dialog.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_diff.html.erb.tt +10 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_drawer.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_empty.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_field.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_input_group.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_kbd.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_menu.html.erb.tt +9 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_meter.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_note.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_outline.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_pagination.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_panel.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_popover.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_radio.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_savestate.html.erb.tt +4 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_segmented.html.erb.tt +15 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_stepper.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_switch.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_tag.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_toast.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_toolbar.html.erb.tt +11 -0
- data/lib/generators/seams/design/templates/lib/design/components.rb.tt +22 -0
- data/lib/generators/seams/design/templates/lib/design.rb.tt +18 -0
- data/lib/generators/seams/design/templates/lib/engine.rb.tt +38 -0
- data/lib/generators/seams/design/templates/lib/generators/design/component/component_generator.rb.tt +52 -0
- data/lib/generators/seams/design/templates/lib/generators/design/component/templates/component.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/lib/generators/design/component/templates/preview.html.erb.tt +3 -0
- data/lib/generators/seams/design/templates/spec/runtime/design_boot_spec.rb.tt +79 -0
- data/lib/generators/seams/design/templates/spec/runtime/form_builder_spec.rb.tt +97 -0
- data/lib/generators/seams/design/templates/spec/runtime/guide_spec.rb.tt +40 -0
- data/lib/generators/seams/design/templates/spec/runtime/ui_components_spec.rb.tt +70 -0
- data/lib/generators/seams/engine/engine_generator.rb +6 -0
- data/lib/generators/seams/engine/templates/Gemfile.tt +1 -1
- data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
- data/lib/generators/seams/engine/templates/rubocop.yml.tt +9 -0
- data/lib/generators/seams/install/install_generator.rb +72 -6
- data/lib/generators/seams/install/templates/Dockerfile.tt +2 -2
- data/lib/generators/seams/install/templates/bin_seams.tt +7 -4
- data/lib/generators/seams/install/templates/ci.yml.tt +18 -8
- data/lib/generators/seams/install/templates/deploy.yml.tt +4 -2
- data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +1 -1
- data/lib/generators/seams/install/templates/docker-entrypoint.tt +4 -2
- data/lib/generators/seams/install/templates/herb.yml.tt +10 -0
- data/lib/generators/seams/install/templates/lefthook.yml.tt +25 -0
- data/lib/generators/seams/install/templates/ruby-version.tt +1 -1
- data/lib/generators/seams/install/templates/seams.rake.tt +13 -1
- data/lib/generators/seams/install/templates/strong_migrations.rb.tt +26 -0
- data/lib/generators/seams/notifications/notifications_generator.rb +3 -2
- data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +5 -13
- data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +12 -0
- data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +1 -0
- data/lib/generators/seams/notifications/templates/lib/preferences.rb.tt +36 -0
- data/lib/generators/seams/permissions/permissions_generator.rb +97 -0
- data/lib/generators/seams/permissions/templates/config/initializers/seams_permissions.rb.tt +35 -0
- data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +27 -11
- data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +5 -13
- data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +2 -4
- data/lib/generators/seams/teams/templates/lib/engine.rb.tt +13 -0
- data/lib/seams/cli/list.rb +15 -0
- data/lib/seams/cli/quality.rb +46 -5
- data/lib/seams/cli/resolve.rb +9 -5
- data/lib/seams/cli/test_changed.rb +5 -1
- data/lib/seams/cli.rb +20 -0
- data/lib/seams/configuration.rb +24 -1
- data/lib/seams/cops/no_cross_engine_dependency.rb +39 -7
- data/lib/seams/cops/no_cross_engine_model_access.rb +79 -1
- data/lib/seams/event_registry.rb +19 -0
- data/lib/seams/events/adapter.rb +9 -0
- data/lib/seams/events/adapters/active_support.rb +4 -0
- data/lib/seams/events/publisher.rb +29 -0
- data/lib/seams/events.rb +1 -0
- data/lib/seams/generators/dummy_app_writer.rb +21 -0
- data/lib/seams/generators/follow_up_generator.rb +1 -2
- data/lib/seams/generators/host_injector.rb +9 -2
- data/lib/seams/generators/splicer.rb +2 -2
- data/lib/seams/observability/adapter.rb +22 -0
- data/lib/seams/observability.rb +6 -0
- data/lib/seams/permission_registry.rb +66 -0
- data/lib/seams/permissions.rb +120 -0
- data/lib/seams/version.rb +1 -1
- data/lib/seams.rb +7 -0
- metadata +107 -3
|
@@ -29,7 +29,7 @@ module RuboCop
|
|
|
29
29
|
# would force every cross-engine read of per-request identity /
|
|
30
30
|
# account / team to go through a host-defined shim, which defeats
|
|
31
31
|
# the purpose of `CurrentAttributes` as a shared per-request bus.
|
|
32
|
-
# The exception is documented in `doc/CURRENT_ATTRIBUTES.md`.
|
|
32
|
+
# The exception is documented in `doc/reference/CURRENT_ATTRIBUTES.md`.
|
|
33
33
|
class NoCrossEngineModelAccess < Base
|
|
34
34
|
MSG = "Engine `%<own>s` must not access `%<const>s` directly. " \
|
|
35
35
|
"Use an event or a %<other>s-exposed concern instead."
|
|
@@ -57,6 +57,13 @@ module RuboCop
|
|
|
57
57
|
Engine
|
|
58
58
|
].freeze
|
|
59
59
|
|
|
60
|
+
ASSOCIATION_MACROS = %i[
|
|
61
|
+
belongs_to
|
|
62
|
+
has_one
|
|
63
|
+
has_many
|
|
64
|
+
has_and_belongs_to_many
|
|
65
|
+
].freeze
|
|
66
|
+
|
|
60
67
|
def on_const(node)
|
|
61
68
|
return unless flaggable?(node)
|
|
62
69
|
|
|
@@ -71,8 +78,79 @@ module RuboCop
|
|
|
71
78
|
)
|
|
72
79
|
end
|
|
73
80
|
|
|
81
|
+
# Association macros name the target class via a `class_name:`
|
|
82
|
+
# STRING (`belongs_to :account, class_name: "Accounts::Account"`),
|
|
83
|
+
# which `on_const` cannot see — string literals are not constant
|
|
84
|
+
# nodes, so this exact pattern used to sail through the cop.
|
|
85
|
+
# Flag the option when the named class lives in another engine.
|
|
86
|
+
#
|
|
87
|
+
# `class_name:` given as a constant is already caught by
|
|
88
|
+
# `on_const`, so only string values are flagged here (no double
|
|
89
|
+
# offense). `polymorphic: true` associations are exempt: the
|
|
90
|
+
# target class is decided at runtime by the owning record.
|
|
91
|
+
def on_send(node)
|
|
92
|
+
pair = string_class_name_pair(node)
|
|
93
|
+
return unless pair
|
|
94
|
+
|
|
95
|
+
full_name = pair.value.value.to_s.delete_prefix("::")
|
|
96
|
+
return unless cross_engine_class_name?(full_name)
|
|
97
|
+
|
|
98
|
+
assert_own_engine_configured!
|
|
99
|
+
|
|
100
|
+
add_offense(
|
|
101
|
+
pair,
|
|
102
|
+
message: format(MSG, own: own_engine, const: full_name,
|
|
103
|
+
other: full_name.split("::").first)
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
74
107
|
private
|
|
75
108
|
|
|
109
|
+
# Returns the `class_name: "..."` pair of an association macro
|
|
110
|
+
# when its value is a string literal worth checking; nil
|
|
111
|
+
# otherwise (not an association, no options, polymorphic, or a
|
|
112
|
+
# constant value already covered by `on_const`).
|
|
113
|
+
def string_class_name_pair(node)
|
|
114
|
+
options = association_options(node)
|
|
115
|
+
return nil unless options
|
|
116
|
+
return nil if polymorphic_option?(options)
|
|
117
|
+
|
|
118
|
+
pair = class_name_pair(options)
|
|
119
|
+
return nil unless pair
|
|
120
|
+
|
|
121
|
+
pair.value.str_type? ? pair : nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def association_options(node)
|
|
125
|
+
return nil unless association_macro?(node)
|
|
126
|
+
|
|
127
|
+
options = node.last_argument
|
|
128
|
+
options if options&.hash_type?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def association_macro?(node)
|
|
132
|
+
node.receiver.nil? && ASSOCIATION_MACROS.include?(node.method_name)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def polymorphic_option?(options)
|
|
136
|
+
options.pairs.any? do |pair|
|
|
137
|
+
pair.key.sym_type? && pair.key.value == :polymorphic && pair.value.true_type?
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def class_name_pair(options)
|
|
142
|
+
options.pairs.find { |pair| pair.key.sym_type? && pair.key.value == :class_name }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def cross_engine_class_name?(full_name)
|
|
146
|
+
parts = full_name.split("::")
|
|
147
|
+
return false if parts.size < 2
|
|
148
|
+
return false unless other_engines.include?(parts.first)
|
|
149
|
+
return false if exposed_concern?(full_name)
|
|
150
|
+
|
|
151
|
+
true
|
|
152
|
+
end
|
|
153
|
+
|
|
76
154
|
def flaggable?(node)
|
|
77
155
|
parts = const_parts_under_other_engine(node)
|
|
78
156
|
return false unless parts
|
data/lib/seams/event_registry.rb
CHANGED
|
@@ -12,6 +12,18 @@ module Seams
|
|
|
12
12
|
@mutex = Mutex.new
|
|
13
13
|
|
|
14
14
|
class << self
|
|
15
|
+
# Declare that an engine emits an event. Call this from the engine
|
|
16
|
+
# that owns the event (typically in its engine.rb), so the event
|
|
17
|
+
# bus, `bin/rails seams:list`, and the orphan-subscription check
|
|
18
|
+
# all know it exists.
|
|
19
|
+
#
|
|
20
|
+
# @param name [String, Symbol] the event name, +resource.action.engine+ form.
|
|
21
|
+
# @param emitted_by [String] the owning engine (e.g. "Billing").
|
|
22
|
+
# @raise [Seams::Events::InvalidEventNameError] if the name is malformed.
|
|
23
|
+
# @raise [Seams::Events::DuplicateEventError] if another engine already owns it.
|
|
24
|
+
# @return [String] the owning engine name.
|
|
25
|
+
# @example
|
|
26
|
+
# Seams::EventRegistry.register("invoice.paid.billing", emitted_by: "Billing")
|
|
15
27
|
def register(name, emitted_by:)
|
|
16
28
|
Events.assert_valid_name!(name)
|
|
17
29
|
name = name.to_s
|
|
@@ -29,18 +41,25 @@ module Seams
|
|
|
29
41
|
end
|
|
30
42
|
end
|
|
31
43
|
|
|
44
|
+
# @param name [String, Symbol] an event name.
|
|
45
|
+
# @return [Boolean] whether any engine has registered it.
|
|
32
46
|
def registered?(name)
|
|
33
47
|
@registry.key?(name.to_s)
|
|
34
48
|
end
|
|
35
49
|
|
|
50
|
+
# @param name [String, Symbol] an event name.
|
|
51
|
+
# @return [String, nil] the owning engine, or nil if unregistered.
|
|
36
52
|
def emitter_of(name)
|
|
37
53
|
@registry[name.to_s]
|
|
38
54
|
end
|
|
39
55
|
|
|
56
|
+
# @return [Hash{String=>String}] a copy of the full event => owner map.
|
|
40
57
|
def all
|
|
41
58
|
@registry.dup
|
|
42
59
|
end
|
|
43
60
|
|
|
61
|
+
# Clear the registry. Intended for test teardown.
|
|
62
|
+
# @return [void]
|
|
44
63
|
def reset!
|
|
45
64
|
@mutex.synchronize { @registry.clear }
|
|
46
65
|
end
|
data/lib/seams/events/adapter.rb
CHANGED
|
@@ -8,14 +8,23 @@ module Seams
|
|
|
8
8
|
# transport (ActiveSupport::Notifications, an external queue, etc.) and
|
|
9
9
|
# implement the three primitives below.
|
|
10
10
|
class Adapter
|
|
11
|
+
# Deliver an event (a name plus a Hash payload) to the transport.
|
|
12
|
+
# @return [void]
|
|
13
|
+
# @raise [NotImplementedError] unless a subclass overrides it.
|
|
11
14
|
def publish(_event_name, _payload)
|
|
12
15
|
raise NotImplementedError, "#{self.class} must implement #publish"
|
|
13
16
|
end
|
|
14
17
|
|
|
18
|
+
# Register a block to run when the named event is published.
|
|
19
|
+
# @return [Object] a transport-specific subscriber handle.
|
|
20
|
+
# @raise [NotImplementedError] unless a subclass overrides it.
|
|
15
21
|
def subscribe(_event_name, &)
|
|
16
22
|
raise NotImplementedError, "#{self.class} must implement #subscribe"
|
|
17
23
|
end
|
|
18
24
|
|
|
25
|
+
# Detach a previously-registered subscriber (the handle {#subscribe} returned).
|
|
26
|
+
# @return [void]
|
|
27
|
+
# @raise [NotImplementedError] unless a subclass overrides it.
|
|
19
28
|
def unsubscribe(_subscriber)
|
|
20
29
|
raise NotImplementedError, "#{self.class} must implement #unsubscribe"
|
|
21
30
|
end
|
|
@@ -7,21 +7,25 @@ require "seams/events/adapter"
|
|
|
7
7
|
|
|
8
8
|
module Seams
|
|
9
9
|
module Events
|
|
10
|
+
# Namespace for the built-in event-bus adapters.
|
|
10
11
|
module Adapters
|
|
11
12
|
# Default in-process adapter, backed by ActiveSupport::Notifications.
|
|
12
13
|
# Suitable for monolithic deployments — every engine in the host app
|
|
13
14
|
# shares the same process, so subscribers fire synchronously after
|
|
14
15
|
# the publisher's call returns.
|
|
15
16
|
class ActiveSupport < Seams::Events::Adapter
|
|
17
|
+
# (see Seams::Events::Adapter#publish)
|
|
16
18
|
def publish(event_name, payload)
|
|
17
19
|
payload = { payload: payload } unless payload.is_a?(Hash)
|
|
18
20
|
::ActiveSupport::Notifications.instrument(event_name, payload)
|
|
19
21
|
end
|
|
20
22
|
|
|
23
|
+
# (see Seams::Events::Adapter#subscribe)
|
|
21
24
|
def subscribe(event_name, &)
|
|
22
25
|
::ActiveSupport::Notifications.subscribe(event_name, &)
|
|
23
26
|
end
|
|
24
27
|
|
|
28
|
+
# (see Seams::Events::Adapter#unsubscribe)
|
|
25
29
|
def unsubscribe(subscriber)
|
|
26
30
|
::ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
27
31
|
end
|
|
@@ -20,6 +20,17 @@ module Seams
|
|
|
20
20
|
# convention that the boundary review catches.
|
|
21
21
|
module Publisher
|
|
22
22
|
class << self
|
|
23
|
+
# Publish a domain event to every subscriber.
|
|
24
|
+
#
|
|
25
|
+
# @param event_name [String, Symbol] the event name, in
|
|
26
|
+
# +resource.action.engine+ form (e.g. "subscription.created.billing").
|
|
27
|
+
# @param payload [Hash] data handed to each subscriber block.
|
|
28
|
+
# @raise [Seams::Events::InvalidEventNameError] if the name is malformed.
|
|
29
|
+
# @raise [Seams::Events::UnregisteredEventError] if no engine has
|
|
30
|
+
# registered the event via {Seams::EventRegistry.register}.
|
|
31
|
+
# @return [void]
|
|
32
|
+
# @example
|
|
33
|
+
# Seams::Events::Publisher.publish("invoice.paid.billing", invoice_id: 42)
|
|
23
34
|
def publish(event_name, payload = {})
|
|
24
35
|
Events.assert_valid_name!(event_name)
|
|
25
36
|
name = event_name.to_s
|
|
@@ -34,6 +45,18 @@ module Seams
|
|
|
34
45
|
adapter.publish(name, payload)
|
|
35
46
|
end
|
|
36
47
|
|
|
48
|
+
# Subscribe a block to an event. Subscribers run **synchronously**
|
|
49
|
+
# in the publisher's thread — enqueue a background job for any
|
|
50
|
+
# network side effect rather than performing it inline. For a
|
|
51
|
+
# reload-safe, idempotent subscription prefer {attach_class}.
|
|
52
|
+
#
|
|
53
|
+
# @param event_name [String, Symbol] the event to listen for.
|
|
54
|
+
# @yieldparam payload [Hash] the published payload.
|
|
55
|
+
# @return [Object] an adapter-specific subscriber handle.
|
|
56
|
+
# @example
|
|
57
|
+
# Seams::Events::Publisher.subscribe("identity.signed_up.auth") do |payload|
|
|
58
|
+
# WelcomeJob.perform_later(payload[:identity_id])
|
|
59
|
+
# end
|
|
37
60
|
def subscribe(event_name, &)
|
|
38
61
|
Events.assert_valid_name!(event_name)
|
|
39
62
|
name = event_name.to_s
|
|
@@ -115,6 +138,9 @@ module Seams
|
|
|
115
138
|
end
|
|
116
139
|
end
|
|
117
140
|
|
|
141
|
+
# Detach a subscriber handle from the adapter.
|
|
142
|
+
# @param subscriber [Object] the handle a subscribe call returned.
|
|
143
|
+
# @return [void]
|
|
118
144
|
def unsubscribe(subscriber)
|
|
119
145
|
adapter.unsubscribe(subscriber)
|
|
120
146
|
end
|
|
@@ -143,6 +169,9 @@ module Seams
|
|
|
143
169
|
subscriptions.reject { |name| EventRegistry.registered?(name) }.uniq
|
|
144
170
|
end
|
|
145
171
|
|
|
172
|
+
# The configured event-bus adapter, instantiated lazily.
|
|
173
|
+
# @return [Seams::Events::Adapter] the adapter instance.
|
|
174
|
+
# @raise [Seams::ConfigurationError] if the configured class can't be loaded.
|
|
146
175
|
def adapter
|
|
147
176
|
@adapter ||= build_adapter
|
|
148
177
|
end
|
data/lib/seams/events.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Seams
|
|
|
9
9
|
# effects synchronously, so that the publisher's transaction can commit
|
|
10
10
|
# quickly and side effects can retry independently.
|
|
11
11
|
module Events
|
|
12
|
+
# Base class for event-bus errors.
|
|
12
13
|
class Error < Seams::Error; end
|
|
13
14
|
|
|
14
15
|
# Raised when an event name is published that no engine has registered.
|
|
@@ -59,6 +59,7 @@ module Seams
|
|
|
59
59
|
write(File.join(engine_path, "spec/dummy/config/application.rb"), application_rb(engine_module))
|
|
60
60
|
write(File.join(engine_path, "spec/dummy/config/environment.rb"), environment_rb)
|
|
61
61
|
write(File.join(engine_path, "spec/dummy/config/database.yml"), database_yml(engine_path))
|
|
62
|
+
write(File.join(engine_path, "spec/dummy/config/cable.yml"), cable_yml)
|
|
62
63
|
write(File.join(engine_path, "spec/dummy/config/environments/test.rb"), test_environment_rb)
|
|
63
64
|
write(File.join(engine_path, "spec/dummy/config/initializers/secret_key.rb"), secret_key_rb)
|
|
64
65
|
write(File.join(engine_path, "spec/dummy/config/routes.rb"), routes_rb(engine_module, mount_at))
|
|
@@ -163,6 +164,16 @@ module Seams
|
|
|
163
164
|
YML
|
|
164
165
|
end
|
|
165
166
|
|
|
167
|
+
# solid_cable >= 4.1 reads config/cable.yml eagerly at boot, so the
|
|
168
|
+
# dummy app must ship one even though engine specs never open a
|
|
169
|
+
# socket. The in-process test adapter needs no database or server.
|
|
170
|
+
def cable_yml
|
|
171
|
+
<<~YML
|
|
172
|
+
test:
|
|
173
|
+
adapter: test
|
|
174
|
+
YML
|
|
175
|
+
end
|
|
176
|
+
|
|
166
177
|
def test_environment_rb
|
|
167
178
|
<<~RB
|
|
168
179
|
# frozen_string_literal: true
|
|
@@ -251,10 +262,20 @@ module Seams
|
|
|
251
262
|
# inherit from ::ApplicationController (the seams:engine generator
|
|
252
263
|
# default) can boot inside a request spec. Real hosts will have
|
|
253
264
|
# their own; this is dummy-only.
|
|
265
|
+
#
|
|
266
|
+
# `authenticate_identity!` is stubbed as a no-op so engine request
|
|
267
|
+
# specs pass by default (simulating an authenticated session).
|
|
268
|
+
# Specs that need to verify the authentication gate should stub or
|
|
269
|
+
# override this method, or use the `sign_in_as` helper when present.
|
|
254
270
|
<<~RB
|
|
255
271
|
# frozen_string_literal: true
|
|
256
272
|
|
|
257
273
|
class ApplicationController < ActionController::Base
|
|
274
|
+
def authenticate_identity!
|
|
275
|
+
# Stub: always passes in the dummy app. Engine specs that want to
|
|
276
|
+
# exercise the unauthenticated path should stub this method or
|
|
277
|
+
# call `sign_in_as` from the authentication test helper.
|
|
278
|
+
end
|
|
258
279
|
end
|
|
259
280
|
RB
|
|
260
281
|
end
|
|
@@ -67,7 +67,7 @@ module Seams
|
|
|
67
67
|
# assert_marker_exists!). Splitting them into separate blocks
|
|
68
68
|
# would obscure the "these are the public follow-up generator
|
|
69
69
|
# primitives" grouping. Disable BlockLength for this one block.
|
|
70
|
-
# rubocop:disable Metrics/BlockLength
|
|
70
|
+
# rubocop:disable-next Metrics/BlockLength
|
|
71
71
|
no_tasks do
|
|
72
72
|
# Resolve a path inside the host's engines/<this_engine>/.
|
|
73
73
|
# Mirrors the canonical generators' +engine_path+ method but
|
|
@@ -130,7 +130,6 @@ module Seams
|
|
|
130
130
|
MSG
|
|
131
131
|
end
|
|
132
132
|
end
|
|
133
|
-
# rubocop:enable Metrics/BlockLength
|
|
134
133
|
|
|
135
134
|
# Subclass override-point. Subclasses define their own public
|
|
136
135
|
# method named `report_summary` (or any other end-of-run name);
|
|
@@ -114,9 +114,16 @@ module Seams
|
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
def build_gem_line(name, args, group)
|
|
117
|
-
|
|
117
|
+
# Every String arg is a version requirement, so a range such as
|
|
118
|
+
# (">= 7.1", "< 9") is emitted in full.
|
|
119
|
+
version_part = args.grep(String).map { |req| %(, "#{req}") }.join
|
|
118
120
|
gem_line = %(gem "#{name}"#{version_part})
|
|
119
|
-
|
|
121
|
+
# group may be a single symbol (:development) or several
|
|
122
|
+
# (%i[development test] -> `group :development, :test do`).
|
|
123
|
+
if group
|
|
124
|
+
groups = Array(group).map(&:inspect).join(", ")
|
|
125
|
+
gem_line = "group #{groups} do\n #{gem_line}\nend"
|
|
126
|
+
end
|
|
120
127
|
gem_line
|
|
121
128
|
end
|
|
122
129
|
end
|
|
@@ -11,8 +11,8 @@ module Seams
|
|
|
11
11
|
# the whole file. The Splicer is the shared primitive: every
|
|
12
12
|
# follow-up generator funnels through these methods.
|
|
13
13
|
#
|
|
14
|
-
# See doc/INSERTION_POINTS.md for the marker format spec and
|
|
15
|
-
# doc/INSERTION_POINTS_CATALOGUE.md for the canonical list of
|
|
14
|
+
# See doc/reference/INSERTION_POINTS.md for the marker format spec and
|
|
15
|
+
# doc/reference/INSERTION_POINTS_CATALOGUE.md for the canonical list of
|
|
16
16
|
# markers each engine ships.
|
|
17
17
|
#
|
|
18
18
|
# Example:
|
|
@@ -9,22 +9,44 @@ module Seams
|
|
|
9
9
|
# Datadog, OpenTelemetry, etc.) — engines never speak to those
|
|
10
10
|
# libraries directly.
|
|
11
11
|
class Adapter
|
|
12
|
+
# Log a debug-level message with structured context.
|
|
13
|
+
# @param _message [String] the log message.
|
|
14
|
+
# @param _context [Hash] structured key/value context (engine, actor id, ...).
|
|
15
|
+
# @return [void]
|
|
16
|
+
# @raise [NotImplementedError] unless a subclass overrides it.
|
|
12
17
|
def debug(_message, **_context)
|
|
13
18
|
raise NotImplementedError, "#{self.class} must implement #debug"
|
|
14
19
|
end
|
|
15
20
|
|
|
21
|
+
# Log an info-level message with structured context.
|
|
22
|
+
# @param (see #debug)
|
|
23
|
+
# @return [void]
|
|
24
|
+
# @raise [NotImplementedError] unless a subclass overrides it.
|
|
16
25
|
def info(_message, **_context)
|
|
17
26
|
raise NotImplementedError, "#{self.class} must implement #info"
|
|
18
27
|
end
|
|
19
28
|
|
|
29
|
+
# Log a warn-level message with structured context.
|
|
30
|
+
# @param (see #debug)
|
|
31
|
+
# @return [void]
|
|
32
|
+
# @raise [NotImplementedError] unless a subclass overrides it.
|
|
20
33
|
def warn(_message, **_context)
|
|
21
34
|
raise NotImplementedError, "#{self.class} must implement #warn"
|
|
22
35
|
end
|
|
23
36
|
|
|
37
|
+
# Log an error-level message with structured context.
|
|
38
|
+
# @param (see #debug)
|
|
39
|
+
# @return [void]
|
|
40
|
+
# @raise [NotImplementedError] unless a subclass overrides it.
|
|
24
41
|
def error(_message, **_context)
|
|
25
42
|
raise NotImplementedError, "#{self.class} must implement #error"
|
|
26
43
|
end
|
|
27
44
|
|
|
45
|
+
# Time the given block and emit the duration as structured context.
|
|
46
|
+
# @param _operation [String] a label for the timed operation.
|
|
47
|
+
# @param _context [Hash] structured key/value context.
|
|
48
|
+
# @return [Object] whatever the block returns.
|
|
49
|
+
# @raise [NotImplementedError] unless a subclass overrides it.
|
|
28
50
|
def measure(_operation, **_context, &)
|
|
29
51
|
raise NotImplementedError, "#{self.class} must implement #measure"
|
|
30
52
|
end
|
data/lib/seams/observability.rb
CHANGED
|
@@ -9,13 +9,19 @@ module Seams
|
|
|
9
9
|
# logs can be parsed and filtered uniformly by the host application's
|
|
10
10
|
# log aggregator.
|
|
11
11
|
module Observability
|
|
12
|
+
# Base class for observability errors.
|
|
12
13
|
class Error < Seams::Error; end
|
|
13
14
|
|
|
14
15
|
class << self
|
|
16
|
+
# The configured observability adapter, instantiated lazily.
|
|
17
|
+
# @return [Seams::Observability::Adapter] the adapter instance.
|
|
18
|
+
# @raise [Seams::ConfigurationError] if the configured class can't be loaded.
|
|
15
19
|
def adapter
|
|
16
20
|
@adapter ||= build_adapter
|
|
17
21
|
end
|
|
18
22
|
|
|
23
|
+
# Drop the memoized adapter so the next call rebuilds it. Test hook.
|
|
24
|
+
# @return [void]
|
|
19
25
|
def reset!
|
|
20
26
|
@adapter = nil
|
|
21
27
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "seams/permissions"
|
|
4
|
+
|
|
5
|
+
module Seams
|
|
6
|
+
# Tracks every ability code that an engine has declared it understands,
|
|
7
|
+
# along with the engine that owns it. Acts as the single source of truth
|
|
8
|
+
# for authorisation lookups and prevents two engines from claiming the
|
|
9
|
+
# same ability code.
|
|
10
|
+
module PermissionRegistry
|
|
11
|
+
@registry = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Declare that an engine understands an ability code. Call this from
|
|
16
|
+
# the engine that owns the ability so authorisation lookups have a
|
|
17
|
+
# single source of truth.
|
|
18
|
+
#
|
|
19
|
+
# @param ability [String, Symbol] the ability code (e.g. "billing.manage").
|
|
20
|
+
# @param owned_by [String] the owning engine (e.g. "Billing").
|
|
21
|
+
# @raise [Seams::Permissions::DuplicateAbilityError] if another engine owns it.
|
|
22
|
+
# @return [String] the owning engine name.
|
|
23
|
+
# @example
|
|
24
|
+
# Seams::PermissionRegistry.register("billing.manage", owned_by: "Billing")
|
|
25
|
+
def register(ability, owned_by:)
|
|
26
|
+
Permissions.assert_valid_name!(ability)
|
|
27
|
+
ability = ability.to_s
|
|
28
|
+
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
existing = @registry[ability]
|
|
31
|
+
|
|
32
|
+
if existing && existing != owned_by
|
|
33
|
+
raise Permissions::DuplicateAbilityError,
|
|
34
|
+
"Ability #{ability.inspect} already registered by #{existing.inspect}; " \
|
|
35
|
+
"cannot also be registered by #{owned_by.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@registry[ability] = owned_by
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param ability [String, Symbol] an ability code.
|
|
43
|
+
# @return [Boolean] whether any engine has registered it.
|
|
44
|
+
def registered?(ability)
|
|
45
|
+
@registry.key?(ability.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param ability [String, Symbol] an ability code.
|
|
49
|
+
# @return [String, nil] the owning engine, or nil if unregistered.
|
|
50
|
+
def owner_of(ability)
|
|
51
|
+
@registry[ability.to_s]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Hash{String=>String}] a copy of the full ability => owner map.
|
|
55
|
+
def all
|
|
56
|
+
@registry.dup
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Clear the registry. Intended for test teardown.
|
|
60
|
+
# @return [void]
|
|
61
|
+
def reset!
|
|
62
|
+
@mutex.synchronize { @registry.clear }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Seams
|
|
4
|
+
# Permissions module — public API for authorisation across engines.
|
|
5
|
+
#
|
|
6
|
+
# Engines register the ability codes they understand at boot through
|
|
7
|
+
# Seams::PermissionRegistry, mirroring the event bus. Authorisation is
|
|
8
|
+
# resolved through Seams::Permissions.can?, which answers from a
|
|
9
|
+
# code-defined role hierarchy and a host-configurable grant map. Nothing
|
|
10
|
+
# here enforces permissions on a request; it is the machinery that the
|
|
11
|
+
# request and controller layers build on in later phases.
|
|
12
|
+
module Permissions
|
|
13
|
+
class Error < Seams::Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a permission is checked for an ability that no engine has
|
|
16
|
+
# registered.
|
|
17
|
+
class UnregisteredAbilityError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when two engines try to register the same ability code.
|
|
20
|
+
class DuplicateAbilityError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when an ability code doesn't follow the resource.action.engine
|
|
23
|
+
# convention (e.g., "invoice.read.billing").
|
|
24
|
+
class InvalidAbilityNameError < Error; end
|
|
25
|
+
|
|
26
|
+
# Three dot-separated segments, lowercase, snake_case allowed. Matches
|
|
27
|
+
# the event name convention so abilities and events read alike.
|
|
28
|
+
NAME_PATTERN = /\A[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\z/
|
|
29
|
+
|
|
30
|
+
# Roles ordered from most to least privileged. Each role inherits every
|
|
31
|
+
# ability granted to the roles below it (owner ⊇ admin ⊇ member).
|
|
32
|
+
ROLE_HIERARCHY = %w[owner admin member].freeze
|
|
33
|
+
|
|
34
|
+
# A pseudo-role that bypasses every check — used by trusted internal
|
|
35
|
+
# callers such as background jobs and system actors.
|
|
36
|
+
SYSTEM_ROLE = "system"
|
|
37
|
+
|
|
38
|
+
# Sensible default grant map so `can?` allows things out of the box,
|
|
39
|
+
# keyed by role. `member` may read; `admin` may manage. `owner`
|
|
40
|
+
# inherits everything granted to `admin` (and through it `member`)
|
|
41
|
+
# via ROLE_HIERARCHY, and `system` bypasses checks entirely — so
|
|
42
|
+
# neither needs its own entry here. Hosts override the whole map
|
|
43
|
+
# through `Seams.configuration.permission_grants`. The codes mirror
|
|
44
|
+
# what each canonical engine registers from its engine.rb.
|
|
45
|
+
DEFAULT_GRANTS = {
|
|
46
|
+
"member" => %w[
|
|
47
|
+
identity.read.auth
|
|
48
|
+
invoice.read.billing
|
|
49
|
+
subscription.read.billing
|
|
50
|
+
team.read.teams
|
|
51
|
+
account.read.accounts
|
|
52
|
+
membership.read.accounts
|
|
53
|
+
notification.read.notifications
|
|
54
|
+
].freeze,
|
|
55
|
+
"admin" => %w[
|
|
56
|
+
identity.manage.auth
|
|
57
|
+
invoice.manage.billing
|
|
58
|
+
subscription.manage.billing
|
|
59
|
+
plan.manage.billing
|
|
60
|
+
lifetime.manage.billing
|
|
61
|
+
team.manage.teams
|
|
62
|
+
member.manage.teams
|
|
63
|
+
invitation.manage.teams
|
|
64
|
+
account.manage.accounts
|
|
65
|
+
membership.manage.accounts
|
|
66
|
+
notification.manage.notifications
|
|
67
|
+
preference.manage.notifications
|
|
68
|
+
].freeze
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
def self.valid_name?(name)
|
|
72
|
+
NAME_PATTERN.match?(name.to_s)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.assert_valid_name!(name)
|
|
76
|
+
return if valid_name?(name)
|
|
77
|
+
|
|
78
|
+
raise InvalidAbilityNameError,
|
|
79
|
+
"Ability name #{name.inspect} must follow resource.action.engine " \
|
|
80
|
+
"(e.g. invoice.read.billing)"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Resolves whether a role is allowed an ability. The role string is one
|
|
84
|
+
# of owner/admin/member/system; request-layer bypasses (e.g. staff?)
|
|
85
|
+
# are handled by callers, not here. Raises if the ability was never
|
|
86
|
+
# registered, matching the event bus's loud-failure behaviour.
|
|
87
|
+
def self.can?(role:, ability:)
|
|
88
|
+
ability = ability.to_s
|
|
89
|
+
|
|
90
|
+
unless PermissionRegistry.registered?(ability)
|
|
91
|
+
raise UnregisteredAbilityError,
|
|
92
|
+
"Ability #{ability.inspect} is not registered; register it with " \
|
|
93
|
+
"Seams::PermissionRegistry.register before checking it"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
role = role.to_s
|
|
97
|
+
return true if role == SYSTEM_ROLE
|
|
98
|
+
|
|
99
|
+
roles_for(role).any? { |r| granted_abilities_for(r).include?(ability) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The role itself plus every role it inherits from, lowest first. An
|
|
103
|
+
# unknown role resolves to just itself (and therefore no grants).
|
|
104
|
+
def self.roles_for(role)
|
|
105
|
+
role = role.to_s
|
|
106
|
+
index = ROLE_HIERARCHY.index(role)
|
|
107
|
+
return [role] unless index
|
|
108
|
+
|
|
109
|
+
ROLE_HIERARCHY[index..]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Abilities granted directly to a single role in the configured map,
|
|
113
|
+
# tolerating string or symbol keys and values.
|
|
114
|
+
def self.granted_abilities_for(role)
|
|
115
|
+
grants = Seams.configuration.permission_grants
|
|
116
|
+
list = grants[role.to_s] || grants[role.to_sym]
|
|
117
|
+
Array(list).map(&:to_s)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
data/lib/seams/version.rb
CHANGED
data/lib/seams.rb
CHANGED
|
@@ -6,8 +6,13 @@ require "seams/version"
|
|
|
6
6
|
#
|
|
7
7
|
# See https://github.com/Davidslv/seams for documentation.
|
|
8
8
|
module Seams
|
|
9
|
+
# Base class for all Seams errors.
|
|
9
10
|
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Raised when Seams is misconfigured (e.g. an adapter class can't be loaded).
|
|
10
13
|
class ConfigurationError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a generator hits an unrecoverable problem.
|
|
11
16
|
class GeneratorError < Error; end
|
|
12
17
|
end
|
|
13
18
|
|
|
@@ -17,6 +22,8 @@ require "seams/events"
|
|
|
17
22
|
require "seams/events/adapter"
|
|
18
23
|
require "seams/events/adapters/active_support"
|
|
19
24
|
require "seams/event_registry"
|
|
25
|
+
require "seams/permissions"
|
|
26
|
+
require "seams/permission_registry"
|
|
20
27
|
require "seams/events/publisher"
|
|
21
28
|
require "seams/observability"
|
|
22
29
|
require "seams/observability/adapter"
|