Skip to content

feat: add operator dashboard - #37

Merged
cardmagic merged 2 commits into
mainfrom
agent/web-dashboard
Aug 15, 2026
Merged

feat: add operator dashboard#37
cardmagic merged 2 commits into
mainfrom
agent/web-dashboard

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

SolidObjects::Web is a mountable Rack dashboard over the runtime tables:
instances and their committed state, the ready and claimed mailbox, reminders,
effects, broadcasts, dead letters, and processes.

# config/routes.rb
require "solid_objects/web"

Rails.application.routes.draw do
  mount SolidObjects::Web => "/solid_objects/dashboard"
end

It is a separate require. A worker process must not carry a web stack, and
test/integration/load_contract_test.rb records each new file as deliberately
deferred.

API

New public surface: SolidObjects::Web.call (the Rack entry point),
.register for extension tabs, routes and view directories, .use for Rack
middleware, and .chart_library_url / .chart_library_integrity.

rack becomes an explicit dependency at >= 3.1. It already arrives with
Action Pack in every supported Rails version; the floor is stated because the
dashboard writes lowercase response headers, which Rack 3 requires. The web/
directory is added to the gemspec file list.

No migration. Nothing in the runtime path changed.

Security

Every route declares the administration policy it needs, and Router#route
raises at load time on a route without one. A page added later therefore cannot
reach the actor tables before an application has said who may read it, so the
deny-by-default posture is enforced by construction rather than by remembering
a filter. An unconfigured mount returns 403 everywhere.

authorization_context: is the request object, which answers request,
session and env, so a policy can read the signed-in operator the way a
controller does. docs/dashboard.md lists the action and resource of every
page.

Other boundaries:

  • CSRF: a masked per-request token in the Rack session; a state changing
    request without a valid one gets 403.
  • CSP: default-src 'self', a per-request nonce, and no unsafe-inline. The
    Chart.js CDN host is the only external origin named, and only when charts are
    enabled. Chart data travels in a data- attribute, not an inline script.
  • Every application supplied string is escaped, including chart data, where an
    actor type would otherwise close the attribute.
  • The page size is clamped, because page and page size both arrive from the
    query string.
  • Actor id filtering goes through Arel matches with sanitize_sql_like.
  • Brakeman stays warning free.

Correctness

Two write actions, both narrow:

  • Retry a dead letter goes through DeadLetterManager, so it is idempotent.
    A retry the mailbox refuses, such as an actor class that no longer exists,
    renders the reason with 422 rather than failing the request.
  • Pause an instance sets paused_at, which the activation manager already
    honours. This is an operator brake, not a stop: a pass already in flight
    finishes its turn, and a synchronous caller waiting on a paused instance
    times out rather than receiving a result. Both are stated on the page and in
    the docs.

Not included: bulk retry, because DeadLetterManager exposes no bulk
operation, and audit records of who pressed what. docs/roadmap.md records
both.

Cost

The summary bar issues one grouped count per subsystem on every page, and each
list page counts its own relation to page it. Instance counts per actor type
are one grouped query on the dashboard only, bounded to twelve rows. Actor type
suggestions come from the registry rather than a DISTINCT no adapter can
answer from an index. Process rows count activated instances in one grouped
query rather than one per row. HEAD / exists so an uptime monitor need not
load a whole page. The roadmap records that this was reasoned about rather than
benchmarked.

Validation

bundle exec rake          # 499 runs, 1697 assertions, 0 failures, 14 skips
npm test                  # 39 tests, 0 failures

rake covers Minitest, Standard Ruby, RuboCop, RBS generation and validation,
Steep, and Brakeman; all clean. The 14 skips are the pre-existing PostgreSQL
and MySQL suites that skip on SQLite; this change adds none.

Coverage worth naming:

  • test/integration/web_mount_test.rb drives the dashboard through a real
    Rails router
    in a subprocess, not a mock environment: the mount supplies
    SCRIPT_NAME, the Rails session middleware supplies the session CSRF needs,
    and a dashboard nested below the engine mount is only reachable because the
    engine cascades a path it does not serve. It asserts a forged token is
    refused and changes nothing, and that a valid one redirects to the
    mount-prefixed path and applies the write.
  • Browser modules run under jsdom, including that the poller and the server
    format the same number identically.

Every significant behaviour was mutation tested: authorization, CSRF, HTML
escaping, pause, retry, filtering, paging, formatting, chart data and the chart
container each fail when the implementation is removed.

Three defects were found this way and fixed:

  1. A template compiled before an extension registered its views kept winning,
    so a replacement page was unreachable.
  2. Middleware added after the first request was silently dropped, because the
    built stack is memoized.
  3. A retry for a deregistered actor class raised out of the Rack handler as a
    bare 500.

Not verified

A chart that grows on repeated redraws was reported during development. The
canvas is now in a dedicated sized container with position: absolute, which
is what Chart.js requires and makes the resize feedback loop structurally
impossible. I could not reproduce the original growth headlessly at
devicePixelRatio: 2 over 60 resize and update rounds, so the fix is
structural rather than confirmed against a reproduction.

