Class: Insika::Channels::Web
- Inherits:
-
Object
- Object
- Insika::Channels::Web
- Defined in:
- lib/insika/channels/web.rb
Overview
The web widget — the FIRST Shape A channel, and the adoption claim behind the whole RFC: an adopter pastes one tag into their site and has a working agent, with no backend of their own and no build step.
Shape A means the reply comes back on the request's own stream, so there is
no outbox, no claim and no deliver here — the three routes and a static
asset are the entire surface:
POST /channels/web/sessions mint an opaque session id (the engine's)
POST /channels/web/messages the turn, answered as SSE on this connection
GET /channels/web/asset/widget.js
What makes this channel different from the relay is that it is PUBLIC: the caller is an anonymous browser, so there is no shared secret to check and the money faucet is real. Three things stand in for the missing credential, and all three are refusals, never grants (R2):
· an AGENT allowlist — a visitor addresses the agents the operator published
to the widget, not every agent in the deployment;
· an ORIGIN allowlist — exact match, no wildcards, and no "allow all" value;
· a mandatory chat RATE LIMIT — the channel answers `:disabled`
until one is configured, because a public endpoint with an LLM behind it
and no ceiling is an unmetered bill waiting to happen.
R1/R2 hold: this object translates and refuses, and does nothing else. No Executor, no store, no RubyLLM — the rate-limit probe is injected as a plain callable so the channel never learns what a profile or a settings store is.
Constant Summary collapse
- DEFAULT_ID =
"web"- ASSETS =
The static files this channel serves, by request name. A closed map, not a directory listing:
asset/:ftakes a name from the URL, and anything that resolves a path from user input is a traversal waiting to be found. { "widget.js" => { file: "web/widget.js", content_type: "application/javascript; charset=utf-8" } }.freeze
- ASSET_CACHE_CONTROL =
An unversioned URL cannot be cached for a year — the next release would never reach a browser that already has it. Short max-age + an ETag instead: the common case is a 304 with no body, and an upgrade lands within minutes. (A deliberate narrowing of's "long-cache versioned URL": the install snippet in the RFC has no version in it, so there is nothing to bust.)
"public, max-age=300"
Instance Attribute Summary collapse
-
#id ⇒ Object
readonly
Returns the value of attribute id.
Class Method Summary collapse
- .csv(value) ⇒ Object
-
.from_env(env = ENV, id: DEFAULT_ID, chat_rate_limit: nil) ⇒ Object
The bundled widget as an operator configures it.
-
.limit_resolver(profiles:, settings_store: nil) ⇒ Object
The probe's gate needs, asked exactly the way
EdgeLimiterasks it at turn time: the per-agent override first, the platform default second.
Instance Method Summary collapse
-
#asset(name) ⇒ Object
GET /channels/web/asset/:f -> { content_type:, body:, etag: } | nil.
-
#authenticate(req) ⇒ Object
-> :ok | :unauthorized | :disabled.
-
#cors_headers(origin) ⇒ Object
-> the CORS headers for this request's origin.
-
#frame_for(event) ⇒ Object
Turn Event -> SSE frame | nil.
-
#initialize(origins:, agents:, id: DEFAULT_ID, chat_rate_limit: nil) ⇒ Web
constructor
origins: exact-match origin allowlist (
https://shop.example). -
#mint_session_id ⇒ Object
's hard rule for a public channel: the ENGINE issues the id and the client never proposes one.
-
#parse(_req, body:) ⇒ Object
POST /channels/web/messages — STRING keys in, because this body comes off the public internet and nothing in it should become a symbol.
-
#rate_limited? ⇒ Boolean
Is a chat rate limit configured for EVERY agent the widget publishes? All of them and not just the one being addressed: the check runs before the body is parsed, and "the widget is open for agent A but closed for agent B" is a posture nobody can hold in their head.
Constructor Details
#initialize(origins:, agents:, id: DEFAULT_ID, chat_rate_limit: nil) ⇒ Web
origins: exact-match origin allowlist (https://shop.example). No
wildcard, no "*" — CORS is a browser courtesy and a value
that means "anyone" would only make it look like a control.
agents: agent ids a visitor may address.
chat_rate_limit: callable(agent_id) -> the effective limit, or nil. Absent ⇒
the channel is :disabled, which is the fail-closed
reading of "no ceiling configured".
96 97 98 99 100 101 102 |
# File 'lib/insika/channels/web.rb', line 96 def initialize(origins:, agents:, id: DEFAULT_ID, chat_rate_limit: nil) @id = id.to_s @origins = Array(origins).map(&:to_s) @agents = Array(agents).map(&:to_s) @chat_rate_limit = chat_rate_limit @assets = {} end |
Instance Attribute Details
#id ⇒ Object (readonly)
Returns the value of attribute id.
56 57 58 |
# File 'lib/insika/channels/web.rb', line 56 def id @id end |
Class Method Details
.csv(value) ⇒ Object
87 |
# File 'lib/insika/channels/web.rb', line 87 def self.csv(value) = value.to_s.split(",").map(&:strip).reject(&:empty?) |
.from_env(env = ENV, id: DEFAULT_ID, chat_rate_limit: nil) ⇒ Object
The bundled widget as an operator configures it. BOTH allowlists are the switch: a widget with no origins can be embedded nowhere, and one with no agents can address nothing, so either one missing means the operator has not actually asked for this channel. -> Web | nil.
62 63 64 65 66 67 68 |
# File 'lib/insika/channels/web.rb', line 62 def self.from_env(env = ENV, id: DEFAULT_ID, chat_rate_limit: nil) origins = csv(Insika::EnvSchema.read("INSIKA_WIDGET_ORIGINS", env)) agents = csv(Insika::EnvSchema.read("INSIKA_WIDGET_AGENTS", env)) return nil if origins.empty? || agents.empty? new(origins: origins, agents: agents, id: id, chat_rate_limit: chat_rate_limit) end |
.limit_resolver(profiles:, settings_store: nil) ⇒ Object
The probe's gate needs, asked exactly the way EdgeLimiter asks it at
turn time: the per-agent override first, the platform default second. Built
here so both composition roots wire the gate identically, and returned as a
lambda so the channel itself stays store-free.
Resolved on every check rather than at boot: an operator who removes the limit tomorrow closes the widget, instead of leaving it open because it was configured correctly once.
78 79 80 81 82 83 84 85 |
# File 'lib/insika/channels/web.rb', line 78 def self.limit_resolver(profiles:, settings_store: nil) lambda do |agent_id| limits = profiles&.fetch(agent_id.to_s)&.limits || {} next limits[:chat_rate_limit] if limits.key?(:chat_rate_limit) ((settings_store&.get || {})["edge"] || {})["chat_rate_limit"] end end |
Instance Method Details
#asset(name) ⇒ Object
GET /channels/web/asset/:f -> { content_type:, body:, etag: } | nil. Read once and memoized: the file ships with the gem and cannot change under a running process.
178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/insika/channels/web.rb', line 178 def asset(name) entry = ASSETS[name.to_s] return nil if entry.nil? @assets[name.to_s] ||= begin body = File.read(File.(entry[:file], __dir__)) { content_type: entry[:content_type], body: body, etag: %("#{Digest::SHA256.hexdigest(body)[0, 16]}"), cache_control: ASSET_CACHE_CONTROL } end end |
#authenticate(req) ⇒ Object
-> :ok | :unauthorized | :disabled. Same verdict vocabulary as every other
channel (no Rack triple in lib/), and the same fail-closed default.
A request with NO Origin is not a browser, so there is nothing for CORS to
protect and refusing it would be theatre — curl can always set any origin it
likes. The rate limit is what actually defends this route, which is why it
is checked here and not left to the operator's memory.
111 112 113 114 115 116 117 118 119 |
# File 'lib/insika/channels/web.rb', line 111 def authenticate(req) return :disabled if @agents.empty? return :disabled unless rate_limited? origin = req.get_header("HTTP_ORIGIN").to_s return :ok if origin.empty? allowed_origin?(origin) ? :ok : :unauthorized end |
#cors_headers(origin) ⇒ Object
-> the CORS headers for this request's origin. An origin that is not on the list simply gets none, and the browser refuses the response itself — the engine does not have to pretend that is an authorization decision.
193 194 195 196 197 198 199 200 201 202 |
# File 'lib/insika/channels/web.rb', line 193 def cors_headers(origin) o = origin.to_s return { "vary" => "origin" } unless allowed_origin?(o) { "access-control-allow-origin" => o, "vary" => "origin", "access-control-allow-headers" => "content-type", "access-control-allow-methods" => "POST, OPTIONS", "access-control-max-age" => "600" } end |
#frame_for(event) ⇒ Object
Turn Event -> SSE frame | nil. Four frames, which is the whole widget protocol: what to type, what to say while a tool runs, and how it ended.
:intermediate and :thinking are deliberately absent. :content is the
ANSWER — the model's narration on the way there is internal, and a
widget that rendered it would show the customer the engine thinking out loud.
164 165 166 167 168 169 170 171 172 173 |
# File 'lib/insika/channels/web.rb', line 164 def frame_for(event) case event.type when :content then sse("delta", { delta: event.data[:delta].to_s }) when :tool_call then sse("working", { name: event.data[:name].to_s }) when :task_completed then sse("done", {}) when :task_failed then sse("error", { message: event.data[:message].to_s }) when :task_cancelled then sse("error", { message: "task cancelled" }) when :error then sse("error", { message: event.data[:message].to_s }) end end |
#mint_session_id ⇒ Object
's hard rule for a public channel: the ENGINE issues the id and the
client never proposes one. A visitor-supplied session id on an anonymous
endpoint is session hijacking by enumeration, so there is no create-on-write
path — POST /messages with an unknown id is a 404, not a new conversation.
156 |
# File 'lib/insika/channels/web.rb', line 156 def mint_session_id = "#{@id}:#{SecureRandom.hex(16)}" |
#parse(_req, body:) ⇒ Object
POST /channels/web/messages — STRING keys in, because this body comes off the public internet and nothing in it should become a symbol.
{ "agent": "support", "session_id": "web:8f3c…", "message": "oi" }
The agent allowlist is checked HERE, which is a refusal and not a grant: the profile still decides what that agent may do.
138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/insika/channels/web.rb', line 138 def parse(_req, body:) body = body.is_a?(Hash) ? body : {} agent = body["agent"].to_s.strip session_id = body["session_id"].to_s.strip = body["message"].to_s raise Insika::ValidationError, "agent is required" if agent.empty? raise Insika::ValidationError, "agent '#{agent}' is not published to the widget" unless @agents.include?(agent) raise Insika::ValidationError, "session_id is required" if session_id.empty? raise Insika::ValidationError, "message is required" if .strip.empty? { agent: agent, session_id: session_id, message: } end |
#rate_limited? ⇒ Boolean
Is a chat rate limit configured for EVERY agent the widget publishes? All of them and not just the one being addressed: the check runs before the body is parsed, and "the widget is open for agent A but closed for agent B" is a posture nobody can hold in their head.
125 126 127 128 129 |
# File 'lib/insika/channels/web.rb', line 125 def rate_limited? return false if @chat_rate_limit.nil? !@agents.empty? && @agents.all? { |agent| @chat_rate_limit.call(agent).to_i.positive? } end |