Feat/app env vars (#430)

* initial changes for env var support

* WIP

* Update applications.js

* JSON stringify before encrypting

* use call_hook.url

* env vars working

GET /v1/AppEnv?url=[URL] to trigger options request to URL and return app-schema
POST /v1/Applications with {env_vars: [OBJECT} to create app with env vars
PUT /v1/Applications/[SID] with {env_vars: [OBJECT} to change env vars
GET returns env vars

POST and PUT will also trigger an OPTIONS request to the call_hook url to get schema and then validate the env_vars against it

* update appenv cannot finish request.

* wip

* wip

* wip

* wip

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
Co-authored-by: Quan HL <quan.luuhoang8@gmail.com>
Co-authored-by: Hoan Luu Huu <110280845+xquanluu@users.noreply.github.com>
This commit is contained in:
Sam Machin
2025-05-08 13:41:50 +01:00
committed by GitHub
parent e41caf8887
commit 30a799030c
11 changed files with 3752 additions and 4626 deletions

View File

@@ -516,6 +516,7 @@ fallback_speech_synthesis_label VARCHAR(64),
fallback_speech_recognizer_vendor VARCHAR(64),
fallback_speech_recognizer_language VARCHAR(64),
fallback_speech_recognizer_label VARCHAR(64),
env_vars TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
record_all_calls BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (application_sid)
@@ -743,4 +744,4 @@ ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (devic
ALTER TABLE accounts ADD FOREIGN KEY siprec_hook_sid_idxfk (siprec_hook_sid) REFERENCES applications (application_sid);
SET FOREIGN_KEY_CHECKS=1;
SET FOREIGN_KEY_CHECKS=1;

View File

@@ -2492,7 +2492,7 @@
</location>
<size>
<width>345.00</width>
<height>540.00</height>
<height>560.00</height>
</size>
<zorder>0</zorder>
<SQLField>
@@ -2685,6 +2685,11 @@
<notNull><![CDATA[0]]></notNull>
<uid><![CDATA[65AA5173-6523-49F7-9D95-78C4B3A7C7E6]]></uid>
</SQLField>
<SQLField>
<name><![CDATA[env_vars]]></name>
<type><![CDATA[TEXT]]></type>
<uid><![CDATA[C22DCA56-385D-45EE-A36F-2B9C6167AFAA]]></uid>
</SQLField>
<SQLField>
<name><![CDATA[created_at]]></name>
<type><![CDATA[DATETIME]]></type>
@@ -3163,9 +3168,9 @@
<SQLEditorFileFormatVersion><![CDATA[4]]></SQLEditorFileFormatVersion>
<uid><![CDATA[58C99A00-06C9-478C-A667-C63842E088F3]]></uid>
<windowHeight><![CDATA[1055.000000]]></windowHeight>
<windowLocationX><![CDATA[58.000000]]></windowLocationX>
<windowLocationY><![CDATA[24.000000]]></windowLocationY>
<windowScrollOrigin><![CDATA[{0, 278}]]></windowScrollOrigin>
<windowLocationX><![CDATA[1845.000000]]></windowLocationX>
<windowLocationY><![CDATA[37.000000]]></windowLocationY>
<windowScrollOrigin><![CDATA[{0, 544}]]></windowScrollOrigin>
<windowWidth><![CDATA[1670.000000]]></windowWidth>
</SQLDocumentInfo>
<AllowsIndexRenamingOnInsert><![CDATA[1]]></AllowsIndexRenamingOnInsert>

View File

@@ -225,6 +225,9 @@ const sql = {
'ALTER TABLE google_custom_voices ADD COLUMN use_voice_cloning_key BOOLEAN DEFAULT false',
'ALTER TABLE voip_carriers ADD COLUMN dtmf_type ENUM(\'rfc2833\',\'tones\',\'info\') NOT NULL DEFAULT \'rfc2833\'',
'ALTER TABLE voip_carriers ADD COLUMN outbound_sip_proxy VARCHAR(255)',
],
9004: [
'ALTER TABLE applications ADD COLUMN env_vars TEXT',
]
};

47
lib/routes/api/appenv.js Normal file
View File

@@ -0,0 +1,47 @@
const router = require('express').Router();
const sysError = require('../error');
const { fetchAppEnvSchema, validateAppEnvSchema } = require('../../utils/appenv_utils');
const URL = require('url').URL;
const isValidUrl = (s) => {
const protocols = ['https:', 'http:', 'ws:', 'wss:'];
try {
const url = new URL(s);
if (protocols.includes(url.protocol)) {
return true;
}
else {
return false;
}
} catch (err) {
return false;
}
};
/* get appenv schema for endpoint */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const url = req.query.url;
if (!isValidUrl(url)) {
sysError(logger, res, 'Invalid URL');
} else {
try {
const appenv = await fetchAppEnvSchema(logger, url);
if (appenv && validateAppEnvSchema(appenv)) {
return res.status(200).json(appenv);
} else if (appenv) {
return res.status(400).json({
msg: 'Invalid appenv schema',
});
} else {
return res.status(204).end(); //No appenv returned from url, normal scenario
}
}
catch (err) {
sysError(logger, res, err);
}
}
});
module.exports = router;

View File

@@ -12,7 +12,8 @@ const preconditions = {
'add': validateAdd,
'update': validateUpdate
};
const { fetchAppEnvSchema, validateAppEnvData } = require('../../utils/appenv_utils');
const {decrypt, encrypt} = require('../../utils/encrypt-decrypt');
const validateRequest = async(req, account_sid) => {
try {
@@ -151,6 +152,16 @@ router.post('/', async(req, res) => {
throw new DbErrorBadRequest(err);
}
}
// validate env_vars data if required
if (obj['env_vars']) {
const appenvschema = await fetchAppEnvSchema(logger, req.body.call_hook.url);
const errors = await validateAppEnvData(appenvschema, obj['env_vars']);
if (errors) {
throw new DbErrorBadRequest(errors);
} else {
obj['env_vars'] = encrypt(JSON.stringify(obj['env_vars']));
}
}
const uuid = await Application.make(obj);
res.status(201).json({sid: uuid});
@@ -167,7 +178,15 @@ router.get('/', async(req, res) => {
const account_sid = req.user.hasAccountAuth ? req.user.account_sid : null;
const name = req.query.name;
const results = await Application.retrieveAll(service_provider_sid, account_sid, name);
res.status(200).json(results);
const ret = results.map((a) => {
if (a.env_vars) {
a.env_vars = JSON.parse(decrypt(a.env_vars));
return a;
} else {
return a;
}
});
res.status(200).json(ret);
} catch (err) {
sysError(logger, res, err);
}
@@ -183,6 +202,9 @@ router.get('/:sid', async(req, res) => {
const results = await Application.retrieve(application_sid, service_provider_sid, account_sid);
if (results.length === 0) return res.status(404).end();
await validateRequest(req, results[0].account_sid);
if (results[0].env_vars) {
results[0].env_vars = JSON.parse(decrypt(results[0].env_vars));
}
return res.status(200).json(results[0]);
}
catch (err) {
@@ -237,6 +259,8 @@ router.put('/:sid', async(req, res) => {
try {
const sid = parseApplicationSid(req);
await validateUpdate(req, sid);
const service_provider_sid = req.user.hasServiceProviderAuth ? req.user.service_provider_sid : null;
const account_sid = req.user.hasAccountAuth ? req.user.account_sid : null;
// create webhooks if provided
const obj = Object.assign({}, req.body);
@@ -268,6 +292,19 @@ router.put('/:sid', async(req, res) => {
}
}
// validate env_vars data if required
if (obj['env_vars']) {
const applications = await Application.retrieve(sid, service_provider_sid, account_sid);
const call_hook_url = req.body.call_hook ? req.body.call_hook : applications[0].call_hook.url;
const appenvschema = await fetchAppEnvSchema(logger, call_hook_url);
const errors = await validateAppEnvData(appenvschema, obj['env_vars']);
if (errors) {
throw new DbErrorBadRequest(errors);
} else {
obj['env_vars'] = encrypt(JSON.stringify(obj['env_vars']));
}
}
const rowsAffected = await Application.update(sid, obj);
if (rowsAffected === 0) {
return res.status(404).end();

View File

@@ -39,6 +39,7 @@ api.use('/change-password', require('./change-password'));
api.use('/ActivationCode', require('./activation-code'));
api.use('/Availability', require('./availability'));
api.use('/AccountTest', require('./account-test'));
api.use('/AppEnv', require('./appenv'));
//api.use('/Products', require('./products'));
api.use('/Prices', require('./prices'));
api.use('/StripeCustomerId', require('./stripe-customer-id'));

View File

@@ -821,7 +821,6 @@ router.get('/:sid/test', async(req, res) => {
SpeechCredential.ttsTestResult(sid, true);
} catch (err) {
let reason = err.message;
// if error is from bent, let get the body
try {
reason = await err.text();
} catch {}
@@ -837,7 +836,6 @@ router.get('/:sid/test', async(req, res) => {
SpeechCredential.ttsTestResult(sid, true);
} catch (err) {
let reason = err.message;
// if error is from bent, let get the body
try {
reason = await err.text();
} catch {}

View File

@@ -0,0 +1,50 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"patternProperties": {
"^[a-zA-Z0-9_]+$": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"string",
"number",
"boolean"
]
},
"required": {
"type": "boolean"
},
"default": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
}
]
},
"enum": {
"type": "array"
},
"obscure": {
"type": "boolean"
}
},
"required": [
"type",
"description"
]
}
}
}

70
lib/utils/appenv_utils.js Normal file
View File

@@ -0,0 +1,70 @@
const Ajv = require('ajv');
const assert = require('assert');
const ajv = new Ajv();
const schemaSchema = require('./appenv_schemaSchema.json');
const validateAppEnvSchema = (schema) => {
const validate = ajv.compile(schemaSchema);
return validate(schema);
};
//Currently this request is not signed with the webhook secret as it is outside an account
const fetchAppEnvSchema = async(logger, url) => {
try {
const response = await fetch(url, {
method: 'OPTIONS',
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
logger.info(`Failure to fetch app env schema ${response.status} ${response.statusText}`);
return false;
}
const schema = await response.json();
return schema;
}
catch (e) {
logger.info(`Failure to fetch app env schema ${e}`);
return false;
}
};
const validateAppEnvData = async(schema, data) => {
const schemaKeys = Object.keys(schema);
const dataKeys = Object.keys(data);
let errorMsg = false;
// Check for required keys
schemaKeys.forEach((k) => {
if (schema[k].required) {
if (!dataKeys.includes(k)) {
errorMsg = `Missing required value env_vars.${k}`;
console.log(errorMsg);
}
}
});
//Validate the values
dataKeys.forEach((k) => {
if (schemaKeys.includes(k)) {
try {
// Check value is correct type
assert(typeof data[k] == schema[k].type);
// if enum check value is valid
if (schema[k].enum) {
assert(schema[k].enum.includes(data[k]));
}
} catch (error) {
errorMsg = `Invalid value/type for env_vars.${k}`;
}
}
});
return errorMsg;
};
module.exports = {
validateAppEnvSchema,
fetchAppEnvSchema,
validateAppEnvData
};

8147
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@
"@jambonz/time-series": "^0.2.8",
"@jambonz/verb-specifications": "^0.0.72",
"@soniox/soniox-node": "^1.2.2",
"ajv": "^8.17.1",
"argon2": "^0.40.1",
"assemblyai": "^4.3.4",
"cors": "^2.8.5",