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,47 @@
1
+ # maidenhead — a tep app that *fully* runs on an external gem (via a Gemfile)
2
+
3
+ This app is built entirely on a real published Ruby gem:
4
+ [`maidenhead` 1.0.1](https://rubygems.org/gems/maidenhead) (MIT), the
5
+ ham-radio [Maidenhead Locator System](https://en.wikipedia.org/wiki/Maidenhead_Locator_System)
6
+ converter. It is **declared in a `Gemfile`** and resolved the proper way —
7
+ not hand-vendored.
8
+
9
+ **Every route exercises the gem, and the entire public API compiles** —
10
+ there is no unsupported-method caveat (contrast the sibling `geohash`
11
+ example, where the gem's hot path works but two helpers need spinel
12
+ features that aren't there yet).
13
+
14
+ ## How a tep app uses a gem (the spinelgems convention)
15
+
16
+ tep apps are ahead-of-time compiled by spinel — there is no runtime gem
17
+ loader, and spinel has no gem load path. The
18
+ [`bundler-spinel`](https://github.com/OriPekelman/spinelgems) plugin
19
+ (`spinel-compat`) bridges that: it reads a `Gemfile.lock`, places each
20
+ gem's source under `vendor/spinel/<name>/`, and generates
21
+ `vendor/spinel/deps.rb` (a `require_relative` per gem, in lock order). The
22
+ app pulls everything in with one line:
23
+
24
+ ```ruby
25
+ require_relative 'vendor/spinel/deps'
26
+ ```
27
+
28
+ `bin/tep build` inlines that `require_relative` **recursively** (deps.rb
29
+ chains to each gem), so the gems become native compiled code in the
30
+ binary. The `Gemfile` + `Gemfile.lock` are the source of truth and are
31
+ committed; `vendor/spinel/` is generated and gitignored.
32
+
33
+ ## Build + run
34
+
35
+ ```sh
36
+ make vendor-examples # spinel-compat vendor -> vendor/spinel/ (needs ../spinelgems)
37
+ bin/tep build examples/maidenhead/app.rb -o /tmp/maidenhead
38
+ /tmp/maidenhead -p 4981 &
39
+
40
+ curl 'http://127.0.0.1:4981/valid?loc=FN31pr' # => true
41
+ curl 'http://127.0.0.1:4981/to_latlon?loc=FN31pr' # => 41.731076,-72.704514
42
+ curl 'http://127.0.0.1:4981/to_grid?lat=40.7128&lon=-74.0060&precision=3' # => FN20xr
43
+ ```
44
+
45
+ Each result is identical to CRuby's `Maidenhead.*`.
46
+ `test/test_maidenhead_example.rb` runs the vendor step, builds this app,
47
+ and checks every route against CRuby's output.
@@ -0,0 +1,46 @@
1
+ require 'sinatra'
2
+
3
+ # A tep app built entirely on a REAL published Ruby gem -- maidenhead
4
+ # 1.0.1 (MIT), the ham-radio grid-locator <-> lat/lon converter --
5
+ # declared in a Gemfile and resolved the proper way: `spinel-compat
6
+ # vendor` (bundler-spinel, from ../spinelgems) reads Gemfile.lock and
7
+ # places the gem under vendor/spinel/ with a generated deps.rb. We pull
8
+ # it in with the ONE require below; bin/tep inlines the require_relative
9
+ # chain into the AOT binary. Nothing here is hand-vendored. Run
10
+ # `make vendor` (or see README.md) before building. Unlike the geohash
11
+ # example, EVERY route exercises the gem and the whole public API
12
+ # compiles -- no unsupported-method caveat.
13
+ require_relative 'vendor/spinel/deps'
14
+
15
+ # GET /valid?loc=FN31pr -> "true" / "false"
16
+ get '/valid' do
17
+ if Maidenhead.valid_maidenhead?(params["loc"])
18
+ "true"
19
+ else
20
+ "false"
21
+ end
22
+ end
23
+
24
+ # GET /to_latlon?loc=FN31pr -> "41.731076,-72.704514"
25
+ get '/to_latlon' do
26
+ r = Maidenhead.to_latlon(params["loc"])
27
+ r[0].to_s + "," + r[1].to_s
28
+ end
29
+
30
+ # GET /to_grid?lat=40.7128&lon=-74.0060&precision=3 -> "FN20xr"
31
+ get '/to_grid' do
32
+ lat = params["lat"].to_f
33
+ lon = params["lon"].to_f
34
+ prec = params["precision"].to_i
35
+ if prec <= 0
36
+ prec = 5
37
+ end
38
+ Maidenhead.to_maidenhead(lat, lon, prec)
39
+ end
40
+
41
+ get '/' do
42
+ "tep + maidenhead 1.0.1 (a real, unmodified published gem)\n" +
43
+ "try: /valid?loc=FN31pr\n" +
44
+ " /to_latlon?loc=FN31pr\n" +
45
+ " /to_grid?lat=40.7128&lon=-74.0060&precision=3\n"
46
+ end
@@ -0,0 +1,76 @@
1
+ # Tep::PG smoke test app -- exercises the v1 PG battery surface.
2
+ #
3
+ # GET / - libpq + server version + a SELECT round-trip
4
+ # GET /tables - list user tables in the public schema
5
+ # GET /error - issues a deliberate SELECT against a missing
6
+ # table, demonstrates the v1 result.ok? check
7
+ #
8
+ # Set PG_URL in the environment (default: postgresql:///postgres,
9
+ # the admin DB on localhost via Unix socket).
10
+ #
11
+ # PG_URL=postgresql://postgres:postgres@127.0.0.1/postgres \
12
+ # ./examples/pg_hello -p 4567
13
+ #
14
+ # v1 returns Results-with-ok? rather than raising on error -- spinel's
15
+ # rescue dispatch can't match module-namespaced exception classes
16
+ # today (matz/spinel#627). Once that lands, this example collapses
17
+ # to the AR-shape `rescue PG::Error => e`.
18
+ require_relative "../lib/tep"
19
+
20
+ PG_URL = ENV["PG_URL"] != nil && ENV["PG_URL"].length > 0 ? ENV["PG_URL"] : "postgresql:///postgres"
21
+
22
+ get '/' do
23
+ c = PG.connect(PG_URL)
24
+ if !c.connected?
25
+ res.set_status(503)
26
+ "PG.connect failed: " + c.last_error_message
27
+ else
28
+ sv = c.server_version
29
+ r = c.exec("SELECT 1 AS one, 'hello' AS greeting")
30
+ body = "libpq " + PG.libpq_version + "\n" +
31
+ "server_version: " + sv.to_s + "\n" +
32
+ "row 0: " + r.getvalue(0, 0) + " / " + r.getvalue(0, 1) + "\n"
33
+ r.clear
34
+ c.close
35
+ res.headers["Content-Type"] = "text/plain"
36
+ body
37
+ end
38
+ end
39
+
40
+ get '/tables' do
41
+ c = PG.connect(PG_URL)
42
+ r = c.exec("SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public' ORDER BY tablename")
43
+ out = "tables (" + r.ntuples.to_s + "):\n"
44
+ i = 0
45
+ n = r.ntuples
46
+ while i < n
47
+ out = out + " " + r.getvalue(i, 0) + "\n"
48
+ i += 1
49
+ end
50
+ r.clear
51
+ c.close
52
+ res.headers["Content-Type"] = "text/plain"
53
+ out
54
+ end
55
+
56
+ # exec raises the SQLSTATE-mapped PG::Error subclass on failure (the
57
+ # ruby-pg / AR shape). Rescue the leaf (PG::UndefinedTable) or the base
58
+ # (PG::Error); the SQLSTATE / message stay on the connection's last_*.
59
+ get '/error' do
60
+ c = PG.connect(PG_URL)
61
+ out = ""
62
+ begin
63
+ r = c.exec("SELECT * FROM tep_no_such_table")
64
+ r.clear
65
+ out = "unexpected: query succeeded"
66
+ rescue PG::UndefinedTable => e
67
+ out = "rescued PG::UndefinedTable\n" +
68
+ "sqlstate: " + c.last_sqlstate + "\n" +
69
+ "is undefined-table? " + (c.last_sqlstate == "42P01" ? "yes" : "no") + "\n" +
70
+ "is PG::Error? " + (e.is_a?(PG::Error) ? "yes" : "no") + "\n" +
71
+ "message: " + e.message
72
+ end
73
+ c.close
74
+ res.headers["Content-Type"] = "text/plain"
75
+ out
76
+ end
@@ -0,0 +1,11 @@
1
+ source "https://rubygems.org"
2
+ ruby "3.3.0", engine: "spinel", engine_version: "0.0.0"
3
+
4
+ # The rejected end of the spectrum. qdrant-ruby is a real, useful gem --
5
+ # but its transport is Faraday and its request bodies are built as
6
+ # incrementally-mutated heterogeneous hashes, neither of which compiles
7
+ # under spinel today. We declare it the same way as the working examples
8
+ # precisely so the spinelgems GATE (`spinel-compat check`) can tell us so,
9
+ # with reasons -- that is the whole point of running gems through
10
+ # spinelgems. See README.md.
11
+ gem "qdrant-ruby", "0.9.10"
@@ -0,0 +1,29 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ faraday (2.14.2)
5
+ faraday-net_http (>= 2.0, < 3.5)
6
+ json
7
+ logger
8
+ faraday-net_http (3.4.3)
9
+ net-http (~> 0.5)
10
+ json (2.19.7)
11
+ logger (1.7.0)
12
+ net-http (0.9.1)
13
+ uri (>= 0.11.1)
14
+ qdrant-ruby (0.9.10)
15
+ faraday (>= 2.0.1, < 3)
16
+ uri (1.1.1)
17
+
18
+ PLATFORMS
19
+ aarch64-linux
20
+ ruby
21
+
22
+ DEPENDENCIES
23
+ qdrant-ruby (= 0.9.10)
24
+
25
+ RUBY VERSION
26
+ ruby 3.4.9p82
27
+
28
+ BUNDLED WITH
29
+ 2.6.9
@@ -0,0 +1,54 @@
1
+ # qdrant — the rejected end of the spectrum (a spinelgems gate experiment)
2
+
3
+ This directory has **no app** — on purpose. It's the counterpart to the
4
+ `geohash` and `maidenhead` examples: a real, useful gem
5
+ ([`qdrant-ruby`](https://rubygems.org/gems/qdrant-ruby)) that **cannot** be
6
+ used from a tep app today, declared in a [`Gemfile`](Gemfile) precisely so
7
+ the spinelgems compatibility **gate** can tell us *why*. Running gems
8
+ through spinelgems is the point; a clean "no" with reasons is a useful
9
+ result.
10
+
11
+ ## The gate verdict
12
+
13
+ ```sh
14
+ bundle lock
15
+ SPINEL_DIR=/spinel ruby -I $SPINELGEMS/lib $SPINELGEMS/exe/spinel-compat check Gemfile.lock
16
+ ```
17
+
18
+ ```
19
+ ✗ faraday 2.14.2 rejected — hard:Mutex.new
20
+ ✗ faraday-net_http 3.4.3 rejected — no-entrypoint
21
+ ✗ json 2.19.7 rejected — c-extension
22
+ ✗ logger 1.7.0 rejected — unresolved:instance_method, …
23
+ ✗ net-http 0.9.1 rejected — no-entrypoint
24
+ ✓ qdrant-ruby 0.9.10 clean
25
+ ✗ uri 1.1.1 rejected — unresolved:…
26
+ ```
27
+
28
+ The gem's *own* source is `clean` — but it's unusable, because its entire
29
+ transitive transport stack is rejected:
30
+
31
+ - **faraday** uses `Mutex.new`, which spinel doesn't support (`hard:`).
32
+ - **json** is a C-extension gem (spinel provides its own `JSON`, but the
33
+ gem's native build can't be compiled in).
34
+ - **net-http / faraday-net_http** have no spinel entrypoint.
35
+ - **uri / logger** lean on dozens of unresolved stdlib methods.
36
+
37
+ So unlike `geohash` (gem reuse is per-*method*), qdrant fails at the
38
+ *dependency* level: there's no subset of entry points that avoids Faraday.
39
+
40
+ ## What we proved separately
41
+
42
+ Even bypassing the gem (raw `Tep::Http` + `JSON.generate`), the Qdrant
43
+ *write* path is blocked by a spinel codegen gap: request bodies are built
44
+ as incrementally-mutated **heterogeneous** hashes (`h["a"]=1; h["b"]="x"`),
45
+ which spinel types homogeneously and fails to compile. A tep-native client
46
+ that builds bodies as *literals* does round-trip against Qdrant Cloud over
47
+ TLS — but that's not "using the gem". See the project memory notes.
48
+
49
+ ## Why this is here
50
+
51
+ A gem catalog is only trustworthy if the "no"s are as legible as the
52
+ "yes"es. `geohash` + `maidenhead` show what works; `qdrant` shows the gate
53
+ catching what doesn't, with actionable reasons. All three go through the
54
+ same Gemfile + `spinel-compat` path.
@@ -0,0 +1,32 @@
1
+ # Sinatra-classic style. Compile with `bin/tep build sinatra_style.rb`.
2
+ # This file is NOT meant to be passed to spinel directly -- the translator
3
+ # rewrites the do/end blocks into Tep::Handler subclasses first.
4
+ require 'sinatra' # ignored by translator; documentation-only
5
+
6
+ set :public_dir, '../public'
7
+
8
+ get '/' do
9
+ "<h1>hello, world</h1>" +
10
+ "<p>This is real Sinatra-style source compiled by spinel via tep.</p>"
11
+ end
12
+
13
+ get '/hi/:name' do
14
+ "<p>hi, " + params[:name] + "!</p>"
15
+ end
16
+
17
+ get '/about' do
18
+ content_type 'text/plain'
19
+ "served as plain text\n"
20
+ end
21
+
22
+ get '/old' do
23
+ redirect '/'
24
+ end
25
+
26
+ before do
27
+ puts "[" + request.verb + "] " + request.path
28
+ end
29
+
30
+ not_found do
31
+ "<h1>oops -- " + request.path + " not here</h1>"
32
+ end
@@ -0,0 +1,37 @@
1
+ # Tep WebSocket echo demo.
2
+ #
3
+ # Walks the Sinatra-shaped DSL hook the bin/tep translator lowers
4
+ # into a generated upgrade route + per-event Tep::WebSocket::Handler
5
+ # subclasses. WS support requires the scheduled server (the recv
6
+ # loop parks on Tep::Scheduler.io_wait), so we opt in via
7
+ # `set :scheduler, :scheduled`.
8
+ #
9
+ # Try it:
10
+ # ./examples/websocket_echo -p 4567
11
+ # # then from a separate terminal:
12
+ # websocat ws://127.0.0.1:4567/echo
13
+ # > hello
14
+ # < echo: hello
15
+ require_relative "../lib/tep"
16
+
17
+ set :scheduler, :scheduled
18
+
19
+ get "/" do
20
+ "<!doctype html><html><body>" +
21
+ "<p>WebSocket echo server. Connect to <code>ws://host:port/echo</code>.</p>" +
22
+ "</body></html>"
23
+ end
24
+
25
+ websocket "/echo" do |ws|
26
+ on_open do |evt|
27
+ ws.text("welcome")
28
+ end
29
+
30
+ on_message do |evt|
31
+ ws.text("echo: " + evt.data)
32
+ end
33
+
34
+ on_close do |evt|
35
+ # No-op; placeholder for the user's cleanup path.
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # Tep::AgentDelegation -- the "on behalf of" half of a delegated
2
+ # identity. Carries the agent's own id (distinct from the human
3
+ # principal it acts for), the grant timestamps, and the origin
4
+ # label so audit logs can tell apart "issued via OAuth consent" vs
5
+ # "minted from a session handoff" vs "raw API token".
6
+ #
7
+ # Always paired with a Tep::Identity whose principal_id is the
8
+ # human being acted for. An Identity#human? has acting_via == nil;
9
+ # an Identity#agent? has acting_via populated with this struct.
10
+ #
11
+ # Lives in its own file so consumers that want the delegation
12
+ # vocabulary without the full Identity surface can require it
13
+ # narrowly.
14
+ module Tep
15
+ class AgentDelegation
16
+ attr_reader :agent_id # String, e.g. "summarizer-bot"
17
+ attr_reader :issued_at # Integer (unix epoch seconds)
18
+ attr_reader :expires_at # Integer (unix epoch seconds)
19
+ attr_reader :origin # Symbol: :token, :oauth_grant, :session_handoff, ...
20
+
21
+ def initialize(agent_id, issued_at, expires_at, origin)
22
+ @agent_id = agent_id
23
+ @issued_at = issued_at
24
+ @expires_at = expires_at
25
+ @origin = origin
26
+ end
27
+
28
+ # `now` is unix epoch seconds (Time.now.to_i shape). Passed in
29
+ # rather than read from Time.now so callers control the clock
30
+ # source (and tests can fast-forward).
31
+ def expired?(now)
32
+ now >= @expires_at
33
+ end
34
+ end
35
+ end
data/lib/tep/app.rb ADDED
@@ -0,0 +1,291 @@
1
+ # Tep::App -- the registered route table + filter slots + 404 handler.
2
+ #
3
+ # Each filter slot holds a single Tep::Filter instance. Spinel's
4
+ # `PtrArray` is homogeneously-typed and doesn't carry cls_id tags,
5
+ # so an array of mixed Filter subclasses falls through to base-class
6
+ # dispatch (the user's #before / #after never runs). A single slot
7
+ # typed as a union of subclasses keeps virtual dispatch working.
8
+ # Users compose multiple filters by writing one class that calls
9
+ # the others.
10
+ module Tep
11
+ class App
12
+ attr_accessor :router, :static_root, :session_secret
13
+ attr_accessor :tls_cert, :tls_key
14
+ attr_accessor :before_filter, :after_filter, :nf_handler
15
+ # The auth-filter runs BEFORE before_filter so handler bodies and
16
+ # user filters always see a populated req.identity. Separate slot
17
+ # (rather than wedging into before_filter) so user-installed
18
+ # filters and the auth populate don't fight for the single slot
19
+ # tep otherwise imposes. Default is a no-op Tep::Filter; the
20
+ # Auth battery installs Tep::AuthFilter on top via
21
+ # Tep::Auth.install!.
22
+ attr_accessor :auth_filter
23
+ # Shared HS256 secret consumed by Tep::AuthBearerToken. Stored on
24
+ # APP (rather than a class var) so spinel routes the read through
25
+ # the canonical instance-attr path.
26
+ attr_accessor :auth_bearer_secret
27
+ # Per-process OAuth2 client registry + ephemeral authorization-code
28
+ # store. See Tep::AuthOAuth2 for the issuance flow.
29
+ attr_accessor :auth_oauth2_clients
30
+ attr_accessor :auth_oauth2_codes
31
+ # Per-process Broadcast subscriber registry. Each entry pairs a
32
+ # topic with an output fd; publish iterates + writes the payload
33
+ # to every matching fd.
34
+ attr_accessor :broadcast_subs
35
+ # Per-process Presence entry registry. Each entry is one
36
+ # (principal, session, topic) tracking, with kind/agent_id +
37
+ # structured-status fields inline. See Tep::Presence.
38
+ attr_accessor :presence_entries
39
+ # PG-mirror state for cross-worker visibility. `enabled` is 0
40
+ # when off, 1 when on. `worker_id` uniquely identifies this
41
+ # worker's rows in the tep_presence table (PID + boot epoch
42
+ # so a restart on the same PID isn't aliased). See
43
+ # Tep::Presence.enable_pg_mirror.
44
+ attr_accessor :presence_pg_enabled
45
+ attr_accessor :presence_pg_worker_id
46
+ attr_accessor :presence_pg_conn
47
+ # PG-backed cross-worker pub/sub state. `broadcast_pg_enabled`
48
+ # is 0 when off, 1 when on. The dedicated LISTEN connection
49
+ # lives in `broadcast_pg_conn`; channel name in
50
+ # `broadcast_pg_channel`. Configured by
51
+ # Tep::Broadcast.enable_pg_backend.
52
+ attr_accessor :broadcast_pg_enabled
53
+ attr_accessor :broadcast_pg_channel
54
+ attr_accessor :broadcast_pg_conn
55
+ # Tep::Llm::OpenAI::Server backend (Battery 7). Set by
56
+ # Server.use(backend) at boot; the route handlers dispatch through
57
+ # it per request. Seeded with a base Backend in lib/tep.rb (after
58
+ # openai_server.rb loads -- not in initialize, since the class
59
+ # isn't defined yet there), same pattern as broadcast_pg_conn.
60
+ attr_accessor :openai_backend
61
+ # Tep::Events emitter for the openai-server (7.1c). Configured by
62
+ # Server.serve!(events_jsonl); empty path => zero-overhead disabled.
63
+ # Late-seeded for the same reason as openai_backend.
64
+ attr_accessor :openai_events
65
+ attr_accessor :asset_bodies, :asset_mimes, :asset_etags
66
+ attr_accessor :sched_fibers, :sched_wake_at, :sched_current
67
+ attr_accessor :sched_io_fd, :sched_io_mode, :sched_io_ready
68
+ # Tep::Job background-worker idempotency flag. App-level so a
69
+ # single-shot spawn from a before-filter doesn't fire repeatedly.
70
+ # Per-worker (each prefork child has its own Tep::APP, so each
71
+ # worker spawns one background fiber).
72
+ attr_accessor :user_bg_started
73
+
74
+ def initialize
75
+ @router = Router.new
76
+ @static_root = ""
77
+ @session_secret = ""
78
+ @tls_cert = "" # inbound TLS cert path (tep#148 ph2; "" = plain HTTP)
79
+ @tls_key = "" # inbound TLS key path
80
+ @before_filter = Filter.new # no-op default
81
+ @after_filter = Filter.new
82
+ @auth_filter = Filter.new # no-op until Tep::Auth.install!
83
+ @auth_bearer_secret = ""
84
+ # Type-seed the OAuth2 registries with a single dummy entry +
85
+ # immediate drop so the PtrArray slot type is pinned.
86
+ @auth_oauth2_clients = [Tep::AuthOAuth2Client.new("_", "", "", [:_])]
87
+ @auth_oauth2_clients.delete_at(0)
88
+ @auth_oauth2_codes = [Tep::AuthOAuth2Code.new("_", "", "", "", 0)]
89
+ @auth_oauth2_codes.delete_at(0)
90
+ # Same type-seed pattern for the Broadcast subscriber registry.
91
+ @broadcast_subs = [Tep::BroadcastSubscription.new("_", -1, 0)]
92
+ @broadcast_subs.delete_at(0)
93
+ # And for the Presence entry registry.
94
+ @presence_entries = [Tep::PresenceEntry.new("_", "", :human, "", -1, 0)]
95
+ @presence_entries.delete_at(0)
96
+ @presence_pg_enabled = 0
97
+ @presence_pg_worker_id = ""
98
+ @broadcast_pg_enabled = 0
99
+ @broadcast_pg_channel = ""
100
+ # Seed broadcast_pg_conn later via lib/tep.rb's setter seed
101
+ # (APP.set_broadcast_pg_conn(PG::Connection.new(""))) -- module
102
+ # load order means PG::Connection isn't safely callable from
103
+ # App#initialize when this is loaded before pg.rb's full surface.
104
+ @nf_handler = Handler.new
105
+ @asset_bodies = Tep.str_hash # path -> bytes (filled at boot
106
+ @asset_mimes = Tep.str_hash # by Tep::Assets._add lines
107
+ # the bin/tep translator emits)
108
+ @asset_etags = Tep.str_hash # path -> content-hash ETag (#152)
109
+ # FiberSlot array for the cooperative scheduler. Initialise
110
+ # with a noop-bodied slot to pin the array element type, then
111
+ # drop it. Each slot holds one Fiber + a timer entry in the
112
+ # parallel `sched_wake_at` int array.
113
+ @sched_fibers = [Tep::FiberSlot.new(Fiber.new { Tep.seed_fiber_noop })]
114
+ @sched_fibers.delete_at(0)
115
+ @sched_wake_at = [0]
116
+ @sched_wake_at.delete_at(0)
117
+ @sched_current = -1 # currently-running fiber idx
118
+ # (-1 = scheduler root).
119
+ # Parallel I/O-wait arrays. `sched_io_fd[i] == -1` means the
120
+ # fiber isn't parked on I/O (pure timer wait, or ready). When
121
+ # parked: `sched_io_mode[i]` carries the requested READ/WRITE
122
+ # bits, and tick() writes back the observed-ready bits into
123
+ # `sched_io_ready[i]`. io_wait returns those bits to its caller.
124
+ @sched_io_fd = [0]
125
+ @sched_io_fd.delete_at(0)
126
+ @sched_io_mode = [0]
127
+ @sched_io_mode.delete_at(0)
128
+ @sched_io_ready = [0]
129
+ @sched_io_ready.delete_at(0)
130
+ @user_bg_started = false
131
+ end
132
+
133
+ def add_asset(path, body, mime)
134
+ @asset_bodies[path] = body
135
+ @asset_mimes[path] = mime
136
+ # Content-hash ETag for cache revalidation (#152). SHA-1 is used
137
+ # purely as a fast content fingerprint here (not a security hash --
138
+ # collision resistance is irrelevant for an ETag, same as git's
139
+ # content addressing). Computed once at boot. (Binary bodies with
140
+ # embedded NULs hash by their leading bytes via the FFI string
141
+ # boundary; still stable per content, which is all an ETag needs.)
142
+ @asset_etags[path] = Crypto.sp_crypto_sha1_hex(body)
143
+ end
144
+
145
+ def set_session_secret(s)
146
+ @session_secret = s
147
+ end
148
+
149
+ # Inbound TLS cert/key paths (tep#148 phase 2). Set via
150
+ # Tep.tls_cert= / Tep.tls_key=; read by Tep::Server.run at boot.
151
+ def set_tls_cert(s)
152
+ @tls_cert = s
153
+ end
154
+
155
+ def set_tls_key(s)
156
+ @tls_key = s
157
+ end
158
+
159
+ def add_route(verb, pattern, handler)
160
+ @router.add(verb, pattern, handler)
161
+ end
162
+
163
+ def set_static_root(root); @static_root = root; end
164
+ def set_before(f); @before_filter = f; end
165
+ def set_after(f); @after_filter = f; end
166
+ def set_auth_filter(f); @auth_filter = f; end
167
+ def set_auth_bearer_secret(s); @auth_bearer_secret = s; end
168
+ def set_broadcast_pg_enabled(v); @broadcast_pg_enabled = v; end
169
+ def set_broadcast_pg_channel(s); @broadcast_pg_channel = s; end
170
+ def set_broadcast_pg_conn(c); @broadcast_pg_conn = c; end
171
+ def set_presence_pg_enabled(v); @presence_pg_enabled = v; end
172
+ def set_presence_pg_worker_id(s); @presence_pg_worker_id = s; end
173
+ def set_presence_pg_conn(c); @presence_pg_conn = c; end
174
+ def set_openai_backend(b); @openai_backend = b; end
175
+ def set_openai_events(e); @openai_events = e; end
176
+ def set_not_found(h); @nf_handler = h; end
177
+
178
+ def dispatch(req, res)
179
+ # Pull a signed session cookie into req.session, when configured.
180
+ secret = Tep.session_secret
181
+ if secret.length > 0
182
+ cv = req.cookies[Tep::COOKIE_NAME]
183
+ if cv.length > 0
184
+ req.session.load_from(cv, secret)
185
+ end
186
+ end
187
+
188
+ asset_served = false
189
+ # Auth filter populates req.identity (anonymous or matched
190
+ # provider's Identity) before the user's before-filter runs,
191
+ # so user code can always rely on req.identity being set.
192
+ @auth_filter.before(req, res)
193
+ if res.halted
194
+ # Auth filter signalled "deny" -- skip the user filter +
195
+ # route dispatch, fall through to after-filter + session.
196
+ end
197
+ @before_filter.before(req, res)
198
+ if !res.halted
199
+ # Bundled assets (everything under <app>/assets/, baked into
200
+ # the binary by bin/tep) take precedence over the route
201
+ # table. Match by exact path; on hit we set the body + ct
202
+ # and skip route dispatch + 404 fallback. The after-filter
203
+ # and session cookie writing still run normally.
204
+ if Tep::Assets.serve(req.path, res)
205
+ asset_served = true
206
+ end
207
+ end
208
+ if !res.halted && !asset_served
209
+ route = @router.match(req)
210
+ # `pass` loop: a handler can signal skip-to-next-route by
211
+ # setting req.passed. Iterate until a handler doesn't pass,
212
+ # or we run out of matching routes.
213
+ served = false
214
+ while route != nil && !served
215
+ route.fold_captures(req)
216
+ req.passed = false
217
+ out = route.handler.handle(req, res)
218
+ if req.passed
219
+ idx = @router.index_of(route)
220
+ route = @router.match_after(req, idx)
221
+ else
222
+ res.set_body_if_empty(out)
223
+ served = true
224
+ end
225
+ end
226
+ if !served
227
+ if !try_static(req, res)
228
+ out = @nf_handler.handle(req, res)
229
+ res.set_status(404)
230
+ if out.length > 0
231
+ res.set_body_if_empty(out)
232
+ else
233
+ res.set_body_if_empty("<h1>404 Not Found</h1><p>" +
234
+ req.verb + " " + req.path + "</p>\n")
235
+ end
236
+ end
237
+ end
238
+ end
239
+ @after_filter.after(req, res)
240
+
241
+ # If the handler / filters mutated the session, sign + emit a
242
+ # Set-Cookie line. Path=/ so the cookie applies to the whole
243
+ # app; HttpOnly to keep it out of JS.
244
+ secret_w = Tep.session_secret
245
+ if secret_w.length > 0 && req.session.dirty
246
+ opts = Tep.str_hash
247
+ opts["Path"] = "/"
248
+ opts["HttpOnly"] = ""
249
+ opts["SameSite"] = "Lax"
250
+ res.set_cookie(Tep::COOKIE_NAME, req.session.to_cookie_value(secret_w), opts)
251
+ end
252
+ end
253
+
254
+ def try_static(req, res)
255
+ if @static_root.length == 0
256
+ return false
257
+ end
258
+ if req.verb != "GET" && req.verb != "HEAD"
259
+ return false
260
+ end
261
+ if Tep.str_find(req.path, "..", 0) >= 0
262
+ return false
263
+ end
264
+ full = @static_root + req.path
265
+ sz = Sock.sphttp_filesize(full)
266
+ if sz < 0
267
+ return false
268
+ end
269
+ res.headers["Content-Type"] = App.guess_mime(full)
270
+ res.headers["X-Tep-Static"] = "1"
271
+ res.send_file(full)
272
+ true
273
+ end
274
+
275
+ def self.guess_mime(path)
276
+ lower = path.downcase
277
+ if lower.end_with?(".html") || lower.end_with?(".htm")
278
+ return "text/html; charset=utf-8"
279
+ end
280
+ if lower.end_with?(".css"); return "text/css"; end
281
+ if lower.end_with?(".js"); return "application/javascript"; end
282
+ if lower.end_with?(".json"); return "application/json"; end
283
+ if lower.end_with?(".png"); return "image/png"; end
284
+ if lower.end_with?(".jpg") || lower.end_with?(".jpeg"); return "image/jpeg"; end
285
+ if lower.end_with?(".gif"); return "image/gif"; end
286
+ if lower.end_with?(".svg"); return "image/svg+xml"; end
287
+ if lower.end_with?(".txt"); return "text/plain; charset=utf-8"; end
288
+ "application/octet-stream"
289
+ end
290
+ end
291
+ end