From ed6fa0686926d1934a277c7e4a89f9ddbe9131ca Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Thu, 14 May 2026 17:04:21 -0400 Subject: [PATCH 1/4] Refresh stale IMS access token on collab WebSocket auth failures getAuthToken now reads window.adobeIMS.getAccessToken() live instead of returning the page-load snapshot that nx's loadIms() captures in onReady. On the collab WebSocket, refresh provider.protocols on connection-close so y-websocket's next reconnect picks up a fresh token without rebuilding the provider; 4403 stops reconnection. Co-Authored-By: Claude Sonnet 4.6 --- blocks/edit/prose/index.js | 12 +++++ blocks/shared/utils.js | 5 +++ test/unit/blocks/edit/prose/index.test.js | 54 +++++++++++++++++++++++ test/unit/blocks/shared/utils.test.js | 52 ++++++++++++++++++++++ 4 files changed, 123 insertions(+) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index ef2b7419..056d4108 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -54,6 +54,18 @@ export async function createConnection(path) { // (exponential backoff starting with 100ms) and then every 30s. provider.maxBackoffTime = 30000; + // y-websocket re-reads provider.protocols on each reconnect, so swapping in a + // fresh IMS token here is enough; no provider rebuild needed. + // Server close codes: 4401 = token expired (refresh + retry), 4403 = forbidden (stop). + provider.on('connection-close', async (event) => { + if (event?.code === 4403) { + provider.shouldConnect = false; + return; + } + const fresh = await getAuthToken(); + provider.protocols = fresh ? ['yjs', fresh] : ['yjs']; + }); + return { wsProvider: provider, ydoc }; } diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index f5b52fee..a8a0ea4a 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -24,6 +24,11 @@ export async function getAuthToken() { if (!localStorage.getItem('nx-ims')) { return null; } + // imslib auto-refreshes its internal token; reading it live avoids returning the + // page-load snapshot that nx's loadIms() captured once in its onReady handler. + if (window.adobeIMS?.getAccessToken) { + return window.adobeIMS.getAccessToken()?.token || null; + } const ims = await initIms(); return ims?.accessToken?.token || null; } diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index abecf61e..f040ced9 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -87,6 +87,60 @@ describe('prose/index createConnection', () => { result.wsProvider.destroy?.(); result.ydoc.destroy(); }); + + it('Refreshes protocols with the live IMS token on connection-close', async () => { + window.localStorage.setItem('nx-ims', 'true'); + const savedIMS = window.adobeIMS; + let tokenIndex = 0; + const tokens = ['T-initial', 'T-rotated']; + window.adobeIMS = { + getAccessToken: () => { + const t = tokens[tokenIndex]; + tokenIndex += 1; + return { token: t }; + }, + }; + + try { + const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); + expect(wsProvider.protocols).to.deep.equal(['yjs', 'T-initial']); + + // Simulate a server-signalled auth failure + wsProvider.emit('connection-close', [{ code: 4401, reason: 'auth' }, wsProvider]); + await new Promise((r) => { setTimeout(r, 0); }); + + expect(wsProvider.protocols).to.deep.equal(['yjs', 'T-rotated']); + expect(wsProvider.shouldConnect).to.equal(true); + + wsProvider.disconnect(); + wsProvider.destroy?.(); + ydoc.destroy(); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + + it('Stops reconnecting on a 4403 forbidden close', async () => { + window.localStorage.setItem('nx-ims', 'true'); + const savedIMS = window.adobeIMS; + window.adobeIMS = { getAccessToken: () => ({ token: 'T-initial' }) }; + + try { + const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); + expect(wsProvider.shouldConnect).to.equal(true); + + wsProvider.emit('connection-close', [{ code: 4403, reason: 'forbidden' }, wsProvider]); + await new Promise((r) => { setTimeout(r, 0); }); + + expect(wsProvider.shouldConnect).to.equal(false); + + wsProvider.disconnect(); + wsProvider.destroy?.(); + ydoc.destroy(); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); }); describe('prose/index createAwarenessStatusWidget', () => { diff --git a/test/unit/blocks/shared/utils.test.js b/test/unit/blocks/shared/utils.test.js index 6263da5f..f14e5115 100644 --- a/test/unit/blocks/shared/utils.test.js +++ b/test/unit/blocks/shared/utils.test.js @@ -11,6 +11,7 @@ import { getSidekickConfig, sanitizeName, fetchDaConfigs, + getAuthToken, } from '../../../../blocks/shared/utils.js'; describe('getSheetByIndex', () => { @@ -178,6 +179,57 @@ describe('sanitizeName', () => { }); }); +describe('getAuthToken', () => { + let savedLocalStorage; + let savedAdobeIMS; + + beforeEach(() => { + savedLocalStorage = window.localStorage.getItem('nx-ims'); + savedAdobeIMS = window.adobeIMS; + }); + + afterEach(() => { + if (savedLocalStorage) { + window.localStorage.setItem('nx-ims', savedLocalStorage); + } else { + window.localStorage.removeItem('nx-ims'); + } + if (savedAdobeIMS === undefined) { + delete window.adobeIMS; + } else { + window.adobeIMS = savedAdobeIMS; + } + }); + + it('Returns null when nx-ims is not set', async () => { + window.localStorage.removeItem('nx-ims'); + window.adobeIMS = { getAccessToken: () => ({ token: 'should-not-see' }) }; + expect(await getAuthToken()).to.be.null; + }); + + it('Reads the live token from window.adobeIMS.getAccessToken when present', async () => { + window.localStorage.setItem('nx-ims', 'true'); + let calls = 0; + const tokens = ['T1', 'T2']; + window.adobeIMS = { + getAccessToken: () => { + const t = tokens[calls]; + calls += 1; + return { token: t }; + }, + }; + + expect(await getAuthToken()).to.equal('T1'); + expect(await getAuthToken()).to.equal('T2'); + }); + + it('Returns null when getAccessToken returns null (signed out)', async () => { + window.localStorage.setItem('nx-ims', 'true'); + window.adobeIMS = { getAccessToken: () => null }; + expect(await getAuthToken()).to.be.null; + }); +}); + describe('daFetch', () => { let savedFetch; let savedLocalStorage; From 7df4652e92570b278767165f035a841afe64cdc6 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Thu, 14 May 2026 17:11:51 -0400 Subject: [PATCH 2/4] Stop collab reconnect loop when 4401 cannot be recovered On a 4401 close, force an imslib refresh and bail (shouldConnect=false) if the resulting token is null or identical to what we just sent. Avoids infinite retries when the SSO session is gone or imslib has not rotated the token, and covers the anonymous-trying-private-doc path. Co-Authored-By: Claude Sonnet 4.6 --- blocks/edit/prose/index.js | 15 +++++ test/unit/blocks/edit/prose/index.test.js | 79 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 056d4108..c91ec11a 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -57,13 +57,28 @@ export async function createConnection(path) { // y-websocket re-reads provider.protocols on each reconnect, so swapping in a // fresh IMS token here is enough; no provider rebuild needed. // Server close codes: 4401 = token expired (refresh + retry), 4403 = forbidden (stop). + let lastSentToken = token || null; provider.on('connection-close', async (event) => { if (event?.code === 4403) { provider.shouldConnect = false; return; } + if (event?.code === 4401) { + // Force imslib to attempt a refresh before deciding to give up. + try { await window.adobeIMS?.refreshToken?.(); } catch { /* ignore */ } + const fresh = await getAuthToken(); + if (!fresh || fresh === lastSentToken) { + // No new token to try — retrying would loop on the same 4401 forever. + provider.shouldConnect = false; + return; + } + provider.protocols = ['yjs', fresh]; + lastSentToken = fresh; + return; + } const fresh = await getAuthToken(); provider.protocols = fresh ? ['yjs', fresh] : ['yjs']; + lastSentToken = fresh; }); return { wsProvider: provider, ydoc }; diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index f040ced9..856543e4 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -141,6 +141,85 @@ describe('prose/index createConnection', () => { if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; } }); + + it('Stops reconnecting on 4401 when imslib cannot produce a token', async () => { + window.localStorage.setItem('nx-ims', 'true'); + const savedIMS = window.adobeIMS; + let refreshCalls = 0; + window.adobeIMS = { + getAccessToken: () => ({ token: 'T-initial' }), + refreshToken: async () => { refreshCalls += 1; }, + }; + + try { + const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); + + // After construction, simulate imslib losing the token (SSO expired) + window.adobeIMS.getAccessToken = () => null; + + wsProvider.emit('connection-close', [{ code: 4401, reason: 'auth' }, wsProvider]); + await new Promise((r) => { setTimeout(r, 0); }); + + expect(refreshCalls).to.equal(1); + expect(wsProvider.shouldConnect).to.equal(false); + + wsProvider.disconnect(); + wsProvider.destroy?.(); + ydoc.destroy(); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + + it('Stops reconnecting on 4401 when imslib hands back the same stale token', async () => { + window.localStorage.setItem('nx-ims', 'true'); + const savedIMS = window.adobeIMS; + window.adobeIMS = { + getAccessToken: () => ({ token: 'T-same' }), + refreshToken: async () => {}, + }; + + try { + const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); + expect(wsProvider.protocols).to.deep.equal(['yjs', 'T-same']); + + wsProvider.emit('connection-close', [{ code: 4401, reason: 'auth' }, wsProvider]); + await new Promise((r) => { setTimeout(r, 0); }); + + // No new token to try — don't loop. + expect(wsProvider.shouldConnect).to.equal(false); + + wsProvider.disconnect(); + wsProvider.destroy?.(); + ydoc.destroy(); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + + it('Non-auth close with no token reconnects as anonymous', async () => { + window.localStorage.removeItem('nx-ims'); + const savedIMS = window.adobeIMS; + delete window.adobeIMS; + + try { + const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); + expect(wsProvider.protocols).to.deep.equal(['yjs']); + + // Simulate a generic network drop — no custom code + wsProvider.emit('connection-close', [{ code: 1006 }, wsProvider]); + await new Promise((r) => { setTimeout(r, 0); }); + + expect(wsProvider.protocols).to.deep.equal(['yjs']); + expect(wsProvider.shouldConnect).to.equal(true); + + wsProvider.disconnect(); + wsProvider.destroy?.(); + ydoc.destroy(); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); }); describe('prose/index createAwarenessStatusWidget', () => { From dd8be667df192e31e4a79e8d4e8d0db151293516 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Thu, 14 May 2026 17:15:17 -0400 Subject: [PATCH 3/4] Route signed-in users through IMS sign-in on unrecoverable collab 4401 When we bail out of the reconnect loop because imslib cannot produce a usable token, call handleSignIn() so the user is sent through IMS for re-auth instead of being silently stuck. Mirrors daFetch's 401 behaviour. Only fires when nx-ims is set, so anonymous users hitting a private doc are not punted into a sign-in flow they did not ask for. Co-Authored-By: Claude Sonnet 4.6 --- blocks/edit/prose/index.js | 10 +++++++ test/fixtures/nx/utils/ims.js | 6 ++-- test/unit/blocks/edit/prose/index.test.js | 36 +++++++++++++++++++++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index c91ec11a..a2459a60 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -23,6 +23,7 @@ import { } from 'da-y-wrapper'; import { getSchema } from 'da-parser'; +import { getNx } from '../../../scripts/utils.js'; import { COLLAB_ORIGIN, DA_ORIGIN } from '../../shared/constants.js'; import { daFetch, getAuthToken } from '../../shared/utils.js'; import { getDiffClass, checkForLocNodes, addActiveView } from './diff/diff-utils.js'; @@ -70,6 +71,15 @@ export async function createConnection(path) { if (!fresh || fresh === lastSentToken) { // No new token to try — retrying would loop on the same 4401 forever. provider.shouldConnect = false; + // If the user expected to be signed in, route them through IMS sign-in + // so collab can recover. Matches daFetch's 401 handling. Anonymous users + // (no nx-ims flag) hitting a private doc are left disconnected. + if (localStorage.getItem('nx-ims')) { + try { + const { handleSignIn } = await import(`${getNx()}/utils/ims.js`); + handleSignIn(); + } catch { /* nothing to do */ } + } return; } provider.protocols = ['yjs', fresh]; diff --git a/test/fixtures/nx/utils/ims.js b/test/fixtures/nx/utils/ims.js index 9ddbe290..efc67d3d 100644 --- a/test/fixtures/nx/utils/ims.js +++ b/test/fixtures/nx/utils/ims.js @@ -5,6 +5,8 @@ export async function loadIms() { } export function handleSignIn() { - // Mock implementation - return Promise.resolve(); + // Mirrors the real nx handleSignIn so tests can observe the effect via + // window.adobeIMS.signIn(). + localStorage.setItem('nx-ims', true); + if (window.adobeIMS?.signIn) window.adobeIMS.signIn(); } diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index 856543e4..4129fe80 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -142,13 +142,15 @@ describe('prose/index createConnection', () => { } }); - it('Stops reconnecting on 4401 when imslib cannot produce a token', async () => { + it('Stops reconnecting on 4401 when imslib cannot produce a token, triggers sign-in', async () => { window.localStorage.setItem('nx-ims', 'true'); const savedIMS = window.adobeIMS; let refreshCalls = 0; + let signInCalls = 0; window.adobeIMS = { getAccessToken: () => ({ token: 'T-initial' }), refreshToken: async () => { refreshCalls += 1; }, + signIn: () => { signInCalls += 1; }, }; try { @@ -158,10 +160,40 @@ describe('prose/index createConnection', () => { window.adobeIMS.getAccessToken = () => null; wsProvider.emit('connection-close', [{ code: 4401, reason: 'auth' }, wsProvider]); - await new Promise((r) => { setTimeout(r, 0); }); + // Allow the dynamic import + signIn call to settle + await new Promise((r) => { setTimeout(r, 50); }); expect(refreshCalls).to.equal(1); expect(wsProvider.shouldConnect).to.equal(false); + expect(signInCalls).to.equal(1); + + wsProvider.disconnect(); + wsProvider.destroy?.(); + ydoc.destroy(); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + + it('Anonymous user hitting a private doc bails on 4401 without sign-in redirect', async () => { + window.localStorage.removeItem('nx-ims'); + const savedIMS = window.adobeIMS; + let signInCalls = 0; + window.adobeIMS = { + getAccessToken: () => null, + refreshToken: async () => {}, + signIn: () => { signInCalls += 1; }, + }; + + try { + const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); + expect(wsProvider.protocols).to.deep.equal(['yjs']); + + wsProvider.emit('connection-close', [{ code: 4401, reason: 'auth' }, wsProvider]); + await new Promise((r) => { setTimeout(r, 50); }); + + expect(wsProvider.shouldConnect).to.equal(false); + expect(signInCalls).to.equal(0); wsProvider.disconnect(); wsProvider.destroy?.(); From 8e374437fe33aaf171a580f6c419f218f183697f Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Thu, 14 May 2026 17:29:28 -0400 Subject: [PATCH 4/4] Restore afterEach cleanup of nx-ims localStorage key The createConnection describe block's afterEach only restored a prior truthy value, leaving the key in place when a test had populated an originally-empty slot. handleSignIn writes nx-ims back, so the 4401 bail tests leaked the key into subsequent test files whose daFetch calls then crashed in initIms because getNx was unset there. Co-Authored-By: Claude Sonnet 4.6 --- test/unit/blocks/edit/prose/index.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index 4129fe80..60bd0329 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -74,7 +74,11 @@ describe('prose/index createConnection', () => { window.localStorage.removeItem('nx-ims'); }); afterEach(() => { - if (savedNxIms) window.localStorage.setItem('nx-ims', savedNxIms); + if (savedNxIms) { + window.localStorage.setItem('nx-ims', savedNxIms); + } else { + window.localStorage.removeItem('nx-ims'); + } }); it('Returns a wsProvider and a Y.Doc with maxBackoffTime configured', async () => {