tina4ruby 3.13.133 → 3.13.135

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83f372e751a027c81f97a04d5002b5dd756f16d9f94bc9a9b771c820f0878619
4
- data.tar.gz: 6436d18bc08e5dd6f5b6232feb90a7e2650a728064420f12e505eb279e3b941c
3
+ metadata.gz: 96ecd0fafd99947cd5435ae06d6c143232ddefb23716b6f7ed1c5f87fc2652d5
4
+ data.tar.gz: a333fd4f697a9d7284e15809c9e6f9e626db38f1c11bd92582b76c08103d71dc
5
5
  SHA512:
6
- metadata.gz: 8c214ae801254e82779984e62aabf4c07895c45109e25a75a28ea48aecb41aef707edfdbaed6a86db94e458b3bfdf06df783fc1e398bd7e77a1bc7796cc967b8
7
- data.tar.gz: e98653bdc54a68d5c9c205645651b0b682ce1d23912d5dc437d774161636d09abfe1225fc01366ddcc2f0ad38f69fe2bd9984dc77579541f5876bae41e973c4b
6
+ metadata.gz: 005d92cc99c52488903fe6e753ebe14bdb0bf05b5e9e7c254fb6202fe38564edbc4efc8dc23ac1536a9f5d51d6bd1c66857ae394f03748e4ee75309d2dd3f1d5
7
+ data.tar.gz: def97142d30947a7b508a68656f91eb030bf254f550e52e9f3a8997b0d1928d3f0cb15cc7083c35e0ce906ea873d9cc491e96987954f0c07b96b218f19cf2c25
data/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.134
10
+
11
+ Feature 140 Web Push is now available with provider-neutral subscription delivery,
12
+ native result envelopes, fail-closed configuration, and the documented runtime
13
+ configuration. Developer skills now include the Web Push API and configuration
14
+ reference for all supported language stacks.
15
+
9
16
  ## 3.13.133
10
17
 
11
18
  Maintainability pass, no behavior changes. Swagger.generate decomposed into cohesive
data/README.md CHANGED
@@ -102,7 +102,7 @@ Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
102
102
  | **Tina4 Ruby** | **10,243** | 0 | 55 |
103
103
  | Sinatra | 9,548 | 5+ | ~4 |
104
104
 
105
- Tina4 Ruby outperforms Sinatra while delivering **98 features vs ~4**, with zero runtime dependencies.
105
+ Tina4 Ruby outperforms Sinatra while delivering **140 cataloged features vs ~4**, with zero runtime dependencies.
106
106
 
107
107
  **Across all 4 Tina4 implementations:**
108
108
 
@@ -116,7 +116,7 @@ Tina4 Ruby outperforms Sinatra while delivering **98 features vs ~4**, with zero
116
116
 
117
117
  ## Cross-Framework Parity
118
118
 
119
- Tina4 ships identical features across four languages: same architecture, same conventions, same 98 features:
119
+ Tina4 ships identical features across four languages: same architecture, same conventions, the same 140 cataloged features:
120
120
 
121
121
  | | Python | PHP | Ruby | Node.js |
122
122
  |---|--------|-----|------|---------|
@@ -842,23 +842,43 @@ module Tina4
842
842
  [status, { "content-type" => "application/json; charset=utf-8" }, [body]]
843
843
  end
844
844
 
845
+ # A version check that did not happen says so.
846
+ #
847
+ # This used to fall back to latest = current on any failure, and the
848
+ # toolbar renders that as a green "Latest: vX — You are up to date!". A
849
+ # developer several releases behind, on a machine with no route out, was
850
+ # told the opposite of the truth — and the toolbar's own "Could not check
851
+ # for updates" branch could never fire, because the failure arrived as a
852
+ # success.
853
+ #
854
+ # latest is nil when the check could not be made, and error says why. The
855
+ # registry URL is TINA4_VERSION_CHECK_URL when set (a mirror, or a test's
856
+ # own server), else RubyGems.
845
857
  def version_check_payload
846
858
  current = Tina4::VERSION
