solid_objects 0.4.3 → 0.5.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 +9 -0
- data/README.md +54 -7
- data/app/assets/javascripts/solid_objects/component_refresh.js +124 -0
- data/app/controllers/solid_objects/components_controller.rb +2 -7
- data/app/helpers/solid_objects/actor_helper.rb +8 -1
- data/docs/adr/0009-realtime-updates.md +18 -1
- data/docs/architecture.md +34 -18
- data/docs/authorization.md +24 -0
- data/docs/correctness.md +12 -4
- data/docs/realtime.md +78 -13
- data/docs/roadmap.md +6 -5
- data/examples/application/app/views/chat_rooms/show.html.erb +3 -1
- data/lib/solid_objects/actor_view.rb +40 -13
- data/lib/solid_objects/component_registration.rb +61 -16
- data/lib/solid_objects/component_renderer.rb +14 -17
- data/lib/solid_objects/component_subscriptions.rb +7 -7
- data/lib/solid_objects/component_token.rb +70 -2
- data/lib/solid_objects/dom_identity.rb +12 -3
- data/lib/solid_objects/engine.rb +7 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +14 -5
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/actor_view.rbs +10 -4
- data/sig/generated/lib/solid_objects/component_registration.rbs +34 -10
- data/sig/generated/lib/solid_objects/component_renderer.rbs +4 -8
- data/sig/generated/lib/solid_objects/component_token.rbs +24 -2
- data/sig/generated/lib/solid_objects/dom_identity.rbs +5 -2
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +3 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f761acc1e99fba4f747cddd191fc0547ee80c7a30f2e03859f24ff22ab0dd70
|
|
4
|
+
data.tar.gz: 4ed7f4a52926a5276fa04804ce8fbc142a0c4d9b70641b23b27dc07175956b5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c63ed0b033d8028041c51c08f4d0bdb8667ab8560d469907474327bbc1536ea561ce00b062ae38f56fc72ded1e9498ecffc55d77bdc4efeaad2d6ffa8953e25
|
|
7
|
+
data.tar.gz: 9aaa0d27ab02b23c4aa95b05cec82ea4463851f51d2ae9d778058aa93fbaa3a7b6e0c66c3ba3b52efbd3ecefa5079f23bf9d37fe12a9481b0b8b65ecc6b2847b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
- Add repeatable reactive components with signed string or integer keys and
|
|
6
|
+
JSON-compatible partial locals.
|
|
7
|
+
- Add opt-in Turbo morph refreshes with superseded-request cancellation and
|
|
8
|
+
browser-side actor revision fencing.
|
|
9
|
+
- Pass signed component keys and locals through request-time query
|
|
10
|
+
authorization without broadcasting personalized HTML.
|
|
11
|
+
|
|
3
12
|
## 0.4.3 - 2026-08-07
|
|
4
13
|
|
|
5
14
|
- Bound SQLite caller-process registration, reuse, heartbeat, and synchronous
|
data/README.md
CHANGED
|
@@ -205,9 +205,42 @@ dependencies changes:
|
|
|
205
205
|
<% end %>
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
+
Component names can repeat when each instance has a stable key. Signed
|
|
209
|
+
JSON-compatible locals let one conventional partial render the matching
|
|
210
|
+
projection:
|
|
211
|
+
|
|
212
|
+
```erb
|
|
213
|
+
<%= solid_object @room, authorization_context: current_user do |room| %>
|
|
214
|
+
<% @players.each do |player| %>
|
|
215
|
+
<%= room.component :player,
|
|
216
|
+
key: player.id,
|
|
217
|
+
observes: %i[players life_totals],
|
|
218
|
+
locals: { player_id: player.id },
|
|
219
|
+
refresh: :morph %>
|
|
220
|
+
<% end %>
|
|
221
|
+
<% end %>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The host partial still resolves only to `actors/chat_room/_player`. It receives
|
|
225
|
+
`actor`, `authorization_context`, `component_key`, and the declared locals:
|
|
226
|
+
|
|
227
|
+
```erb
|
|
228
|
+
<article id="player_<%= player_id %>">
|
|
229
|
+
Life: <%= actor.life_totals.fetch(player_id.to_s) %>
|
|
230
|
+
</article>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The default refresh strategy is `:replace`. `refresh: :morph` loads the
|
|
234
|
+
authorized component HTML through a gem-owned browser element, rejects stale
|
|
235
|
+
responses by actor revision, and applies the result using Turbo's scoped
|
|
236
|
+
`replace method="morph"`. Superseded requests for the same keyed target are
|
|
237
|
+
aborted. This preserves unchanged DOM nodes where Turbo's morphing rules allow
|
|
238
|
+
it, including focus and `data-turbo-permanent` content.
|
|
239
|
+
|
|
208
240
|
`room.component(:messages)` resolves only
|
|
209
241
|
`actors/chat_room/_messages`. Its partial receives `actor` and
|
|
210
|
-
`authorization_context` locals
|
|
242
|
+
`authorization_context` locals, plus a `component_key` of `nil` when the
|
|
243
|
+
component is unkeyed:
|
|
211
244
|
|
|
212
245
|
```erb
|
|
213
246
|
<ul>
|
|
@@ -221,7 +254,14 @@ Declared observables are deeply frozen ordinary Ruby values inside a
|
|
|
221
254
|
component. Arrays support loops, hashes support ordinary lookup, conditionals
|
|
222
255
|
work normally, and ERB still escapes user strings. A reactive component cannot
|
|
223
256
|
read `actor.state`, access an undeclared observable, or choose a dynamic
|
|
224
|
-
partial path.
|
|
257
|
+
partial path. A component name and key pair must be unique within its
|
|
258
|
+
`solid_object` scope.
|
|
259
|
+
|
|
260
|
+
Component keys and locals are signed into the refresh token and cannot be
|
|
261
|
+
modified without invalidating it, but they are visible to the browser and are
|
|
262
|
+
not secrets. Every initial render and refresh passes the signed locals and
|
|
263
|
+
`component_key` to `authorize_query` as `arguments`. Authorization must still
|
|
264
|
+
bind them to the authenticated request context.
|
|
225
265
|
|
|
226
266
|
That template provides initial server rendering, stable opaque DOM targets,
|
|
227
267
|
and live updates after committed actor turns. One `solid_object` block makes
|
|
@@ -254,20 +294,27 @@ for the same actor without sharing either projection.
|
|
|
254
294
|
Reconnect compares the component's signed initial revision with the latest
|
|
255
295
|
actor incarnation and state revision, then refreshes stale components. Cable
|
|
256
296
|
coalesces several dependency changes from one actor turn into one component
|
|
257
|
-
refresh and ignores older out-of-order invalidations.
|
|
258
|
-
|
|
259
|
-
|
|
297
|
+
refresh and ignores older out-of-order invalidations. Replace refreshes detach
|
|
298
|
+
an older in-flight frame. Morph refreshes abort the older request and compare
|
|
299
|
+
the returned revision with the current target before applying HTML.
|
|
260
300
|
|
|
261
301
|
Reactive components add no HTML to durable rows, but each affected component
|
|
262
302
|
causes an authorized HTTP render. One actor turn still inserts one broadcast
|
|
263
303
|
row per changed observable; several dependencies from that turn coalesce at
|
|
264
304
|
the subscriber. Keep components bounded, declare only necessary dependencies,
|
|
265
|
-
and use scalar observables for inexpensive
|
|
305
|
+
keep signed locals small, and use scalar observables for inexpensive
|
|
306
|
+
single-value replacement. Each keyed component counts toward the 50-component
|
|
307
|
+
subscription limit and carries its own signed token.
|
|
266
308
|
|
|
267
309
|
Reactive views require `turbo-rails` and a working Action Cable adapter in the
|
|
268
310
|
host application. The Solid Objects engine must be mounted so its signed
|
|
269
311
|
component endpoint is reachable. Reactive views are optional; the actor
|
|
270
|
-
runtime itself does not depend on Turbo.
|
|
312
|
+
runtime itself does not depend on Turbo. Morph components automatically include
|
|
313
|
+
the engine's `solid_objects/component_refresh` JavaScript module; the host does
|
|
314
|
+
not need a Stimulus controller or custom stream action. The default Rails
|
|
315
|
+
Propshaft and Sprockets setups discover namespaced engine assets automatically.
|
|
316
|
+
An application created with `--skip-asset-pipeline` should use replace refreshes
|
|
317
|
+
unless it explicitly serves that module.
|
|
271
318
|
|
|
272
319
|
```ruby
|
|
273
320
|
# config/routes.rb
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const activeRefreshes = new Map()
|
|
2
|
+
|
|
3
|
+
class SolidObjectsRefreshElement extends HTMLElement {
|
|
4
|
+
connectedCallback() {
|
|
5
|
+
if (this.dataset.started === "true") return
|
|
6
|
+
|
|
7
|
+
this.dataset.started = "true"
|
|
8
|
+
this.refresh()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
disconnectedCallback() {
|
|
12
|
+
this.refreshController?.abort()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async refresh() {
|
|
16
|
+
const targetName = this.dataset.target
|
|
17
|
+
const source = this.dataset.source
|
|
18
|
+
if (!targetName || !document.getElementById(targetName) || !source) {
|
|
19
|
+
return this.remove()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const previousRefresh = activeRefreshes.get(targetName)
|
|
23
|
+
previousRefresh?.abort()
|
|
24
|
+
|
|
25
|
+
const refresh = new AbortController()
|
|
26
|
+
this.refreshController = refresh
|
|
27
|
+
activeRefreshes.set(targetName, refresh)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const sourceUrl = this.sourceUrl(source)
|
|
31
|
+
const response = await fetch(sourceUrl, {
|
|
32
|
+
credentials: "same-origin",
|
|
33
|
+
headers: {
|
|
34
|
+
Accept: "text/html",
|
|
35
|
+
"Turbo-Frame": targetName
|
|
36
|
+
},
|
|
37
|
+
redirect: "error",
|
|
38
|
+
signal: refresh.signal
|
|
39
|
+
})
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
this.dispatchRefreshError(`http_${response.status}`)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const responseDocument = new DOMParser().parseFromString(
|
|
46
|
+
await response.text(),
|
|
47
|
+
"text/html"
|
|
48
|
+
)
|
|
49
|
+
const replacement = responseDocument.getElementById(targetName)
|
|
50
|
+
if (!replacement || replacement.tagName !== "TURBO-FRAME") {
|
|
51
|
+
this.dispatchRefreshError("missing_frame")
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const currentTarget = document.getElementById(targetName)
|
|
55
|
+
if (!currentTarget || !newerRevision(replacement, currentTarget)) return
|
|
56
|
+
|
|
57
|
+
renderMorph(targetName, replacement)
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error.name !== "AbortError") {
|
|
60
|
+
this.dispatchRefreshError("request_failed")
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
if (activeRefreshes.get(targetName) === refresh) {
|
|
64
|
+
activeRefreshes.delete(targetName)
|
|
65
|
+
}
|
|
66
|
+
this.remove()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
sourceUrl(source) {
|
|
71
|
+
const sourceUrl = new URL(source, window.location.href)
|
|
72
|
+
if (sourceUrl.origin === window.location.origin) return sourceUrl
|
|
73
|
+
|
|
74
|
+
throw new Error("cross_origin_source")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
dispatchRefreshError(reason) {
|
|
78
|
+
this.dispatchEvent(
|
|
79
|
+
new CustomEvent("solid-objects:component-refresh-error", {
|
|
80
|
+
bubbles: true,
|
|
81
|
+
detail: { reason }
|
|
82
|
+
})
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function newerRevision(candidate, current) {
|
|
88
|
+
const candidateRevision = revisionFor(candidate)
|
|
89
|
+
const currentRevision = revisionFor(current)
|
|
90
|
+
if (!candidateRevision || !currentRevision) return false
|
|
91
|
+
|
|
92
|
+
return candidateRevision[0] > currentRevision[0] ||
|
|
93
|
+
(candidateRevision[0] === currentRevision[0] &&
|
|
94
|
+
candidateRevision[1] > currentRevision[1])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function revisionFor(element) {
|
|
98
|
+
const revision = element.dataset.solidObjectsRevision
|
|
99
|
+
if (!revision) return
|
|
100
|
+
|
|
101
|
+
const values = revision.split(":").map(Number)
|
|
102
|
+
if (
|
|
103
|
+
values.length !== 2 ||
|
|
104
|
+
values.some((value) => !Number.isSafeInteger(value) || value < 0)
|
|
105
|
+
) return
|
|
106
|
+
|
|
107
|
+
return values
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderMorph(targetName, replacement) {
|
|
111
|
+
const stream = document.createElement("turbo-stream")
|
|
112
|
+
stream.setAttribute("action", "replace")
|
|
113
|
+
stream.setAttribute("method", "morph")
|
|
114
|
+
stream.setAttribute("target", targetName)
|
|
115
|
+
|
|
116
|
+
const template = document.createElement("template")
|
|
117
|
+
template.content.append(document.importNode(replacement, true))
|
|
118
|
+
stream.append(template)
|
|
119
|
+
document.documentElement.append(stream)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!customElements.get("solid-objects-refresh")) {
|
|
123
|
+
customElements.define("solid-objects-refresh", SolidObjectsRefreshElement)
|
|
124
|
+
}
|
|
@@ -21,8 +21,7 @@ module SolidObjects
|
|
|
21
21
|
.call(controller: self)
|
|
22
22
|
rendered = ComponentRenderer.new(
|
|
23
23
|
snapshot:,
|
|
24
|
-
|
|
25
|
-
dependencies: registration.dependencies,
|
|
24
|
+
registration:,
|
|
26
25
|
view_context: component_view_context,
|
|
27
26
|
authorization_context:
|
|
28
27
|
).call
|
|
@@ -67,12 +66,8 @@ module SolidObjects
|
|
|
67
66
|
|
|
68
67
|
# @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
|
|
69
68
|
def component_frame(registration, snapshot, rendered)
|
|
70
|
-
target = DomIdentity.component(
|
|
71
|
-
registration.reference,
|
|
72
|
-
registration.component_name
|
|
73
|
-
)
|
|
74
69
|
revision = "#{snapshot.instance_id}:#{snapshot.revision}"
|
|
75
|
-
%(<turbo-frame id="#{
|
|
70
|
+
%(<turbo-frame id="#{registration.dom_id}" data-solid-objects-revision="#{revision}" data-solid-objects-refresh="#{registration.refresh_method}">#{rendered}</turbo-frame>).html_safe
|
|
76
71
|
end
|
|
77
72
|
end
|
|
78
73
|
end
|
|
@@ -23,10 +23,17 @@ module SolidObjects
|
|
|
23
23
|
channel: "SolidObjects::ActorChannel",
|
|
24
24
|
data: subscription_data
|
|
25
25
|
)
|
|
26
|
+
refresh_client = if actor.morph_components?
|
|
27
|
+
javascript_include_tag(
|
|
28
|
+
"solid_objects/component_refresh",
|
|
29
|
+
type: "module",
|
|
30
|
+
data: { turbo_track: "reload" }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
26
33
|
|
|
27
34
|
content_tag(
|
|
28
35
|
:div,
|
|
29
|
-
safe_join([ subscription, content ]),
|
|
36
|
+
safe_join([ refresh_client, subscription, content ].compact),
|
|
30
37
|
id: DomIdentity.scope(reference)
|
|
31
38
|
)
|
|
32
39
|
end
|
|
@@ -11,11 +11,28 @@ Action Cable broadcasts are online-only. A transaction can roll back, a broadcas
|
|
|
11
11
|
|
|
12
12
|
The executor evaluates declared observables before and after a successful message. Changed values create broadcast outbox records inside the message commit. A broadcast worker delivers Turbo Stream replacements after commit.
|
|
13
13
|
|
|
14
|
-
One `solid_object` block creates one signed Action Cable subscription and
|
|
14
|
+
One `solid_object` block creates one signed Action Cable subscription and
|
|
15
|
+
contains stable targets for multiple observables and components. Component
|
|
16
|
+
names may repeat behind signed string or integer keys. Small JSON locals,
|
|
17
|
+
dependencies, refresh strategy, and initial revision are signed into each
|
|
18
|
+
component registration.
|
|
19
|
+
|
|
20
|
+
Subscription authorization runs after token verification and before streaming.
|
|
21
|
+
Every initial or request-time component render separately authorizes the
|
|
22
|
+
component name and dependencies with its signed key and locals. Personalized
|
|
23
|
+
HTML is never stored or broadcast.
|
|
24
|
+
|
|
25
|
+
Replace refreshes use Turbo Frames. Optional morph refreshes use a gem-owned
|
|
26
|
+
browser element to fetch the same authorized endpoint, abort superseded
|
|
27
|
+
requests, reject stale revisions, and apply Turbo's scoped morph operation.
|
|
28
|
+
Reconnect refresh reads current actor state; the broadcast stream is an
|
|
29
|
+
optimization, not state.
|
|
15
30
|
|
|
16
31
|
## Consequences
|
|
17
32
|
|
|
18
33
|
- Disconnected clients may miss individual broadcasts but can converge by refresh.
|
|
19
34
|
- Broadcast delivery is at least once and replacements must be idempotent.
|
|
20
35
|
- Actor IDs and signed stream names are identifiers, not authorization.
|
|
36
|
+
- Component keys and locals are visible integrity-protected inputs, not
|
|
37
|
+
secrets or capabilities.
|
|
21
38
|
- Realtime support is optional and loaded only when Action Cable and Turbo are present.
|
data/docs/architecture.md
CHANGED
|
@@ -473,13 +473,20 @@ emits:
|
|
|
473
473
|
- Stable child target IDs for values and components
|
|
474
474
|
- A signed actor token used by the channel subscription
|
|
475
475
|
- Signed component registrations containing a conventional component name,
|
|
476
|
-
|
|
477
|
-
|
|
476
|
+
optional string or integer key, JSON locals, explicit observable
|
|
477
|
+
dependencies, refresh strategy, the initial actor incarnation/revision, and
|
|
478
|
+
a same-origin engine refresh path
|
|
478
479
|
|
|
479
480
|
```erb
|
|
480
|
-
<%= solid_object
|
|
481
|
-
|
|
482
|
-
|
|
481
|
+
<%= solid_object @room do |room| %>
|
|
482
|
+
Present: <%= room.presence %>
|
|
483
|
+
<% @players.each do |player| %>
|
|
484
|
+
<%= room.component :player,
|
|
485
|
+
key: player.id,
|
|
486
|
+
observes: :players,
|
|
487
|
+
locals: { player_id: player.id },
|
|
488
|
+
refresh: :morph %>
|
|
489
|
+
<% end %>
|
|
483
490
|
<% end %>
|
|
484
491
|
```
|
|
485
492
|
|
|
@@ -487,9 +494,11 @@ The signed token proves integrity, not authorization. `ActorChannel#subscribed`
|
|
|
487
494
|
|
|
488
495
|
Scalar observable calls remain direct escaped Turbo replacements. A reactive
|
|
489
496
|
component resolves only `actors/<actor_class>/_<component>`, receives its
|
|
490
|
-
declared observables as frozen Ruby values, and cannot read
|
|
491
|
-
dependency it did not declare.
|
|
492
|
-
|
|
497
|
+
declared observables and signed locals as frozen Ruby values, and cannot read
|
|
498
|
+
raw state or a dependency it did not declare. Repeated component names use a
|
|
499
|
+
keyed digest in the DOM identity, subscription revision map, and duplicate
|
|
500
|
+
check. A static initial-only component can still use a server-selected
|
|
501
|
+
explicit partial; a reactive component cannot.
|
|
493
502
|
|
|
494
503
|
Broadcast replacements happen after the actor transaction commits because only
|
|
495
504
|
a committed broadcast outbox row can be delivered. Multiple scalar values and
|
|
@@ -502,24 +511,29 @@ dependencies do not send their values to the browser, and the stream never
|
|
|
502
511
|
contains personalized component HTML. For each subscription, `ActorChannel`
|
|
503
512
|
matches the changed observable to registered component dependencies. It
|
|
504
513
|
coalesces multiple dependencies at the same message sequence and drops older
|
|
505
|
-
revision pairs
|
|
506
|
-
|
|
514
|
+
revision pairs independently for every component name and key pair. A default
|
|
515
|
+
component invalidation replaces its stable target with a Turbo Frame whose
|
|
516
|
+
source is the signed engine endpoint. A morph invalidation appends a temporary
|
|
517
|
+
gem-owned refresh element carrying the same signed URL.
|
|
507
518
|
|
|
508
519
|
The browser then makes an ordinary cookie-bearing HTTP request. The engine
|
|
509
520
|
controller derives a request-specific context through
|
|
510
521
|
`component_authorization_context`, calls `authorize_query` for the component
|
|
511
|
-
name and every declared dependency
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
Cable or the
|
|
522
|
+
name and every declared dependency with the signed key and locals as
|
|
523
|
+
authorization arguments, renders the host partial from a new committed
|
|
524
|
+
snapshot, and returns `private, no-store` HTML. Subscribers to the same actor
|
|
525
|
+
can therefore receive different HTML without sharing it through Cable or the
|
|
526
|
+
database.
|
|
515
527
|
|
|
516
528
|
Each channel subscription transmits current scalar replacements and compares
|
|
517
529
|
each component's signed initial revision against the latest committed
|
|
518
530
|
`(instance_id, state_revision)` pair, including after reconnect. Missing a
|
|
519
531
|
broadcast therefore creates temporary staleness, not permanent divergence.
|
|
520
532
|
The instance primary key distinguishes destroy-and-recreate incarnations.
|
|
521
|
-
|
|
522
|
-
|
|
533
|
+
Replace refreshes detach an older in-flight frame. Morph refreshes abort a
|
|
534
|
+
superseded fetch for the same target, re-read the current DOM target after the
|
|
535
|
+
response arrives, compare monotonic revision pairs, and apply authorized HTML
|
|
536
|
+
through Turbo's scoped morph operation only when it is newer.
|
|
523
537
|
|
|
524
538
|
## Authorization
|
|
525
539
|
|
|
@@ -540,8 +554,10 @@ No controller, channel, or administrative command treats an actor ID, message ID
|
|
|
540
554
|
|
|
541
555
|
Initial component rendering, Cable subscription, and request-time component
|
|
542
556
|
refresh deliberately use different authorization contexts. Signed component
|
|
543
|
-
tokens constrain actor identity, component convention,
|
|
544
|
-
and same-origin refresh path
|
|
557
|
+
tokens constrain actor identity, component convention, optional key and
|
|
558
|
+
locals, dependencies, refresh method, revision, and same-origin refresh path
|
|
559
|
+
but never grant access. Keys and locals are browser-visible integrity-protected
|
|
560
|
+
inputs, not encrypted capabilities.
|
|
545
561
|
|
|
546
562
|
Actor IDs are bounded UTF-8 strings and never become constant names, SQL identifiers, file paths, or raw stream names.
|
|
547
563
|
|
data/docs/authorization.md
CHANGED
|
@@ -57,6 +57,30 @@ The stream token also signs the scalar observable targets rendered into that
|
|
|
57
57
|
specific scope. Component-only dependencies send invalidation metadata but not
|
|
58
58
|
their state value to the browser.
|
|
59
59
|
|
|
60
|
+
Keyed components sign their `component_key` and declared JSON-compatible
|
|
61
|
+
locals into the component token. Initial rendering and every refresh pass those
|
|
62
|
+
values to `authorize_query` as `arguments`; unkeyed components without locals
|
|
63
|
+
retain an empty arguments hash. This lets a policy authorize a projection such
|
|
64
|
+
as one seat or player:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
configuration.authorize_query = lambda do |actor_type:, actor_id:, message_name:, arguments:, authorization_context:|
|
|
68
|
+
user = authorization_context
|
|
69
|
+
player_id = arguments["player_id"]
|
|
70
|
+
|
|
71
|
+
actor_type == "PlaymatRoom" &&
|
|
72
|
+
user.present? &&
|
|
73
|
+
user.can_view_room?(actor_id) &&
|
|
74
|
+
(player_id.nil? || user.can_view_player?(player_id))
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The values are signed but not encrypted. They are present in server-rendered
|
|
79
|
+
HTML and the Cable subscription identifier, so they must not contain secrets
|
|
80
|
+
or sensitive state. A valid signature proves that the server issued the
|
|
81
|
+
registration; it does not prove the current user may still read it. Always
|
|
82
|
+
reauthorize against the current request context.
|
|
83
|
+
|
|
60
84
|
## A tenant-aware policy
|
|
61
85
|
|
|
62
86
|
Pass the authenticated user as the call context:
|
data/docs/correctness.md
CHANGED
|
@@ -137,10 +137,18 @@ its name and revision over Cable, not its serialized value.
|
|
|
137
137
|
|
|
138
138
|
Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies
|
|
139
139
|
changed by the same turn, and ignores an older pair after a newer one. A new
|
|
140
|
-
invalidation
|
|
141
|
-
the
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
invalidation advances each keyed component registration independently. Replace
|
|
141
|
+
refreshes replace the whole Turbo Frame generation, so a response owned by the
|
|
142
|
+
detached older frame cannot overwrite the current frame. Morph refreshes abort
|
|
143
|
+
a superseded request for the same target and compare the response revision
|
|
144
|
+
with the current DOM revision immediately before applying Turbo's scoped
|
|
145
|
+
morph. Reconnect compares every component's signed initial pair with the
|
|
146
|
+
current instance row and requests the latest committed snapshot when stale.
|
|
147
|
+
|
|
148
|
+
Component keys, JSON locals, dependencies, and refresh strategy are covered by
|
|
149
|
+
the signed registration. Keys and locals are visible to the browser and are
|
|
150
|
+
passed back to `authorize_query` on every render; integrity never substitutes
|
|
151
|
+
for request-specific authorization.
|
|
144
152
|
|
|
145
153
|
## Synchronous invocation
|
|
146
154
|
|
data/docs/realtime.md
CHANGED
|
@@ -28,11 +28,37 @@ never influence partial resolution. The older
|
|
|
28
28
|
`actor.component(:summary, partial: "server/chosen/path")` form remains
|
|
29
29
|
available for initial-only static rendering.
|
|
30
30
|
|
|
31
|
-
The partial receives
|
|
31
|
+
The partial receives these built-in component locals:
|
|
32
32
|
|
|
33
33
|
- `actor`, which exposes the declared observables as deeply frozen ordinary
|
|
34
34
|
Ruby values plus `actor_id` and `reference`; and
|
|
35
35
|
- `authorization_context`, the context for this initial render or refresh.
|
|
36
|
+
- `component_key`, the signed string or integer key, or `nil` for an unkeyed
|
|
37
|
+
component.
|
|
38
|
+
|
|
39
|
+
Applications can declare additional JSON-compatible locals. They are
|
|
40
|
+
normalized, signed into the component token, deeply frozen, and supplied on
|
|
41
|
+
both the initial render and every refresh:
|
|
42
|
+
|
|
43
|
+
```erb
|
|
44
|
+
<% @players.each do |player| %>
|
|
45
|
+
<%= actor.component :player,
|
|
46
|
+
key: player.id,
|
|
47
|
+
observes: %i[players life_totals],
|
|
48
|
+
locals: { player_id: player.id } %>
|
|
49
|
+
<% end %>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This resolves every instance to the same `_player.html.erb` partial while
|
|
53
|
+
giving each one a distinct opaque DOM target. The `(component name, key)` pair
|
|
54
|
+
must be unique within one `solid_object` scope. An unkeyed component retains
|
|
55
|
+
the existing target and uniqueness behavior.
|
|
56
|
+
|
|
57
|
+
Local names must be valid Ruby local identifiers. `actor`,
|
|
58
|
+
`authorization_context`, and `component_key` are reserved. Local values must
|
|
59
|
+
use the same safe JSON value set as actor messages. Tokens are limited to
|
|
60
|
+
16 KiB and one subscription accepts at most 50 components, so locals should be
|
|
61
|
+
small identifiers or rendering options rather than copied actor state.
|
|
36
62
|
|
|
37
63
|
`actor.state` is unavailable in reactive components. Reading an observable not
|
|
38
64
|
listed in `observes:` raises `UnknownComponentDependency`. This keeps
|
|
@@ -51,6 +77,39 @@ Arrays, hashes, loops, conditionals, nested markup, and host helper output are
|
|
|
51
77
|
normal ERB. Escaping remains Action View's responsibility; Solid Objects never
|
|
52
78
|
marks actor strings as HTML safe.
|
|
53
79
|
|
|
80
|
+
## Replace and morph refreshes
|
|
81
|
+
|
|
82
|
+
Reactive components use `refresh: :replace` by default. The existing path
|
|
83
|
+
replaces the target with a Turbo Frame whose signed URL performs the authorized
|
|
84
|
+
request-time render.
|
|
85
|
+
|
|
86
|
+
Use `refresh: :morph` when preserving unchanged DOM nodes matters:
|
|
87
|
+
|
|
88
|
+
```erb
|
|
89
|
+
<%= actor.component :battlefield,
|
|
90
|
+
key: player.id,
|
|
91
|
+
observes: %i[battlefields zone_counts],
|
|
92
|
+
locals: { player_id: player.id },
|
|
93
|
+
refresh: :morph %>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Morph invalidations append a short-lived gem-owned browser element to the
|
|
97
|
+
actor scope. It fetches the same signed component endpoint with normal
|
|
98
|
+
same-origin cookies, aborts an older request for the same keyed target, and
|
|
99
|
+
converts the authorized response into Turbo's scoped
|
|
100
|
+
`replace method="morph"`. Before applying it, the browser compares the
|
|
101
|
+
response's `(instance_id, state_revision)` with the current target. An older
|
|
102
|
+
response cannot overwrite newer HTML.
|
|
103
|
+
|
|
104
|
+
The engine exposes the `solid_objects/component_refresh` module through the
|
|
105
|
+
host asset pipeline and `solid_object` includes it only when the scope contains
|
|
106
|
+
a morph component. No host Stimulus controller, custom channel, custom stream
|
|
107
|
+
action, or polling loop is required. Default Propshaft and Sprockets
|
|
108
|
+
applications discover the namespaced engine asset. Applications created with
|
|
109
|
+
`--skip-asset-pipeline` should keep the default replace strategy unless they
|
|
110
|
+
explicitly serve the module. Turbo's normal morph rules still apply; use
|
|
111
|
+
`data-turbo-permanent` for elements that must never be changed.
|
|
112
|
+
|
|
54
113
|
## Authorization
|
|
55
114
|
|
|
56
115
|
The HTML contains a signed actor identity token. Signing prevents modification;
|
|
@@ -61,9 +120,10 @@ approval.
|
|
|
61
120
|
Initial scalar and component reads call `authorize_query` with the context
|
|
62
121
|
passed to `solid_object`. The refresh controller resolves a new request context
|
|
63
122
|
through `component_authorization_context`, then calls `authorize_query` again
|
|
64
|
-
for the component name and every declared dependency.
|
|
65
|
-
|
|
66
|
-
|
|
123
|
+
for the component name and every declared dependency. Keyed registrations pass
|
|
124
|
+
their `component_key` plus all declared locals as `arguments` at both
|
|
125
|
+
boundaries. The default resolver supplies the engine controller; applications
|
|
126
|
+
commonly resolve it to `Current.user`:
|
|
67
127
|
|
|
68
128
|
```ruby
|
|
69
129
|
configuration.component_authorization_context = ->(controller:) { Current.user }
|
|
@@ -77,8 +137,10 @@ The three contexts are intentionally different:
|
|
|
77
137
|
| Action Cable subscription | The authenticated Cable connection |
|
|
78
138
|
| Component refresh | Value returned by `component_authorization_context` for the engine controller request |
|
|
79
139
|
|
|
80
|
-
Do not substitute a signed token for any of them.
|
|
81
|
-
|
|
140
|
+
Do not substitute a signed token for any of them. Keys and locals are visible
|
|
141
|
+
to the browser and signed for integrity, not encrypted or authorized. Never
|
|
142
|
+
authorize solely from actor ID, token possession, stream name, component name,
|
|
143
|
+
component key, locals, or DOM ID.
|
|
82
144
|
|
|
83
145
|
## Broadcast durability
|
|
84
146
|
|
|
@@ -96,10 +158,11 @@ authorized browser requests affected components through the engine endpoint
|
|
|
96
158
|
with its normal cookies. Responses are `private, no-store`.
|
|
97
159
|
|
|
98
160
|
Several changed dependencies from one message sequence produce one logical
|
|
99
|
-
refresh for
|
|
100
|
-
newer invalidation arrives while a
|
|
101
|
-
frame replaces the old frame element; the detached older
|
|
102
|
-
current target.
|
|
161
|
+
refresh for each keyed component registration. An unrelated observable does
|
|
162
|
+
not refresh it. If a newer invalidation arrives while a replace request is in
|
|
163
|
+
flight, the new frame replaces the old frame element; the detached older
|
|
164
|
+
response has no current target. Morph requests are coalesced per target with an
|
|
165
|
+
`AbortController` and perform a final client-side revision comparison.
|
|
103
166
|
|
|
104
167
|
If Cable delivery is lost, reconnecting `ActorChannel` transmits replacements
|
|
105
168
|
from current actor state. It compares the signed component revision with the
|
|
@@ -111,8 +174,8 @@ truth.
|
|
|
111
174
|
|
|
112
175
|
The component endpoint rejects a requested revision newer than the committed
|
|
113
176
|
snapshot. This is a final server-side guard; browser safety primarily comes
|
|
114
|
-
from monotonic channel filtering
|
|
115
|
-
|
|
177
|
+
from monotonic channel filtering plus replace-frame detachment or morph
|
|
178
|
+
response revision fencing.
|
|
116
179
|
|
|
117
180
|
## Cost model
|
|
118
181
|
|
|
@@ -120,7 +183,9 @@ The durable row cost is unchanged: one broadcast row per changed observable,
|
|
|
120
183
|
containing its JSON value and the message/instance references needed to derive
|
|
121
184
|
invalidation metadata. No rendered document is stored. Each affected component
|
|
122
185
|
adds one authorized GET and one partial render per non-coalesced state
|
|
123
|
-
revision.
|
|
186
|
+
revision. A repeated keyed component adds one GET and render per key. Signed
|
|
187
|
+
locals increase page and Cable subscription bytes but do not create durable
|
|
188
|
+
rows. Scalar observables remain the cheaper path for one text value.
|
|
124
189
|
|
|
125
190
|
## Deployment
|
|
126
191
|
|
data/docs/roadmap.md
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
- Transactional effects with success/failure actor messages
|
|
16
16
|
- Actor-to-actor asynchronous outbox delivery
|
|
17
17
|
- One-shot and recurring reminders with `:latest` or `:all` catch-up
|
|
18
|
-
- Durable observable invalidations, scalar Turbo replacement,
|
|
19
|
-
|
|
18
|
+
- Durable observable invalidations, scalar Turbo replacement, keyed ERB
|
|
19
|
+
components, signed component locals, and authorized replace or morph refresh
|
|
20
20
|
- Reconciliation read APIs
|
|
21
21
|
- Installation doctor, authorization reference, fit guide, and legacy-state
|
|
22
22
|
migration cookbook
|
|
@@ -36,9 +36,10 @@
|
|
|
36
36
|
role or run periodic maintenance automatically.
|
|
37
37
|
- Wake-up strategy: in-process signaling plus durable polling and injection are
|
|
38
38
|
implemented; PostgreSQL `LISTEN/NOTIFY` and optional Redis adapters are not.
|
|
39
|
-
- Realtime: scalar and dependency-driven ERB component replacement
|
|
40
|
-
personalized refresh authorization, revision fencing, coalescing,
|
|
41
|
-
reconnect convergence are implemented; Turbo
|
|
39
|
+
- Realtime: scalar and dependency-driven keyed ERB component replacement or
|
|
40
|
+
morphing, personalized refresh authorization, revision fencing, coalescing,
|
|
41
|
+
and reconnect convergence are implemented; application-directed Turbo
|
|
42
|
+
append intents are not.
|
|
42
43
|
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
|
|
43
44
|
distributed per-actor rate limits and global admission control do not.
|
|
44
45
|
- Administration: actor and dead-letter views plus policy hooks exist; richer
|