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
data/lib/tep/json.rb ADDED
@@ -0,0 +1,572 @@
1
+ # Tep::Json -- a small JSON encoder + flat-key decoder for
2
+ # spinel-AOT'd apps.
3
+ #
4
+ # Why bundle one? The stdlib `json` gem's fast path is a CRuby
5
+ # native extension (`JSON::Ext`); the pure-Ruby fallback
6
+ # (`JSON::Pure`) is heavily metaprogrammed (`define_method` for
7
+ # state-machine transitions, `class_eval`'d generator dispatch),
8
+ # which spinel can't lower. The `oj` / `yajl-ruby` / `multi_json`
9
+ # alternatives are all C extensions or thin wrappers thereof.
10
+ #
11
+ # Scope
12
+ # -----
13
+ # This is a *batteries-included for JSON-over-HTTP* shim, not a
14
+ # full JSON library:
15
+ #
16
+ # * **Encode**: produce JSON strings from typed Ruby values --
17
+ # `escape(s)` / `quote(s)` for strings; `encode_pair_str(k, v)`
18
+ # and `encode_pair_int(k, v)` as fixed-arity building blocks
19
+ # for object literals; `from_str_array(a)` and
20
+ # `from_int_array(a)` for array literals. Compose objects in
21
+ # user code by concatenation:
22
+ #
23
+ # "{" + Tep::Json.encode_pair_str("name", name) + "," +
24
+ # Tep::Json.encode_pair_int("age", age) + "}"
25
+ #
26
+ # * **Decode**: typed accessors that read a *top-level key* from
27
+ # a flat JSON object: `get_str`, `get_int`, `has_key?`. The
28
+ # parser walks the object once, skipping nested values without
29
+ # materialising them. For deeper traversal users hand-write
30
+ # the walk; nested JSON-object access of the form
31
+ # `payload.user.email` should be done by the API contract:
32
+ # pass discrete fields rather than a nested blob.
33
+ #
34
+ # Out of scope
35
+ # ------------
36
+ # * Floats. Numbers parse / emit as int (`.to_s`). JSON's number
37
+ # grammar is wider but APIs in practice send integers for IDs,
38
+ # counts, timestamps; treat anything fractional as a string in
39
+ # transport.
40
+ # * Unicode-escape decoding (`\uXXXX`). Round-trips through
41
+ # escape/unescape work for ASCII; non-ASCII input bytes are
42
+ # passed through verbatim.
43
+ # * Streaming / pull parsers. Loads the whole string into the
44
+ # parser; suitable for request-body sizes typical of APIs.
45
+ module Tep
46
+ class Json
47
+ # ---- Encoders ----
48
+
49
+ # Escape a string for inclusion inside a JSON string literal
50
+ # (does NOT add the surrounding quotes -- use `quote(s)` for
51
+ # that). Handles ", \, and the JSON-required control-char
52
+ # escapes (\b, \f, \n, \r, \t); other control bytes go through
53
+ # \u00XX. Forward slash is left unescaped (legal either way;
54
+ # the unescaped form is more readable and shorter).
55
+ def self.escape(s)
56
+ out = ""
57
+ i = 0
58
+ n = s.length
59
+ while i < n
60
+ c = s[i]
61
+ if c == "\""
62
+ out = out + "\\\""
63
+ elsif c == "\\"
64
+ out = out + "\\\\"
65
+ elsif c == "\n"
66
+ out = out + "\\n"
67
+ elsif c == "\r"
68
+ out = out + "\\r"
69
+ elsif c == "\t"
70
+ out = out + "\\t"
71
+ elsif c == "\b"
72
+ out = out + "\\b"
73
+ elsif c == "\f"
74
+ out = out + "\\f"
75
+ elsif c < " "
76
+ # Other control byte -- emit \u00XX. c.getbyte(0) is the
77
+ # raw byte value, mapped to two hex digits.
78
+ b = c.getbyte(0)
79
+ out = out + "\\u00" + Json.hex2(b)
80
+ else
81
+ out = out + c
82
+ end
83
+ i += 1
84
+ end
85
+ out
86
+ end
87
+
88
+ # Two-digit lowercase hex of a byte (0..255).
89
+ def self.hex2(n)
90
+ hex = "0123456789abcdef"
91
+ out = ""
92
+ out = out + hex[(n / 16) % 16, 1]
93
+ out = out + hex[n % 16, 1]
94
+ out
95
+ end
96
+
97
+ # Wrap a string in JSON quotes, escaping its body.
98
+ def self.quote(s)
99
+ "\"" + Json.escape(s) + "\""
100
+ end
101
+
102
+ # Encode a single key/value pair as `"k":"v"` (escaped both
103
+ # sides). Building block for ad-hoc object literals where the
104
+ # caller wants control over key ordering or layout:
105
+ #
106
+ # "{" + Tep::Json.encode_pair_str("name", name) + "," +
107
+ # Tep::Json.encode_pair_int("age", age) + "}"
108
+ #
109
+ # When you have a real Hash, prefer `from_str_hash` /
110
+ # `from_int_hash` -- those iterate via `each |k, v|` directly.
111
+ def self.encode_pair_str(k, v)
112
+ Json.quote(k) + ":" + Json.quote(v)
113
+ end
114
+
115
+ # Same shape, integer value side. `v` is rendered via `.to_s`
116
+ # so JSON-numeric output without quoting.
117
+ def self.encode_pair_int(k, v)
118
+ Json.quote(k) + ":" + v.to_s
119
+ end
120
+
121
+ # Encode a Hash<String,String> as a JSON object.
122
+ def self.from_str_hash(h)
123
+ out = "{"
124
+ first = true
125
+ h.each do |k, v|
126
+ if !first
127
+ out = out + ","
128
+ end
129
+ first = false
130
+ out = out + Json.quote(k) + ":" + Json.quote(v)
131
+ end
132
+ out + "}"
133
+ end
134
+
135
+ # Same shape with integer values. JSON-numeric, no quoting.
136
+ def self.from_int_hash(h)
137
+ out = "{"
138
+ first = true
139
+ h.each do |k, v|
140
+ if !first
141
+ out = out + ","
142
+ end
143
+ first = false
144
+ out = out + Json.quote(k) + ":" + v.to_s
145
+ end
146
+ out + "}"
147
+ end
148
+
149
+ # Encode a string array as a JSON array of quoted strings.
150
+ def self.from_str_array(a)
151
+ out = "["
152
+ i = 0
153
+ while i < a.length
154
+ if i > 0
155
+ out = out + ","
156
+ end
157
+ out = out + Json.quote(a[i])
158
+ i += 1
159
+ end
160
+ out + "]"
161
+ end
162
+
163
+ # Encode an int array as a JSON array of numbers.
164
+ def self.from_int_array(a)
165
+ out = "["
166
+ i = 0
167
+ while i < a.length
168
+ if i > 0
169
+ out = out + ","
170
+ end
171
+ out = out + a[i].to_s
172
+ i += 1
173
+ end
174
+ out + "]"
175
+ end
176
+
177
+ # ---- Decoders (flat-key, top-level only) ----
178
+ #
179
+ # `get_str(s, key)` finds the entry for `key` in the top-level
180
+ # object literal `s` and returns its value as a string.
181
+ # Returns "" when `key` is absent or the value isn't a string.
182
+ # Same shape for `get_int`. `has_key?(s, key)` returns a
183
+ # boolean independent of value type.
184
+ #
185
+ # The parser is a hand-rolled state machine that walks one
186
+ # `{ "k": <value>, ... }` pair at a time, skipping over any
187
+ # value (including nested objects / arrays) it doesn't need.
188
+ # Strings inside values are honoured for escape sequences so
189
+ # that `\"` doesn't terminate the string and corrupt the walk.
190
+
191
+ def self.get_str(s, key)
192
+ pos = Json.find_value_start(s, key)
193
+ if pos < 0
194
+ return ""
195
+ end
196
+ Json.parse_str_value(s, pos)
197
+ end
198
+
199
+ def self.get_int(s, key)
200
+ pos = Json.find_value_start(s, key)
201
+ if pos < 0
202
+ return 0
203
+ end
204
+ Json.parse_int_value(s, pos)
205
+ end
206
+
207
+ # Decode a JSON number value at `key` -> Float. Accepts both
208
+ # integer-literal (`42`) and float-literal (`3.14`, `-0.5`, `1e2`)
209
+ # JSON-number syntax; the integer form returns N.0. Missing key
210
+ # or malformed value returns 0.0 (consistent with the other
211
+ # getters' missing-key defaults).
212
+ #
213
+ # Implementation: delegates the value-span walking to skip_value
214
+ # (already handles all JSON-number syntax + structural-char
215
+ # boundaries), then String#to_f on the substring. Inlined rather
216
+ # than factored into a parse_float_value helper because spinel's
217
+ # type inference mis-widens `s` to int through the indirection
218
+ # ("cannot resolve call to 'length' on int" + the downstream
219
+ # skip_ws/skip_value pointer-vs-int conversion errors).
220
+ def self.get_float(s, key)
221
+ pos = Json.find_value_start(s, key)
222
+ if pos < 0
223
+ return 0.0
224
+ end
225
+ pos = Json.skip_ws(s, pos)
226
+ if pos >= s.length
227
+ return 0.0
228
+ end
229
+ end_pos = Json.skip_value(s, pos)
230
+ if end_pos <= pos
231
+ return 0.0
232
+ end
233
+ s[pos, end_pos - pos].to_f
234
+ end
235
+
236
+ def self.has_key?(s, key)
237
+ Json.find_value_start(s, key) >= 0
238
+ end
239
+
240
+ # Decode a flat JSON array of integers at `key` -> Array[Integer].
241
+ # The `prompt` of /v1/completions is a token-id array
242
+ # (`[464, 6193, ...]`). A missing or non-array value yields []
243
+ # (the tep typed-empty-array idiom); non-int elements are skipped.
244
+ def self.get_int_array(s, key)
245
+ out = [0]
246
+ out.delete_at(0)
247
+ pos = Json.find_value_start(s, key)
248
+ if pos < 0
249
+ return out
250
+ end
251
+ pos = Json.skip_ws(s, pos)
252
+ if pos >= s.length || s[pos] != "["
253
+ return out
254
+ end
255
+ pos += 1
256
+ while pos < s.length
257
+ pos = Json.skip_ws(s, pos)
258
+ if pos >= s.length
259
+ return out
260
+ end
261
+ c = s[pos]
262
+ if c == "]"
263
+ return out
264
+ elsif c == ","
265
+ pos += 1
266
+ elsif (c >= "0" && c <= "9") || c == "-"
267
+ out.push(Json.parse_int_value(s, pos))
268
+ # Advance past the number parse_int_value just consumed
269
+ # (optional '-' then digits).
270
+ if s[pos] == "-"
271
+ pos += 1
272
+ end
273
+ while pos < s.length && s[pos] >= "0" && s[pos] <= "9"
274
+ pos += 1
275
+ end
276
+ else
277
+ # Non-int element (string / object / etc.): skip it.
278
+ pos = Json.skip_value(s, pos)
279
+ end
280
+ end
281
+ out
282
+ end
283
+
284
+ # ---- Internal helpers ----
285
+
286
+ # Skip whitespace starting at `pos`, return the new position.
287
+ def self.skip_ws(s, pos)
288
+ while pos < s.length
289
+ c = s[pos]
290
+ if c == " " || c == "\t" || c == "\n" || c == "\r"
291
+ pos += 1
292
+ else
293
+ return pos
294
+ end
295
+ end
296
+ pos
297
+ end
298
+
299
+ # Walk a JSON-quoted string starting at `pos` (which must point
300
+ # at the opening `"`). Returns the position one past the
301
+ # closing `"`. Returns -1 on malformed input.
302
+ def self.skip_str(s, pos)
303
+ if pos >= s.length || s[pos] != "\""
304
+ return -1
305
+ end
306
+ pos += 1
307
+ while pos < s.length
308
+ c = s[pos]
309
+ if c == "\\"
310
+ # Skip the escape and the escaped character. \uXXXX
311
+ # spans 6 chars total but skipping 2 still keeps us
312
+ # inside the string for the rest of the walk -- the
313
+ # remaining 4 hex digits look like ordinary string
314
+ # bytes and won't terminate the literal.
315
+ pos += 2
316
+ elsif c == "\""
317
+ return pos + 1
318
+ else
319
+ pos += 1
320
+ end
321
+ end
322
+ -1
323
+ end
324
+
325
+ # Walk a JSON value starting at `pos` (which must point at the
326
+ # first non-ws char of the value). Returns the position one
327
+ # past the value (or the input length on truncation).
328
+ def self.skip_value(s, pos)
329
+ pos = Json.skip_ws(s, pos)
330
+ if pos >= s.length
331
+ return pos
332
+ end
333
+ c = s[pos]
334
+ if c == "\""
335
+ return Json.skip_str(s, pos)
336
+ end
337
+ if c == "{" || c == "["
338
+ return Json.skip_container(s, pos)
339
+ end
340
+ # number / true / false / null -- read until the next
341
+ # structural / whitespace char.
342
+ while pos < s.length
343
+ c = s[pos]
344
+ if c == "," || c == "}" || c == "]" ||
345
+ c == " " || c == "\t" || c == "\n" || c == "\r"
346
+ return pos
347
+ end
348
+ pos += 1
349
+ end
350
+ pos
351
+ end
352
+
353
+ # Walk a balanced { ... } or [ ... ] starting at `pos`. Honours
354
+ # string literals so that `{` / `}` inside a value-string don't
355
+ # confuse the brace counter. Returns position one past the
356
+ # matching closer.
357
+ def self.skip_container(s, pos)
358
+ open_c = s[pos]
359
+ close_c = open_c == "{" ? "}" : "]"
360
+ depth = 1
361
+ pos += 1
362
+ while pos < s.length && depth > 0
363
+ c = s[pos]
364
+ if c == "\""
365
+ # whole nested string -- skip past it
366
+ npos = Json.skip_str(s, pos)
367
+ if npos < 0
368
+ return s.length
369
+ end
370
+ pos = npos
371
+ elsif c == open_c
372
+ depth += 1
373
+ pos += 1
374
+ elsif c == close_c
375
+ depth -= 1
376
+ pos += 1
377
+ else
378
+ pos += 1
379
+ end
380
+ end
381
+ pos
382
+ end
383
+
384
+ # Read a JSON-quoted string at `pos` and return its decoded
385
+ # contents (no surrounding quotes). Decodes the same escape
386
+ # sequences that `escape` produces. Returns "" on malformed
387
+ # input.
388
+ def self.parse_str_value(s, pos)
389
+ pos = Json.skip_ws(s, pos)
390
+ if pos >= s.length || s[pos] != "\""
391
+ return ""
392
+ end
393
+ pos += 1
394
+ out = ""
395
+ while pos < s.length
396
+ c = s[pos]
397
+ if c == "\""
398
+ return out
399
+ end
400
+ if c == "\\"
401
+ if pos + 1 >= s.length
402
+ return out
403
+ end
404
+ esc = s[pos + 1]
405
+ if esc == "\""
406
+ out = out + "\""
407
+ elsif esc == "\\"
408
+ out = out + "\\"
409
+ elsif esc == "/"
410
+ out = out + "/"
411
+ elsif esc == "n"
412
+ out = out + "\n"
413
+ elsif esc == "r"
414
+ out = out + "\r"
415
+ elsif esc == "t"
416
+ out = out + "\t"
417
+ elsif esc == "b"
418
+ out = out + "\b"
419
+ elsif esc == "f"
420
+ out = out + "\f"
421
+ elsif esc == "u"
422
+ # \u00XX -> map the two-digit hex back to a byte. Wider
423
+ # codepoints (Ā+ or surrogate pairs) aren't
424
+ # decoded; the byte we emit is the low byte of the
425
+ # codepoint, which round-trips ASCII at minimum.
426
+ if pos + 5 < s.length
427
+ h1 = Json.hex_nibble(s[pos + 4])
428
+ h2 = Json.hex_nibble(s[pos + 5])
429
+ if h1 >= 0 && h2 >= 0
430
+ # rebuild the byte and push it -- spinel strings
431
+ # are byte-blobs, so this works for ASCII; for
432
+ # non-ASCII the original encoder would have used a
433
+ # passthrough byte anyway.
434
+ b = h1 * 16 + h2
435
+ out = out + Json.byte_to_chr(b)
436
+ pos += 6
437
+ next
438
+ end
439
+ end
440
+ out = out + "?"
441
+ pos += 2
442
+ next
443
+ else
444
+ out = out + esc
445
+ end
446
+ pos += 2
447
+ else
448
+ out = out + c
449
+ pos += 1
450
+ end
451
+ end
452
+ out
453
+ end
454
+
455
+ def self.hex_nibble(c)
456
+ if c >= "0" && c <= "9"
457
+ return c.getbyte(0) - "0".getbyte(0)
458
+ end
459
+ if c >= "a" && c <= "f"
460
+ return c.getbyte(0) - "a".getbyte(0) + 10
461
+ end
462
+ if c >= "A" && c <= "F"
463
+ return c.getbyte(0) - "A".getbyte(0) + 10
464
+ end
465
+ -1
466
+ end
467
+
468
+ # Build a single-byte string from an integer 0..255.
469
+ # Spinel doesn't expose `n.chr` for arbitrary bytes uniformly;
470
+ # the table covers the ASCII printable range and falls back to
471
+ # "?" for anything else (the JSON encoder side never produces
472
+ # non-ASCII via \u, so the fallback is reachable only for
473
+ # malformed input).
474
+ def self.byte_to_chr(n)
475
+ printable = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
476
+ if n >= 32 && n < 127
477
+ return printable[n - 32, 1]
478
+ end
479
+ if n == 9
480
+ return "\t"
481
+ end
482
+ if n == 10
483
+ return "\n"
484
+ end
485
+ if n == 13
486
+ return "\r"
487
+ end
488
+ "?"
489
+ end
490
+
491
+ # Read an integer at `pos`. Accepts an optional leading `-`.
492
+ # Returns 0 on no-digit / non-numeric input (caller can use
493
+ # `has_key?` first if 0-vs-absent matters).
494
+ def self.parse_int_value(s, pos)
495
+ pos = Json.skip_ws(s, pos)
496
+ if pos >= s.length
497
+ return 0
498
+ end
499
+ neg = false
500
+ if s[pos] == "-"
501
+ neg = true
502
+ pos += 1
503
+ end
504
+ n = 0
505
+ saw_digit = false
506
+ while pos < s.length
507
+ c = s[pos]
508
+ if c >= "0" && c <= "9"
509
+ n = n * 10 + (c.getbyte(0) - "0".getbyte(0))
510
+ saw_digit = true
511
+ pos += 1
512
+ else
513
+ break
514
+ end
515
+ end
516
+ if !saw_digit
517
+ return 0
518
+ end
519
+ neg ? -n : n
520
+ end
521
+
522
+ # Walk the top-level object looking for the entry whose key
523
+ # matches `target_key`; return the position of the value's
524
+ # first non-ws character. Returns -1 if not found.
525
+ def self.find_value_start(s, target_key)
526
+ pos = Json.skip_ws(s, 0)
527
+ if pos >= s.length || s[pos] != "{"
528
+ return -1
529
+ end
530
+ pos += 1
531
+ while pos < s.length
532
+ pos = Json.skip_ws(s, pos)
533
+ if pos >= s.length
534
+ return -1
535
+ end
536
+ if s[pos] == "}"
537
+ return -1
538
+ end
539
+ # Read a key.
540
+ if s[pos] != "\""
541
+ return -1
542
+ end
543
+ key_start = pos
544
+ pos = Json.skip_str(s, pos)
545
+ if pos < 0
546
+ return -1
547
+ end
548
+ # Decode the key for comparison (handles \" inside keys).
549
+ key = Json.parse_str_value(s, key_start)
550
+ # Skip ws, ":".
551
+ pos = Json.skip_ws(s, pos)
552
+ if pos >= s.length || s[pos] != ":"
553
+ return -1
554
+ end
555
+ pos += 1
556
+ pos = Json.skip_ws(s, pos)
557
+ if key == target_key
558
+ return pos
559
+ end
560
+ # Skip the value, then the comma (if any).
561
+ pos = Json.skip_value(s, pos)
562
+ pos = Json.skip_ws(s, pos)
563
+ if pos < s.length && s[pos] == ","
564
+ pos += 1
565
+ elsif pos < s.length && s[pos] == "}"
566
+ return -1
567
+ end
568
+ end
569
+ -1
570
+ end
571
+ end
572
+ end
data/lib/tep/jwt.rb ADDED
@@ -0,0 +1,126 @@
1
+ # Tep::Jwt -- HS256 JWT encode + decode + verify.
2
+ #
3
+ # Why bundle one? CRuby's `jwt` gem dispatches algorithms via
4
+ # runtime class registration (`JWT::Algos.find`) and uses
5
+ # `OpenSSL::HMAC` (a CRuby C extension). We can't load it through
6
+ # spinel.
7
+ #
8
+ # Tep already ships HMAC-SHA256 in tep_crypto.c (the session-cookie
9
+ # store uses it). The JWT spec on top of that is short:
10
+ # base64url-encoded JSON for header + payload, base64url-encoded
11
+ # 32-byte HMAC for the signature, joined by `.`.
12
+ #
13
+ # Surface
14
+ # -------
15
+ #
16
+ # payload_json = "{" + Tep::Json.encode_pair_str("sub", user_id) + "," +
17
+ # Tep::Json.encode_pair_int("exp", exp_unix) + "}"
18
+ # token = Tep::Jwt.encode_hs256(payload_json, secret)
19
+ #
20
+ # # On the receiving side:
21
+ # if Tep::Jwt.verify_hs256(token, secret)
22
+ # payload = Tep::Jwt.decode_payload(token) # the JSON string
23
+ # sub = Tep::Json.get_str(payload, "sub")
24
+ # end
25
+ #
26
+ # Scope
27
+ # -----
28
+ # **Algorithm:** HS256 only. ES256 / RS256 need RSA / ECDSA from
29
+ # OpenSSL; deferred until callers explicitly need asymmetric
30
+ # verification. The spec's `none` algorithm is intentionally not
31
+ # supported (forbidden in RFC 8725 §3.1).
32
+ #
33
+ # **Claims validation:** the `verify_hs256` only checks the
34
+ # signature. `exp` / `nbf` / `iss` / `aud` claim checks are left
35
+ # to caller code -- pull them with `Tep::Json.get_int(payload, "exp")`
36
+ # and compare against `Time.now.to_i`. This keeps the surface
37
+ # small and lets the app's policy decide what's required (some
38
+ # apps want skew tolerance, some want strict expiry).
39
+ #
40
+ # Constraints
41
+ # -----------
42
+ # **Constant header.** We always emit `{"alg":"HS256","typ":"JWT"}`
43
+ # (precomputed base64url constant). On decode we don't validate
44
+ # the header's alg, only the signature -- which is a deliberate
45
+ # choice that prevents algorithm-confusion attacks (no path that
46
+ # trusts the token's claimed alg).
47
+ module Tep
48
+ class Jwt
49
+ # base64url-encoded `{"alg":"HS256","typ":"JWT"}`.
50
+ HEADER_B64U = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
51
+
52
+ # Build a token from a JSON-encoded payload string and the
53
+ # signing secret. Returns the three-segment `header.payload.sig`
54
+ # string.
55
+ def self.encode_hs256(payload_json, secret)
56
+ payload_b64 = Crypto.sp_crypto_b64url_encode(payload_json)
57
+ signing_input = HEADER_B64U + "." + payload_b64
58
+ sig = Crypto.sp_crypto_hmac_sha256_b64url(secret, signing_input)
59
+ signing_input + "." + sig
60
+ end
61
+
62
+ # Verify the signature on a token. Returns true / false. Does
63
+ # NOT check claim semantics (exp / nbf / iss / aud).
64
+ def self.verify_hs256(token, secret)
65
+ d1 = Tep.str_find(token, ".", 0)
66
+ if d1 < 0
67
+ return false
68
+ end
69
+ d2 = Tep.str_find(token, ".", d1 + 1)
70
+ if d2 < 0
71
+ return false
72
+ end
73
+ signing_input = token[0, d2]
74
+ provided_sig = token[d2 + 1, token.length - d2 - 1]
75
+ expected_sig = Crypto.sp_crypto_hmac_sha256_b64url(secret, signing_input)
76
+ Jwt.timing_safe_eq(provided_sig, expected_sig)
77
+ end
78
+
79
+ # Pull the JSON-encoded payload back out of a token. No
80
+ # signature verification -- call `verify_hs256` first if you
81
+ # haven't, OR use the wrapped `verify_and_decode` form.
82
+ def self.decode_payload(token)
83
+ d1 = Tep.str_find(token, ".", 0)
84
+ if d1 < 0
85
+ return ""
86
+ end
87
+ d2 = Tep.str_find(token, ".", d1 + 1)
88
+ if d2 < 0
89
+ return ""
90
+ end
91
+ payload_b64 = token[d1 + 1, d2 - d1 - 1]
92
+ Crypto.sp_crypto_b64url_decode(payload_b64)
93
+ end
94
+
95
+ # One-shot: verify, then decode. Returns the JSON payload on
96
+ # success, "" on bad signature / malformed token. Saves callers
97
+ # an explicit early-return check.
98
+ def self.verify_and_decode(token, secret)
99
+ if !Jwt.verify_hs256(token, secret)
100
+ return ""
101
+ end
102
+ Jwt.decode_payload(token)
103
+ end
104
+
105
+ # Constant-time string compare. Returns true iff strings are
106
+ # byte-identical. Used so a token-signature mismatch leaks no
107
+ # timing info about how many leading bytes matched.
108
+ def self.timing_safe_eq(a, b)
109
+ if a.length != b.length
110
+ return false
111
+ end
112
+ diff = 0
113
+ i = 0
114
+ n = a.length
115
+ while i < n
116
+ # getbyte(i), NOT bytes[i] -- the same O(n^2)-allocation +
117
+ # GC-pressure hazard fixed in Tep::Session.timing_safe_eq
118
+ # (allocation storm can free the FFI-returned signature local
119
+ # mid-compare; #1052-family spinel rooting gap, see tep#157).
120
+ diff = diff | (a.getbyte(i) ^ b.getbyte(i))
121
+ i += 1
122
+ end
123
+ diff == 0
124
+ end
125
+ end
126
+ end