tina4ruby 3.13.133 → 3.13.134

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: 1d0d4c7801e0766f00041e3d004a0318d7bd4e22d9b4a4d102d05516b89ebba1
4
+ data.tar.gz: 837234011a9a76e4d0733d8a05936bdb08a5bcb8681638679064b51d84dc2392
5
5
  SHA512:
6
- metadata.gz: 8c214ae801254e82779984e62aabf4c07895c45109e25a75a28ea48aecb41aef707edfdbaed6a86db94e458b3bfdf06df783fc1e398bd7e77a1bc7796cc967b8
7
- data.tar.gz: e98653bdc54a68d5c9c205645651b0b682ce1d23912d5dc437d774161636d09abfe1225fc01366ddcc2f0ad38f69fe2bd9984dc77579541f5876bae41e973c4b
6
+ metadata.gz: bfd2d4b058f1366920dd46b66f1d59f504f1b30cc649b71ecc7f5d8f8d15a385eaa19b5de9078768c0f935aba9a3115e8e10efe72806cfa53be5aca73f5c2caf
7
+ data.tar.gz: 178596fd8db63262bc35aeb25ae13e11c609b3f50a85bc06d91ce12063b9739973f80ce5783ec5a330d115b3b07e9389718f2316d8c53590b47867ad777b193d
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
  |---|--------|-----|------|---------|
