diff --git a/docs/api-catalog.txt b/docs/api-catalog.txt
index d98dc751cf..3c354c9f6f 100644
--- a/docs/api-catalog.txt
+++ b/docs/api-catalog.txt
@@ -2,7 +2,7 @@
> Compact provider slug catalog for agents. Use the slug to reconstruct provider-specific docs URLs only when needed.
-Provider count: 884
+Provider count: 885
URL patterns:
- Main provider page: https://nango.dev/docs/integrations/all/{slug}.md or https://nango.dev/docs/api-integrations/{slug}.md
@@ -667,6 +667,7 @@ URL patterns:
| `sage-intacct` | Sage Intacct | TWO_STEP | [docs](https://nango.dev/docs/integrations/all/sage-intacct.md) | [connect](https://nango.dev/docs/integrations/all/sage-intacct/connect.md) | | accounting, erp |
| `sage-intacct-cc` | Sage Intacct (Client Credentials) | TWO_STEP | [docs](https://nango.dev/docs/api-integrations/sage-intacct-cc.md) | [connect](https://nango.dev/docs/api-integrations/sage-intacct-cc/connect.md) | [setup](https://nango.dev/docs/api-integrations/sage-intacct-cc/connect.md) | accounting, erp |
| `sage-intacct-oauth` | Sage Intacct (OAuth) | OAUTH2 | [docs](https://nango.dev/docs/integrations/all/sage-intacct-oauth.md) | | | accounting, erp, popular |
+| `sage-member` | SAGE Member | API_KEY | [docs](https://nango.dev/docs/api-integrations/sage-member.md) | [connect](https://nango.dev/docs/api-integrations/sage-member/connect.md) | | marketing, e-commerce |
| `sage-people` | Sage People | OAUTH2 | [docs](https://nango.dev/docs/integrations/all/sage-people.md) | [connect](https://nango.dev/docs/integrations/all/sage-people/connect.md) | | hr |
| `salesforce` | Salesforce | OAUTH2 | [docs](https://nango.dev/docs/api-integrations/salesforce.md) | [connect](https://nango.dev/docs/api-integrations/salesforce/connect.md) | [setup](https://nango.dev/docs/api-integrations/salesforce/salesforce-api-oauth-app-setup.md) | crm, popular |
| `salesforce-cc` | Salesforce (Client Credentials) | OAUTH2_CC | [docs](https://nango.dev/docs/api-integrations/salesforce-cc.md) | [connect](https://nango.dev/docs/api-integrations/salesforce-cc/connect.md) | | crm |
diff --git a/docs/api-integrations/sage-member.mdx b/docs/api-integrations/sage-member.mdx
new file mode 100644
index 0000000000..6eee37d795
--- /dev/null
+++ b/docs/api-integrations/sage-member.mdx
@@ -0,0 +1,87 @@
+---
+title: 'SAGE Member'
+sidebarTitle: 'SAGE Member'
+description: 'Integrate your application with the SAGE Member API'
+---
+
+## Quickstart
+
+Connect to SAGE Member with Nango and see data flow in 2 minutes.
+
+
+
+ In Nango ([free signup](https://app.nango.dev)), go to [Integrations](https://app.nango.dev/dev/integrations) -> _Configure New Integration_ -> _SAGE Member_.
+
+
+ Go to [Connections](https://app.nango.dev/dev/connections) -> _Add Test Connection_ -> _Authorize_, then enter your Account Number, Login ID, and Authentication Key. Later, you'll let your users do the same directly from your app.
+
+
+ Let's make your first request to the SAGE Member API. Replace the placeholders below with your [secret key](https://app.nango.dev/dev/environment-settings), [integration ID](https://app.nango.dev/dev/integrations), and [connection ID](https://app.nango.dev/dev/connections):
+
+
+
+ ```bash
+ curl -X POST "https://api.nango.dev/proxy/ConnectAPI" \
+ -H "Authorization: Bearer " \
+ -H "Provider-Config-Key: " \
+ -H "Connection-Id: " \
+ -H "Content-Type: application/json" \
+ -d '{"serviceId": 101, "apiVer": 130}'
+ ```
+
+
+
+
+
+ Install Nango's backend SDK with `npm i @nangohq/node`. Then run:
+
+ ```typescript
+ import { Nango } from '@nangohq/node';
+
+ const nango = new Nango({ secretKey: '' });
+
+ const res = await nango.post({
+ endpoint: '/ConnectAPI',
+ providerConfigKey: '',
+ connectionId: '',
+ data: {
+ serviceId: 101,
+ apiVer: 130
+ }
+ });
+
+ console.log(res.data);
+ ```
+
+
+
+ Nango automatically injects your Account Number, Login ID, and Authentication Key into the `auth` object of every proxied request body — you only need to supply the `serviceId` and other fields for the specific SAGE Member module you're calling.
+
+ Or fetch credentials with the [Node SDK](/reference/sdks/node#get-a-connection-with-credentials) or [API](/reference/api/connection/get).
+
+ You're connected! Check the [Logs](https://app.nango.dev/dev/logs) tab in Nango to inspect requests.
+
+
+
+ Follow our [Auth implementation guide](/guides/primitives/auth) to integrate Nango in your app.
+
+ To obtain your own production credentials, follow the setup guide linked below.
+
+
+
+## SAGE Member Integration guides
+
+Nango-maintained guides for common use cases.
+
+- [How do I link my account?](/api-integrations/sage-member/connect)
+Connect your SAGE Member account using the Connect UI
+
+## Pre-built syncs & actions for SAGE Member
+
+Enable them in your dashboard. Extend and customize to fit your needs.
+
+import PreBuiltUseCases from "/snippets/generated/sage-member/PreBuiltUseCases.mdx"
+
+
+
+---
diff --git a/docs/api-integrations/sage-member/connect.mdx b/docs/api-integrations/sage-member/connect.mdx
new file mode 100644
index 0000000000..cf9cdc7d48
--- /dev/null
+++ b/docs/api-integrations/sage-member/connect.mdx
@@ -0,0 +1,40 @@
+---
+title: SAGE Member - How do I link my account?
+sidebarTitle: SAGE Member
+---
+
+# Overview
+
+To authenticate with SAGE Member, you need:
+1. **Account Number** — Your SAGE Member account number.
+2. **Login ID** — The login ID of the SAGE Member user account making the requests.
+3. **Authentication Key** — The key associated with that user's login, used to authenticate every request.
+
+### Prerequisites
+
+- An active SAGE Workplace subscription or Supplier Advantage membership.
+- A SAGE Member module enabled on your account (enable modules on the Services tab).
+
+### Step 1: Finding your credentials
+
+SAGE Member authenticates on a per-user basis, so each set of credentials is tied to one user on your account.
+
+1. Log in to your SAGE account and go to **Developer APIs** > **SAGE Connect**.
+2. Go to the **Users** tab.
+3. Click your profile in the top right to find your **Account Number**.
+4. Select the user that will be making API requests and note their **Login ID** and **Authentication Key**.
+
+
+
+
+### Step 2: Enter credentials in the Connect UI
+
+Once you have your credentials:
+1. Open the form where you need to authenticate with SAGE Member.
+2. Enter your **Account Number**, **Login ID**, and **Authentication Key** in their designated fields.
+3. Submit the form, and you should be successfully authenticated.
+
+
+
+
+You are now connected to SAGE Member.
diff --git a/docs/api-integrations/sage-member/form.png b/docs/api-integrations/sage-member/form.png
new file mode 100644
index 0000000000..24c9f27885
Binary files /dev/null and b/docs/api-integrations/sage-member/form.png differ
diff --git a/docs/api-integrations/sage-member/users_tab.png b/docs/api-integrations/sage-member/users_tab.png
new file mode 100644
index 0000000000..5640444b63
Binary files /dev/null and b/docs/api-integrations/sage-member/users_tab.png differ
diff --git a/docs/docs.json b/docs/docs.json
index ae10abf001..71b1bb1d3b 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -1568,6 +1568,7 @@
"integrations/all/sage-intacct",
"integrations/all/sage-intacct-oauth",
"api-integrations/sage-intacct-cc",
+ "api-integrations/sage-member",
"integrations/all/sage-people",
"api-integrations/sap-ariba",
"api-integrations/salesforce",
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 6a2e3f6f5c..bda8357fc0 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -14960,6 +14960,7 @@ Use these slugs to construct provider-specific docs URLs only when needed.
| `sage-intacct` | Sage Intacct | TWO_STEP | [docs](https://nango.dev/docs/integrations/all/sage-intacct.md) | [connect](https://nango.dev/docs/integrations/all/sage-intacct/connect.md) | | accounting, erp |
| `sage-intacct-cc` | Sage Intacct (Client Credentials) | TWO_STEP | [docs](https://nango.dev/docs/api-integrations/sage-intacct-cc.md) | [connect](https://nango.dev/docs/api-integrations/sage-intacct-cc/connect.md) | [setup](https://nango.dev/docs/api-integrations/sage-intacct-cc/connect.md) | accounting, erp |
| `sage-intacct-oauth` | Sage Intacct (OAuth) | OAUTH2 | [docs](https://nango.dev/docs/integrations/all/sage-intacct-oauth.md) | | | accounting, erp, popular |
+| `sage-member` | SAGE Member | API_KEY | [docs](https://nango.dev/docs/api-integrations/sage-member.md) | [connect](https://nango.dev/docs/api-integrations/sage-member/connect.md) | | marketing, e-commerce |
| `sage-people` | Sage People | OAUTH2 | [docs](https://nango.dev/docs/integrations/all/sage-people.md) | [connect](https://nango.dev/docs/integrations/all/sage-people/connect.md) | | hr |
| `salesforce` | Salesforce | OAUTH2 | [docs](https://nango.dev/docs/api-integrations/salesforce.md) | [connect](https://nango.dev/docs/api-integrations/salesforce/connect.md) | [setup](https://nango.dev/docs/api-integrations/salesforce/salesforce-api-oauth-app-setup.md) | crm, popular |
| `salesforce-cc` | Salesforce (Client Credentials) | OAUTH2_CC | [docs](https://nango.dev/docs/api-integrations/salesforce-cc.md) | [connect](https://nango.dev/docs/api-integrations/salesforce-cc/connect.md) | | crm |
diff --git a/docs/llms.txt b/docs/llms.txt
index 963b87e283..ad3d85d5a1 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -139,7 +139,7 @@ Use provider URLs by replacing `{slug}` with a slug from the API catalog:
## APIs and integrations
-- [API catalog](https://nango.dev/docs/api-catalog.txt): 884 provider slugs with canonical docs routes, auth modes, setup guides, and connect guides.
+- [API catalog](https://nango.dev/docs/api-catalog.txt): 885 provider slugs with canonical docs routes, auth modes, setup guides, and connect guides.
- Provider-specific pages are intentionally not expanded here so core Nango guides remain easy for agents to find.
## Resources
diff --git a/docs/snippets/generated/sage-member/PreBuiltUseCases.mdx b/docs/snippets/generated/sage-member/PreBuiltUseCases.mdx
new file mode 100644
index 0000000000..4c3a8de9e0
--- /dev/null
+++ b/docs/snippets/generated/sage-member/PreBuiltUseCases.mdx
@@ -0,0 +1,3 @@
+_No pre-built syncs or actions available yet._
+
+Not seeing the integration you need? [Build your own](/guides/functions/functions-guide) independently.
diff --git a/packages/providers/providers.yaml b/packages/providers/providers.yaml
index 9bc98e3296..3ff4b7e6e5 100644
--- a/packages/providers/providers.yaml
+++ b/packages/providers/providers.yaml
@@ -17887,6 +17887,46 @@ sage-intacct:
description: Your Sage Intacct User Password
secret: true
+sage-member:
+ display_name: SAGE Member
+ categories:
+ - marketing
+ - e-commerce
+ auth_mode: API_KEY
+ proxy:
+ base_url: https://www.promoplace.com/ws/ws.dll
+ body:
+ auth:
+ acctId: ${connectionConfig.acctId}
+ loginId: ${connectionConfig.loginId}
+ key: ${apiKey}
+ docs: https://nango.dev/docs/api-integrations/sage-member
+ docs_connect: https://nango.dev/docs/api-integrations/sage-member/connect
+ credentials:
+ apiKey:
+ type: string
+ title: Authentication Key
+ example: 56edbbe3********************141a
+ pattern: '^[a-f0-9]{32}$'
+ description: The authentication key for your SAGE Member user.
+ secret: true
+ doc_section: '#step-1-finding-your-credentials'
+ connection_config:
+ acctId:
+ type: string
+ title: Account Number
+ description: Your SAGE Member account number.
+ pattern: '^[0-9]+$'
+ example: '4353478'
+ order: 1
+ doc_section: '#step-1-finding-your-credentials'
+ loginId:
+ type: string
+ title: Login ID
+ description: The login ID for your SAGE Member user.
+ order: 2
+ doc_section: '#step-1-finding-your-credentials'
+
sage-people:
display_name: Sage People
categories:
diff --git a/packages/shared/lib/services/proxy/utils.ts b/packages/shared/lib/services/proxy/utils.ts
index e470754148..975385dbef 100644
--- a/packages/shared/lib/services/proxy/utils.ts
+++ b/packages/shared/lib/services/proxy/utils.ts
@@ -26,6 +26,7 @@ import type {
InternalProxyConfiguration,
OAuth2ClientCredentials,
ProviderOAuth1,
+ ProxyBodyValue,
UserProvidedProxyConfiguration
} from '@nangohq/types';
import type { Result } from '@nangohq/utils';
@@ -509,25 +510,44 @@ export function buildProxyBody({
}: {
config: ApplicationConstructedProxyConfiguration;
connection: ConnectionForProxy;
-}): Record | null {
+}): Record | null {
if (!config.provider?.proxy?.body) {
return null;
}
- const body: Record = {};
const replacers = {
connectionConfig: connection.connection_config,
credentials: connection.credentials,
...(connection.credentials as unknown as Record)
};
- for (const [key, value] of Object.entries(config.provider.proxy.body)) {
- if (typeof value !== 'string') {
- continue;
+ // Recurses through arbitrarily nested proxy.body maps, interpolating string leaves and dropping
+ // (at any depth) any leaf whose placeholder didn't resolve, and any object left empty as a result.
+ const buildValue = (value: unknown): ProxyBodyValue | null => {
+ if (typeof value === 'string') {
+ const interpolated = interpolateIfNeeded(value, replacers);
+ return interpolated.includes('${') ? null : interpolated;
+ }
+
+ if (!isPlainObject(value)) {
+ return null;
}
- const interpolated = interpolateIfNeeded(value, replacers);
- if (!interpolated.includes('${')) {
- body[key] = interpolated;
+
+ const nested: Record = {};
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
+ const built = buildValue(nestedValue);
+ if (built !== null) {
+ nested[nestedKey] = built;
+ }
+ }
+ return Object.keys(nested).length > 0 ? nested : null;
+ };
+
+ const body: Record = {};
+ for (const [key, value] of Object.entries(config.provider.proxy.body)) {
+ const built = buildValue(value);
+ if (built !== null) {
+ body[key] = built;
}
}
@@ -545,15 +565,29 @@ function isPlainObject(value: unknown): value is Record {
);
}
-function mergeInjectedBody(existing: unknown, injected: Record): unknown {
+function appendInjectedEntry(append: (key: string, value: string) => void, key: string, value: ProxyBodyValue): void {
+ if (typeof value === 'string') {
+ append(key, value);
+ return;
+ }
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
+ appendInjectedEntry(append, `${key}[${nestedKey}]`, nestedValue);
+ }
+}
+
+function mergeInjectedBody(existing: unknown, injected: Record): unknown {
if (!existing) return injected;
if (isPlainObject(existing)) return { ...existing, ...injected };
if (existing instanceof FormData) {
- for (const [key, value] of Object.entries(injected)) existing.append(key, value);
+ for (const [key, value] of Object.entries(injected)) {
+ appendInjectedEntry((k, v) => existing.append(k, v), key, value);
+ }
return existing;
}
if (existing instanceof URLSearchParams) {
- for (const [key, value] of Object.entries(injected)) existing.append(key, value);
+ for (const [key, value] of Object.entries(injected)) {
+ appendInjectedEntry((k, v) => existing.append(k, v), key, value);
+ }
return existing;
}
return existing;
diff --git a/packages/shared/lib/services/proxy/utils.unit.test.ts b/packages/shared/lib/services/proxy/utils.unit.test.ts
index 1933fd66eb..05d8f2d52a 100644
--- a/packages/shared/lib/services/proxy/utils.unit.test.ts
+++ b/packages/shared/lib/services/proxy/utils.unit.test.ts
@@ -3,6 +3,8 @@ import * as crypto from 'node:crypto';
import FormData from 'form-data';
import { describe, expect, it } from 'vitest';
+import { getProvider } from '@nangohq/providers';
+
import { getTestConnection } from '../../seeders/connection.seeder.js';
import {
absoluteUrlFromRedirectRequestOptions,
@@ -2183,4 +2185,415 @@ describe('buildProxyBody', () => {
const result = buildProxyBody({ config, connection: getTestConnection() });
expect(result).toEqual({ label: 'static' });
});
+
+ it('builds a nested object from a nested proxy.body map', () => {
+ const config = getDefaultProxy({
+ provider: {
+ auth_mode: 'API_KEY',
+ proxy: {
+ base_url: 'https://example.com',
+ body: {
+ auth: {
+ acctId: '${connectionConfig.acctId}',
+ loginId: '${connectionConfig.loginId}',
+ key: '${apiKey}'
+ }
+ }
+ }
+ }
+ });
+ const result = buildProxyBody({
+ config,
+ connection: getTestConnection({
+ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' },
+ connection_config: { acctId: '26878', loginId: 'TestUser' }
+ })
+ });
+ expect(result).toEqual({ auth: { acctId: '26878', loginId: 'TestUser', key: 'my-secret-key' } });
+ });
+
+ it('drops unresolved keys from a nested proxy.body map but keeps the resolved ones', () => {
+ const config = getDefaultProxy({
+ provider: {
+ auth_mode: 'API_KEY',
+ proxy: {
+ base_url: 'https://example.com',
+ body: {
+ auth: {
+ acctId: '${connectionConfig.acctId}',
+ key: '${apiKey}'
+ }
+ }
+ }
+ }
+ });
+ const result = buildProxyBody({
+ config,
+ connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' }, connection_config: {} })
+ });
+ expect(result).toEqual({ auth: { key: 'my-secret-key' } });
+ });
+
+ it('omits a nested map entirely when none of its keys resolve', () => {
+ const config = getDefaultProxy({
+ provider: {
+ auth_mode: 'API_KEY',
+ proxy: {
+ base_url: 'https://example.com',
+ body: { auth: { acctId: '${connectionConfig.acctId}' } }
+ }
+ }
+ });
+ const result = buildProxyBody({
+ config,
+ connection: getTestConnection({ connection_config: {} })
+ });
+ expect(result).toBeNull();
+ });
+
+ it('mixes flat and nested values in the same proxy.body map', () => {
+ const config = getDefaultProxy({
+ provider: {
+ auth_mode: 'API_KEY',
+ proxy: {
+ base_url: 'https://example.com',
+ body: {
+ serviceId: '101',
+ auth: { key: '${apiKey}' }
+ }
+ }
+ }
+ });
+ const result = buildProxyBody({
+ config,
+ connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } })
+ });
+ expect(result).toEqual({ serviceId: '101', auth: { key: 'my-secret-key' } });
+ });
+
+ it('builds a value nested more than one level deep', () => {
+ const config = getDefaultProxy({
+ provider: {
+ auth_mode: 'API_KEY',
+ proxy: {
+ base_url: 'https://example.com',
+ body: {
+ request: {
+ control: {
+ senderid: '${connectionConfig.senderId}',
+ password: '${apiKey}'
+ },
+ operation: {
+ authentication: {
+ login: {
+ userid: '${connectionConfig.userId}'
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ });
+ const result = buildProxyBody({
+ config,
+ connection: getTestConnection({
+ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' },
+ connection_config: { senderId: 'sender-1', userId: 'user-1' }
+ })
+ });
+ expect(result).toEqual({
+ request: {
+ control: { senderid: 'sender-1', password: 'my-secret-key' },
+ operation: { authentication: { login: { userid: 'user-1' } } }
+ }
+ });
+ });
+
+ it('drops an unresolved leaf several levels deep but keeps its siblings', () => {
+ const config = getDefaultProxy({
+ provider: {
+ auth_mode: 'API_KEY',
+ proxy: {
+ base_url: 'https://example.com',
+ body: {
+ a: {
+ b: {
+ c: '${connectionConfig.missing}',
+ d: '${apiKey}'
+ }
+ }
+ }
+ }
+ }
+ });
+ const result = buildProxyBody({
+ config,
+ connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' }, connection_config: {} })
+ });
+ expect(result).toEqual({ a: { b: { d: 'my-secret-key' } } });
+ });
+
+ it('omits a deeply nested map entirely when every leaf under it fails to resolve', () => {
+ const config = getDefaultProxy({
+ provider: {
+ auth_mode: 'API_KEY',
+ proxy: {
+ base_url: 'https://example.com',
+ body: {
+ a: { b: { c: '${connectionConfig.missing}' } }
+ }
+ }
+ }
+ });
+ const result = buildProxyBody({
+ config,
+ connection: getTestConnection({ connection_config: {} })
+ });
+ expect(result).toBeNull();
+ });
+});
+
+describe('buildProxyBody merged into an existing request body', () => {
+ const provider = {
+ auth_mode: 'API_KEY' as const,
+ proxy: {
+ base_url: 'https://example.com',
+ body: {
+ auth: {
+ nested: {
+ key: '${apiKey}'
+ }
+ }
+ }
+ }
+ };
+
+ it('injects a value nested more than one level deep into an existing URLSearchParams body using bracket notation', () => {
+ const config = getDefaultProxy({
+ provider,
+ method: 'POST',
+ data: new URLSearchParams({ existing: 'value' })
+ });
+
+ const axiosConfig = getAxiosConfiguration({
+ proxyConfig: config,
+ connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } })
+ });
+
+ expect((axiosConfig.data as URLSearchParams).toString()).toBe('existing=value&auth%5Bnested%5D%5Bkey%5D=my-secret-key');
+ });
+
+ it('injects a value nested more than one level deep into an existing FormData body using bracket notation', () => {
+ const form = new FormData();
+ form.append('existing', 'value');
+ const config = getDefaultProxy({
+ provider,
+ method: 'POST',
+ data: form
+ });
+
+ const axiosConfig = getAxiosConfiguration({
+ proxyConfig: config,
+ connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } })
+ });
+
+ const resultForm = axiosConfig.data as FormData;
+ expect(resultForm).toBe(form);
+ const buffer = resultForm.getBuffer().toString('utf8');
+ expect(buffer).toContain('name="existing"');
+ expect(buffer).toContain('name="auth[nested][key]"');
+ expect(buffer).toContain('my-secret-key');
+ });
+
+ const providerDepth1 = {
+ auth_mode: 'API_KEY' as const,
+ proxy: {
+ base_url: 'https://example.com',
+ body: {
+ auth: {
+ key: '${apiKey}'
+ }
+ }
+ }
+ };
+
+ it('injects a value nested exactly one level deep into an existing URLSearchParams body using bracket notation', () => {
+ const config = getDefaultProxy({
+ provider: providerDepth1,
+ method: 'POST',
+ data: new URLSearchParams({ existing: 'value' })
+ });
+
+ const axiosConfig = getAxiosConfiguration({
+ proxyConfig: config,
+ connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } })
+ });
+
+ expect((axiosConfig.data as URLSearchParams).toString()).toBe('existing=value&auth%5Bkey%5D=my-secret-key');
+ });
+
+ it('injects a value nested exactly one level deep into an existing FormData body using bracket notation', () => {
+ const form = new FormData();
+ form.append('existing', 'value');
+ const config = getDefaultProxy({
+ provider: providerDepth1,
+ method: 'POST',
+ data: form
+ });
+
+ const axiosConfig = getAxiosConfiguration({
+ proxyConfig: config,
+ connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } })
+ });
+
+ const resultForm = axiosConfig.data as FormData;
+ expect(resultForm).toBe(form);
+ const buffer = resultForm.getBuffer().toString('utf8');
+ expect(buffer).toContain('name="existing"');
+ expect(buffer).toContain('name="auth[key]"');
+ expect(buffer).toContain('my-secret-key');
+ });
+
+ const providerDepth3 = {
+ auth_mode: 'API_KEY' as const,
+ proxy: {
+ base_url: 'https://example.com',
+ body: {
+ auth: {
+ a: {
+ b: {
+ key: '${apiKey}'
+ }
+ }
+ }
+ }
+ }
+ };
+
+ it('injects a value nested three levels deep into an existing URLSearchParams body using bracket notation', () => {
+ const config = getDefaultProxy({
+ provider: providerDepth3,
+ method: 'POST',
+ data: new URLSearchParams({ existing: 'value' })
+ });
+
+ const axiosConfig = getAxiosConfiguration({
+ proxyConfig: config,
+ connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } })
+ });
+
+ expect((axiosConfig.data as URLSearchParams).toString()).toBe('existing=value&auth%5Ba%5D%5Bb%5D%5Bkey%5D=my-secret-key');
+ });
+
+ it('injects a value nested three levels deep into an existing FormData body using bracket notation', () => {
+ const form = new FormData();
+ form.append('existing', 'value');
+ const config = getDefaultProxy({
+ provider: providerDepth3,
+ method: 'POST',
+ data: form
+ });
+
+ const axiosConfig = getAxiosConfiguration({
+ proxyConfig: config,
+ connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } })
+ });
+
+ const resultForm = axiosConfig.data as FormData;
+ expect(resultForm).toBe(form);
+ const buffer = resultForm.getBuffer().toString('utf8');
+ expect(buffer).toContain('name="existing"');
+ expect(buffer).toContain('name="auth[a][b][key]"');
+ expect(buffer).toContain('my-secret-key');
+ });
+});
+
+describe('sage-member (real provider config)', () => {
+ const provider = getProvider('sage-member');
+
+ it('is defined in providers.yaml with the expected nested auth body', () => {
+ // Guards against providers.yaml drifting out of sync with the nested-body test scenarios below.
+ expect(provider).toMatchObject({
+ auth_mode: 'API_KEY',
+ proxy: {
+ base_url: 'https://www.promoplace.com/ws/ws.dll',
+ body: {
+ auth: {
+ acctId: '${connectionConfig.acctId}',
+ loginId: '${connectionConfig.loginId}',
+ key: '${apiKey}'
+ }
+ }
+ }
+ });
+ });
+
+ const connection = getTestConnection({
+ provider_config_key: 'sage-member',
+ credentials: { type: 'API_KEY', apiKey: 'deadbeefdeadbeefdeadbeefdeadbeef' },
+ connection_config: { acctId: '4353478', loginId: 'TestUser' }
+ });
+
+ it('injects account credentials into the auth object', () => {
+ const config = getDefaultProxy({ providerConfigKey: 'sage-member', providerName: 'sage-member', provider: provider! });
+
+ const result = buildProxyBody({ config, connection });
+
+ expect(result).toEqual({
+ auth: { acctId: '4353478', loginId: 'TestUser', key: 'deadbeefdeadbeefdeadbeefdeadbeef' }
+ });
+ });
+
+ it('drops the injected auth entirely when connectionConfig is missing (no leaked ${...} placeholders)', () => {
+ const config = getDefaultProxy({ providerConfigKey: 'sage-member', providerName: 'sage-member', provider: provider! });
+
+ const result = buildProxyBody({
+ config,
+ connection: getTestConnection({
+ provider_config_key: 'sage-member',
+ credentials: { type: 'API_KEY', apiKey: 'deadbeefdeadbeefdeadbeefdeadbeef' },
+ connection_config: {}
+ })
+ });
+
+ expect(result).toEqual({ auth: { key: 'deadbeefdeadbeefdeadbeefdeadbeef' } });
+ });
+
+ it('builds the full axios request: merges caller-supplied JSON body with the injected auth object, no auth header added', () => {
+ const config = getDefaultProxy({
+ providerConfigKey: 'sage-member',
+ providerName: 'sage-member',
+ provider: provider!,
+ endpoint: '/ConnectAPI',
+ method: 'POST',
+ data: { serviceId: 101, apiVer: 130 }
+ });
+
+ const axiosConfig = getAxiosConfiguration({ proxyConfig: config, connection });
+
+ expect(axiosConfig.url).toBe('https://www.promoplace.com/ws/ws.dll/ConnectAPI');
+ expect(axiosConfig.data).toEqual({
+ serviceId: 101,
+ apiVer: 130,
+ auth: { acctId: '4353478', loginId: 'TestUser', key: 'deadbeefdeadbeefdeadbeefdeadbeef' }
+ });
+ // API_KEY has no dedicated header template for this provider, so no default authorization header is added —
+ // the key only ever travels inside the JSON body's `auth` object.
+ expect(axiosConfig.headers).toEqual({});
+ });
+
+ it('does not inject the body for GET requests (methodDataAllowed excludes GET)', () => {
+ const config = getDefaultProxy({
+ providerConfigKey: 'sage-member',
+ providerName: 'sage-member',
+ provider: provider!,
+ endpoint: '/ConnectAPI',
+ method: 'GET'
+ });
+
+ const axiosConfig = getAxiosConfiguration({ proxyConfig: config, connection });
+
+ expect(axiosConfig.data).toBeUndefined();
+ });
});
diff --git a/packages/types/lib/providers/provider.ts b/packages/types/lib/providers/provider.ts
index 20df92ae93..db675b07a5 100644
--- a/packages/types/lib/providers/provider.ts
+++ b/packages/types/lib/providers/provider.ts
@@ -44,6 +44,9 @@ export interface SimplifiedJSONSchema {
visible_when?: { field: string; equals: string };
}
+// A `proxy.body` leaf is a string (interpolated) or a nested object of more leaves, to any depth.
+export type ProxyBodyValue = string | { [key: string]: ProxyBodyValue };
+
export interface BaseProvider {
display_name: string;
auth_mode: AuthModeType;
@@ -53,7 +56,7 @@ export interface BaseProvider {
headers?: Record;
connection_config?: Record;
query?: Record;
- body?: Record;
+ body?: Record;
retry?: RetryHeaderConfig;
decompress?: boolean;
forward_headers_on_redirect?: boolean;
diff --git a/packages/webapp/public/images/template-logos/sage-member.svg b/packages/webapp/public/images/template-logos/sage-member.svg
new file mode 100644
index 0000000000..42ce2180ea
--- /dev/null
+++ b/packages/webapp/public/images/template-logos/sage-member.svg
@@ -0,0 +1 @@
+
diff --git a/scripts/validation/providers/schema.json b/scripts/validation/providers/schema.json
index 404fd17f6b..075fdd0987 100644
--- a/scripts/validation/providers/schema.json
+++ b/scripts/validation/providers/schema.json
@@ -298,7 +298,15 @@
"body": {
"type": "object",
"additionalProperties": {
- "type": "string"
+ "oneOf": [
+ { "type": "string" },
+ {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ ]
}
},
"connection_config": {