where_is_waldo 0.1.3 → 0.1.5

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: b49938f0c49191db60463517da2d41115da11af0f7bb1c62f27065bb4598afc5
4
- data.tar.gz: a247e74c4c86bb2e3188ad1f6b1b057d890b5daba01442ae0b2fd4b25786b60a
3
+ metadata.gz: 9caee3a6cb5747d9ce5ab460315fdaca5a9f05f7521dbe07f36a6d9fe5de48d9
4
+ data.tar.gz: f7e293f87b04039c7a2dbc21e6a80ef574a773438800588a9f87255b9336497a
5
5
  SHA512:
6
- metadata.gz: 740783e4a9a19df3895bb62f113ff711f2d1815101e086388d604194beec71fe996803de241e0370f5685aa3d07f0ec5f1b4eff90fe654d19dd3f4cf9ac70436
7
- data.tar.gz: be170741d609ae6d904c2f29a73637139d71f17bd4d54dfed6a2a8c6c9a3662f0624131c690d5af11a8c6676774bff4c149f9a17134b5bb72080f676c475e7bb
6
+ metadata.gz: 95e63474cdbfff6c224b4bc4882bba3fb96cb157bf73a7769a1755f8b6326cba60e19aee6b06fc8fb58fc483496a68f8aa3e6bd03513a1782802ca123f8b9cb7
7
+ data.tar.gz: 9abb3e965ddf1a13722e5700c2c3c8927c9b6b43593801e592670ec4ab09483ce2a3cf2da6a764dfbfb204e238a0107cbd9628067c5e06b0d7920a24412a0726
data/CHANGELOG.md CHANGED
@@ -3,6 +3,98 @@
3
3
  Notable changes to where_is_waldo. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/).
5
5
 
