Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/samples/meetings.min.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/@webex/plugin-meetings/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const LOCI = 'loci';
export const LOCUS_URL = 'locusUrl';
export const END = 'end';
export const LLM_PRACTICE_SESSION = 'llm-practice-session';
export const LLM_DEFAULT_SESSION = 'llm-default-session';

export const MAX_RANDOM_DELAY_FOR_MEETING_INFO = 3 * 60 * 1000;
export const MEETINGINFO = 'meetingInfo';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@ import {Interceptor} from '@webex/http-core';
import LoggerProxy from '../common/logs/logger-proxy';
import {DATA_CHANNEL_AUTH_HEADER, MAX_RETRY, RETRY_INTERVAL, RETRY_KEY} from './constant';
import {isJwtTokenExpired} from './utils';
import {LLM_DEFAULT_SESSION, LLM_PRACTICE_SESSION} from '../constants';
import {MEETING_KEY} from '../meetings/meetings.types';

/*!
* Copyright (c) 2015-2026 Cisco Systems, Inc. See LICENSE file.
*/

const retryCountMap = new Map();
// Marker substring on practice-session datachannel URLs (the base64-encoded
// locus path embeds `practiceSession`). Used to pick the right LLM session id
// when resolving the owning Meeting at refresh time.
const PRACTICE_SESSION_URL_MARKER = 'practiceSession';
interface HttpLikeError extends Error {
statusCode?: number;
original?: any;
Expand All @@ -20,7 +26,7 @@ interface HttpLikeError extends Error {
* @class
*/
export default class DataChannelAuthTokenInterceptor extends Interceptor {
private _refreshDataChannelToken: () => Promise<string>;
private _refreshDataChannelToken: (requestUrl?: string) => Promise<string>;
private _isDataChannelTokenEnabled: () => Promise<boolean>;
constructor(options) {
super(options);
Expand All @@ -42,10 +48,52 @@ export default class DataChannelAuthTokenInterceptor extends Interceptor {
return this.internal.llm.isDataChannelTokenEnabled();
},

refreshDataChannelToken: async () => {
// Resolves the *owning* Meeting at refresh time instead of relying on
// whichever Meeting most recently overwrote the singleton refresh handler
// in `internal-plugin-llm`. Uses the in-flight request URL to pick the
// correct LLM session id (default vs practice-session), looks up the
// session's tracked locusUrl, and finds the matching Meeting in the
// meetings collection. Falls back to the LLM plugin's singleton handler
// when the lookup cannot resolve a Meeting (preserves prior behavior).
refreshDataChannelToken: async (requestUrl?: string) => {
const sessionId =
typeof requestUrl === 'string' && requestUrl.includes(PRACTICE_SESSION_URL_MARKER)
? LLM_PRACTICE_SESSION
: LLM_DEFAULT_SESSION;

// @ts-ignore
const {body} = await this.internal.llm.refreshDataChannelToken();
const {datachannelToken, dataChannelTokenType} = body ?? {};
const sessionLocusUrl = this.internal.llm.getLocusUrl?.(sessionId);
const meeting =
(sessionLocusUrl &&
// @ts-ignore
this.meetings?.getMeetingByType?.(MEETING_KEY.LOCUS_URL, sessionLocusUrl)) ||
undefined;

let result;
try {
if (meeting) {
result = await meeting.refreshDataChannelToken();
} else {
LoggerProxy.logger.warn(
`DataChannelAuthTokenInterceptor: no owning meeting resolved for sessionId=${sessionId} (locusUrl=${sessionLocusUrl}); falling back to LLM singleton refresh handler`
);
// @ts-ignore
result = await this.internal.llm.refreshDataChannelToken();
}
} catch (err) {
LoggerProxy.logger.warn(
`DataChannelAuthTokenInterceptor: refresh threw for sessionId=${sessionId}: ${
err?.message || err
}`
);
throw err;
}

if (!result?.body) {
return undefined;
}

const {datachannelToken, dataChannelTokenType} = result.body;

// @ts-ignore
this.internal.llm.setDatachannelToken(datachannelToken, dataChannelTokenType);
Expand Down Expand Up @@ -87,7 +135,7 @@ export default class DataChannelAuthTokenInterceptor extends Interceptor {

if (isJwtTokenExpired(token)) {
try {
const newToken = await this._refreshDataChannelToken();
const newToken = await this._refreshDataChannelToken(options.uri || options.url);
options.headers[DATA_CHANNEL_AUTH_HEADER] = newToken;
} catch (e) {
LoggerProxy.logger.warn(`DataChannelAuthTokenInterceptor: refresh failed: ${e.message}`);
Expand Down Expand Up @@ -144,7 +192,7 @@ export default class DataChannelAuthTokenInterceptor extends Interceptor {
setTimeout(async () => {
const key = this.getRetryKey(options);
try {
const newToken = await this._refreshDataChannelToken();
const newToken = await this._refreshDataChannelToken(options.uri || options.url);

options.headers[DATA_CHANNEL_AUTH_HEADER] = newToken;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,130 @@ describe('plugin-meetings', () => {
/DataChannel token refresh failed: request failed/
);
});

it('passes the in-flight request URL through to the refresh handler', async () => {
interceptor._refreshDataChannelToken.resolves('new-token');
webex.request.resolves('mock-response');

const psOptions = {
...options,
uri: 'https://aibridge/practiceSession/datachannel',
};

const promise = interceptor.refreshTokenAndRetryWithDelay(psOptions);
clock.tick(2000);
await promise;

sinon.assert.calledOnceWithExactly(
interceptor._refreshDataChannelToken,
psOptions.uri
);
});
});

describe('refreshDataChannelToken routing (factory dispatcher)', () => {
let factoryWebex;
let factoryInterceptor;
let psMeeting;
let defaultMeeting;

beforeEach(() => {
factoryWebex = new MockWebex({children: {}});
factoryWebex.internal.llm = {
isDataChannelTokenEnabled: sinon.stub().resolves(true),
getLocusUrl: sinon.stub(),
refreshDataChannelToken: sinon.stub().resolves({
body: {datachannelToken: 'fallback-token', dataChannelTokenType: 'llm-default-session'},
}),
setDatachannelToken: sinon.stub(),
};

psMeeting = {
refreshDataChannelToken: sinon.stub().resolves({
body: {datachannelToken: 'ps-meeting-token', dataChannelTokenType: 'llm-practice-session'},
}),
};
defaultMeeting = {
refreshDataChannelToken: sinon.stub().resolves({
body: {datachannelToken: 'default-meeting-token', dataChannelTokenType: 'llm-default-session'},
}),
};

factoryWebex.meetings = {
getMeetingByType: sinon.stub().callsFake((key, value) => {
if (key !== 'locusUrl') return undefined;
if (value === 'locus://A') return psMeeting;
if (value === 'locus://B') return defaultMeeting;

return undefined;
}),
};

factoryInterceptor = Reflect.apply(
DataChannelAuthTokenInterceptor.create,
factoryWebex,
[]
);
});

it('routes a practice-session request URL to the PS-owning meeting', async () => {
factoryWebex.internal.llm.getLocusUrl.callsFake((sessionId) =>
sessionId === 'llm-practice-session' ? 'locus://A' : 'locus://B'
);

const token = await factoryInterceptor._refreshDataChannelToken(
'https://aibridge/practiceSession/datachannel'
);

sinon.assert.calledOnce(psMeeting.refreshDataChannelToken);
sinon.assert.notCalled(defaultMeeting.refreshDataChannelToken);
sinon.assert.notCalled(factoryWebex.internal.llm.refreshDataChannelToken);
sinon.assert.calledOnceWithExactly(
factoryWebex.internal.llm.setDatachannelToken,
'ps-meeting-token',
'llm-practice-session'
);
expect(token).to.equal('ps-meeting-token');
});

it('routes a non-PS request URL to the default-session-owning meeting', async () => {
factoryWebex.internal.llm.getLocusUrl.callsFake((sessionId) =>
sessionId === 'llm-default-session' ? 'locus://B' : 'locus://A'
);

const token = await factoryInterceptor._refreshDataChannelToken(
'https://example.com/datachannel'
);

sinon.assert.calledOnce(defaultMeeting.refreshDataChannelToken);
sinon.assert.notCalled(psMeeting.refreshDataChannelToken);
sinon.assert.notCalled(factoryWebex.internal.llm.refreshDataChannelToken);
expect(token).to.equal('default-meeting-token');
});

it('falls back to the LLM singleton handler when no Meeting matches', async () => {
factoryWebex.internal.llm.getLocusUrl.returns('locus://unknown');

const token = await factoryInterceptor._refreshDataChannelToken(
'https://example.com/datachannel'
);

sinon.assert.calledOnce(factoryWebex.internal.llm.refreshDataChannelToken);
sinon.assert.notCalled(psMeeting.refreshDataChannelToken);
sinon.assert.notCalled(defaultMeeting.refreshDataChannelToken);
expect(token).to.equal('fallback-token');
});

it('falls back to the LLM singleton handler when LLM has no locusUrl for the session', async () => {
factoryWebex.internal.llm.getLocusUrl.returns(undefined);

const token = await factoryInterceptor._refreshDataChannelToken(
'https://aibridge/practiceSession/datachannel'
);

sinon.assert.calledOnce(factoryWebex.internal.llm.refreshDataChannelToken);
expect(token).to.equal('fallback-token');
});
});
});
});
Expand Down
Loading