tep 0.11.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 (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
@@ -0,0 +1,103 @@
1
+ # agentic chat -- the four-battery demo
2
+
3
+ A small chat room exercising every battery in tep's agentic
4
+ story: identity, broadcast, presence, server-rendered HTML
5
+ pushed over WebSocket.
6
+
7
+ ```
8
+ ┌───────────────────────────────────────────────────────────┐
9
+ │ agentic chat you are user:uXsC2g │
10
+ ├──────────────────────────────────────┬────────────────────┤
11
+ │ user:42 hi │ HUMANS (2) │
12
+ │ user:99 hello │ • user:42 │
13
+ │ agent:summarizer-bot/user:42 │ • user:99 │
14
+ │ i'm here -- watching for │ │
15
+ │ things to summarize. │ AGENTS (1) │
16
+ │ │ • user:42 │
17
+ │ [ type message ] [send] │ via summari… │
18
+ │ │ busy: │
19
+ │ [+ summarizer] │ summarizing │
20
+ └──────────────────────────────────────┴────────────────────┘
21
+ ```
22
+
23
+ ## Run
24
+
25
+ ```sh
26
+ bin/tep build examples/agentic_chat/app.rb -o /tmp/agentic_chat
27
+ /tmp/agentic_chat -p 4567
28
+ # open http://127.0.0.1:4567/ in two browsers
29
+ ```
30
+
31
+ Every chat message + agent spawn arrives in the other tab in
32
+ <100ms via a WebSocket push. No polling, no full-page reload --
33
+ the server re-renders the `#messages` + `#presence` regions on
34
+ every change and broadcasts both to all subscribed sockets.
35
+ Click **+ summarizer** to invite a synthetic agent into the
36
+ room -- it appears in the presence sidebar with `kind=agent_for`,
37
+ shares the inviter's `principal_id`, and posts an arrival
38
+ message.
39
+
40
+ ## What's wired
41
+
42
+ | Battery | What it does here |
43
+ |---|---|
44
+ | `Tep::Auth` (`Tep::AuthSessionCookie`) | Every visitor's first request auto-creates an `identity` cookie. Subsequent requests land with `req.identity` populated; `req.identity.subject` is rendered in the header + drives presence rows. |
45
+ | `Tep::AuthOAuth2`-style delegation | The `+ summarizer` route constructs a `Tep::AgentDelegation` + `Tep::Identity` with `kind=:agent_for, origin=:oauth_grant` -- same shape an external bot would receive over the real OAuth flow. |
46
+ | `Tep::Broadcast` | Every `POST /chat/send` and `POST /agent/add` calls `publish_room`, which builds the updated `#messages` + `#presence` HTML once and publishes a single TEXT frame to every WS subscriber via `Tep::Broadcast.publish`. |
47
+ | `Tep::Presence` | Humans tracked on every `GET /chat` (one row per principal_id). Agents tracked on `+ summarizer` with `status_state=:busy, status_note="summarizing the room"`. Sidebar renders both groups with the agentic kind + status. |
48
+ | `Tep::LiveView` | `CHAT.render` + `render_presence` are the live-view content targets. The WS push delivers the new HTML; client-side JS does `outerHTML = ...` on both regions in place. The same render functions run for the initial page load and for every push -- no special template-vs-push divergence. |
49
+
50
+ ## Wire shape
51
+
52
+ ```
53
+ client server
54
+ | GET /chat |
55
+ |<------------------------------| full page (HTML + JS)
56
+ | |
57
+ | GET /chat/ws (Upgrade) |
58
+ |------------------------------>| websocket "/chat/ws" do |ws|
59
+ |<------------------------------| on_open -> subscribe_ws
60
+ | 101 Switching |
61
+ | |
62
+ | POST /chat/send {body:"hi"} |
63
+ |------------------------------>| CHAT.add + publish_room
64
+ |<------------------------------| 204 No Content
65
+ | |
66
+ |<------------------------------| WS TEXT frame:
67
+ | "<<TEP>><div id='messages'> | "<<TEP>>{messages}
68
+ | ...<<TEP>><aside id=' | <<TEP>>{presence}"
69
+ | presence'>..." |
70
+ | |
71
+ | JS: e.data.split('<<TEP>>') |
72
+ | -> swap each outerHTML |
73
+ ```
74
+
75
+ `<<TEP>>` is a sentinel separator (ASCII, unlikely to appear in
76
+ user text). The server packs both regions into one frame so
77
+ subscribers see them update atomically.
78
+
79
+ ## Code size
80
+
81
+ | File | LOC |
82
+ |---|---|
83
+ | `app.rb` | ~270 (incl. CSS + JS inline) |
84
+ | `README.md` | this file |
85
+
86
+ No CSS framework, no JS bundler, no DB.
87
+
88
+ ## What this demo does NOT show
89
+
90
+ - **Cross-worker presence/broadcast.** Single worker. Both
91
+ `Tep::Broadcast.enable_pg_backend` and
92
+ `Tep::Presence.enable_pg_mirror` would make this multi-
93
+ worker; left out for demo simplicity.
94
+ - **Real bot in a separate process.** The "+ summarizer"
95
+ button adds a synthetic presence row + posts one message
96
+ in the same process. A real bot would open its own WS with
97
+ the JWT minted by `Tep::AuthOAuth2.exchange_code` and chat
98
+ alongside the humans -- the framework surface is identical.
99
+ - **Per-subscriber rendering.** Every WS subscriber gets the
100
+ same HTML payload. Personalized views (e.g. mention
101
+ highlights for the current user) would need either per-fd
102
+ render filters or a client-side template that consumes a
103
+ structured payload instead of HTML.
@@ -0,0 +1,310 @@
1
+ # Agentic chat -- the four-battery demo, now WS-driven.
2
+ #
3
+ # Exercises all four batteries in tep's agentic story:
4
+ # * Tep::Auth (session-cookie identity)
5
+ # * Tep::Broadcast (in-process pub-sub over WS)
6
+ # * Tep::Presence (who's here, agent-aware, with structured status)
7
+ # * Tep::LiveView (server-rendered HTML pushed over WS on change)
8
+ #
9
+ # Run:
10
+ # bin/tep build examples/agentic_chat/app.rb -o /tmp/agentic_chat
11
+ # /tmp/agentic_chat -p 4567
12
+ # Open http://127.0.0.1:4567/ in two browsers; each message + agent
13
+ # spawn lands in <100ms via WS push, no polling, no reloads.
14
+ require 'sinatra'
15
+
16
+ set :scheduler, :scheduled
17
+
18
+ Tep.session_secret = "demo-only-do-not-use-in-prod-XXXXXXXXXX"
19
+ Tep::Auth.install!
20
+
21
+ CHAT_TOPIC = "agentic_chat:room"
22
+
23
+ # Sentinel for the WS payload's two-region split. ASCII-only,
24
+ # unlikely to appear in any user-typed message.
25
+ TEP_SEP = "<<TEP>>"
26
+
27
+ # ---- shared chat state ----
28
+
29
+ class ChatRoom
30
+ def initialize
31
+ @msg_subjects = [""]
32
+ @msg_subjects.delete_at(0)
33
+ @msg_bodies = [""]
34
+ @msg_bodies.delete_at(0)
35
+ @msg_kinds = [""]
36
+ @msg_kinds.delete_at(0)
37
+ end
38
+
39
+ def add(subject, body, kind)
40
+ @msg_subjects.push(subject)
41
+ @msg_bodies.push(body)
42
+ @msg_kinds.push(kind)
43
+ while @msg_subjects.length > 50
44
+ @msg_subjects.delete_at(0)
45
+ @msg_bodies.delete_at(0)
46
+ @msg_kinds.delete_at(0)
47
+ end
48
+ 0
49
+ end
50
+
51
+ def render
52
+ out = "<div id='messages'>"
53
+ if @msg_subjects.length == 0
54
+ out = out + "<div class='msg empty'>" +
55
+ "<em>no messages yet. say hi using the form below.</em></div>"
56
+ end
57
+ i = 0
58
+ while i < @msg_subjects.length
59
+ out = out + "<div class='msg " + @msg_kinds[i] + "'>" +
60
+ "<span class='who'>" + Tep.h(@msg_subjects[i]) + "</span>" +
61
+ "<span class='body'>" + Tep.h(@msg_bodies[i]) + "</span>" +
62
+ "</div>"
63
+ i += 1
64
+ end
65
+ out + "</div>"
66
+ end
67
+ end
68
+
69
+ CHAT = ChatRoom.new
70
+
71
+ # ---- synthetic agent state ----
72
+
73
+ AGENT_FD_COUNTER = [-9000]
74
+
75
+ def next_agent_fd
76
+ AGENT_FD_COUNTER[0] = AGENT_FD_COUNTER[0] - 1
77
+ AGENT_FD_COUNTER[0]
78
+ end
79
+
80
+ def spawn_agent(principal_id)
81
+ fd = next_agent_fd
82
+ agent_req = Tep::Request.new
83
+ delegation = Tep::AgentDelegation.new(
84
+ "summarizer-bot", Time.now.to_i,
85
+ Time.now.to_i + 3600, :oauth_grant)
86
+ agent_req.identity = Tep::Identity.new(
87
+ principal_id, delegation, [:read, :post_summary])
88
+ Tep::Presence.track(agent_req, CHAT_TOPIC, fd)
89
+ Tep::Presence.set_status(
90
+ CHAT_TOPIC, fd, :busy, "summarizing the room",
91
+ Time.now.to_i + 60)
92
+ CHAT.add(
93
+ "agent:summarizer-bot/" + principal_id,
94
+ "i'm here -- watching for things to summarize.", "agent")
95
+ fd
96
+ end
97
+
98
+ # ---- presence sidebar ----
99
+
100
+ def render_presence
101
+ entries = Tep::Presence.list(CHAT_TOPIC)
102
+ humans = ""
103
+ agents = ""
104
+ hcount = 0
105
+ acount = 0
106
+ i = 0
107
+ while i < entries.length
108
+ e = entries[i]
109
+ row = "<div class='pres-row " + e.kind.to_s + " " +
110
+ e.status_state.to_s + "'>" +
111
+ "<span class='dot'></span>" +
112
+ "<span class='who'>" + Tep.h(e.principal_id) + "</span>"
113
+ if e.kind == :agent_for
114
+ row = row + "<span class='agent-of'>via " +
115
+ Tep.h(e.agent_id) + "</span>"
116
+ end
117
+ if e.status_state != :available
118
+ row = row + "<div class='note'>" + e.status_state.to_s +
119
+ ": " + Tep.h(e.status_note) + "</div>"
120
+ end
121
+ row = row + "</div>"
122
+ if e.kind == :human
123
+ humans = humans + row
124
+ hcount += 1
125
+ else
126
+ agents = agents + row
127
+ acount += 1
128
+ end
129
+ i += 1
130
+ end
131
+ "<aside id='presence'>" +
132
+ "<h3>humans (" + hcount.to_s + ")</h3>" +
133
+ "<div class='group humans'>" + humans + "</div>" +
134
+ "<h3>agents (" + acount.to_s + ")</h3>" +
135
+ "<div class='group agents'>" + agents + "</div>" +
136
+ "</aside>"
137
+ end
138
+
139
+ # Broadcast both regions in one frame. Subscribers' JS splits on
140
+ # TEP_SEP and swaps each outerHTML in place -- no full reload.
141
+ def publish_room
142
+ payload = TEP_SEP + CHAT.render + TEP_SEP + render_presence
143
+ Tep::Broadcast.publish(CHAT_TOPIC, payload)
144
+ 0
145
+ end
146
+
147
+ # ---- routes ----
148
+
149
+ before do
150
+ if req.session.get("identity_sub").length == 0
151
+ pid = Crypto.sp_crypto_random_b64url(4)
152
+ ident = Tep::Identity.new(pid, nil, [:read, :write])
153
+ Tep::AuthSessionCookie.set(req, ident, 0)
154
+ req.identity = ident
155
+ end
156
+ end
157
+
158
+ get '/' do
159
+ res.set_status(302)
160
+ res.headers["Location"] = "/chat"
161
+ ""
162
+ end
163
+
164
+ get '/chat' do
165
+ res.headers["Content-Type"] = "text/html; charset=utf-8"
166
+ # Presence tracking happens at WS upgrade (see the websocket
167
+ # block below) -- gives a true "currently connected" view with
168
+ # auto-cleanup on close instead of synthesizing a per-principal
169
+ # fd at page-render time.
170
+ user_subject = req.identity.subject
171
+ "<!doctype html><html><head>" +
172
+ "<meta charset='utf-8'>" +
173
+ "<title>agentic chat (tep)</title>" +
174
+ "<link rel='stylesheet' href='/agentic_chat/style.css'>" +
175
+ "</head><body>" +
176
+ "<header>" +
177
+ "<span class='title'>agentic chat</span>" +
178
+ "<span class='user'>you are <code>" +
179
+ Tep.h(user_subject) + "</code></span>" +
180
+ "</header>" +
181
+ "<main>" +
182
+ "<section id='room'>" +
183
+ CHAT.render +
184
+ "<form id='compose' onsubmit='return tepSend(event)' method='POST' action='/chat/send'>" +
185
+ "<input name='body' placeholder='message...' autocomplete='off' autofocus>" +
186
+ "<button type='submit'>send</button>" +
187
+ "</form>" +
188
+ "<form id='agent-form' onsubmit='return tepSend(event)' method='POST' action='/agent/add'>" +
189
+ "<button type='submit'>+ summarizer</button>" +
190
+ "</form>" +
191
+ "</section>" +
192
+ render_presence +
193
+ "</main>" +
194
+ "<script>" + agentic_chat_js + "</script>" +
195
+ "</body></html>"
196
+ end
197
+
198
+ post '/chat/send' do
199
+ body = req.params["body"]
200
+ if body.length > 0
201
+ CHAT.add(req.identity.subject, body, "human")
202
+ publish_room
203
+ end
204
+ res.set_status(204)
205
+ ""
206
+ end
207
+
208
+ post '/agent/add' do
209
+ pid = req.identity.principal_id + ""
210
+ spawn_agent(pid)
211
+ publish_room
212
+ res.set_status(204)
213
+ ""
214
+ end
215
+
216
+ get '/agentic_chat/style.css' do
217
+ res.headers["Content-Type"] = "text/css"
218
+ agentic_chat_css
219
+ end
220
+
221
+ websocket "/chat/ws" do |ws|
222
+ on_open do |evt|
223
+ # req is the upgrade-time request, so req.identity is the
224
+ # session-cookie identity of whoever opened the socket.
225
+ Tep::Broadcast.subscribe_ws(CHAT_TOPIC, ws.fd)
226
+ Tep::Presence.track(req, CHAT_TOPIC, ws.fd)
227
+ publish_room
228
+ end
229
+
230
+ # No explicit on_close needed -- Tep::WebSocket::Connection auto-
231
+ # drops every Broadcast subscription AND Presence row keyed on
232
+ # the closed fd. Apps that want to do additional work on close
233
+ # (logging, custom cleanup) still register on_close blocks
234
+ # normally.
235
+ end
236
+
237
+ # ---- inline assets ----
238
+
239
+ def agentic_chat_js
240
+ "var __tepSep = '" + TEP_SEP + "';" +
241
+ "var __tepWs = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/chat/ws');" +
242
+ "__tepWs.onmessage = function(e){" +
243
+ "var parts = e.data.split(__tepSep);" +
244
+ "if (parts[1]) {" +
245
+ "var m = document.getElementById('messages');" +
246
+ "if (m) m.outerHTML = parts[1];" +
247
+ "}" +
248
+ "if (parts[2]) {" +
249
+ "var p = document.getElementById('presence');" +
250
+ "if (p) p.outerHTML = parts[2];" +
251
+ "}" +
252
+ "};" +
253
+ "function tepSend(ev){" +
254
+ "ev.preventDefault();" +
255
+ "var f = ev.target;" +
256
+ "fetch(f.action, {method:f.method, body:new FormData(f)});" +
257
+ "var inp = f.querySelector('input[name=body]');" +
258
+ "if (inp) inp.value='';" +
259
+ "return false;" +
260
+ "}"
261
+ end
262
+
263
+ def agentic_chat_css
264
+ "*{box-sizing:border-box}" +
265
+ "body{margin:0;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;" +
266
+ "background:#f6f7f9;color:#1a1a1a}" +
267
+ "header{display:flex;justify-content:space-between;align-items:center;" +
268
+ "padding:.6rem 1rem;background:#1a1a1a;color:#f6f7f9}" +
269
+ "header .title{font-weight:600}" +
270
+ "header .user code{background:#2a2a2a;padding:.15em .4em;border-radius:3px;" +
271
+ "font-size:.85em;color:#cde}" +
272
+ "main{display:grid;grid-template-columns:1fr 260px;height:calc(100vh - 44px)}" +
273
+ "#room{display:flex;flex-direction:column;background:#fff}" +
274
+ "#messages{flex:1;overflow-y:auto;padding:1rem;display:flex;" +
275
+ "flex-direction:column;gap:.4rem}" +
276
+ ".msg{display:flex;gap:.6rem;padding:.3rem .5rem;border-radius:4px}" +
277
+ ".msg.empty{justify-content:center;color:#888}" +
278
+ ".msg .who{color:#666;font-size:.85em;min-width:11rem;text-align:right;" +
279
+ "flex-shrink:0;font-family:ui-monospace,monospace}" +
280
+ ".msg .body{flex:1}" +
281
+ ".msg.agent{background:#fef8eb}" +
282
+ ".msg.agent .who{color:#a07412}" +
283
+ "#compose{display:flex;gap:.5rem;padding:.7rem;border-top:1px solid #e8e9eb;" +
284
+ "background:#fafbfc}" +
285
+ "#compose input{flex:1;padding:.5rem .7rem;border:1px solid #d0d3d8;" +
286
+ "border-radius:4px;font:inherit;background:#fff}" +
287
+ "#compose input:focus{outline:none;border-color:#3b82f6}" +
288
+ "#compose button{padding:.5rem .9rem;border:1px solid #d0d3d8;background:#1a1a1a;" +
289
+ "color:#fff;border-color:#1a1a1a;border-radius:4px;font:inherit;cursor:pointer}" +
290
+ "#compose button:hover{background:#000}" +
291
+ "#agent-form{padding:.5rem .7rem;background:#fafbfc;border-top:1px solid #e8e9eb}" +
292
+ "#agent-form button{padding:.4rem .8rem;border:1px solid #d0d3d8;background:#fff;" +
293
+ "border-radius:4px;font:inherit;cursor:pointer}" +
294
+ "#agent-form button:hover{background:#f0f1f3}" +
295
+ "#presence{background:#fafbfc;border-left:1px solid #e8e9eb;padding:1rem;" +
296
+ "overflow-y:auto}" +
297
+ "#presence h3{margin:0 0 .5rem;font-size:.7em;text-transform:uppercase;" +
298
+ "letter-spacing:.08em;color:#888;font-weight:600}" +
299
+ "#presence .group{margin-bottom:1.5rem;display:flex;flex-direction:column;gap:.4rem}" +
300
+ ".pres-row{display:flex;align-items:center;gap:.5rem;font-size:.9em;flex-wrap:wrap}" +
301
+ ".pres-row .dot{width:.6rem;height:.6rem;border-radius:50%;background:#22c55e;" +
302
+ "flex-shrink:0}" +
303
+ ".pres-row.busy .dot{background:#eab308}" +
304
+ ".pres-row.blocked .dot{background:#ef4444}" +
305
+ ".pres-row .who{font-family:ui-monospace,monospace;font-size:.85em;color:#1a1a1a}" +
306
+ ".pres-row.agent_for .who{color:#a07412}" +
307
+ ".pres-row .agent-of{font-size:.75em;color:#888;font-family:ui-monospace,monospace}" +
308
+ ".pres-row .note{width:100%;padding-left:1.1rem;font-size:.8em;color:#888;" +
309
+ "font-style:italic}"
310
+ end
@@ -0,0 +1,49 @@
1
+ # api_gateway — a capability-gated API gateway on `Tep::Proxy`
2
+
3
+ The non-streaming sibling of [`examples/llm_gateway`](../llm_gateway).
4
+ Fronts an upstream HTTP API on the buffered (6.1) proxy path and adds
5
+ the three jobs of a gateway in ~30 lines:
6
+
7
+ 1. **Authorization** — `before` short-circuits with `403` unless
8
+ `req.identity.may?(:call_upstream)`. The upstream is never hit for
9
+ a denied request.
10
+ 2. **Credential swap** — for an authorized request, strip the
11
+ client's key and attach the server-side one (`ureq.set_header`).
12
+ 3. **Observability** — `after` logs the call and stamps
13
+ `X-Proxy-Status` / `X-Proxy-Upstream` on the response — **including
14
+ for rejected requests** (`after` runs on the short-circuit path
15
+ too, so the audit log sees denials).
16
+
17
+ Uses the **block-form proxy DSL** (`api.before do … end`), which
18
+ `bin/tep` lowers to a `Tep::Proxy` subclass.
19
+
20
+ ## Run
21
+
22
+ ```sh
23
+ UPSTREAM=https://api.example.com \
24
+ UPSTREAM_KEY=secret \
25
+ GATEWAY_KEY=let-me-in \
26
+ bin/tep build examples/api_gateway/app.rb -o /tmp/ag && /tmp/ag -p 4567
27
+
28
+ curl -i localhost:4567/v1/data # 403, missing capability
29
+ curl -i localhost:4567/v1/data -H 'x-api-key: let-me-in' # forwarded with upstream key
30
+ ```
31
+
32
+ Both responses carry `X-Proxy-Status` / `X-Proxy-Upstream`.
33
+
34
+ ## Notes
35
+
36
+ - The `before do … end` filter granting `:call_upstream` on the
37
+ gateway key is a **stand-in** for the Auth battery — a real app
38
+ installs `Tep::Auth` (bearer JWT / session / OAuth2), which
39
+ populates `req.identity` the same way, so the `may?` gate is
40
+ unchanged.
41
+ - One `Tep::Proxy` instance serves many routes; mount whatever paths
42
+ you proxy.
43
+ - Non-streaming (buffered) — for SSE/streaming upstreams + per-request
44
+ telemetry, see `examples/llm_gateway`.
45
+
46
+ ## See also
47
+
48
+ - [`docs/PROXY-BATTERY.md`](../../docs/PROXY-BATTERY.md) — the battery.
49
+ - [`examples/llm_gateway`](../llm_gateway) — the streaming half of 6.3.
@@ -0,0 +1,66 @@
1
+ # examples/api_gateway -- a capability-gated API gateway on Tep::Proxy.
2
+ #
3
+ # The non-streaming sibling of examples/llm_gateway. Fronts an upstream
4
+ # HTTP API and adds the three things a gateway exists for, on the
5
+ # buffered (6.1) proxy path:
6
+ #
7
+ # 1. Authorization -- gate on req.identity.may?(:call_upstream);
8
+ # reject (403) before the upstream is ever hit.
9
+ # 2. Credential swap -- strip the client's key, attach the server's.
10
+ # 3. Observability -- log + stamp X-Proxy-* headers on the way out,
11
+ # including for rejected requests (audit).
12
+ #
13
+ # Run:
14
+ # UPSTREAM=https://api.example.com UPSTREAM_KEY=secret \
15
+ # GATEWAY_KEY=let-me-in \
16
+ # bin/tep build examples/api_gateway/app.rb -o /tmp/ag && /tmp/ag -p 4567
17
+ #
18
+ # curl -i localhost:4567/v1/data # 403 (no key)
19
+ # curl -i localhost:4567/v1/data -H 'x-api-key: let-me-in' # forwarded
20
+ require 'sinatra'
21
+
22
+ UPSTREAM = ENV["UPSTREAM"] || "http://127.0.0.1:8080"
23
+ UPSTREAM_KEY = ENV["UPSTREAM_KEY"] || ""
24
+ GATEWAY_KEY = ENV["GATEWAY_KEY"] || "let-me-in"
25
+ LOGGER = Tep::Logger.new # stderr; .to_file(path) to redirect
26
+
27
+ # Stand-in for the Auth battery: grant :call_upstream to callers
28
+ # presenting the gateway key. A real app installs Tep::Auth (bearer
29
+ # JWT / session / OAuth2), which populates req.identity the same way.
30
+ before do
31
+ if req.req_headers["x-api-key"] == GATEWAY_KEY
32
+ req.identity = Tep::Identity.new("client:demo", nil, [:call_upstream])
33
+ end
34
+ end
35
+
36
+ api = Tep::Proxy.new(UPSTREAM)
37
+
38
+ # Capability gate + credential swap. Returning true short-circuits --
39
+ # the upstream is never called; res (set here) goes straight back.
40
+ api.before do |req, res, ureq|
41
+ if !req.identity.may?(:call_upstream)
42
+ res.set_status(403)
43
+ res.set_body("{\"error\":\"missing capability: call_upstream\"}")
44
+ true
45
+ else
46
+ if UPSTREAM_KEY.length > 0
47
+ ureq.set_header("Authorization", "Bearer " + UPSTREAM_KEY)
48
+ end
49
+ false
50
+ end
51
+ end
52
+
53
+ # Observability. Runs on the forwarded path AND the short-circuit path
54
+ # (ures.status is 0 when before_forward rejected) -- so the audit log
55
+ # sees denied requests too.
56
+ api.after do |req, ures, res|
57
+ res.headers["X-Proxy-Status"] = ures.status.to_s
58
+ res.headers["X-Proxy-Upstream"] = UPSTREAM
59
+ LOGGER.info("[api_gateway] " + req.verb + " " + req.raw_path +
60
+ " -> " + ures.status.to_s)
61
+ 0
62
+ end
63
+
64
+ # Mount whatever paths you proxy (one instance serves many).
65
+ Tep.get "/v1/data", api
66
+ Tep.post "/v1/submit", api