where_is_waldo 0.1.10 → 0.1.12

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: ce4ac18944bec996440772eed972216ff64975a1be13b4f7f3d0230e5cc0807f
4
- data.tar.gz: 80e1f0ba820d24dc5f5d74f7ef4cc31e00ba3f183145d24de17dc24e5bd4b78b
3
+ metadata.gz: e9b514019f64edaaea98c2d51266e3668f5a3bd270c5727013bb38bd7db51232
4
+ data.tar.gz: c4313e9f5f1f05b3640c2e019dcbe7883f4a59f027944f6395878baa60bdfdf4
5
5
  SHA512:
6
- metadata.gz: 877c8e0adf85cb7c8e75f992939c372690d1a928ab3ef4b6a4d64479ba9801ccd74820235db112d4efcab473a0589097165b62edf07824247a1a6be82d6ba32a
7
- data.tar.gz: d91ec5e623c3a8e8b7b149d5d9a80d6110ab5f4a8c81cc71bdaa73608afc593efe534bc2dc53c71df067837fb56c1cac9479df52a421f7364d1b545cc7657018
6
+ metadata.gz: 02aaa7a93acfa3c4732eec460b1fad4f6361dc0c64e9ed60539914b025d785db4dd4ed8acb8b0e55ae92a4cda116c31acb1f46745c1567196b461470b2ea8e27
7
+ data.tar.gz: 955493cf1cab229f550e25ffc3a8e54069ba297bf9bf9e0ee6ed0acc5e092bae61c00278b684a44d14833fe23210b0b35409fc2a09f6234cabcd10df2a44bf91
data/CHANGELOG.md CHANGED
@@ -3,6 +3,91 @@
3
3
  Notable changes to where_is_waldo. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/).
5
5
 
6
+ ## Unreleased
7
+
8
+ ## 0.1.12 - 2026-10-27
9
+
10
+ ### Fixed
11
+
12
+ - **A subject in more than one org could be put on the wrong roster.**
13
+ `roster_org` receives only the subject, so an app whose users belong to
14
+ several orgs had to guess, and the obvious guess (`accounts.first`) is
15
+ association order rather than the org the person is actually working in.
16
+ They were then invisible to the teammates they were with, and shown to an
17
+ org they were not in.
18
+
19
+ New `roster_org_for` receives `(subject, container)`, where the container is
20
+ the one the CONNECTION is scoped to -- the only place that answer exists. It
21
+ applies on BOTH sides: the presence row stores the resolved org at connect
22
+ and later broadcasts read it back, and the roster subscription resolves the
23
+ same way, so a viewer and the broadcasts meant for them can no longer land
24
+ on different streams. `container` comes from your connection's
25
+ `identified_by :current_container`; without that it is nil and behaviour is
26
+ unchanged. A subject present in two
27
+ orgs now appears in both rosters rather than one arbitrary one.
28
+
29
+ Stored in the existing `metadata` column, so **no migration is required**.
30
+ `roster_org` is untouched and still works for single-org apps.
31
+
32
+ - **A presence transition read a subject's sessions twice.** Publishing needs
33
+ both the org keys and the aggregate state, and both come from the same rows;
34
+ they are now derived from one read. This is the hottest path in the library,
35
+ so the second read was pure overhead.
36
+
37
+
38
+ ## 0.1.11 - 2026-10-27
39
+
40
+ ### Fixed
41
+
42
+ - **The install generator emitted the pre-0.1.5 schema.** It wrote a unique
43
+ index on the session column alone, while 0.1.5 moved presence rows to being
44
+ keyed by `(subject, session)` precisely so two authenticated subjects who
45
+ collide on a session id cannot upsert onto each other's row — session ids are
46
+ client-supplied in some setups, and a reused JWT `jti` is enough. The adapter
47
+ has scoped every session lookup by both columns since; only the generator
48
+ lagged, so every app installed after 0.1.5 got a schema the library had
49
+ already moved away from and needed a follow-up migration to correct. An
50
+ unused second migration template carrying the right index (and a hardcoded
51
+ `Migration[7.0]`) has been removed rather than left as a second answer.
52
+
53
+ - **The install generator overwrote an existing ActionCable connection.**
54
+ Installing into an app that already authenticates its own cable connection
55
+ replaced that file with the turnkey one, silently dropping whatever the app
56
+ did beyond what this library needs — container scoping and token bookkeeping
57
+ in the cases seen. Existing channel files are now kept, and the post-install
58
+ message names them and states what the channel requires of a connection
59
+ (`current_user` and `session_id`).
60
+
61
+ ### Changed
62
+
63
+ - **The Redis adapter is now tested against a real server, on both redis-rb 5
64
+ and 6.** Its existing spec runs against `MockRedis`, a pure-Ruby fake with no
65
+ protocol layer, so it could not see wire-format changes at all. redis-rb 6
66
+ defaults to RESP3, which types several replies differently from RESP2 —
67
+ `SMEMBERS` among them, and this adapter feeds that result straight into
68
+ `filter_map` and `all?`. Nothing in the adapter's command set behaves
69
+ differently between the two, but that was previously unverified rather than
70
+ known.
71
+
72
+ - New integration spec drives the adapter against a live Redis. It skips when
73
+ no server is reachable, so contributors are not required to run one.
74
+ - CI adds an `rspec-redis` job with a Redis service, run as a matrix over
75
+ redis-rb 5.x and 6.x, and fails rather than skips if the service is missing
76
+ — a silent skip would read as a pass.
77
+ - Development dependency widened from `~> 5.0` to `>= 5.0, < 7`. The gem has
78
+ no runtime dependency on `redis` and never has, so this constrains nothing
79
+ for consumers; it only lets the library test what consumers actually use.
80
+ - README documents supported client versions, and that `protocol: 2` is not
81
+ needed.
82
+
83
+ The committed lockfile stays on redis 5.x, which keeps `mock_redis` at
84
+ 0.53.0 for every existing spec. `mock_redis` 0.53.0 depends on `redis ~> 5`,
85
+ so resolving against redis 6 forces it down to 0.48.1 — that happens only
86
+ inside the redis 6 CI leg, which runs the real-server spec alone and never
87
+ touches MockRedis. No mock-based spec changed the double it runs against.
88
+
89
+ No runtime code changed.
90
+
6
91
  ## 0.1.10
