where_is_waldo 0.1.4 → 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: 29833d47ae21a1aa5a1882e515152ddde9f7201eb9754cff81545495a0cd6d63
4
- data.tar.gz: e7d41c790a9abbd4f3bdbd3ced6db38ce335629c189c0bd6404c40ce3874f82d
3
+ metadata.gz: 9caee3a6cb5747d9ce5ab460315fdaca5a9f05f7521dbe07f36a6d9fe5de48d9
4
+ data.tar.gz: f7e293f87b04039c7a2dbc21e6a80ef574a773438800588a9f87255b9336497a
5
5
  SHA512:
6
- metadata.gz: f7634cd0688c8f56bb3292ae1f8c9db9146d1775552afb7f7801a5c898a97ab257bdbb80aa012c3a7ef79bfdd714bce7c1bb9a9c486d859e2f7bff5dd70891cc
7
- data.tar.gz: 59335dc215e4666a577294fd9f383cd178102bf1c8f649b0bf30a738ec63be5078e0d541cb3f6d11b6ed9765b561fa45c17665fdc2fb4a8f8df23f59fc486815
6
+ metadata.gz: 95e63474cdbfff6c224b4bc4882bba3fb96cb157bf73a7769a1755f8b6326cba60e19aee6b06fc8fb58fc483496a68f8aa3e6bd03513a1782802ca123f8b9cb7
7
+ data.tar.gz: 9abb3e965ddf1a13722e5700c2c3c8927c9b6b43593801e592670ec4ab09483ce2a3cf2da6a764dfbfb204e238a0107cbd9628067c5e06b0d7920a24412a0726
data/CHANGELOG.md CHANGED
@@ -3,6 +3,51 @@
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
+
6
51
  ## 0.1.4
7
52
 
8
53
  ### Fixed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.4
1
+ 0.1.5
@@ -25,7 +25,7 @@ module WhereIsWaldo
25
25
  def unsubscribed
26
26
  return if presence_suppressed?
27
27
 
28
- WhereIsWaldo.disconnect(session_id: waldo_session_id)
28
+ WhereIsWaldo.disconnect(session_id: waldo_session_id, subject_id: waldo_subject_id)
29
29
 
30
30
  # Recompute the subject's aggregate (they may still be present in another
31
31
  # tab/device) and announce the change to the org roster.
@@ -42,6 +42,7 @@ module WhereIsWaldo
42
42
 
43
43
  WhereIsWaldo.heartbeat(
44
44
  session_id: waldo_session_id,
45
+ subject_id: waldo_subject_id,
45
46
  tab_visible: tab_visible,
46
47
  subject_active: subject_active,
47
48
  last_activity_at: data[:last_activity_at],
@@ -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
 
@@ -65,8 +70,10 @@ module WhereIsWaldo
65
70
 
66
71
  # Get session status
67
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)
68
75
  # @return [Hash, nil] Presence record or nil
69
- def session_status(session_id)
76
+ def session_status(session_id, subject_id)
70
77
  raise NotImplementedError
71
78
  end
72
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?
@@ -88,8 +100,8 @@ module WhereIsWaldo
88
100
  .transform_values { |rows| rows.map(&:as_presence_hash) }
89
101
  end
90
102
 
91
- def session_status(session_id)
92
- scope = Presence.where(session_column => session_id)
103
+ def session_status(session_id, subject_id)
104
+ scope = Presence.where(session_column => session_id, subject_column => subject_id)
93
105
  scope = scope.includes(:subject) if config.subject_class_constant
94
106
  scope.first&.as_presence_hash
95
107
  end
@@ -102,8 +114,8 @@ module WhereIsWaldo
102
114
  private
103
115
 
104
116
  def build_lookup_scope(session_id: nil, subject_id: nil)
105
- if session_id
106
- Presence.where(session_column => session_id)
117
+ if session_id && subject_id
118
+ Presence.where(session_column => session_id, subject_column => subject_id)
107
119
  elsif subject_id
108
120
  Presence.where(subject_column => subject_id)
109
121
  else
@@ -20,17 +20,15 @@ module WhereIsWaldo
20
20
  }
21
21
 
22
22
  redis.multi do |tx|
23
- # Store session data
24
- 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)
25
26
 
26
27
  # Add to online subjects sorted set with timestamp as score
27
28
  tx.zadd(online_subjects_key, now, subject_id)
28
29
 
29
30
  # Add to subject's sessions set
30
31
  tx.sadd(subject_sessions_key(subject_id), session_id)
31
-
32
- # Map session to subject for reverse lookup
33
- tx.set(session_subject_key(session_id), subject_id, ex: ttl)
34
32
  end
35
33
 
36
34
  true
@@ -41,19 +39,25 @@ module WhereIsWaldo
41
39
 
42
40
  def disconnect(session_id: nil, subject_id: nil)
43
41
  if session_id
44
- disconnect_session(session_id)
42
+ raise ArgumentError, "disconnect(session_id:) requires subject_id:" unless subject_id
43
+
44
+ disconnect_session(subject_id, session_id)
45
45
  elsif subject_id
46
46
  disconnect_subject(subject_id)
47
47
  end
48
48
 
49
49
  true
50
+ rescue ArgumentError
51
+ raise
50
52
  rescue StandardError => e
51
53
  Rails.logger.error "[WhereIsWaldo] Redis disconnect failed: #{e.message}"
52
54
  false
53
55
  end
54
56
 
55
- def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
56
- 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)
57
61
  return false unless data
58
62
 
59
63
  now = Time.current.to_i
@@ -69,7 +73,7 @@ module WhereIsWaldo
69
73
  data["metadata"] = data["metadata"].merge(metadata) if metadata.present?
70
74
 
71
75
  redis.multi do |tx|
72
- 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)
73
77
  tx.zadd(online_subjects_key, now, data["subject_id"])
74
78
  end
75
79
 
@@ -90,7 +94,7 @@ module WhereIsWaldo
90
94
  session_ids = redis.smembers(subject_sessions_key(subject_id))
91
95
 
92
96
  session_ids.filter_map do |sid|
93
- data = get_presence_data(sid)
97
+ data = get_presence_data(subject_id, sid)
94
98
  build_presence_hash(data) if data
95
99
  end
96
100
  end
@@ -103,7 +107,7 @@ module WhereIsWaldo
103
107
  ids.each_with_object({}) do |sid, memo|
104
108
  session_ids = redis.smembers(subject_sessions_key(sid))
105
109
  live = session_ids.filter_map do |cid|
106
- data = get_presence_data(cid)
110
+ data = get_presence_data(sid, cid)
107
111
  next unless data && data["last_heartbeat"].to_i >= threshold
108
112
 
109
113
  build_presence_hash(data)
@@ -112,8 +116,8 @@ module WhereIsWaldo
112
116
  end
113
117
  end
114
118
 
115
- def session_status(session_id)
116
- data = get_presence_data(session_id)
119
+ def session_status(session_id, subject_id)
120
+ data = get_presence_data(subject_id, session_id)
117
121
  return nil unless data
118
122
 
119
123
  build_presence_hash(data)
@@ -130,7 +134,7 @@ module WhereIsWaldo
130
134
  # Check if any sessions are still active
131
135
  session_ids = redis.smembers(subject_sessions_key(subject_id))
132
136
  all_stale = session_ids.all? do |sid|
133
- data = get_presence_data(sid)
137
+ data = get_presence_data(subject_id, sid)
134
138
  !data || data["last_heartbeat"].to_i < threshold
135
139
  end
136
140
 
@@ -160,8 +164,11 @@ module WhereIsWaldo
160
164
  config.redis_prefix || "where_is_waldo"
161
165
  end
162
166
 
163
- def session_key(session_id)
164
- "#{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}"
165
172
  end
166
173
 
167
174
  def online_subjects_key
@@ -172,27 +179,17 @@ module WhereIsWaldo
172
179
  "#{key_prefix}:subject:#{subject_id}:sessions"
173
180
  end
174
181
 
175
- def session_subject_key(session_id)
176
- "#{key_prefix}:session_subject:#{session_id}"
177
- end
178
-
179
- def get_presence_data(session_id)
180
- 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))
181
184
  return nil unless json
182
185
 
183
186
  JSON.parse(json)
184
187
  end
185
188
 
186
- def disconnect_session(session_id)
187
- data = get_presence_data(session_id)
188
- return unless data
189
-
190
- subject_id = data["subject_id"]
191
-
189
+ def disconnect_session(subject_id, session_id)
192
190
  redis.multi do |tx|
193
- tx.del(session_key(session_id))
191
+ tx.del(session_key(subject_id, session_id))
194
192
  tx.srem(subject_sessions_key(subject_id), session_id)
195
- tx.del(session_subject_key(session_id))
196
193
  end
197
194
 
198
195
  # If no more sessions for this subject, remove from online set
@@ -202,7 +199,7 @@ module WhereIsWaldo
202
199
 
203
200
  def disconnect_subject(subject_id)
204
201
  session_ids = redis.smembers(subject_sessions_key(subject_id))
205
- session_ids.each { |sid| disconnect_session(sid) }
202
+ session_ids.each { |sid| disconnect_session(subject_id, sid) }
206
203
  redis.zrem(online_subjects_key, subject_id)
207
204
  end
208
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,
@@ -85,6 +91,8 @@ module WhereIsWaldo
85
91
 
86
92
  # Get status of a specific session
87
93
  # @param session_id [String] Session identifier
94
+ # @param subject_id [Integer/String] Subject identifier — required
95
+ # for the same reason as heartbeat
88
96
  # @return [Hash, nil] Presence record or nil
89
97
  delegate :session_status, to: :adapter
90
98
 
@@ -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
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.4
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