mbeditor 0.12.2 → 0.12.3

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: a2d9c1ba81f945f5ce0772455f51e66a110fa43a1e24f62c96e6859312c4a968
4
- data.tar.gz: d01eb3a6edd6e221e92c25e093c7c5944b32c50dafad6a4cacfa953e5c0b1925
3
+ metadata.gz: 88d963e43c97cf6974b3e38c079ffd2dba642dc1c13a68237f1a3a4f7c21e9a0
4
+ data.tar.gz: 9e32a366d6ea7ce5014fff94ee89adba7f6909d09aa29f6404142b4155688a3a
5
5
  SHA512:
6
- metadata.gz: f8c189fa9f7215d5a376ab321a10186645c6671a2c1de874c3f3b254e3ebebb20a2c102e0c5357fb0ee0183eb0c634d2f26c7f7860901cec8e941f56ca26bd75
7
- data.tar.gz: d0f8bdb6abe76b88e8438c0f31e78055d49fe09dfb3c88df1031312c33985b8ad2906f72482cc32a7c986a4019dee19226e42c96fa6d99b52f1da1cdb4faa790
6
+ metadata.gz: 100805339d3956837f0ec5349de86b5bcf4315ef5dd261e76b80c0acb589cd9036264b70bb65677be3d27be9575007daee02e08c1cbad86ac3136bd331312a6b
7
+ data.tar.gz: d1d0681c8962251ceb0d9f18db0937e20f3c1ae1e2d30754a08a88ee10a47b6a6492641de84b9e83c0b2ab81963e79d1f3d86bb61a985bfa7b4a5eb579d0548d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.3] - 2026-08-03
11
+
12
+ ### Added
13
+ - **`config.cable_authenticate_with`** — authentication for the collaboration
14
+ WebSocket, falling back to `authenticate_with` when unset.
15
+ - **A "Pairing off" chip and diagnostics panel.** When collaboration cannot
16
+ work, the editor now says so and lists every condition it depends on — the
17
+ vendored libraries, Action Cable's JavaScript, whether the server advertises
18
+ it, whether the socket actually connected, and whether anyone else is here —
19
+ with the fix written beside each failure. Nothing is shown when everything is
20
+ healthy and you are simply alone.
21
+
22
+ ### Fixed
23
+ - **A WebSocket subscribe runs no controller, and this made pairing silently
24
+ impossible for most authenticated apps.** `authenticate_with` is evaluated on
25
+ the cable against a probe exposing `session`, `cookies`, `request` and
26
+ `params` — so a hook reading `Current.user`, calling `UserSession.find`, or
27
+ using a memoised `current_user` gets `nil` or a `NameError`, denies, and the
28
+ socket is rejected. The hook keeps working perfectly over HTTP, so nothing
29
+ looks wrong; collaboration simply never connects.
30
+
31
+ Rejections are now logged as `[mbeditor] WebSocket subscription rejected: …`
32
+ naming the cause, `cable_authenticate_with` provides an escape hatch when one
33
+ proc cannot serve both contexts, and the README says this plainly instead of
34
+ noting that request-scoped state "may be narrower". The README's own example
35
+ used `UserSession.find`, which is exactly the pattern that cannot work.
36
+
10
37
  ## [0.12.2] - 2026-08-03
11
38
 
12
39
  ### Added
data/README.md CHANGED
@@ -67,8 +67,12 @@ Mbeditor.configure do |config|
67
67
  config.excluded_paths = %w[.git tmp log node_modules .bundle coverage vendor/bundle]
68
68
  config.rubocop_command = "bundle exec rubocop"
69
69
 
70
- # Optional authentication (runs as a before_action in the engine controllers)
71
- # config.authenticate_with = proc { redirect_to login_path unless UserSession.find }
70
+ # Optional authentication (runs as a before_action in the engine controllers,
71
+ # and on the collaboration WebSocket subscribe). Resolve the user from
72
+ # `session` — a WebSocket subscribe runs no controller, so Current.user,
73
+ # UserSession.find and a memoised current_user are all unavailable there.
74
+ # config.authenticate_with = proc { redirect_to login_path unless User.find_by(id: session[:user_id]) }
75
+ # config.cable_authenticate_with = proc { ... } # when one proc cannot serve both
72
76
 
