diff --git a/CHANGELOG.md b/CHANGELOG.md index 658422b..e1e4273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## Unreleased + +- Add `SolidObjects::Web`, a mountable Rack dashboard for the actor runtime. + It covers instances and their committed state, the ready and claimed + mailbox, reminders, effects, broadcasts, dead letters, and processes, with + actor-type and actor-id filtering, status filters, paging, and a polled + `GET /stats` endpoint. Mount it with + `mount SolidObjects::Web => "/solid_objects/dashboard"` after + `require "solid_objects/web"`; requiring the gem does not load it, so a + worker process carries no web stack. +- Authorize every dashboard route through `authorize_administration`. Each + route declares its own `action` and `resource`, and a route declared without + a policy raises at load time. The policy receives a context that answers + `request`, `session`, and `env`. +- Add two dashboard actions: an idempotent dead letter retry through + `SolidObjects.dead_letters.retry`, and instance pause/resume, which sets and + clears `paused_at` so the activation manager stops claiming that identity. A + retry the mailbox refuses, such as an actor class that no longer exists, + renders the reason with a 422 rather than failing the request. +- Draw instances per actor type, mailbox depth, and outbox and reminder status + with Chart.js, loaded from a CDN with a subresource integrity hash. The CDN + host is the only external origin the content security policy names. Point + `SolidObjects::Web.chart_library_url` at a vendored copy for a deployment + with no outbound network access, or set it to nil to render without charts. +- Add `SolidObjects::Web.register` for extension tabs, routes, and view + directories, and `SolidObjects::Web.use` for Rack middleware in front of the + dashboard. +- Add `rack` as an explicit dependency at `>= 3.1`, and package the `web/` + directory in the gem. + ## 0.13.0 - 2026-08-13 - **Breaking:** make observables invalidation-only by default. An ordinary diff --git a/Gemfile.lock b/Gemfile.lock index ee653c9..68e1420 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,7 @@ PATH actionview (>= 8.0) activerecord (>= 8.0) activesupport (>= 8.0) + rack (>= 3.1) railties (>= 8.0) thor (>= 1.3) diff --git a/README.md b/README.md index 3d26f8a..964d7b6 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ tested, but the project does not yet claim production readiness. See - [State migrations](#state-migrations) - [Configuration](#configuration) - [Workers and operations](#workers-and-operations) +- [Dashboard](#dashboard) - [Database support](#database-support) - [Guarantees](#guarantees) - [When to use it](#when-to-use-it) @@ -1084,6 +1085,50 @@ an initializer. See the [operations guide](docs/operations.md) for monitoring, reconciliation, shutdown, retention, and backup guidance. +## Dashboard + +`SolidObjects::Web` is a Rack application that shows instances and their state, +the mailbox, reminders, effects, broadcasts, dead letters, and the registered +processes. Mount it inside the application routes, so the Rails session +middleware runs first: + +```ruby +# config/routes.rb +require "solid_objects/web" + +Rails.application.routes.draw do + mount SolidObjects::Web => "/solid_objects/dashboard" +end +``` + +It is not loaded by `require "solid_objects"`: a worker process must not carry +a web stack. The dashboard and the engine are separate mounts, so an +application that uses reactive ERB mounts both on different paths. + +Every page asks `authorize_administration` before its handler runs, and that +policy denies by default, so a mount alone exposes nothing. The block receives +the route's own `action:` and `resource:`, and an `authorization_context:` that +answers `request`, `session`, and `env`. + +The dashboard changes only two things. Retrying a dead letter goes through +`SolidObjects.dead_letters.retry`, which is idempotent. Pausing an instance +sets `paused_at` so the activation manager stops claiming that identity; a pass +already in flight finishes its turn, and a synchronous caller waiting on a +paused instance times out rather than receiving a result. + +The dashboard draws instances per actor type, mailbox depth, and outbox status +with Chart.js, loaded from a CDN with a subresource integrity hash. A +deployment with no outbound network access can vendor the file, or turn the +charts off: + +```ruby +SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js" +SolidObjects::Web.chart_library_integrity = nil +``` + +Read the [dashboard guide](docs/dashboard.md) for the full policy table, +extension registration, and query cost. + ## Database support Solid Objects supports: diff --git a/docs/authorization.md b/docs/authorization.md index fd5ca97..da1df7d 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -13,7 +13,7 @@ intentionally inert until the host application defines its trust boundary. | `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants | | `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted | | `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors | -| `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable | +| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable | Waiting again through `MessageReference#wait` reauthorizes the stored invocation as a message or query. Internal reminder, effect-callback, and @@ -154,6 +154,13 @@ configuration.authorize_administration = lambda do |authorization_context:, **| end ``` +That policy also denies every `SolidObjects::Web` page, which is the correct +result for a host whose only administration boundary is shell access. A policy +that opens the dashboard should separate reading from writing, because +`action` distinguishes them: `index` and `show` read, while `pause`, `resume`, +and `retry` change the runtime. The [dashboard guide](dashboard.md) lists the +action and resource of every page. + Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy probe is deliberately conservative: a context-aware policy may correctly warn because it denies a `nil` context. diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..395c9a5 --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,200 @@ +# Operator dashboard + +`SolidObjects::Web` is a Rack application that shows what the actor runtime is +doing: instances and their state, the mailbox, reminders, effects, broadcasts, +dead letters, and the registered processes. It reads the same tables the +runtime writes, so it needs no separate store and no agent. + +It is deliberately not loaded by `require "solid_objects"`. A worker process +must not carry a web stack, and an application that never mounts the dashboard +must not pay for it. + +## Mounting + +```ruby +# config/routes.rb +require "solid_objects/web" + +Rails.application.routes.draw do + mount SolidObjects::Web => "/solid_objects/dashboard" +end +``` + +Mount it inside the application routes so the Rails session middleware runs +first. The dashboard needs a Rack session for CSRF protection and refuses a +state changing request without one. + +The dashboard and the engine are separate mounts. Mount the engine as well if +the application uses reactive ERB, and give each one its own path: + +```ruby +mount SolidObjects::Engine => "/solid_objects" +mount SolidObjects::Web => "/solid_objects/dashboard" +``` + +In a bare Rack application, supply the session middleware yourself: + +```ruby +use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true +run SolidObjects::Web +``` + +## Authorization + +Every page asks `configuration.authorize_administration` before its handler +runs. That policy denies by default, so a mount alone exposes nothing. A route +declared without a policy raises at load time, which is why a new page cannot +reach the actor tables before an application has said who may read it. + +The block receives the route's own action and resource: + +| Page | `action` | `resource` | `resource_id` | +| --- | --- | --- | --- | +| Dashboard, `GET /stats`, `HEAD /` | `index` | `dashboard` | none | +| Instance list | `index` | `instances` | none | +| Instance detail | `show` | `instances` | instance id | +| Pause an instance | `pause` | `instances` | instance id | +| Resume an instance | `resume` | `instances` | instance id | +| Mailbox | `index` | `messages` | none | +| Message detail | `show` | `messages` | message id | +| Reminders | `index` | `reminders` | none | +| Effects | `index` | `effects` | none | +| Broadcasts | `index` | `broadcasts` | none | +| Dead letter list | `index` | `dead_letters` | none | +| Dead letter detail | `show` | `dead_letters` | dead letter id | +| Retry a dead letter | `retry` | `dead_letters` | dead letter id | +| Processes | `index` | `processes` | none | + +`authorization_context:` is the request object. It answers `request`, +`session`, and `env`, so a policy can read the signed-in operator the same way +a controller does: + +```ruby +SolidObjects.configure do |configuration| + configuration.authorize_administration = lambda do |action:, authorization_context:, **| + return false unless authorization_context.respond_to?(:session) + + operator = Operator.find_by(id: authorization_context.session[:operator_id]) + return false unless operator&.administrator? + + action == "index" || action == "show" || operator.may_write_runtime? + end +end +``` + +The command line reaches the same policy with `{ source: "cli" }` rather than +a request, which is why the example checks what the context answers before +reading a session from it. + +## Pages + +**Dashboard.** Totals per subsystem, the registered processes, and the most +recent dead letters. The summary bar appears on every page and can poll +`GET /stats` for the same numbers; nothing else on the page refreshes, because +a table that reloads under an operator who is reading it is worse than a stale +one. + +**Instances.** Filter by actor type and by an actor id substring. Each row +shows the lease state: `idle`, `activated`, `expired`, or `paused`. The detail +page shows committed state, the ready and claimed mailbox, message history, +reminders, effects, broadcasts, and dead letters for that identity. + +**Mailbox.** The ready and claimed messages across every identity, oldest +first. Mailbox lag on the summary bar is the age of the oldest message that is +already due, which is how far behind the workers are. + +**Reminders, effects, broadcasts, processes.** Status filtered lists. + +## Charts + +The dashboard draws three charts: instances per actor type, mailbox depth, and +a stacked view of effects, broadcasts, and reminders by status. Each canvas +carries its own numbers in a `data-chart-values` attribute, so the page needs +no inline script and no request to draw. Mailbox depth and the status chart +redraw when the Live poller reports new totals, because `/stats` already +carries those numbers. The instance chart does not: `/stats` does not group by +actor type, and adding that would put a `GROUP BY` on every poll. + +Chart.js comes from a CDN with a subresource integrity hash, so a compromised +CDN cannot substitute other code, and the CDN host is the only external origin +the content security policy names. + +A deployment with no outbound network access should vendor the file: + +```ruby +SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js" +SolidObjects::Web.chart_library_integrity = nil +``` + +A path below the mount is served from the dashboard's own asset directory and +needs no policy exception. Setting the URL to `nil` renders the dashboard +without charts and names no external origin at all. + +Set these before the first request. The middleware stack and the compiled +templates are built once and cached. + +**Dead letters.** The exception, its message, and its backtrace, with a retry +button. + +## Actions + +The dashboard changes only two things. + +**Retry a dead letter** goes through `SolidObjects.dead_letters.retry`, which +enqueues the original operation under an idempotency key. Pressing it twice +produces one message rather than two. + +A retry re-enters the mailbox, which refuses work the runtime cannot accept: an +actor class that no longer exists, a full mailbox, a payload over the cap. The +dashboard renders the dead letter again with the reason and a 422 status, +rather than failing the request. + +**Pause an instance** sets `paused_at`, and the activation manager stops +claiming that identity. Two consequences matter: + +- A pass already in flight finishes its turn. Pause is not a stop. +- A synchronous caller waiting on a paused instance times out rather than + receiving a result, because nothing will execute its message. + +Resume clears the column and the mailbox drains in sequence order. + +## Extensions + +An extension adds pages by declaring routes on the application class. Its +routes carry an authorization policy like every other route: + +```ruby +module Tenants + def self.registered(application) + application.get "/tenants", policy: { action: "index", resource: "tenants" } do + @tenants = Tenant.order(:name) + erb(:tenants) + end + end +end + +SolidObjects::Web.register( + Tenants, + tab: "Tenants", + path: "/tenants", + views: File.expand_path("../web/views", __dir__) +) +``` + +A registered view directory is searched before the packaged one, so an +application can replace a single page without forking the gem. A template +reads its arguments from `locals`, and a replacement `layout.erb` renders the +page it wraps with `locals.fetch(:content)`. + +Add Rack middleware in front of the dashboard with `SolidObjects::Web.use`, +for example to require HTTP basic authentication in an environment that has no +session-backed operator. + +## Cost + +The summary bar issues one grouped count per subsystem on every page, and each +list page counts its own relation to page it. That is a fixed set of indexed +aggregate queries, not a scan proportional to actor traffic, but it is not +free: do not put the dashboard behind an uptime monitor that loads the whole +page on an interval. `HEAD /` exists for that. It touches one table and +returns no body. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6fab961..e29138c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -108,8 +108,25 @@ handing the block a raw Cable connection. - Backpressure: mailbox/payload/state/result caps and fair yields exist; distributed per-actor rate limits and global admission control do not. -- Administration: actor and dead-letter views plus policy hooks exist; richer - filtering, audit records, and bulk-safe tools do not. +- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering + instances, mailbox, reminders, effects, broadcasts, dead letters, and + processes, with actor-type and actor-id filtering, status filters, paging, a + polled stats endpoint, Chart.js charts, and extension registration. The chart + library is fetched from a CDN with a subresource integrity hash, which a + deployment without outbound network access must replace with a vendored copy + or turn off. Every route declares its + own administration policy and a route declared without one raises at load + time, so the deny-by-default posture is enforced by construction rather than + by remembering to add a check. It changes only two things: an idempotent dead + letter retry and instance pause/resume. What does not exist is audit records + of who pressed what, and bulk-safe tools: retry is one dead letter at a time, + because `DeadLetterManager` exposes no bulk operation. Pause is an operator + brake and not a stop, since a pass already in flight finishes its turn and a + synchronous caller waiting on a paused instance times out. The page cost was + reasoned about rather than measured: the summary bar issues a fixed set of + indexed aggregate queries per page, which is why `HEAD /` exists for uptime + monitors, but no dashboard latency has been benchmarked against a large + table. ## Next milestones diff --git a/lib/solid_objects/web.rb b/lib/solid_objects/web.rb new file mode 100644 index 0000000..e8f43e0 --- /dev/null +++ b/lib/solid_objects/web.rb @@ -0,0 +1,222 @@ +# rbs_inline: enabled + +require "erb" +require "json" +require "securerandom" +require "uri" +require "rack" +require "rack/builder" +require "rack/static" + +require "solid_objects" +require "solid_objects/web/route" +require "solid_objects/web/router" +require "solid_objects/web/statistics" +require "solid_objects/web/paginator" +require "solid_objects/web/helpers" +require "solid_objects/web/action" +require "solid_objects/web/csrf_protection" +require "solid_objects/web/application" + +module SolidObjects + # An operator dashboard for the actor runtime, served as a Rack application: + # + # Rails.application.routes.draw do + # mount SolidObjects::Web => "/solid_objects" + # end + # + # It is deliberately not loaded by `require "solid_objects"`. A worker + # process must not carry a web stack, and an application that never mounts + # the dashboard must not pay for it. + # + # Every page asks `configuration.authorize_administration` first. That block + # denies by default, so a mount alone exposes nothing until an application + # states who may read it. + class Web + ROOT = File.expand_path("../../web", __dir__) + VIEWS = File.join(ROOT, "views") + ASSETS = File.join(ROOT, "assets") + + NONCE_KEY = "solid_objects.content_security_policy_nonce" + CSRF_TOKEN_KEY = "solid_objects.csrf_token" + NONCE_BYTES = 16 + ASSET_CACHE_SECONDS = 86_400 + TEMPLATE_NAME = /\A_?[a-z][a-z0-9_]*\z/ + + # The dashboard charts need a charting library, and this one is fetched + # from a public CDN with a subresource integrity hash, so a compromised CDN + # cannot substitute other code. A deployment with no outbound network + # access should vendor the file and point `chart_library_url` at it, or set + # that to nil to render the dashboard without charts. + CHART_LIBRARY_URL = "https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js" + CHART_LIBRARY_INTEGRITY = "sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4/DCMd7XLcA6Y" + + DEFAULT_TABS = { + "Dashboard" => "/", + "Instances" => "/instances", + "Mailbox" => "/mailbox", + "Reminders" => "/reminders", + "Effects" => "/effects", + "Broadcasts" => "/broadcasts", + "Dead letters" => "/dead_letters", + "Processes" => "/processes" + }.freeze + + LOCK = Mutex.new + + class << self + # A path below the mount serves a vendored copy; an absolute URL is + # fetched from that host and is named in the content security policy. + # nil renders the dashboard without charts. + # @rbs @chart_library_url: String? + # @rbs @chart_library_integrity: String? + attr_writer :chart_library_url, :chart_library_integrity + + # @rbs () -> String? + def chart_library_url + defined?(@chart_library_url) ? @chart_library_url : CHART_LIBRARY_URL + end + + # @rbs () -> String? + def chart_library_integrity + defined?(@chart_library_integrity) ? @chart_library_integrity : CHART_LIBRARY_INTEGRITY + end + + # @rbs () -> bool + def charts? + !chart_library_url.nil? + end + + # The origin the content security policy has to allow. A vendored copy + # served from the mount has none, so the policy stays at 'self'. + # @rbs () -> String? + def chart_library_origin + url = chart_library_url + return nil unless url&.include?("//") + + uri = URI.parse(url) + return nil unless uri.scheme && uri.host + + "#{uri.scheme}://#{uri.host}" + rescue URI::InvalidURIError + nil + end + + # @rbs () -> Hash[String, String] + def tabs + @tabs ||= DEFAULT_TABS.dup + end + + # Searched in order, so an extension directory added first wins over the + # packaged one and an application can replace a single page. + # @rbs () -> Array[String] + def views + @views ||= [ VIEWS ] + end + + # @rbs () -> Array[[Array[untyped], Proc?]] + def middlewares + @middlewares ||= [] + end + + # The built stack is memoized, so a middleware added after the first + # request would otherwise be dropped without a word. + # @rbs (*untyped) ?{ () -> untyped } -> void + def use(*arguments, &block) + middlewares << [ arguments, block ] + LOCK.synchronize { @application = nil } + end + + # Adds pages to the dashboard. The extension receives the application + # class and declares its own routes on it, which means its routes carry + # an authorization policy like every other route. + # + # @rbs (untyped, tab: String, path: String, ?views: String?) -> void + def register(extension, tab:, path:, views: nil) + if views + self.views.unshift(views) + # A template compiled before this call resolved against the old + # search path, so a replacement view would never be reached. + LOCK.synchronize { @templates = {} } + end + tabs[tab] = path + extension.registered(Application) + end + + # @rbs (Symbol) -> untyped + def template(name) + LOCK.synchronize do + templates[name] ||= ERB.new(File.read(template_path(name)), trim_mode: "-") + end + end + + # @rbs () -> void + def reset! + LOCK.synchronize { @templates = {} } + @tabs = nil + @views = nil + @middlewares = nil + @application = nil + remove_instance_variable(:@chart_library_url) if defined?(@chart_library_url) + remove_instance_variable(:@chart_library_integrity) if defined?(@chart_library_integrity) + end + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call(env) + application.call(env) + end + + # @rbs () -> Web + def application + LOCK.synchronize { @application ||= new } + end + + private + + # @rbs () -> Hash[Symbol, untyped] + def templates + @templates ||= {} + end + + # A template name never comes from a request, and this keeps it that way + # rather than trusting every future caller to know it. + # @rbs (Symbol) -> String + def template_path(name) + raise ArgumentError, "invalid template name" unless name.to_s.match?(TEMPLATE_NAME) + + found = views.lazy.map { |directory| File.join(directory, "#{name}.erb") }.find { |path| File.exist?(path) } + found || raise(ArgumentError, "no template named #{name}") + end + end + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call(env) + env[NONCE_KEY] = SecureRandom.base64(NONCE_BYTES) + app.call(env) + end + + # @rbs () -> untyped + def app + @app ||= build + end + + private + + # @rbs () -> untyped + def build + assets = ASSETS + extra = self.class.middlewares + + ::Rack::Builder.new do + use ::Rack::Static, + urls: [ "/stylesheets", "/javascripts" ], + root: assets, + cascade: true, + header_rules: [ [ :all, { "cache-control" => "private, max-age=#{ASSET_CACHE_SECONDS}" } ] ] + extra.each { |arguments, block| use(*arguments, &block) } + use CsrfProtection + run Application.new + end + end + end +end diff --git a/lib/solid_objects/web/action.rb b/lib/solid_objects/web/action.rb new file mode 100644 index 0000000..abb3dd2 --- /dev/null +++ b/lib/solid_objects/web/action.rb @@ -0,0 +1,109 @@ +# rbs_inline: enabled + +require "erb" +require "rack/request" +require "rack/utils" + +module SolidObjects + class Web + # One request. A route handler runs inside an instance of this class, so a + # handler and the template it renders share the same helpers and the same + # request. + class Action + include Helpers + + # @rbs @env: Hash[String, untyped] + # @rbs @route: Route + # @rbs @captures: Hash[Symbol, String?] + # @rbs @request: untyped + # @rbs @layout_rendered: bool + # @rbs @response_status: Integer + + attr_reader :env, :route, :response_status + + # @rbs (env: Hash[String, untyped], route: Route) -> void + def initialize(env:, route:) + @env = env + @route = route + @captures = route.capture(env["PATH_INFO"].to_s) + @layout_rendered = false + @response_status = 200 + end + + # Sets the status a rendered page is served with. `halt` and `redirect` + # stop the handler, so a page that must render its own body and still + # report a failure needs this instead. + # @rbs (Integer) -> void + def status(code) + @response_status = code + end + + # @rbs () -> untyped + def request + @request ||= ::Rack::Request.new(env) + end + + # @rbs () -> Hash[untyped, untyped]? + def session + env["rack.session"] + end + + # @rbs (Symbol) -> String? + def route_params(key) + @captures[key] + end + + # @rbs (String) -> untyped + def url_params(key) + request.params[key] + end + + # @rbs () -> untyped + def call + instance_exec(&route.handler) + end + + # The layout is rendered once per request. The flag is raised before the + # page body runs so a partial the body renders returns its own fragment + # rather than a second whole page. The layout reads the page it wraps + # from `locals.fetch(:content)`. + # @rbs (Symbol, ?Hash[Symbol, untyped]) -> String + def erb(name, locals = {}) + return evaluate(Web.template(name), locals) if @layout_rendered + + @layout_rendered = true + content = evaluate(Web.template(name), locals) + evaluate(Web.template(:layout), { content: }) + end + + # @rbs (Integer, ?String) -> void + def halt(status, body = ::Rack::Utils::HTTP_STATUS_CODES.fetch(status, "Error")) + throw :halt, [ status, { "content-type" => "text/plain" }, [ body ] ] + end + + # @rbs (String) -> void + def redirect(path) + throw :halt, [ 302, { "location" => path_to(path) }, [] ] + end + + # @rbs (untyped) -> void + def json(payload) + throw :halt, [ + 200, + { "content-type" => "application/json", "cache-control" => "private, no-store" }, + [ JSON.generate(payload) ] + ] + end + + private + + # `locals` is a local variable of this method, so a template reads it by + # name, and every helper is reachable because the template runs against + # this object. + # @rbs (untyped, Hash[Symbol, untyped]) -> String + def evaluate(template, locals) + template.result(binding) + end + end + end +end diff --git a/lib/solid_objects/web/application.rb b/lib/solid_objects/web/application.rb new file mode 100644 index 0000000..26aeb4d --- /dev/null +++ b/lib/solid_objects/web/application.rb @@ -0,0 +1,239 @@ +# rbs_inline: enabled + +module SolidObjects + class Web + # The pages. Each route states the administration policy it needs, and + # `call` asks that policy before the handler runs, so a page cannot read + # the actor tables on behalf of an unauthorized request. + class Application + extend Router + + SCRIPT_PLACEHOLDER = "!script-src!" + CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "img-src 'self' data:", + "style-src 'self'", + "script-src #{SCRIPT_PLACEHOLDER}", + "connect-src 'self'", + "object-src 'none'" + ].join("; ").freeze + + MAILBOX_MEMBERSHIPS = %w[ready claimed].freeze + RECENT_LIMIT = 10 + CHART_TYPE_LIMIT = 12 + + head "/", policy: { action: "index", resource: "dashboard" } do + # The cheapest liveness check available: it proves the dashboard can + # reach the database the actors run on, and returns no body. + ReadyMessage.count + "" + end + + get "/", policy: { action: "index", resource: "dashboard" } do + @statistics = statistics.to_h + @processes = Process.order(last_heartbeat_at: :desc).limit(RECENT_LIMIT) + @dead_letters = DeadLetter.order(last_failed_at: :desc, id: :desc).limit(RECENT_LIMIT) + # The only chart that costs its own query, and the only one the poller + # cannot refresh, because the other two read what `/stats` already + # returns. Bounded so a runtime with many actor types draws a readable + # chart rather than every type it has ever seen. + @instances_by_type = Instance + .group(:actor_type) + .order(Arel.sql("COUNT(*) DESC")) + .limit(CHART_TYPE_LIMIT) + .count + erb(:dashboard) + end + + get "/stats", policy: { action: "index", resource: "dashboard" } do + json(statistics.to_h) + end + + get "/instances", policy: { action: "index", resource: "instances" } do + @paginator = paginate(filtered_instances) + # Suggestions come from the registry rather than a DISTINCT over the + # instances table, which no adapter can answer from an index. The field + # stays free text, so an actor type that is no longer registered is + # still reachable. + @actor_types = SolidObjects.registry.to_h.keys.sort + erb(:instances) + end + + get "/instances/:id", policy: { action: "show", resource: "instances" } do + @instance = find_instance + @ready_messages = @instance.messages + .where(id: ReadyMessage.select(:message_id)) + .order(sequence: :asc) + .limit(RECENT_LIMIT) + @claimed_messages = @instance.messages + .where(id: ClaimedMessage.select(:message_id)) + .order(sequence: :asc) + .limit(RECENT_LIMIT) + @recent_messages = @instance.messages.order(sequence: :desc).limit(RECENT_LIMIT) + @reminders = Reminder.where(instance_id: @instance.id).order(next_run_at: :asc).limit(RECENT_LIMIT) + @effects = Effect.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT) + @broadcasts = Broadcast.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT) + @dead_letters = DeadLetter.where(instance_id: @instance.id).order(last_failed_at: :desc).limit(RECENT_LIMIT) + erb(:instance) + end + + # Pausing stops the activation manager from claiming the instance again. + # A pass already in flight finishes its turn, and a synchronous caller + # waiting on this instance times out rather than being answered, so this + # is an operator brake and not a delivery guarantee. + post "/instances/:id/pause", policy: { action: "pause", resource: "instances" } do + instance = find_instance + instance.update!(paused_at: SolidObjects.database_adapter.database_now) + redirect("/instances/#{instance.id}") + end + + post "/instances/:id/resume", policy: { action: "resume", resource: "instances" } do + instance = find_instance + instance.update!(paused_at: nil) + redirect("/instances/#{instance.id}") + end + + get "/mailbox", policy: { action: "index", resource: "messages" } do + @membership = filter_value(MAILBOX_MEMBERSHIPS, default: "ready") + @paginator = paginate(mailbox_messages(@membership)) + erb(:mailbox) + end + + get "/messages/:id", policy: { action: "show", resource: "messages" } do + @message = Message.find_by(id: route_params(:id)) + halt(404) unless @message + erb(:message) + end + + get "/reminders", policy: { action: "index", resource: "reminders" } do + @status = filter_value(Statistics::REMINDER_STATUSES) + relation = Reminder.order(next_run_at: :asc, id: :asc) + @paginator = paginate(@status ? relation.where(status: @status) : relation) + erb(:reminders) + end + + get "/effects", policy: { action: "index", resource: "effects" } do + @status = filter_value(Statistics::EFFECT_STATUSES) + relation = Effect.order(id: :desc) + @paginator = paginate(@status ? relation.where(status: @status) : relation) + erb(:effects) + end + + get "/broadcasts", policy: { action: "index", resource: "broadcasts" } do + @status = filter_value(Statistics::BROADCAST_STATUSES) + relation = Broadcast.order(id: :desc) + @paginator = paginate(@status ? relation.where(status: @status) : relation) + erb(:broadcasts) + end + + get "/dead_letters", policy: { action: "index", resource: "dead_letters" } do + @paginator = paginate(DeadLetter.order(last_failed_at: :desc, id: :desc)) + erb(:dead_letters) + end + + get "/dead_letters/:id", policy: { action: "show", resource: "dead_letters" } do + @dead_letter = DeadLetter.find_by(id: route_params(:id)) + halt(404) unless @dead_letter + erb(:dead_letter) + end + + # A retry re-enters the mailbox, which refuses work the runtime cannot + # accept: an actor class that no longer exists, a full mailbox, a payload + # over the cap. The operator who pressed the button is told which, + # instead of being handed a bare 500 from the Rack handler. + post "/dead_letters/:id/retry", policy: { action: "retry", resource: "dead_letters" } do + SolidObjects.dead_letters.retry(route_params(:id).to_i, authorization_context: self) + redirect("/dead_letters") + rescue Unauthorized + raise + rescue SolidObjects::Error => error + @dead_letter = DeadLetter.find_by(id: route_params(:id)) + halt(404) unless @dead_letter + @error = "#{error.class.name.split("::").last}: #{error.message}" + status(422) + erb(:dead_letter) + end + + get "/processes", policy: { action: "index", resource: "processes" } do + @status = filter_value(Statistics::PROCESS_STATES) + relation = Process.order(last_heartbeat_at: :desc) + @paginator = paginate(@status ? relation.where(shutdown_state: @status) : relation) + # Counted in one grouped query rather than once per row, because this + # page is read while the runtime is already under load. + @activated_counts = Instance + .where(activation_owner_id: @paginator.records.map(&:id)) + .group(:activation_owner_id) + .count + erb(:processes) + end + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call(env) + route = self.class.match(env["REQUEST_METHOD"].to_s, env["PATH_INFO"].to_s) + return not_found unless route + + action = Action.new(env:, route:) + return forbidden unless authorized?(action) + + respond(action, catch(:halt) { action.call }) + rescue Unauthorized + forbidden + end + + private + + # @rbs (Action, untyped) -> Array[untyped] + def respond(action, result) + return result if result.is_a?(Array) + + [ action.response_status, page_headers(action.env), [ result.to_s ] ] + end + + # @rbs (Action) -> bool + def authorized?(action) + policy = action.route.policy + SolidObjects.configuration.authorize_administration.call( + action: policy.fetch(:action), + resource: policy.fetch(:resource), + resource_id: action.route_params(:id), + authorization_context: action + ) + end + + # @rbs (Hash[String, untyped]) -> Hash[String, String] + def page_headers(env) + { + "content-type" => "text/html; charset=utf-8", + "cache-control" => "private, no-store", + "content-security-policy" => content_security_policy(env), + "x-content-type-options" => "nosniff", + "referrer-policy" => "same-origin" + } + end + + # The chart host is named only when one is configured, so a deployment + # that vendors the library or turns charts off never advertises a third + # party origin it does not use. + # @rbs (Hash[String, untyped]) -> String + def content_security_policy(env) + sources = [ "'self'", "'nonce-#{env[Web::NONCE_KEY]}'", Web.chart_library_origin ].compact + CONTENT_SECURITY_POLICY.sub(SCRIPT_PLACEHOLDER, sources.join(" ")) + end + + # The cascade header lets a host application serve its own 404 for a path + # below the mount that the dashboard does not define. + # @rbs () -> Array[untyped] + def not_found + [ 404, { "content-type" => "text/plain", "x-cascade" => "pass" }, [ "Not Found" ] ] + end + + # @rbs () -> Array[untyped] + def forbidden + [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ] + end + end + end +end diff --git a/lib/solid_objects/web/csrf_protection.rb b/lib/solid_objects/web/csrf_protection.rb new file mode 100644 index 0000000..02b0246 --- /dev/null +++ b/lib/solid_objects/web/csrf_protection.rb @@ -0,0 +1,130 @@ +# rbs_inline: enabled + +require "rack/request" +require "rack/utils" +require "securerandom" + +module SolidObjects + class Web + # A state changing request must carry the token of the session that asked + # for the form. The token a form receives is masked with a fresh one-time + # pad on every request, so the bytes on the wire differ each time and a + # compression side channel cannot recover the session token. + class CsrfProtection + SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze + TOKEN_BYTES = 32 + + MISSING_SESSION = <<~MESSAGE + SolidObjects::Web needs a Rack session for CSRF protection. + + Mount it inside the application routes so the Rails session middleware runs first: + + Rails.application.routes.draw do + mount SolidObjects::Web => "/solid_objects" + end + + In a bare Rack application, run a session middleware before it: + + use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true + run SolidObjects::Web + MESSAGE + + # @rbs (untyped) -> void + def initialize(app) + @app = app + end + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call(env) + return forbidden unless accept?(env) + + session = session!(env) + session[:csrf] ||= SecureRandom.base64(TOKEN_BYTES) + env[Web::CSRF_TOKEN_KEY] = mask(session[:csrf]) + @app.call(env) + end + + private + + # @rbs (Hash[String, untyped]) -> bool + def accept?(env) + return true if SAFE_METHODS.include?(env["REQUEST_METHOD"]) + + valid?(env, ::Rack::Request.new(env).params["authenticity_token"]) + end + + # @rbs (Hash[String, untyped], String?) -> bool + def valid?(env, given) + return false if given.nil? || given.empty? + + session = session!(env) + stored = session[:csrf] + return false if stored.nil? + + token = decode(given) + return false unless token + + # The secret is not rotated here. A page renders one Retry form per + # dead letter, and a browser keeps pages open in other tabs, so + # spending the secret on the first submission would answer 403 to + # every other form already rendered. Single use is not what a CSRF + # token provides: it proves the request came from a page this session + # was served, and the per-request mask below is what keeps the value + # on the wire from repeating. + matches?(token, stored) + end + + # @rbs (String, String) -> bool + def matches?(token, stored) + candidate = case token.bytesize + when TOKEN_BYTES then token + when TOKEN_BYTES * 2 then unmask(token) + else return false + end + + ::Rack::Utils.secure_compare(candidate, decode(stored).to_s) + end + + # @rbs (String) -> String + def mask(token) + decoded = decode(token).to_s + pad = SecureRandom.random_bytes(decoded.bytesize) + encode(pad + exclusive_or(pad, decoded)) + end + + # @rbs (String) -> String + def unmask(masked) + half = masked.bytesize / 2 + exclusive_or(masked[0, half].to_s, masked[half..].to_s) + end + + # @rbs (String, String) -> String + def exclusive_or(left, right) + left.bytes.zip(right.bytes).map { |first, second| first ^ second.to_i }.pack("c*") + end + + # @rbs (String) -> String + def encode(token) + [ token ].pack("m0").tr("+/", "-_") + end + + # @rbs (String) -> String? + def decode(token) + decoded = token.tr("-_", "+/").unpack1("m0") + decoded.is_a?(String) ? decoded : nil + rescue ArgumentError + nil + end + + # @rbs (Hash[String, untyped]) -> Hash[untyped, untyped] + def session!(env) + env["rack.session"] || raise(MISSING_SESSION) + end + + # @rbs () -> Array[untyped] + def forbidden + [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ] + end + end + end +end diff --git a/lib/solid_objects/web/helpers.rb b/lib/solid_objects/web/helpers.rb new file mode 100644 index 0000000..b178692 --- /dev/null +++ b/lib/solid_objects/web/helpers.rb @@ -0,0 +1,227 @@ +# rbs_inline: enabled + +require "json" +require "rack/utils" + +module SolidObjects + class Web + # The methods a view may call. Everything a template prints goes through + # `h`, because an actor id, an operation name, and an exception message are + # all application supplied strings that reach this page unchanged. + module Helpers + # Only these survive a page link. A filter an operator set stays set when + # they turn the page; anything else the query string carries does not + # come back. + FORWARDED_PARAMS = %w[actor_type actor_id status per_page].freeze + TRUNCATION_LIMIT = 2_000 + + # @rbs (untyped) -> String + def h(text) + ::Rack::Utils.escape_html(text.to_s) + end + + # @rbs () -> String + def root_path + env["SCRIPT_NAME"].to_s + end + + # @rbs (String) -> String + def path_to(path) + "#{root_path}#{path}" + end + + # @rbs () -> String + def current_path + request.path_info + end + + # @rbs (String) -> bool + def current_tab?(path) + return current_path == "/" if path == "/" + + current_path.start_with?(path) + end + + # @rbs () -> Hash[String, String] + def tabs + Web.tabs + end + + # @rbs () -> String? + def csp_nonce + env[Web::NONCE_KEY] + end + + # @rbs () -> String + def csrf_tag + %() + end + + # @rbs (String) -> String + def form_to(path) + %(
#{csrf_tag}) + end + + # @rbs (untyped) -> String + def relative_time(time) + return "—" unless time + + stamp = time.getutc.iso8601 + %() + end + + # @rbs (untyped) -> String + def number(value) + h(value.to_i.to_s.reverse.scan(/\d{1,3}/).join(",").reverse) + end + + # @rbs (Numeric?) -> String + def duration(seconds) + return "—" unless seconds + + return "#{h(format("%.3f", seconds))} s" if seconds < 60 + + h("#{(seconds / 60).floor} min #{(seconds % 60).round} s") + end + + # @rbs (untyped, ?Integer) -> String + def json_block(value) + return "—" if value.nil? + + %(
#{h(truncate(JSON.pretty_generate(value)))}
) + rescue JSON::GeneratorError, TypeError + %(
#{h(truncate(value.inspect))}
) + end + + # @rbs (String, ?Integer) -> String + def truncate(text, limit = TRUNCATION_LIMIT) + return text if text.length <= limit + + "#{text[0, limit]}…" + end + + # @rbs (String?) -> String + def status_label(status) + %(#{h(status)}) + end + + # @rbs (untyped) -> String + def actor_label(record) + "#{h(record.actor_type)} / #{h(record.actor_id)}" + end + + # @rbs (untyped) -> String + def instance_link(instance) + %(#{actor_label(instance)}) + end + + # @rbs (?Hash[String, untyped]) -> String + def query_string(overrides = {}) + merged = FORWARDED_PARAMS + .to_h { |name| [ name, url_params(name) ] } + .merge(overrides.transform_keys(&:to_s)) + .reject { |_name, value| value.nil? || value.to_s.empty? } + return "" if merged.empty? + + "?#{merged.map { |name, value| "#{::Rack::Utils.escape(name)}=#{::Rack::Utils.escape(value.to_s)}" }.join("&")}" + end + + # @rbs (?Hash[String, untyped]) -> String + def page_link(overrides = {}) + h("#{path_to(current_path)}#{query_string(overrides)}") + end + + # @rbs (Instance) -> String + def lease_state(instance) + return "paused" if instance.paused_at + return "idle" unless instance.activation_owner_id + return "idle" unless instance.activation_expires_at + + (instance.activation_expires_at > statistics.now) ? "activated" : "expired" + end + + # @rbs () -> Statistics + def statistics + @statistics ||= Statistics.new + end + + # @rbs (untyped) -> Paginator + def paginate(relation) + Paginator.new(relation:, page: url_params("page"), per_page: url_params("per_page")) + end + + # An unrecognized filter falls back to the default rather than returning + # nothing, so a hand edited query string cannot make a page look empty. + # @rbs (Array[String], ?default: String?) -> String? + def filter_value(allowed, default: nil) + value = url_params("status") + allowed.include?(value) ? value : default + end + + # @rbs () -> Instance + def find_instance + instance = Instance.find_by(id: route_params(:id)) + halt(404) unless instance + + instance + end + + # @rbs () -> untyped + def filtered_instances + relation = Instance.order(updated_at: :desc, id: :desc) + actor_type = url_params("actor_type") + relation = relation.where(actor_type:) unless actor_type.to_s.empty? + actor_id = url_params("actor_id") + return relation if actor_id.to_s.empty? + + relation.where( + Instance.arel_table[:actor_id].matches("%#{Instance.sanitize_sql_like(actor_id)}%") + ) + end + + # @rbs (String) -> untyped + def mailbox_messages(membership) + membership_model = (membership == "claimed") ? ClaimedMessage : ReadyMessage + Message + .where(id: membership_model.select(:message_id)) + .order(available_at: :asc, id: :asc) + end + + # Chart data travels in an attribute rather than an inline script block, + # so the page needs no script-src exception and an actor type cannot + # close the attribute and open a tag. + # + # The container is not decoration. Chart.js measures a responsive canvas + # against its parent, so the parent has to have a height of its own; a + # panel that sizes to its children would grow a little on every redraw. + # @rbs (String, untyped) -> String + def chart(name, values) + return "" unless Web.charts? + + %(
) + end + + # A vendored copy is a path below the mount; a CDN copy is an absolute + # URL and is left alone. + # @rbs () -> String + def chart_library_source + url = Web.chart_library_url.to_s + url.include?("//") ? url : path_to(url) + end + + # @rbs () -> String + def chart_library_integrity_attributes + integrity = Web.chart_library_integrity + return "" unless integrity + + %( integrity="#{h(integrity)}" crossorigin="anonymous" referrerpolicy="no-referrer") + end + + # @rbs () -> String + def environment_name + ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development" + end + end + end +end diff --git a/lib/solid_objects/web/paginator.rb b/lib/solid_objects/web/paginator.rb new file mode 100644 index 0000000..69f04ec --- /dev/null +++ b/lib/solid_objects/web/paginator.rb @@ -0,0 +1,64 @@ +# rbs_inline: enabled + +module SolidObjects + class Web + # Counts and slices one relation. The page size is clamped because the page + # number and the page size both arrive from the query string, and an + # operator page that accepts an unbounded limit is a denial of service + # against the database the actors run on. + class Paginator + DEFAULT_PER_PAGE = 25 + MAXIMUM_PER_PAGE = 200 + + # @rbs @page: Integer + # @rbs @per_page: Integer + # @rbs @total: Integer + # @rbs @records: Array[untyped] + + attr_reader :page, :per_page, :total, :records + + # @rbs (relation: untyped, ?page: String?, ?per_page: String?) -> void + def initialize(relation:, page: nil, per_page: nil) + @per_page = bounded(per_page, default: DEFAULT_PER_PAGE, maximum: MAXIMUM_PER_PAGE) + @total = relation.count + @page = bounded(page, default: 1, maximum: last_page) + @records = relation.offset((@page - 1) * @per_page).limit(@per_page).to_a + end + + # @rbs () -> Integer + def last_page + [ (total.to_f / per_page).ceil, 1 ].max + end + + # @rbs () -> Integer? + def previous_page + (page > 1) ? page - 1 : nil + end + + # @rbs () -> Integer? + def next_page + (page < last_page) ? page + 1 : nil + end + + # @rbs () -> Integer + def first_record + total.zero? ? 0 : ((page - 1) * per_page) + 1 + end + + # @rbs () -> Integer + def last_record + [ page * per_page, total ].min + end + + private + + # @rbs (String?, default: Integer, maximum: Integer) -> Integer + def bounded(value, default:, maximum:) + requested = Integer(value.to_s, 10, exception: false) + return default unless requested&.positive? + + [ requested, maximum ].min + end + end + end +end diff --git a/lib/solid_objects/web/route.rb b/lib/solid_objects/web/route.rb new file mode 100644 index 0000000..610c91c --- /dev/null +++ b/lib/solid_objects/web/route.rb @@ -0,0 +1,55 @@ +# rbs_inline: enabled + +module SolidObjects + class Web + class Route + # A named segment stops at the next slash, so `/instances/:id` never + # swallows `/instances/1/pause` and route order cannot hide a page. + NAMED_SEGMENT = %r{/([^/]*):([^.:$/]+)} + SEGMENT_CAPTURE = '/\1(?<\2>[^$/]+)' + + # @rbs @matcher: String | Regexp + # @rbs @request_method: String + # @rbs @pattern: String + # @rbs @policy: Hash[Symbol, String] + # @rbs @handler: Proc + + attr_reader :request_method, :pattern, :policy, :handler + + # @rbs (request_method: String, pattern: String, policy: Hash[Symbol, String], handler: Proc) -> void + def initialize(request_method:, pattern:, policy:, handler:) + @request_method = request_method + @pattern = pattern + @policy = policy + @handler = handler + @matcher = compile(pattern) + end + + # @rbs (String) -> bool + def match?(path) + return @matcher == path if @matcher.is_a?(String) + + @matcher.match?(path) + end + + # @rbs (String) -> Hash[Symbol, String?] + def capture(path) + return {} if @matcher.is_a?(String) + + match = @matcher.match(path) + return {} unless match + + match.named_captures.transform_keys(&:to_sym) + end + + private + + # @rbs (String) -> (String | Regexp) + def compile(pattern) + return pattern unless pattern.match?(NAMED_SEGMENT) + + Regexp.new("\\A#{pattern.gsub(NAMED_SEGMENT, SEGMENT_CAPTURE)}\\z") + end + end + end +end diff --git a/lib/solid_objects/web/router.rb b/lib/solid_objects/web/router.rb new file mode 100644 index 0000000..d386048 --- /dev/null +++ b/lib/solid_objects/web/router.rb @@ -0,0 +1,46 @@ +# rbs_inline: enabled + +module SolidObjects + class Web + # Declares the pages of the dashboard. Every route carries the + # administration policy it needs, and a route declared without one raises + # at load time. A new page therefore cannot reach the database before an + # application has said who may read it. + module Router + # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + def head(path, policy: nil, &handler) + route("HEAD", path, policy:, &handler) + end + + # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + def get(path, policy: nil, &handler) + route("GET", path, policy:, &handler) + end + + # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + def post(path, policy: nil, &handler) + route("POST", path, policy:, &handler) + end + + # @rbs (String, String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + def route(request_method, path, policy: nil, &handler) + raise ArgumentError, "route #{path} requires an authorization policy" unless policy + raise ArgumentError, "route #{path} requires a handler" unless handler + raise ArgumentError, "route #{path} requires a policy action" unless policy[:action] + raise ArgumentError, "route #{path} requires a policy resource" unless policy[:resource] + + routes[request_method] << Route.new(request_method:, pattern: path, policy:, handler:) + end + + # @rbs () -> Hash[String, Array[Route]] + def routes + @routes ||= Hash.new { |store, request_method| store[request_method] = [] } + end + + # @rbs (String, String) -> Route? + def match(request_method, path) + routes[request_method].find { |route| route.match?(path) } + end + end + end +end diff --git a/lib/solid_objects/web/statistics.rb b/lib/solid_objects/web/statistics.rb new file mode 100644 index 0000000..69c2f74 --- /dev/null +++ b/lib/solid_objects/web/statistics.rb @@ -0,0 +1,78 @@ +# rbs_inline: enabled + +module SolidObjects + class Web + # The counts behind the dashboard and behind `GET /stats`. Both read the + # same object, so the polled JSON and the rendered page can never disagree + # about what a number means. + class Statistics + EFFECT_STATUSES = %w[pending processing completed dead].freeze + BROADCAST_STATUSES = %w[pending processing delivered dead].freeze + REMINDER_STATUSES = %w[scheduled paused completed].freeze + PROCESS_STATES = %w[running draining stopped].freeze + + # @rbs @now: Time + + attr_reader :now + + # @rbs (?now: Time) -> void + def initialize(now: SolidObjects.database_adapter.database_now) + @now = now + end + + # @rbs () -> Hash[Symbol, untyped] + def to_h + { + instances:, + mailbox:, + effects: grouped(Effect, :status, EFFECT_STATUSES), + broadcasts: grouped(Broadcast, :status, BROADCAST_STATUSES), + reminders:, + dead_letters: { total: DeadLetter.count }, + processes: grouped(Process, :shutdown_state, PROCESS_STATES), + server_time: now.utc.iso8601 + } + end + + # @rbs () -> Hash[Symbol, Integer] + def instances + { + total: Instance.count, + paused: Instance.where.not(paused_at: nil).count, + activated: Instance.where(activation_expires_at: now..).count + } + end + + # The oldest ready message that is already due is the queue latency of + # this runtime: how far behind the workers are, in seconds. + # @rbs () -> Hash[Symbol, untyped] + def mailbox + due = ReadyMessage.where(available_at: ..now) + oldest = due.minimum(:available_at) + { + ready: ReadyMessage.count, + due: due.count, + claimed: ClaimedMessage.count, + latency: oldest ? (now - oldest).round(3) : 0.0 + } + end + + # @rbs () -> Hash[Symbol, Integer] + def reminders + grouped(Reminder, :status, REMINDER_STATUSES).merge( + due: Reminder.where(status: "scheduled", next_run_at: ..now).count + ) + end + + private + + # A status the schema allows but the table does not currently hold still + # reports zero, so a row of counts keeps the same shape between polls. + # @rbs (untyped, Symbol, Array[String]) -> Hash[Symbol, Integer] + def grouped(model, column, statuses) + counts = model.group(column).count + statuses.to_h { |status| [ status.to_sym, counts.fetch(status, 0) ] } + end + end + end +end diff --git a/sig/generated/lib/solid_objects/web.rbs b/sig/generated/lib/solid_objects/web.rbs new file mode 100644 index 0000000..44ff355 --- /dev/null +++ b/sig/generated/lib/solid_objects/web.rbs @@ -0,0 +1,129 @@ +# Generated from lib/solid_objects/web.rb with RBS::Inline + +module SolidObjects + # An operator dashboard for the actor runtime, served as a Rack application: + # + # Rails.application.routes.draw do + # mount SolidObjects::Web => "/solid_objects" + # end + # + # It is deliberately not loaded by `require "solid_objects"`. A worker + # process must not carry a web stack, and an application that never mounts + # the dashboard must not pay for it. + # + # Every page asks `configuration.authorize_administration` first. That block + # denies by default, so a mount alone exposes nothing until an application + # states who may read it. + class Web + ROOT: untyped + + VIEWS: untyped + + ASSETS: untyped + + NONCE_KEY: ::String + + CSRF_TOKEN_KEY: ::String + + NONCE_BYTES: ::Integer + + ASSET_CACHE_SECONDS: ::Integer + + TEMPLATE_NAME: ::Regexp + + # The dashboard charts need a charting library, and this one is fetched + # from a public CDN with a subresource integrity hash, so a compromised CDN + # cannot substitute other code. A deployment with no outbound network + # access should vendor the file and point `chart_library_url` at it, or set + # that to nil to render the dashboard without charts. + CHART_LIBRARY_URL: ::String + + CHART_LIBRARY_INTEGRITY: ::String + + DEFAULT_TABS: untyped + + LOCK: untyped + + # A path below the mount serves a vendored copy; an absolute URL is + # fetched from that host and is named in the content security policy. + # nil renders the dashboard without charts. + # @rbs @chart_library_url: String? + # @rbs @chart_library_integrity: String? + attr_writer chart_library_url: untyped + + # A path below the mount serves a vendored copy; an absolute URL is + # fetched from that host and is named in the content security policy. + # nil renders the dashboard without charts. + # @rbs @chart_library_url: String? + # @rbs @chart_library_integrity: String? + attr_writer chart_library_integrity: untyped + + # @rbs () -> String? + def self.chart_library_url: () -> String? + + # @rbs () -> String? + def self.chart_library_integrity: () -> String? + + # @rbs () -> bool + def self.charts?: () -> bool + + # The origin the content security policy has to allow. A vendored copy + # served from the mount has none, so the policy stays at 'self'. + # @rbs () -> String? + def self.chart_library_origin: () -> String? + + # @rbs () -> Hash[String, String] + def self.tabs: () -> Hash[String, String] + + # Searched in order, so an extension directory added first wins over the + # packaged one and an application can replace a single page. + # @rbs () -> Array[String] + def self.views: () -> Array[String] + + # @rbs () -> Array[[Array[untyped], Proc?]] + def self.middlewares: () -> Array[[ Array[untyped], Proc? ]] + + # The built stack is memoized, so a middleware added after the first + # request would otherwise be dropped without a word. + # @rbs (*untyped) ?{ () -> untyped } -> void + def self.use: (*untyped) ?{ () -> untyped } -> void + + # Adds pages to the dashboard. The extension receives the application + # class and declares its own routes on it, which means its routes carry + # an authorization policy like every other route. + # + # @rbs (untyped, tab: String, path: String, ?views: String?) -> void + def self.register: (untyped, tab: String, path: String, ?views: String?) -> void + + # @rbs (Symbol) -> untyped + def self.template: (Symbol) -> untyped + + # @rbs () -> void + def self.reset!: () -> void + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def self.call: (Hash[String, untyped]) -> Array[untyped] + + # @rbs () -> Web + def self.application: () -> Web + + # @rbs () -> Hash[Symbol, untyped] + private def self.templates: () -> Hash[Symbol, untyped] + + # A template name never comes from a request, and this keeps it that way + # rather than trusting every future caller to know it. + # @rbs (Symbol) -> String + private def self.template_path: (Symbol) -> String + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call: (Hash[String, untyped]) -> Array[untyped] + + # @rbs () -> untyped + def app: () -> untyped + + private + + # @rbs () -> untyped + def build: () -> untyped + end +end diff --git a/sig/generated/lib/solid_objects/web/action.rbs b/sig/generated/lib/solid_objects/web/action.rbs new file mode 100644 index 0000000..6d651d8 --- /dev/null +++ b/sig/generated/lib/solid_objects/web/action.rbs @@ -0,0 +1,78 @@ +# Generated from lib/solid_objects/web/action.rb with RBS::Inline + +module SolidObjects + class Web + # One request. A route handler runs inside an instance of this class, so a + # handler and the template it renders share the same helpers and the same + # request. + class Action + include Helpers + + @response_status: Integer + + @layout_rendered: bool + + @request: untyped + + @captures: Hash[Symbol, String?] + + @route: Route + + @env: Hash[String, untyped] + + attr_reader env: untyped + + attr_reader route: untyped + + attr_reader response_status: untyped + + # @rbs (env: Hash[String, untyped], route: Route) -> void + def initialize: (env: Hash[String, untyped], route: Route) -> void + + # Sets the status a rendered page is served with. `halt` and `redirect` + # stop the handler, so a page that must render its own body and still + # report a failure needs this instead. + # @rbs (Integer) -> void + def status: (Integer) -> void + + # @rbs () -> untyped + def request: () -> untyped + + # @rbs () -> Hash[untyped, untyped]? + def session: () -> Hash[untyped, untyped]? + + # @rbs (Symbol) -> String? + def route_params: (Symbol) -> String? + + # @rbs (String) -> untyped + def url_params: (String) -> untyped + + # @rbs () -> untyped + def call: () -> untyped + + # The layout is rendered once per request. The flag is raised before the + # page body runs so a partial the body renders returns its own fragment + # rather than a second whole page. The layout reads the page it wraps + # from `locals.fetch(:content)`. + # @rbs (Symbol, ?Hash[Symbol, untyped]) -> String + def erb: (Symbol, ?Hash[Symbol, untyped]) -> String + + # @rbs (Integer, ?String) -> void + def halt: (Integer, ?String) -> void + + # @rbs (String) -> void + def redirect: (String) -> void + + # @rbs (untyped) -> void + def json: (untyped) -> void + + private + + # `locals` is a local variable of this method, so a template reads it by + # name, and every helper is reachable because the template runs against + # this object. + # @rbs (untyped, Hash[Symbol, untyped]) -> String + def evaluate: (untyped, Hash[Symbol, untyped]) -> String + end + end +end diff --git a/sig/generated/lib/solid_objects/web/application.rbs b/sig/generated/lib/solid_objects/web/application.rbs new file mode 100644 index 0000000..4a0264c --- /dev/null +++ b/sig/generated/lib/solid_objects/web/application.rbs @@ -0,0 +1,50 @@ +# Generated from lib/solid_objects/web/application.rb with RBS::Inline + +module SolidObjects + class Web + # The pages. Each route states the administration policy it needs, and + # `call` asks that policy before the handler runs, so a page cannot read + # the actor tables on behalf of an unauthorized request. + class Application + extend Router + + SCRIPT_PLACEHOLDER: ::String + + CONTENT_SECURITY_POLICY: untyped + + MAILBOX_MEMBERSHIPS: untyped + + RECENT_LIMIT: ::Integer + + CHART_TYPE_LIMIT: ::Integer + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call: (Hash[String, untyped]) -> Array[untyped] + + private + + # @rbs (Action, untyped) -> Array[untyped] + def respond: (Action, untyped) -> Array[untyped] + + # @rbs (Action) -> bool + def authorized?: (Action) -> bool + + # @rbs (Hash[String, untyped]) -> Hash[String, String] + def page_headers: (Hash[String, untyped]) -> Hash[String, String] + + # The chart host is named only when one is configured, so a deployment + # that vendors the library or turns charts off never advertises a third + # party origin it does not use. + # @rbs (Hash[String, untyped]) -> String + def content_security_policy: (Hash[String, untyped]) -> String + + # The cascade header lets a host application serve its own 404 for a path + # below the mount that the dashboard does not define. + # @rbs () -> Array[untyped] + def not_found: () -> Array[untyped] + + # @rbs () -> Array[untyped] + def forbidden: () -> Array[untyped] + end + end +end diff --git a/sig/generated/lib/solid_objects/web/csrf_protection.rbs b/sig/generated/lib/solid_objects/web/csrf_protection.rbs new file mode 100644 index 0000000..79947a4 --- /dev/null +++ b/sig/generated/lib/solid_objects/web/csrf_protection.rbs @@ -0,0 +1,55 @@ +# Generated from lib/solid_objects/web/csrf_protection.rb with RBS::Inline + +module SolidObjects + class Web + # A state changing request must carry the token of the session that asked + # for the form. The token a form receives is masked with a fresh one-time + # pad on every request, so the bytes on the wire differ each time and a + # compression side channel cannot recover the session token. + class CsrfProtection + SAFE_METHODS: untyped + + TOKEN_BYTES: ::Integer + + MISSING_SESSION: ::String + + # @rbs (untyped) -> void + def initialize: (untyped) -> void + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call: (Hash[String, untyped]) -> Array[untyped] + + private + + # @rbs (Hash[String, untyped]) -> bool + def accept?: (Hash[String, untyped]) -> bool + + # @rbs (Hash[String, untyped], String?) -> bool + def valid?: (Hash[String, untyped], String?) -> bool + + # @rbs (String, String) -> bool + def matches?: (String, String) -> bool + + # @rbs (String) -> String + def mask: (String) -> String + + # @rbs (String) -> String + def unmask: (String) -> String + + # @rbs (String, String) -> String + def exclusive_or: (String, String) -> String + + # @rbs (String) -> String + def encode: (String) -> String + + # @rbs (String) -> String? + def decode: (String) -> String? + + # @rbs (Hash[String, untyped]) -> Hash[untyped, untyped] + def session!: (Hash[String, untyped]) -> Hash[untyped, untyped] + + # @rbs () -> Array[untyped] + def forbidden: () -> Array[untyped] + end + end +end diff --git a/sig/generated/lib/solid_objects/web/helpers.rbs b/sig/generated/lib/solid_objects/web/helpers.rbs new file mode 100644 index 0000000..988f38b --- /dev/null +++ b/sig/generated/lib/solid_objects/web/helpers.rbs @@ -0,0 +1,118 @@ +# Generated from lib/solid_objects/web/helpers.rb with RBS::Inline + +module SolidObjects + class Web + # The methods a view may call. Everything a template prints goes through + # `h`, because an actor id, an operation name, and an exception message are + # all application supplied strings that reach this page unchanged. + module Helpers + # Only these survive a page link. A filter an operator set stays set when + # they turn the page; anything else the query string carries does not + # come back. + FORWARDED_PARAMS: untyped + + TRUNCATION_LIMIT: ::Integer + + # @rbs (untyped) -> String + def h: (untyped) -> String + + # @rbs () -> String + def root_path: () -> String + + # @rbs (String) -> String + def path_to: (String) -> String + + # @rbs () -> String + def current_path: () -> String + + # @rbs (String) -> bool + def current_tab?: (String) -> bool + + # @rbs () -> Hash[String, String] + def tabs: () -> Hash[String, String] + + # @rbs () -> String? + def csp_nonce: () -> String? + + # @rbs () -> String + def csrf_tag: () -> String + + # @rbs (String) -> String + def form_to: (String) -> String + + # @rbs (untyped) -> String + def relative_time: (untyped) -> String + + # @rbs (untyped) -> String + def number: (untyped) -> String + + # @rbs (Numeric?) -> String + def duration: (Numeric?) -> String + + # @rbs (untyped, ?Integer) -> String + def json_block: (untyped, ?Integer) -> String + + # @rbs (String, ?Integer) -> String + def truncate: (String, ?Integer) -> String + + # @rbs (String?) -> String + def status_label: (String?) -> String + + # @rbs (untyped) -> String + def actor_label: (untyped) -> String + + # @rbs (untyped) -> String + def instance_link: (untyped) -> String + + # @rbs (?Hash[String, untyped]) -> String + def query_string: (?Hash[String, untyped]) -> String + + # @rbs (?Hash[String, untyped]) -> String + def page_link: (?Hash[String, untyped]) -> String + + # @rbs (Instance) -> String + def lease_state: (Instance) -> String + + # @rbs () -> Statistics + def statistics: () -> Statistics + + # @rbs (untyped) -> Paginator + def paginate: (untyped) -> Paginator + + # An unrecognized filter falls back to the default rather than returning + # nothing, so a hand edited query string cannot make a page look empty. + # @rbs (Array[String], ?default: String?) -> String? + def filter_value: (Array[String], ?default: String?) -> String? + + # @rbs () -> Instance + def find_instance: () -> Instance + + # @rbs () -> untyped + def filtered_instances: () -> untyped + + # @rbs (String) -> untyped + def mailbox_messages: (String) -> untyped + + # Chart data travels in an attribute rather than an inline script block, + # so the page needs no script-src exception and an actor type cannot + # close the attribute and open a tag. + # + # The container is not decoration. Chart.js measures a responsive canvas + # against its parent, so the parent has to have a height of its own; a + # panel that sizes to its children would grow a little on every redraw. + # @rbs (String, untyped) -> String + def chart: (String, untyped) -> String + + # A vendored copy is a path below the mount; a CDN copy is an absolute + # URL and is left alone. + # @rbs () -> String + def chart_library_source: () -> String + + # @rbs () -> String + def chart_library_integrity_attributes: () -> String + + # @rbs () -> String + def environment_name: () -> String + end + end +end diff --git a/sig/generated/lib/solid_objects/web/paginator.rbs b/sig/generated/lib/solid_objects/web/paginator.rbs new file mode 100644 index 0000000..7013117 --- /dev/null +++ b/sig/generated/lib/solid_objects/web/paginator.rbs @@ -0,0 +1,54 @@ +# Generated from lib/solid_objects/web/paginator.rb with RBS::Inline + +module SolidObjects + class Web + # Counts and slices one relation. The page size is clamped because the page + # number and the page size both arrive from the query string, and an + # operator page that accepts an unbounded limit is a denial of service + # against the database the actors run on. + class Paginator + DEFAULT_PER_PAGE: ::Integer + + MAXIMUM_PER_PAGE: ::Integer + + @page: Integer + + @per_page: Integer + + @total: Integer + + @records: Array[untyped] + + attr_reader page: untyped + + attr_reader per_page: untyped + + attr_reader total: untyped + + attr_reader records: untyped + + # @rbs (relation: untyped, ?page: String?, ?per_page: String?) -> void + def initialize: (relation: untyped, ?page: String?, ?per_page: String?) -> void + + # @rbs () -> Integer + def last_page: () -> Integer + + # @rbs () -> Integer? + def previous_page: () -> Integer? + + # @rbs () -> Integer? + def next_page: () -> Integer? + + # @rbs () -> Integer + def first_record: () -> Integer + + # @rbs () -> Integer + def last_record: () -> Integer + + private + + # @rbs (String?, default: Integer, maximum: Integer) -> Integer + def bounded: (String?, default: Integer, maximum: Integer) -> Integer + end + end +end diff --git a/sig/generated/lib/solid_objects/web/route.rbs b/sig/generated/lib/solid_objects/web/route.rbs new file mode 100644 index 0000000..a4970f5 --- /dev/null +++ b/sig/generated/lib/solid_objects/web/route.rbs @@ -0,0 +1,45 @@ +# Generated from lib/solid_objects/web/route.rb with RBS::Inline + +module SolidObjects + class Web + class Route + # A named segment stops at the next slash, so `/instances/:id` never + # swallows `/instances/1/pause` and route order cannot hide a page. + NAMED_SEGMENT: ::Regexp + + SEGMENT_CAPTURE: ::String + + @matcher: String | Regexp + + @request_method: String + + @pattern: String + + @policy: Hash[Symbol, String] + + @handler: Proc + + attr_reader request_method: untyped + + attr_reader pattern: untyped + + attr_reader policy: untyped + + attr_reader handler: untyped + + # @rbs (request_method: String, pattern: String, policy: Hash[Symbol, String], handler: Proc) -> void + def initialize: (request_method: String, pattern: String, policy: Hash[Symbol, String], handler: Proc) -> void + + # @rbs (String) -> bool + def match?: (String) -> bool + + # @rbs (String) -> Hash[Symbol, String?] + def capture: (String) -> Hash[Symbol, String?] + + private + + # @rbs (String) -> (String | Regexp) + def compile: (String) -> (String | Regexp) + end + end +end diff --git a/sig/generated/lib/solid_objects/web/router.rbs b/sig/generated/lib/solid_objects/web/router.rbs new file mode 100644 index 0000000..4975d0d --- /dev/null +++ b/sig/generated/lib/solid_objects/web/router.rbs @@ -0,0 +1,29 @@ +# Generated from lib/solid_objects/web/router.rb with RBS::Inline + +module SolidObjects + class Web + # Declares the pages of the dashboard. Every route carries the + # administration policy it needs, and a route declared without one raises + # at load time. A new page therefore cannot reach the database before an + # application has said who may read it. + module Router + # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + def head: (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + + # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + def get: (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + + # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + def post: (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + + # @rbs (String, String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + def route: (String, String, policy: Hash[Symbol, String]?) { () -> untyped } -> void + + # @rbs () -> Hash[String, Array[Route]] + def routes: () -> Hash[String, Array[Route]] + + # @rbs (String, String) -> Route? + def match: (String, String) -> Route? + end + end +end diff --git a/sig/generated/lib/solid_objects/web/statistics.rbs b/sig/generated/lib/solid_objects/web/statistics.rbs new file mode 100644 index 0000000..aa6aa95 --- /dev/null +++ b/sig/generated/lib/solid_objects/web/statistics.rbs @@ -0,0 +1,46 @@ +# Generated from lib/solid_objects/web/statistics.rb with RBS::Inline + +module SolidObjects + class Web + # The counts behind the dashboard and behind `GET /stats`. Both read the + # same object, so the polled JSON and the rendered page can never disagree + # about what a number means. + class Statistics + EFFECT_STATUSES: untyped + + BROADCAST_STATUSES: untyped + + REMINDER_STATUSES: untyped + + PROCESS_STATES: untyped + + @now: Time + + attr_reader now: untyped + + # @rbs (?now: Time) -> void + def initialize: (?now: Time) -> void + + # @rbs () -> Hash[Symbol, untyped] + def to_h: () -> Hash[Symbol, untyped] + + # @rbs () -> Hash[Symbol, Integer] + def instances: () -> Hash[Symbol, Integer] + + # The oldest ready message that is already due is the queue latency of + # this runtime: how far behind the workers are, in seconds. + # @rbs () -> Hash[Symbol, untyped] + def mailbox: () -> Hash[Symbol, untyped] + + # @rbs () -> Hash[Symbol, Integer] + def reminders: () -> Hash[Symbol, Integer] + + private + + # A status the schema allows but the table does not currently hold still + # reports zero, so a row of counts keeps the same shape between polls. + # @rbs (untyped, Symbol, Array[String]) -> Hash[Symbol, Integer] + def grouped: (untyped, Symbol, Array[String]) -> Hash[Symbol, Integer] + end + end +end diff --git a/solid_objects.gemspec b/solid_objects.gemspec index 610bbc9..263b1bf 100644 --- a/solid_objects.gemspec +++ b/solid_objects.gemspec @@ -25,7 +25,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.files = Dir[ - "{app,benchmark,config,db,docs,examples,exe,lib,sig}/**/*", + "{app,benchmark,config,db,docs,examples,exe,lib,sig,web}/**/*", "CHANGELOG.md", "README.md", "Rakefile", @@ -40,6 +40,10 @@ Gem::Specification.new do |spec| spec.add_dependency "actionview", ">= 8.0" spec.add_dependency "activerecord", ">= 8.0" spec.add_dependency "activesupport", ">= 8.0" + # The operator dashboard is a Rack application. Rack arrives with Action Pack + # in every supported Rails version; the floor is stated because the dashboard + # writes lowercase response headers, which Rack 3 requires. + spec.add_dependency "rack", ">= 3.1" spec.add_dependency "railties", ">= 8.0" spec.add_dependency "thor", ">= 1.3" diff --git a/test/dummy/web_mount_check.rb b/test/dummy/web_mount_check.rb new file mode 100644 index 0000000..1aea6f3 --- /dev/null +++ b/test/dummy/web_mount_check.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# A Rack application behaves differently under a real Rails router than under a +# mock environment: the mount supplies SCRIPT_NAME, the session middleware +# supplies the session CSRF protection needs, and a nested mount depends on the +# engine cascading paths it does not serve. None of that is exercised by +# calling the dashboard directly, so this runs it where it actually runs. + +ENV["RAILS_ENV"] = "test" + +require_relative "config/environment" +require "solid_objects/web" +require "rack/mock_request" +require_relative "../../db/migrate/20260805000000_create_solid_objects_tables" +require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" +require_relative "../../db/migrate/20260813000000_rename_message_dispatch_columns" + +ActiveRecord::Migration.verbose = false +CreateSolidObjectsTables.new.migrate(:up) +AddStateRevisionToSolidObjectsInstances.new.migrate(:up) +RenameMessageDispatchColumns.new.migrate(:up) + +instance = SolidObjects::Instance.create!( + actor_type: "MountCheckActor", + actor_id: "only-one", + state: { "value" => 7 } +) + +Rails.application.routes.draw do + mount SolidObjects::Engine => "/solid_objects" + mount SolidObjects::Web => "/solid_objects/dashboard" +end + +request = Rack::MockRequest.new(Rails.application) + +SolidObjects.configuration.authorize_administration = ->(**) { false } +denied = request.get("https://example.com/solid_objects/dashboard/instances") + +SolidObjects.configuration.authorize_administration = ->(**) { true } +allowed = request.get("https://example.com/solid_objects/dashboard/instances") + +detail_path = "https://example.com/solid_objects/dashboard/instances/#{instance.id}" +detail = request.get(detail_path) +cookie = detail.headers["set-cookie"].to_s.split(";").first.to_s +token = detail.body[/name="authenticity_token" value="([^"]+)"/, 1].to_s + +def pause(request, path, cookie, token) + options = { params: { "authenticity_token" => token } } + options["HTTP_COOKIE"] = cookie + request.post("#{path}/pause", options) +end + +# Well formed and the right length, so it reaches the comparison rather than +# being turned away by the length check on the way in. +forged = pause(request, detail_path, cookie, SecureRandom.base64(32)) +forged_paused = instance.reload.paused_at + +paused = pause(request, detail_path, cookie, token) + +puts "denied=#{denied.status}" +puts "allowed=#{allowed.status}" +puts "actor=#{allowed.body.include?("MountCheckActor")}" +puts "mounted_link=#{allowed.body.include?("/solid_objects/dashboard/stylesheets/application.css")}" +puts "session=#{allowed.headers["set-cookie"].to_s.include?("_dummy_session")}" +puts "forged=#{forged.status}" +puts "forged_paused=#{!forged_paused.nil?}" +puts "paused=#{paused.status}" +puts "paused_location=#{paused.headers["location"]}" +puts "paused_at=#{!instance.reload.paused_at.nil?}" diff --git a/test/fixtures/web_extension/_probe.erb b/test/fixtures/web_extension/_probe.erb new file mode 100644 index 0000000..34016db --- /dev/null +++ b/test/fixtures/web_extension/_probe.erb @@ -0,0 +1,3 @@ +
+