data/lib/tina4/push.rb ADDED
@@ -0,0 +1,195 @@
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
+ "publicKey" => b64(key.public_key.to_bn.to_s(2)),
24
+ "privateKey" => b64(key.private_key.to_s(2))
25
+ }
26
+ end
27
+
28
+ def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil)
29
+ @subject = (subject || ENV.fetch("TINA4_VAPID_SUBJECT", "")).strip
30
+ @public_key = (public_key || ENV.fetch("TINA4_VAPID_PUBLIC", "")).strip
31
+ @private_key = (private_key || ENV.fetch("TINA4_VAPID_PRIVATE", "")).strip
32
+ @ttl = ttl
33
+ @urgency = urgency
34
+ if %w[0 false off no].include?(ENV.fetch("TINA4_WEB_PUSH", "").strip.downcase)
35
+ raise PushError, "Web Push is disabled by TINA4_WEB_PUSH"
36
+ end
37
+ configuration if [@subject, @public_key, @private_key].any? { |value| !value.empty? }
38
+ end
39
+
40
+ def send(subscription, payload)
41
+ endpoint, uri = endpoint_for(subscription)
42
+ subject, public_key, private_key = configuration
43
+ public, private = vapid_keys(public_key, private_key)
44
+ deliver(endpoint, uri, subject, public_key, private, public, encrypt(payload_bytes(payload), subscription))
45
+ rescue URI::InvalidURIError => e
46
+ raise PushError, "Push subscription endpoint must be a valid URL: #{e.message}"
47
+ rescue Net::HTTPError, SocketError, SystemCallError => e
48
+ raise PushError, "Web Push request failed: #{e.message}"
49
+ end
50
+
51
+ private
52
+
53
+ def endpoint_for(subscription)
54
+ endpoint = subscription.is_a?(Hash) ? (subscription["endpoint"] || subscription[:endpoint]) : nil
55
+ raise PushError, "A Web Push subscription with an endpoint is required" unless endpoint.is_a?(String) && !endpoint.empty?
56
+ uri = URI.parse(endpoint)
57
+ raise PushError, "Push subscription endpoint must use HTTP or HTTPS" unless %w[http https].include?(uri.scheme) && uri.host
58
+ [endpoint, uri]
59
+ end
60
+
61
+ def vapid_keys(public_key, private_key)
62
+ public = decode(public_key, "TINA4_VAPID_PUBLIC")
63
+ private = decode(private_key, "TINA4_VAPID_PRIVATE")
64
+ raise PushError, "TINA4_VAPID_PUBLIC must be a 65-byte P-256 public key" unless public.bytesize == 65 && public.getbyte(0) == 4
65
+ raise PushError, "TINA4_VAPID_PRIVATE must be a 32-byte P-256 private key" unless private.bytesize == 32
66
+ begin
67
+ derived = private_key_from_raw(private, public).public_key.to_bn.to_s(2)
68
+ rescue OpenSSL::PKey::PKeyError => e
69
+ raise PushError, "TINA4_VAPID_PRIVATE is not a valid P-256 private key: #{e.message}"
70
+ end
71
+ raise PushError, "TINA4_VAPID_PUBLIC does not match TINA4_VAPID_PRIVATE" unless derived == public
72
+ [public, private]
73
+ end
74
+
75
+ def deliver(endpoint, uri, subject, public_key, private, public, body)
76
+ request = Net::HTTP::Post.new(uri)
77
+ request["Authorization"] = "vapid t=#{vapid_token(uri, subject, private, public)}, k=#{public_key}"
78
+ request["Content-Encoding"] = "aes128gcm"
79
+ request["Content-Type"] = "application/octet-stream"
80
+ request["TTL"] = @ttl.to_i.to_s
81
+ request["Urgency"] = @urgency if @urgency && !@urgency.empty?
82
+ request.body = body
83
+ http = Net::HTTP.new(uri.host, uri.port)
84
+ http.use_ssl = uri.scheme == "https"
85
+ response = http.start { |client| client.request(request) }
86
+ status = response.code.to_i
87
+ { "ok" => status < 400, "status" => status, "dead" => [404, 410].include?(status), "retryable" => [408, 429].include?(status) || status >= 500, "endpoint" => endpoint, "response" => response.body.to_s }
88
+ end
89
+
90
+ def configuration
91
+ missing = []
92
+ missing << "TINA4_VAPID_SUBJECT" if @subject.empty?
93
+ missing << "TINA4_VAPID_PUBLIC" if @public_key.empty?
94
+ missing << "TINA4_VAPID_PRIVATE" if @private_key.empty?
95
+ raise PushError, "Web Push is configured but missing: #{missing.join(', ')}" unless missing.empty?
96
+ self.class.send(:require_openssl)
97
+ [@subject, @public_key, @private_key]
98
+ end
99
+
100
+ def self.require_openssl
101
+ return if defined?(OpenSSL::PKey::EC)
102
+
103
+ raise PushError, "Web Push requires Ruby's OpenSSL stdlib capability; rebuild Ruby with OpenSSL support"
104
+ end
105
+
106
+ def self.b64(value)
107
+ Base64.urlsafe_encode64(value, padding: false)
108
+ end
109
+
110
+ def decode(value, name)
111
+ raise PushError, "#{name} must be a non-empty base64url string" unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9_-]+\z/)
112
+
113
+ Base64.urlsafe_decode64(value)
114
+ rescue ArgumentError => e
115
+ raise PushError, "#{name} must be base64url encoded: #{e.message}"
116
+ end
117
+
118
+ def payload_bytes(payload)
119
+ return payload.b if payload.is_a?(String)
120
+ JSON.generate(payload)
121
+ rescue JSON::GeneratorError => e
122
+ raise PushError, "Push payload is not JSON serializable: #{e.message}"
123
+ end
124
+
125
+ def private_key_from_raw(private, public)
126
+ body = OpenSSL::ASN1::Sequence.new([
127
+ OpenSSL::ASN1::Integer.new(1),
128
+ OpenSSL::ASN1::OctetString.new(private),
129
+ OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId.new("prime256v1")], 0, :CONTEXT_SPECIFIC),
130
+ OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::BitString.new(public)], 1, :CONTEXT_SPECIFIC)
131
+ ])
132
+ OpenSSL::PKey.read(body.to_der)
133
+ end
134
+
135
+ def public_key_from_raw(public)
136
+ algorithm = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new("id-ecPublicKey"), OpenSSL::ASN1::ObjectId.new("prime256v1")])
137
+ OpenSSL::PKey.read(OpenSSL::ASN1::Sequence.new([algorithm, OpenSSL::ASN1::BitString.new(public)]).to_der)
138
+ end
139
+
140
+ def encrypt(payload, subscription)
141
+ raise PushError, "Push payload is too large; maximum is #{MAX_PAYLOAD} bytes" if payload.bytesize > MAX_PAYLOAD
142
+ keys = subscription.is_a?(Hash) ? (subscription["keys"] || subscription[:keys] || {}) : {}
143
+ p256dh = keys["p256dh"] || keys[:p256dh]
144
+ auth = keys["auth"] || keys[:auth]
145
+ client = decode(p256dh.to_s, "subscription.keys.p256dh")
146
+ auth_secret = decode(auth.to_s, "subscription.keys.auth")
147
+ raise PushError, "subscription.keys.p256dh must be a 65-byte P-256 public key" unless client.bytesize == 65 && client.getbyte(0) == 4
148
+ raise PushError, "subscription.keys.auth must be a 16-byte authentication secret" unless auth_secret.bytesize == 16
149
+
150
+ ephemeral = OpenSSL::PKey::EC.generate("prime256v1")
151
+ server = ephemeral.public_key.to_bn.to_s(2)
152
+ shared = ephemeral.derive(public_key_from_raw(client))
153
+ ikm = hkdf(hmac(auth_secret, shared), "WebPush: info\0" + client + server, 32)
154
+ salt = OpenSSL::Random.random_bytes(16)
155
+ prk = hmac(salt, ikm)
156
+ cek = hkdf(prk, "Content-Encoding: aes128gcm\0", 16)
157
+ nonce = hkdf(prk, "Content-Encoding: nonce\0", 12)
158
+ cipher = OpenSSL::Cipher.new("aes-128-gcm")
159
+ cipher.encrypt
160
+ cipher.key = cek
161
+ cipher.iv = nonce
162
+ ciphertext = cipher.update(payload + "\x02") + cipher.final
163
+ tag = cipher.auth_tag
164
+ salt + [RECORD_SIZE].pack("N") + [server.bytesize].pack("C") + server + ciphertext + tag
165
+ end
166
+
167
+ def hmac(key, value)
168
+ OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, key, value)
169
+ end
170
+
171
+ def hkdf(prk, info, length)
172
+ output = +""
173
+ previous = +""
174
+ counter = 1
175
+ while output.bytesize < length
176
+ previous = hmac(prk, previous + info + [counter].pack("C"))
177
+ output << previous
178
+ counter += 1
179
+ raise PushError, "HKDF output is too large" if counter > 255
180
+ end
181
+ output.byteslice(0, length)
182
+ end
183
+
184
+ def vapid_token(uri, subject, private, public)
185
+ aud = "#{uri.scheme}://#{uri.host}#{uri.port && ![80, 443].include?(uri.port) ? ":#{uri.port}" : ""}"
186
+ header = self.class.send(:b64, JSON.generate({ typ: "JWT", alg: "ES256" }))
187
+ claims = self.class.send(:b64, JSON.generate({ aud: aud, exp: Time.now.to_i + 43_200, sub: subject }))
188
+ input = "#{header}.#{claims}"
189
+ signature = private_key_from_raw(private, public).sign(OpenSSL::Digest::SHA256.new, input)
190
+ asn = OpenSSL::ASN1.decode(signature)
191
+ raw = [asn.value[0].value.to_s(2), asn.value[1].value.to_s(2)].map { |value| value.rjust(32, "\0") }.join
192
+ "#{input}.#{self.class.send(:b64, raw)}"
193
+ end
194
+ end
195
+ end
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.134"
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,7 +1,7 @@
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.134
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
@@ -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