samagotchi 0.110.0 → 0.111.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/docs/architecture.md +8 -5
  4. data/docs/cli.md +3 -0
  5. data/docs/configuration.md +43 -0
  6. data/docs/internals/engine.md +39 -10
  7. data/docs/internals/participants.md +1 -1
  8. data/docs/internals/remote-sessions.md +3 -3
  9. data/docs/internals/web-push.md +200 -0
  10. data/docs/internals/working-set.md +7 -6
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +4 -0
  14. data/lib/samagotchi/engine.rb +146 -490
  15. data/lib/samagotchi/engine_snapshots.rb +223 -0
  16. data/lib/samagotchi/event_fanout.rb +236 -0
  17. data/lib/samagotchi/model_binding.rb +138 -0
  18. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  19. data/lib/samagotchi/send_command.rb +2 -1
  20. data/lib/samagotchi/session.rb +12 -3
  21. data/lib/samagotchi/session_access/remote.rb +2 -1
  22. data/lib/samagotchi/session_access.rb +7 -3
  23. data/lib/samagotchi/session_manager.rb +5 -0
  24. data/lib/samagotchi/turn_endings.rb +137 -0
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +27 -1377
  27. data/lib/samagotchi/web/grants.rb +3 -12
  28. data/lib/samagotchi/web/message_display.rb +274 -0
  29. data/lib/samagotchi/web/private_files.rb +31 -0
  30. data/lib/samagotchi/web/public/app.js +30 -6
  31. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  32. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  33. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  34. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  35. data/lib/samagotchi/web/public/index.html +4 -0
  36. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  37. data/lib/samagotchi/web/public/notifications.js +137 -3
  38. data/lib/samagotchi/web/public/notify.js +1 -1
  39. data/lib/samagotchi/web/public/push.js +95 -0
  40. data/lib/samagotchi/web/public/stage_view.js +8 -0
  41. data/lib/samagotchi/web/public/sw.js +101 -0
  42. data/lib/samagotchi/web/public/turn_events.js +22 -0
  43. data/lib/samagotchi/web/push/attention.rb +105 -0
  44. data/lib/samagotchi/web/push/client.rb +103 -0
  45. data/lib/samagotchi/web/push/crypto.rb +126 -0
  46. data/lib/samagotchi/web/push/notifier.rb +230 -0
  47. data/lib/samagotchi/web/push/store.rb +285 -0
  48. data/lib/samagotchi/web/push.rb +73 -0
  49. data/lib/samagotchi/web/push_routes.rb +99 -0
  50. data/lib/samagotchi/web/server.rb +26 -2
  51. data/lib/samagotchi/web/session_control.rb +441 -0
  52. data/lib/samagotchi/web/session_create.rb +384 -0
  53. data/lib/samagotchi/web/session_show.rb +411 -0
  54. data/lib/samagotchi/worker.rb +3 -2
  55. metadata +25 -1
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require_relative "crypto"
8
+
9
+ module Samagotchi
10
+ module Web
11
+ module Push
12
+ # One push to one device: the payload encrypted for its keys
13
+ # (Crypto.encrypt), POSTed to its endpoint with chi web's VAPID header,
14
+ # and the push service's answer as a Result. No retry (P1). See
15
+ # docs/internals/web-push.md.
16
+ class Client
17
+ OPEN_TIMEOUT = 5
18
+ READ_TIMEOUT = 10
19
+
20
+ # +status+ :ok (201/200/202), :gone (404/410: the device dropped
21
+ # it), :bad_jwt (403 BadJwtToken: the push service refused chi's
22
+ # VAPID token) or :failed; +code+ the HTTP status (nil when none
23
+ # came), +error+ a short word for a failure, +ms+ the call's time.
24
+ Result = Data.define(:status, :code, :ms, :error) do
25
+ def initialize(error: nil, **) = super
26
+ def ok? = status == :ok
27
+ end
28
+
29
+ # Net::HTTP over TLS, the timeouts above; a transport takes the URI
30
+ # and the request and returns the response (specs inject one).
31
+ TLS = lambda do |uri, request|
32
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
33
+ http.request(request)
34
+ end
35
+ end
36
+
37
+ # What a failed connection says in the log: never its message (it
38
+ # may carry the endpoint).
39
+ NETWORK_ERRORS = [SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Timeout::Error,
40
+ Net::HTTPBadResponse, Net::ProtocolError].freeze
41
+
42
+ # @param key [OpenSSL::PKey::EC] chi web's VAPID key (Store#vapid_key)
43
+ # @param contact [String] the VAPID sub (Crypto.valid_sub?)
44
+ # @param transport [#call] (uri, request) → Net::HTTPResponse
45
+ # @param clock [#call] the Time now (the JWT's exp)
46
+ # @param monotonic [#call] seconds, for +ms+
47
+ def initialize(key:, contact:, transport: TLS, clock: -> { Time.now },
48
+ monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
49
+ @key = key
50
+ @contact = contact
51
+ @transport = transport
52
+ @clock = clock
53
+ @monotonic = monotonic
54
+ end
55
+
56
+ # Never the key.
57
+ def inspect = "#<#{self.class.name} #{@contact}>"
58
+ alias to_s inspect
59
+
60
+ # @param sub [Store::Sub]
61
+ # @param payload [Hash] the JSON the service worker reads
62
+ # @param ttl [Integer] seconds the push service keeps it for an
63
+ # offline device
64
+ # @param urgency ["high", "normal"]
65
+ # @return [Result]
66
+ def deliver(sub, payload, ttl:, urgency:)
67
+ started = @monotonic.call
68
+ response = @transport.call(URI(sub.endpoint), request(sub, payload, ttl: ttl, urgency: urgency))
69
+ result_for(response, ms(started))
70
+ rescue *NETWORK_ERRORS => e
71
+ Result.new(status: :failed, code: nil, ms: ms(started), error: e.is_a?(Timeout::Error) ? "timeout" : e.class.name)
72
+ end
73
+
74
+ private
75
+
76
+ def request(sub, payload, ttl:, urgency:)
77
+ body = Crypto.encrypt(JSON.generate(payload), p256dh: sub.p256dh_bytes, auth: sub.auth_bytes)
78
+ Net::HTTP::Post.new(URI(sub.endpoint)).tap do |req|
79
+ req["Content-Encoding"] = "aes128gcm"
80
+ req["Content-Type"] = "application/octet-stream"
81
+ req["TTL"] = ttl.to_i.to_s
82
+ req["Urgency"] = urgency
83
+ req["Authorization"] = Crypto.vapid_authorization(sub.endpoint, key: @key, sub: @contact, now: @clock.call)
84
+ req.body = body
85
+ end
86
+ end
87
+
88
+ def result_for(response, elapsed)
89
+ code = response.code.to_i
90
+ status = case code
91
+ when 200, 201, 202 then :ok
92
+ when 404, 410 then :gone
93
+ when 403 then response.body.to_s.include?("BadJwtToken") ? :bad_jwt : :failed
94
+ else :failed
95
+ end
96
+ Result.new(status: status, code: code, ms: elapsed, error: status == :failed ? code.to_s : nil)
97
+ end
98
+
99
+ def ms(started) = ((@monotonic.call - started) * 1000).round
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module Samagotchi
8
+ module Web
9
+ module Push
10
+ # Web Push's two pieces of crypto, on OpenSSL alone (no gem): the
11
+ # message body a push service carries to the device (RFC 8291's
12
+ # aes128gcm, RFC 8188: one record, no padding), and the VAPID
13
+ # Authorization header that says the push comes from chi web's key
14
+ # (RFC 8292: an ES256 JWT for the endpoint's origin). The RFC 8291
15
+ # Appendix A vector pins #encrypt byte for byte
16
+ # (spec/web/push/crypto_spec.rb). See docs/internals/web-push.md.
17
+ module Crypto
18
+ module_function
19
+
20
+ GROUP = "prime256v1"
21
+ # RFC 8188's record size: one record holds a push message (< 4 KB).
22
+ RECORD_SIZE = 4096
23
+ # A VAPID token lives this long (RFC 8292 allows up to 24 h).
24
+ JWT_TTL = 12 * 3600
25
+
26
+ def b64u(bytes) = [bytes].pack("m0").tr("+/", "-_").delete("=")
27
+
28
+ # base64url with or without its padding; ArgumentError when it
29
+ # isn't base64url.
30
+ def unb64u(text)
31
+ text = text.to_s
32
+ raise ArgumentError, "not base64url" unless text.match?(/\A[A-Za-z0-9_-]*={0,2}\z/)
33
+
34
+ text = text.delete_suffix("=").delete_suffix("=")
35
+ raise ArgumentError, "not base64url" if text.length % 4 == 1
36
+
37
+ (text.tr("-_", "+/") + ("=" * ((4 - (text.length % 4)) % 4))).unpack1("m0")
38
+ end
39
+
40
+ # A new P-256 key (chi web's VAPID key; a message's sender key).
41
+ def generate_key = OpenSSL::PKey::EC.generate(GROUP)
42
+
43
+ # The 65-byte uncompressed point of +key+'s public half.
44
+ def raw_public(key) = key.public_key.to_octet_string(:uncompressed)
45
+
46
+ # What the page hands pushManager.subscribe as applicationServerKey.
47
+ def public_key_b64(key) = b64u(raw_public(key))
48
+
49
+ # A P-256 public key from its 65-byte uncompressed point (OpenSSL 3
50
+ # keys are immutable: built through an SPKI DER).
51
+ def ec_public(raw)
52
+ spki = OpenSSL::ASN1::Sequence([
53
+ OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
54
+ OpenSSL::ASN1::ObjectId(GROUP)]),
55
+ OpenSSL::ASN1::BitString(raw)
56
+ ])
57
+ OpenSSL::PKey::EC.new(spki.to_der)
58
+ end
59
+
60
+ # A P-256 key pair from its 32-byte private scalar and its public
61
+ # point (RFC 8291's fixed sender key, a spec's receiver).
62
+ def ec_private(priv, pub)
63
+ der = OpenSSL::ASN1::Sequence([
64
+ OpenSSL::ASN1::Integer(1),
65
+ OpenSSL::ASN1::OctetString(priv),
66
+ OpenSSL::ASN1::ObjectId(GROUP, 0, :EXPLICIT),
67
+ OpenSSL::ASN1::BitString(pub, 1, :EXPLICIT)
68
+ ])
69
+ OpenSSL::PKey::EC.new(der.to_der)
70
+ end
71
+
72
+ def hkdf(salt, ikm, info, length) = OpenSSL::KDF.hkdf(ikm, salt: salt, info: info, length: length, hash: "SHA256")
73
+
74
+ # RFC 8291 §3.4: +plaintext+ for the device whose subscription keys
75
+ # are +p256dh+ (its 65-byte public point) and +auth+ (16 bytes), as
76
+ # one aes128gcm record (RFC 8188 §2: salt, rs, idlen, keyid, then the
77
+ # record with its 0x02 delimiter and no padding). +salt+ and
78
+ # +sender_key+ are fresh per message; the RFC vector fixes them.
79
+ # @return [String] the request body (binary)
80
+ def encrypt(plaintext, p256dh:, auth:, salt: OpenSSL::Random.random_bytes(16), sender_key: generate_key)
81
+ sender_public = raw_public(sender_key)
82
+ shared = sender_key.derive(ec_public(p256dh))
83
+ ikm = hkdf(auth, shared, "WebPush: info\0".b + p256dh.b + sender_public, 32)
84
+ cek = hkdf(salt, ikm, "Content-Encoding: aes128gcm\0".b, 16)
85
+ nonce = hkdf(salt, ikm, "Content-Encoding: nonce\0".b, 12)
86
+ cipher = OpenSSL::Cipher.new("aes-128-gcm").encrypt
87
+ cipher.key = cek
88
+ cipher.iv = nonce
89
+ record = cipher.update("#{plaintext.b}\x02".b) + cipher.final + cipher.auth_tag
90
+ salt.b + [RECORD_SIZE].pack("N") + [sender_public.bytesize].pack("C") + sender_public + record
91
+ end
92
+
93
+ # RFC 8292: the Authorization header for a push to +endpoint+, signed
94
+ # with +key+ (chi web's VAPID key): aud = the endpoint's origin, exp
95
+ # = +now+ + JWT_TTL, sub = +sub+ (a contact URL: valid_sub?).
96
+ # @param now [Time]
97
+ def vapid_authorization(endpoint, key:, sub:, now: Time.now)
98
+ claims = { aud: origin_of(endpoint), exp: now.to_i + JWT_TTL, sub: sub }
99
+ input = "#{b64u(JSON.generate({ typ: "JWT", alg: "ES256" }))}.#{b64u(JSON.generate(claims))}"
100
+ # ES256 wants r‖s, 32 bytes each; OpenSSL signs DER.
101
+ r, s = OpenSSL::ASN1.decode(key.sign("SHA256", input)).value.map { |int| int.value.to_s(2).rjust(32, "\0") }
102
+ "vapid t=#{input}.#{b64u(r + s)}, k=#{public_key_b64(key)}"
103
+ end
104
+
105
+ # scheme://host[:port] of a URL, the port only when it isn't the
106
+ # scheme's default.
107
+ def origin_of(url)
108
+ uri = URI(url)
109
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
110
+ end
111
+
112
+ # A VAPID sub Apple takes: a mailto: with an @, or an https URL with
113
+ # a host. A bare "chi@localhost" gets 403 BadJwtToken (the spike).
114
+ def valid_sub?(sub)
115
+ text = sub.to_s
116
+ return text.match?(/\Amailto:[^@\s]+@[^@\s]+\z/) if text.start_with?("mailto:")
117
+
118
+ uri = URI.parse(text)
119
+ uri.scheme == "https" && !uri.host.to_s.empty?
120
+ rescue URI::InvalidURIError
121
+ false
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "attention"
5
+ require_relative "../../log"
6
+
7
+ module Samagotchi
8
+ module Web
9
+ module Push
10
+ # chi web's one sender of pushes: a SessionHub subscriber that keeps
11
+ # the summaries the page's trackAttention keeps (seeded by the
12
+ # subscribe's snapshot, so what was open before never pushes), turns
13
+ # each new approval or question (Attention.for) into a show, and
14
+ # hands it to its own sender thread through a Queue; one answered
15
+ # anywhere (Attention.closed_keys) only cancels its show's hold.
16
+ #
17
+ # Every push it sends shows a notification: WebKit removes a
18
+ # subscription after a few pushes that show none (its silent-push
19
+ # quota), so there is no close push. A show carries +open+, the keys
20
+ # still open, and the badge, both from the sink's summaries
21
+ # (Attention.open_keys): the service worker closes the answered ones
22
+ # when it shows the new one, and the page when it opens.
23
+ #
24
+ # #call is the hub's sink: it runs under the hub's Monitor, on the
25
+ # tick thread and on chi web's request threads (SessionHub#touch), so
26
+ # it only enqueues; a push is a 0.5 s https call, made on the sender
27
+ # thread. There a show waits its hold (HOLD, or the event's own) and
28
+ # is pushed only while still open, to every device subscribed on this
29
+ # run's public origin. See docs/internals/web-push.md.
30
+ class Notifier
31
+ HOLD = 3.0
32
+ # Waiting kinds: kept 12 h for an offline phone, delivered at once.
33
+ SHOW_TTL = 12 * 3600
34
+ # A show carries +open+ only while its JSON stays this small: one
35
+ # aes128gcm record leaves 3993 bytes of plaintext under Web Push's
36
+ # 4096 (86 header, 1 delimiter, 16 tag). Longer, it goes without
37
+ # (and the worker cleans up nothing).
38
+ OPEN_LIMIT = 3000
39
+ STOP_JOIN = 2.0
40
+
41
+ Show = Data.define(:attention, :title, :body, :at)
42
+ Close = Data.define(:keys)
43
+
44
+ # @param store [Store] the devices (and the key the client signs with)
45
+ # @param client [#deliver] Client
46
+ # @param origin [String] this run's public origin: only the
47
+ # subscriptions made on it are sent to
48
+ # @param now [#call] monotonic seconds (specs move it)
49
+ def initialize(store:, client:, origin:, hold: HOLD, now: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
50
+ @store = store
51
+ @client = client
52
+ @origin = origin
53
+ @hold = hold
54
+ @now = now
55
+ @queue = Queue.new
56
+ # The sink's side (under @mutex): the last summary per session.
57
+ @mutex = Mutex.new
58
+ @by_id = {}
59
+ @seeded = false
60
+ @early = []
61
+ # The sender's side (its thread only, or #pump in a spec).
62
+ @pending = {} # key → Show waiting its hold
63
+ @refused = false
64
+ @thread = nil
65
+ @handle = nil
66
+ end
67
+
68
+ # Subscribes to +hub+ (its snapshot is the seed) and starts the
69
+ # sender thread. Call it after a hub.scan, or the snapshot is empty
70
+ # and the first scan's events push every open approval.
71
+ # @param sender [Boolean] false: no thread, a spec drives #pump
72
+ def start(hub, sender: true)
73
+ @handle, summaries = hub.subscribe(self, snapshot: true)
74
+ seed(summaries)
75
+ @thread = Thread.new { run } if sender
76
+ self
77
+ end
78
+
79
+ # Unsubscribes and ends the sender thread; holds still waiting are
80
+ # dropped, a push in flight may finish (joined with a timeout).
81
+ def stop
82
+ @handle&.unsubscribe
83
+ @handle = nil
84
+ @queue << :stop
85
+ @thread&.join(STOP_JOIN)
86
+ @thread = nil
87
+ nil
88
+ end
89
+
90
+ # The hub's sink (SessionHub::Event): never blocks.
91
+ def call(event)
92
+ @mutex.synchronize do
93
+ next @early << event unless @seeded
94
+
95
+ notice(event)
96
+ end
97
+ end
98
+
99
+ # The seed: the summaries as the subscribe saw them. Events that came
100
+ # before it (newer than the snapshot) are applied after it.
101
+ def seed(summaries)
102
+ @mutex.synchronize do
103
+ summaries.each { |summary| @by_id[summary[:id]] = summary if summary[:id] }
104
+ @seeded = true
105
+ @early.each { |event| notice(event) }
106
+ @early.clear
107
+ end
108
+ end
109
+
110
+ # What is queued, handled now, then the shows whose hold is up
111
+ # pushed; the sender thread's step (specs call it with no thread).
112
+ def pump
113
+ loop do
114
+ message = @queue.pop(true)
115
+ return if message == :stop
116
+
117
+ handle(message)
118
+ rescue ThreadError
119
+ break
120
+ end
121
+ flush
122
+ end
123
+
124
+ private
125
+
126
+ def notice(event)
127
+ case event.type
128
+ when "session"
129
+ summary = event.data[:session]
130
+ return unless summary.is_a?(Hash) && summary[:id]
131
+
132
+ prev = @by_id[summary[:id]]
133
+ @by_id[summary[:id]] = summary
134
+ keys = Attention.closed_keys(prev, summary)
135
+ @queue << Close.new(keys: keys) unless keys.empty?
136
+ attention = Attention.for(prev, summary)
137
+ enqueue_show(attention, summary) if attention
138
+ when "session_gone"
139
+ prev = @by_id.delete(event.data[:id])
140
+ keys = Attention.closed_keys(prev, nil)
141
+ @queue << Close.new(keys: keys) unless keys.empty?
142
+ end
143
+ end
144
+
145
+ def enqueue_show(attention, summary)
146
+ title, body = Attention.text(summary, attention.reason)
147
+ @queue << Show.new(attention: attention, title: title, body: body, at: @now.call)
148
+ end
149
+
150
+ def run
151
+ loop do
152
+ due = @pending.values.map { |show| due_at(show) }.min
153
+ wait = due && [due - @now.call, 0].max
154
+ message = @queue.pop(timeout: wait)
155
+ break if message == :stop
156
+
157
+ handle(message) if message
158
+ flush
159
+ rescue StandardError => e
160
+ Log.exception(:web, "push_sender_failed", e)
161
+ end
162
+ end
163
+
164
+ def handle(message)
165
+ case message
166
+ when Show then @pending[message.attention.key] = message
167
+ # Answered anywhere: a show still in its hold is dropped. Nothing
168
+ # is pushed (a push that shows nothing counts against the quota).
169
+ when Close then message.keys.each { |key| @pending.delete(key) }
170
+ end
171
+ end
172
+
173
+ def due_at(show) = show.at + (show.attention.hold || @hold)
174
+
175
+ def flush
176
+ now = @now.call
177
+ due = @pending.values.select { |show| due_at(show) <= now }
178
+ return if due.empty?
179
+
180
+ due.each { |show| @pending.delete(show.attention.key) }
181
+ open = @mutex.synchronize { Attention.open_keys(@by_id) }
182
+ due.each do |show|
183
+ key = show.attention.key
184
+ next unless open.include?(key)
185
+
186
+ deliver(show_payload(show, open), kind: show.attention.reason)
187
+ end
188
+ end
189
+
190
+ def show_payload(show, open)
191
+ payload = { v: 1, kind: "show", tag: show.attention.key, title: show.title, body: show.body,
192
+ url: "/#/s/#{show.attention.session_id}", badge: open.size }
193
+ with_open = payload.merge(open: open)
194
+ JSON.generate(with_open).bytesize <= OPEN_LIMIT ? with_open : payload
195
+ end
196
+
197
+ # @param kind [String] the reason, for the log line
198
+ # @return [Boolean] whether any device took it
199
+ def deliver(payload, kind:)
200
+ return false if @refused
201
+
202
+ @store.for_origin(@origin).map do |sub|
203
+ result = @client.deliver(sub, payload, ttl: SHOW_TTL, urgency: "high")
204
+ record(sub, result, kind)
205
+ result.ok?
206
+ end.any?
207
+ end
208
+
209
+ def record(sub, result, kind)
210
+ fields = { device: sub.id, code: result.code, ms: result.ms, kind: kind }
211
+ case result.status
212
+ when :ok
213
+ Log.info(:web, "push_sent", **fields)
214
+ @store.mark(sub.id, ok: true)
215
+ when :gone
216
+ Log.info(:web, "push_gone", **fields)
217
+ @store.remove(sub.id)
218
+ when :bad_jwt
219
+ @refused = true
220
+ Log.warn(:web, "push_bad_jwt", echo: "[chi web] the push service refused chi's VAPID token: check " \
221
+ "web.push.contact (no pushes until chi web restarts)", **fields)
222
+ else
223
+ Log.warn(:web, "push_failed", error: result.error, **fields)
224
+ @store.mark(sub.id, error: result.error)
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end