where_is_waldo 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +467 -0
  5. data/VERSION +1 -0
  6. data/app/channels/where_is_waldo/application_cable/channel.rb +19 -0
  7. data/app/channels/where_is_waldo/application_cable/connection.rb +45 -0
  8. data/app/channels/where_is_waldo/jwt_connection.rb +83 -0
  9. data/app/channels/where_is_waldo/presence_channel.rb +86 -0
  10. data/app/channels/where_is_waldo/roster_channel.rb +52 -0
  11. data/app/jobs/where_is_waldo/application_job.rb +6 -0
  12. data/app/jobs/where_is_waldo/presence_cleanup_job.rb +13 -0
  13. data/app/models/concerns/where_is_waldo/broadcastable.rb +85 -0
  14. data/app/models/where_is_waldo/application_record.rb +7 -0
  15. data/app/models/where_is_waldo/presence.rb +87 -0
  16. data/app/services/where_is_waldo/adapters/base_adapter.rb +80 -0
  17. data/app/services/where_is_waldo/adapters/database_adapter.rb +104 -0
  18. data/app/services/where_is_waldo/adapters/redis_adapter.rb +205 -0
  19. data/app/services/where_is_waldo/broadcaster.rb +79 -0
  20. data/app/services/where_is_waldo/presence_service.rb +119 -0
  21. data/app/services/where_is_waldo/roster.rb +246 -0
  22. data/app/services/where_is_waldo/roster_delivery.rb +184 -0
  23. data/lib/generators/where_is_waldo/install/install_generator.rb +103 -0
  24. data/lib/generators/where_is_waldo/install/templates/channel.rb.tt +6 -0
  25. data/lib/generators/where_is_waldo/install/templates/connection.rb.tt +51 -0
  26. data/lib/generators/where_is_waldo/install/templates/create_presences.rb.tt +39 -0
  27. data/lib/generators/where_is_waldo/install/templates/initializer.rb.tt +37 -0
  28. data/lib/generators/where_is_waldo/install/templates/migration.rb.tt +20 -0
  29. data/lib/generators/where_is_waldo/install/templates/presence_channel.rb.tt +37 -0
  30. data/lib/where_is_waldo/configuration.rb +209 -0
  31. data/lib/where_is_waldo/engine.rb +17 -0
  32. data/lib/where_is_waldo/version.rb +5 -0
  33. data/lib/where_is_waldo.rb +117 -0
  34. metadata +249 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e8d42866099dafaaf3fcbcd2ea265cdff18d6eff9ffe040df788f59ff97f7f92