847
- latest = current
859
+ url = ENV.fetch("TINA4_VERSION_CHECK_URL",
860
+ "https://rubygems.org/api/v1/versions/tina4ruby/latest.json")
848
861
  begin
849
- uri = URI.parse("https://rubygems.org/api/v1/versions/tina4ruby/latest.json")
862
+ uri = URI.parse(url)
850
863
  http = Net::HTTP.new(uri.host, uri.port)
851
- http.use_ssl = true
864
+ http.use_ssl = (uri.scheme == "https")
852
865
  http.open_timeout = 5
853
866
  http.read_timeout = 5
854
- req = Net::HTTP::Get.new(uri)
855
- resp = http.request(req)
856
- if resp.is_a?(Net::HTTPSuccess)
857
- data = JSON.parse(resp.body)
858
- latest = data["version"] || current
867
+ resp = http.request(Net::HTTP::Get.new(uri))
868
+ unless resp.is_a?(Net::HTTPSuccess)
869
+ return { current: current, latest: nil,
870
+ error: "RubyGems answered #{resp.code}" }
859
871
  end
860
- rescue StandardError
861
- # Offline or timeout return current as latest
872
+ latest = JSON.parse(resp.body)["version"]
873
+ # Reaching RubyGems is not the same as learning the version: an
874
+ # answer with none in it is the same lie by another route.
875
+ if latest.nil? || latest.to_s.empty?
876
+ return { current: current, latest: nil,
877
+ error: "RubyGems did not report a version" }
878
+ end
879
+ rescue StandardError => e
880
+ return { current: current, latest: nil,
881
+ error: "#{e.class}: #{e.message}" }
862
882
  end
863
883
  { current: current, latest: latest }
864
884
  end