73
77
  # Optional test runner (Minitest or RSpec)
74
78
  # config.test_framework = :minitest # :minitest or :rspec — auto-detected when nil
@@ -123,6 +127,7 @@ end
123
127
  | Option | Default | Description |
124
128
  |--------|---------|-------------|
125
129
  | `authenticate_with` | `nil` | Proc run as a `before_action` in all engine controllers. Executed via `instance_exec` inside the controller, so it has access to `session`, `cookies`, `redirect_to`, and auth-library class methods (e.g. Authlogic's `UserSession.find`) — but not helper methods from the host's `ApplicationController`. The same hook is also evaluated when the collaboration / editor **WebSocket** subscribes (see [Collaborative pairing](#collaborative-pairing-optional)); if it halts or raises, that subscription is rejected (fail-closed). Over the cable the proc runs against a request-derived probe, so request-scoped state may be narrower than over HTTP. |
130
+ | `cable_authenticate_with` | `nil` | Authentication for the collaboration WebSocket; `nil` falls back to `authenticate_with`. Set this when the HTTP hook cannot work on the cable — a WebSocket subscribe runs no controller, so `Current.*`, an Authlogic `UserSession` and a memoised `current_user` are `nil` or raise, and the hook then denies the socket while working perfectly over HTTP. See [Collaborative pairing](#collaborative-pairing-optional). |
126
131
  | `authentication_cache_ttl` | `0` | Seconds to cache the auth result in the session (`0` = no caching). Set e.g. `300` to avoid calling `authenticate_with` on every request when the proc is expensive. Trade-off: after host logout, mbeditor stays accessible for up to TTL seconds. |
127
132
 
128
133
  ### Test runner
@@ -335,20 +340,45 @@ should be able to reach the port.
335
340
 
336
341
  **2. Set an authentication hook — it runs on the WebSocket handshake.**
337
342
  Configure `authenticate_with` (see the [Authentication](#authentication) options).
338
- The same hook that gates the HTTP editor is now also evaluated when the collaboration /
343
+ The same hook that gates the HTTP editor is also evaluated when the collaboration /
339
344
  editor WebSocket subscribes: if it halts (e.g. `redirect_to`/`render`/`head`) — or raises —
340
345
  the socket subscription is **rejected (fail-closed)**, so pairing cannot bypass your auth.
341
346
 
347
+ > **A WebSocket subscribe runs no controller.** This is the single most common reason
348
+ > pairing appears to do nothing. Anything a `before_action` populates is unavailable on
349
+ > the cable: `Current.user` (and any other `ActiveSupport::CurrentAttributes`) is `nil`,
350
+ > `UserSession.find` raises because Authlogic's controller adapter was never activated,
351
+ > and a memoised `current_user` is undefined. The hook then denies — correctly, given it
352
+ > was handed nobody — and the socket is rejected. It works perfectly over HTTP the whole
353
+ > time, so nothing looks wrong until you notice collaboration never connects.
354
+
355
+ The hook is evaluated against a probe exposing `session`, `cookies`, `request`, `params`
356
+ and the halt methods. Resolve the user from `session` so the same hook works in both
357
+ contexts:
358
+
359
+ ```ruby
360
+ Mbeditor.configure do |c|
361
+ c.authenticate_with = proc do
362
+ user = Current.user || User.find_by(id: session[:user_credentials_id])
363
+ head :forbidden unless user&.super_admin_access?
364
+ end
365
+ end
366
+ ```
367
+
368
+ When one proc genuinely cannot serve both, give the cable its own:
369
+
342
370
  ```ruby
343
371
  Mbeditor.configure do |c|
344
- # Runs as a controller before_action AND on the cable subscribe.
345
- c.authenticate_with = proc { head :forbidden unless UserSession.find }
372
+ c.authenticate_with = proc { head :forbidden unless Current.user&.super_admin_access? }
373
+ c.cable_authenticate_with = proc do
374
+ head :forbidden unless User.find_by(id: session[:user_credentials_id])&.super_admin_access?
375
+ end
346
376
  end
347
377
  ```
348
378
 
349
- Because the cable mount can bypass parts of the host middleware stack, a hook that leans on
350
- request-scoped state (full `session`, encrypted `cookies`) may see less over the WebSocket
351
- than it does over HTTP. For defence in depth, also authenticate at your host app's
379
+ Every rejection is logged as `[mbeditor] WebSocket subscription rejected: …` with the
380
+ reason, so a denial is visible rather than silent. The editor also shows a **Pairing off**
381
+ chip when collaboration cannot work, listing each failing condition. For defence in depth, also authenticate at your host app's
352
382
  `ApplicationCable::Connection` (the standard `identified_by` / `reject_unauthorized_connection`
353
383
  pattern) — mbeditor's hook is an additional gate, not a replacement for securing the cable
354
384
  connection itself.
@@ -120,6 +120,67 @@ var CollaborationService = (function () {
120
120
  });
121
121
  }
122
122
 
123
+ // Every condition collaboration depends on, reported separately.
124
+ //
125
+ // isAvailable() collapses five things into one boolean, which is useless when
126
+ // pairing does not work: "false" could be a missing vendored library, a cable
127
+ // the server never advertised, a rejected handshake, or simply nobody else
128
+ // being here. Someone debugging this on another machine cannot paste a console
129
+ // snippet back, so the editor has to be able to say which.
130
+ function diagnostics() {
131
+ var cableStatus = (typeof WebSocketService !== 'undefined' &&
132
+ typeof WebSocketService.cableStatus === 'function')
133
+ ? WebSocketService.cableStatus() : 'unknown';
134
+
135
+ var checks = [
136
+ {
137
+ key: 'libraries',
138
+ label: 'Collaboration libraries loaded',
139
+ ok: typeof window.Y !== 'undefined' && typeof window.MonacoBinding !== 'undefined',
140
+ detail: 'vendor/assets/javascripts/yjs-collab.js is served by the asset pipeline. ' +
141
+ 'If this fails, the host app is not serving it — try rails assets:clobber, ' +
142
+ 'or check config.assets.precompile.'
143
+ },
144
+ {
145
+ key: 'actioncable',
146
+ label: 'Action Cable JavaScript present',
147
+ ok: typeof window.ActionCable !== 'undefined',
148
+ detail: 'The host app must make actioncable.js available to the page.'
149
+ },
150
+ {
151
+ key: 'advertised',
152
+ label: 'Server advertises Action Cable',
153
+ ok: typeof WebSocketService !== 'undefined' && WebSocketService.isCableAvailable(),
154
+ detail: 'Action Cable must be enabled and mounted in the host app routes.'
155
+ },
156
+ {
157
+ key: 'connected',
158
+ label: 'WebSocket connected',
159
+ ok: cableStatus === 'connected',
160
+ detail: cableStatus === 'rejected'
161
+ ? 'The server rejected the subscription. Usually action_cable.allowed_request_origins ' +
162
+ 'not listing the origin you are reaching the editor through, or an authenticate_with ' +
163
+ 'hook halting on the cable (it fails closed). Look for "Request origin not allowed" in the log.'
164
+ : 'Status: ' + cableStatus + '.'
165
+ },
166
+ {
167
+ key: 'peers',
168
+ label: 'Another participant connected',
169
+ ok: _peerPresent,
170
+ detail: 'Presence is held per web process. If two people are on different Puma workers ' +
171
+ 'they never see each other — run a single worker (WEB_CONCURRENCY=0) while pairing.'
172
+ }
173
+ ];
174
+
175
+ return {
176
+ ok: checks.every(function (c) { return c.ok; }),
177
+ cableStatus: cableStatus,
178
+ checks: checks,
179
+ // The first failing check is the one worth acting on; the rest follow from it.
180
+ firstProblem: checks.filter(function (c) { return !c.ok; })[0] || null
181
+ };
182
+ }
183
+
123
184
  // Subscribe to availability transitions. Returns an unsubscribe function.
124
185
  function onAvailabilityChange(fn) {
125
186
  _availabilityListeners.push(fn);
@@ -674,6 +735,7 @@ var CollaborationService = (function () {
674
735
  return {
675
736
  isAvailable: isAvailable,
676
737
  setPeerPresent: setPeerPresent,
738
+ diagnostics: diagnostics,
677
739
  onAvailabilityChange: onAvailabilityChange,
678
740
  isEnabledFor: isEnabledFor,
679
741
  ensureRoom: ensureRoom,
@@ -2450,6 +2450,30 @@ var MbeditorApp = function MbeditorApp() {
2450
2450
  // external-change detection for solo users. The service is told about the roster
2451
2451
  // from the presence handler above, not from an effect here, so it hears about
2452
2452
  // every message rather than only about a change in the participant count.
2453
+ var _collabDiag = useState(false);
2454
+ var collabDiagOpen = _collabDiag[0], setCollabDiagOpen = _collabDiag[1];
2455
+ var _collabTrouble = useState(null);
2456
+ var collabTrouble = _collabTrouble[0], setCollabTrouble = _collabTrouble[1];
2457
+
2458
+ // Re-checked on a slow interval because the failures worth reporting — a
2459
+ // rejected handshake, a dropped socket — happen asynchronously and nothing
2460
+ // else would notice. Only the first failing check's key is stored, so an
2461
+ // unchanged state is an identical string and React bails rather than
2462
+ // re-rendering the app every tick.
2463
+ useEffect(function () {
2464
+ function check() {
2465
+ if (typeof CollaborationService === 'undefined' ||
2466
+ typeof CollaborationService.diagnostics !== 'function') return;
2467
+ var d = CollaborationService.diagnostics();
2468
+ // "Nobody else is here" is not a fault; everything else is.
2469
+ var problem = d.firstProblem && d.firstProblem.key !== 'peers' ? d.firstProblem.key : null;
2470
+ setCollabTrouble(function (prev) { return prev === problem ? prev : problem; });
2471
+ }
2472
+ check();
2473
+ var id = setInterval(check, 10000);
2474
+ return function () { clearInterval(id); };
2475
+ }, []);
2476
+
2453
2477
  var collabPeerIds = Object.keys(collabRoster);
2454
2478
 
2455
2479
  // A labelled peer chip costs ~110px (name + filename), and the titlebar button
@@ -4217,6 +4241,28 @@ var MbeditorApp = function MbeditorApp() {
4217
4241
  !toolbarIconOnly && " Git"
4218
4242
  )
4219
4243
  ),
4244
+ // Collaboration trouble chip. Deliberately not shown when everything is
4245
+ // healthy and you are simply alone — that was the whole point of hiding
4246
+ // the solo chip. It appears only when a condition pairing needs has
4247
+ // actually failed, so silence means "nobody here" and never "quietly
4248
+ // broken", which is exactly the ambiguity that made a real pairing
4249
+ // failure impossible to diagnose from the other machine.
4250
+ collabTrouble && React.createElement(
4251
+ React.Fragment,
4252
+ null,
4253
+ React.createElement("div", { className: "statusbar-sep" }),
4254
+ React.createElement(
4255
+ "button",
4256
+ {
4257
+ type: "button",
4258
+ className: "statusbar-btn mbeditor-collab-trouble",
4259
+ title: "Pairing is not available — click for details",
4260
+ onClick: function () { setCollabDiagOpen(true); }
4261
+ },
4262
+ React.createElement("i", { className: "fas fa-user-slash" }),
4263
+ !toolbarIconOnly && " Pairing off"
4264
+ )
4265
+ ),
4220
4266
  // Your own chip appears only once someone else is actually connected.
4221
4267
  // Alone it told you nothing — Action Cable is up in any normal dev setup,
4222
4268
  // so it sat in the toolbar permanently announcing a session of one. While
@@ -4358,6 +4404,49 @@ var MbeditorApp = function MbeditorApp() {
4358
4404
  )
4359
4405
  );
4360
4406
  })(),
4407
+ // The diagnostics panel. Every condition listed with its own state, because
4408
+ // the person who needs this is usually on the other machine and cannot paste
4409
+ // a console snippet back.
4410
+ collabDiagOpen && (function () {
4411
+ var d = CollaborationService.diagnostics();
4412
+ return React.createElement(
4413
+ 'div',
4414
+ { className: 'mbeditor-modal-backdrop', onClick: function () { setCollabDiagOpen(false); } },
4415
+ React.createElement(
4416
+ 'div',
4417
+ { className: 'mbeditor-collab-diag', onClick: function (e) { e.stopPropagation(); } },
4418
+ React.createElement('div', { className: 'mbeditor-collab-diag-title' }, 'Pair programming status'),
4419
+ React.createElement(
4420
+ 'ul',
4421
+ { className: 'mbeditor-collab-diag-list' },
4422
+ d.checks.map(function (c) {
4423
+ return React.createElement(
4424
+ 'li',
4425
+ { key: c.key, className: 'mbeditor-collab-diag-row' + (c.ok ? '' : ' is-bad') },
4426
+ React.createElement('i', { className: c.ok ? 'fas fa-check' : 'fas fa-times' }),
4427
+ React.createElement(
4428
+ 'div',
4429
+ null,
4430
+ React.createElement('div', { className: 'mbeditor-collab-diag-label' }, c.label),
4431
+ !c.ok && React.createElement('div', { className: 'mbeditor-collab-diag-detail' }, c.detail)
4432
+ )
4433
+ );
4434
+ })
4435
+ ),
4436
+ React.createElement(
4437
+ 'div',
4438
+ { className: 'mbeditor-collab-diag-foot' },
4439
+ d.ok
4440
+ ? 'Pairing is working.'
4441
+ : 'The first failing item above is the one to fix; the rest follow from it.'
4442
+ ),
4443
+ React.createElement('button', {
4444
+ type: 'button', className: 'ide-model-graph-btn',
4445
+ onClick: function () { setCollabDiagOpen(false); }
4446
+ }, 'Close')
4447
+ )
4448
+ );
4449
+ })(),
4361
4450
  showHelp && React.createElement(ShortcutHelp, { onClose: function () { return setShowHelp(false); } }),
4362
4451
  React.createElement(
4363
4452
  "div",
@@ -10,6 +10,11 @@ var WebSocketService = (function () {
10
10
  var _consumer = null;
11
11
  var _subscription = null;
12
12
  var _connected = false;
13
+ // Why collaboration is or is not working, so the editor can say so instead of
14
+ // failing silently. A rejected subscription used to just schedule a reconnect
15
+ // and tell nobody, which made a blocked cable indistinguishable from an empty
16
+ // room.
17
+ var _status = 'idle'; // idle | unsupported | connecting | connected | rejected | dropped
13
18
  var _filesChangedCallbacks = [];
14
19
  var _fileSavedCallbacks = [];
15
20
  var _presenceCallbacks = [];
@@ -98,6 +103,7 @@ var WebSocketService = (function () {
98
103
  }
99
104
 
100
105
  _lastCableAttemptAt = Date.now();
106
+ if (_status !== 'rejected') _status = 'connecting';
101
107
 
102
108
  try {
103
109
  _consumer = _getConsumer();
@@ -106,12 +112,15 @@ var WebSocketService = (function () {
106
112
  {
107
113
  connected: function () {
108
114
  _connected = true;
115
+ _status = 'connected';
109
116
  },
110
117
  disconnected: function () {
118
+ _status = _status === 'rejected' ? 'rejected' : 'dropped';
111
119
  _cleanupConsumer();
112
120
  _scheduleReconnect();
113
121
  },
114
122
  rejected: function () {
123
+ _status = 'rejected';
115
124
  _cleanupConsumer();
116
125
  _scheduleReconnect();
117
126
  },
@@ -180,6 +189,7 @@ var WebSocketService = (function () {
180
189
  function connect(serverSupportsWs) {
181
190
  _serverSupportsWs = !!serverSupportsWs;
182
191
  if (!_serverSupportsWs || !_isActionCableAvailable()) {
192
+ _status = 'unsupported';
183
193
  return; // polling remains the only refresh mechanism
184
194
  }
185
195
  _installUnhandledRejectionGuard();
@@ -201,6 +211,12 @@ var WebSocketService = (function () {
201
211
  return _connected;
202
212
  }
203
213
 
214
+ // Coarse state of the editor's own cable subscription, for the collaboration
215
+ // diagnostics panel.
216
+ function cableStatus() {
217
+ return _status;
218
+ }
219
+
204
220
  // Returns true when ActionCable is loaded and the server advertised cable
205
221
  // support. Collaboration uses this as its up-front "is the feature available?"
206
222
  // gate, independent of whether the EditorChannel handshake has completed yet.
@@ -290,6 +306,7 @@ var WebSocketService = (function () {
290
306
  connect: connect,
291
307
  disconnect: disconnect,
292
308
  isConnected: isConnected,
309
+ cableStatus: cableStatus,
293
310
  isCableAvailable: isCableAvailable,
294
311
  subscribeCollaboration: subscribeCollaboration,
295
312
  perform: perform,
@@ -3395,3 +3395,31 @@ input.ide-model-graph-search[type="search"] {
3395
3395
  font-style: italic;
3396
3396
  opacity: 0.8;
3397
3397
  }
3398
+
3399
+ /* Pair-programming diagnostics. The chip only appears when a condition pairing
3400
+ needs has actually failed — an empty room is silence, not a warning. */
3401
+ .mbeditor-collab-trouble { color: var(--ide-warning, #cca700); }
3402
+ .mbeditor-modal-backdrop {
3403
+ position: fixed; inset: 0; z-index: 5000;
3404
+ background: rgba(0, 0, 0, 0.45);
3405
+ display: flex; align-items: center; justify-content: center;
3406
+ }
3407
+ .mbeditor-collab-diag {
3408
+ width: min(560px, 92vw);
3409
+ max-height: 80vh; overflow-y: auto;
3410
+ padding: 18px 20px;
3411
+ background: var(--ide-panel-bg, #252526);
3412
+ border: 1px solid var(--ide-border, #3c3c3c);
3413
+ border-radius: 8px;
3414
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
3415
+ color: var(--ide-text, #d4d4d4);
3416
+ font-size: 12px;
3417
+ }
3418
+ .mbeditor-collab-diag-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
3419
+ .mbeditor-collab-diag-list { list-style: none; margin: 0 0 12px; padding: 0; }
3420
+ .mbeditor-collab-diag-row { display: flex; gap: 9px; align-items: flex-start; padding: 6px 0; }
3421
+ .mbeditor-collab-diag-row i { margin-top: 2px; color: var(--ide-success, #4ec9b0); }
3422
+ .mbeditor-collab-diag-row.is-bad i { color: var(--ide-danger, #f14c4c); }
3423
+ .mbeditor-collab-diag-label { font-weight: 500; }
3424
+ .mbeditor-collab-diag-detail { color: var(--ide-text-muted, #858585); margin-top: 3px; line-height: 1.5; }
3425
+ .mbeditor-collab-diag-foot { color: var(--ide-text-muted, #858585); margin-bottom: 12px; }
@@ -19,20 +19,49 @@ module Mbeditor
19
19
  # True when the connection is allowed (or no hook is configured); otherwise
20
20
  # rejects the subscription and returns false.
21
21
  def mbeditor_authenticated?
22
- hook = Mbeditor.configuration.authenticate_with
22
+ hook = mbeditor_auth_hook
23
23
  return true unless hook
24
24
 
25
25
  probe = AuthProbe.new(mbeditor_connection_env)
26
26
  probe.instance_exec(&hook)
27
27
  return true unless probe.denied?
28
28
 
29
+ # Both denial paths used to be completely silent, which is what makes this
30
+ # so hard to diagnose: pairing simply never works and nothing anywhere says
31
+ # why. The commonest cause is a hook that reads state a controller filter
32
+ # populates — Current.user, an Authlogic session — because a WebSocket
33
+ # subscribe runs no controller, so that state is nil or raises here while
34
+ # working perfectly over HTTP.
35
+ mbeditor_log_denial("the authenticate_with hook denied the connection")
29
36
  mbeditor_reject_subscription
30
37
  false
31
- rescue StandardError
38
+ rescue StandardError => e
39
+ mbeditor_log_denial("the authenticate_with hook raised #{e.class}: #{e.message}")
32
40
  mbeditor_reject_subscription
33
41
  false
34
42
  end
35
43
 
44
+ # `cable_authenticate_with` when set, otherwise the HTTP hook. A hook that
45
+ # depends on controller filters cannot work here at all, and asking people to
46
+ # write one proc that straddles both contexts is worse than letting them
47
+ # supply the cable one explicitly.
48
+ def mbeditor_auth_hook
49
+ Mbeditor.configuration.cable_authenticate_with ||
50
+ Mbeditor.configuration.authenticate_with
51
+ end
52
+
53
+ def mbeditor_log_denial(reason)
54
+ Rails.logger&.warn(
55
+ "[mbeditor] WebSocket subscription rejected: #{reason}. " \
56
+ "Realtime collaboration will not work. A WebSocket subscribe runs no " \
57
+ "controller, so Current.*, Authlogic sessions and other request-scoped " \
58
+ "state set by before_actions are unavailable here — resolve the user " \
59
+ "from `session` instead, or set config.cable_authenticate_with."
60
+ )
61
+ rescue StandardError
62
+ # Logging must never be the thing that breaks the socket.
63
+ end
64
+
36
65
  private
37
66
 
38
67
  def mbeditor_connection_env
@@ -5,7 +5,7 @@ module Mbeditor
5
5
  attr_accessor :allowed_environments, :workspace_root, :excluded_paths, :rubocop_command, :rubocop_server,
6
6
  :redmine_enabled, :redmine_url, :redmine_api_key, :redmine_ticket_source,
7
7
  :test_framework, :test_command, :test_timeout,
8
- :authenticate_with, :authentication_cache_ttl, :user_name_callback, :user_name_methods,
8
+ :authenticate_with, :cable_authenticate_with, :authentication_cache_ttl, :user_name_callback, :user_name_methods,
9
9
  :lint_timeout, :base_branch_candidates, :git_timeout, :search_timeout,
10
10
  :ruby_def_include_dirs, :related_files_custom_paths,
11
11
  :mount_path, :resilient_routing, :js_global_identifiers,
@@ -58,6 +58,16 @@ module Mbeditor
58
58
  @ruby_def_include_dirs = %w[app/models app/controllers app/helpers app/concerns]
59
59
  @related_files_custom_paths = []
60
60
  @authentication_cache_ttl = 0
61
+ # Authentication for the collaboration WebSocket. nil falls back to
62
+ # authenticate_with.
63
+ #
64
+ # Exists because the two contexts genuinely differ: a WebSocket subscribe
65
+ # runs no controller, so anything a before_action populates —
66
+ # ActiveSupport::CurrentAttributes, an Authlogic session, a memoised
67
+ # current_user — is nil or raises here, and the hook then denies the socket
68
+ # while working perfectly over HTTP. Set this to a proc that resolves the
69
+ # user from `session` directly when the HTTP hook cannot.
70
+ @cable_authenticate_with = nil
61
71
  @user_name_callback = nil # proc resolved in controller context (instance_exec) → collaboration display name; nil falls through to current_user, then to the client-generated name
62
72
  # Attributes tried on current_user, in order, when no user_name_callback
63
73
  # is set. First non-blank one wins. Name your own column here rather than
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.12.2"
4
+ VERSION = "0.12.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mbeditor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.2
4
+ version: 0.12.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan