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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +2 -2
- data/lib/tina4/dev_admin.rb +30 -10
- data/lib/tina4/push.rb +204 -0
- data/lib/tina4/rack_app.rb +12 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +2 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96ecd0fafd99947cd5435ae06d6c143232ddefb23716b6f7ed1c5f87fc2652d5
|
|
4
|
+
data.tar.gz: a333fd4f697a9d7284e15809c9e6f9e626db38f1c11bd92582b76c08103d71dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 **
|
|
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
|
|
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/dev_admin.rb
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
862
|
+
uri = URI.parse(url)
|
|
850
863
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
851
|
-
http.use_ssl =
|
|
864
|
+
http.use_ssl = (uri.scheme == "https")
|
|
852
865
|
http.open_timeout = 5
|
|
853
866
|
http.read_timeout = 5
|
|
854
|
-
|
|
855
|
-
resp
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
861
|
-
#
|
|
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
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -1033,6 +1033,14 @@ module Tina4
|
|
|
1033
1033
|
el.className = 't4-ok';
|
|
1034
1034
|
el.innerHTML = 'Latest: <strong class="t4-ok">v' + latest + '</strong> — 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
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.
|
|
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-
|
|
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
|