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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- 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
|