7
92
 
8
93
  ### Added
data/README.md CHANGED
@@ -41,6 +41,13 @@ rails generate where_is_waldo:install --adapter=redis --subject_column=user_id
41
41
  npm install @byscott-io/where-is-waldo @rails/actioncable
42
42
  ```
43
43
 
44
+ Installing into an app that already has `app/channels/application_cable/`?
45
+ Those files are kept, never overwritten — the generator only adds what is
46
+ missing, and says which it left alone. The presence channel needs the
47
+ connection to identify **`current_user`** and **`session_id`**; add them to
48
+ your own connection, or `include WhereIsWaldo::JwtConnection` (corebyscott
49
+ apps) to get both from the JWT.
50
+
44
51
  ### 2. Configure
45
52
 
46
53
  ```ruby
@@ -148,6 +155,20 @@ WhereIsWaldo.configure do |config|
148
155
  end
149
156
  ```
150
157
 
158
+ #### Redis adapter and client versions
159
+
160
+ The gem does not depend on `redis`. Choosing `:redis` means supplying your own
161
+ client, so you pick the version.
162
+
163
+ `redis-rb` 5.x and 6.x are both supported and both run in CI against a real
164
+ server. Version 6 switched to RESP3 by default, which changes reply types for
165
+ some commands; none of the commands this adapter uses behave differently, and
166
+ the integration spec exists to keep that true. Nothing here needs `protocol: 2`.
167
+
168
+ The adapter stores presence under keys prefixed by `config.redis_prefix`
169
+ (default `where_is_waldo`) and sets a TTL on session keys, so it does not need a
170
+ cleanup job the way the database adapter does.
171
+
151
172
  ### Querying Presence
152
173
 
153
174
  ```ruby
@@ -269,6 +290,32 @@ their live sessions (multiple browser tabs, mobile, etc.):
269
290
  WhereIsWaldo.configure do |config|
270
291
  # The org/account a subject belongs to. Required to enable the roster.
271
292
  config.roster_org = ->(subject) { subject.account }
293
+ #
294
+ # MULTI-ORG SUBJECTS: roster_org is handed only the subject, so it has to
295
+ # guess which org the person is in right now -- and `accounts.first` guesses
296
+ # by association order. Someone who has switched is then put on the wrong
297
+ # roster: invisible to the teammates they are with, shown to an org they are
298
+ # not in. Use roster_org_for instead, which also receives the container the
299
+ # CONNECTION is scoped to:
300
+ #
301
+ # config.roster_org_for = ->(user, container) { container || user.accounts.first }
302
+ #
303
+ # `container` is read from your own ActionCable connection, so it is only
304
+ # ever non-nil if that connection identifies one:
305
+ #
306
+ # module ApplicationCable
307
+ # class Connection < ActionCable::Connection::Base
308
+ # identified_by :current_user, :current_container, :session_id
309
+ # end
310
+ # end
311
+ #
312
+ # WhereIsWaldo::JwtConnection already does this. Without it `container` is
313
+ # always nil and the fallback above behaves exactly like plain roster_org --
314
+ # which is to say, like the bug.
315
+ #
316
+ # The resolved org is stored on the presence row at connect and read back by
317
+ # later broadcasts, so the subscriber and the broadcaster always agree on the
318
+ # stream. roster_org keeps working unchanged for single-org apps.
272
319
 
273
320
  # What a VIEWER may see (:poll/:nudge/:fanout). Any visibility rule,
274
321
  # server-enforced. Defaults to the viewer's whole org when unset.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.10
1
+ 0.1.12
@@ -88,13 +88,43 @@ module WhereIsWaldo
88
88
  end
89
89
 
90
90
  def register_presence
91
+ # The org key is computed HERE and written over anything the client sent.
92
+ # params[:metadata] is client-supplied, so letting a tab name its own
93
+ # roster would let it subscribe itself into an org it has no business
94
+ # seeing.
95
+ #
96
+ # The client's value is REMOVED FIRST, unconditionally. Only overwriting
97
+ # it when the server resolves an org leaves the hole open in exactly the
98
+ # case that matters: a connection with no org of its own could name any
99
+ # org it liked, the value would persist on the presence row, and later
100
+ # broadcasts read it back verbatim and push that subject's presence into
101
+ # a stream they have no claim on.
102
+ metadata = (params[:metadata] || {}).to_h
103
+ metadata.delete(WhereIsWaldo::Roster::ORG_KEY)
104
+ metadata.delete(WhereIsWaldo::Roster::ORG_KEY.to_sym)
105
+
106
+ key = WhereIsWaldo::Roster.org_key(wiw_connection_org)
107
+ metadata[WhereIsWaldo::Roster::ORG_KEY] = key if key
108
+
91
109
  WhereIsWaldo.connect(
92
110
  session_id: waldo_session_id,
93
111
  subject_id: waldo_subject_id,
94
- metadata: params[:metadata] || {}
112
+ metadata: metadata
95
113
  )
96
114
  end
97
115
 
116
+ # The org this CONNECTION is scoped to. The container comes from the host
117
+ # app's own cable connection, which is the only place that knows the org
118
+ # the person is actually working in -- a lambda given just the subject has
119
+ # to guess, and guesses by association order.
120
+ def wiw_connection_org
121
+ return @wiw_connection_org if defined?(@wiw_connection_org)
122
+
123
+ subject = WhereIsWaldo.config.subject_class_constant&.find_by(id: waldo_subject_id)
124
+ container = connection.try(:current_container)
125
+ @wiw_connection_org = subject && WhereIsWaldo.config.resolve_roster_org(subject, container: container)
126
+ end
127
+
98
128
  def roster_transition?(tab_visible, subject_active)
99
129
  tab_visible != @wiw_tab_visible || subject_active != @wiw_subject_active
100
130
  end
@@ -104,8 +134,7 @@ module WhereIsWaldo
104
134
  def resolve_roster_strategy
105
135
  return nil unless WhereIsWaldo.config.roster_enabled?
106
136
 
107
- subject = WhereIsWaldo.config.subject_class_constant&.find_by(id: waldo_subject_id)
108
- org = subject && WhereIsWaldo.config.resolve_roster_org(subject)
137
+ org = wiw_connection_org
109
138
  return nil unless org
110
139
 
111
140
  WhereIsWaldo::RosterDelivery.for(WhereIsWaldo.config.resolve_mode(org))
@@ -13,12 +13,15 @@ module WhereIsWaldo
13
13
  class RosterChannel < ApplicationCable::Channel
14
14
  def subscribed
15
15
  subject = current_subject
16
- mode = resolve_mode(subject)
16
+ org = resolve_org(subject)
17
+ return reject unless org
18
+
19
+ mode = WhereIsWaldo.config.resolve_mode(org)
17
20
  return reject unless mode
18
21
 
19
22
  @wiw_subject = subject
20
23
  @wiw_strategy = RosterDelivery.for(mode)
21
- apply_plan(@wiw_strategy.subscribe_plan(subject, waldo_session_id))
24
+ apply_plan(@wiw_strategy.subscribe_plan(subject, waldo_session_id, org: org))
22
25
  end
23
26
 
24
27
  # Client-driven poll (pull/nudge modes). Transmits a filtered snapshot
@@ -31,11 +34,15 @@ module WhereIsWaldo
31
34
 
32
35
  private
33
36
 
34
- def resolve_mode(subject)
35
- org = WhereIsWaldo.config.resolve_roster_org(subject)
36
- return nil unless org
37
+ # The org this VIEWER is watching. Resolved from the connection's container
38
+ # like the writer side, so the stream they subscribe to is the one deltas
39
+ # are actually broadcast into. Resolving from the subject alone here was
40
+ # the other half of the same bug: a viewer scoped to their second org sat
41
+ # on their first org's stream and simply never saw anything.
42
+ def resolve_org(subject)
43
+ return nil unless subject
37
44
 
38
- WhereIsWaldo.config.resolve_mode(org)
45
+ WhereIsWaldo.config.resolve_roster_org(subject, container: connection.try(:current_container))
39
46
  end
40
47
 
41
48
  def apply_plan(plan)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class Roster
5
+ # Which roster a session belongs to, and how that is stored.
6
+ #
7
+ # Extracted from Roster so the resolution rules sit together and can be
8
+ # read without the delivery machinery around them.
9
+ module OrgKeys
10
+ # WHICH ROSTER A SESSION BELONGS TO IS DECIDED ONCE, AT CONNECT, AND
11
+ # STORED ON THE PRESENCE ROW.
12
+ #
13
+ # It cannot be re-derived later. The connection knows the container the
14
+ # person is actually working in; a background broadcast an hour later has
15
+ # only a subject id, and a subject can belong to several orgs. Deriving
16
+ # it twice is how the subscriber ends up listening to one stream while
17
+ # the broadcaster writes to another -- which looks exactly like presence
18
+ # being broken, not like a resolution bug.
19
+ #
20
+ # Stored in metadata rather than a column so this needs no migration in
21
+ # any consuming app.
22
+ def org_key(org)
23
+ return nil unless org
24
+
25
+ klass = org.class.respond_to?(:base_class) ? org.class.base_class : org.class
26
+ "#{klass.name}:#{org.id}"
27
+ end
28
+
29
+ def org_from_key(key)
30
+ type, id = key.to_s.split(":", 2)
31
+ return nil if type.blank? || id.blank?
32
+
33
+ type.safe_constantize&.find_by(id: id)
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ # Every org this subject is currently connected in -- usually one, but a
39
+ # person with two tabs in two accounts is present in both and must appear
40
+ # in both rosters. Falls back to resolving from the subject alone for
41
+ # rows written before this existed, and for apps with no container.
42
+ def org_keys_for_subject(subject_id, timeout: nil)
43
+ by_subject = WhereIsWaldo::PresenceService.sessions_for_subjects([subject_id], timeout: timeout)
44
+ org_keys_from(sessions_for(by_subject, subject_id), subject_id)
45
+ end
46
+
47
+ # The same answer from sessions the caller has ALREADY loaded. A presence
48
+ # transition needs both the org keys and the subject's aggregate state,
49
+ # and both are derived from one set of session rows -- reading them twice
50
+ # doubles the adapter traffic on the hottest path in the library.
51
+ def org_keys_from(sessions, subject_id)
52
+ keys = sessions.filter_map do |session|
53
+ session.dig(:metadata, ORG_KEY) || session.dig("metadata", ORG_KEY)
54
+ end.uniq
55
+ return keys if keys.any?
56
+
57
+ [org_key(WhereIsWaldo.config.resolve_roster_org(find_subject(subject_id)))].compact
58
+ end
59
+
60
+ private
61
+
62
+ # THE KEY TYPES ON THE TWO SIDES ARE NOT GUARANTEED TO MATCH.
63
+ #
64
+ # The database adapter keys its result by the AR-typecast column value,
65
+ # typically an Integer, while a subject id arriving from a cable
66
+ # connection is often a String -- authenticate_proc returns whatever the
67
+ # host app decides, and connection params are strings. A plain fetch then
68
+ # misses, and the caller falls back to resolving from the subject alone:
69
+ # silently, and straight back to the guess this exists to replace.
70
+ # Comparing as strings is what stops a type difference becoming a wrong
71
+ # roster.
72
+ def sessions_for(by_subject, subject_id)
73
+ return by_subject[subject_id] if by_subject.key?(subject_id)
74
+
75
+ wanted = subject_id.to_s
76
+ _, sessions = by_subject.find { |key, _| key.to_s == wanted }
77
+ sessions || []
78
+ end
79
+ end
80
+ end
81
+ end
@@ -34,6 +34,13 @@ module WhereIsWaldo
34
34
  RANK = { active: 3, idle: 2, background: 1, offline: 0 }.freeze
35
35
  DEFAULT_PLATFORM = "web"
36
36
 
37
+ # The metadata key holding which roster a session belongs to. Defined on
38
+ # the class, not the singleton, so callers can reference
39
+ # WhereIsWaldo::Roster::ORG_KEY.
40
+ ORG_KEY = "wiw_org"
41
+
42
+ extend OrgKeys
43
+
37
44
  class << self
38
45
  # Shared stream name for an org record (nil when org is absent).
39
46
  def stream_name(org)
@@ -45,8 +52,8 @@ module WhereIsWaldo
45
52
 
46
53
  # Shared stream name for the org a subject belongs to (nil when the roster
47
54
  # is unconfigured or the subject has no org).
48
- def stream_for_subject(subject)
49
- stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
55
+ def stream_for_subject(subject, container: nil)
56
+ stream_name(WhereIsWaldo.config.resolve_roster_org(subject, container: container))
50
57
  end
51
58
 
52
59
  # Full roster state for an org: every member subject with their current
@@ -118,12 +125,23 @@ module WhereIsWaldo
118
125
  def publish(subject_id, timeout: nil)
119
126
  return false if subject_id.blank?
120
127
 
121
- subject = find_subject(subject_id)
122
- stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
123
- return false unless stream
128
+ # ONE read of the subject's sessions, used for both halves. The org
129
+ # keys and the aggregate state are derived from the same rows, and a
130
+ # transition is the hottest path here -- fetching twice doubled the
131
+ # adapter traffic for no new information.
132
+ by_subject = WhereIsWaldo::PresenceService.sessions_for_subjects([subject_id], timeout: timeout)
133
+ sessions = sessions_for(by_subject, subject_id)
124
134
 
125
- member = build_member_by_id(subject, subject_id, timeout: timeout)
126
- ActionCable.server.broadcast(stream, delta_message(member))
135
+ keys = org_keys_from(sessions, subject_id)
136
+ return false if keys.empty?
137
+
138
+ subject = find_subject(subject_id)
139
+ state = sessions.any? ? aggregate(sessions) : offline_state
140
+ member = merge_member(subject, state, id: subject_id)
141
+ keys.each do |key|
142
+ stream = "where_is_waldo:roster:#{key}"
143
+ ActionCable.server.broadcast(stream, delta_message(member))
144
+ end
127
145
  true
128
146
  rescue StandardError => e
129
147
  Rails.logger&.warn("[WhereIsWaldo::Roster] publish failed for #{subject_id}: #{e.class}: #{e.message}")
@@ -158,11 +176,10 @@ module WhereIsWaldo
158
176
  def publish_nudge(subject_id)
159
177
  return false if subject_id.blank?
160
178
 
161
- subject = find_subject(subject_id)
162
- stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
163
- return false unless stream
179
+ keys = org_keys_for_subject(subject_id)
180
+ return false if keys.empty?
164
181
 
165
- ActionCable.server.broadcast(stream, nudge_message)
182
+ keys.each { |key| ActionCable.server.broadcast("where_is_waldo:roster:#{key}", nudge_message) }
166
183
  true
167
184
  rescue StandardError => e
168
185
  Rails.logger&.warn("[WhereIsWaldo::Roster] nudge failed for #{subject_id}: #{e.class}: #{e.message}")
@@ -28,7 +28,7 @@ module WhereIsWaldo
28
28
  # Base contract. Channels call these; return values drive the channel's I/O.
29
29
  class Base