data/lib/tina4/push.rb ADDED
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ module Tina4
10
+ class PushError < StandardError; end
11
+
12
+ # Provider-neutral Web Push sender using Ruby's stdlib OpenSSL and Net::HTTP.
13
+ # Web Push remains optional at use time; a Ruby build without OpenSSL fails
14
+ # loudly when the feature is selected rather than silently sending plaintext.
15
+ class Push
16
+ RECORD_SIZE = 4096
17
+ MAX_PAYLOAD = RECORD_SIZE - 17
18
+
19
+ def self.generate_vapid_keys
20
+ require_openssl
21
+ key = OpenSSL::PKey::EC.generate("prime256v1")
22
+ {
23
+ # The public point keeps its width -- the 0x04 lead byte is non-zero, so
24
+ # to_s(2) never drops it. The private SCALAR has no such guard: OpenSSL
25
+ # strips a leading zero byte, so ~0.3% of keys come back 31 bytes and the
26
+ # app's own 32-byte validation would then reject the key it just made.
27
+ "publicKey" => b64(key.public_key.to_bn.to_s(2)),
28
+ "privateKey" => b64(pad32(key.private_key.to_s(2)))
29
+ }
30
+ end
31
+
32
+ # Left-pad big-endian EC material to the fixed 32-byte P-256 field width.
33
+ def self.pad32(bytes)
34
+ bytes.b.rjust(32, "\x00".b)
35
+ end
36
+
37
+ def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil)
38
+ @subject = (subject || ENV.fetch("TINA4_VAPID_SUBJECT", "")).strip
39
+ @public_key = (public_key || ENV.fetch("TINA4_VAPID_PUBLIC", "")).strip
40
+ @private_key = (private_key || ENV.fetch("TINA4_VAPID_PRIVATE", "")).strip
41
+ @ttl = ttl
42
+ @urgency = urgency
43
+ if %w[0 false off no].include?(ENV.fetch("TINA4_WEB_PUSH", "").strip.downcase)
44
+ raise PushError, "Web Push is disabled by TINA4_WEB_PUSH"
45
+ end
46
+ configuration if [@subject, @public_key, @private_key].any? { |value| !value.empty? }
47
+ end
48
+
49
+ def send(subscription, payload)
50
+ endpoint, uri = endpoint_for(subscription)
51
+ subject, public_key, private_key = configuration
52
+ public, private = vapid_keys(public_key, private_key)
53
+ deliver(endpoint, uri, subject, public_key, private, public, encrypt(payload_bytes(payload), subscription))
54
+ rescue URI::InvalidURIError => e
55
+ raise PushError, "Push subscription endpoint must be a valid URL: #{e.message}"
56
+ rescue Net::HTTPError, SocketError, SystemCallError => e
57
+ raise PushError, "Web Push request failed: #{e.message}"
58
+ end
59
+
60
+ private
61
+
62
+ def endpoint_for(subscription)
63
+ endpoint = subscription.is_a?(Hash) ? (subscription["endpoint"] || subscription[:endpoint]) : nil
64
+ raise PushError, "A Web Push subscription with an endpoint is required" unless endpoint.is_a?(String) && !endpoint.empty?
65
+ uri = URI.parse(endpoint)
66
+ raise PushError, "Push subscription endpoint must use HTTP or HTTPS" unless %w[http https].include?(uri.scheme) && uri.host
67
+ [endpoint, uri]
68
+ end
69
+
70
+ def vapid_keys(public_key, private_key)
71
+ public = decode(public_key, "TINA4_VAPID_PUBLIC")
72
+ private = decode(private_key, "TINA4_VAPID_PRIVATE")
73
+ raise PushError, "TINA4_VAPID_PUBLIC must be a 65-byte P-256 public key" unless public.bytesize == 65 && public.getbyte(0) == 4
74
+ raise PushError, "TINA4_VAPID_PRIVATE must be a 32-byte P-256 private key" unless private.bytesize == 32
75
+ begin
76
+ derived = private_key_from_raw(private, public).public_key.to_bn.to_s(2)
77
+ rescue OpenSSL::PKey::PKeyError => e
78
+ raise PushError, "TINA4_VAPID_PRIVATE is not a valid P-256 private key: #{e.message}"
79
+ end
80
+ raise PushError, "TINA4_VAPID_PUBLIC does not match TINA4_VAPID_PRIVATE" unless derived == public
81
+ [public, private]
82
+ end
83
+
84
+ def deliver(endpoint, uri, subject, public_key, private, public, body)
85
+ request = Net::HTTP::Post.new(uri)
86
+ request["Authorization"] = "vapid t=#{vapid_token(uri, subject, private, public)}, k=#{public_key}"
87
+ request["Content-Encoding"] = "aes128gcm"
88
+ request["Content-Type"] = "application/octet-stream"
89
+ request["TTL"] = @ttl.to_i.to_s
90
+ request["Urgency"] = @urgency if @urgency && !@urgency.empty?
91
+ request.body = body
92
+ http = Net::HTTP.new(uri.host, uri.port)
93
+ http.use_ssl = uri.scheme == "https"
94
+ response = http.start { |client| client.request(request) }
95
+ status = response.code.to_i
96
+ { "ok" => status < 400, "status" => status, "dead" => [404, 410].include?(status), "retryable" => [408, 429].include?(status) || status >= 500, "endpoint" => endpoint, "response" => response.body.to_s }
97
+ end
98
+
99
+ def configuration
100
+ missing = []
101
+ missing << "TINA4_VAPID_SUBJECT" if @subject.empty?
102
+ missing << "TINA4_VAPID_PUBLIC" if @public_key.empty?
103
+ missing << "TINA4_VAPID_PRIVATE" if @private_key.empty?
104
+ raise PushError, "Web Push is configured but missing: #{missing.join(', ')}" unless missing.empty?
105
+ self.class.send(:require_openssl)
106
+ [@subject, @public_key, @private_key]
107
+ end
108
+
109
+ def self.require_openssl
110
+ return if defined?(OpenSSL::PKey::EC)
111
+
112
+ raise PushError, "Web Push requires Ruby's OpenSSL stdlib capability; rebuild Ruby with OpenSSL support"
113
+ end
114
+
115
+ def self.b64(value)
116
+ Base64.urlsafe_encode64(value, padding: false)
117
+ end
118
+
119
+ def decode(value, name)
120
+ raise PushError, "#{name} must be a non-empty base64url string" unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9_-]+\z/)
121
+
122
+ Base64.urlsafe_decode64(value)
123
+ rescue ArgumentError => e
124
+ raise PushError, "#{name} must be base64url encoded: #{e.message}"
125
+ end
126
+
127
+ def payload_bytes(payload)
128
+ return payload.b if payload.is_a?(String)
129
+ JSON.generate(payload)
130
+ rescue JSON::GeneratorError => e
131
+ raise PushError, "Push payload is not JSON serializable: #{e.message}"
132
+ end
133
+
134
+ def private_key_from_raw(private, public)
135
+ body = OpenSSL::ASN1::Sequence.new([
136
+ OpenSSL::ASN1::Integer.new(1),
137
+ OpenSSL::ASN1::OctetString.new(private),
138
+ OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId.new("prime256v1")], 0, :CONTEXT_SPECIFIC),
139
+ OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::BitString.new(public)], 1, :CONTEXT_SPECIFIC)
140
+ ])
141
+ OpenSSL::PKey.read(body.to_der)
142
+ end
143
+
144
+ def public_key_from_raw(public)
145
+ algorithm = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new("id-ecPublicKey"), OpenSSL::ASN1::ObjectId.new("prime256v1")])
146
+ OpenSSL::PKey.read(OpenSSL::ASN1::Sequence.new([algorithm, OpenSSL::ASN1::BitString.new(public)]).to_der)
147
+ end
148
+
149
+ def encrypt(payload, subscription)
150
+ raise PushError, "Push payload is too large; maximum is #{MAX_PAYLOAD} bytes" if payload.bytesize > MAX_PAYLOAD
151
+ keys = subscription.is_a?(Hash) ? (subscription["keys"] || subscription[:keys] || {}) : {}
152
+ p256dh = keys["p256dh"] || keys[:p256dh]
153
+ auth = keys["auth"] || keys[:auth]
154
+ client = decode(p256dh.to_s, "subscription.keys.p256dh")
155
+ auth_secret = decode(auth.to_s, "subscription.keys.auth")
156
+ raise PushError, "subscription.keys.p256dh must be a 65-byte P-256 public key" unless client.bytesize == 65 && client.getbyte(0) == 4
157
+ raise PushError, "subscription.keys.auth must be a 16-byte authentication secret" unless auth_secret.bytesize == 16
158
+
159
+ ephemeral = OpenSSL::PKey::EC.generate("prime256v1")
160
+ server = ephemeral.public_key.to_bn.to_s(2)
161
+ shared = ephemeral.derive(public_key_from_raw(client))
162
+ ikm = hkdf(hmac(auth_secret, shared), "WebPush: info\0" + client + server, 32)
163
+ salt = OpenSSL::Random.random_bytes(16)
164
+ prk = hmac(salt, ikm)
165
+ cek = hkdf(prk, "Content-Encoding: aes128gcm\0", 16)
166
+ nonce = hkdf(prk, "Content-Encoding: nonce\0", 12)
167
+ cipher = OpenSSL::Cipher.new("aes-128-gcm")
168
+ cipher.encrypt
169
+ cipher.key = cek
170
+ cipher.iv = nonce
171
+ ciphertext = cipher.update(payload + "\x02") + cipher.final
172
+ tag = cipher.auth_tag
173
+ salt + [RECORD_SIZE].pack("N") + [server.bytesize].pack("C") + server + ciphertext + tag
174
+ end
175
+
176
+ def hmac(key, value)
177
+ OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, key, value)
178
+ end
179
+
180
+ def hkdf(prk, info, length)
181
+ output = +""
182
+ previous = +""
183
+ counter = 1
184
+ while output.bytesize < length
185
+ previous = hmac(prk, previous + info + [counter].pack("C"))
186
+ output << previous
187
+ counter += 1
188
+ raise PushError, "HKDF output is too large" if counter > 255
189
+ end
190
+ output.byteslice(0, length)
191
+ end
192
+
193
+ def vapid_token(uri, subject, private, public)
194
+ aud = "#{uri.scheme}://#{uri.host}#{uri.port && ![80, 443].include?(uri.port) ? ":#{uri.port}" : ""}"
195
+ header = self.class.send(:b64, JSON.generate({ typ: "JWT", alg: "ES256" }))
196
+ claims = self.class.send(:b64, JSON.generate({ aud: aud, exp: Time.now.to_i + 43_200, sub: subject }))
197
+ input = "#{header}.#{claims}"
198
+ signature = private_key_from_raw(private, public).sign(OpenSSL::Digest::SHA256.new, input)
199
+ asn = OpenSSL::ASN1.decode(signature)
200
+ raw = [asn.value[0].value.to_s(2), asn.value[1].value.to_s(2)].map { |value| value.rjust(32, "\0") }.join
201
+ "#{input}.#{self.class.send(:b64, raw)}"
202
+ end
203
+ end
204
+ end
@@ -1033,6 +1033,14 @@ module Tina4
1033
1033
  el.className = 't4-ok';
1034
1034
  el.innerHTML = 'Latest: <strong class="t4-ok">v' + latest + '</strong> &mdash; You are up to date!';
1035
1035
  }
1036
+ // A check that did not happen is not a clean bill of health. The
1037
+ // server sends latest: null when it could not reach the registry,
1038
+ // and saying so is the whole point -- "up to date" here would be a
1039
+ // guess dressed as a fact.
1040
+ function couldNotCheck(el, why) {
1041
+ el.className = 't4-err';
1042
+ el.textContent = 'Could not check for updates' + (why ? ' (' + why + ')' : '');
1043
+ }
1036
1044
  function checkVersion() {
1037
1045
  if (modal.style.display === 'block') { modal.style.display = 'none'; return; }
1038
1046
  modal.style.display = 'block';
@@ -1041,6 +1049,10 @@ module Tina4
1041
1049
  el.textContent = 'Checking for updates...';
1042
1050
  fetch('/__dev/api/version-check').then(function (r) { return r.json(); }).then(function (d) {
1043
1051
  var latest = d.latest, current = d.current;
1052
+ // A check that did not happen says so -- act on a missing
1053
+ // latest before comparing, so a null never falls into the
1054
+ // up-to-date branch.
1055
+ if (!latest) { couldNotCheck(el, d.error); return; }
1044
1056
  if (latest === current) { upToDate(el, latest); return; }
1045
1057
  var cP = current.split('.').map(Number), lP = latest.split('.').map(Number);
1046
1058
  var isNewer = false, i, c, l;
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.133"
4
+ VERSION = "3.13.135"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -169,6 +169,8 @@ module Tina4
169
169
  autoload :MqttError, File.expand_path("tina4/mqtt", __dir__)
170
170
  autoload :MqttTimeoutError, File.expand_path("tina4/mqtt", __dir__)
171
171
  autoload :MqttMessage, File.expand_path("tina4/mqtt_message", __dir__)
172
+ autoload :Push, File.expand_path("tina4/push", __dir__)
173
+ autoload :PushError, File.expand_path("tina4/push", __dir__)
172
174
  autoload :Testing, File.expand_path("tina4/testing", __dir__)
173
175
  # Queue / Messenger / DocStore were the last three optional subsystems still
174
176
  # loaded eagerly, so every `require "tina4"` paid for a queue backend, an
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.133
4
+ version: 3.13.135
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-05 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -405,6 +405,7 @@ files:
405
405
  - lib/tina4/public/js/tina4js.min.js
406
406
  - lib/tina4/public/swagger/index.html
407
407
  - lib/tina4/public/swagger/oauth2-redirect.html
408
+ - lib/tina4/push.rb
408
409
  - lib/tina4/query_builder.rb
409
410
  - lib/tina4/queue.rb
410
411
  - lib/tina4/queue_backends/kafka_backend.rb