solid_objects 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +72 -6
- data/app/models/solid_objects/broadcast.rb +8 -0
- data/docs/adr/0009-realtime-updates.md +5 -1
- data/docs/architecture.md +4 -2
- data/docs/authorization.md +11 -4
- data/docs/correctness.md +3 -3
- data/docs/dashboard.md +200 -0
- data/docs/database-schema.md +5 -4
- data/docs/development.md +12 -0
- data/docs/realtime.md +37 -7
- data/docs/roadmap.md +28 -6
- data/docs/security.md +8 -0
- data/examples/application/README.md +5 -5
- data/examples/application/app/actors/chat_room_actor.rb +1 -1
- data/examples/application/app/actors/shopping_cart_actor.rb +5 -5
- data/lib/solid_objects/actor.rb +6 -5
- data/lib/solid_objects/actor_channel.rb +7 -4
- data/lib/solid_objects/actor_definition.rb +15 -3
- data/lib/solid_objects/actor_view.rb +6 -1
- data/lib/solid_objects/effect_executor.rb +10 -2
- data/lib/solid_objects/errors.rb +3 -0
- data/lib/solid_objects/executor.rb +7 -1
- data/lib/solid_objects/reminder_scheduler.rb +28 -14
- data/lib/solid_objects/test_helper.rb +16 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +3 -3
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/web/action.rb +109 -0
- data/lib/solid_objects/web/application.rb +239 -0
- data/lib/solid_objects/web/csrf_protection.rb +130 -0
- data/lib/solid_objects/web/helpers.rb +227 -0
- data/lib/solid_objects/web/paginator.rb +64 -0
- data/lib/solid_objects/web/route.rb +55 -0
- data/lib/solid_objects/web/router.rb +46 -0
- data/lib/solid_objects/web/statistics.rb +78 -0
- data/lib/solid_objects/web.rb +222 -0
- data/sig/generated/lib/solid_objects/actor.rbs +2 -2
- data/sig/generated/lib/solid_objects/actor_definition.rbs +9 -2
- data/sig/generated/lib/solid_objects/errors.rbs +3 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
- data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
- data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
- data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
- data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
- data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
- data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
- data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
- data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
- data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
- data/sig/generated/lib/solid_objects/web.rbs +129 -0
- data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
- data/web/assets/javascripts/application.js +118 -0
- data/web/assets/javascripts/charts.js +190 -0
- data/web/assets/stylesheets/application.css +527 -0
- data/web/views/_messages.erb +29 -0
- data/web/views/_navigation.erb +15 -0
- data/web/views/_paging.erb +17 -0
- data/web/views/_status_filter.erb +8 -0
- data/web/views/_summary.erb +39 -0
- data/web/views/broadcasts.erb +35 -0
- data/web/views/dashboard.erb +128 -0
- data/web/views/dead_letter.erb +40 -0
- data/web/views/dead_letters.erb +42 -0
- data/web/views/effects.erb +35 -0
- data/web/views/instance.erb +139 -0
- data/web/views/instances.erb +49 -0
- data/web/views/layout.erb +22 -0
- data/web/views/mailbox.erb +35 -0
- data/web/views/message.erb +34 -0
- data/web/views/processes.erb +37 -0
- data/web/views/reminders.erb +37 -0
- metadata +55 -2
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
class Route
|
|
6
|
+
# A named segment stops at the next slash, so `/instances/:id` never
|
|
7
|
+
# swallows `/instances/1/pause` and route order cannot hide a page.
|
|
8
|
+
NAMED_SEGMENT = %r{/([^/]*):([^.:$/]+)}
|
|
9
|
+
SEGMENT_CAPTURE = '/\1(?<\2>[^$/]+)'
|
|
10
|
+
|
|
11
|
+
# @rbs @matcher: String | Regexp
|
|
12
|
+
# @rbs @request_method: String
|
|
13
|
+
# @rbs @pattern: String
|
|
14
|
+
# @rbs @policy: Hash[Symbol, String]
|
|
15
|
+
# @rbs @handler: Proc
|
|
16
|
+
|
|
17
|
+
attr_reader :request_method, :pattern, :policy, :handler
|
|
18
|
+
|
|
19
|
+
# @rbs (request_method: String, pattern: String, policy: Hash[Symbol, String], handler: Proc) -> void
|
|
20
|
+
def initialize(request_method:, pattern:, policy:, handler:)
|
|
21
|
+
@request_method = request_method
|
|
22
|
+
@pattern = pattern
|
|
23
|
+
@policy = policy
|
|
24
|
+
@handler = handler
|
|
25
|
+
@matcher = compile(pattern)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @rbs (String) -> bool
|
|
29
|
+
def match?(path)
|
|
30
|
+
return @matcher == path if @matcher.is_a?(String)
|
|
31
|
+
|
|
32
|
+
@matcher.match?(path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @rbs (String) -> Hash[Symbol, String?]
|
|
36
|
+
def capture(path)
|
|
37
|
+
return {} if @matcher.is_a?(String)
|
|
38
|
+
|
|
39
|
+
match = @matcher.match(path)
|
|
40
|
+
return {} unless match
|
|
41
|
+
|
|
42
|
+
match.named_captures.transform_keys(&:to_sym)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# @rbs (String) -> (String | Regexp)
|
|
48
|
+
def compile(pattern)
|
|
49
|
+
return pattern unless pattern.match?(NAMED_SEGMENT)
|
|
50
|
+
|
|
51
|
+
Regexp.new("\\A#{pattern.gsub(NAMED_SEGMENT, SEGMENT_CAPTURE)}\\z")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
# Declares the pages of the dashboard. Every route carries the
|
|
6
|
+
# administration policy it needs, and a route declared without one raises
|
|
7
|
+
# at load time. A new page therefore cannot reach the database before an
|
|
8
|
+
# application has said who may read it.
|
|
9
|
+
module Router
|
|
10
|
+
# @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
|
|
11
|
+
def head(path, policy: nil, &handler)
|
|
12
|
+
route("HEAD", path, policy:, &handler)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
|
|
16
|
+
def get(path, policy: nil, &handler)
|
|
17
|
+
route("GET", path, policy:, &handler)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
|
|
21
|
+
def post(path, policy: nil, &handler)
|
|
22
|
+
route("POST", path, policy:, &handler)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @rbs (String, String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
|
|
26
|
+
def route(request_method, path, policy: nil, &handler)
|
|
27
|
+
raise ArgumentError, "route #{path} requires an authorization policy" unless policy
|
|
28
|
+
raise ArgumentError, "route #{path} requires a handler" unless handler
|
|
29
|
+
raise ArgumentError, "route #{path} requires a policy action" unless policy[:action]
|
|
30
|
+
raise ArgumentError, "route #{path} requires a policy resource" unless policy[:resource]
|
|
31
|
+
|
|
32
|
+
routes[request_method] << Route.new(request_method:, pattern: path, policy:, handler:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @rbs () -> Hash[String, Array[Route]]
|
|
36
|
+
def routes
|
|
37
|
+
@routes ||= Hash.new { |store, request_method| store[request_method] = [] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @rbs (String, String) -> Route?
|
|
41
|
+
def match(request_method, path)
|
|
42
|
+
routes[request_method].find { |route| route.match?(path) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
# The counts behind the dashboard and behind `GET /stats`. Both read the
|
|
6
|
+
# same object, so the polled JSON and the rendered page can never disagree
|
|
7
|
+
# about what a number means.
|
|
8
|
+
class Statistics
|
|
9
|
+
EFFECT_STATUSES = %w[pending processing completed dead].freeze
|
|
10
|
+
BROADCAST_STATUSES = %w[pending processing delivered dead].freeze
|
|
11
|
+
REMINDER_STATUSES = %w[scheduled paused completed].freeze
|
|
12
|
+
PROCESS_STATES = %w[running draining stopped].freeze
|
|
13
|
+
|
|
14
|
+
# @rbs @now: Time
|
|
15
|
+
|
|
16
|
+
attr_reader :now
|
|
17
|
+
|
|
18
|
+
# @rbs (?now: Time) -> void
|
|
19
|
+
def initialize(now: SolidObjects.database_adapter.database_now)
|
|
20
|
+
@now = now
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs () -> Hash[Symbol, untyped]
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
instances:,
|
|
27
|
+
mailbox:,
|
|
28
|
+
effects: grouped(Effect, :status, EFFECT_STATUSES),
|
|
29
|
+
broadcasts: grouped(Broadcast, :status, BROADCAST_STATUSES),
|
|
30
|
+
reminders:,
|
|
31
|
+
dead_letters: { total: DeadLetter.count },
|
|
32
|
+
processes: grouped(Process, :shutdown_state, PROCESS_STATES),
|
|
33
|
+
server_time: now.utc.iso8601
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @rbs () -> Hash[Symbol, Integer]
|
|
38
|
+
def instances
|
|
39
|
+
{
|
|
40
|
+
total: Instance.count,
|
|
41
|
+
paused: Instance.where.not(paused_at: nil).count,
|
|
42
|
+
activated: Instance.where(activation_expires_at: now..).count
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The oldest ready message that is already due is the queue latency of
|
|
47
|
+
# this runtime: how far behind the workers are, in seconds.
|
|
48
|
+
# @rbs () -> Hash[Symbol, untyped]
|
|
49
|
+
def mailbox
|
|
50
|
+
due = ReadyMessage.where(available_at: ..now)
|
|
51
|
+
oldest = due.minimum(:available_at)
|
|
52
|
+
{
|
|
53
|
+
ready: ReadyMessage.count,
|
|
54
|
+
due: due.count,
|
|
55
|
+
claimed: ClaimedMessage.count,
|
|
56
|
+
latency: oldest ? (now - oldest).round(3) : 0.0
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @rbs () -> Hash[Symbol, Integer]
|
|
61
|
+
def reminders
|
|
62
|
+
grouped(Reminder, :status, REMINDER_STATUSES).merge(
|
|
63
|
+
due: Reminder.where(status: "scheduled", next_run_at: ..now).count
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# A status the schema allows but the table does not currently hold still
|
|
70
|
+
# reports zero, so a row of counts keeps the same shape between polls.
|
|
71
|
+
# @rbs (untyped, Symbol, Array[String]) -> Hash[Symbol, Integer]
|
|
72
|
+
def grouped(model, column, statuses)
|
|
73
|
+
counts = model.group(column).count
|
|
74
|
+
statuses.to_h { |status| [ status.to_sym, counts.fetch(status, 0) ] }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "rack"
|
|
8
|
+
require "rack/builder"
|
|
9
|
+
require "rack/static"
|
|
10
|
+
|
|
11
|
+
require "solid_objects"
|
|
12
|
+
require "solid_objects/web/route"
|
|
13
|
+
require "solid_objects/web/router"
|
|
14
|
+
require "solid_objects/web/statistics"
|
|
15
|
+
require "solid_objects/web/paginator"
|
|
16
|
+
require "solid_objects/web/helpers"
|
|
17
|
+
require "solid_objects/web/action"
|
|
18
|
+
require "solid_objects/web/csrf_protection"
|
|
19
|
+
require "solid_objects/web/application"
|
|
20
|
+
|
|
21
|
+
module SolidObjects
|
|
22
|
+
# An operator dashboard for the actor runtime, served as a Rack application:
|
|
23
|
+
#
|
|
24
|
+
# Rails.application.routes.draw do
|
|
25
|
+
# mount SolidObjects::Web => "/solid_objects"
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# It is deliberately not loaded by `require "solid_objects"`. A worker
|
|
29
|
+
# process must not carry a web stack, and an application that never mounts
|
|
30
|
+
# the dashboard must not pay for it.
|
|
31
|
+
#
|
|
32
|
+
# Every page asks `configuration.authorize_administration` first. That block
|
|
33
|
+
# denies by default, so a mount alone exposes nothing until an application
|
|
34
|
+
# states who may read it.
|
|
35
|
+
class Web
|
|
36
|
+
ROOT = File.expand_path("../../web", __dir__)
|
|
37
|
+
VIEWS = File.join(ROOT, "views")
|
|
38
|
+
ASSETS = File.join(ROOT, "assets")
|
|
39
|
+
|
|
40
|
+
NONCE_KEY = "solid_objects.content_security_policy_nonce"
|
|
41
|
+
CSRF_TOKEN_KEY = "solid_objects.csrf_token"
|
|
42
|
+
NONCE_BYTES = 16
|
|
43
|
+
ASSET_CACHE_SECONDS = 86_400
|
|
44
|
+
TEMPLATE_NAME = /\A_?[a-z][a-z0-9_]*\z/
|
|
45
|
+
|
|
46
|
+
# The dashboard charts need a charting library, and this one is fetched
|
|
47
|
+
# from a public CDN with a subresource integrity hash, so a compromised CDN
|
|
48
|
+
# cannot substitute other code. A deployment with no outbound network
|
|
49
|
+
# access should vendor the file and point `chart_library_url` at it, or set
|
|
50
|
+
# that to nil to render the dashboard without charts.
|
|
51
|
+
CHART_LIBRARY_URL = "https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js"
|
|
52
|
+
CHART_LIBRARY_INTEGRITY = "sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4/DCMd7XLcA6Y"
|
|
53
|
+
|
|
54
|
+
DEFAULT_TABS = {
|
|
55
|
+
"Dashboard" => "/",
|
|
56
|
+
"Instances" => "/instances",
|
|
57
|
+
"Mailbox" => "/mailbox",
|
|
58
|
+
"Reminders" => "/reminders",
|
|
59
|
+
"Effects" => "/effects",
|
|
60
|
+
"Broadcasts" => "/broadcasts",
|
|
61
|
+
"Dead letters" => "/dead_letters",
|
|
62
|
+
"Processes" => "/processes"
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
LOCK = Mutex.new
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
# A path below the mount serves a vendored copy; an absolute URL is
|
|
69
|
+
# fetched from that host and is named in the content security policy.
|
|
70
|
+
# nil renders the dashboard without charts.
|
|
71
|
+
# @rbs @chart_library_url: String?
|
|
72
|
+
# @rbs @chart_library_integrity: String?
|
|
73
|
+
attr_writer :chart_library_url, :chart_library_integrity
|
|
74
|
+
|
|
75
|
+
# @rbs () -> String?
|
|
76
|
+
def chart_library_url
|
|
77
|
+
defined?(@chart_library_url) ? @chart_library_url : CHART_LIBRARY_URL
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @rbs () -> String?
|
|
81
|
+
def chart_library_integrity
|
|
82
|
+
defined?(@chart_library_integrity) ? @chart_library_integrity : CHART_LIBRARY_INTEGRITY
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @rbs () -> bool
|
|
86
|
+
def charts?
|
|
87
|
+
!chart_library_url.nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The origin the content security policy has to allow. A vendored copy
|
|
91
|
+
# served from the mount has none, so the policy stays at 'self'.
|
|
92
|
+
# @rbs () -> String?
|
|
93
|
+
def chart_library_origin
|
|
94
|
+
url = chart_library_url
|
|
95
|
+
return nil unless url&.include?("//")
|
|
96
|
+
|
|
97
|
+
uri = URI.parse(url)
|
|
98
|
+
return nil unless uri.scheme && uri.host
|
|
99
|
+
|
|
100
|
+
"#{uri.scheme}://#{uri.host}"
|
|
101
|
+
rescue URI::InvalidURIError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @rbs () -> Hash[String, String]
|
|
106
|
+
def tabs
|
|
107
|
+
@tabs ||= DEFAULT_TABS.dup
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Searched in order, so an extension directory added first wins over the
|
|
111
|
+
# packaged one and an application can replace a single page.
|
|
112
|
+
# @rbs () -> Array[String]
|
|
113
|
+
def views
|
|
114
|
+
@views ||= [ VIEWS ]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @rbs () -> Array[[Array[untyped], Proc?]]
|
|
118
|
+
def middlewares
|
|
119
|
+
@middlewares ||= []
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The built stack is memoized, so a middleware added after the first
|
|
123
|
+
# request would otherwise be dropped without a word.
|
|
124
|
+
# @rbs (*untyped) ?{ () -> untyped } -> void
|
|
125
|
+
def use(*arguments, &block)
|
|
126
|
+
middlewares << [ arguments, block ]
|
|
127
|
+
LOCK.synchronize { @application = nil }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Adds pages to the dashboard. The extension receives the application
|
|
131
|
+
# class and declares its own routes on it, which means its routes carry
|
|
132
|
+
# an authorization policy like every other route.
|
|
133
|
+
#
|
|
134
|
+
# @rbs (untyped, tab: String, path: String, ?views: String?) -> void
|
|
135
|
+
def register(extension, tab:, path:, views: nil)
|
|
136
|
+
if views
|
|
137
|
+
self.views.unshift(views)
|
|
138
|
+
# A template compiled before this call resolved against the old
|
|
139
|
+
# search path, so a replacement view would never be reached.
|
|
140
|
+
LOCK.synchronize { @templates = {} }
|
|
141
|
+
end
|
|
142
|
+
tabs[tab] = path
|
|
143
|
+
extension.registered(Application)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @rbs (Symbol) -> untyped
|
|
147
|
+
def template(name)
|
|
148
|
+
LOCK.synchronize do
|
|
149
|
+
templates[name] ||= ERB.new(File.read(template_path(name)), trim_mode: "-")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @rbs () -> void
|
|
154
|
+
def reset!
|
|
155
|
+
LOCK.synchronize { @templates = {} }
|
|
156
|
+
@tabs = nil
|
|
157
|
+
@views = nil
|
|
158
|
+
@middlewares = nil
|
|
159
|
+
@application = nil
|
|
160
|
+
remove_instance_variable(:@chart_library_url) if defined?(@chart_library_url)
|
|
161
|
+
remove_instance_variable(:@chart_library_integrity) if defined?(@chart_library_integrity)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @rbs (Hash[String, untyped]) -> Array[untyped]
|
|
165
|
+
def call(env)
|
|
166
|
+
application.call(env)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @rbs () -> Web
|
|
170
|
+
def application
|
|
171
|
+
LOCK.synchronize { @application ||= new }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# @rbs () -> Hash[Symbol, untyped]
|
|
177
|
+
def templates
|
|
178
|
+
@templates ||= {}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A template name never comes from a request, and this keeps it that way
|
|
182
|
+
# rather than trusting every future caller to know it.
|
|
183
|
+
# @rbs (Symbol) -> String
|
|
184
|
+
def template_path(name)
|
|
185
|
+
raise ArgumentError, "invalid template name" unless name.to_s.match?(TEMPLATE_NAME)
|
|
186
|
+
|
|
187
|
+
found = views.lazy.map { |directory| File.join(directory, "#{name}.erb") }.find { |path| File.exist?(path) }
|
|
188
|
+
found || raise(ArgumentError, "no template named #{name}")
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @rbs (Hash[String, untyped]) -> Array[untyped]
|
|
193
|
+
def call(env)
|
|
194
|
+
env[NONCE_KEY] = SecureRandom.base64(NONCE_BYTES)
|
|
195
|
+
app.call(env)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @rbs () -> untyped
|
|
199
|
+
def app
|
|
200
|
+
@app ||= build
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
# @rbs () -> untyped
|
|
206
|
+
def build
|
|
207
|
+
assets = ASSETS
|
|
208
|
+
extra = self.class.middlewares
|
|
209
|
+
|
|
210
|
+
::Rack::Builder.new do
|
|
211
|
+
use ::Rack::Static,
|
|
212
|
+
urls: [ "/stylesheets", "/javascripts" ],
|
|
213
|
+
root: assets,
|
|
214
|
+
cascade: true,
|
|
215
|
+
header_rules: [ [ :all, { "cache-control" => "private, max-age=#{ASSET_CACHE_SECONDS}" } ] ]
|
|
216
|
+
extra.each { |arguments, block| use(*arguments, &block) }
|
|
217
|
+
use CsrfProtection
|
|
218
|
+
run Application.new
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -87,8 +87,8 @@ module SolidObjects
|
|
|
87
87
|
# @rbs (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
|
|
88
88
|
def self.query: (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
|
|
89
89
|
|
|
90
|
-
# @rbs (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
91
|
-
def self.observable: (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
90
|
+
# @rbs (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
91
|
+
def self.observable: (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
92
92
|
|
|
93
93
|
# @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
|
|
94
94
|
def self.broadcast_payload: (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
|
|
@@ -44,6 +44,8 @@ module SolidObjects
|
|
|
44
44
|
|
|
45
45
|
@payload_broadcasts: Hash[Symbol, Handler]
|
|
46
46
|
|
|
47
|
+
@observable_broadcasts: Hash[Symbol, Symbol]
|
|
48
|
+
|
|
47
49
|
@observables: Hash[Symbol, Handler]
|
|
48
50
|
|
|
49
51
|
@queries: Hash[Symbol, Handler]
|
|
@@ -82,8 +84,11 @@ module SolidObjects
|
|
|
82
84
|
# @rbs (Symbol | String, Proc) -> Handler
|
|
83
85
|
def add_query: (Symbol | String, Proc) -> Handler
|
|
84
86
|
|
|
85
|
-
# @rbs (Symbol | String, Proc
|
|
86
|
-
def add_observable: (Symbol | String, Proc
|
|
87
|
+
# @rbs (Symbol | String, Proc?, broadcast: Symbol) -> Handler
|
|
88
|
+
def add_observable: (Symbol | String, Proc?, broadcast: Symbol) -> Handler
|
|
89
|
+
|
|
90
|
+
# @rbs (Symbol | String) -> bool
|
|
91
|
+
def broadcasts_observable_value?: (Symbol | String) -> bool
|
|
87
92
|
|
|
88
93
|
# @rbs (Symbol | String, Proc) -> Handler
|
|
89
94
|
def add_payload_broadcast: (Symbol | String, Proc) -> Handler
|
|
@@ -109,6 +114,8 @@ module SolidObjects
|
|
|
109
114
|
|
|
110
115
|
attr_reader method_messages: untyped
|
|
111
116
|
|
|
117
|
+
attr_reader observable_broadcasts: untyped
|
|
118
|
+
|
|
112
119
|
# @rbs (Symbol) -> Handler
|
|
113
120
|
def add_method_message: (Symbol) -> Handler
|
|
114
121
|
|
|
@@ -13,8 +13,8 @@ module SolidObjects
|
|
|
13
13
|
# @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
|
|
14
14
|
def initialize: (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
|
|
15
15
|
|
|
16
|
-
# @rbs () -> bool
|
|
17
|
-
def run_once: () -> bool
|
|
16
|
+
# @rbs (?now: Time?) -> bool
|
|
17
|
+
def run_once: (?now: Time?) -> bool
|
|
18
18
|
|
|
19
19
|
# @rbs () -> void
|
|
20
20
|
def stop: () -> void
|
|
@@ -37,11 +37,11 @@ module SolidObjects
|
|
|
37
37
|
|
|
38
38
|
attr_reader database_adapter: untyped
|
|
39
39
|
|
|
40
|
-
# @rbs () -> Reminder?
|
|
41
|
-
def claim_next: () -> Reminder?
|
|
40
|
+
# @rbs (now: Time?) -> Reminder?
|
|
41
|
+
def claim_next: (now: Time?) -> Reminder?
|
|
42
42
|
|
|
43
|
-
# @rbs (Reminder) -> MessageReference?
|
|
44
|
-
def enqueue: (Reminder) -> MessageReference?
|
|
43
|
+
# @rbs (Reminder, now: Time?) -> MessageReference?
|
|
44
|
+
def enqueue: (Reminder, now: Time?) -> MessageReference?
|
|
45
45
|
|
|
46
46
|
# @rbs (Reminder) -> void
|
|
47
47
|
def release: (Reminder) -> void
|
|
@@ -51,5 +51,8 @@ module SolidObjects
|
|
|
51
51
|
|
|
52
52
|
# @rbs (Reminder, Time) -> Time
|
|
53
53
|
def next_run_at: (Reminder, Time) -> Time
|
|
54
|
+
|
|
55
|
+
# @rbs (Time?) -> Time?
|
|
56
|
+
def normalize_test_time: (Time?) -> Time?
|
|
54
57
|
end
|
|
55
58
|
end
|
|
@@ -26,6 +26,9 @@ module SolidObjects
|
|
|
26
26
|
# @rbs (?roles: Array[Symbol], ?max_passes: Integer) -> Integer
|
|
27
27
|
def drain_solid_objects: (?roles: Array[Symbol], ?max_passes: Integer) -> Integer
|
|
28
28
|
|
|
29
|
+
# @rbs (now: Time, ?max_reminders: Integer) -> Integer
|
|
30
|
+
def run_due_reminders: (now: Time, ?max_reminders: Integer) -> Integer
|
|
31
|
+
|
|
29
32
|
private
|
|
30
33
|
|
|
31
34
|
# @rbs (Array[Symbol]) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Generated from lib/solid_objects/web/action.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
# One request. A route handler runs inside an instance of this class, so a
|
|
6
|
+
# handler and the template it renders share the same helpers and the same
|
|
7
|
+
# request.
|
|
8
|
+
class Action
|
|
9
|
+
include Helpers
|
|
10
|
+
|
|
11
|
+
@response_status: Integer
|
|
12
|
+
|
|
13
|
+
@layout_rendered: bool
|
|
14
|
+
|
|
15
|
+
@request: untyped
|
|
16
|
+
|
|
17
|
+
@captures: Hash[Symbol, String?]
|
|
18
|
+
|
|
19
|
+
@route: Route
|
|
20
|
+
|
|
21
|
+
@env: Hash[String, untyped]
|
|
22
|
+
|
|
23
|
+
attr_reader env: untyped
|
|
24
|
+
|
|
25
|
+
attr_reader route: untyped
|
|
26
|
+
|
|
27
|
+
attr_reader response_status: untyped
|
|
28
|
+
|
|
29
|
+
# @rbs (env: Hash[String, untyped], route: Route) -> void
|
|
30
|
+
def initialize: (env: Hash[String, untyped], route: Route) -> void
|
|
31
|
+
|
|
32
|
+
# Sets the status a rendered page is served with. `halt` and `redirect`
|
|
33
|
+
# stop the handler, so a page that must render its own body and still
|
|
34
|
+
# report a failure needs this instead.
|
|
35
|
+
# @rbs (Integer) -> void
|
|
36
|
+
def status: (Integer) -> void
|
|
37
|
+
|
|
38
|
+
# @rbs () -> untyped
|
|
39
|
+
def request: () -> untyped
|
|
40
|
+
|
|
41
|
+
# @rbs () -> Hash[untyped, untyped]?
|
|
42
|
+
def session: () -> Hash[untyped, untyped]?
|
|
43
|
+
|
|
44
|
+
# @rbs (Symbol) -> String?
|
|
45
|
+
def route_params: (Symbol) -> String?
|
|
46
|
+
|
|
47
|
+
# @rbs (String) -> untyped
|
|
48
|
+
def url_params: (String) -> untyped
|
|
49
|
+
|
|
50
|
+
# @rbs () -> untyped
|
|
51
|
+
def call: () -> untyped
|
|
52
|
+
|
|
53
|
+
# The layout is rendered once per request. The flag is raised before the
|
|
54
|
+
# page body runs so a partial the body renders returns its own fragment
|
|
55
|
+
# rather than a second whole page. The layout reads the page it wraps
|
|
56
|
+
# from `locals.fetch(:content)`.
|
|
57
|
+
# @rbs (Symbol, ?Hash[Symbol, untyped]) -> String
|
|
58
|
+
def erb: (Symbol, ?Hash[Symbol, untyped]) -> String
|
|
59
|
+
|
|
60
|
+
# @rbs (Integer, ?String) -> void
|
|
61
|
+
def halt: (Integer, ?String) -> void
|
|
62
|
+
|
|
63
|
+
# @rbs (String) -> void
|
|
64
|
+
def redirect: (String) -> void
|
|
65
|
+
|
|
66
|
+
# @rbs (untyped) -> void
|
|
67
|
+
def json: (untyped) -> void
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# `locals` is a local variable of this method, so a template reads it by
|
|
72
|
+
# name, and every helper is reachable because the template runs against
|
|
73
|
+
# this object.
|
|
74
|
+
# @rbs (untyped, Hash[Symbol, untyped]) -> String
|
|
75
|
+
def evaluate: (untyped, Hash[Symbol, untyped]) -> String
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Generated from lib/solid_objects/web/application.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
# The pages. Each route states the administration policy it needs, and
|
|
6
|
+
# `call` asks that policy before the handler runs, so a page cannot read
|
|
7
|
+
# the actor tables on behalf of an unauthorized request.
|
|
8
|
+
class Application
|
|
9
|
+
extend Router
|
|
10
|
+
|
|
11
|
+
SCRIPT_PLACEHOLDER: ::String
|
|
12
|
+
|
|
13
|
+
CONTENT_SECURITY_POLICY: untyped
|
|
14
|
+
|
|
15
|
+
MAILBOX_MEMBERSHIPS: untyped
|
|
16
|
+
|
|
17
|
+
RECENT_LIMIT: ::Integer
|
|
18
|
+
|
|
19
|
+
CHART_TYPE_LIMIT: ::Integer
|
|
20
|
+
|
|
21
|
+
# @rbs (Hash[String, untyped]) -> Array[untyped]
|
|
22
|
+
def call: (Hash[String, untyped]) -> Array[untyped]
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# @rbs (Action, untyped) -> Array[untyped]
|
|
27
|
+
def respond: (Action, untyped) -> Array[untyped]
|
|
28
|
+
|
|
29
|
+
# @rbs (Action) -> bool
|
|
30
|
+
def authorized?: (Action) -> bool
|
|
31
|
+
|
|
32
|
+
# @rbs (Hash[String, untyped]) -> Hash[String, String]
|
|
33
|
+
def page_headers: (Hash[String, untyped]) -> Hash[String, String]
|
|
34
|
+
|
|
35
|
+
# The chart host is named only when one is configured, so a deployment
|
|
36
|
+
# that vendors the library or turns charts off never advertises a third
|
|
37
|
+
# party origin it does not use.
|
|
38
|
+
# @rbs (Hash[String, untyped]) -> String
|
|
39
|
+
def content_security_policy: (Hash[String, untyped]) -> String
|
|
40
|
+
|
|
41
|
+
# The cascade header lets a host application serve its own 404 for a path
|
|
42
|
+
# below the mount that the dashboard does not define.
|
|
43
|
+
# @rbs () -> Array[untyped]
|
|
44
|
+
def not_found: () -> Array[untyped]
|
|
45
|
+
|
|
46
|
+
# @rbs () -> Array[untyped]
|
|
47
|
+
def forbidden: () -> Array[untyped]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Generated from lib/solid_objects/web/csrf_protection.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
# A state changing request must carry the token of the session that asked
|
|
6
|
+
# for the form. The token a form receives is masked with a fresh one-time
|
|
7
|
+
# pad on every request, so the bytes on the wire differ each time and a
|
|
8
|
+
# compression side channel cannot recover the session token.
|
|
9
|
+
class CsrfProtection
|
|
10
|
+
SAFE_METHODS: untyped
|
|
11
|
+
|
|
12
|
+
TOKEN_BYTES: ::Integer
|
|
13
|
+
|
|
14
|
+
MISSING_SESSION: ::String
|
|
15
|
+
|
|
16
|
+
# @rbs (untyped) -> void
|
|
17
|
+
def initialize: (untyped) -> void
|
|
18
|
+
|
|
19
|
+
# @rbs (Hash[String, untyped]) -> Array[untyped]
|
|
20
|
+
def call: (Hash[String, untyped]) -> Array[untyped]
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# @rbs (Hash[String, untyped]) -> bool
|
|
25
|
+
def accept?: (Hash[String, untyped]) -> bool
|
|
26
|
+
|
|
27
|
+
# @rbs (Hash[String, untyped], String?) -> bool
|
|
28
|
+
def valid?: (Hash[String, untyped], String?) -> bool
|
|
29
|
+
|
|
30
|
+
# @rbs (String, String) -> bool
|
|
31
|
+
def matches?: (String, String) -> bool
|
|
32
|
+
|
|
33
|
+
# @rbs (String) -> String
|
|
34
|
+
def mask: (String) -> String
|
|
35
|
+
|
|
36
|
+
# @rbs (String) -> String
|
|
37
|
+
def unmask: (String) -> String
|
|
38
|
+
|
|
39
|
+
# @rbs (String, String) -> String
|
|
40
|
+
def exclusive_or: (String, String) -> String
|
|
41
|
+
|
|
42
|
+
# @rbs (String) -> String
|
|
43
|
+
def encode: (String) -> String
|
|
44
|
+
|
|
45
|
+
# @rbs (String) -> String?
|
|
46
|
+
def decode: (String) -> String?
|
|
47
|
+
|
|
48
|
+
# @rbs (Hash[String, untyped]) -> Hash[untyped, untyped]
|
|
49
|
+
def session!: (Hash[String, untyped]) -> Hash[untyped, untyped]
|
|
50
|
+
|
|
51
|
+
# @rbs () -> Array[untyped]
|
|
52
|
+
def forbidden: () -> Array[untyped]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|