probe page

+
diff --git a/test/fixtures/web_extension/_summary.erb b/test/fixtures/web_extension/_summary.erb new file mode 100644 index 0000000..bb48826 --- /dev/null +++ b/test/fixtures/web_extension/_summary.erb @@ -0,0 +1 @@ +
replaced summary
diff --git a/test/integration/load_contract_test.rb b/test/integration/load_contract_test.rb index 5014f1f..6327787 100644 --- a/test/integration/load_contract_test.rb +++ b/test/integration/load_contract_test.rb @@ -21,7 +21,16 @@ class LoadContractTest < ActiveSupport::TestCase "errors" => "defines error classes individually, so no SolidObjects::Errors exists", "sync_diagnostics" => "the caller path, required with the client", "synchronous_invocation" => "the caller path, required with the client", - "test_helper" => "opt-in, required by host application tests" + "test_helper" => "opt-in, required by host application tests", + "web" => "the operator dashboard, required by an application that mounts it", + "web/action" => "loaded with the dashboard", + "web/application" => "loaded with the dashboard", + "web/csrf_protection" => "loaded with the dashboard", + "web/helpers" => "loaded with the dashboard", + "web/paginator" => "loaded with the dashboard", + "web/route" => "loaded with the dashboard", + "web/router" => "loaded with the dashboard", + "web/statistics" => "loaded with the dashboard" }.freeze test "requiring the gem defines everything a runtime role reaches for" do diff --git a/test/integration/web_charts_test.rb b/test/integration/web_charts_test.rb new file mode 100644 index 0000000..031c1ec --- /dev/null +++ b/test/integration/web_charts_test.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "web_test_helper" + +class WebChartsTest < WebTestCase + teardown do + SolidObjects::Web.reset! + end + + test "loads the chart library from the configured source with an integrity hash" do + response = get("/") + + assert_match(%r{src="https://cdn\.jsdelivr\.net/npm/chart\.js@[\d.]+/dist/chart\.umd\.min\.js"}, response.body) + assert_match(/integrity="sha384-[A-Za-z0-9+\/=]+"/, response.body) + assert_match(/crossorigin="anonymous"/, response.body) + end + + test "names the chart host in the content security policy and nothing wider" do + policy = get("/").headers["content-security-policy"] + script_source = policy[/script-src ([^;]+)/, 1] + + assert_includes script_source, "https://cdn.jsdelivr.net" + refute_includes script_source, "'unsafe-inline'" + refute_includes script_source, "'unsafe-eval'" + # The host, not the scheme. A bare `https:` source would admit every host + # that serves over TLS. + refute_match(/(\A|\s)https:(\s|\z)/, script_source) + end + + test "renders the instance counts each chart draws" do + 2.times { |index| create_instance(actor_type: "web-counter", actor_id: "counter-#{index}") } + create_instance(actor_type: "web-room", actor_id: "room") + + values = chart_values(get("/").body, "instances_by_type") + + assert_equal({ "web-counter" => 2, "web-room" => 1 }, values) + end + + test "renders mailbox and outbox charts from the same counts the page shows" do + instance = create_instance + mark_ready(create_message(instance)) + body = get("/").body + + assert_equal({ "Ready" => 1, "Due" => 1, "Claimed" => 0 }, chart_values(body, "mailbox_depth")) + + statuses = chart_values(body, "work_by_status") + + assert_equal 0, statuses.dig("Effects", "pending") + assert_equal 0, statuses.dig("Broadcasts", "delivered") + assert_equal 0, statuses.dig("Reminders", "scheduled") + end + + test "escapes an actor type that would otherwise close the data attribute" do + create_instance(actor_type: "web-'onerror=alert(1)", actor_id: "one") + body = get("/").body + + # The quote is what matters: escaped, the payload stays one attribute + # value; raw, it would close the attribute and start a new one. + assert_includes body, "'onerror" + refute_includes body, "'onerror" + assert_equal({ "web-'onerror=alert(1)" => 1 }, chart_values(body, "instances_by_type")) + end + + test "serves no chart library and names no external host when charts are disabled" do + SolidObjects::Web.chart_library_url = nil + + response = get("/") + + refute_match(/jsdelivr/, response.body) + refute_match(/data-chart=/, response.body) + refute_includes response.headers["content-security-policy"], "jsdelivr" + end + + test "accepts a self hosted copy without widening the policy" do + SolidObjects::Web.chart_library_url = "/javascripts/chart.js" + SolidObjects::Web.chart_library_integrity = nil + + response = get("/") + script_source = response.headers["content-security-policy"][/script-src ([^;]+)/, 1] + + assert_match(%r{src="/solid_objects/javascripts/chart\.js"}, response.body) + refute_match(/integrity=/, response.body) + refute_match(%r{https?://}, script_source) + end + + test "leaves list pages without a chart library" do + refute_match(/jsdelivr/, get("/instances").body) + end + + # Chart.js sizes a responsive canvas from its parent. Left in a panel whose + # own height follows its children, each redraw measures a box that the last + # redraw resized, and the chart creeps larger on every poll. The library + # requires a dedicated container with a height of its own. + test "puts every canvas in a container with a height that does not follow it" do + body = get("/").body + + canvases = body.scan("(_attempt) { 0 } + @dead_letter = create_dead_letter + end + + test "lists a dead letter with its exception" do + response = get("/dead_letters") + + assert_equal 200, response.status + assert_match(/web-dead-letter-poison/, response.body) + assert_match(/RuntimeError/, response.body) + end + + test "shows a dead letter with its backtrace" do + response = get("/dead_letters/#{@dead_letter.id}") + + assert_equal 200, response.status + assert_match(/poison message/, response.body) + assert_match(/web_dead_letters_test/, response.body) + end + + test "retries a dead letter through the authorized manager" do + PoisonActor.fail = false + + response = post("/dead_letters/#{@dead_letter.id}/retry") + + assert_equal 302, response.status + assert @dead_letter.reload.retried_message_id + end + + test "retrying twice reuses the first retry message" do + PoisonActor.fail = false + + post("/dead_letters/#{@dead_letter.id}/retry") + first_message_id = @dead_letter.reload.retried_message_id + post("/dead_letters/#{@dead_letter.id}/retry") + + assert_equal first_message_id, @dead_letter.reload.retried_message_id + end + + # A class can be deleted while its dead letters outlive it. The operator who + # presses Retry has to be told why nothing happened, rather than shown a bare + # 500 from an exception that reached the Rack handler. + test "reports a retry the runtime refuses rather than failing the page" do + orphan = create_orphan_dead_letter + + response = post("/dead_letters/#{orphan.id}/retry") + + assert_equal 422, response.status + assert_match(/web-retired-actor/, response.body) + assert_match(/unknown actor type/, response.body) + assert_nil orphan.reload.retried_message_id + end + + test "refuses a retry the administration policy denies" do + SolidObjects.configuration.authorize_administration = lambda do |action:, **| + action != "retry" + end + + response = post("/dead_letters/#{@dead_letter.id}/retry") + + assert_equal 403, response.status + assert_nil @dead_letter.reload.retried_message_id + end + + private + + def create_orphan_dead_letter + instance = create_instance(actor_type: "web-retired-actor", actor_id: "gone") + message = create_message(instance, operation: "checkout") + now = SolidObjects.database_adapter.database_now + SolidObjects::DeadLetter.create!( + message:, + instance:, + actor_type: instance.actor_type, + actor_id: instance.actor_id, + operation: message.operation, + arguments: message.arguments, + attempts: 5, + exception_class: "RuntimeError", + exception_message: "gave up", + backtrace: [], + first_failed_at: now, + last_failed_at: now + ) + end + + def create_dead_letter + PoisonActor.ref("one").async.run + worker = SolidObjects::Worker.new + worker.run_until_idle + SolidObjects::DeadLetter.first + ensure + worker&.stop + end +end diff --git a/test/integration/web_extension_test.rb b/test/integration/web_extension_test.rb new file mode 100644 index 0000000..460f685 --- /dev/null +++ b/test/integration/web_extension_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "web_test_helper" + +class WebExtensionTest < WebTestCase + EXTENSION = Module.new do + # @rbs (untyped) -> void + def self.registered(application) + application.get "/probe", policy: { action: "index", resource: "probe" } do + erb(:_probe) + end + end + end + + VIEWS = File.expand_path("../fixtures/web_extension", __dir__) + + setup do + SolidObjects::Web.register(EXTENSION, tab: "Probe", path: "/probe", views: VIEWS) + end + + teardown do + SolidObjects::Web.reset! + end + + test "serves a page the extension declared" do + response = get("/probe") + + assert_equal 200, response.status + assert_match(/probe page/, response.body) + end + + test "adds the extension tab to every page" do + assert_match(%r{href="/solid_objects/probe"}, get("/").body) + end + + test "applies the administration policy to an extension route" do + SolidObjects.configuration.authorize_administration = lambda do |resource:, **| + resource != "probe" + end + + assert_equal 403, get("/probe").status + end + + test "prefers an extension view directory over the packaged one" do + assert_match(/replaced summary/, get("/probe").body) + end + + # Registered after the dashboard has already served a request, because the + # built middleware stack is memoized and a late `use` would otherwise be + # silently ignored. + test "runs middleware an application put in front of the dashboard" do + get("/") + SolidObjects::Web.use(StampMiddleware) + + assert_equal "stamped", get("/").headers["x-probe"] + end + + class StampMiddleware + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = @app.call(env) + [ status, headers.merge("x-probe" => "stamped"), body ] + end + end +end diff --git a/test/integration/web_mount_test.rb b/test/integration/web_mount_test.rb new file mode 100644 index 0000000..6b4f2aa --- /dev/null +++ b/test/integration/web_mount_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "test_helper" +require "open3" + +# A Rack application behaves differently under a real Rails router than under a +# mock environment. The mount supplies SCRIPT_NAME, the Rails session +# middleware supplies the session CSRF protection needs, and a dashboard nested +# below the engine mount is only reachable because the engine cascades a path +# it does not serve. This runs the dashboard where it actually runs. +class WebMountTest < ActiveSupport::TestCase + test "serves the dashboard from a real Rails mount" do + assert_equal "403", results.fetch("denied"), "an unauthorized request must not reach a page" + assert_equal "200", results.fetch("allowed") + assert_equal "true", results.fetch("actor"), "the page must show data read through the mount" + assert_equal "true", results.fetch("mounted_link"), "assets must be linked below the mount path" + assert_equal "true", results.fetch("session"), "the Rails session must reach the dashboard" + end + + test "rejects a forged token and applies a valid one through the Rails session" do + assert_equal "403", results.fetch("forged") + assert_equal "false", results.fetch("forged_paused"), "a forged token must not change the runtime" + assert_equal "302", results.fetch("paused") + assert_equal "/solid_objects/dashboard/instances/1", results.fetch("paused_location") + assert_equal "true", results.fetch("paused_at") + end + + private + + def results + @results ||= begin + output, error_output, status = Open3.capture3( + Gem.ruby, + File.expand_path("../dummy/web_mount_check.rb", __dir__) + ) + assert status.success?, error_output + output.split("\n").to_h { |line| line.split("=", 2) } + end + end +end diff --git a/test/integration/web_test.rb b/test/integration/web_test.rb new file mode 100644 index 0000000..e5db2f1 --- /dev/null +++ b/test/integration/web_test.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require "web_test_helper" + +class WebTest < WebTestCase + test "denies every page when administration is not authorized" do + SolidObjects.configuration.authorize_administration = ->(**) { false } + + %w[/ /instances /mailbox /reminders /effects /broadcasts /dead_letters /processes /stats].each do |path| + response = get(path) + + assert_equal 403, response.status, "#{path} must deny by default" + end + end + + test "denies with the default configuration, which authorizes nothing" do + SolidObjects.reset! + + assert_equal 403, get("/").status + end + + test "passes the route policy and the request to the authorization block" do + instance = create_instance + seen = [] + SolidObjects.configuration.authorize_administration = lambda do |action:, resource:, resource_id:, authorization_context:| + seen << [ action, resource, resource_id, authorization_context.request.path ] + true + end + + get("/instances/#{instance.id}") + + assert_equal [ [ "show", "instances", instance.id.to_s, "/solid_objects/instances/#{instance.id}" ] ], seen + end + + test "renders the dashboard with runtime counts" do + instance = create_instance + mark_ready(create_message(instance)) + + response = get("/") + + assert_equal 200, response.status + assert_match(/Solid Objects/, response.body) + assert_match(/Instances/, response.body) + assert_match(/Ready/, response.body) + end + + test "reports the same counts as JSON" do + instance = create_instance + mark_ready(create_message(instance)) + + response = get("/stats") + payload = JSON.parse(response.body) + + assert_equal 200, response.status + assert_equal "application/json", response.headers["content-type"] + assert_equal "private, no-store", response.headers["cache-control"] + assert_equal 1, payload.dig("instances", "total") + assert_equal 1, payload.dig("mailbox", "ready") + assert_equal 0, payload.dig("mailbox", "claimed") + end + + test "answers a HEAD request without rendering a page" do + response = request("/", method: "HEAD", params: {}) + + assert_equal 200, response.status + assert_empty response.body + end + + test "lists instances and links to each one" do + instance = create_instance(actor_type: "web-counter", actor_id: "alpha") + + response = get("/instances") + + assert_equal 200, response.status + assert_match(/web-counter/, response.body) + assert_match(/alpha/, response.body) + assert_match(%r{/solid_objects/instances/#{instance.id}}, response.body) + end + + test "filters instances by actor type and by actor id substring" do + create_instance(actor_type: "web-counter", actor_id: "alpha") + create_instance(actor_type: "web-room", actor_id: "beta") + + typed = get("/instances", "actor_type" => "web-room") + + assert_match(/beta/, typed.body) + refute_match(/alpha/, typed.body) + + named = get("/instances", "actor_id" => "alph") + + assert_match(/alpha/, named.body) + refute_match(/beta/, named.body) + end + + test "pages the instance list" do + 3.times { |index| create_instance(actor_id: "page-#{index}") } + + response = get("/instances", "per_page" => "2") + + assert_equal 2, response.body.scan("data-instance-row").length + assert_match(/page=2/, response.body) + end + + test "shows an instance with its state and mailbox" do + instance = create_instance(state: { "value" => 41 }) + mark_ready(create_message(instance, operation: "increment")) + + response = get("/instances/#{instance.id}") + + assert_equal 200, response.status + assert_match(/41/, response.body) + assert_match(/increment/, response.body) + end + + test "returns 404 for an unknown instance" do + assert_equal 404, get("/instances/999999").status + end + + test "renders an instance whose actor type is no longer registered" do + instance = create_instance(actor_type: "web-retired-actor") + + response = get("/instances/#{instance.id}") + + assert_equal 200, response.status + assert_match(/web-retired-actor/, response.body) + end + + test "escapes actor identifiers in rendered pages" do + create_instance(actor_id: "") + + response = get("/instances") + + refute_match(%r{ + <% if SolidObjects::Web.charts? && current_tab?("/") %> + + + <% end %> + + "> + <%= erb(:_navigation) %> +
+ <%= erb(:_summary) %> + <%= locals.fetch(:content) %> +
+ + diff --git a/web/views/mailbox.erb b/web/views/mailbox.erb new file mode 100644 index 0000000..14fa46d --- /dev/null +++ b/web/views/mailbox.erb @@ -0,0 +1,35 @@ +
+

Mailbox

+ <%= erb(:_status_filter, statuses: SolidObjects::Web::Application::MAILBOX_MEMBERSHIPS, selected: @membership, all: false) %> + + <% if @paginator.records.empty? %> +

No message is <%= h(@membership) %>.

+ <% else %> + + + + + + + + + + + + + <% @paginator.records.each do |message| %> + + + + + + + + + <% end %> + +
ActorSequenceOperationDeliveryAttemptsAvailable
"><%= actor_label(message) %>"><%= number(message.sequence) %><%= h(message.operation) %><%= status_label(message.delivery_mode) %><%= number(message.attempt_count) %> / <%= number(message.max_attempts) %><%= relative_time(message.available_at) %>
+ <% end %> + + <%= erb(:_paging) %> +
diff --git a/web/views/message.erb b/web/views/message.erb new file mode 100644 index 0000000..61b1013 --- /dev/null +++ b/web/views/message.erb @@ -0,0 +1,34 @@ +
+
+

<%= h(@message.operation) %>

+
+ <%= status_label(@message.delivery_mode) %> + "><%= actor_label(@message) %> +
+
+ +
+
Sequence
<%= number(@message.sequence) %>
+
Attempts
<%= number(@message.attempt_count) %> / <%= number(@message.max_attempts) %>
+
Request id
<%= h(@message.request_id) %>
+
Idempotency key
<%= h(@message.idempotency_key || "none") %>
+
Enqueued
<%= relative_time(@message.enqueued_at) %>
+
Available
<%= relative_time(@message.available_at) %>
+
Started
<%= relative_time(@message.started_at) %>
+
Completed
<%= relative_time(@message.completed_at) %>
+
Rejected
<%= relative_time(@message.rejected_at) %>
+
Last failed
<%= relative_time(@message.last_failed_at) %>
+
+ +