30
30
  # @return [Hash] { streams: [names], messages: [cable messages] }
31
- def subscribe_plan(_subject, _session_id)
31
+ def subscribe_plan(_subject, _session_id, org: nil) # rubocop:disable Lint/UnusedMethodArgument
32
32
  { streams: [], messages: [] }
33
33
  end
34
34
 
@@ -50,8 +50,11 @@ module WhereIsWaldo
50
50
  # Instant push to a single shared per-account stream. NO visibility
51
51
  # filtering — everyone in the account sees everyone. O(1) per transition.
52
52
  class Broadcast < Base
53
- def subscribe_plan(subject, _session_id)
54
- org = config.resolve_roster_org(subject)
53
+ def subscribe_plan(subject, _session_id, org: nil)
54
+ # org comes from the channel, which knows the connection's container.
55
+ # Resolving it again here would reach a different answer for a subject
56
+ # in several orgs, and put the viewer on a stream nobody broadcasts to.
57
+ org ||= config.resolve_roster_org(subject)
55
58
  stream = Roster.stream_name(org)
56
59
  return { streams: [], messages: [] } unless stream
57
60
 
@@ -77,10 +80,13 @@ module WhereIsWaldo
77
80
  :poll
78
81
  end
79
82
 
80
- def subscribe_plan(subject, session_id)
83
+ def subscribe_plan(subject, session_id, org: nil)
81
84
  current = current_members(subject)
82
85
  write_baseline(session_id, current)
83
- { streams: subscribe_streams(subject), messages: [Roster.snapshot_message(current.values, mode: mode)] }
86
+ {
87
+ streams: subscribe_streams(subject, org: org),
88
+ messages: [Roster.snapshot_message(current.values, mode: mode)]
89
+ }
84
90
  end
85
91
 
86
92
  def poll_messages(subject, session_id)
@@ -101,7 +107,7 @@ module WhereIsWaldo
101
107
 
102
108
  # Streams to subscribe on connect. :poll needs none (data rides the poll
103
109
  # transmit); :nudge overrides this to also receive the account nudge.
104
- def subscribe_streams(_subject)
110
+ def subscribe_streams(_subject, org: nil) # rubocop:disable Lint/UnusedMethodArgument
105
111
  []
106
112
  end
107
113
 
@@ -156,8 +162,9 @@ module WhereIsWaldo
156
162
  private
157
163
 
158
164
  # Also subscribe the account stream so this client receives nudges.
159
- def subscribe_streams(subject)
160
- stream = Roster.stream_name(config.resolve_roster_org(subject))
165
+ def subscribe_streams(subject, org: nil)
166
+ org ||= config.resolve_roster_org(subject)
167
+ stream = Roster.stream_name(org)
161
168
  stream ? [stream] : []
162
169
  end
163
170
  end
@@ -168,7 +175,7 @@ module WhereIsWaldo
168
175
  # its directional audience (config.roster_viewers_of). O(audience) per
169
176
  # transition — the price of instant delivery under restricted visibility.
170
177
  class Fanout < Base
171
- def subscribe_plan(subject, _session_id)
178
+ def subscribe_plan(subject, _session_id, org: nil) # rubocop:disable Lint/UnusedMethodArgument
172
179
  members = Roster.members_for(config.resolve_visible_to(subject))
173
180
  {
174
181
  streams: [Roster.viewer_stream(subject.id)],
@@ -32,9 +32,17 @@ module WhereIsWaldo
32
32
  end
33
33
 
34
34
  def create_channels
35
- template "connection.rb.tt", "app/channels/application_cable/connection.rb"
36
- template "channel.rb.tt", "app/channels/application_cable/channel.rb"
37
- template "presence_channel.rb.tt", "app/channels/presence_channel.rb"
35
+ # NEVER overwrite an app's existing cable connection or channel base.
36
+ # A host app that already authenticates ActionCable has its auth,
37
+ # container scoping and session identity in those files, and the
38
+ # turnkey versions here provide only what this library needs -- so
39
+ # replacing them silently drops whatever else they did. Installing
40
+ # into an existing app is the common case, not the exception.
41
+ @kept_channels = []
42
+
43
+ copy_channel "connection.rb.tt", "app/channels/application_cable/connection.rb"
44
+ copy_channel "channel.rb.tt", "app/channels/application_cable/channel.rb"
45
+ copy_channel "presence_channel.rb.tt", "app/channels/presence_channel.rb"
38
46
  end
39
47
 
40
48
  def show_post_install_message
@@ -54,10 +62,34 @@ module WhereIsWaldo
54
62
  say " <App />"
55
63
  say " </PresenceProvider>"
56
64
  say ""
65
+ report_kept_channels
57
66
  end
58
67
 
59
68
  private
60
69
 
70
+ # Say plainly what was left alone. A silent skip is as confusing as a
71
+ # silent overwrite: the install looks complete and presence then fails
72
+ # because the app's own connection exposes no session identity.
73
+ def report_kept_channels
74
+ return if @kept_channels.blank?
75
+
76
+ say "Kept your existing files (not overwritten):", :yellow
77
+ @kept_channels.each { |f| say " - #{f}" }
78
+ say ""
79
+ say " WhereIsWaldo's channel reads connection.current_user and"
80
+ say " connection.session_id. If your connection does not identify"
81
+ say " both, add them -- or include WhereIsWaldo::JwtConnection."
82
+ say ""
83
+ end
84
+
85
+ def copy_channel(source, destination)
86
+ if File.exist?(File.join(destination_root, destination))
87
+ @kept_channels << destination
88
+ else
89
+ template source, destination
90
+ end
91
+ end
92
+
61
93
  def migration_version
62
94
  "[#{ActiveRecord::VERSION::STRING.to_f}]"
63
95
  end
@@ -14,7 +14,12 @@ class CreatePresences < ActiveRecord::Migration<%= migration_version %>
14
14
  t.timestamps
15
15
  end
16
16
 
17
- add_index :presences, :<%= session_column %>, unique: true
17
+ # (subject, session) must be unique TOGETHER, never session alone. Session
18
+ # ids are not globally unique -- they are client-supplied in some setups,
19
+ # and a reused JWT jti is enough -- so keying on the session alone lets two
20
+ # authenticated subjects upsert onto each other's presence row. This is the
21
+ # keying the adapter uses: every session lookup scopes by both columns.
22
+ add_index :presences, [ :<%= subject_column %>, :<%= session_column %> ], unique: true
18
23
  add_index :presences, :last_heartbeat
19
24
  end
20
25
  end
@@ -83,6 +83,16 @@ module WhereIsWaldo
83
83
  # roster_members_association: association used to build the default roster
84
84
  # from the org when roster_members is unset (defaults to the pluralized
85
85
  # subject_class, e.g. :users).
86
+ # roster_org_for: the container-aware form of roster_org. Receives
87
+ # (subject, container) where container is the one the CONNECTION is
88
+ # scoped to -- the account the person is actually working in, which a
89
+ # subject-only lambda cannot know. Prefer this whenever subjects can
90
+ # belong to more than one org; roster_org picks by association order,
91
+ # which puts a switched user on the wrong roster: invisible to the
92
+ # teammates they are with, visible to an org they are not.
93
+ # config.roster_org_for = ->(user, container) { container || user.accounts.first }
94
+ attr_accessor :roster_org_for
95
+
86
96
  attr_accessor :roster_org, :roster_members, :roster_members_association
87
97
 
88
98
  # roster_visible_to: given a VIEWER, return the AR scope of subjects
@@ -142,6 +152,7 @@ module WhereIsWaldo
142
152
 
143
153
  # Presence roster (set per app to enable)
144
154
  @roster_org = nil
155
+ @roster_org_for = nil
145
156
  @roster_members = nil
146
157
  @roster_members_association = nil
147
158
  @roster_visible_to = nil
@@ -165,10 +176,17 @@ module WhereIsWaldo
165
176
  end
166
177
 
167
178
  # Resolve the org/container record for a subject (nil if unset/absent).
168
- def resolve_roster_org(subject)
169
- return nil unless roster_org && subject
170
-
171
- roster_org.call(subject)
179
+ # @param container [Object, nil] the connection's container, when known.
180
+ # Only roster_org_for receives it; roster_org keeps its old arity so
181
+ # existing apps are untouched.
182
+ def resolve_roster_org(subject, container: nil)
183
+ return nil unless subject
184
+
185
+ if roster_org_for
186
+ roster_org_for.call(subject, container)
187
+ elsif roster_org
188
+ roster_org.call(subject)
189
+ end
172
190
  end
173
191
 
174
192
  # Association name used to derive the default roster from an org when
@@ -193,7 +211,7 @@ module WhereIsWaldo
193
211
 
194
212
  # True when the live-presence roster feature is configured.
195
213
  def roster_enabled?
196
- !roster_org.nil?
214
+ !roster_org.nil? || !roster_org_for.nil?
197
215
  end
198
216
 
199
217
  # Resolve the AR scope of subjects a VIEWER may see. Defaults to the
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: where_is_waldo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.10
4
+ version: 0.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Gibson
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-08-07 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -84,16 +83,22 @@ dependencies:
84
83
  name: redis
85
84
  requirement: !ruby/object:Gem::Requirement
86
85
  requirements:
87
- - - "~>"
86
+ - - ">="
88
87
  - !ruby/object:Gem::Version
89
88
  version: '5.0'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '7'
90
92
  type: :development
91
93
  prerelease: false
92
94
  version_requirements: !ruby/object:Gem::Requirement
93
95
  requirements:
94
- - - "~>"
96
+ - - ">="
95
97
  - !ruby/object:Gem::Version
96
98
  version: '5.0'
99
+ - - "<"
100
+ - !ruby/object:Gem::Version
101
+ version: '7'
97
102
  - !ruby/object:Gem::Dependency
98
103
  name: rspec-rails
99
104
  requirement: !ruby/object:Gem::Requirement
@@ -206,11 +211,11 @@ files:
206
211
  - app/services/where_is_waldo/broadcaster.rb
207
212
  - app/services/where_is_waldo/presence_service.rb
208
213
  - app/services/where_is_waldo/roster.rb
214
+ - app/services/where_is_waldo/roster/org_keys.rb
209
215
  - app/services/where_is_waldo/roster_delivery.rb
210
216
  - lib/generators/where_is_waldo/install/install_generator.rb
211
217
  - lib/generators/where_is_waldo/install/templates/channel.rb.tt
212
218
  - lib/generators/where_is_waldo/install/templates/connection.rb.tt
213
- - lib/generators/where_is_waldo/install/templates/create_presences.rb.tt
214
219
  - lib/generators/where_is_waldo/install/templates/initializer.rb.tt
215
220
  - lib/generators/where_is_waldo/install/templates/migration.rb.tt
216
221
  - lib/generators/where_is_waldo/install/templates/presence_channel.rb.tt
@@ -228,7 +233,6 @@ metadata:
228
233
  changelog_uri: https://github.com/byscott-io/where_is_waldo/blob/master/CHANGELOG.md
229
234
  allowed_push_host: https://rubygems.org
230
235
  rubygems_mfa_required: 'true'
231
- post_install_message:
232
236
  rdoc_options: []
233
237
  require_paths:
234
238
  - lib
@@ -243,8 +247,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
243
247
  - !ruby/object:Gem::Version
244
248
  version: '0'
245
249
  requirements: []
246
- rubygems_version: 3.5.22
247
- signing_key:
250
+ rubygems_version: 4.0.16
248
251
  specification_version: 4
249
252
  summary: Real-time presence tracking for Rails + React applications
250
253
  test_files: []
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Create<%= table_name.camelize %> < ActiveRecord::Migration[7.0]
4
- def change
5
- create_table :<%= table_name %> do |t|
6
- # Identifiers - column names are configurable
7
- t.string :<%= session_column %>, null: false # Unique session/connection identifier
8
- t.bigint :<%= subject_column %>, null: false # Who (user, member, student, etc.)
9
-
10
- # Timestamps
11
- t.datetime :connected_at, null: false
12
- t.datetime :last_heartbeat, null: false
13
- t.datetime :last_activity
14
-
15
- # Activity state
16
- t.boolean :tab_visible, default: true, null: false
17
- t.boolean :subject_active, default: true, null: false
18
-
19
- # Extensible metadata
20
- t.<%= json_column_type %> :metadata
21
-
22
- t.timestamps
23
- end
24
-
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
34
- add_index :<%= table_name %>, :<%= subject_column %>
35
-
36
- # Cleanup old records
37
- add_index :<%= table_name %>, :last_heartbeat
38
-
39
- <% if subject_table.present? -%>
40
- # Foreign key to subject table
41
- add_foreign_key :<%= table_name %>, :<%= subject_table %>, column: :<%= subject_column %>
42
- <% end -%>
43
- end
44
- end