The runtime writes seven tables and exposes none of them. An operator
who wants to know why a message has not run reads SQL, and the only
administration surfaces are two engine controllers and a handful of CLI
commands.

SolidObjects::Web is a mountable Rack application over those tables:
instances and their committed state, the ready and claimed mailbox,
reminders, effects, broadcasts, dead letters, and processes. It is a
separate require, because a worker process must not carry a web stack.

Authorization is the reason it is a router rather than a set of
controllers. Every route declares the administration policy it needs and
a route without one raises at load time, so a page added later cannot
reach the actor tables before somebody says who may read it. The
deny-by-default posture is enforced by construction rather than by
remembering a filter.

It changes only two things. Retrying a dead letter goes through
DeadLetterManager, which is idempotent; a retry the mailbox refuses
renders the reason rather than a 500. Pausing an instance sets paused_at
so the activation manager stops claiming it, which is an operator brake
and not a stop: a pass in flight finishes its turn and a synchronous
caller waiting on that instance times out.

Charts come from Chart.js on a CDN with a subresource integrity hash,
and that host is the only external origin the policy names. A deployment
without outbound network access vendors the file or turns charts off.

Rack is now an explicit dependency at >= 3.1 because the dashboard
writes lowercase response headers.
@greptile-apps

greptile-apps Bot commented Aug 15, 2026

Copy link
Copy Markdown

Greptile Summary

SolidObjects::Web adds a separately loaded, mountable Rack operator dashboard for inspecting actor-runtime tables and performing narrowly authorized administrative actions.

  • Adds route-level administration authorization and session-backed CSRF protection.
  • Adds runtime views, filtering, pagination, statistics, charts, extensions, and dashboard middleware support.
  • Adds dead-letter retry and instance pause/resume actions.
  • Adds documentation, generated signatures, browser assets, and integration coverage for Rails mounting and security boundaries.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
lib/solid_objects/web.rb Defines the dashboard entry point, configuration, extension registration, template caching, assets, and middleware stack without an eligible follow-up defect.
lib/solid_objects/web/application.rb Defines authorized dashboard routes, read views, statistics, dead-letter retry, and instance pause/resume behavior.
lib/solid_objects/web/csrf_protection.rb Keeps the session CSRF secret stable while issuing fresh masked tokens, resolving the previously reported multi-form invalidation.
lib/solid_objects/web/helpers.rb Provides escaped rendering, mounted-path URL generation, filtering, pagination integration, and chart serialization.
test/integration/web_test.rb Covers authorization, CSRF token reuse and masking, rendering, filtering, actions, headers, assets, and mounted paths.
test/integration/web_mount_test.rb Verifies authorization and CSRF behavior through a real Rails router and session middleware.

Sequence Diagram

sequenceDiagram
    participant Operator
    participant Rails as Rails Router / Session
    participant Web as SolidObjects::Web
    participant CSRF as CSRF Protection
    participant Policy as Administration Policy
    participant Runtime as Runtime Tables
    Operator->>Rails: Dashboard request
    Rails->>Web: Mounted Rack request with session
    Web->>CSRF: Validate state-changing request
    CSRF->>Policy: Forward accepted request
    Policy-->>Web: Allow or deny route action/resource
    alt Authorized
        Web->>Runtime: Read data or perform narrow action
        Runtime-->>Web: Result
        Web-->>Operator: HTML, JSON, or redirect
    else Denied
        Web-->>Operator: 403 Forbidden
    end
Loading

Reviews (2): Last reviewed commit: "fix: keep the CSRF secret for the sessio..." | Re-trigger Greptile

Comment on lines +67 to +70
# The session token is replaced whether or not this comparison
# succeeds, so a token cannot be replayed after it is spent.
session[:csrf] = SecureRandom.base64(TOKEN_BYTES)
matches?(token, stored)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Session rotation invalidates valid forms

When an operator submits one of several forms rendered from the same session secret, valid? replaces that secret before comparison, causing every other form already open on the page or in another tab to receive 403 until it is reloaded.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/solid_objects/web/csrf_protection.rb
Line: 67-70

Comment:
**Session rotation invalidates valid forms**

When an operator submits one of several forms rendered from the same session secret, `valid?` replaces that secret before comparison, causing every other form already open on the page or in another tab to receive 403 until it is reloaded.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

The dead-letter list renders one Retry form per row, and a browser keeps
pages open in other tabs. Rotating the session secret on the first
submission answered 403 to every other form the same page had already
rendered, so retrying a second dead letter appeared to be forbidden
until the operator reloaded.

Single use is not what a CSRF token provides. It proves the request came
from a page this session was served; the per-request mask is what keeps
the value on the wire from repeating, which is the property rotation
looked like it was adding.

The mount check now forges a token of the right length so it reaches the
comparison rather than being turned away by the length check, which is
what a wrong-but-well-formed token would do.
@cardmagic
cardmagic merged commit 27f0dcc into main Aug 15, 2026
29 checks passed
@cardmagic
cardmagic deleted the agent/web-dashboard branch August 15, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant