Published on January 3, 2022.
Many of the NetSuite projects that I've worked on over the past few years have involved developing external Web applications. They were usually PHP-based, and integrated with NetSuite using SuiteTalk or RESTlets.
However, over the past several months, I've been using a more "NetSuite native" approach, where I'm providing external access to SuiteScript-based applications. In some cases, I'm exposing external-facing Suitelets directly. But in many cases - especially when the solutions are being made available to customers, vendors, and partners - I've been serving up the applications behind NGINX, which I'm using as a reverse proxy server.
In this post, I'll explain the technique, share an example NGINX configuration file, as well as a sample Suitelet that demonstrates the technique.
Before I discuss the details of the technique, here's an animation that shows it being used to serve up an Employee Directory, where the employee data is being sourced from NetSuite. It's a a very simple example of what you can do with the technique.
A demo version of the app is available here: https://nsrelay.timdietrich.me
The demo applicaton is hosted on a low cost Amazon Lightsail server running Debian GNU/Linux 10.
Essentially the technique works like this:
• NGINX listens for requests coming in for a specific domain / subdomain.
• It packages up the details of the request and forwards them to a NetSuite Suitelet.
• The Suitelet receives the request, processes it, and returns a response to NGINX.
• NGINX then returns that response to the client.
So NGINX is acting as a proxy, relaying requests and responses to and from the client and NetSuite.
With this approach, I can leverage some of NGINX's features and capabilities, such as caching, rate limiting, and so on. And to some extent, I can hide the fact that the data is being served up from NetSuite, because the apps are being accessed using non-NetSuite domain names and subdomains. It's an interesting technique, and I've already used it successfully on several client projects.
To use this technique, you'll need to have some familiarity with NGINX. Of course, you can use another server if you'd like - perhaps HAProxy.
Here's the configuration file.
server { server_name nsrelay.timdietrich.me; listen 80; return 301 https://nsrelay.timdietrich.me$request_uri; } server { server_name nsrelay.timdietrich.me; listen 443 ssl; # SSL certificate location. ssl_certificate /etc/letsencrypt/live/timdietrich.me-1092/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/timdietrich.me-1092/privkey.pem; # Log file names / locations. access_log /opt/bitnami/nginx/logs/nsrelay-timdietrich-me-access.log; error_log /opt/bitnami/nginx/logs/nsrelay-timdietrich-me-error.log; # Remove the nginx version from the server response header. server_tokens off; # Allow maximum client body size of 10M. client_max_body_size 10M; location / { proxy_set_header User-Agent Mozilla/5.0; proxy_set_header X-Original-Remote-Addr $remote_addr; proxy_set_header X-Original-Path $uri; proxy_set_header X-Original-URL-Params $args; proxy_set_header X-Server-Name $server_name; set $args $args&script=123&deploy=3&compid=TSTDRV999999&h=6940ec3656b1c08ca3f7; # Putting a "?" here allows paths to work on POSTs. # However, it breaks URL params on GETs. # Therefore, I'm passing the URL params as a header as well. proxy_pass https://tstdrv999999.extforms.netsuite.com/app/site/hosting/scriptlet.nl?; proxy_ssl_server_name on; proxy_pass_request_headers on; } location /404/ { return 404; } }
If you're going to use the configuration file, be sure to adjust the domain / subdomains, the location of your SSL certificate, and your Suitelet's deployment URL.
The NGINX configuration is designed to forward as much information as possible to the Suitelet, as if the request had been made directly to the Suitelet. Some of the information that is forwarded includes:
• URL params on GET requests.
• Request bodies on POST requests.
• The path that was originally requested.
• The original ("real") remote address of the client that made the request.
The code for the Suitelet is included at the end of this post.
The Suitelet looks at the path that was requested (which is passed to the Suitelet via a custom HTTP header) and attempts to route it to a function, which then processes the request. If the path isn't valid, the Suitelet redirects the user to a special "404" subdirectory, which NGINX processes directly (and returns a "404" response code).
There's also optional code in the main function which you can use to help prevent the Suitelet from being called directly. It looks at another custom HTTP header ("X-Server-Name") to determine if the request appears to have come from the expected domain / subdomain. Of course, this can be easily spoofed, so if you're concerned about the Suitelet being called directly, you might want to add additional code to secure the Suitelet (such as using HTTP basic authentication or some other header-based approach).
The demo app uses Bootstrap, a popular CSS Framework for developing responsive and mobile-first websites. Note that I'm using an older version of Bootstrap (version 4.5.2).
The Suitelet that I've provided is designed to demonstrate just a few of the things that are possible with the technique. For example, there's an example of how you can process a form submission, serve up content using a combination of SuiteQL and a record (loaded via the N/record module), display an image stored in the File Cabinet, serve up JSON based on the results of a SuiteQL query, and more. There's also code to show how you can drop a cookie, and a debug display function that shows all of the data that the Suitelet has available to it when a request is forwarded from NGINX. (Those last two features are disabled in the demo.)
When deploying the Suitelet:
• Make sure that it has been released.
• Allow external access to the Suitelet by checking the Available Without Login option.
• Have "All Roles" set as the Audience.
I hope you find the technique and code that I've shared in this post to be helpful. If you're a NetSuite developer looking for a way to provide SuiteScript-based apps without exposing NetSuite directly, then this might be a technique worth exploring. There's much more that you can do with this technique, including providing secure Web APIs, full-blown Web applications, mobile-friendly Web apps, etc.
The script is included below. It is also available as a download that includes the NGINX configuration file in a single zip file.
/** * @NApiVersion 2.1 * @NScriptType Suitelet * @NModuleScope Public */ /* ------------------------------------------------------------------------------------------ Script Information ------------------------------------------------------------------------------------------ Name: NGINX Example 1 ID: _nginx_example_01 Description Handles requests sent via NGINX, which is acting as a reverse proxy for the Suitelet. ------------------------------------------------------------------------------------------ MIT License ------------------------------------------------------------------------------------------ Copyright (c) 2022 Timothy Dietrich. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------------------ Developer ------------------------------------------------------------------------------------------ Tim Dietrich • timdietrich@me.com • https://timdietrich.me ------------------------------------------------------------------------------------------ History ------------------------------------------------------------------------------------------ 20220103 - Tim Dietrich • Initial public release. */ var error, log, query, record, redirect, runtime, url; define( [ 'N/error', 'N/log', 'N/query', 'N/record', 'N/redirect', 'N/runtime', 'N/url' ], main ); function main( errorModule, logModule, queryModule, recordModule, redirectModule, runtimeModule, urlModule ) { error = errorModule; log = logModule; query = queryModule; record = recordModule; redirect = redirectModule; runtime = runtimeModule; url = urlModule; return { onRequest: function ( context ) { log.debug( 'hit', context ); if ( context.request.headers['X-Server-Name'] != 'nsrelay.some-domain.com' ) { context.response.write( '<h1>Error</h1>' ); context.response.write( '<p>This Suitelet cannot be called directly.</p>' ); return; } switch ( context.request.headers['X-Original-Path'] ) { case '/': indexGenerate( context ); break; case '/cookie-drop/': cookieDrop( context ); break; case '/debug/': debugGenerate( context ); break; case '/employee-details/': employeeDetailsGenerate( context ); break; case '/employee-directory/': employeeDirectoryGenerate( context ); break; case '/form-test/': formTest( context ); break; case '/inventory/': inventory( context ); break; default: // Note that Suitelets always return "200 OK" HTTP status codes, // and we cannot override that. So we redirect to an NGINX-based // path to force a 404 error, which will return 404 as // the status code. redirect.redirect( { url: '/404/' } ); } } } } function cookieDrop( context ) { let currentDate = new Date(); let time = currentDate.getHours() + ":" + currentDate.getMinutes() + ":" + currentDate.getSeconds(); let test = ( Math.random().toString(16) + "000000000" ).substr( 2, 8 ); let html = `<html> <head> <title>NetSuite Relay Test: Cookie Dropped</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> </head> <body> ${navGenerate()} <div style="margin: 24px;"> <h1>Cookie Dropped</h1> <p>A cookie has been dropped.</p> <p>The cookie is named "test" and has a value of: ${test}</p> <p>To confirm that the cookie is working properly, <a href="/debug/">click here</a>.</p> <p>Then look for "Cookie" in the "Headers" values.</p> </div> </body> </html>`; context.response.setHeader( { name: 'Content-Type', value: 'text/html; charset=UTF-8' } ); context.response.setHeader( { name: 'Set-Cookie', value: `test=${test}; path=/` } ); context.response.write( html ); } function debugGenerate( context ) { var params = paramsGet( context ); var runtimeInfo = {} runtimeInfo.accountId = runtime.accountId; runtimeInfo.envType = runtime.envType; runtimeInfo.executionContext = runtime.executionContext; runtimeInfo.processorCount = runtime.processorCount; runtimeInfo.queueCount = runtime.queueCount; runtimeInfo.version = runtime.version; runtimeInfo.currentScript = runtime.getCurrentScript(); runtimeInfo.currentSession = runtime.getCurrentSession(); runtimeInfo.currentUser = runtime.getCurrentUser(); let html = `<html> <head> <title>NetSuite Relay Test: Debug Info</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> </head> <body> ${navGenerate()} <div style="margin: 24px;"> <h1>NS Relay Test: Debug Info</h1> <h2>Headers</h2> <pre>${JSON.stringify( context.request.headers, null, 2 )}</pre> <h2>URL Parameters</h2> <pre>${JSON.stringify( params, null, 2 )}</pre> <h2>Body</h2> <pre>${context.request.body}</pre> <h2>Runtime Info</h2> <pre>${JSON.stringify( runtimeInfo, null, 2 )}</pre> </div> </body> </html>`; context.response.setHeader( { name: 'Content-Type', value: 'text/html; charset=UTF-8' } ); context.response.write( html ); } function employeeDetailsGenerate( context ) { var params = paramsGet( context ); var sql = ` SELECT ID, LastName, FirstName, Phone, Email, Title, BUILTIN.DF( Department ) AS Department, BUILTIN.DF( Subsidiary ) AS Subsidiary, BUILTIN.DF( Supervisor ) AS Supervisor, HireDate FROM Employee WHERE ( Email LIKE '%@test.com' ) AND ( LastName IS NOT NULL ) AND ( Title IS NOT NULL ) AND ( ID = ? ) ORDER BY LastName, FirstName `; var queryResults = query.runSuiteQL( { query: sql, params: [ params['employeeID'] ] } ); var employees = queryResults.asMappedResults(); if ( employees.length == 0 ) { context.response.write( 'Error: Invalid employee ID.' ); return context; } var employee = employees[0]; var employeeRecord = record.load( { type: 'employee', id: params['employeeID'], isDynamic: false } ); var employeeObject = JSON.parse( JSON.stringify( employeeRecord ) ); var imageHTML = ''; if ( employeeObject.fields.image != null ) { sql = `SELECT URL FROM File WHERE ID = ?`; queryResults = query.runSuiteQL( { query: sql, params: [ employeeObject.fields.image ] } ); var files = queryResults.asMappedResults(); var imageFile = files[0]; var appURL = url.resolveDomain( { hostType: url.HostType.APPLICATION } ); var imageURL = 'https://' + appURL + imageFile.url; imageHTML = ` <img src="${imageURL}" style="max-width: 100%; max-height: 600px;"> `; } let html = `<html> <head> <title>NetSuite Relay Test: Employee Details</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> </head> <body> ${navGenerate()} <div style="margin: 18px;"> <h1 style="margin-bottom: 12px;">Employee Details</h1> <div class="container-fluid"> <div class="row"> <div class="col-sm"> <p> <span style="font-weight: bold;">First Name:</span><br> ${employee.firstname} </p> <p> <span style="font-weight: bold;">Last Name:</span><br> ${employee.lastname} </p> <p> <span style="font-weight: bold;">Title:</span><br> ${employee.title} </p> <p> <span style="font-weight: bold;">Department:</span><br> ${employee.department} </p> <p> <span style="font-weight: bold;">Subsidiary:</span><br> ${employee.subsidiary} </p> <p> <span style="font-weight: bold;">Email Address:</span><br> <a href="mailto:${employee.email}">${employee.email}</a> </p> <p> <span style="font-weight: bold;">Supervisor:</span><br> ${employee.supervisor} </p> <p> <span style="font-weight: bold;">Hire Date:</span><br> ${employee.hiredate} </p> </div> <div class="col-sm"> ${imageHTML} </div> </div> </div> <hr style="margin-top: 24px; margin-bottom: 24px;"> <p><a href="/employee-directory/">< Return to Directory</a></p> </div> </body> </html>`; context.response.setHeader( { name: 'Content-Type', value: 'text/html; charset=UTF-8' } ); context.response.setHeader( { name: 'Cache-Control', value: 'public, max-age=60' } ); context.response.write( html ); } function employeeDirectoryGenerate( context ) { var sql = ` SELECT ID, LastName, FirstName, Phone, Email, Title FROM Employee WHERE ( Email LIKE '%@test.com' ) AND ( LastName IS NOT NULL ) AND ( Title IS NOT NULL ) ORDER BY LastName, FirstName `; var queryResults = query.runSuiteQL( { query: sql, params: [] } ); var employees = queryResults.asMappedResults(); var employeesTable = '<p>No employees were found.</p>'; if ( employees.length > 0 ) { employeesTable = '<table class="table table-sm table-bordered table-hover table-responsive-sm" id="resultsTable">'; employeesTable += '<thead class="thead-light">'; employeesTable += '<tr>'; employeesTable += '<th>Name</th>'; employeesTable += '<th>Title</th>'; employeesTable += '<th>Phone</th>'; employeesTable += '<th>Email</th>'; employeesTable += '</tr>'; employeesTable += '</thead>'; employeesTable += '<tbody>'; for ( i = 0; i < employees.length; i++ ) { var detailsURL = `/employee-details/?employeeID=${employees[i].id}`; employeesTable += '<tr>'; employeesTable += `<td><a href="${detailsURL}">${employees[i].lastname}, ${employees[i].firstname}</a></td>`; employeesTable += `<td>${employees[i].title}</td>`; if ( employees[i].phone != null ) { employeesTable += `<td>${employees[i].phone}</td>`; } else { employeesTable += `<td> </td>`; } employeesTable += `<td><a href="mailto:${employees[i].email}">${employees[i].email}</a></td>`; employeesTable += '</tr>'; } employeesTable += '</tbody>'; employeesTable += '</table>'; } let html = `<html> <head> <title>NetSuite Relay Test: Employee Directory</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> </head> <body> ${navGenerate()} <div style="margin: 24px;"> <h1 style="margin-bottom: 18px;">Employee Directory</h1> ${employeesTable} </div> </body> </html>`; context.response.setHeader( { name: 'Content-Type', value: 'text/html; charset=UTF-8' } ); context.response.setHeader( { name: 'Cache-Control', value: 'public, max-age=60' } ); context.response.write( html ); } function formTest( context ) { if ( context.request.method == 'POST' ) { log.debug( 'formTest - submitted', context ); let html = `<html> <head> <title>NetSuite Relay Test: Form Test</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> </head> <body> ${navGenerate()} <div style="margin: 24px;"> <h1>Form Received</h1> <p> <span style="font-weight: bold;">Email Address:</span><br> ${context.request.parameters.emailAddress} </p> <p> <span style="font-weight: bold;">Rating:</span><br> ${context.request.parameters.rating} </p> <p> <span style="font-weight: bold;">Comments:</span><br> ${context.request.parameters.comments} </p> </body> </html>`; context.response.setHeader( { name: 'Content-Type', value: 'text/html; charset=UTF-8' } ); context.response.setHeader( { name: 'Cache-Control', value: 'public, max-age=60' } ); context.response.write( html ); return; } let html = `<html> <head> <title>NetSuite Relay Test: Form Test</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> </head> <body> ${navGenerate()} <div style="margin: 24px; max-width: 600px;"> <h1>Form Test</h1> <form method="POST" action="/form-test/"> <div class="form-group"> <label for="field1">Email Address</label> <input type="email" class="form-control" id="field1" name="emailAddress" placeholder="name@test.com"> </div> <div class="form-group"> <label for="field2">Rating</label> <select class="form-control" id="field2" name="rating"> <option value="1">1 (Low)</option> <option value="2">2</option> <option value="3">3</option> <option value="4">4</option> <option value="5">5 (High)</option> </select> </div> <div class="form-group"> <label for="field3">Comments</label> <textarea class="form-control" id="field3" name="comments" rows="3"></textarea> </div> <button type="submit" id="submitButton" name="submitButton" value="clicked" class="btn btn-small btn-primary">Submit Form ></button> </form> </div> </body> </html>`; context.response.setHeader( { name: 'Content-Type', value: 'text/html; charset=UTF-8' } ); context.response.setHeader( { name: 'Cache-Control', value: 'public, max-age=60' } ); context.response.write( html ); } function indexGenerate( context ) { let html = `<html> <head> <title>NetSuite Relay Test</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> </head> <body> <div style="margin: 24px;"> <div class="jumbotron"> <h1 class="display-4">NetSuite Relay</h1> <p class="lead">A demonstration of NGINX as a reverse proxy for NetSuite.</p> </div> <div style="margin: 24px;"> <h3>Tests and Examples</h1> <p>• <a href="/cookie-drop/">Test cookies.</a></p> <p>• <a href="/form-test/">Test a form submission.</a></p> <p>• <a href="/debug/">View debug info.</a></p> <p>• <a href="/employee-directory/">View an Employee Directory.</a></p> <p>• <a href="/inventory/" target="_inventory">Get inventory availability (as JSON).</a></p> <p>• <a href="/nope/">Simulate an error.</a></p> </div> </div> </body> </html>`; context.response.setHeader( { name: 'Content-Type', value: 'text/html; charset=UTF-8' } ); context.response.setHeader( { name: 'Cache-Control', value: 'public, max-age=60' } ); context.response.write( html ); } function inventory( context ) { var sql = ` SELECT Item.ID, Item.ItemID, AggregateItemLocation.quantityavailable, BUILTIN.DF( AggregateItemLocation.location ) AS Location FROM Item INNER JOIN AggregateItemLocation ON ( AggregateItemLocation.Item = Item.ID ) WHERE ( Item.IsInactive = 'F' ) AND ( AggregateItemLocation.quantityavailable > 0 ) ORDER BY Item.ID `; var queryResults = query.runSuiteQL( { query: sql, params: [] } ); var items = queryResults.asMappedResults(); context.response.setHeader( { name: 'Content-Type', value: 'application/json' } ); context.response.write( JSON.stringify( items, null, 2 ) ); } function navGenerate() { return ` <nav class="navbar navbar-dark bg-dark"> <a class="navbar-brand" href="/">NetSuite Relay</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarNavDropdown"> <ul class="navbar-nav"> <li class="nav-item"> <a class="nav-link" href="/">Home</a> </li> <li class="nav-item"> <a class="nav-link" href="/cookie-drop/">Cookie Drop</a> </li> <li class="nav-item"> <a class="nav-link" href="/debug/">Debug Information</a> </li> <li class="nav-item" active> <a class="nav-link" href="/employee-directory/">Employee Directory</a> </li> <li class="nav-item"> <a class="nav-link" href="/form-test/">Form Test</a> </li> <li class="nav-item"> <a class="nav-link" href="/inventory/" target="_inventory">Inventory (JSON)</a> </li> </ul> </div> </nav> `; } function paramsGet( context ) { var paramsOriginal = context.request.headers['X-Original-URL-Params']; var params = {} paramsOriginal = paramsOriginal.split("&"); for ( var i = 0; i < paramsOriginal.length; i++ ){ var thisParam = paramsOriginal[i]; thisParam = thisParam.split("="); if ( thisParam[0] != '' ) { params[thisParam[0]] = decodeURIComponent( thisParam[1] ); } } return params; }
Hello, I'm Tim Dietrich. I develop custom software for businesses that are running on NetSuite, including mobile apps, Web portals, Web APIs, and more.
I'm the developer of several popular NetSuite open source solutions, including the SuiteQL Query Tool, SuiteAPI, and more.
I founded SuiteStep, a NetSuite development studio, to provide custom software and AI solutions - and continue pushing the boundaries of what's possible on the NetSuite platform.
Copyright © 2025 Tim Dietrich.