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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/AUDIT.md +102 -0
  3. data/CHANGELOG.md +71 -0
  4. data/CODE_OF_CONDUCT.md +37 -0
  5. data/CONFIGURATION.md +79 -0
  6. data/CONTRIBUTING.md +66 -0
  7. data/LICENSE +22 -0
  8. data/README.md +471 -0
  9. data/RELEASING.md +90 -0
  10. data/SECURITY.md +48 -0
  11. data/THIRD_PARTY_NOTICES.md +45 -0
  12. data/UPGRADING.md +26 -0
  13. data/app/controllers/openui/rails/actions_controller.rb +77 -0
  14. data/app/controllers/openui/rails/application_controller.rb +10 -0
  15. data/app/javascript/openui/rails/openui_controller.js +347 -0
  16. data/app/javascript/openui/rails/openui_envelope_controller.js +38 -0
  17. data/config/routes.rb +5 -0
  18. data/lib/generators/openui/install/install_generator.rb +62 -0
  19. data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +30 -0
  20. data/lib/generators/openui/install/templates/config/openui/README.md +24 -0
  21. data/lib/openui/rails/actions/dispatcher.rb +194 -0
  22. data/lib/openui/rails/actions/idempotency/memory_store.rb +64 -0
  23. data/lib/openui/rails/actions/invocation.rb +16 -0
  24. data/lib/openui/rails/actions/registry.rb +113 -0
  25. data/lib/openui/rails/actions/schema.rb +115 -0
  26. data/lib/openui/rails/actions/submission.rb +102 -0
  27. data/lib/openui/rails/actions.rb +18 -0
  28. data/lib/openui/rails/adapters/ruby_llm.rb +113 -0
  29. data/lib/openui/rails/configuration.rb +282 -0
  30. data/lib/openui/rails/engine.rb +46 -0
  31. data/lib/openui/rails/errors.rb +41 -0
  32. data/lib/openui/rails/json_value.rb +33 -0
  33. data/lib/openui/rails/library_manifest.rb +234 -0
  34. data/lib/openui/rails/message.rb +139 -0
  35. data/lib/openui/rails/prompt_builder.rb +115 -0
  36. data/lib/openui/rails/runtime_asset.rb +65 -0
  37. data/lib/openui/rails/runtime_package.rb +74 -0
  38. data/lib/openui/rails/stream/accumulator.rb +86 -0
  39. data/lib/openui/rails/stream/bridge.rb +193 -0
  40. data/lib/openui/rails/stream/broadcaster.rb +106 -0
  41. data/lib/openui/rails/stream/envelope.rb +159 -0
  42. data/lib/openui/rails/stream/transports/action_cable.rb +50 -0
  43. data/lib/openui/rails/stream/transports/memory.rb +33 -0
  44. data/lib/openui/rails/stream/transports/turbo_streams.rb +61 -0
  45. data/lib/openui/rails/stream/transports.rb +41 -0
  46. data/lib/openui/rails/surface.rb +150 -0
  47. data/lib/openui/rails/version.rb +5 -0
  48. data/lib/openui/rails/view_helper.rb +56 -0
  49. data/lib/openui/rails.rb +33 -0
  50. data/lib/tasks/openui.rake +16 -0
  51. metadata +125 -0
data/README.md ADDED
@@ -0,0 +1,471 @@
1
+ # openui-rails
2
+
3
+ [![CI](https://github.com/yorzi/openui-rails/actions/workflows/ci.yml/badge.svg)](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.