Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1f7c36e
Initial plan
Copilot Aug 6, 2026
34834ef
Add private Twilio configuration endpoint to account-scoped lambda
Copilot Aug 7, 2026
ceb9315
Merge remote-tracking branch 'origin/copilot/add-private-configuratio…
stephenhand Aug 17, 2026
ff334e5
Redux & client support in plugin for 'private' configuration
stephenhand Aug 17, 2026
de981be
Fix lambda container build
stephenhand Aug 17, 2026
70172cd
Add support for flex token in auth header for account-scoped lambda
stephenhand Aug 18, 2026
e2ccc89
Comment about deprecation
stephenhand Aug 18, 2026
919562d
Move account scoped lambda service methods to separate file
stephenhand Aug 18, 2026
522f248
Rename 'private twilio configuration' -> 'aselo twilio configuration'
stephenhand Aug 18, 2026
5c89b88
Fix tests
stephenhand Aug 18, 2026
9f204e0
Fix lint
stephenhand Aug 18, 2026
ff9c892
Finish wiring up configuration to quickdial dialog
stephenhand Aug 18, 2026
95dc990
Merge branch 'master' into CHI-3946-quick_dial_with_config
stephenhand Aug 18, 2026
280419c
test: fix config reducer expectations and add quick-dial unit coverage
Copilot Aug 18, 2026
61eed07
Fix remaining fetchProtectedApi unit test expectation
Copilot Aug 18, 2026
319aa1b
Merge branch 'CHI-3946-quick_dial' into CHI-3946-quick_dial_with_config
stephenhand Aug 21, 2026
c94c999
Merge branch 'master' into CHI-3946-quick_dial_with_config
stephenhand Aug 21, 2026
9fe2cd0
Merge branch 'CHI-3946-quick_dial' into CHI-3946-quick_dial_with_config
stephenhand Aug 24, 2026
fb236e1
Fix merge issue
stephenhand Aug 24, 2026
ee5df12
Fix merge issue in tests
stephenhand Aug 24, 2026
3788bd2
Merge branch 'CHI-3946-quick_dial' into CHI-3946-quick_dial_with_config
stephenhand Aug 25, 2026
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
1 change: 1 addition & 0 deletions lambdas/account-scoped/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@aws-sdk/client-lex-runtime-v2": "^3.1045.0",
"@aws-sdk/client-ssm": "^3.1045.0",
"@tech-matters/configuration": "^1.0.0",
"@tech-matters/s3": "^1.0.0",
"@tech-matters/hrm-form-definitions": "^1.0.0",
"@tech-matters/hrm-types": "^1.0.0",
"@tech-matters/result-type": "^1.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Copyright (C) 2021-2023 Technology Matters
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import type { AccountSID } from '@tech-matters/twilio-types';
import { getDocsBucketName } from '@tech-matters/twilio-configuration';
import { getS3Object } from '@tech-matters/s3';
import { newErr, newOk } from '@tech-matters/result-type';
import { AccountScopedHandler } from '../httpTypes';

const ASELO_TWILIO_CONFIGURATION_KEY = 'configuration/twilio.json';

export const getAseloTwilioConfigurationHandler: AccountScopedHandler = async (
_event,
accountSid: AccountSID,
) => {
try {
const bucket = await getDocsBucketName(accountSid);
const content = await getS3Object(bucket, ASELO_TWILIO_CONFIGURATION_KEY);
return newOk(JSON.parse(content));
Comment thread
stephenhand marked this conversation as resolved.
} catch (err: any) {
if (err?.name === 'NoSuchKey') {
return newOk({});
}
return newErr({ message: err.message, error: { statusCode: 500, cause: err } });
}
};
8 changes: 8 additions & 0 deletions lambdas/account-scoped/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { isSkilledWorkerAvailableHandler } from './worker/isSkilledWorkerAvailab
import { filterCountryOrVoIPHandler } from './voice/filterCountryOrVoIP';
import { handleCreateScheduleJob } from './scheduled-jobs/create-schedule';
import { recordingCompleteCallback } from './voicemail/recordingCompleteCallback';
import { getAseloTwilioConfigurationHandler } from './configuration/getAseloTwilioConfiguration';

/**
* Super simple router sufficient for directly ported Twilio Serverless functions
Expand Down Expand Up @@ -422,6 +423,13 @@ const ACCOUNTSID_ROUTES: Record<
],
handler: handleCreateScheduleJob,
}),
'configuration/twilio': newRoute({
requestPipeline: [
validateRequestMethod('GET'),
validateFlexTokenRequest({ tokenMode: 'agent' }),
],
handler: getAseloTwilioConfigurationHandler,
}),
};

const ENV_SHORTCODE_ROUTES: Record<string, FunctionRoute> = {
Expand Down
11 changes: 9 additions & 2 deletions lambdas/account-scoped/src/validation/flexToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,16 @@ export const validateFlexTokenRequest: ({
}) => HttpRequestPipelineStep =
({ tokenMode }: { tokenMode: 'supervisor' | 'agent' | 'guest' }) =>
async (request, { accountSid }) => {
const { Token: token } = request.body;
let token: string;
if (request.headers?.authorization?.startsWith('Bearer ')) {
token = request.headers?.authorization.slice('Bearer '.length);
} else {
token = request.body?.Token;
}
if (!token) {
return newMissingParameterResult('Token');
return newMissingParameterResult(
'Bearer authorization header or Token body parameter',
);
}
try {
const tokenResult: TokenValidatorResponse = (await validator(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Copyright (C) 2021-2023 Technology Matters
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import { getAseloTwilioConfigurationHandler } from '../../../src/configuration/getAseloTwilioConfiguration';
import { getDocsBucketName } from '@tech-matters/twilio-configuration';
import { getS3Object } from '@tech-matters/s3';
import { isErr, isOk } from '@tech-matters/result-type';
import { HttpRequest } from '../../../src/httpTypes';
import { TEST_ACCOUNT_SID } from '../../testTwilioValues';

jest.mock('@tech-matters/twilio-configuration', () => ({
getDocsBucketName: jest.fn(),
}));

jest.mock('@tech-matters/s3', () => ({
getS3Object: jest.fn(),
}));

const mockGetDocsBucketName = getDocsBucketName as jest.MockedFunction<
typeof getDocsBucketName
>;
const mockGetS3Object = getS3Object as jest.MockedFunction<typeof getS3Object>;

const TEST_BUCKET = 'test-docs-bucket';

const createMockRequest = (): HttpRequest => ({
method: 'GET',
headers: {},
path: '/test',
query: {},
body: {},
});

describe('getAseloTwilioConfigurationHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetDocsBucketName.mockResolvedValue(TEST_BUCKET);
});

it('should return parsed JSON content when configuration file exists', async () => {
const config = { someKey: 'someValue', nested: { flag: true } };
mockGetS3Object.mockResolvedValue(JSON.stringify(config));

const result = await getAseloTwilioConfigurationHandler(
createMockRequest(),
TEST_ACCOUNT_SID,
);

expect(isOk(result)).toBe(true);
if (isOk(result)) {
expect(result.data).toEqual(config);
}
expect(mockGetDocsBucketName).toHaveBeenCalledWith(TEST_ACCOUNT_SID);
expect(mockGetS3Object).toHaveBeenCalledWith(
TEST_BUCKET,
'configuration/twilio.json',
);
});

it('should return an empty object when configuration file does not exist (NoSuchKey)', async () => {
const noSuchKeyError = Object.assign(new Error('The specified key does not exist.'), {
name: 'NoSuchKey',
});
mockGetS3Object.mockRejectedValue(noSuchKeyError);

const result = await getAseloTwilioConfigurationHandler(
createMockRequest(),
TEST_ACCOUNT_SID,
);

expect(isOk(result)).toBe(true);
if (isOk(result)) {
expect(result.data).toEqual({});
}
});

it('should return 500 on unexpected S3 error', async () => {
mockGetS3Object.mockRejectedValue(new Error('S3 service unavailable'));

const result = await getAseloTwilioConfigurationHandler(
createMockRequest(),
TEST_ACCOUNT_SID,
);

expect(isErr(result)).toBe(true);
if (isErr(result)) {
expect(result.message).toBe('S3 service unavailable');
expect(result.error.statusCode).toBe(500);
}
});

it('should return 500 when getDocsBucketName fails', async () => {
mockGetDocsBucketName.mockRejectedValue(new Error('SSM parameter not found'));

const result = await getAseloTwilioConfigurationHandler(
createMockRequest(),
TEST_ACCOUNT_SID,
);

expect(isErr(result)).toBe(true);
if (isErr(result)) {
expect(result.message).toBe('SSM parameter not found');
expect(result.error.statusCode).toBe(500);
}
});
});
118 changes: 118 additions & 0 deletions lambdas/account-scoped/tests/unit/validation/flexToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Copyright (C) 2021-2026 Technology Matters
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import { validator } from 'twilio-flex-token-validator';
import { getAccountAuthToken } from '@tech-matters/twilio-configuration';
import { isErr, isOk } from '@tech-matters/result-type';
import { AccountScopedRoute, HttpRequest } from '../../../src/httpTypes';
import { validateFlexTokenRequest } from '../../../src/validation/flexToken';
import { TEST_ACCOUNT_SID } from '../../testTwilioValues';

jest.mock('twilio-flex-token-validator', () => ({
validator: jest.fn(),
}));

jest.mock('@tech-matters/twilio-configuration', () => ({
getAccountAuthToken: jest.fn(),
}));

const mockValidator = validator as jest.MockedFunction<typeof validator>;
const mockGetAccountAuthToken = getAccountAuthToken as jest.MockedFunction<
typeof getAccountAuthToken
>;

const baseRequest: HttpRequest = {
method: 'GET',
headers: {},
path: '/configuration/twilio',
query: {},
body: {},
};

const routeContext = {
accountSid: TEST_ACCOUNT_SID,
} as AccountScopedRoute;

describe('validateFlexTokenRequest', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetAccountAuthToken.mockResolvedValue('account-auth-token');
mockValidator.mockResolvedValue({
worker_sid: 'WK123',
roles: ['agent'],
} as any);
});

test('accepts bearer token from authorization header', async () => {
const request = {
...baseRequest,
headers: {
authorization: ['Bearer', 'from-header-token'].join(' '),
},
body: {},
};

const result = await validateFlexTokenRequest({ tokenMode: 'agent' })(
request,
routeContext,
);

expect(isOk(result)).toBe(true);
if (isOk(result) && 'tokenResult' in result.data) {
expect(result.data.tokenResult.worker_sid).toBe('WK123');
}
expect(mockValidator).toHaveBeenCalledWith(
'from-header-token',
TEST_ACCOUNT_SID,
'account-auth-token',
);
});

test('falls back to Token from body when no authorization header is present', async () => {
const request = {
...baseRequest,
body: {
Token: 'from-body-token',
},
};

const result = await validateFlexTokenRequest({ tokenMode: 'agent' })(
request,
routeContext,
);

expect(isOk(result)).toBe(true);
expect(mockValidator).toHaveBeenCalledWith(
'from-body-token',
TEST_ACCOUNT_SID,
'account-auth-token',
);
});

test('returns missing-parameter error when no token is provided', async () => {
const result = await validateFlexTokenRequest({ tokenMode: 'agent' })(
baseRequest,
routeContext,
);

expect(isErr(result)).toBe(true);
if (isErr(result)) {
expect(result.error.statusCode).toBe(400);
expect(result.message).toContain('Token body parameter missing');
}
expect(mockValidator).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions lambdas/account-scoped/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"files": [],
"references": [
{ "path": "packages/result-type" },
{ "path": "packages/s3" },
{ "path": "packages/ssm-cache" },
{ "path": "packages/configuration" },
{ "path": "packages/hrm-types" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"Switchboard-NoQueuesSwitchboarded": "No queues are currently being switchboarded",
"Admin": "Translated Admin",
"Chat Queue Test": "Quat Teue Chest",
"CustomLink-Label-ResourceMap": "Resource Map"
"CustomLink-Label-ResourceMap": "Resource Map",
"Conference-PhoneInputDialog-QuickDialItem/988-English": "988 (English)",
"Conference-PhoneInputDialog-QuickDialItem/988-Spanish": "988 (Spanish)"
},
"es": {
"HelplineSubstitution": "Substitución de la Línea de Ayuda",
Expand Down
9 changes: 8 additions & 1 deletion plugin-hrm-form/src/HrmFormPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import { setUpFullStory } from './fullStory/setUp';
import { getPathFromUrl } from './states/routing/reducer';
import { setUpCustomSideLinks } from './components/customSideLinks/setUpCustomSideLinks';
import { setUpVoicemailComponents } from './voicemail/setUpVoicemailComponents';
import { newLoadAseloTwilioConfigurationAsyncAction } from './states/configuration/loadAseloTwilioConfiguration';
import asyncDispatch from './states/asyncDispatch';

const PLUGIN_NAME = 'HrmFormPlugin';

Expand Down Expand Up @@ -235,7 +237,12 @@ export default class HrmFormPlugin extends FlexPlugin {
},
};
manager.updateConfig(managerConfiguration);

// The 'private' configuration is ok to store in plain text in memory on the client, it doesn't need to be treated as sensitive for security purposes so can be kept in redux
try {
await asyncDispatch(manager.store.dispatch)(newLoadAseloTwilioConfigurationAsyncAction());
} catch (error) {
console.warn('Failed to load private configuration, using default', error);
}
// TODO(nick): Eventually remove this log line or set to debug. Should we fail hard here?
const { hrmBaseUrl } = config;
console.info(`HRM URL: ${hrmBaseUrl}`);
Expand Down
Loading
Loading