4
+ data.tar.gz: 2362c64ad821aae70425e26dafbb3a227903fbd70f7c33817aeaaa878e8f2586
5
+ SHA512:
6
+ metadata.gz: 0b96cfebe30d871334617f83ab6aa5ef9087e95f5dc5886b061b86a7556c9e1cc5edb2b15329713f7f0887dd5f9d2958d4cbe21cb731f31cdbb53df60a511db6
7
+ data.tar.gz: 9905f053dd841bea8d9563de3f488c7ef81093ea0c202835e4d4556108f678f1b636bc559627ef299ac2e7cdb9fea2bbc08cf33145aab134b07f026a0cbff35d
data/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ Notable changes to where_is_waldo. Format loosely follows
4
+ [Keep a Changelog](https://keepachangelog.com/).
5
+
6
+ ## 0.1.1
7
+
8
+ ### Fixed
9
+
10
+ - Package the `VERSION` file (and `CHANGELOG.md`) in the gem. `0.1.0`'s gemspec
11
+ omitted `VERSION`, but `version.rb` reads it at load time, so the installed
12
+ gem raised `Errno::ENOENT` on require and failed to boot. (npm `0.1.0` was
13
+ unaffected — it reads its version from `package.json`.)
14
+
15
+ ## 0.1.0
16
+
17
+ First release to public **npm** (`@byscott-io/where-is-waldo`) and **RubyGems**
18
+ (`where_is_waldo`).
19
+
20
+ ### Added
21
+
22
+ - **Live presence roster** — per-device presence (`active` / `idle` /
23
+ `background` / `offline`, aggregated across web, mobile, and multiple
24
+ sessions), delivered as **data** via the `usePresenceRoster` hook (bring your
25
+ own UI).
26
+ - **Pluggable per-account delivery** (`roster_mode`) — trade latency ×
27
+ visibility × cost per account:
28
+ - `:poll` — client polls; server replies with a server-filtered diff.
29
+ - `:nudge` — `:poll` plus a content-free "re-poll" signal for near-instant refresh.
30
+ - `:fanout` — instant per-viewer push (arbitrary/asymmetric visibility).
31
+ - `:broadcast` — instant shared-stream push (open-visibility accounts).
32
+ - **Server-side visibility enforcement** via `roster_visible_to` (subjects a
33
+ viewer may see) and `roster_viewers_of` (its inverse, for `:fanout`); org
34
+ boundary via `roster_org`.
35
+ - **DOM-free presence reporter core** (`createPresenceReporter`) shared by the
36
+ web `usePresence` hook and a React Native reporter (`metadata.platform`).
37
+ - Docs: mode decision guide (README) and `docs/SERVER_SIDE_SUBSCRIPTIONS.md`
38
+ (future authorized pub/sub direction).
39
+
40
+ ### Notes
41
+
42
+ - npm entry points now ship the built bundle (`dist/`); `@rails/actioncable` is
43
+ a peer dependency (not bundled).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Scott Gibson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,467 @@
1
+ # WhereIsWaldo
2
+
3
+ Real-time presence tracking for Rails + React using ActionCable.
4
+
5
+ ## Features
6
+
7
+ - **Presence tracking** - know who's online
8
+ - **Live presence roster** - per-device "who's around" (active/idle/background across web + mobile), pluggable per-account delivery (`:poll` / `:nudge` / `:fanout` / `:broadcast`)
9
+ - **Scope-based queries** - `online(org.users.admin)`
10
+ - **Targeted broadcasting** - send to any AR scope
11
+ - **Event subscriptions** - components subscribe by event type (`useWaldoEvent`)
12
+ - **Activity monitoring** - tab visibility, user activity
13
+ - **Multi-session** - same user, multiple tabs/devices
14
+ - **Flexible storage** - database or Redis
15
+
16
+ ## Quickstart
17
+
18
+ ### 1. Install
19
+
20
+ ```ruby
21
+ # Gemfile — published on RubyGems.org
22
+ gem 'where_is_waldo', '~> 0.1'
23
+ ```
24
+
25
+ ```bash
26
+ # Database adapter (default)
27
+ rails generate where_is_waldo:install --subject_column=user_id
28
+ rails db:migrate
29
+
30
+ # Redis adapter (no migration needed)
31
+ rails generate where_is_waldo:install --adapter=redis --subject_column=user_id
32
+ ```
33
+
34
+ ```bash
35
+ # published on npm (public registry)
36
+ npm install @byscott-io/where-is-waldo @rails/actioncable
37
+ ```
38
+
39
+ ### 2. Configure
40
+
41
+ ```ruby
42
+ # config/initializers/where_is_waldo.rb
43
+ WhereIsWaldo.configure do |config|
44
+ config.subject_class = "User"
45
+ config.authenticate_proc = ->(request) {
46
+ # Return user_id from your auth token
47
+ decode_token(request.params[:token])[:user_id]
48
+ }
49
+ end
50
+ ```
51
+
52
+ ```jsx
53
+ // app.jsx — configure the connection once, wrap the app
54
+ import { configureCable, PresenceProvider } from '@byscott-io/where-is-waldo';
55
+
56
+ configureCable({
57
+ url: '/cable',
58
+ getToken: () => localStorage.getItem('token'),
59
+ presence: {
60
+ debug: true, // Enable console logging for troubleshooting
61
+ },
62
+ });
63
+
64
+ <PresenceProvider>
65
+ <App />
66
+ </PresenceProvider>
67
+ ```
68
+
69
+ ```jsx
70
+ // any component — subscribe to an event type and filter the payload yourself.
71
+ // Auto-unsubscribes on unmount; no central handler config.
72
+ import { useWaldoEvent } from '@byscott-io/where-is-waldo';
73
+
74
+ function Notifications() {
75
+ useWaldoEvent('notification', (data) => showToast(data.message));
76
+ useWaldoEvent('force_logout', () => logout());
77
+ return null;
78
+ }
79
+ ```
80
+
81
+ ### 3. Use
82
+
83
+ ```ruby
84
+ # Query who's online
85
+ WhereIsWaldo.online(org.users) # => AR relation
86
+ WhereIsWaldo.online(org.users.admin) # => filter by scope
87
+ WhereIsWaldo.subject_online?(user.id) # => true/false
88
+
89
+ # Broadcast messages
90
+ WhereIsWaldo.broadcast_to(org.users, :notification, { message: "Hello!" })
91
+ WhereIsWaldo.broadcast_to(user, :force_logout, { reason: "Password changed" })
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Detailed Documentation
97
+
98
+ ### Server Configuration
99
+
100
+ ```ruby
101
+ WhereIsWaldo.configure do |config|
102
+ config.adapter = :database # or :redis
103
+ config.table_name = "presences"
104
+ config.session_column = :session_id
105
+ config.subject_column = :user_id # or :member_id, :student_id
106
+ config.subject_class = "User" # or "Member", "Student"
107
+
108
+ config.timeout = 90 # seconds until offline
109
+ config.heartbeat_interval = 30
110
+
111
+ # Optional: custom subject data in presence hash. NOTE: with the roster
112
+ # enabled, these fields are broadcast to every member of the org — see
113
+ # Security.
114
+ config.subject_data_proc = ->(user) {
115
+ { id: user.id, name: user.name, avatar: user.avatar_url }
116
+ }
117
+
118
+ # Live presence roster (see "Live Presence Roster"). Set roster_org to
119
+ # enable; roster_members is optional (defaults to org.<subjects>).
120
+ config.roster_org = ->(user) { user.account }
121
+ config.roster_members = ->(org) { org.users.active }
122
+
123
+ # Redis adapter
124
+ # config.redis_client = Redis.new(url: ENV["REDIS_URL"])
125
+ end
126
+ ```
127
+
128
+ ### Querying Presence
129
+
130
+ ```ruby
131
+ # Get online subjects from any AR scope
132
+ WhereIsWaldo.online(org.users)
133
+ WhereIsWaldo.online(User.where(role: "admin"))
134
+ WhereIsWaldo.online(classroom.students)
135
+
136
+ # Get just IDs
137
+ WhereIsWaldo.online_ids(org.users)
138
+
139
+ # Check specific subject
140
+ WhereIsWaldo.subject_online?(user.id)
141
+
142
+ # Get all sessions for a subject
143
+ WhereIsWaldo.sessions_for_subject(user.id)
144
+ # => [{ session_id: "...", tab_visible: true, subject_active: false, ... }]
145
+ ```
146
+
147
+ ### Broadcasting
148
+
149
+ ```ruby
150
+ # To any AR scope
151
+ WhereIsWaldo.broadcast_to(org.users, :notification, { message: "Hi" })
152
+ WhereIsWaldo.broadcast_to(org.users.admin, :alert, { level: "warning" })
153
+
154
+ # Only to online subjects
155
+ WhereIsWaldo.broadcast_to_online(org.users, :update, { data: "..." })
156
+
157
+ # To a single subject (all their sessions)
158
+ WhereIsWaldo.broadcast_to(user, :force_logout, {})
159
+
160
+ # To a specific session
161
+ WhereIsWaldo.broadcast_to_session(session_id, :warning, { message: "..." })
162
+ ```
163
+
164
+ ### Live Presence Roster ("who's around in my org")
165
+
166
+ A ready-made way to show live presence awareness across an org/account — who's
167
+ here right now, on what device, and how active. It is built as **data, not UI**:
168
+ the server keeps the client's roster in sync and you render whatever component
169
+ you like.
170
+
171
+ **Delivery is a per-account strategy** so you can trade latency × cost ×
172
+ visibility-enforcement to fit your app. The server picks the mode and the client
173
+ adapts automatically (no client mode config). Modes (default `:poll`):
174
+
175
+ | Mode | Latency | Visibility | Cost/transition |
176
+ |------|---------|------------|-----------------|
177
+ | `:poll` (default) | ~poll interval | server-side query — **any** rule | flat (1 cached query/poll) |
178
+ | `:nudge` | near-instant | server-side query — **any** rule | O(1) content-free trigger + filtered poll |
179
+ | `:fanout` | instant | server-side per-viewer — **any** rule (incl. asymmetric) | O(audience) |
180
+ | `:broadcast` | instant | **none** (everyone in account sees everyone) | O(1) |
181
+
182
+ `:poll` sends a full **snapshot** on connect, then the client polls and the
183
+ server replies with a server-*filtered* diff (baseline cached per session,
184
+ TTL'd for auto-resync) — so arbitrary/asymmetric visibility "just works" via
185
+ `roster_visible_to`. `:nudge` is `:poll` plus a content-free "re-poll"
186
+ broadcast on each transition, so clients refresh near-instantly instead of
187
+ waiting for the next interval — same airtight server-side filtering, just lower
188
+ latency; the nudge carries no identity/state (only "activity happened").
189
+ `:fanout` pushes instantly to a **per-viewer** stream: on a transition the
190
+ subject's delta goes to every viewer in its directional `roster_viewers_of`
191
+ (the inverse of `roster_visible_to`), so even *asymmetric* visibility
192
+ (manager-sees-report-but-not-vice-versa) is exact — at O(audience) broadcasts
193
+ per transition. `:broadcast` instead streams one shared account stream and
194
+ pushes deltas instantly, with **no** visibility filtering (open-visibility
195
+ accounts only); pair it with the client `filter` option below for *cosmetic*
196
+ per-viewer hiding.
197
+
198
+ ```jsx
199
+ // :broadcast + cosmetic client filter (NOT a security boundary — the full
200
+ // roster still reaches the client; use a server-side mode to truly enforce).
201
+ const { online } = usePresenceRoster({ filter: (m) => canISee(m.id) });
202
+ ```
203
+
204
+ #### Which mode should I use?
205
+
206
+ Two questions decide it:
207
+
208
+ **1. Is the client *always* interested, or only *sometimes*?**
209
+ - **Sometimes** — presence is ambient (an avatar stack / sidebar you glance at; the component mounts only when relevant). Use the **pull family** (`:poll` / `:nudge`): the client fetches only while something's mounted and cares, and the server is idle at rest.
210
+ - **Always** — presence *is* the live experience (chat, collaborative cursors). Use the **push family** (`:fanout` / `:broadcast`): the server streams every change as it happens.
211
+
212
+ **2. Then the within-family choice:**
213
+ - `:poll` vs `:nudge` → **latency.** Both pull, both filtered. `:poll` is interval-fresh; `:nudge` is near-instant (a content-free "changed" ping triggers an immediate poll).
214
+ - `:fanout` vs `:broadcast` → **visibility.** Both push. `:fanout` keeps per-viewer server-side filtering; `:broadcast` uses one shared stream with no filtering (open-visibility accounts only).
215
+
216
+ Two things people get subtly wrong:
217
+ - **Filtering is not fanout-only.** `:poll`, `:nudge`, *and* `:fanout` all enforce visibility server-side. **`:broadcast` is the only unfiltered mode.**
218
+ - **`:fanout` ships the data; `:nudge` ships a "time to poll" signal.** Both give instant, filtered updates — `:fanout` spends server fan-out (O(audience) data pushes), `:nudge` spends client polls (O(1) ping + a poll per interested viewer). Same result, different cost.
219
+
220
+ See `docs/PRESENCE_ROSTER_PLAN.md` for the full mode matrix and tradeoffs.
221
+
222
+ **Per-device, multi-session.** A subject's state is aggregated across *all*
223
+ their live sessions (multiple browser tabs, mobile, etc.):
224
+
225
+ ```
226
+ { id: 7, status: "active", devices: { web: "idle", mobile: "active" } }
227
+ ```
228
+
229
+ - `status` — highest activity across devices (the "active anywhere?" answer):
230
+ - `active` — a live session is visible/foreground **and** working
231
+ - `idle` — a live session is visible/foreground but not actively using
232
+ - `background` — only backgrounded/hidden sessions are live
233
+ - `offline` — no live sessions
234
+ - `devices[platform]` — that platform's own status (answers "active on
235
+ **mobile**?" vs. "active at all?").
236
+
237
+ #### Configure
238
+
239
+ ```ruby
240
+ WhereIsWaldo.configure do |config|
241
+ # The org/account a subject belongs to. Required to enable the roster.
242
+ config.roster_org = ->(subject) { subject.account }
243
+
244
+ # What a VIEWER may see (:poll/:nudge/:fanout). Any visibility rule,
245
+ # server-enforced. Defaults to the viewer's whole org when unset.
246
+ config.roster_visible_to = ->(viewer) { viewer.visible_users }
247
+
248
+ # Who may see a SUBJECT — the inverse of roster_visible_to. Only needed
249
+ # for :fanout (enables instant push under asymmetric visibility).
250
+ config.roster_viewers_of = ->(subject) { subject.visible_to_users }
251
+
252
+ # Delivery mode, per account. Symbol or a callable resolving account -> mode.
253
+ # MUST be a function of the account (uniform for all its members). Default :poll.
254
+ config.roster_mode = ->(account) { account.everyone_admin? ? :broadcast : :poll }
255
+
256
+ # :broadcast only — the member list for the shared snapshot. Defaults to
257
+ # org.public_send(<subjects>) inferred from subject_class (e.g. :users).
258
+ config.roster_members = ->(org) { org.users.active }
259
+
260
+ # Tuning (:poll/:nudge)
261
+ config.roster_poll_interval = 15 # seconds
262
+ config.roster_cache_ttl = 90 # seconds; > poll gap → auto-resync
263
+ end
264
+ ```
265
+
266
+ The `RosterChannel` is provided by the gem; no app code is needed beyond config.
267
+
268
+ #### Consume it (React — data hook, bring your own UI)
269
+
270
+ ```jsx
271
+ import { usePresenceRoster, presenceColor, memberLabel } from '@byscott-io/where-is-waldo';
272
+
273
+ function TeamPresence() {
274
+ const { online, members, onlineCount, byId } = usePresenceRoster();
275
+
276
+ // `members` is always the full, live set (snapshot seeded, deltas patched).
277
+ return (
278
+ <ul>
279
+ {online.map((m) => (
280
+ <li key={m.id}>
281
+ <span style={{ color: presenceColor(m.status) }}>●</span>
282
+ {memberLabel(m)} — {m.status}
283
+ {m.devices.mobile && ' 📱'}
284
+ </li>
285
+ ))}
286
+ </ul>
287
+ );
288
+ }
289
+ ```
290
+
291
+ The hook and its reducer (`../core/rosterStore`) are pure — **no DOM** — so a
292
+ React Native app reuses the exact same data logic and only swaps the view.
293
+
294
+ #### Query presence server-side
295
+
296
+ ```ruby
297
+ WhereIsWaldo.roster_snapshot(org) # full roster + per-device state
298
+ WhereIsWaldo.roster_state_for(user.id) # => { status:, devices: }
299
+ WhereIsWaldo.presence_on(user.id, :mobile) # => "idle" (per-device)
300
+ ```
301
+
302
+ #### Reporting presence from mobile
303
+
304
+ Mobile is "logged in" purely by connecting with `metadata: { platform: "mobile" }`
305
+ and sending the same heartbeat shape as the web client. The heartbeat/activity
306
+ state machine lives in a DOM-free core, `createPresenceReporter`, that the web
307
+ `usePresence` hook wraps — a React Native app reuses the **same core** and only
308
+ swaps the sensors: map app foreground/background to `setVisible`, and touches to
309
+ `reportActivity`. No server-side changes; `platform` is read from the metadata
310
+ (defaults to `"web"`).
311
+
312
+ ```jsx
313
+ // React Native reporter — same core, native sensors.
314
+ import { useEffect, useRef } from 'react';
315
+ import { AppState, PanResponder } from 'react-native';
316
+ import { createPresenceReporter, configureCable } from '@byscott-io/where-is-waldo';
317
+
318
+ configureCable({ url: WS_URL, getToken: () => token });
319
+
320
+ export function usePresenceNative() {
321
+ const reporterRef = useRef(null);
322
+
323
+ useEffect(() => {
324
+ const reporter = createPresenceReporter({ metadata: { platform: 'mobile' } });
325
+ reporterRef.current = reporter;
326
+ reporter.start();
327
+ reporter.setVisible(AppState.currentState === 'active');
328
+
329
+ const sub = AppState.addEventListener('change', (s) =>
330
+ reporter.setVisible(s === 'active'),
331
+ );
332
+ return () => {
333
+ sub.remove();
334
+ reporter.stop();
335
+ };
336
+ }, []);
337
+
338
+ // Feed touches as activity (attach these handlers to your root view).
339
+ const pan = useRef(
340
+ PanResponder.create({
341
+ onStartShouldSetPanResponderCapture: () => {
342
+ reporterRef.current?.reportActivity();
343
+ return false; // observe only; don't capture the gesture
344
+ },
345
+ }),
346
+ ).current;
347
+
348
+ return pan.panHandlers;
349
+ }
350
+ ```
351
+
352
+ `createPresenceReporter` is pure JS (no DOM), and `@rails/actioncable` works on
353
+ React Native with the built-in `WebSocket` — so the reporter, the roster hook
354
+ (`usePresenceRoster`), and the roster reducer all run unchanged on mobile; only
355
+ the sensors and the view are platform-specific.
356
+
357
+ ### Client Event Subscriptions
358
+
359
+ Components subscribe to a raw event **type** with the `useWaldoEvent` hook,
360
+ receive the payload, and decide for themselves whether it's relevant. The
361
+ subscription auto-unsubscribes on unmount — there is no central handler
362
+ registry. Adding a new event = a server `broadcast_to` + a `useWaldoEvent`
363
+ call in whatever component cares.
364
+
365
+ ```jsx
366
+ import { useWaldoEvent } from '@byscott-io/where-is-waldo';
367
+
368
+ function ChatRoom({ roomId }) {
369
+ // Single type — filter the payload yourself
370
+ useWaldoEvent('chat_message', (data) => {
371
+ if (data.room_id === roomId) addMessage(data);
372
+ });
373
+
374
+ // Many types at once. useWaldoEvent is a pure subscription — to coalesce a
375
+ // burst into one call, compose a debounce at the call site (e.g. corebyscott's
376
+ // useDebouncedCallback): const refetch = useDebouncedCallback(loadAll, 250);
377
+ useWaldoEvent(['notification', 'data_refresh'], refetch);
378
+
379
+ return null;
380
+ }
381
+ ```
382
+
383
+ For a non-React / imperative context, `subscribeToEvent(type, cb)` returns an
384
+ unsubscribe function:
385
+
386
+ ```js
387
+ import { subscribeToEvent } from '@byscott-io/where-is-waldo';
388
+
389
+ const unsubscribe = subscribeToEvent('chat_message', (data) => addMessage(data));
390
+ // later: unsubscribe();
391
+ ```
392
+
393
+ ### React Hooks
394
+
395
+ ```jsx
396
+ import { usePresenceContext } from '@byscott-io/where-is-waldo';
397
+
398
+ function StatusIndicator() {
399
+ const { connected, tabVisible, subjectActive } = usePresenceContext();
400
+
401
+ return <span>{connected ? 'Online' : 'Offline'}</span>;
402
+ }
403
+ ```
404
+
405
+ ### Cleanup Job
406
+
407
+ ```ruby
408
+ # For database adapter - schedule cleanup of stale records
409
+ # config/initializers/sidekiq.rb
410
+ Sidekiq::Cron::Job.create(
411
+ name: 'Presence cleanup',
412
+ cron: '*/5 * * * *',
413
+ class: 'WhereIsWaldo::PresenceCleanupJob'
414
+ )
415
+ ```
416
+
417
+ ### Version Management
418
+
419
+ ```bash
420
+ rake version:show # Show current version
421
+ rake version:bump[0.1.0] # Bump gem and npm together
422
+ ```
423
+
424
+ ## Security
425
+
426
+ ActionCable presence is only as safe as the connection auth around it. What the
427
+ gem guarantees, and what your app must do:
428
+
429
+ **Guaranteed by the gem**
430
+
431
+ - **No client-chosen rooms.** `RosterChannel` derives the org from the
432
+ *authenticated connection* (`current_subject` → `roster_org`), never from a
433
+ client-supplied param — a user cannot subscribe to another org's roster.
434
+ - **Self-scoped subject streams.** `PresenceChannel` streams only the
435
+ connection's own subject id, so targeted messages can't be eavesdropped.
436
+ - **Unauthenticated connections are rejected** (`JwtConnection`).
437
+
438
+ **Your app's responsibility**
439
+
440
+ - **Identify the connection from a *verified* credential** (a signed JWT, as
441
+ `JwtConnection` does). Never trust a client-supplied `subject_id`. ⚠️ The
442
+ dummy app authenticates from a query param for tests only — do not copy that
443
+ into production.
444
+ - **`subject_data_proc` fans out org-wide.** Every field it returns is
445
+ broadcast to all roster members. Include only what all members may see; keep
446
+ PII out unless intended.
447
+ - **Visibility enforcement depends on the delivery mode.** `:poll`, `:nudge`,
448
+ and `:fanout` enforce server-side — `:poll`/`:nudge` by `presence_visible_
449
+ scope` (a `WHERE ... IN` clause), `:fanout` by `roster_viewers_of` — so
450
+ nothing an unauthorized member could read off the wire. `:broadcast` does
451
+ **no** filtering: it shares one account stream and pushes every member's
452
+ presence to everyone (`roster_members` only scopes the initial *snapshot
453
+ list*, not the live stream). The client `filter` option is **cosmetic** — the
454
+ full data still reaches the client — so it is not an access-control boundary.
455
+ Select `:broadcast` (± client filter) only for genuinely open-visibility
456
+ accounts; for any restricted visibility use a server-side mode (`:poll` is the
457
+ default). See `docs/PRESENCE_ROSTER_PLAN.md` for the full mode matrix.
458
+ - **Token in the URL.** The JWT is passed as `?token=…`; use WSS only, keep
459
+ tokens short-lived, and avoid logging query strings. Set
460
+ `config.action_cable.allowed_request_origins` as defense-in-depth.
461
+ - **Heartbeat/DoS.** Heartbeats are client-paced DB writes; roster broadcasts
462
+ are gated to transitions. For large or hostile deployments use the Redis
463
+ adapter and consider rate-limiting.
464
+
465
+ ## License
466
+
467
+ MIT
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.1
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ module ApplicationCable
5
+ class Channel < ActionCable::Channel::Base
6
+ private
7
+
8
+ def waldo_session_id
9
+ connection.session_id
10
+ end
11
+
12
+ def waldo_subject_id
13
+ subject = connection.try(:current_user) ||
14
+ connection.try("current_#{WhereIsWaldo.configuration.subject_class.underscore}")
15
+ subject&.id
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ module ApplicationCable
5
+ class Connection < ActionCable::Connection::Base
6
+ identified_by :waldo_subject_id, :waldo_session_id
7
+
8
+ def connect
9
+ authenticate_connection
10
+ end
11
+
12
+ private
13
+
14
+ def authenticate_connection
15
+ auth_proc = WhereIsWaldo.config.authenticate_proc
16
+
17
+ if auth_proc
18
+ # Use custom authentication
19
+ result = auth_proc.call(request)
20
+
21
+ if result.is_a?(Hash)
22
+ self.waldo_subject_id = result[:subject_id]
23
+ self.waldo_session_id = result[:session_id] || generate_session_id
24
+ elsif result
25
+ # If proc returns truthy non-hash, use it as subject_id
26
+ self.waldo_subject_id = result
27
+ self.waldo_session_id = generate_session_id
28
+ else
29
+ reject_unauthorized_connection
30
+ end
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
35
+
36
+ reject_unauthorized_connection unless waldo_subject_id
37
+ end
38
+ end
39
+
40
+ def generate_session_id
41
+ SecureRandom.uuid
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ # Turnkey ActionCable connection auth for corebyscott apps. The cable client
5
+ # appends the user's JWT as `?token=...`; this decodes it via
6
+ # Corebyscott::JwtService and identifies the connection by the authenticated
7
+ # user (the presence subject), the JWT's container (cid), and session (jti).
8
+ #
9
+ # # app/channels/application_cable/connection.rb
10
+ # module ApplicationCable
11
+ # class Connection < ActionCable::Connection::Base
12
+ # include WhereIsWaldo::JwtConnection
13
+ # end
14
+ # end
15
+ #
16
+ # The subject model comes from WhereIsWaldo.configuration.subject_class
17
+ # (e.g. "User"); the container class from Corebyscott.config. Opt-in: only
18
+ # include it in apps that authenticate with corebyscott JWTs.
19
+ module JwtConnection
20
+ extend ActiveSupport::Concern
21
+
22
+ included do
23
+ identified_by :current_user, :current_container, :session_id
24
+ end
25
+
26
+ def connect
27
+ self.current_user = wiw_verified_user
28
+ self.current_container = wiw_container
29
+ self.session_id = wiw_session_id
30
+ end
31
+
32
+ private
33
+
34
+ # Memoized decode of the ?token= param. nil when absent/invalid.
35
+ def wiw_token_payload
36
+ return @wiw_token_payload if defined?(@wiw_token_payload)
37
+
38
+ token = request.params[:token]
39
+ @wiw_token_payload =
40
+ if token.present? && defined?(::Corebyscott::JwtService)
41
+ begin
42
+ ::Corebyscott::JwtService.decode(token, touch_last_used: false)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+ end
47
+ end
48
+
49
+ def wiw_verified_user
50
+ payload = wiw_token_payload
51
+ reject_unauthorized_connection unless payload
52
+
53
+ klass = WhereIsWaldo.configuration.subject_class_constant
54
+ user = klass&.find_by(id: payload["sub"] || payload[:sub])
55
+ reject_unauthorized_connection unless user
56
+
57
+ user
58
+ end
59
+
60
+ # Optional container scoping from the JWT's `cid` claim. nil when the app
61
+ # doesn't scope tokens to a container, or corebyscott isn't present.
62
+ def wiw_container
63
+ payload = wiw_token_payload
64
+ return nil unless payload && current_user
65
+ return nil unless defined?(::Corebyscott)
66
+
67
+ cid = payload["cid"] || payload[:cid]
68
+ return nil if cid.blank?
69
+
70
+ container_class = begin
71
+ ::Corebyscott.config.container_class&.constantize
72
+ rescue StandardError
73
+ nil
74
+ end
75
+ container_class&.find_by(id: cid)
76
+ end
77
+
78
+ def wiw_session_id
79
+ payload = wiw_token_payload
80
+ payload&.dig("jti") || payload&.dig(:jti) || SecureRandom.uuid
81
+ end
82
+ end
83
+ end