Arguments

+ <%= json_block(@message.arguments) %> + +

Result

+ <%= json_block(@message.result) %> + +

Error

+ <%= json_block(@message.error) %> + +

Rejection

+ <%= json_block(@message.rejection) %> +
diff --git a/web/views/processes.erb b/web/views/processes.erb new file mode 100644 index 0000000..2e0b6a0 --- /dev/null +++ b/web/views/processes.erb @@ -0,0 +1,37 @@ +
+

Processes

+ <%= erb(:_status_filter, statuses: SolidObjects::Web::Statistics::PROCESS_STATES, selected: @status) %> + + <% if @paginator.records.empty? %> +

No process matches this filter.

+ <% else %> + + + + + + + + + + + + + + <% @paginator.records.each do |process_record| %> + + + + + + + + + + <% end %> + +
KindHostPIDStateStartedLast heartbeatActivated instances
<%= h(process_record.kind) %><%= h(process_record.hostname) %><%= h(process_record.pid) %><%= status_label(process_record.shutdown_state) %><%= relative_time(process_record.started_at) %><%= relative_time(process_record.last_heartbeat_at) %><%= number(@activated_counts.fetch(process_record.id, 0)) %>
+ <% end %> + + <%= erb(:_paging) %> +
diff --git a/web/views/reminders.erb b/web/views/reminders.erb new file mode 100644 index 0000000..f9f1405 --- /dev/null +++ b/web/views/reminders.erb @@ -0,0 +1,37 @@ +
+

Reminders

+ <%= erb(:_status_filter, statuses: SolidObjects::Web::Statistics::REMINDER_STATUSES, selected: @status) %> + + <% if @paginator.records.empty? %> +

No reminder matches this filter.

+ <% else %> + + + + + + + + + + + + + + <% @paginator.records.each do |reminder| %> + + + + + + + + + + <% end %> + +
ActorNameOperationStatusNext runIntervalOccurrence
"><%= actor_label(reminder) %><%= h(reminder.name) %><%= h(reminder.operation) %><%= status_label(reminder.status) %><%= relative_time(reminder.next_run_at) %><%= duration(reminder.interval_seconds) %><%= number(reminder.occurrence) %>
+ <% end %> + + <%= erb(:_paging) %> +