where_is_waldo 0.1.5 → 0.1.7

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: 9caee3a6cb5747d9ce5ab460315fdaca5a9f05f7521dbe07f36a6d9fe5de48d9
4
- data.tar.gz: f7e293f87b04039c7a2dbc21e6a80ef574a773438800588a9f87255b9336497a
3
+ metadata.gz: 0daa39b5301b15f20943db48e10565bb18c7a33e12917551d6f9bea954311e20
4
+ data.tar.gz: 4ffb5a45341e48e9d4bc5f8dd08c9cf5a970dc0939de4e6e869d8a8bfa14c99b
5
5
  SHA512:
6
- metadata.gz: 95e63474cdbfff6c224b4bc4882bba3fb96cb157bf73a7769a1755f8b6326cba60e19aee6b06fc8fb58fc483496a68f8aa3e6bd03513a1782802ca123f8b9cb7
7
- data.tar.gz: 9abb3e965ddf1a13722e5700c2c3c8927c9b6b43593801e592670ec4ab09483ce2a3cf2da6a764dfbfb204e238a0107cbd9628067c5e06b0d7920a24412a0726
6
+ metadata.gz: 59dfa8c1cfb8c29b53dde974da7db16a3f8c16fd4ba24e7adcf1dedac260ec6bad7618d718e161dd7d11b466a3f06b87a8168cabaf0deabeca21c0d00ae71ba0
7
+ data.tar.gz: 27ad4732f42cc5257c1c9f39cbff46a6d21570e1c7c8fb695da6a9caabdf9ff92742f3e82633b793dfdce786beff64e8933839be6c3d850b5465d31326a86859
data/CHANGELOG.md CHANGED
@@ -3,6 +3,61 @@
3
3
  Notable changes to where_is_waldo. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/).
5
5
 