6
+ ## 0.1.5
7
+
8
+ ### Security / correctness
9
+
10
+ - **Session keys are now namespaced by subject_id.** Both adapters previously
11
+ keyed session rows by `session_id` alone (`waldo:session:<sid>` in Redis;
12
+ `unique_by: session_column` upsert in the DB). If two authenticated
13
+ subjects independently supplied the same `session_id` — client-supplied
14
+ values, JWT `jti` reuse across users, etc. — one subject's `connect`
15
+ would overwrite the other's row, and subsequent `heartbeat`/`disconnect`
16
+ calls could reach into the wrong subject's presence. Now:
17
+ - **RedisAdapter**: session key is `waldo:session:<subject_id>:<session_id>`.
18
+ The reverse-map key `waldo:session_subject:<session_id>` is removed —
19
+ callers must pass `subject_id` to `heartbeat`, `session_status`, and
20
+ session-scoped `disconnect`, so the disambiguation is at the API
21
+ boundary rather than a lookup that could return the wrong subject.
22
+ - **DatabaseAdapter**: `Presence.upsert(unique_by: [subject_column,
23
+ session_column])`. `heartbeat` / `session_status` / session-scoped
24
+ `disconnect` all scope by both keys. The install generator's migration
25
+ template now creates a composite unique index on `(subject_column,
26
+ session_column)` instead of a unique index on `session_column` alone.
27
+
28
+ ### Breaking API changes
29
+
30
+ - `heartbeat` — `subject_id:` is now a required kwarg
31
+ (`heartbeat(session_id:, subject_id:, ...)`).
32
+ - `session_status` — signature is `session_status(session_id, subject_id)`
33
+ (previously `session_status(session_id)`).
34
+ - `disconnect(session_id:)` — now raises `ArgumentError` when called with
35
+ `session_id:` but no `subject_id:`. Subject-only `disconnect(subject_id:)`
36
+ and paired `disconnect(session_id:, subject_id:)` are the two supported
37
+ shapes.
38
+ - `Broadcaster.broadcast_to_session(session_id, subject_id, message_type, data)`
39
+ — `subject_id` inserted as the second positional argument (previously
40
+ `broadcast_to_session(session_id, message_type, data)`).
41
+
42
+ ### DB migration
43
+
44
+ Hosts on the `:database` adapter must swap the unique index on the presences
45
+ table:
46
+ ```ruby
47
+ remove_index :presences, :<session_column>
48
+ add_index :presences, [:<subject_column>, :<session_column>], unique: true
49
+ ```
50
+
51
+ ## 0.1.4
52
+
53
+ ### Fixed
54
+
55
+ - **Roster reads now route through the configured adapter.** Previously
56
+ `Roster.states_for` (which powers `state_for`, `snapshot`, `members_for`,
57
+ every roster delta) queried the `Presence` ActiveRecord model directly.
58
+ Under `adapter = :redis`, writes went to Redis but reads hit an empty (or
59
+ absent) database table — presence dots stayed grey no matter how many
60
+ heartbeats came in. `Roster` now calls `PresenceService.sessions_for_subjects`
61
+ which delegates to whichever adapter is configured, so writes and reads
62
+ share one store on every adapter.
63
+
64
+ ### Added
65
+
66
+ - `Adapters::Base#sessions_for_subjects(subject_ids, timeout:)` — bulk-read
67
+ live sessions grouped by subject id, with a correct-but-unoptimized default
68
+ implementation that fans out over `sessions_for_subject`. `DatabaseAdapter`
69
+ overrides it with a single bulk query (`Presence.where(subject_col => ids)`
70
+ + `includes(:subject)`); `RedisAdapter` overrides with per-subject reads
71
+ filtered by heartbeat threshold. Timeout defaults to `config.timeout`.
72
+ - `PresenceService.sessions_for_subjects` — public delegate so callers stay
73
+ off the adapter directly.
74
+ - `Configuration#suppress_presence_proc` — callable that decides, per
75
+ connection, whether to skip presence registration for that subscriber.
76
+ Receives the ActionCable connection; returns truthy to suppress. A
77
+ suppressed subscriber still subscribes normally (streams from
78
+ `where_is_waldo:subject:<id>`, receives broadcasts, can invoke channel
79
+ actions) — they just don't register a Presence row / heartbeat / roster
80
+ transition. Use for cases where the WebSocket session is legitimate but
81
+ shouldn't be counted as "the subject is present" (e.g. support-user
82
+ impersonation tabs).
83
+
84
+ ```ruby
85
+ config.suppress_presence_proc = ->(connection) {
86
+ connection.request.session[:su_user].present?
87
+ }
88
+ ```
89
+
90
+ ### Changed
91
+
92
+ - `Roster.aggregate` / `session_level` / `platform` now consume presence
93
+ hashes (`session[:tab_visible]`, `session[:metadata]`) — matching the
94
+ shape every adapter already returned — instead of ActiveRecord `Presence`
95
+ method calls. No behavior change for callers; existing per-device and
96
+ per-subject aggregation semantics preserved.
97
+
6
98
  ## 0.1.3
7
99
 
8
100
  ### Fixed
data/README.md CHANGED
@@ -120,6 +120,16 @@ WhereIsWaldo.configure do |config|
120
120
  config.roster_org = ->(user) { user.account }
121
121
  config.roster_members = ->(org) { org.users.active }
122
122
 
123
+ # Optional: skip presence registration for specific connections while
124
+ # keeping them subscribed for broadcasts. Return truthy to suppress. The
125
+ # subscriber still streams from where_is_waldo:subject:<id> and receives
126
+ # WhereIsWaldo.broadcast_to messages — they just don't count as present.
127
+ # Typical use: support-user impersonation tabs shouldn't light up as the
128
+ # impersonated user in teammates' rosters.
129
+ config.suppress_presence_proc = ->(connection) {
130
+ connection.request.session[:su_user].present?
131
+ }
132
+
123
133
  # Redis adapter
124
134
  # config.redis_client = Redis.new(url: ENV["REDIS_URL"])
125
135
  end
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.3
1
+ 0.1.5
@@ -5,6 +5,13 @@ module WhereIsWaldo
5
5
  def subscribed
6
6
  stream_from subject_stream
7
7
 
8
+ # The subject_stream subscription above is unconditional: even when the
9
+ # subscriber's presence is suppressed (see suppress_presence_proc), the
10
+ # tab is a legitimate consumer of WhereIsWaldo.broadcast_to signaling
11
+ # and shouldn't be cut off from messages just because it doesn't count
12
+ # as "present."
13
+ return if presence_suppressed?
14
+
8
15
  register_presence
9
16
 
10
17
  # Seed the local transition gate, resolve the roster delivery strategy for
@@ -16,7 +23,9 @@ module WhereIsWaldo
16
23
  end
17
24
 
18
25
  def unsubscribed
19
- WhereIsWaldo.disconnect(session_id: waldo_session_id)
26
+ return if presence_suppressed?
27
+
28
+ WhereIsWaldo.disconnect(session_id: waldo_session_id, subject_id: waldo_subject_id)
20
29
 
21
30
  # Recompute the subject's aggregate (they may still be present in another
22
31
  # tab/device) and announce the change to the org roster.
@@ -24,6 +33,8 @@ module WhereIsWaldo
24
33
  end
25
34
 
26
35
  def heartbeat(data)
36
+ return if presence_suppressed?
37
+
27
38
  data = data.with_indifferent_access
28
39
 
29
40
  tab_visible = data[:tab_visible] != false
@@ -31,6 +42,7 @@ module WhereIsWaldo
31
42
 
32
43
  WhereIsWaldo.heartbeat(
33
44
  session_id: waldo_session_id,
45
+ subject_id: waldo_subject_id,
34
46
  tab_visible: tab_visible,
35
47
  subject_active: subject_active,
36
48
  last_activity_at: data[:last_activity_at],
@@ -48,6 +60,22 @@ module WhereIsWaldo
48
60
 
49
61
  private
50
62
 
63
+ # Memoized per-subscription. Host apps configure suppress_presence_proc
64
+ # to gate WHICH subscriptions register presence — e.g. a support-user
65
+ # impersonation tab is a legitimate WS consumer but shouldn't count as
66
+ # the impersonated subject being "here." Nil proc → default behavior
67
+ # (presence always registered).
68
+ def presence_suppressed?
69
+ # Ivars are @wiw_-prefixed throughout this channel to avoid clobbering
70
+ # host-app ActionCable state; keep the prefix over the cop's rename.
71
+ # rubocop:disable Naming/MemoizedInstanceVariableName
72
+ return @wiw_presence_suppressed if defined?(@wiw_presence_suppressed)
73
+
74
+ proc = WhereIsWaldo.config.suppress_presence_proc
75
+ @wiw_presence_suppressed = proc ? !!proc.call(connection) : false
76
+ # rubocop:enable Naming/MemoizedInstanceVariableName
77
+ end
78
+
51
79
  def register_presence
52
80
  WhereIsWaldo.connect(
53
81
  session_id: waldo_session_id,
@@ -12,9 +12,11 @@ module WhereIsWaldo
12
12
  raise NotImplementedError
13
13
  end
14
14
 
15
- # Remove a presence
16
- # @param session_id [String] Session identifier (optional if subject_id provided)
17
- # @param subject_id [Integer/String] Subject identifier (optional)
15
+ # Remove a presence. Session-scoped disconnect requires both keys so
16
+ # a caller-supplied session_id can't disconnect another subject's row.
17
+ # Subject-only disconnect removes every session for that subject.
18
+ # @param session_id [String] Session identifier (requires subject_id when set)
19
+ # @param subject_id [Integer/String] Subject identifier
18
20
  # @return [Boolean] success
19
21
  def disconnect(session_id: nil, subject_id: nil)
20
22
  raise NotImplementedError
@@ -22,11 +24,14 @@ module WhereIsWaldo
22
24
 
23
25
  # Update heartbeat
24
26
  # @param session_id [String] Session identifier
27
+ # @param subject_id [Integer/String] Subject identifier (required —
28
+ # pairs with session_id to disambiguate colliding session ids across
29
+ # subjects)
25
30
  # @param tab_visible [Boolean] Is tab in foreground
26
31
  # @param subject_active [Boolean] Recent activity
27
32
  # @param metadata [Hash] Additional data
28
33
  # @return [Boolean] success
29
- def heartbeat(session_id:, tab_visible: true, subject_active: true, metadata: {})
34
+ def heartbeat(session_id:, subject_id:, tab_visible: true, subject_active: true, metadata: {})
30
35
  raise NotImplementedError
31
36
  end
32
37
 
@@ -44,10 +49,31 @@ module WhereIsWaldo
44
49
  raise NotImplementedError
45
50
  end
46
51
 
52
+ # Get live sessions for many subjects in one call, grouped by subject id.
53
+ # Subjects with no live sessions are omitted from the returned hash.
54
+ # Adapters should override for efficiency; this default is a correct but
55
+ # unoptimized fan-out over `sessions_for_subject`.
56
+ # @param subject_ids [Array<Integer/String>] Subject identifiers
57
+ # @param timeout [Integer, nil] Seconds threshold; sessions with
58
+ # `last_heartbeat` older than `Time.current - timeout` are excluded
59
+ # @return [Hash{Integer/String => Array<Hash>}] subject_id => sessions
60
+ def sessions_for_subjects(subject_ids, timeout: nil)
61
+ ids = Array(subject_ids).compact.uniq
62
+ return {} if ids.empty?
63
+
64
+ threshold = Time.current - (timeout || default_timeout)
65
+ ids.each_with_object({}) do |sid, memo|
66
+ live = sessions_for_subject(sid).select { |s| s[:last_heartbeat] && s[:last_heartbeat] >= threshold }
67
+ memo[sid] = live if live.any?
68
+ end
69
+ end
70
+
47
71
  # Get session status
48
72
  # @param session_id [String] Session identifier
73
+ # @param subject_id [Integer/String] Subject identifier — required
74
+ # for the same reason as heartbeat (see above)
49
75
  # @return [Hash, nil] Presence record or nil
50
- def session_status(session_id)
76
+ def session_status(session_id, subject_id)
51
77
  raise NotImplementedError
52
78
  end
53
79
 
@@ -19,8 +19,12 @@ module WhereIsWaldo
19
19
  updated_at: now
20
20
  }
21
21
 
22
+ # (subject, session) is the unique key — see the install migration
23
+ # template. Keying on session alone would let a caller-supplied
24
+ # session_id colliding across two subjects upsert onto each other's
25
+ # row.
22
26
  # rubocop:disable Rails/SkipsModelValidations -- intentional for performance
23
- Presence.upsert(attrs, unique_by: session_column)
27
+ Presence.upsert(attrs, unique_by: [subject_column, session_column])
24
28
  # rubocop:enable Rails/SkipsModelValidations
25
29
  true
26
30
  rescue StandardError => e
@@ -29,15 +33,21 @@ module WhereIsWaldo
29
33
  end
30
34
 
31
35
  def disconnect(session_id: nil, subject_id: nil)
36
+ raise ArgumentError, "disconnect(session_id:) requires subject_id:" if session_id && !subject_id
37
+
32
38
  scope = build_lookup_scope(session_id: session_id, subject_id: subject_id)
33
39
  scope.delete_all
34
40
  true
41
+ rescue ArgumentError
42
+ raise
35
43
  rescue StandardError => e
36
44
  Rails.logger.error "[WhereIsWaldo] Disconnect failed: #{e.message}"
37
45
  false
38
46
  end
39
47
 
40
- def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
48
+ # rubocop:disable Metrics/ParameterLists, Layout/LineLength
49
+ def heartbeat(session_id:, subject_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
50
+ # rubocop:enable Metrics/ParameterLists, Layout/LineLength
41
51
  now = Time.current
42
52
  updates = {
43
53
  last_heartbeat: now,
@@ -53,7 +63,9 @@ module WhereIsWaldo
53
63
  end
54
64
  updates[:metadata] = metadata if metadata.present?
55
65
 
56
- scope = Presence.where(session_column => session_id)
66
+ # Scope by both — a caller-supplied session_id shouldn't be able to
67
+ # heartbeat another subject's row.
68
+ scope = Presence.where(session_column => session_id, subject_column => subject_id)
57
69
 
58
70
  # rubocop:disable Rails/SkipsModelValidations -- intentional for performance
59
71
  scope.update_all(updates).positive?
@@ -77,8 +89,19 @@ module WhereIsWaldo
77
89
  scope.map(&:as_presence_hash)
78
90
  end
79
91
 
80
- def session_status(session_id)
81
- scope = Presence.where(session_column => session_id)
92
+ def sessions_for_subjects(subject_ids, timeout: nil)
93
+ ids = Array(subject_ids).compact.uniq
94
+ return {} if ids.empty?
95
+
96
+ threshold = (timeout || default_timeout).seconds.ago
97
+ scope = Presence.where(subject_column => ids).where("last_heartbeat > ?", threshold)
98
+ scope = scope.includes(:subject) if config.subject_class_constant
99
+ scope.group_by { |row| row[subject_column] }
100
+ .transform_values { |rows| rows.map(&:as_presence_hash) }
101
+ end
102
+
103
+ def session_status(session_id, subject_id)
104
+ scope = Presence.where(session_column => session_id, subject_column => subject_id)
82
105
  scope = scope.includes(:subject) if config.subject_class_constant
83
106
  scope.first&.as_presence_hash
84
107
  end
@@ -91,8 +114,8 @@ module WhereIsWaldo
91
114
  private
92
115
 
93
116
  def build_lookup_scope(session_id: nil, subject_id: nil)
94
- if session_id
95
- Presence.where(session_column => session_id)
117
+ if session_id && subject_id
118
+ Presence.where(session_column => session_id, subject_column => subject_id)
96
119
  elsif subject_id
97
120
  Presence.where(subject_column => subject_id)
98
121
  else
@@ -2,7 +2,9 @@
2
2
 
3
3
  module WhereIsWaldo
4
4
  module Adapters
5
- class RedisAdapter < BaseAdapter
5
+ # Redis backing needs more plumbing (keying, TTLs, pipelines) than the
6
+ # DB adapter, so it legitimately runs past the default class-length limit.
7
+ class RedisAdapter < BaseAdapter # rubocop:disable Metrics/ClassLength
6
8
  def connect(session_id:, subject_id:, metadata: {})
7
9
  now = Time.current.to_i
8
10
 
@@ -18,17 +20,15 @@ module WhereIsWaldo
18
20
  }
19
21
 
20
22
  redis.multi do |tx|
21
- # Store session data
22
- tx.set(session_key(session_id), presence_data.to_json, ex: ttl)
23
+ # Session key includes subject_id so two subjects with the same
24
+ # caller-supplied session_id can't overwrite each other's row.
25
+ tx.set(session_key(subject_id, session_id), presence_data.to_json, ex: ttl)
23
26
 
24
27
  # Add to online subjects sorted set with timestamp as score
25
28
  tx.zadd(online_subjects_key, now, subject_id)
26
29
 
27
30
  # Add to subject's sessions set
28
31
  tx.sadd(subject_sessions_key(subject_id), session_id)
29
-
30
- # Map session to subject for reverse lookup
31
- tx.set(session_subject_key(session_id), subject_id, ex: ttl)
32
32
  end
33
33
 
34
34
  true
@@ -39,19 +39,25 @@ module WhereIsWaldo
39
39
 
40
40
  def disconnect(session_id: nil, subject_id: nil)
41
41
  if session_id
42
- disconnect_session(session_id)
42
+ raise ArgumentError, "disconnect(session_id:) requires subject_id:" unless subject_id
43
+
44
+ disconnect_session(subject_id, session_id)
43
45
  elsif subject_id
44
46
  disconnect_subject(subject_id)
45
47
  end
46
48
 
47
49
  true
50
+ rescue ArgumentError
51
+ raise
48
52
  rescue StandardError => e
49
53
  Rails.logger.error "[WhereIsWaldo] Redis disconnect failed: #{e.message}"
50
54
  false
51
55
  end
52
56
 
53
- def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
54
- data = get_presence_data(session_id)
57
+ # rubocop:disable Metrics/ParameterLists, Layout/LineLength
58
+ def heartbeat(session_id:, subject_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
59
+ # rubocop:enable Metrics/ParameterLists, Layout/LineLength
60
+ data = get_presence_data(subject_id, session_id)
55
61
  return false unless data
56
62
 
57
63
  now = Time.current.to_i
@@ -67,7 +73,7 @@ module WhereIsWaldo
67
73
  data["metadata"] = data["metadata"].merge(metadata) if metadata.present?
68
74
 
69
75
  redis.multi do |tx|
70
- tx.set(session_key(session_id), data.to_json, ex: ttl)
76
+ tx.set(session_key(subject_id, session_id), data.to_json, ex: ttl)
71
77
  tx.zadd(online_subjects_key, now, data["subject_id"])
72
78
  end
73
79
 
@@ -88,13 +94,30 @@ module WhereIsWaldo
88
94
  session_ids = redis.smembers(subject_sessions_key(subject_id))
89
95
 
90
96
  session_ids.filter_map do |sid|
91
- data = get_presence_data(sid)
97
+ data = get_presence_data(subject_id, sid)
92
98
  build_presence_hash(data) if data
93
99
  end
94
100
  end
95
101
 
96
- def session_status(session_id)
97
- data = get_presence_data(session_id)
102
+ def sessions_for_subjects(subject_ids, timeout: nil)
103
+ ids = Array(subject_ids).compact.uniq
104
+ return {} if ids.empty?
105
+
106
+ threshold = Time.current.to_i - (timeout || default_timeout)
107
+ ids.each_with_object({}) do |sid, memo|
108
+ session_ids = redis.smembers(subject_sessions_key(sid))
109
+ live = session_ids.filter_map do |cid|
110
+ data = get_presence_data(sid, cid)
111
+ next unless data && data["last_heartbeat"].to_i >= threshold
112
+
113
+ build_presence_hash(data)
114
+ end
115
+ memo[sid] = live if live.any?
116
+ end
117
+ end
118
+
119
+ def session_status(session_id, subject_id)
120
+ data = get_presence_data(subject_id, session_id)
98
121
  return nil unless data
99
122
 
100
123
  build_presence_hash(data)
@@ -111,7 +134,7 @@ module WhereIsWaldo
111
134
  # Check if any sessions are still active
112
135
  session_ids = redis.smembers(subject_sessions_key(subject_id))
113
136
  all_stale = session_ids.all? do |sid|
114
- data = get_presence_data(sid)
137
+ data = get_presence_data(subject_id, sid)
115
138
  !data || data["last_heartbeat"].to_i < threshold
116
139
  end
117
140
 
@@ -141,8 +164,11 @@ module WhereIsWaldo
141
164
  config.redis_prefix || "where_is_waldo"
142
165
  end
143
166
 
144
- def session_key(session_id)
145
- "#{key_prefix}:session:#{session_id}"
167
+ # Session data key. Namespaced by subject_id so a caller-supplied
168
+ # session_id colliding across two authenticated subjects doesn't let one
169
+ # clobber the other's presence row (see CHANGELOG 0.1.5).
170
+ def session_key(subject_id, session_id)
171
+ "#{key_prefix}:session:#{subject_id}:#{session_id}"
146
172
  end
147
173
 
148
174
  def online_subjects_key
@@ -153,27 +179,17 @@ module WhereIsWaldo
153
179
  "#{key_prefix}:subject:#{subject_id}:sessions"
154
180
  end
155
181
 
156
- def session_subject_key(session_id)
157
- "#{key_prefix}:session_subject:#{session_id}"
158
- end
159
-
160
- def get_presence_data(session_id)
161
- json = redis.get(session_key(session_id))
182
+ def get_presence_data(subject_id, session_id)
183
+ json = redis.get(session_key(subject_id, session_id))
162
184
  return nil unless json
163
185
 
164
186
  JSON.parse(json)
165
187
  end
166
188
 
167
- def disconnect_session(session_id)
168
- data = get_presence_data(session_id)
169
- return unless data
170
-
171
- subject_id = data["subject_id"]
172
-
189
+ def disconnect_session(subject_id, session_id)
173
190
  redis.multi do |tx|
174
- tx.del(session_key(session_id))
191
+ tx.del(session_key(subject_id, session_id))
175
192
  tx.srem(subject_sessions_key(subject_id), session_id)
176
- tx.del(session_subject_key(session_id))
177
193
  end
178
194
 
179
195
  # If no more sessions for this subject, remove from online set
@@ -183,7 +199,7 @@ module WhereIsWaldo
183
199
 
184
200
  def disconnect_subject(subject_id)
185
201
  session_ids = redis.smembers(subject_sessions_key(subject_id))
186
- session_ids.each { |sid| disconnect_session(sid) }
202
+ session_ids.each { |sid| disconnect_session(subject_id, sid) }
187
203
  redis.zrem(online_subjects_key, subject_id)
188
204
  end
189
205
 
@@ -35,14 +35,16 @@ module WhereIsWaldo
35
35
 
36
36
  # Broadcast to a specific session
37
37
  # @param session_id [String] Session identifier
38
+ # @param subject_id [Integer/String] Subject identifier — required
39
+ # to disambiguate colliding session ids across subjects
38
40
  # @param message_type [String, Symbol] Message type
39
41
  # @param data [Hash] Message payload
40
- def broadcast_to_session(session_id, message_type, data = {})
41
- status = PresenceService.session_status(session_id)
42
+ def broadcast_to_session(session_id, subject_id, message_type, data = {})
43
+ status = PresenceService.session_status(session_id, subject_id)
42
44
  return false unless status
43
45
 
44
46
  message = build_message(message_type, data, target_session: session_id)
45
- ActionCable.server.broadcast(subject_stream(status[:subject_id]), message)
47
+ ActionCable.server.broadcast(subject_stream(subject_id), message)
46
48
  true
47
49
  end
48
50
 
@@ -29,14 +29,20 @@ module WhereIsWaldo
29
29
 
30
30
  # Update heartbeat for a session
31
31
  # @param session_id [String] Session identifier
32
+ # @param subject_id [Integer/String] Subject identifier (required —
33
+ # pairs with session_id to disambiguate colliding session ids across
34
+ # subjects)
32
35
  # @param tab_visible [Boolean] Is tab in foreground
33
36
  # @param subject_active [Boolean] Recent activity
34
37
  # @param last_activity_at [Integer] Unix timestamp (ms) of last user activity
35
38
  # @param metadata [Hash] Additional data to merge
36
39
  # @return [Boolean] success
37
- def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
40
+ # rubocop:disable Metrics/ParameterLists, Layout/LineLength
41
+ def heartbeat(session_id:, subject_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
42
+ # rubocop:enable Metrics/ParameterLists, Layout/LineLength
38
43
  adapter.heartbeat(
39
44
  session_id: session_id,
45
+ subject_id: subject_id,
40
46
  tab_visible: tab_visible,
41
47
  subject_active: subject_active,
42
48
  last_activity_at: last_activity_at,
@@ -74,8 +80,19 @@ module WhereIsWaldo
74
80
  # @return [Array<Hash>] Presence records
75
81
  delegate :sessions_for_subject, to: :adapter
76
82
 
83
+ # Get live sessions for many subjects in one call, grouped by subject id.
84
+ # Preferred over calling `sessions_for_subject` per id when the caller
85
+ # already has an id list (e.g. roster aggregation) — adapters can (and do)
86
+ # implement this as a bulk read.
87
+ # @param subject_ids [Array<Integer/String>] Subject identifiers
88
+ # @param timeout [Integer, nil] Seconds threshold (defaults to config.timeout)
89
+ # @return [Hash{Integer/String => Array<Hash>}] subject_id => sessions
90
+ delegate :sessions_for_subjects, to: :adapter
91
+
77
92
  # Get status of a specific session
78
93
  # @param session_id [String] Session identifier
94
+ # @param subject_id [Integer/String] Subject identifier — required
95
+ # for the same reason as heartbeat
79
96
  # @return [Hash, nil] Presence record or nil
80
97
  delegate :session_status, to: :adapter
81
98
 
@@ -165,25 +165,24 @@ module WhereIsWaldo
165
165
 
166
166
  private
167
167
 
168
- # Aggregate state for many subjects in one query, keyed by subject id.
168
+ # Aggregate state for many subjects in one adapter call, keyed by
169
+ # subject id. Routes through PresenceService so it matches whichever
170
+ # adapter is configured — reading Presence directly would only work on
171
+ # `:database`, but the roster snapshot needs to see whatever the writer
172
+ # sees under `:redis` too.
169
173
  def states_for(subject_ids, timeout: nil)
170
174
  ids = Array(subject_ids).compact.uniq
171
175
  return {} if ids.empty?
172
176
 
173
- threshold = (timeout || WhereIsWaldo.config.timeout).seconds.ago
174
- subject_col = Presence.subject_column
175
-
176
- rows = Presence.where(subject_col => ids)
177
- .where("last_heartbeat > ?", threshold)
178
- .to_a
179
-
180
- rows.group_by { |row| row[subject_col] }
181
- .transform_values { |sessions| aggregate(sessions) }
177
+ WhereIsWaldo::PresenceService
178
+ .sessions_for_subjects(ids, timeout: timeout)
179
+ .transform_values { |sessions| aggregate(sessions) }
182
180
  end
183
181
 
184
182
  # Reduce a subject's live sessions to per-device statuses plus an overall
185
183
  # roll-up. Sessions are grouped by platform (so several browser tabs form
186
184
  # one "web" status); the overall status is the highest across platforms.
185
+ # @param sessions [Array<Hash>] presence hashes from the adapter
187
186
  # @return [Hash] { status: "active", devices: { "web" => "active", ... } }
188
187
  def aggregate(sessions)
189
188
  devices = sessions.group_by { |s| platform(s) }
@@ -204,13 +203,13 @@ module WhereIsWaldo
204
203
  # a hidden tab / backgrounded app is :background; a visible/foreground
205
204
  # session is :active when working, else :idle.
206
205
  def session_level(session)
207
- return :background unless session.tab_visible
206
+ return :background unless session[:tab_visible]
208
207
 
209
- session.subject_active ? :active : :idle
208
+ session[:subject_active] ? :active : :idle
210
209
  end
211
210
 
212
211
  def platform(session)
213
- meta = session.metadata
212
+ meta = session[:metadata]
214
213
  value = meta && (meta["platform"] || meta[:platform])
215
214
  (value.presence || DEFAULT_PLATFORM).to_s
216
215
  end
@@ -22,10 +22,15 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration[7.0]
22
22
  t.timestamps
23
23
  end
24
24
 
25
- # Session must be unique
26
- add_index :<%= table_name %>, :<%= session_column %>, unique: true
27
-
28
- # Query by subject
25
+ # (subject, session) must be unique together. Keying by session alone
26
+ # would let two authenticated subjects that happen to collide on a
27
+ # session id (client-supplied values, JWT jti reuse, etc.) upsert onto
28
+ # each other's row.
29
+ add_index :<%= table_name %>, [:<%= subject_column %>, :<%= session_column %>], unique: true
30
+
31
+ # Session lookups (heartbeat, disconnect, session_status) always scope
32
+ # by (subject, session); the composite unique index above serves those.
33
+ # Query by subject alone
29
34
  add_index :<%= table_name %>, :<%= subject_column %>
30
35
 
31
36
  # Cleanup old records
@@ -32,6 +32,23 @@ module WhereIsWaldo
32
32
  # :authenticate_proc - proc to authenticate connection, receives request
33
33
  attr_accessor :channel_name, :authenticate_proc
34
34
 
35
+ # :suppress_presence_proc - callable that decides, per connection, whether
36
+ # to SKIP presence registration for that subscriber. Receives the
37
+ # ActionCable connection (host apps can read session/cookies/env off it,
38
+ # e.g. via `connection.request`). Returns truthy to suppress.
39
+ #
40
+ # A suppressed subscriber still subscribes normally — they receive
41
+ # broadcasts to `where_is_waldo:subject:<id>` (WhereIsWaldo.broadcast_to*
42
+ # signaling) and any other Waldo capability — they just don't register a
43
+ # Presence row / heartbeat / roster transition. Use for cases where the
44
+ # WebSocket session is legitimate but shouldn't be counted as "the subject
45
+ # is present", e.g. support-user impersonation tabs.
46
+ #
47
+ # config.suppress_presence_proc = ->(connection) {
48
+ # connection.request.session[:su_user].present?
49
+ # }
50
+ attr_accessor :suppress_presence_proc
51
+
35
52
  # Default audience resolver for the Broadcastable concern. A lambda that,
36
53
  # given a record, returns the AR scope to broadcast to (e.g. that record's
37
54
  # account members). Set once per app to match its container, e.g.:
@@ -106,6 +123,7 @@ module WhereIsWaldo
106
123
  # ActionCable defaults
107
124
  @channel_name = "WhereIsWaldo::PresenceChannel"
108
125
  @authenticate_proc = nil
126
+ @suppress_presence_proc = nil
109
127
 
110
128
  # Broadcastable default audience (set per app)
111
129
  @broadcast_audience = nil
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.3
4
+ version: 0.1.5
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-23 00:00:00.000000000 Z
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails