diff --git a/example.env b/example.env
index 6d59aee..2b48300 100644
--- a/example.env
+++ b/example.env
@@ -28,5 +28,11 @@ AIO_SCOPES=AdobeID,openid,read_organizations,additional_info.projectedProductCon
AIO_EVENTS_PROVIDER_ID_STAGE=ce40dfc9-9a94-41cc-a05a-1334d674fc8c
AIO_EVENTS_PROVIDER_ID_PROD=838bd20b-5725-4f72-96f7-d486799cdabf
+# EBS SOAP API base URL
+EBS_BASE_URL=x
+
# Productbus service token with emails:send permission
EMAIL_TOKEN=x
+
+# helix-proxy token for the org/site
+PROXY_TOKEN=x
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 00bb37b..fb9d024 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,10 @@
"name": "vitamix-forms",
"version": "0.0.1",
"dependencies": {
- "@adobe/aio-lib-state": "5.3.1",
"@adobe/aio-sdk": "^6",
"@adobe/exc-app": "^1.3.0",
"cloudevents": "^4.0.2",
+ "fast-xml-parser": "^5.4.2",
"node-fetch": "^2.6.0",
"regenerator-runtime": "^0.13.5",
"uuid": "^8.0.0"
@@ -5689,6 +5689,25 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
+ "version": "5.3.6",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz",
+ "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "strnum": "^2.1.2"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
@@ -18260,10 +18279,22 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/fast-xml-builder": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
+ "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/fast-xml-parser": {
- "version": "5.3.6",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz",
- "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==",
+ "version": "5.4.2",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz",
+ "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==",
"funding": [
{
"type": "github",
@@ -18272,6 +18303,7 @@
],
"license": "MIT",
"dependencies": {
+ "fast-xml-builder": "^1.0.0",
"strnum": "^2.1.2"
},
"bin": {
diff --git a/package.json b/package.json
index f9c484f..9b356c8 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"@adobe/aio-sdk": "^6",
"@adobe/exc-app": "^1.3.0",
"cloudevents": "^4.0.2",
+ "fast-xml-parser": "^5.4.2",
"node-fetch": "^2.6.0",
"regenerator-runtime": "^0.13.5",
"uuid": "^8.0.0"
@@ -36,4 +37,4 @@
"engines": {
"node": ">=18"
}
-}
\ No newline at end of file
+}
diff --git a/src/actions/submit/index.js b/src/actions/submit/index.js
index 79bd51d..37e93a1 100644
--- a/src/actions/submit/index.js
+++ b/src/actions/submit/index.js
@@ -18,9 +18,6 @@ function validatePayload(data) {
if (!data.formId || typeof data.formId !== 'string') {
return 'missing or invalid formId';
}
- if (!data.data || typeof data.data !== 'object') {
- return 'missing or invalid data';
- }
// check that formId looks valid
// these are further validated in the processor action
@@ -41,7 +38,7 @@ function validatePayload(data) {
}
// nested properties in data
- Object.values(data.data).forEach((val) => {
+ Object.values(data.data ?? data ?? {}).forEach((val) => {
if (typeof val === 'object' && val !== null) {
return 'payload contains nested data';
}
diff --git a/src/context.js b/src/context.js
index 2779609..b917cc8 100644
--- a/src/context.js
+++ b/src/context.js
@@ -20,13 +20,14 @@ export default async function createContext(owParams) {
AIO_IMSORGID,
AIO_EVENTS_PROVIDER_ID,
EMAIL_TOKEN,
+ PROXY_TOKEN,
...data
} = owParams;
const token = await getAccessToken(AIO_CLIENTID, AIO_CLIENTSECRET, AIO_SCOPES);
return {
- env: { ORG, SITE, SHEET, EMAIL_TOKEN },
+ env: { ORG, SITE, SHEET, EMAIL_TOKEN, PROXY_TOKEN },
// @ts-ignore
log: Core.Logger('main', { level: LOG_LEVEL }),
data,
diff --git a/src/ebs.js b/src/ebs.js
new file mode 100644
index 0000000..1ef3bfc
--- /dev/null
+++ b/src/ebs.js
@@ -0,0 +1,176 @@
+import { XMLParser } from 'fast-xml-parser';
+import { proxyFetch } from './proxy.js';
+
+const PATHS = {
+ queryOrder: '/soa-infra/services/OTC/VITOTCQueryOrder/vitotcqueryorderbpel_client_ep',
+ validateSerialNumber: '/soa-infra/services/OTC/VITOTCValidateSerialNum/vitotcvalidateserialnumbpel_client_ep',
+ createRegistration: '/soa-infra/services/OTC/VITOTCProdRegistration/vitotcproductregbpel_client_ep',
+};
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ removeNSPrefix: true,
+});
+
+/**
+ * Escape XML special characters to prevent injection
+ * @param {string} str
+ * @returns {string}
+ */
+function escapeXml(str) {
+ if (str == null) return '';
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * @param {string} xml
+ * @returns {object}
+ */
+function parseResponse(xml) {
+ const parsed = parser.parse(xml);
+ return parsed?.Envelope?.Body ?? parsed;
+}
+
+/**
+ * @param {Context} ctx
+ * @param {string} baseUrl
+ * @param {string} path
+ * @param {string} xml
+ * @returns {Promise<{ status: number, body: object, raw: string }>}
+ */
+async function soapFetch(ctx, baseUrl, path, xml) {
+ const url = `${baseUrl}${path}`;
+ const resp = await proxyFetch(ctx, url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/xml; charset=utf-8' },
+ body: xml,
+ });
+ const raw = await resp.text();
+ const body = parseResponse(raw);
+ return { status: resp.status, body, raw };
+}
+
+/**
+ * Query an order by key via VITOTCQueryOrder SOAP API
+ * @param {Context} ctx
+ * @param {string} baseUrl - EBS SOAP base URL
+ * @param {string} orderKey - order key (e.g. "omstg1000031076")
+ * @returns {Promise<{ status: number, body: object, raw: string }>}
+ */
+export async function queryOrder(ctx, baseUrl, orderKey) {
+ const requestId = crypto.randomUUID();
+ const xml = [
+ '',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '',
+ ].join('\n');
+ console.log('querying order:', xml);
+ return soapFetch(ctx, baseUrl, PATHS.queryOrder, xml);
+}
+
+/**
+ * Validate a product serial number via VITOTCValidateSerialNum SOAP API
+ * @param {Context} ctx
+ * @param {string} baseUrl - EBS SOAP base URL
+ * @param {string} serialNumber - serial number (e.g. "067881201029626223")
+ * @returns {Promise<{ status: number, body: object, raw: string }>}
+ */
+export async function validateSerialNumber(ctx, baseUrl, serialNumber) {
+ const requestId = crypto.randomUUID();
+ const xml = [
+ '',
+ '',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '',
+ ].join('\n');
+ return soapFetch(ctx, baseUrl, PATHS.validateSerialNumber, xml);
+}
+
+/**
+ * @typedef {object} RegistrationData
+ * @property {string} formCode
+ * @property {string} purchaseLocation
+ * @property {string} purchaseDate - ISO date string (e.g. "2026-02-22T00:00:00")
+ * @property {string} [prefix]
+ * @property {string} [suffix]
+ * @property {string} address1
+ * @property {string} city
+ * @property {string} region - state/province code (e.g. "OH")
+ * @property {string} postalCode
+ * @property {string} geoCode
+ * @property {string} country - country code (e.g. "US")
+ * @property {string} mobile
+ * @property {string} email
+ * @property {string} firstName
+ * @property {string} lastName
+ * @property {string} [middleName]
+ * @property {string} serialNumber
+ */
+
+/**
+ * Create a product registration via VITOTCProdRegistration SOAP API
+ * @param {Context} ctx
+ * @param {string} baseUrl - EBS SOAP base URL
+ * @param {RegistrationData} data
+ * @returns {Promise<{ status: number, body: object, raw: string }>}
+ */
+export async function createProductRegistration(ctx, baseUrl, data) {
+ const requestId = crypto.randomUUID();
+ const e = escapeXml;
+ const xml = [
+ '',
+ '',
+ ' ',
+ ' ',
+ ` `,
+ ` `,
+ ` `,
+ ' ',
+ ` ${e(data.address1)}`,
+ ` ${e(data.city)}`,
+ ` ${e(data.region)}`,
+ ` ${e(data.postalCode)}`,
+ ` ${e(data.geoCode)}`,
+ ` ${e(data.country)}`,
+ ' ',
+ ` ${e(data.mobile)}`,
+ ` ${e(data.email)}`,
+ ` ${e(data.firstName)}`,
+ ` ${e(data.lastName)}`,
+ ` ${e(data.middleName ?? '')}`,
+ ' ',
+ ` `,
+ ' ',
+ ' ',
+ ' ',
+ '',
+ ].join('\n');
+ return soapFetch(ctx, baseUrl, PATHS.createRegistration, xml);
+}
diff --git a/src/proxy.js b/src/proxy.js
new file mode 100644
index 0000000..d48d4de
--- /dev/null
+++ b/src/proxy.js
@@ -0,0 +1,34 @@
+/**
+ * Proxy URL for the org/site
+ * @param {string} org
+ * @param {string} site
+ * @returns {string}
+ */
+const PROXY_URL = (org, site) => `https://lqmig3v5eb.execute-api.us-east-1.amazonaws.com/helix-services/proxy/v1/${org}/${site}`
+
+/**
+ * Fetch via proxy
+ * @param {Context} ctx
+ * @param {string} url
+ * @param {RequestInit} opts
+ * @returns {Promise}
+ */
+export async function proxyFetch(ctx, url, opts) {
+ const proxyUrl = PROXY_URL(ctx.env.ORG, ctx.env.SITE);
+ console.log('proxy fetching:', opts.method ?? 'GET', url, '=>', proxyUrl);
+ const resp = await fetch(proxyUrl, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${ctx.env.PROXY_TOKEN}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ url,
+ ...(opts ?? {}),
+ }),
+ })
+ if (!resp.ok) {
+ throw new Error(`Proxy fetch failed: ${resp.status} ${resp.statusText} - ${resp.headers.get('x-error') ?? ''}`);
+ }
+ return resp;
+}
\ No newline at end of file
diff --git a/src/types.d.ts b/src/types.d.ts
index b229f37..fb31b51 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -6,6 +6,7 @@ declare global {
SITE: string;
SHEET: string;
EMAIL_TOKEN: string;
+ PROXY_TOKEN: string;
}
export interface EventsConfig {
diff --git a/test/submit.test.js b/test/submit.test.js
index 283c5ff..0dae4bc 100644
--- a/test/submit.test.js
+++ b/test/submit.test.js
@@ -75,18 +75,20 @@ describe('submit action', () => {
expect(result.error.headers['x-error']).toBe('missing or invalid formId');
});
- test('rejects missing data', async () => {
- mockMakeContext.mockResolvedValue(makeCtx({ data: { formId: 'test-form' } }));
+ test('accepts flat payload (no nested data object)', async () => {
+ mockMakeContext.mockResolvedValue(makeCtx({
+ data: { formId: 'test-form', name: 'Alice', email: 'alice@test.com' },
+ }));
const result = await main({});
- expect(result.error.statusCode).toBe(400);
- expect(result.error.headers['x-error']).toBe('missing or invalid data');
+ expect(result.statusCode).toBe(201);
});
- test('rejects non-object data', async () => {
- mockMakeContext.mockResolvedValue(makeCtx({ data: { formId: 'test-form', data: 'string' } }));
+ test('accepts flat payload where data field is a non-object', async () => {
+ mockMakeContext.mockResolvedValue(makeCtx({
+ data: { formId: 'test-form', data: 'string', name: 'Bob' },
+ }));
const result = await main({});
- expect(result.error.statusCode).toBe(400);
- expect(result.error.headers['x-error']).toBe('missing or invalid data');
+ expect(result.statusCode).toBe(201);
});
test.each([
@@ -155,6 +157,19 @@ describe('submit action', () => {
);
});
+ test('publishes flat payload properties as form data (formId stripped)', async () => {
+ mockMakeContext.mockResolvedValue(makeCtx({
+ data: { formId: 'test-form', name: 'Alice', email: 'alice@test.com' },
+ }));
+ await main({});
+
+ const eventData = mockPublishEvent.mock.calls[0][2];
+ expect(eventData.formId).toBe('test-form');
+ expect(eventData.data.name).toBe('Alice');
+ expect(eventData.data.email).toBe('alice@test.com');
+ expect(eventData.data).not.toHaveProperty('formId');
+ });
+
test('adds server-generated timestamp and IP', async () => {
mockMakeContext.mockResolvedValue(makeCtx());
await main({});