6
+ ## 0.1.7
7
+
8
+ ### Added
9
+
10
+ - **`last_activity` in roster member payloads.** Each member in a
11
+ `roster_snapshot` / `roster_delta` message now carries a `last_activity`
12
+ ISO8601 timestamp — the most recent `subject_active: true` heartbeat time
13
+ across all of the subject's live sessions (web, mobile, whichever). `nil`
14
+ when the subject has no live sessions. Lets hosts derive "seconds idle"
15
+ client-side (`Date.now() - new Date(last_activity)`) and apply their own
16
+ thresholds instead of being tied to the gem's active↔idle boundary.
17
+
18
+ Example: a picker that wants "stay green until 3 minutes without input"
19
+ no longer needs a server-side `activityTimeout` override — it reads
20
+ `member.last_activity` and thresholds against its own clock. The
21
+ server's own active/idle enum still ships in `status` for consumers who
22
+ do want it, and the roster still broadcasts only on transitions (no
23
+ extra bandwidth beyond the added field).
24
+
25
+ Payload shape:
26
+ ```json
27
+ { "id": 7, "status": "idle",
28
+ "devices": { "web": "idle", "mobile": "active" },
29
+ "last_activity": "2026-07-27T15:23:04Z",
30
+ "name": "Alan Smith" }
31
+ ```
32
+
33
+ Both adapters were already tracking `last_activity` per session — this
34
+ release plumbs it through the roster aggregation (max across sessions)
35
+ and normalizes to an ISO string in the broadcast payload, absorbing the
36
+ DB-adapter-returns-string / Redis-adapter-returns-Time mismatch at the
37
+ roster boundary.
38
+
39
+ ## 0.1.6
40
+
41
+ ### Security
42
+
43
+ - **The built-in connection no longer authenticates from client-supplied
44
+ `subject_id` in production.** `WhereIsWaldo::ApplicationCable::Connection`
45
+ previously fell back to `request.params[:subject_id]` whenever no
46
+ `authenticate_proc` was configured — so a client could connect with
47
+ `?subject_id=<anyone>` and be authenticated as that subject (cross-account
48
+ impersonation). The params path is now permitted **only in local dev/test**;
49
+ production **fails closed** (rejects the connection) unless `authenticate_proc`
50
+ (or a self-identified connection such as `JwtConnection`) establishes the
51
+ subject. Apps that mount the built-in connection in production without an
52
+ `authenticate_proc` will now be rejected — configure one.
53
+
54
+ ### Tests
55
+
56
+ - Added a two-account roster isolation spec (a viewer streams only their own
57
+ org's stream, sees only their own members, and receives none of another
58
+ account's deltas) and a connection spec proving the params-fallback path
59
+ fails closed in production.
60
+
6
61
  ## 0.1.5
7
62
 
8
63
  ### Security / correctness
data/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  Real-time presence tracking for Rails + React using ActionCable.
4
4
 
5
+ [![CI](https://github.com/byscott-io/where_is_waldo/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/byscott-io/where_is_waldo/actions/workflows/ci.yml)
6
+ [![Gem Version](https://img.shields.io/gem/v/where_is_waldo)](https://rubygems.org/gems/where_is_waldo)
7
+ [![npm version](https://img.shields.io/npm/v/@byscott-io/where-is-waldo)](https://www.npmjs.com/package/@byscott-io/where-is-waldo)
8
+ [![License](https://img.shields.io/github/license/byscott-io/where_is_waldo)](LICENSE)
9
+
5
10
  ## Features
6
11
 
7
12
  - **Presence tracking** - know who's online
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.5
1
+ 0.1.7
@@ -29,9 +29,16 @@ module WhereIsWaldo
29
29
  reject_unauthorized_connection
30
30
  end
31
31
  else
32
- # Default: try to get subject_id from params
33
- self.waldo_subject_id = request.params[:subject_id]
34
- self.waldo_session_id = request.params[:session_id] || generate_session_id
32
+ # No authenticate_proc configured. The only remaining identity source
33
+ # is client-controlled params, which must NEVER authenticate a real
34
+ # connection (a client could pass ?subject_id=<anyone> and impersonate
35
+ # them). Permit it ONLY in local dev/test as a convenience; fail closed
36
+ # everywhere else. Production apps must configure `authenticate_proc`
37
+ # (or identify the connection themselves, e.g. via JwtConnection).
38
+ if Rails.env.local?
39
+ self.waldo_subject_id = request.params[:subject_id]
40
+ self.waldo_session_id = request.params[:session_id] || generate_session_id
41
+ end
35
42
 
36
43
  reject_unauthorized_connection unless waldo_subject_id
37
44
  end
@@ -8,12 +8,18 @@ module WhereIsWaldo
8
8
  #
9
9
  # A member's presence is reported per device AND as an overall roll-up:
10
10
  #
11
- # { id: 7, status: "active", devices: { "web" => "active", "mobile" => "idle" } }
11
+ # { id: 7, status: "active",
12
+ # devices: { "web" => "active", "mobile" => "idle" },
13
+ # last_activity: "2026-07-27T15:23:04Z" }
12
14
  #
13
15
  # `devices[platform]` is that platform's own aggregate status (several browser
14
16
  # tabs roll up into one "web" status); `status` is the highest level across
15
17
  # all platforms — the "is the user active anywhere?" answer, while
16
- # `devices["mobile"]` answers "is the user active on mobile?". Each status is:
18
+ # `devices["mobile"]` answers "is the user active on mobile?". `last_activity`
19
+ # is the most recent input timestamp across all of the subject's live sessions
20
+ # (nil when offline), letting the client subtract from its own clock to derive
21
+ # seconds-idle and apply its own thresholds without new broadcasts. Each
22
+ # status is:
17
23
  #
18
24
  # active - a live session on that device is tab-visible AND working
19
25
  # idle - a live session is tab-visible but not actively using
@@ -182,15 +188,21 @@ module WhereIsWaldo
182
188
  # Reduce a subject's live sessions to per-device statuses plus an overall
183
189
  # roll-up. Sessions are grouped by platform (so several browser tabs form
184
190
  # one "web" status); the overall status is the highest across platforms.
191
+ # `last_activity` is the max across all sessions (any platform), so it
192
+ # answers "when did any of this subject's tabs last see input?".
185
193
  # @param sessions [Array<Hash>] presence hashes from the adapter
186
- # @return [Hash] { status: "active", devices: { "web" => "active", ... } }
194
+ # @return [Hash] { status:, devices:, last_activity: }
187
195
  def aggregate(sessions)
188
196
  devices = sessions.group_by { |s| platform(s) }
189
197
  .transform_values { |sess| platform_level(sess) }
190
198
  overall = devices.values.max_by { |level| RANK[level] } || :offline
199
+ # DB adapter returns last_activity as ISO string, Redis as Time; both sort
200
+ # correctly among themselves. Normalize to ISO string in the payload.
201
+ last = sessions.filter_map { |s| s[:last_activity] }.max
191
202
  {
192
203
  status: overall.to_s,
193
- devices: devices.transform_values(&:to_s)
204
+ devices: devices.transform_values(&:to_s),
205
+ last_activity: last.respond_to?(:iso8601) ? last.iso8601 : last
194
206
  }
195
207
  end
196
208
 
@@ -229,12 +241,13 @@ module WhereIsWaldo
229
241
  data.merge(
230
242
  id: id || record&.id,
231
243
  status: state[:status],
232
- devices: state[:devices]
244
+ devices: state[:devices],
245
+ last_activity: state[:last_activity]
233
246
  )
234
247
  end
235
248
 
236
249
  def offline_state
237
- { status: "offline", devices: {} }
250
+ { status: "offline", devices: {}, last_activity: nil }
238
251
  end
239
252
 
240
253
  def find_subject(subject_id)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: where_is_waldo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Gibson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-24 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails