Skip to content

Commit d358449

Browse files
committed
feat: mount an engine transmit ingest route
Add POST /solid_objects/transmit, the engine-mounted ingest that #47 deferred. The route parses the body, authorizes it through the new authorize_transmission policy, and hands it to Transmission.receive with the new transmission_actor_type_resolver configuration. The policy denies by default like the other five, because the ingest skips authorize_message by design. It receives the parsed envelope and the controller as authorization_context, so an application can bind actorType and actorId to the authenticated caller rather than only compare a shared token. An unauthorized envelope gets 403; a permanently unappliable one (malformed, unknown type or operation, oversized, conflicting replay, unparseable body) gets 422, so a sending outbox dead-letters it instead of retrying forever. The controller inherits ActionController::API: the endpoint is token-authenticated machine traffic with no session, which keeps the Brakeman scan warning-free where a null_session forgery setting does not. The install generator template gains the policy with a bearer token example, and the generator test pins six deny-by-default policies. Verified live in a scratch Rails app: 403 without or with a wrong token, 200 with the token, 422 for malformed and unparseable bodies, and the effect worker delivering through the engine route end to end with the configured actor type resolver.
1 parent ef6182e commit d358449

11 files changed

Lines changed: 272 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626
failed delivery, and the receiving side dedups on `transmit:<effectId>`.
2727
A raw `emit "solid-objects.transmit"` with explicit `actorType` and
2828
`actorId` targets a different actor, matching the JS staging surface.
29+
- Mount `POST /solid_objects/transmit` in the engine, an ingest route
30+
behind the new deny-by-default `authorize_transmission` policy. The
31+
policy receives the parsed envelope and the controller, an unauthorized
32+
envelope gets 403, and a permanently unappliable one gets 422, so a
33+
sending outbox dead-letters it instead of retrying forever. The new
34+
`transmission_actor_type_resolver` configuration maps diverged actor
35+
type names for the engine route.
2936

3037
## 0.13.3 - 2026-08-18
3138

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# rbs_inline: enabled
2+
3+
require "action_controller/api"
4+
5+
module SolidObjects
6+
class TransmissionsController < ActionController::API
7+
# @rbs () -> void
8+
def create
9+
envelope = JSON.parse(request.body.read)
10+
return head :forbidden unless authorized_transmission?(envelope)
11+
12+
Transmission.receive(
13+
envelope,
14+
resolve_actor_type: SolidObjects.configuration.transmission_actor_type_resolver
15+
)
16+
head :ok
17+
rescue JSON::ParserError, InvalidTransmission, UnknownActorType, UnknownMessage,
18+
PayloadTooLarge, IdempotencyConflict
19+
head :unprocessable_entity
20+
end
21+
22+
private
23+
24+
# @rbs (untyped) -> bool
25+
def authorized_transmission?(envelope)
26+
SolidObjects.configuration.authorize_transmission.call(
27+
envelope:,
28+
authorization_context: self
29+
)
30+
end
31+
end
32+
end

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# rbs_inline: enabled
22

33
SolidObjects::Engine.routes.draw do
4+
post :transmit, to: "transmissions#create"
45
get :components, to: "components#show"
56
get "components/batch", to: "components#batch"
67
resources :instances, only: %i[index show]

docs/roadmap.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@
8181
undelivered sibling for the actor up to the claimed effect's mailbox
8282
sequence, oldest first, so per-actor order survives a failed delivery.
8383
The wire contract is pinned by golden fixtures in
84-
`compatibility/transmit-envelopes.json`. An engine-mounted ingest route
84+
`compatibility/transmit-envelopes.json`. The engine mounts
85+
`POST /solid_objects/transmit` behind a deny-by-default
86+
`authorize_transmission` policy with a configurable actor type resolver.
87+
Bidirectional replication as a declared surface, with echo suppression,
8588
is not implemented
8689
- A JavaScript suite covering every browser module, run in CI with Node's test
8790
runner and jsdom, plus a browser suite running the same modules against real

docs/transmission.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,36 @@ executor uses. Internal delivery skips `authorize_message` by construction.
114114
The host application must authenticate the request before it calls
115115
`receive`.
116116

117-
## The host application owns HTTP
117+
## The engine route
118118

119-
The gem draws the same boundary here that it draws for realtime transport:
120-
the host owns the route, authentication, and rate limits.
119+
The engine mounts `POST /solid_objects/transmit` (under wherever the host
120+
mounts `SolidObjects::Engine`). It parses the body, authorizes it through
121+
`authorize_transmission`, and passes it to `Transmission.receive` with the
122+
configured `transmission_actor_type_resolver`. The policy denies by
123+
default, because the ingest skips `authorize_message` by design; an
124+
unauthorized envelope gets 403, and an envelope the server can never apply
125+
gets 422:
126+
127+
```ruby
128+
SolidObjects.configure do |configuration|
129+
configuration.authorize_transmission = lambda do |envelope:, authorization_context:|
130+
ActiveSupport::SecurityUtils.secure_compare(
131+
authorization_context.request.headers["Authorization"].to_s,
132+
"Bearer #{Rails.application.credentials.transmit_token}"
133+
)
134+
end
135+
end
136+
```
137+
138+
The policy receives the parsed envelope and the controller as
139+
`authorization_context:`, so it can bind `actorType` and `actorId` to the
140+
authenticated caller, not only check a shared token. Rate limits stay with
141+
the host (Rack::Attack or the proxy), the same boundary the dashboard
142+
draws.
143+
144+
## A hand-rolled route
145+
146+
An application that wants its own controller keeps the same shape:
121147

122148
```ruby
123149
class TransmitController < ApplicationController
@@ -157,6 +183,7 @@ SolidObjects::Transmission.receive(
157183

158184
## Scope
159185

160-
An engine-mounted route with an authentication hook is a possible
161-
follow-up; it stays out because it carries authentication, CSRF, and
162-
rate-limit decisions of its own.
186+
Bidirectional replication as a first-class surface, where two runtimes
187+
declare a replica pair and echo suppression keeps a replayed operation
188+
from transmitting back, is a separate feature. The transmit family gives
189+
it the mechanism; the declaration API does not exist yet.

lib/generators/solid_objects/templates/solid_objects.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@
4949
configuration.authorize_subscription = ->(**) { false }
5050
configuration.authorize_administration = ->(**) { false }
5151

52+
# The engine route POST /solid_objects/transmit ingests transmit envelopes
53+
# from another Solid Objects runtime. It stays denied until its callers are
54+
# authenticated, because the ingest skips authorize_message by design:
55+
#
56+
# configuration.authorize_transmission = lambda do |envelope:, authorization_context:|
57+
# ActiveSupport::SecurityUtils.secure_compare(
58+
# authorization_context.request.headers["Authorization"].to_s,
59+
# "Bearer #{Rails.application.credentials.transmit_token}"
60+
# )
61+
# end
62+
#
63+
# When the sending runtime names actor types differently, map them here:
64+
#
65+
# configuration.transmission_actor_type_resolver = ->(actor_type) { actor_type.sub("browser-", "server-") }
66+
configuration.authorize_transmission = ->(**) { false }
67+
5268
# Configure component_authorization_context to return the authenticated
5369
# principal used for reactive component refreshes.
5470

lib/solid_objects/configuration.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ class Configuration
4747
# @rbs @authorize_destroy: Proc
4848
# @rbs @authorize_subscription: Proc
4949
# @rbs @authorize_administration: Proc
50+
# @rbs @authorize_transmission: Proc
51+
# @rbs @transmission_actor_type_resolver: Proc
5052

5153
attr_accessor :table_name_prefix,
5254
:polling_interval,
@@ -92,7 +94,9 @@ class Configuration
9294
:authorize_query,
9395
:authorize_destroy,
9496
:authorize_subscription,
95-
:authorize_administration
97+
:authorize_administration,
98+
:authorize_transmission,
99+
:transmission_actor_type_resolver
96100

97101
# @rbs @additional_components: Array[untyped]
98102
attr_reader :additional_components
@@ -148,6 +152,8 @@ def initialize
148152
@authorize_destroy = ->(**) { false }
149153
@authorize_subscription = ->(**) { false }
150154
@authorize_administration = ->(**) { false }
155+
@authorize_transmission = ->(**) { false }
156+
@transmission_actor_type_resolver = ->(actor_type) { actor_type }
151157
@additional_components = []
152158
end
153159

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Generated from app/controllers/solid_objects/transmissions_controller.rb with RBS::Inline
2+
3+
module SolidObjects
4+
class TransmissionsController < ActionController::API
5+
# @rbs () -> void
6+
def create: () -> void
7+
8+
private
9+
10+
# @rbs (untyped) -> bool
11+
def authorized_transmission?: (untyped) -> bool
12+
end
13+
end

sig/generated/lib/solid_objects/configuration.rbs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ module SolidObjects
44
class Configuration
55
@table_name_prefix: String
66

7-
@process_alive_threshold: Float
8-
97
@shutdown_timeout: Float
108

119
@supervisor_monitor_interval: Float
@@ -58,6 +56,10 @@ module SolidObjects
5856

5957
@authorize_administration: Proc
6058

59+
@authorize_transmission: Proc
60+
61+
@transmission_actor_type_resolver: Proc
62+
6163
@polling_interval: Float
6264

6365
@idle_polling_interval: Float
@@ -92,6 +94,8 @@ module SolidObjects
9294

9395
@process_heartbeat_interval: Float
9496

97+
@process_alive_threshold: Float
98+
9599
attr_accessor table_name_prefix: untyped
96100

97101
attr_accessor polling_interval: untyped
@@ -182,6 +186,10 @@ module SolidObjects
182186

183187
attr_accessor authorize_administration: untyped
184188

189+
attr_accessor authorize_transmission: untyped
190+
191+
attr_accessor transmission_actor_type_resolver: untyped
192+
185193
# @rbs @additional_components: Array[untyped]
186194
attr_reader additional_components: untyped
187195

test/integration/install_generator_test.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ class InstallGeneratorTest < ActiveSupport::TestCase
2323
assert_includes initializer, "authorization_context[:source] == \"cli\""
2424
assert_includes initializer, "component_authorization_context"
2525
refute_includes initializer, "component_authorization_context = lambda"
26-
assert_equal 5, initializer.scan("= ->(**) { false }").length
26+
assert_includes initializer, "configuration.authorize_transmission = ->(**) { false }"
27+
assert_equal 6, initializer.scan("= ->(**) { false }").length
2728
end
2829
end
2930
end

0 commit comments

Comments
 (0)