openui-rails 0.1.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 +7 -0
- data/AUDIT.md +102 -0
- data/CHANGELOG.md +71 -0
- data/CODE_OF_CONDUCT.md +37 -0
- data/CONFIGURATION.md +79 -0
- data/CONTRIBUTING.md +66 -0
- data/LICENSE +22 -0
- data/README.md +471 -0
- data/RELEASING.md +90 -0
- data/SECURITY.md +48 -0
- data/THIRD_PARTY_NOTICES.md +45 -0
- data/UPGRADING.md +26 -0
- data/app/controllers/openui/rails/actions_controller.rb +77 -0
- data/app/controllers/openui/rails/application_controller.rb +10 -0
- data/app/javascript/openui/rails/openui_controller.js +347 -0
- data/app/javascript/openui/rails/openui_envelope_controller.js +38 -0
- data/config/routes.rb +5 -0
- data/lib/generators/openui/install/install_generator.rb +62 -0
- data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +30 -0
- data/lib/generators/openui/install/templates/config/openui/README.md +24 -0
- data/lib/openui/rails/actions/dispatcher.rb +194 -0
- data/lib/openui/rails/actions/idempotency/memory_store.rb +64 -0
- data/lib/openui/rails/actions/invocation.rb +16 -0
- data/lib/openui/rails/actions/registry.rb +113 -0
- data/lib/openui/rails/actions/schema.rb +115 -0
- data/lib/openui/rails/actions/submission.rb +102 -0
- data/lib/openui/rails/actions.rb +18 -0
- data/lib/openui/rails/adapters/ruby_llm.rb +113 -0
- data/lib/openui/rails/configuration.rb +282 -0
- data/lib/openui/rails/engine.rb +46 -0
- data/lib/openui/rails/errors.rb +41 -0
- data/lib/openui/rails/json_value.rb +33 -0
- data/lib/openui/rails/library_manifest.rb +234 -0
- data/lib/openui/rails/message.rb +139 -0
- data/lib/openui/rails/prompt_builder.rb +115 -0
- data/lib/openui/rails/runtime_asset.rb +65 -0
- data/lib/openui/rails/runtime_package.rb +74 -0
- data/lib/openui/rails/stream/accumulator.rb +86 -0
- data/lib/openui/rails/stream/bridge.rb +193 -0
- data/lib/openui/rails/stream/broadcaster.rb +106 -0
- data/lib/openui/rails/stream/envelope.rb +159 -0
- data/lib/openui/rails/stream/transports/action_cable.rb +50 -0
- data/lib/openui/rails/stream/transports/memory.rb +33 -0
- data/lib/openui/rails/stream/transports/turbo_streams.rb +61 -0
- data/lib/openui/rails/stream/transports.rb +41 -0
- data/lib/openui/rails/surface.rb +150 -0
- data/lib/openui/rails/version.rb +5 -0
- data/lib/openui/rails/view_helper.rb +56 -0
- data/lib/openui/rails.rb +33 -0
- data/lib/tasks/openui.rake +16 -0
- metadata +125 -0
data/README.md
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# openui-rails
|
|
2
|
+
|
|
3
|
+
[](https://github.com/yorzi/openui-rails/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
[Documentation](https://openui-rails.wangyaodi.com/)
|
|
6
|
+
|
|
7
|
+
Render model-generated, streaming, interactive **OpenUI Lang** UI inside an
|
|
8
|
+
existing Rails application — without migrating the whole frontend to a
|
|
9
|
+
React/Next.js SPA and without hand-writing the stream, state and action glue
|
|
10
|
+
for every screen.
|
|
11
|
+
|
|
12
|
+
> **Unofficial integration.** `openui-rails` is not affiliated with, endorsed
|
|
13
|
+
> by, or maintained by Thesys. It reuses the official, version-pinned OpenUI
|
|
14
|
+
> JavaScript runtime (loaded in the browser) and keeps Rails authoritative for
|
|
15
|
+
> authentication, authorization and persistence. It is **not** a Ruby
|
|
16
|
+
> reimplementation of OpenUI Lang — there is no Ruby parser or renderer in this
|
|
17
|
+
> Gem.
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
- **Rails view helper + Stimulus bridge** that mounts the official OpenUI
|
|
22
|
+
renderer against a server-owned surface record.
|
|
23
|
+
- **Versioned library/prompt contract** so the model prompt and the browser
|
|
24
|
+
component library are generated from the *same* library revision, with a
|
|
25
|
+
SHA-256 fingerprint persisted alongside the response.
|
|
26
|
+
- **Ordered streaming** envelopes with snapshot recovery when chunks arrive out
|
|
27
|
+
of order or a client reconnects. Transport-agnostic: broadcast over Turbo
|
|
28
|
+
Streams or Action Cable.
|
|
29
|
+
- **A single, authorized action loop**: component `onAction` and browser
|
|
30
|
+
`toolProvider` `Query()`/`Mutation()` calls both route through one allowlisted
|
|
31
|
+
endpoint with CSRF, tenancy, staleness and idempotency controls. It fails
|
|
32
|
+
closed.
|
|
33
|
+
- **Optional RubyLLM adapter** that owns RubyLLM-specific event normalization.
|
|
34
|
+
|
|
35
|
+
RubyLLM is an optional integration, never a core dependency. Rails remains the
|
|
36
|
+
source of truth for users, tenants and business objects.
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
| Component | Supported |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| Ruby | `>= 3.2` |
|
|
43
|
+
| Rails | `>= 8.0` (`< 9.0`) |
|
|
44
|
+
| OpenUI browser runtime | pinned JS runtime, `@openuidev/browser-bundle` **0.2.1** |
|
|
45
|
+
| OpenUI CLI (prompt generation) | pinned `@openuidev/cli` **0.6.0** |
|
|
46
|
+
| RubyLLM | optional; **2.1.1** verified through its real OpenAI chat-completions SSE parser with mocked HTTP |
|
|
47
|
+
|
|
48
|
+
Unknown provider/version combinations are *unsupported*, not implicitly
|
|
49
|
+
compatible. The JavaScript runtime is always referenced by an exact version;
|
|
50
|
+
there is no `@latest` or floating CDN range.
|
|
51
|
+
|
|
52
|
+
> **Rails 8.0 note.** On the Rails 8.0.x line, use `json < 3`. Rails 8.0's
|
|
53
|
+
> ActiveSupport and integration-test helpers pass a `quirks_mode:` keyword to
|
|
54
|
+
> `JSON.generate` that `json` 3.1 removed; Rails 8.1 no longer does. CI covers
|
|
55
|
+
> both lines (`RAILS_VERSION` in the `Gemfile` pins the matrix).
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# Gemfile
|
|
61
|
+
gem "openui-rails", "~> 0.1.0"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bundle install
|
|
66
|
+
bin/rails generate openui:install
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The generator writes `config/initializers/openui.rb`, creates `config/openui/`
|
|
70
|
+
and mounts the engine:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# config/routes.rb
|
|
74
|
+
mount OpenUI::Rails::Engine => "/openui-rails"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It intentionally does **not** download the runtime or generate the library
|
|
78
|
+
prompt — those are explicit, reproducible steps (below).
|
|
79
|
+
|
|
80
|
+
## Quickstart
|
|
81
|
+
|
|
82
|
+
### 1. Configure
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# config/initializers/openui.rb
|
|
86
|
+
OpenUI::Rails.configure do |config|
|
|
87
|
+
config.transport = :turbo # :turbo, :action_cable or :none
|
|
88
|
+
config.library_spec_path = Rails.root.join("config/openui/library.spec.json")
|
|
89
|
+
config.prompt_path = Rails.root.join("config/openui/library.prompt.txt")
|
|
90
|
+
config.runtime_assets = :self_hosted # :self_hosted or :external
|
|
91
|
+
|
|
92
|
+
# Authz is required and fails closed: with no authorizer an action is denied.
|
|
93
|
+
config.authorize_action = ->(invocation) { invocation.actor.present? }
|
|
94
|
+
|
|
95
|
+
# Resolve a surface id to a record scoped to the actor/tenant. Returning nil
|
|
96
|
+
# is treated as an unknown/inaccessible surface and denies the action.
|
|
97
|
+
config.surface_resolver = ->(surface_id, actor:, **) {
|
|
98
|
+
actor&.surfaces&.find_by(id: surface_id)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# The engine inherits your ApplicationController, including authentication
|
|
102
|
+
# filters. It reads current_user by default; configure this for other auth.
|
|
103
|
+
config.actor_resolver = ->(controller) { controller.current_user }
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 2. Generate the library prompt and spec
|
|
108
|
+
|
|
109
|
+
The default browser bundle contains **only `openuiChatLibrary`**. Its prompt
|
|
110
|
+
and spec must describe that exact library, not an unrelated custom library.
|
|
111
|
+
The loaded bundle exposes `window.__OpenUI.openuiChatLibrary.prompt()` and
|
|
112
|
+
`.toSpec()`. Save those outputs as the configured prompt text and JSON spec.
|
|
113
|
+
The compatibility suite verifies this exact workflow against the pinned bundle.
|
|
114
|
+
|
|
115
|
+
For a host-built custom library, use the pinned CLI and a matching host-built
|
|
116
|
+
runtime that exposes the same `window.__OpenUI` contract. The CLI alone does
|
|
117
|
+
not add components to the default browser bundle:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx --yes @openuidev/cli@0.6.0 generate ./src/library.ts \
|
|
121
|
+
--out config/openui/library.prompt.txt --no-telemetry
|
|
122
|
+
# rename the emitted library.prompt.txt.spec.json to library.spec.json
|
|
123
|
+
# (or point library_spec_path at the generated name) and commit both.
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Set `DO_NOT_TRACK=1` in CI as well as `--no-telemetry`.
|
|
127
|
+
|
|
128
|
+
### 3. Self-host the pinned runtime
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
bin/rails openui:runtime:fetch # vendors the bundle into public/openui (needs npm)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The task checks the archive and exact JS/CSS/license files against committed
|
|
135
|
+
checksums. It disables npm lifecycle scripts and never extracts archive paths
|
|
136
|
+
to disk. The default runtime and stylesheet use pinned SRI hashes.
|
|
137
|
+
|
|
138
|
+
Include the stylesheet once in your layout's `<head>`:
|
|
139
|
+
|
|
140
|
+
```erb
|
|
141
|
+
<%= openui_stylesheet_tag %>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
For external assets, configure both `runtime_url` and `runtime_stylesheet_url`
|
|
145
|
+
with exact-version URLs. Supply their integrity hashes too.
|
|
146
|
+
|
|
147
|
+
### 4. Register the Stimulus controllers
|
|
148
|
+
|
|
149
|
+
The engine adds its `app/javascript` directory to the asset paths, so the
|
|
150
|
+
controllers are available to the asset pipeline. For importmap, add the pins:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# config/importmap.rb
|
|
154
|
+
pin_all_from OpenUI::Rails::Engine.root.join("app/javascript/openui"), under: "openui"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
// app/javascript/controllers/index.js
|
|
159
|
+
import { application } from "controllers/application"
|
|
160
|
+
import OpenUIController from "openui/rails/openui_controller"
|
|
161
|
+
import OpenUIEnvelopeController from "openui/rails/openui_envelope_controller"
|
|
162
|
+
|
|
163
|
+
application.register("openui", OpenUIController)
|
|
164
|
+
application.register("openui-envelope", OpenUIEnvelopeController)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The `openui` controller mounts the renderer; `openui-envelope` is only needed
|
|
168
|
+
for the Turbo Streams transport (below).
|
|
169
|
+
|
|
170
|
+
### 5. Render a surface
|
|
171
|
+
|
|
172
|
+
```erb
|
|
173
|
+
<%# @surface is any object exposing #id (and optionally #version,
|
|
174
|
+
#library_version, #response, #context, #message_id) %>
|
|
175
|
+
<%= openui_surface(surface: @surface, stream: true) %>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Pass `label: "Order details"` to expose the mount as a named `region` landmark.
|
|
179
|
+
A streaming surface carries `aria-busy="true"` until it completes, so assistive
|
|
180
|
+
technology waits for the region to settle instead of announcing each delta.
|
|
181
|
+
|
|
182
|
+
### 6. Register actions
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# config/initializers/openui.rb (or an initializer of your own)
|
|
186
|
+
OpenUI::Rails.actions.register "orders.refresh",
|
|
187
|
+
description: "Re-fetch an order",
|
|
188
|
+
schema: { "orderId" => String },
|
|
189
|
+
authorize: ->(invocation) { OrderPolicy.new(invocation.actor, invocation.args["orderId"]).refresh? },
|
|
190
|
+
handler: ->(invocation) { OrderSerializer.new(Order.find(invocation.args["orderId"])).as_json }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Rendering surfaces
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
openui_surface(surface = nil, stream: false, dom_id: nil, initial_state: nil, label: nil, **options)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`Surface.coerce` accepts a `Surface`, a plain `Hash`, or a host record. It
|
|
200
|
+
emits a flat `data-openui-*` mount element; the Stimulus controller reads it and
|
|
201
|
+
never receives a credential or a server secret. Recognised record methods:
|
|
202
|
+
`id`, `version`, `library_version`, `response`, `context`, `message_id`.
|
|
203
|
+
|
|
204
|
+
When a manifest is configured, the helper rejects an incompatible persisted
|
|
205
|
+
`library_version`. Before mounting, the browser hashes the runtime library's
|
|
206
|
+
`toSpec()` output and compares it with that manifest. A mismatch emits
|
|
207
|
+
`openui:error` with `library_version_mismatch` and does not mount the renderer.
|
|
208
|
+
Without a configured manifest, no library identity check is possible.
|
|
209
|
+
|
|
210
|
+
## Streaming
|
|
211
|
+
|
|
212
|
+
The stream bridge normalizes model output into ordered envelopes and coalesces
|
|
213
|
+
small chunks up to `pending_byte_limit` (default 32 KiB), enforcing
|
|
214
|
+
`stream_byte_limit` (default 1 MiB).
|
|
215
|
+
|
|
216
|
+
Envelope types: `openui.delta`, `openui.snapshot`, `openui.completed`,
|
|
217
|
+
`openui.error`. Every envelope carries `message_id`, `sequence` (from `0`) and
|
|
218
|
+
an optional `library_version`.
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
bridge = OpenUI::Rails::Stream::Bridge.new(
|
|
222
|
+
message_id: surface.id, library_version: config.library_fingerprint
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Pull source (an Enumerable of chunks):
|
|
226
|
+
bridge.envelopes(chunks).each { |envelope| broadcast(envelope) }
|
|
227
|
+
|
|
228
|
+
# Push source (a streaming provider callback):
|
|
229
|
+
writer = bridge.writer
|
|
230
|
+
writer.push(chunk).each { |envelope| broadcast(envelope) }
|
|
231
|
+
writer.finish.each { |envelope| broadcast(envelope) }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The browser controller applies envelopes in order, ignores duplicates, and
|
|
235
|
+
requests a canonical snapshot (`openui:snapshot-required` event) when it detects
|
|
236
|
+
a sequence gap. Deltas pause until a current snapshot arrives. Stale snapshots,
|
|
237
|
+
duplicate terminal frames, malformed frames, and post-completion deltas cannot
|
|
238
|
+
rewind or extend a completed response. The browser also enforces the byte limit.
|
|
239
|
+
|
|
240
|
+
The controller **receives** `openui:envelope` and emits `openui:action`,
|
|
241
|
+
`openui:state`, `openui:error`, and `openui:snapshot-required`. Your host must
|
|
242
|
+
fetch and dispatch the authoritative snapshot on a gap or reconnect. The gem
|
|
243
|
+
does not provide a persistence layer or snapshot HTTP endpoint.
|
|
244
|
+
|
|
245
|
+
### Transports
|
|
246
|
+
|
|
247
|
+
`Stream::Broadcaster` binds the bridge to a transport and broadcasts each
|
|
248
|
+
envelope as it is produced. Delivery is best effort — neither Action Cable nor
|
|
249
|
+
Turbo Streams guarantee order — which is exactly why the client applies
|
|
250
|
+
sequence numbers and recovers from a snapshot on a gap.
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
transport = OpenUI::Rails::Stream::Transports.build(:action_cable, stream_name: "openui:surface:#{surface.id}")
|
|
254
|
+
# or: Transports.build(:turbo, stream: surface, target: "openui-surface-#{surface.id}")
|
|
255
|
+
# or: Transports.build(:memory) # tests/examples
|
|
256
|
+
|
|
257
|
+
broadcaster = OpenUI::Rails::Stream::Broadcaster.new(
|
|
258
|
+
transport: transport, message_id: surface.id, library_version: config.library_fingerprint
|
|
259
|
+
)
|
|
260
|
+
chat.complete { |chunk| broadcaster.push(chunk.content) }
|
|
261
|
+
broadcaster.finish
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- **Action Cable** (`:action_cable`) broadcasts the envelope hash. Subscribe in
|
|
265
|
+
the browser and re-emit it as an event; authorization for the subscription
|
|
266
|
+
stays in your channel:
|
|
267
|
+
|
|
268
|
+
```js
|
|
269
|
+
import { createConsumer } from "@rails/actioncable"
|
|
270
|
+
const consumer = createConsumer()
|
|
271
|
+
consumer.subscriptions.create(
|
|
272
|
+
{ channel: "YourStreamChannel", stream: "openui:surface:42" },
|
|
273
|
+
{ received: (data) =>
|
|
274
|
+
document.dispatchEvent(new CustomEvent("openui:envelope", { bubbles: true, detail: data })) }
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- **Turbo Streams** (`:turbo`) appends an `openui-envelope` element to a target
|
|
279
|
+
already in the DOM. The `openui-envelope` controller re-emits the envelope as
|
|
280
|
+
a bubbling `openui:envelope` event and removes itself — no inline script, no
|
|
281
|
+
CSP relaxation. This transport requires `turbo-rails` in the host; it is
|
|
282
|
+
looked up lazily and never a hard dependency of this Gem.
|
|
283
|
+
|
|
284
|
+
Subscribe with `<%= turbo_stream_from stream %>` and provide a dedicated
|
|
285
|
+
inbox, such as `<div id="openui-inbox" hidden></div>`. Pass that inbox's ID
|
|
286
|
+
as `target:`. Keep the inbox outside the React-owned subtree. Restrict stream
|
|
287
|
+
subscription and signed-stream access to authorized users in your host.
|
|
288
|
+
|
|
289
|
+
- **Memory** (`:memory`) keeps every envelope in memory for tests and examples.
|
|
290
|
+
|
|
291
|
+
The surface controller listens for `openui:envelope` on `document`, so any
|
|
292
|
+
transport that produces a bubbling event (or dispatches on the element) works,
|
|
293
|
+
and envelopes for other `message_id`s are ignored.
|
|
294
|
+
|
|
295
|
+
## The action loop
|
|
296
|
+
|
|
297
|
+
Component `onAction` and browser `toolProvider` `Query()`/`Mutation()` calls
|
|
298
|
+
post the same body to the single endpoint (`/openui-rails/actions` by default):
|
|
299
|
+
|
|
300
|
+
```json
|
|
301
|
+
{ "action": "orders.refresh", "args": {}, "surface_id": "…", "version": 3,
|
|
302
|
+
"idempotency_key": "…", "confirmed": false }
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
The official component event uses `{ type, params }`; the bridge maps it to
|
|
306
|
+
`action` and `args`. A tool receives the handler's `value`, not the endpoint's
|
|
307
|
+
result wrapper. Unknown tool names still fail the server allowlist.
|
|
308
|
+
|
|
309
|
+
`confirmed: true` is a host-supplied acknowledgement, not cryptographic proof
|
|
310
|
+
of human consent. It is never inferred from `params` or form state. Collect
|
|
311
|
+
confirmation in trusted host UI. For high-risk operations, verify a signed,
|
|
312
|
+
server-issued approval in the authorizer as well.
|
|
313
|
+
|
|
314
|
+
The `Actions::Dispatcher` applies every gate **before any side effect**, in
|
|
315
|
+
order:
|
|
316
|
+
|
|
317
|
+
1. submission parse/validate
|
|
318
|
+
2. action is registered (allowlist — no reflection from a name to a method)
|
|
319
|
+
3. args match the schema (required/optional types, extra keys rejected)
|
|
320
|
+
4. explicit `confirmed: true` when the action is destructive
|
|
321
|
+
5. surface revision is current (stale protection)
|
|
322
|
+
6. authorizer allows it (required; fail closed)
|
|
323
|
+
7. idempotency claim (duplicates never run twice, replay with different args is rejected)
|
|
324
|
+
|
|
325
|
+
Idempotency keys are scoped to the server actor and tenant. The request digest
|
|
326
|
+
also binds the action, surface, version, arguments, and confirmation flag.
|
|
327
|
+
See [configuration](CONFIGURATION.md) for the shared-store contract and TTL
|
|
328
|
+
limits. This is not an exactly-once guarantee across expiry or process restarts.
|
|
329
|
+
|
|
330
|
+
Structured errors (JSON `{ error: { code, message } }`):
|
|
331
|
+
|
|
332
|
+
| Code | Status | Meaning |
|
|
333
|
+
| --- | --- | --- |
|
|
334
|
+
| `unknown_action` | 404 | not in the registry |
|
|
335
|
+
| `unauthorized` | 403 | authorizer denied, no authorizer, or unresolvable surface |
|
|
336
|
+
| `stale_surface` | 409 | submitted against a superseded revision |
|
|
337
|
+
| `replay` | 409 | idempotency key reused |
|
|
338
|
+
| `invalid_action` | 422 | malformed payload/args or missing confirmation |
|
|
339
|
+
| `csrf_failed` | 422 | invalid authenticity token |
|
|
340
|
+
|
|
341
|
+
## Message protocol
|
|
342
|
+
|
|
343
|
+
`OpenUI::Rails::Message` implements the upstream `buildMessage`/`parseMessage`
|
|
344
|
+
framing (marker lines beginning with `]]>openui:`). Use it to store a response
|
|
345
|
+
so the browser `Renderer` can read both content and context, and to strip the
|
|
346
|
+
markers before a stored message is sent back to a model:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
stored = OpenUI::Rails::Message.build(content: lang, context: { form: form_state }, finished: true)
|
|
350
|
+
OpenUI::Rails::Message.strip(stored) # => model-safe content, no markers
|
|
351
|
+
OpenUI::Rails::Message.parse(stored).context
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
This is *message framing*, not an OpenUI Lang parser. Client-supplied context is
|
|
355
|
+
untrusted input.
|
|
356
|
+
|
|
357
|
+
## RubyLLM adapter
|
|
358
|
+
|
|
359
|
+
Optional and never required. It owns RubyLLM event normalization and delegates
|
|
360
|
+
sequencing/limits to the stream bridge, so a host keeps RubyLLM's own Rails
|
|
361
|
+
streaming pattern:
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
OpenUI::Rails::Adapters::RubyLLM.stream(
|
|
365
|
+
chat: chat, question: question, surface_id: surface.id
|
|
366
|
+
) { |envelope| broadcast(envelope) }
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`ruby_llm` is isolated under `OpenUI::Rails::Adapters`; failures are classified
|
|
370
|
+
by class *name*, so no `RubyLLM` constant is referenced at load time.
|
|
371
|
+
|
|
372
|
+
## Assets and CSP
|
|
373
|
+
|
|
374
|
+
- The runtime is version-pinned and served from your app (or an exact-version
|
|
375
|
+
URL you configure). Self-hosting is the default.
|
|
376
|
+
- You may pin subresource integrity via `config.runtime_integrity`; the fetch
|
|
377
|
+
task prints the digest to use.
|
|
378
|
+
- To use a strict Content Security Policy, add a nonce/host for the runtime
|
|
379
|
+
script you serve. This bridge adds no inline scripts. The upstream renderer
|
|
380
|
+
does use inline styles and injects a loading `<style>` element. Strict
|
|
381
|
+
`style-src` policies need separate validation; script SRI is not proof of
|
|
382
|
+
strict-CSP compatibility.
|
|
383
|
+
|
|
384
|
+
## Accessibility
|
|
385
|
+
|
|
386
|
+
- A streaming surface is marked `aria-busy="true"` and flips to `"false"` when
|
|
387
|
+
it completes (or errors), so a screen reader waits for the region to settle.
|
|
388
|
+
- Pass `label:` to `openui_surface` to expose the mount as a named `region`
|
|
389
|
+
landmark; without a label no role is added, so unnamed containers are not
|
|
390
|
+
invented.
|
|
391
|
+
- The mount is not `aria-hidden`. Automated checks cover named regions,
|
|
392
|
+
streaming busy states, and real keyboard activation. A human screen-reader
|
|
393
|
+
audit of generated content has not been performed.
|
|
394
|
+
|
|
395
|
+
## Security model
|
|
396
|
+
|
|
397
|
+
Model output is untrusted UI DSL. The controls this Gem is responsible for:
|
|
398
|
+
|
|
399
|
+
- **Allowlist only** — the browser can resolve only components and actions the
|
|
400
|
+
pinned library declares, and only actions registered in the Rails registry.
|
|
401
|
+
- **One action path** — every write passes all seven gates above; no
|
|
402
|
+
model-provided name maps to Ruby reflection.
|
|
403
|
+
- **Fail closed** — no authorizer means denied.
|
|
404
|
+
- **Rails-owned identity** — CSRF protection, session auth and per-surface
|
|
405
|
+
version all come from Rails; surface ids are resolved scoped to actor/tenant.
|
|
406
|
+
- **Untrusted context** — client form state is never treated as a write
|
|
407
|
+
confirmation, and `]]>openui:` markers are stripped before model history.
|
|
408
|
+
- **Pinned supply chain** — exact runtime/CLI versions, optional integrity,
|
|
409
|
+
no production mutable CDN.
|
|
410
|
+
|
|
411
|
+
Report vulnerabilities privately per [`SECURITY.md`](SECURITY.md).
|
|
412
|
+
|
|
413
|
+
## Versioning and rollback
|
|
414
|
+
|
|
415
|
+
- `0.x` releases allow documented breaking changes with migration notes; the
|
|
416
|
+
Ruby API is not frozen. See [`CHANGELOG.md`](CHANGELOG.md).
|
|
417
|
+
- The JS runtime is version-locked, and a response is stored with its
|
|
418
|
+
prompt/library fingerprint, so a mismatched fingerprint is a **visible
|
|
419
|
+
failure** rather than rendering old source against a new schema.
|
|
420
|
+
- Downgrading the Gem is the supported rollback; persisted snapshots are
|
|
421
|
+
readable by previous versions where the schema is compatible, otherwise they
|
|
422
|
+
degrade to a safe read-only/fallback with a diagnostic.
|
|
423
|
+
|
|
424
|
+
## Status
|
|
425
|
+
|
|
426
|
+
Early development. The public API may change between `0.x` releases.
|
|
427
|
+
|
|
428
|
+
The audit fixed defects that the original stub-only tests missed. See
|
|
429
|
+
[AUDIT.md](AUDIT.md) for findings, verification evidence, and publication gates.
|
|
430
|
+
CI runs unit/integration tests, deterministic browser tests, the real pinned
|
|
431
|
+
OpenUI bundle, real Turbo/Action Cable delivery, a RubyLLM 2.1.1 SSE probe,
|
|
432
|
+
dependency advisory checks, and a fresh-host test of the built gem.
|
|
433
|
+
|
|
434
|
+
A live-provider smoke completed through OpenRouter on 2026-10-18; see
|
|
435
|
+
[`AUDIT.md`](AUDIT.md) for its scope and evidence. Releases use the verified
|
|
436
|
+
artifact, RubyGems Trusted Publishing, and the protected GitHub `release`
|
|
437
|
+
environment. See [RubyGems](https://rubygems.org/gems/openui-rails) and
|
|
438
|
+
[GitHub Releases](https://github.com/yorzi/openui-rails/releases) for published
|
|
439
|
+
versions. Hosts that redistribute the fetched browser bundle must review its
|
|
440
|
+
complete transitive notices as described in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
|
441
|
+
|
|
442
|
+
## Development
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
bundle install
|
|
446
|
+
bundle exec bin/rails db:test:prepare
|
|
447
|
+
bundle exec bin/rails test # unit + integration
|
|
448
|
+
bundle exec bin/rails test:system # browser (needs Chrome)
|
|
449
|
+
bundle exec rubocop # rubocop-rails-omakase
|
|
450
|
+
bundle exec gem build openui-rails.gemspec
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
|
454
|
+
|
|
455
|
+
## Documentation
|
|
456
|
+
|
|
457
|
+
- [`CHANGELOG.md`](CHANGELOG.md)
|
|
458
|
+
- [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)
|
|
459
|
+
- [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
|
460
|
+
- [`CONFIGURATION.md`](CONFIGURATION.md)
|
|
461
|
+
- [`UPGRADING.md`](UPGRADING.md)
|
|
462
|
+
- [`AUDIT.md`](AUDIT.md)
|
|
463
|
+
- [`RELEASING.md`](RELEASING.md)
|
|
464
|
+
- [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)
|
|
465
|
+
- [`SECURITY.md`](SECURITY.md)
|
|
466
|
+
|
|
467
|
+
## License
|
|
468
|
+
|
|
469
|
+
Released under the [MIT License](LICENSE). OpenUI and the `@openuidev/*`
|
|
470
|
+
packages are MIT-licensed by Thesys ("Copyright (c) 2011-2024 Thesys Inc.");
|
|
471
|
+
their notices ship in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
data/RELEASING.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Releasing openui-rails
|
|
2
|
+
|
|
3
|
+
The owner authorized the initial `0.1.0` release on 2026-10-18. This is an
|
|
4
|
+
unofficial integration, not a Thesys product. Publish only a tag whose complete
|
|
5
|
+
CI suite and release metadata checks pass.
|
|
6
|
+
|
|
7
|
+
## Publishing configuration
|
|
8
|
+
|
|
9
|
+
The public repository is `yorzi/openui-rails`. Its GitHub `release` environment
|
|
10
|
+
requires approval by `yorzi`, accepts the `main` branch, and disallows
|
|
11
|
+
administrator bypass. Self-review is available for the single maintainer.
|
|
12
|
+
RubyGems trusts workflow `release.yml` and environment `release` through GitHub
|
|
13
|
+
OIDC. No long-lived RubyGems API key is stored in GitHub.
|
|
14
|
+
|
|
15
|
+
The initial pending publisher was registered on 2026-10-18. Pending publishers
|
|
16
|
+
expire if unused; check the RubyGems settings before a delayed first publish.
|
|
17
|
+
The first successful publication creates the gem and activates its publisher.
|
|
18
|
+
|
|
19
|
+
Before each release, review [AUDIT.md](AUDIT.md), the dated changelog section,
|
|
20
|
+
and the complete CI result for the exact commit. Preserve third-party notices.
|
|
21
|
+
The gem excludes the upstream browser bundle; host applications that
|
|
22
|
+
redistribute the fetched bundle must review that exact bundle's complete
|
|
23
|
+
transitive license inventory. See [notices](THIRD_PARTY_NOTICES.md).
|
|
24
|
+
|
|
25
|
+
The live smoke completed on 2026-10-18 through OpenRouter with RubyLLM 2.1.1
|
|
26
|
+
and `openai/gpt-4.1-mini`. It used a synthetic prompt, `:complete` streaming,
|
|
27
|
+
and a 16-token output cap. See [AUDIT.md](AUDIT.md) for the result and limits.
|
|
28
|
+
|
|
29
|
+
## Prepare the candidate
|
|
30
|
+
|
|
31
|
+
1. Set the version in `lib/openui/rails/version.rb`.
|
|
32
|
+
2. Add a dated `## [x.y.z] - YYYY-MM-DD` changelog section.
|
|
33
|
+
Set the same version and `released: true` in `docs/_config.yml`, update
|
|
34
|
+
What's New, release history, and upgrading instructions, then run the docs
|
|
35
|
+
check and build.
|
|
36
|
+
3. Run the full verification suite described in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
37
|
+
4. Run the release metadata check:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
RELEASE_TAG=vx.y.z ruby bin/check-release
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
5. Commit the version and notes.
|
|
44
|
+
6. Create the release tag:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git tag -a vx.y.z -m "Release vx.y.z"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
7. Push that tag:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git push origin vx.y.z
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The tag workflow runs the full reusable CI suite. It then checks release
|
|
57
|
+
metadata and uploads the built gem. **A tag push does not publish to RubyGems.**
|
|
58
|
+
|
|
59
|
+
## Publish after owner approval
|
|
60
|
+
|
|
61
|
+
After all gates pass, start the Release workflow manually:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
gh workflow run release.yml --ref main -f tag=vx.y.z -F publish=true
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The workflow tests the selected tag again. The publish job waits for the
|
|
68
|
+
configured environment reviewers. It downloads that run's verified artifact,
|
|
69
|
+
obtains a short-lived, gem-scoped RubyGems credential through OIDC, and pushes
|
|
70
|
+
the artifact to `https://rubygems.org`. A separate job with GitHub release
|
|
71
|
+
write permission then creates a **draft** GitHub release.
|
|
72
|
+
|
|
73
|
+
The draft includes the verified gem, its SHA-256 checksum, and the dated
|
|
74
|
+
changelog section. Verify the RubyGems version and review that draft before
|
|
75
|
+
making it public.
|
|
76
|
+
`CHANGELOG.md` now contains the dated `0.1.0` notes, and
|
|
77
|
+
`RELEASE_TAG=v0.1.0 ruby bin/check-release` accepts the release metadata.
|
|
78
|
+
|
|
79
|
+
## Recovery
|
|
80
|
+
|
|
81
|
+
Prefer a patch release for a defect. If a published version is unsafe, the owner
|
|
82
|
+
can yank it:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
gem yank openui-rails -v x.y.z
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Record the reason and migration steps. A yank does not undo installations or
|
|
89
|
+
business side effects. Hosts must keep database-level safeguards for irreversible
|
|
90
|
+
actions and must review [UPGRADING.md](UPGRADING.md) before deployment.
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please report vulnerabilities privately to **wangyaodi@gmail.com** rather than
|
|
6
|
+
opening a public issue. Include a description, affected version, and a
|
|
7
|
+
reproduction if possible. We will acknowledge receipt within a few days and
|
|
8
|
+
coordinate a fix and disclosure timeline with you.
|
|
9
|
+
|
|
10
|
+
## Threat model
|
|
11
|
+
|
|
12
|
+
`openui-rails` treats model output as untrusted UI DSL and keeps Rails
|
|
13
|
+
authoritative. The controls this project is responsible for:
|
|
14
|
+
|
|
15
|
+
- **Component allowlist.** The browser runtime resolves its declared library.
|
|
16
|
+
Unknown components report renderer errors, not arbitrary code execution.
|
|
17
|
+
The host must display a fallback when it receives `openui:error`.
|
|
18
|
+
- **Single action path.** Component `onAction` and browser `toolProvider`
|
|
19
|
+
`Query()`/`Mutation()` calls both post to one endpoint and pass the same
|
|
20
|
+
gates: allowlist, schema, confirmation, stale-surface, authorizer and
|
|
21
|
+
idempotency. There is no mapping from a model-provided name to Ruby
|
|
22
|
+
reflection.
|
|
23
|
+
- **Fail closed.** With no authorizer configured, an action is denied.
|
|
24
|
+
- **CSRF and tenancy.** The endpoint relies on Rails' forgery protection and on
|
|
25
|
+
the host resolving a surface id scoped to the actor/tenant; an unresolvable
|
|
26
|
+
surface is treated as unauthorized.
|
|
27
|
+
- **Untrusted context.** Client form state does not confer authorization.
|
|
28
|
+
The host must call `Message.strip` before it sends stored framed content to a
|
|
29
|
+
model. `confirmed: true` is an acknowledgement, not signed proof of consent.
|
|
30
|
+
- **Scoped replays.** Actor/tenant identity scopes idempotency keys. The full
|
|
31
|
+
validated request binds a replay. TTL expiry and process restarts remain host
|
|
32
|
+
concerns; irreversible operations need database-level safeguards.
|
|
33
|
+
- **Pinned assets.** The default JS/CSS assets have committed checksum/SRI pins.
|
|
34
|
+
The fetch task disables npm lifecycle scripts and preserves upstream notices.
|
|
35
|
+
Custom runtimes require explicit pins and separate review.
|
|
36
|
+
- **Library identity.** With a configured manifest, the browser checks the actual
|
|
37
|
+
runtime spec before mounting. Without a manifest, that check is unavailable.
|
|
38
|
+
|
|
39
|
+
The browser bundle is trusted third-party page code, not a sandbox. Hosts must
|
|
40
|
+
set request/rate/provider budgets and validate CSP against upstream inline
|
|
41
|
+
styles. See [AUDIT.md](AUDIT.md) for verified scope and remaining release gates.
|
|
42
|
+
|
|
43
|
+
## Out of scope
|
|
44
|
+
|
|
45
|
+
Vulnerabilities in the upstream OpenUI runtime or the `@openuidev/*` packages
|
|
46
|
+
should be reported to Thesys. Issues in a host application's own authorization
|
|
47
|
+
logic are that application's responsibility, though we welcome reports of ways
|
|
48
|
+
this Gem could make a correct host insecure.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
`openui-rails` is an **unofficial** integration. It is not affiliated with,
|
|
4
|
+
endorsed by, or maintained by Thesys. It does not redistribute the OpenUI
|
|
5
|
+
parser or renderer; it references the official, version-pinned JavaScript
|
|
6
|
+
runtime, which the host application self-hosts (or fetches from an
|
|
7
|
+
exact-version URL it configures).
|
|
8
|
+
|
|
9
|
+
The Gem itself is licensed under the MIT License (see `LICENSE`).
|
|
10
|
+
|
|
11
|
+
## Referenced upstream packages
|
|
12
|
+
|
|
13
|
+
The following `@openuidev/*` packages are published by Thesys under the MIT
|
|
14
|
+
License and are loaded by the browser at runtime (self-hosted by the host app,
|
|
15
|
+
or referenced at an exact pinned version):
|
|
16
|
+
|
|
17
|
+
| Package | Pinned version | License |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| `@openuidev/browser-bundle` | 0.2.1 | MIT |
|
|
20
|
+
| `@openuidev/react-lang` | 1.0.1 | MIT |
|
|
21
|
+
| `@openuidev/lang-core` | 1.0.1 | MIT |
|
|
22
|
+
| `@openuidev/react-ui` | 0.18.1 | MIT |
|
|
23
|
+
| `@openuidev/cli` | 0.6.0 | MIT |
|
|
24
|
+
|
|
25
|
+
The OpenUI repository is licensed MIT: "Copyright (c) 2011-2024 Thesys Inc."
|
|
26
|
+
|
|
27
|
+
## Transitive browser dependencies
|
|
28
|
+
|
|
29
|
+
The official browser bundle inlines dependencies, including React 19. They do
|
|
30
|
+
not all share one license. The pinned bundle's embedded notices identify
|
|
31
|
+
`lucide-react` 0.562.0 as ISC-licensed. React and TanStack identify MIT licenses.
|
|
32
|
+
Their notices must accompany any redistributed bundle.
|
|
33
|
+
|
|
34
|
+
The fetch task preserves the upstream license and the bundle's embedded legal
|
|
35
|
+
comments. These do not establish that all transitive notice obligations have
|
|
36
|
+
been satisfied. The gem package does not include that browser bundle. Before
|
|
37
|
+
redistributing the fetched bundle, the host application's owner must review
|
|
38
|
+
the complete dependency/license inventory for that exact bundle. This document
|
|
39
|
+
does not claim legal clearance.
|
|
40
|
+
|
|
41
|
+
## Naming
|
|
42
|
+
|
|
43
|
+
"OpenUI" is also the name of an unrelated W3C Community Group. `openui-rails`
|
|
44
|
+
is not connected to that group either. No trademark or official-affiliation
|
|
45
|
+
permission is claimed or implied.
|
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Pre-release audit changes
|
|
2
|
+
|
|
3
|
+
These changes apply before the first published release.
|
|
4
|
+
|
|
5
|
+
- Custom idempotency stores must distinguish `:pending` from completed
|
|
6
|
+
`:replay`, return a claim token, and accept `record(..., claim_token:)`.
|
|
7
|
+
See [CONFIGURATION.md](CONFIGURATION.md).
|
|
8
|
+
- Action arguments are canonical JSON copies with string keys. Submissions,
|
|
9
|
+
surfaces, envelopes, and manifests no longer share mutable caller input.
|
|
10
|
+
- The engine controller inherits the host's `ApplicationController` and
|
|
11
|
+
preserves its authentication filters. Full-stack Rails controllers are
|
|
12
|
+
required. API-only controllers need a host-owned HTML/session/CSRF boundary;
|
|
13
|
+
that integration is not covered by this release.
|
|
14
|
+
- The helper rejects a persisted surface whose library differs from the
|
|
15
|
+
configured manifest. The browser verifies the actual runtime `toSpec()`.
|
|
16
|
+
Custom prompt/spec files require a matching custom runtime, not the default
|
|
17
|
+
chat bundle.
|
|
18
|
+
- Add `<%= openui_stylesheet_tag %>` once in your layout. The runtime fetch task
|
|
19
|
+
now installs checksum-verified JS, CSS, and the upstream license.
|
|
20
|
+
- The RubyLLM adapter no longer passes a question argument to `Chat#complete`.
|
|
21
|
+
It adds the user message first. Both `:complete` and `:ask` work with 2.1.1.
|
|
22
|
+
- Completion and error frames participate in sequence validation. After a gap,
|
|
23
|
+
all deltas pause until the host sends a current snapshot. Duplicate finishes
|
|
24
|
+
and writes after a terminal writer state produce no additional frames.
|
|
25
|
+
- Tag pushes verify and build a release artifact. Publication now requires an
|
|
26
|
+
explicit manual workflow dispatch after the full CI suite succeeds.
|