mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(netsuite): add Oracle NetSuite integration (#6476)
* revise netsuite integration * fix(netsuite): align selector route with snowflake * test(netsuite): remove selector route coverage * test(netsuite): align coverage with snowflake * fix(netsuite): complete integration validation * refactor(netsuite): align integration with codebase patterns * test(netsuite): correct async job citation * fix(netsuite): address final audit findings * fix(netsuite): surface upsert/transform Location, relax task link check Oracle documents the Location response header for create and update, and both tools already require it. Upsert and transform also produce a record but Oracle documents no response headers for either, so they dropped the header entirely and the new record's ID was unreachable. Add a `resource-optional` location mode that captures Location when NetSuite sends it and never fails when it is absent, and wire it to upsert and transform along with their tool and block outputs. Async task discovery rejected the whole response if any task link carried a rel other than `self`, collapsing the picker into a 502. Oracle documents a `self` link per task but never guarantees it is the only one, so skip other relationships and fail only when no self link exists. Also use the shared `truncate` helper in the error sanitizer per the repo convention instead of an inline slice. * fix(netsuite): validate SuiteQL pages against their documented shape The shared collection-page validator required links, items, count, hasMore, offset, and totalResults on every 200, and a missing field turns a successful call into a reported failure. Oracle documents all six for record collections and SuiteAnalytics dataset pages, but its SuiteQL reference lists only links, count, offset, totalResults, and items. A documented SuiteQL response that omits hasMore would therefore have been rejected. Split out a suiteql-page validator that requires the five documented SuiteQL fields and type-checks hasMore only when the account returns it. Record collections and dataset pages keep requiring all six. * chore(netsuite): regenerate tool metadata after rebase on staging The rebase conflicted only in the generated tool-id, tool-metadata, and tool-output artifacts, which NetSuite and the newly landed LogRocket integration both extend. Regenerated from the merged registries: the result is staging's catalog plus the 27 NetSuite tools, with LogRocket's entries intact and no other tool changed. --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Bill Leoutsakos <billleoutsakos@Mac.localdomain> Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
co-authored by
Bill Leoutsakos
Bill Leoutsakos
Waleed Latif
parent
046302aa2a
commit
1424809614
@@ -8625,6 +8625,17 @@ export function NewRelicIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function NetSuiteIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 93.9 59.4' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fill='#C74634'
|
||||
d='M30.5 59.4H65c16.4-.4 29.3-14.1 28.9-30.4C93.5 13.1 80.7.4 65 0H30.5C14.1-.4.4 12.5 0 28.9s12.5 30 28.9 30.4c.5.1 1 .1 1.6.1m33.7-10.5h-33c-10.6-.3-18.9-9.2-18.6-19.8C13 19 21.1 10.8 31.2 10.5h33c10.6-.3 19.5 8 19.8 18.6s-8 19.5-18.6 19.8z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WizaIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 51 49' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
|
||||
@@ -156,6 +156,7 @@ import {
|
||||
MongoDBIcon,
|
||||
MySQLIcon,
|
||||
Neo4jIcon,
|
||||
NetSuiteIcon,
|
||||
NeverBounceIcon,
|
||||
NewRelicIcon,
|
||||
NotionIcon,
|
||||
@@ -440,6 +441,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
mongodb: MongoDBIcon,
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
netsuite: NetSuiteIcon,
|
||||
neverbounce: NeverBounceIcon,
|
||||
new_relic: NewRelicIcon,
|
||||
notion: NotionIcon,
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
"mongodb",
|
||||
"mysql",
|
||||
"neo4j",
|
||||
"netsuite",
|
||||
"netsuite-service-account",
|
||||
"neverbounce",
|
||||
"new_relic",
|
||||
"notion",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Oracle NetSuite Service Account
|
||||
description: Configure certificate-based OAuth 2.0 client credentials once and reuse them across NetSuite blocks
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
Oracle NetSuite authenticates SuiteTalk machine-to-machine clients with a signed JWT and a certificate mapping. Sim stores the SuiteTalk URL, Client ID, Certificate ID, and private key as one encrypted service-account credential. Every NetSuite block stores only that credential's ID; Sim signs the assertion and injects the short-lived access token on the server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A dedicated NetSuite integration role with **REST Web Services** and **Log in using OAuth 2.0 Access Tokens**, plus the record and SuiteAnalytics permissions your workflows require.
|
||||
- An integration record with **Client Credentials (Machine to Machine) Grant** and the **REST Web Services** scope enabled.
|
||||
- A 3072- or 4096-bit RSA key pair, or a P-256, P-384, or P-521 EC key pair, and a public certificate generated through your organization's certificate process.
|
||||
- Access to **OAuth 2.0 Client Credentials (M2M) Setup** and **Company URLs** in the target NetSuite environment.
|
||||
|
||||
<Callout type="warn">
|
||||
Create and map credentials separately in production, sandbox, and Release Preview. A sandbox refresh removes its OAuth 2.0 client-credential mappings, and each environment has a different authoritative SuiteTalk URL.
|
||||
</Callout>
|
||||
|
||||
## Configure NetSuite
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
In **Setup → Company → Enable Features**, enable **REST Web Services** and **OAuth 2.0**. Enable **SuiteAnalytics Workbook** if workflows will use datasets.
|
||||
</Step>
|
||||
<Step>
|
||||
Create a dedicated integration role and grant only the record, transaction, subsidiary, and analytics permissions the workflows need. Avoid using Administrator.
|
||||
</Step>
|
||||
<Step>
|
||||
Under **Setup → Integration → Manage Integrations**, create or edit an integration, enable the machine-to-machine client-credentials grant and REST Web Services scope, then save its **Client ID**.
|
||||
</Step>
|
||||
<Step>
|
||||
Upload only the public certificate under **OAuth 2.0 Client Credentials (M2M) Setup**. Map it to the integration, entity, and dedicated role, then save the generated **Certificate ID**. Keep the private key outside NetSuite.
|
||||
</Step>
|
||||
<Step>
|
||||
Under **Setup → Company → Company Information → Company URLs**, copy the complete **SuiteTalk (SOAP and REST Web Services)** URL for this environment.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Oracle documents the [role setup](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771510070.html), [integration record](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771733782.html), [certificate requirements](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/subsect_162755332391.html), and [client-credential mapping](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_162686838198.html).
|
||||
|
||||
## Add the Credential to Sim
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Add an **Oracle NetSuite** block to a workflow and open the **NetSuite Account** dropdown.
|
||||
</Step>
|
||||
<Step>
|
||||
Choose to add a credential, then enter the authoritative SuiteTalk URL, Client ID, Certificate ID, and PEM private key that matches the uploaded certificate.
|
||||
</Step>
|
||||
<Step>
|
||||
Save the credential. Sim validates the URL and key policy, signs a client assertion, and performs a real token exchange before storing the encrypted credential.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The private key is encrypted at rest and is never returned through the token endpoint or injected into a workflow tool. At execution time, Sim resolves the selected credential to a short-lived bearer token and the normalized SuiteTalk origin.
|
||||
|
||||
## Use Pickers and Manual Values
|
||||
|
||||
Selecting the credential enables these account-backed fields:
|
||||
|
||||
| Field | Lists | Additional scope |
|
||||
| --- | --- | --- |
|
||||
| Record Type | Up to 1,000 record types visible in the metadata catalog | credential |
|
||||
| Async Task | Up to 100 tasks belonging to a known batch job | job ID |
|
||||
|
||||
Picker results reflect the selected role's permissions. Switch any picker to Advanced mode to type an identifier or reference an upstream output. Enter SuiteAnalytics dataset IDs manually after finding them with **List SuiteAnalytics Datasets**. Record IDs, job IDs, transform targets, actions, fields, forms, subresources, and relationship IDs also remain manual because NetSuite does not expose bounded universal listings that would make those choices complete and reliable.
|
||||
|
||||
**Create Record** without `replace` returns HTTP 204 with no response body; with `replace`, it returns HTTP 201 and the created record object. Both responses expose NetSuite's validated `location`. The `replace` option applies to create and update, not upsert.
|
||||
|
||||
## Rotate or Revoke
|
||||
|
||||
To rotate a certificate, create and upload the replacement certificate and create its new NetSuite mapping. Then reconnect the existing Sim credential by re-entering all four required fields: SuiteTalk URL, Client ID, the new Certificate ID, and the replacement private key. Reconnecting changes the encrypted credential fingerprint, so later executions mint against the new material.
|
||||
|
||||
After confirming workflows succeed, remove the old certificate mapping in NetSuite so the previous certificate can no longer mint tokens. Deleting a Sim credential removes its workflow bindings but does not revoke the corresponding NetSuite certificate mapping.
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Why can’t I paste the key into each block?", answer: "The signing key is long-lived account material. Keeping it in one encrypted credential avoids duplicating it in workflow state and lets every block reuse the same verified account connection." },
|
||||
{ question: "Why is my picker empty?", answer: "The pickers use the selected integration role. Confirm that role can access the metadata catalog or async job, then use Advanced mode when you already know an identifier." },
|
||||
{ question: "Can one credential access production and sandbox?", answer: "No. Each environment has its own SuiteTalk URL and client-certificate mapping. Create one Sim credential per environment." },
|
||||
{ question: "Why did the credential stop working after a sandbox refresh?", answer: "NetSuite clears OAuth 2.0 client-credential mappings during a sandbox refresh. Recreate the mapping and reconnect the Sim credential with its new Certificate ID." },
|
||||
{ question: "Does deleting the credential revoke it in NetSuite?", answer: "No. Remove the certificate mapping in NetSuite as well when decommissioning or responding to a compromise." },
|
||||
]} />
|
||||
@@ -0,0 +1,653 @@
|
||||
---
|
||||
title: Oracle NetSuite
|
||||
description: Manage NetSuite records, queries, datasets, batches, and async jobs
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="netsuite"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Oracle NetSuite](https://www.netsuite.com/) is a cloud ERP platform for financials, order management, inventory, procurement, CRM, and analytics. Sim connects through SuiteTalk REST Web Services using NetSuite's OAuth 2.0 client-credentials flow; it does not require a RESTlet or a user-interactive login.
|
||||
|
||||
## Before you connect
|
||||
|
||||
1. In **Setup → Company → Enable Features**, enable **REST Web Services** and **OAuth 2.0**. Enable **SuiteAnalytics Workbook** if you will list or execute datasets.
|
||||
2. Use a dedicated integration role. Grant it **REST Web Services** and **Log in using OAuth 2.0 Access Tokens**, plus the record, transaction, and subsidiary permissions needed by your workflows. Dataset access also requires **SuiteAnalytics Workbook** permission and access to the selected datasets. Oracle recommends a purpose-built role instead of Administrator. See [Set Up OAuth 2.0 Roles](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771510070.html) and [Prerequisites and Setup for REST Web Services](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_5085602973.html).
|
||||
3. Create or edit an integration record under **Setup → Integration → Manage Integrations**. Enable **Client Credentials (Machine to Machine) Grant** and the **REST Web Services** OAuth 2.0 scope, then save the **Client ID**. See [Create Integration Records for Applications to Use OAuth 2.0](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771733782.html).
|
||||
4. Create either a 3072- or 4096-bit RSA key pair or a P-256, P-384, or P-521 EC key pair and certificate using your organization's certificate process. Sim signs RSA assertions with PS256 and selects ES256, ES384, or ES512 for the corresponding EC curve. Keep the PEM private key secure; upload only the public certificate to NetSuite, and plan renewal because NetSuite limits certificate validity to two years. See [OAuth 2.0 Client Credentials Certificate Conditions](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/subsect_162755332391.html).
|
||||
5. Go to **Setup → Integration → Manage Authentication → OAuth 2.0 Client Credentials (M2M) Setup**. Create a mapping for the integration's entity, role, application, and public certificate, then save the generated **Certificate ID**. Oracle requires this mapping separately in production, sandbox, and Release Preview, and a sandbox refresh clears it. See [OAuth 2.0 Client Credentials Setup](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_162686838198.html).
|
||||
6. In **Setup → Company → Company Information → Company URLs**, copy the **SuiteTalk (SOAP and REST Web Services)** URL for the current environment. Production, sandbox, and Release Preview environments each have their own authoritative URL.
|
||||
7. Add a NetSuite block, open **NetSuite Account**, and create a reusable credential with the SuiteTalk URL, Client ID, Certificate ID, and matching private key. See [Oracle NetSuite service-account setup](/integrations/netsuite-service-account) for the complete setup and rotation workflow.
|
||||
|
||||
## Usage notes
|
||||
|
||||
- Record fields and supported actions vary by account, enabled features, custom records, forms, role, and permissions. Use **List Record Types** and **Get Record Metadata** before constructing create, update, upsert, action, or transform bodies. Sim intentionally accepts JSON for these dynamic record shapes instead of guessing a fixed schema.
|
||||
- Select the stored NetSuite account once per block. Record Type and known-job Async Task fields use account-backed pickers; switch a field to Advanced mode to type or reference an identifier that is not present in the bounded picker result. Enter SuiteAnalytics dataset IDs manually after finding them with **List SuiteAnalytics Datasets**.
|
||||
- **Create Record** without `replace` returns NetSuite's HTTP 204 response with no body; with `replace`, it returns HTTP 201 and the created record object. Both cases expose the validated `location` returned by NetSuite. The `replace` option applies to create and update, not upsert.
|
||||
- Paged operations return one page only. The default limit is 100, the maximum is 1,000, and the offset must be a non-negative multiple of the limit. Sim never fetches later pages automatically. Requests must stay within NetSuite's first 100,000 results and first 1,000 pages.
|
||||
- Homogeneous batch operations accept 1–100 records of one record type and always run asynchronously. NetSuite processes records in parallel, and individual tasks can fail independently; submission is not an all-or-none transaction. Preserve the returned `location` or `jobId`, use **Get Async Status** with **Job Status** until the job completes, choose **List Tasks** to collect task IDs, check each ID with **Task Status**, then use completed IDs with **Get Async Operation Result**. Canceling or timing out the local Sim request does not cancel a batch that NetSuite has already accepted.
|
||||
- Sim gives the OAuth token exchange and each SuiteTalk request up to 30 seconds. A timed-out request fails locally, but a mutation that NetSuite already accepted may still finish remotely.
|
||||
- Sim limits each materialized request body and successful SuiteTalk response to 16 MiB. Request JSON is also limited to 100 levels of nesting and 100,000 JSON values. Split work into smaller pages or batches when a request or response would exceed these ceilings, even if NetSuite would otherwise accept the payload.
|
||||
- When attaching a contact with a role, provide either the role's internal ID or external ID, not both. File attachments do not use a contact role.
|
||||
- **Attach/Detach**, homogeneous batch operations, **Get Record Form**, and **Get Select Options** require a NetSuite 2026.1-compatible account. Oracle introduced these SuiteTalk REST capabilities in [NetSuite 2026.1](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_N3950559.html).
|
||||
- **Get Governance Limits** returns data only for roles allowed by NetSuite; Oracle documents Administrator access for that operation.
|
||||
- This integration does not include a trigger. SuiteTalk has no generic API for registering record-change webhooks; polling or customer-deployed SuiteScript requires a separate design.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect a reusable Oracle NetSuite service-account credential to SuiteTalk REST Web Services. Read and write account-specific records, execute SuiteQL and SuiteAnalytics datasets, run asynchronous record batches, inspect metadata, and monitor async jobs.
|
||||
|
||||
|
||||
|
||||
## Actions
|
||||
|
||||
### NetSuite List/Search Records
|
||||
|
||||
List one page of a NetSuite record collection, optionally filtered with a q expression.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `q` | string | No | NetSuite record collection filter expression |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | One documented NetSuite collection page |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Matching record references in this page |
|
||||
| ↳ `id` | string | NetSuite record ID |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the record |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `count` | number | Number of items in this page |
|
||||
| ↳ `hasMore` | boolean | Whether another page is available |
|
||||
| ↳ `offset` | number | Offset of this page |
|
||||
| ↳ `totalResults` | number | Total number of matching items |
|
||||
|
||||
### NetSuite Get Record
|
||||
|
||||
Retrieve one NetSuite record by internal or external ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `fields` | string | No | Comma-separated record fields to return |
|
||||
| `expand` | string | No | Comma-separated resources to expand when supported by the record metadata |
|
||||
| `expandSubResources` | boolean | No | Whether to expand sublists and subrecords in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Create Record
|
||||
|
||||
Create a NetSuite record using the account-specific record metadata schema.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `body` | json | Yes | Record fields matching the account-specific NetSuite metadata schema |
|
||||
| `replace` | string | No | Comma-separated sublists whose default lines should be replaced |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for standard HTTP 204 creation; replacement creation can return the documented HTTP 201 post-state object |
|
||||
| `location` | string | Newly created record URL from the Location response header |
|
||||
|
||||
### NetSuite Update Record
|
||||
|
||||
Update fields on an existing NetSuite record with PATCH.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `body` | json | Yes | Record fields matching the account-specific NetSuite metadata schema |
|
||||
| `replace` | string | No | Comma-separated sublists whose existing lines should be replaced |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
| `location` | string | Updated record URL from the Location response header |
|
||||
|
||||
### NetSuite Upsert Record
|
||||
|
||||
Create or update a NetSuite record by external ID with PUT.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `externalId` | string | Yes | External ID without the eid: prefix |
|
||||
| `body` | json | Yes | Record fields matching the account-specific NetSuite metadata schema |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
| `location` | string | URL of the created or updated record, when NetSuite returns a Location header |
|
||||
|
||||
### NetSuite Delete Record
|
||||
|
||||
Delete one NetSuite record by internal or external ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
|
||||
### NetSuite Get Subresource
|
||||
|
||||
Retrieve a record sublist, subrecord, referenced record, or nested subresource.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `subresourcePath` | string | Yes | Slash-separated subresource path, such as item or item/1/inventoryDetail |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Get Record Form
|
||||
|
||||
Return a prepopulated create form, or an edit form when a record ID is supplied.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | No | Existing record ID; omit to request a create form |
|
||||
| `body` | json | No | Record fields matching the account-specific NetSuite metadata schema |
|
||||
| `fields` | string | No | Comma-separated record fields to return |
|
||||
| `expand` | string | No | Comma-separated resources to expand when supported by the record metadata |
|
||||
| `expandSubResources` | boolean | No | Whether to expand sublists and subrecords in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Get Select Options
|
||||
|
||||
Retrieve valid select values for one or more fields on a new or existing record.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | No | Existing record ID; omit to evaluate options for a new record |
|
||||
| `fields` | string | Yes | Comma-separated select field IDs |
|
||||
| `q` | string | No | Optional select-option filter using CONTAIN, IS, or START_WITH |
|
||||
| `body` | json | No | Record fields matching the account-specific NetSuite metadata schema |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Select-options response keyed by requested field ID; each dynamic field contains an _selectOptions object with links, items, count, offset, hasMore, and totalResults |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
|
||||
### NetSuite Attach Record or File
|
||||
|
||||
Attach a contact or file to another NetSuite record.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `relatedType` | string | Yes | Related resource type: contact or file |
|
||||
| `relatedId` | string | Yes | Internal ID, or external ID prefixed with eid:, of the contact or file |
|
||||
| `roleId` | string | No | Optional contact role internal ID |
|
||||
| `roleExternalId` | string | No | Optional contact role external ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
|
||||
### NetSuite Detach Record or File
|
||||
|
||||
Detach a contact or file from another NetSuite record.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `relatedType` | string | Yes | Related resource type: contact or file |
|
||||
| `relatedId` | string | Yes | Internal ID, or external ID prefixed with eid:, of the contact or file |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
|
||||
### NetSuite Execute Record Action
|
||||
|
||||
Execute a supported NetSuite record action such as approve, reject, or confirm.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `action` | string | Yes | NetSuite record action ID without the @ prefix |
|
||||
| `body` | json | No | Parameters accepted by the selected NetSuite record action |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Documented NetSuite record-action response |
|
||||
| ↳ `result` | boolean | True when NetSuite completed the record action |
|
||||
|
||||
### NetSuite Transform Record
|
||||
|
||||
Transform a supported source record into another NetSuite record type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `targetRecordType` | string | Yes | Target record type supported by the source record metadata |
|
||||
| `body` | json | No | Record fields matching the account-specific NetSuite metadata schema |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
| `location` | string | URL of the transformed record, when NetSuite returns a Location header |
|
||||
|
||||
### NetSuite Batch Get Records
|
||||
|
||||
Submit an asynchronous request to retrieve up to 100 records of one type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `ids` | string | Yes | Up to 100 comma-separated internal IDs or eid: external-ID references |
|
||||
| `fields` | string | No | Comma-separated record fields to return |
|
||||
| `expand` | string | No | Comma-separated resources to expand when supported by the record metadata |
|
||||
| `expandSubResources` | boolean | No | Whether to expand sublists and subrecords in the response |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the asynchronous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Batch Create Records
|
||||
|
||||
Submit an asynchronous batch that creates up to 100 records of one type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `items` | array | Yes | Array of 1-100 records matching the account-specific metadata schema |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Batch Update Records
|
||||
|
||||
Submit an asynchronous batch that updates up to 100 records of one type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `items` | array | Yes | Array of 1-100 records; every item must include an internal or external ID |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Batch Upsert Records
|
||||
|
||||
Submit an asynchronous batch that creates or updates up to 100 records by external ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `items` | array | Yes | Array of 1-100 records; every item must include externalId |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Batch Delete Records
|
||||
|
||||
Submit an asynchronous request to delete up to 100 records of one type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `ids` | string | Yes | Up to 100 comma-separated internal IDs or eid: external-ID references |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Execute SuiteQL
|
||||
|
||||
Execute one page of a SuiteQL query through SuiteTalk REST web services.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `query` | string | Yes | SuiteQL SELECT query; use a complete unique ORDER BY when retrieving multiple pages |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | One documented NetSuite collection page |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Items in this page; item fields depend on the record, query, or dataset |
|
||||
| ↳ `count` | number | Number of items in this page |
|
||||
| ↳ `hasMore` | boolean | Whether another page is available |
|
||||
| ↳ `offset` | number | Offset of this page |
|
||||
| ↳ `totalResults` | number | Total number of matching items |
|
||||
|
||||
### NetSuite List SuiteAnalytics Datasets
|
||||
|
||||
List one page of SuiteAnalytics Workbook datasets available to the authenticated role.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | One documented NetSuite collection page |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Items in this page; item fields depend on the record, query, or dataset |
|
||||
| ↳ `count` | number | Number of items in this page |
|
||||
| ↳ `hasMore` | boolean | Whether another page is available |
|
||||
| ↳ `offset` | number | Offset of this page |
|
||||
| ↳ `totalResults` | number | Total number of matching items |
|
||||
|
||||
### NetSuite Execute SuiteAnalytics Dataset
|
||||
|
||||
Execute one page of a standard or custom SuiteAnalytics Workbook dataset.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `datasetId` | string | Yes | SuiteAnalytics dataset script ID |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | One documented NetSuite collection page |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Items in this page; item fields depend on the record, query, or dataset |
|
||||
| ↳ `count` | number | Number of items in this page |
|
||||
| ↳ `hasMore` | boolean | Whether another page is available |
|
||||
| ↳ `offset` | number | Offset of this page |
|
||||
| ↳ `totalResults` | number | Total number of matching items |
|
||||
|
||||
### NetSuite List Record Types
|
||||
|
||||
List record types exposed to the authenticated role by the REST metadata catalog.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite REST metadata catalog |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Record types exposed to the authenticated role |
|
||||
| ↳ `name` | string | REST record type script ID |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `mediaType` | string | Media type advertised for the linked metadata resource |
|
||||
|
||||
### NetSuite Get Record Metadata
|
||||
|
||||
Retrieve account-specific metadata for one NetSuite record type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `format` | string | No | Metadata representation: default, openapi, or json_schema |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Get Async Status
|
||||
|
||||
Retrieve job status, list job tasks, or retrieve one task status.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `jobId` | string | Yes | Asynchronous job ID |
|
||||
| `view` | string | No | Retrieve job status, list tasks for the job, or retrieve one task status |
|
||||
| `taskId` | string | No | Task ID; required when view is task |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Documented NetSuite asynchronous job, task collection, or task status |
|
||||
| ↳ `completed` | boolean | Whether processing has completed |
|
||||
| ↳ `endTime` | string | Task completion time |
|
||||
| ↳ `id` | string | Asynchronous job or task ID |
|
||||
| ↳ `progress` | string | Current task progress state |
|
||||
| ↳ `startTime` | string | Task start time |
|
||||
| ↳ `count` | number | Number of task collection entries returned |
|
||||
| ↳ `items` | array | Collection entries containing links to one or more asynchronous tasks |
|
||||
| ↳ `links` | array | Links to individual tasks |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `links` | array | HATEOAS links for the job or task collection |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `task` | object | Link container for the tasks belonging to this asynchronous job |
|
||||
| ↳ `links` | array | Links to the job task collection |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
|
||||
### NetSuite Get Async Operation Result
|
||||
|
||||
Retrieve the provider response for one task within a completed asynchronous job.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `jobId` | string | Yes | Asynchronous job ID |
|
||||
| `taskId` | string | Yes | Task ID within the asynchronous job |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Result payload for the submitted asynchronous operation; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Get Server Time
|
||||
|
||||
Retrieve the current UTC time from the NetSuite server.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite server time response |
|
||||
| ↳ `serverTime` | string | Current NetSuite server time in UTC |
|
||||
|
||||
### NetSuite Get Governance Limits
|
||||
|
||||
Retrieve REST web-services concurrency limits for the NetSuite account and integration; NetSuite requires an Administrator role.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Documented NetSuite governance limits |
|
||||
| ↳ `accountConcurrencyLimit` | number | Account concurrency limit |
|
||||
| ↳ `accountUnallocatedConcurrencyLimit` | number | Account concurrency not allocated to integrations |
|
||||
| ↳ `integrationConcurrencyLimit` | number | Concurrency allocated to this integration |
|
||||
| ↳ `integrationLimitType` | string | Limit assignment: integrationSpecific, accountLimit, or internal |
|
||||
@@ -202,6 +202,36 @@ describe('OAuth Token API Routes', () => {
|
||||
})
|
||||
|
||||
describe('service account path', () => {
|
||||
it('threads the NetSuite SuiteTalk instance URL into the token response', async () => {
|
||||
const instanceUrl = 'https://1234567.suitetalk.api.netsuite.com'
|
||||
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
|
||||
accountId: '',
|
||||
credentialId: 'netsuite-credential-id',
|
||||
credentialType: 'service_account',
|
||||
providerId: 'netsuite-service-account',
|
||||
workspaceId: 'workspace-id',
|
||||
usedCredentialTable: true,
|
||||
})
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'session',
|
||||
requesterUserId: 'test-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
})
|
||||
mockResolveServiceAccountToken.mockResolvedValueOnce({
|
||||
accessToken: 'netsuite-token',
|
||||
instanceUrl,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { credentialId: 'netsuite-credential-id' })
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toMatchObject({ accessToken: 'netsuite-token', instanceUrl })
|
||||
})
|
||||
|
||||
it('should thread authStyle from the resolver into the response', async () => {
|
||||
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
|
||||
accountId: '',
|
||||
|
||||
@@ -26,7 +26,10 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight'
|
||||
import { ZOOM_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import {
|
||||
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
} from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
import {
|
||||
getCredential,
|
||||
@@ -526,6 +529,33 @@ describe('OAuth Utils', () => {
|
||||
expect(mockMinter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards NetSuite certificate material and caches its SuiteTalk instance URL', async () => {
|
||||
const credId = 'ccsa-netsuite-certificate'
|
||||
const fields = {
|
||||
clientId: 'netsuite-client',
|
||||
certificateId: 'certificate-id',
|
||||
orgId: 'https://1234567.suitetalk.api.netsuite.com',
|
||||
privateKey: 'private-key',
|
||||
}
|
||||
mockDecryptSecret.mockResolvedValueOnce({ decrypted: JSON.stringify(fields) })
|
||||
mockCredentialRow(ENCRYPTED_KEY_A)
|
||||
mockMinter.mockResolvedValueOnce({
|
||||
accessToken: 'netsuite-token',
|
||||
expiresInSeconds: 3600,
|
||||
instanceUrl: fields.orgId,
|
||||
})
|
||||
|
||||
const first = await resolveServiceAccountToken(credId, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)
|
||||
|
||||
expect(first).toEqual({ accessToken: 'netsuite-token', instanceUrl: fields.orgId })
|
||||
expect(mockMinter).toHaveBeenCalledWith(fields, { skipIdentity: true })
|
||||
|
||||
mockCredentialRow(ENCRYPTED_KEY_A)
|
||||
const cached = await resolveServiceAccountToken(credId, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)
|
||||
expect(cached).toEqual(first)
|
||||
expect(mockMinter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-mints when remaining validity is below the 5-minute serve floor', async () => {
|
||||
const credId = 'ccsa-ttl-floor'
|
||||
mockCredentialRow(ENCRYPTED_KEY_A)
|
||||
|
||||
@@ -96,6 +96,7 @@ export const PUT = withRouteHandler(
|
||||
domain: body.domain,
|
||||
clientId: body.clientId,
|
||||
clientSecret: body.clientSecret,
|
||||
certificateId: body.certificateId,
|
||||
orgId: body.orgId,
|
||||
dataCenter: body.dataCenter,
|
||||
authMethod: body.authMethod,
|
||||
|
||||
@@ -188,6 +188,58 @@ describe('POST /api/credentials', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('threads NetSuite certificate credentials through the create contract', async () => {
|
||||
mockVerifyAndBuildServiceAccountSecret.mockResolvedValueOnce({
|
||||
providerId: 'netsuite-service-account',
|
||||
encryptedServiceAccountKey: 'encrypted-netsuite-blob',
|
||||
displayName: 'Oracle NetSuite 1234567',
|
||||
auditMetadata: { principalKind: 'tenant', principalId: '1234567' },
|
||||
principal: { kind: 'tenant', id: '1234567' },
|
||||
})
|
||||
queueTableRows(credential, [])
|
||||
queueTableRows(credential, [])
|
||||
queueTableRows(credential, [
|
||||
{
|
||||
id: 'credential-netsuite',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
type: 'service_account',
|
||||
displayName: 'Oracle NetSuite 1234567',
|
||||
description: null,
|
||||
providerId: 'netsuite-service-account',
|
||||
accountId: null,
|
||||
envKey: null,
|
||||
envOwnerUserId: null,
|
||||
encryptedServiceAccountKey: 'encrypted-netsuite-blob',
|
||||
createdBy: 'user-1',
|
||||
createdAt: new Date('2026-08-11T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-11T00:00:00.000Z'),
|
||||
},
|
||||
])
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
type: 'service_account',
|
||||
providerId: 'netsuite-service-account',
|
||||
orgId: 'https://1234567.suitetalk.api.netsuite.com',
|
||||
clientId: 'netsuite-client-id',
|
||||
certificateId: 'netsuite-certificate-id',
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----key',
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith(
|
||||
'netsuite-service-account',
|
||||
expect.objectContaining({
|
||||
orgId: 'https://1234567.suitetalk.api.netsuite.com',
|
||||
clientId: 'netsuite-client-id',
|
||||
certificateId: 'netsuite-certificate-id',
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----key',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('maps a verification failure to a 400 with the validation code', async () => {
|
||||
mockVerifyAndBuildServiceAccountSecret.mockRejectedValueOnce(
|
||||
new TokenServiceAccountValidationError('invalid_credentials', 400, {
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
|
||||
|
||||
const {
|
||||
mockAuthorizeCredentialUse,
|
||||
mockCheckSessionOrInternalAuth,
|
||||
mockGetAsyncStatus,
|
||||
mockListRecordTypes,
|
||||
mockResolveCredentialAccessToken,
|
||||
mockResolveOAuthAccountId,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthorizeCredentialUse: vi.fn(),
|
||||
mockCheckSessionOrInternalAuth: vi.fn(),
|
||||
mockGetAsyncStatus: vi.fn(),
|
||||
mockListRecordTypes: vi.fn(),
|
||||
mockResolveCredentialAccessToken: vi.fn(),
|
||||
mockResolveOAuthAccountId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/credential-access', () => ({
|
||||
authorizeCredentialUse: mockAuthorizeCredentialUse,
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
vi.mock('@/lib/oauth/credential-service', () => ({
|
||||
resolveCredentialAccessToken: mockResolveCredentialAccessToken,
|
||||
resolveOAuthAccountId: mockResolveOAuthAccountId,
|
||||
}))
|
||||
vi.mock('@/tools/netsuite/get_async_status', () => ({
|
||||
netsuiteGetAsyncStatusTool: { directExecution: mockGetAsyncStatus },
|
||||
}))
|
||||
vi.mock('@/tools/netsuite/list_record_types', () => ({
|
||||
netsuiteListRecordTypesTool: { directExecution: mockListRecordTypes },
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/tools/netsuite/objects/route'
|
||||
|
||||
const URL = 'http://localhost:3000/api/tools/netsuite/objects'
|
||||
const ORIGIN = 'https://1234567.suitetalk.api.netsuite.com'
|
||||
const RECORD_TYPES_BODY = {
|
||||
credential: 'credential-1',
|
||||
workflowId: 'workflow-1',
|
||||
kind: 'record_types',
|
||||
} as const
|
||||
|
||||
function request(
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
headers: Record<string, string> = {}
|
||||
): NextRequest {
|
||||
return new NextRequest(URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
async function json(response: Response): Promise<Record<string, unknown>> {
|
||||
return (await response.json()) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function success(data: unknown) {
|
||||
return { success: true, output: { status: 200, data } }
|
||||
}
|
||||
|
||||
function failure(status?: number) {
|
||||
return { success: false, output: { status, data: null, error: 'provider secret' } }
|
||||
}
|
||||
|
||||
describe('POST /api/tools/netsuite/objects', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockAuthorizeCredentialUse.mockResolvedValue({
|
||||
ok: true,
|
||||
credentialOwnerUserId: 'owner-1',
|
||||
resolvedCredentialId: 'resolved-credential-1',
|
||||
credentialType: 'service_account',
|
||||
})
|
||||
mockResolveOAuthAccountId.mockResolvedValue({
|
||||
credentialType: 'service_account',
|
||||
providerId: 'netsuite-service-account',
|
||||
})
|
||||
mockResolveCredentialAccessToken.mockResolvedValue({
|
||||
accessToken: 'short-lived-token',
|
||||
instanceUrl: ORIGIN,
|
||||
})
|
||||
mockListRecordTypes.mockResolvedValue(success({ items: [{ name: 'customer' }] }))
|
||||
mockGetAsyncStatus.mockResolvedValue(success({ items: [] }))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unauthenticated malformed input', '{not-json', {}, 'Unauthorized'],
|
||||
[
|
||||
'API-key caller',
|
||||
RECORD_TYPES_BODY,
|
||||
{ 'x-api-key': 'external-api-key' },
|
||||
'API key access not allowed for this endpoint',
|
||||
],
|
||||
])('authenticates before parsing and rejects an %s', async (_label, body, headers, error) => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false, error })
|
||||
|
||||
const response = await POST(request(body, undefined, headers), {})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await json(response)).toMatchObject({ error })
|
||||
expect(mockCheckSessionOrInternalAuth).toHaveBeenCalledWith(expect.any(NextRequest), {
|
||||
requireWorkflowId: true,
|
||||
})
|
||||
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['invalid JSON', '{not-json', 400],
|
||||
['removed dataset selector kind', { ...RECORD_TYPES_BODY, kind: 'datasets' }, 400],
|
||||
[
|
||||
'missing async job',
|
||||
{ credential: 'credential-1', workflowId: 'workflow-1', kind: 'async_tasks' },
|
||||
400,
|
||||
],
|
||||
['unexpected record-type job', { ...RECORD_TYPES_BODY, jobId: 'job-1' }, 400],
|
||||
['oversized body', { ...RECORD_TYPES_BODY, padding: 'x'.repeat(17 * 1024) }, 413],
|
||||
])('rejects %s', async (_label, body, expectedStatus) => {
|
||||
const response = await POST(request(body), {})
|
||||
|
||||
expect(response.status).toBe(expectedStatus)
|
||||
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('authorizes the exact credential and injects only resolved provider authentication', async () => {
|
||||
const controller = new AbortController()
|
||||
const discoveryRequest = request(RECORD_TYPES_BODY, controller.signal)
|
||||
const response = await POST(discoveryRequest, {})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(expect.any(NextRequest), {
|
||||
credentialId: 'credential-1',
|
||||
workflowId: 'workflow-1',
|
||||
callerUserId: 'user-1',
|
||||
})
|
||||
expect(mockResolveOAuthAccountId).toHaveBeenCalledWith('resolved-credential-1')
|
||||
expect(mockResolveCredentialAccessToken).toHaveBeenCalledWith(
|
||||
'resolved-credential-1',
|
||||
'owner-1',
|
||||
expect.any(String)
|
||||
)
|
||||
expect(mockListRecordTypes).toHaveBeenCalledWith(
|
||||
{
|
||||
oauthCredential: 'resolved-credential-1',
|
||||
accessToken: 'short-lived-token',
|
||||
instanceUrl: ORIGIN,
|
||||
},
|
||||
discoveryRequest.signal
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'credential authorization',
|
||||
() => mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Forbidden' }),
|
||||
403,
|
||||
],
|
||||
[
|
||||
'non-service-account credential',
|
||||
() =>
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
credentialOwnerUserId: 'owner-1',
|
||||
credentialType: 'oauth',
|
||||
}),
|
||||
400,
|
||||
],
|
||||
[
|
||||
'wrong service-account provider',
|
||||
() =>
|
||||
mockResolveOAuthAccountId.mockResolvedValueOnce({
|
||||
credentialType: 'service_account',
|
||||
providerId: 'snowflake-service-account',
|
||||
}),
|
||||
400,
|
||||
],
|
||||
])('fails closed on invalid %s', async (_label, arrange, expectedStatus) => {
|
||||
arrange()
|
||||
|
||||
const response = await POST(request(RECORD_TYPES_BODY), {})
|
||||
|
||||
expect(response.status).toBe(expectedStatus)
|
||||
expect(mockListRecordTypes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches async task discovery with the exact job, view, auth, and signal', async () => {
|
||||
const body = { ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job 1' } as const
|
||||
const task2 = '/services/rest/async/v1/job/job%201/task/task-2'
|
||||
const task1 = `${ORIGIN}/services/rest/async/v1/job/job%201/task/task-1`
|
||||
mockGetAsyncStatus.mockResolvedValueOnce(
|
||||
success({
|
||||
items: [
|
||||
{ links: [{ rel: 'self', href: task2 }] },
|
||||
{
|
||||
links: [
|
||||
{ rel: 'self', href: task1 },
|
||||
{ rel: 'self', href: task1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const discoveryRequest = request(body)
|
||||
const response = await POST(discoveryRequest, {})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockGetAsyncStatus).toHaveBeenCalledWith(
|
||||
{
|
||||
oauthCredential: 'resolved-credential-1',
|
||||
accessToken: 'short-lived-token',
|
||||
instanceUrl: ORIGIN,
|
||||
jobId: 'job 1',
|
||||
view: 'tasks',
|
||||
},
|
||||
discoveryRequest.signal
|
||||
)
|
||||
expect(await json(response)).toEqual({
|
||||
objects: [
|
||||
{ id: 'task-1', label: 'task-1', detail: null },
|
||||
{ id: 'task-2', label: 'task-2', detail: null },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('skips non-self task link relationships instead of failing discovery', async () => {
|
||||
const href = `${ORIGIN}/services/rest/async/v1/job/job-1/task/task-1`
|
||||
mockGetAsyncStatus.mockResolvedValueOnce(
|
||||
success({
|
||||
items: [
|
||||
{
|
||||
links: [
|
||||
{ rel: 'canonical', href: `${ORIGIN}/services/rest/async/v1/job/job-1` },
|
||||
{ rel: 'self', href },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await json(response)).toEqual({
|
||||
objects: [{ id: 'task-1', label: 'task-1', detail: null }],
|
||||
})
|
||||
})
|
||||
|
||||
it('fails discovery when a task entry has no self link', async () => {
|
||||
mockGetAsyncStatus.mockResolvedValueOnce(
|
||||
success({
|
||||
items: [
|
||||
{ links: [{ rel: 'canonical', href: `${ORIGIN}/services/rest/async/v1/job/job-1` }] },
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(502)
|
||||
})
|
||||
|
||||
it('normalizes, deduplicates, and sorts up to 1,000 unique record types', async () => {
|
||||
const items = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
name: `record_${String(999 - index).padStart(4, '0')}`,
|
||||
}))
|
||||
items.push({ name: 'record_0000' }, { name: 'record_0999' })
|
||||
mockListRecordTypes.mockResolvedValueOnce(success({ items }))
|
||||
|
||||
const response = await POST(request(RECORD_TYPES_BODY), {})
|
||||
const body = await json(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.objects).toHaveLength(1_000)
|
||||
expect((body.objects as { id: string }[]).at(0)?.id).toBe('record_0000')
|
||||
expect((body.objects as { id: string }[]).at(-1)?.id).toBe('record_0999')
|
||||
})
|
||||
|
||||
it('fails closed instead of returning a partial record-type catalog', async () => {
|
||||
mockListRecordTypes.mockResolvedValueOnce(
|
||||
success({
|
||||
items: Array.from({ length: 1_001 }, (_, index) => ({ name: `record_${index}` })),
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(request(RECORD_TYPES_BODY), {})
|
||||
|
||||
expect(response.status).toBe(502)
|
||||
expect(await json(response)).toEqual({
|
||||
error: 'NetSuite returned an invalid object-discovery response.',
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed on a malformed provider envelope', async () => {
|
||||
mockListRecordTypes.mockResolvedValueOnce(success({ items: [{ name: 42 }] }))
|
||||
|
||||
const response = await POST(request(RECORD_TYPES_BODY), {})
|
||||
|
||||
expect(response.status).toBe(502)
|
||||
expect(await json(response)).toEqual({
|
||||
error: 'NetSuite returned an invalid object-discovery response.',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['foreign origin', 'https://evil.example/services/rest/async/v1/job/job-1/task/task-1'],
|
||||
['wrong job', `${ORIGIN}/services/rest/async/v1/job/job-2/task/task-1`],
|
||||
['query string', `${ORIGIN}/services/rest/async/v1/job/job-1/task/task-1?secret=x`],
|
||||
['fragment', `${ORIGIN}/services/rest/async/v1/job/job-1/task/task-1#fragment`],
|
||||
['noncanonical encoding', `${ORIGIN}/services/rest/async/v1/job/job%2D1/task/task-1`],
|
||||
])('rejects a %s task link', async (_label, href) => {
|
||||
mockGetAsyncStatus.mockResolvedValueOnce(
|
||||
success({ items: [{ links: [{ rel: 'self', href }] }] })
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(502)
|
||||
expect(await json(response)).toEqual({
|
||||
error: 'NetSuite returned an invalid object-discovery response.',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps unexpected discovery failures to the generic route error', async () => {
|
||||
mockListRecordTypes.mockRejectedValueOnce(new Error('secret provider detail'))
|
||||
|
||||
const response = await POST(request(RECORD_TYPES_BODY), {})
|
||||
const body = await json(response)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(body.error).toBe('Internal server error')
|
||||
expect(JSON.stringify(body)).not.toContain('secret provider detail')
|
||||
})
|
||||
|
||||
it('rejects malformed task relationships and collections above the provider ceiling', async () => {
|
||||
const taskItems = Array.from({ length: 101 }, (_, index) => ({
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
href: `/services/rest/async/v1/job/job-1/task/task-${index}`,
|
||||
},
|
||||
],
|
||||
}))
|
||||
for (const items of [
|
||||
[{ links: [{ rel: 'alternate', href: taskItems[0].links[0].href }] }],
|
||||
taskItems,
|
||||
[{ links: 'not-an-array' }],
|
||||
]) {
|
||||
mockGetAsyncStatus.mockResolvedValueOnce(success({ items }))
|
||||
const response = await POST(
|
||||
request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }),
|
||||
{}
|
||||
)
|
||||
expect(response.status).toBe(502)
|
||||
}
|
||||
})
|
||||
|
||||
it('applies the async-task ceiling after duplicate task links are removed', async () => {
|
||||
const href = '/services/rest/async/v1/job/job-1/task/task-1'
|
||||
mockGetAsyncStatus.mockResolvedValueOnce(
|
||||
success({
|
||||
items: Array.from({ length: 101 }, () => ({ links: [{ rel: 'self', href }] })),
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await json(response)).toEqual({
|
||||
objects: [{ id: 'task-1', label: 'task-1', detail: null }],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[401, 401, true],
|
||||
[403, 403, undefined],
|
||||
[404, 400, undefined],
|
||||
[500, 502, undefined],
|
||||
[undefined, 502, undefined],
|
||||
])(
|
||||
'maps provider status %s without reflecting its error',
|
||||
async (providerStatus, status, authRequired) => {
|
||||
mockListRecordTypes.mockResolvedValueOnce(failure(providerStatus))
|
||||
|
||||
const response = await POST(request(RECORD_TYPES_BODY), {})
|
||||
const body = await json(response)
|
||||
|
||||
expect(response.status).toBe(status)
|
||||
expect(body.authRequired).toBe(authRequired)
|
||||
expect(JSON.stringify(body)).not.toContain('provider secret')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
[new TokenServiceAccountValidationError('invalid_credentials', 401), 401, true],
|
||||
[new TokenServiceAccountValidationError('provider_unavailable', 502), 502, undefined],
|
||||
[null, 401, true],
|
||||
])(
|
||||
'maps credential resolution failures without exposing details',
|
||||
async (error, status, authRequired) => {
|
||||
if (error === null) {
|
||||
mockResolveCredentialAccessToken.mockResolvedValueOnce(null)
|
||||
} else {
|
||||
mockResolveCredentialAccessToken.mockRejectedValueOnce(error)
|
||||
}
|
||||
|
||||
const response = await POST(request(RECORD_TYPES_BODY), {})
|
||||
const body = await json(response)
|
||||
|
||||
expect(response.status).toBe(status)
|
||||
expect(body.authRequired).toBe(authRequired)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['missing access token', { instanceUrl: ORIGIN }],
|
||||
['missing instance URL', { accessToken: 'short-lived-token' }],
|
||||
])('rejects a resolved credential with %s', async (_label, token) => {
|
||||
mockResolveCredentialAccessToken.mockResolvedValueOnce(token)
|
||||
|
||||
const response = await POST(request(RECORD_TYPES_BODY), {})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await json(response)).toMatchObject({ authRequired: true })
|
||||
expect(mockListRecordTypes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 499 when the caller cancels after provider dispatch', async () => {
|
||||
const controller = new AbortController()
|
||||
mockListRecordTypes.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
return success({ items: [{ name: 'customer' }] })
|
||||
})
|
||||
|
||||
const response = await POST(request(RECORD_TYPES_BODY, controller.signal), {})
|
||||
|
||||
expect(response.status).toBe(499)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
type NetSuiteObjectsSelectorBody,
|
||||
netsuiteObjectsSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/netsuite'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
|
||||
import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
|
||||
import { netsuiteGetAsyncStatusTool } from '@/tools/netsuite/get_async_status'
|
||||
import { netsuiteListRecordTypesTool } from '@/tools/netsuite/list_record_types'
|
||||
import type { NetSuiteAuthParams } from '@/tools/netsuite/types'
|
||||
import { normalizeSuiteTalkUrl } from '@/tools/netsuite/utils'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('NetSuiteObjectsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* This session/internal-only metadata route intentionally has no separate rate
|
||||
* limiter: it reuses read-only NetSuite tools whose deadlines and bounded
|
||||
* result sets constrain each provider call, matching Snowflake's picker route.
|
||||
*/
|
||||
const SELECTOR_REQUEST_MAX_BYTES = 16 * 1024
|
||||
const MAX_RECORD_TYPES = 1_000
|
||||
const MAX_ASYNC_TASKS = 100
|
||||
const MAX_ID_LENGTH = 512
|
||||
|
||||
interface NetSuiteSelectorObject {
|
||||
id: string
|
||||
label: string
|
||||
detail: string | null
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal): void {
|
||||
if (!signal.aborted) return
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('NetSuite selector request was cancelled', 'AbortError')
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string, maxLength: number): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error(`NetSuite returned an invalid ${label}`)
|
||||
}
|
||||
const normalized = value.trim()
|
||||
if (normalized.length > maxLength) {
|
||||
throw new Error(`NetSuite returned an oversized ${label}`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requireItems(data: unknown, label: string): Record<string, unknown>[] {
|
||||
if (!isPlainRecord(data) || !Array.isArray(data.items)) {
|
||||
throw new Error(`NetSuite returned an invalid ${label} response`)
|
||||
}
|
||||
if (!data.items.every(isPlainRecord)) {
|
||||
throw new Error(`NetSuite returned malformed ${label} entries`)
|
||||
}
|
||||
return data.items
|
||||
}
|
||||
|
||||
function dedupeAndSort(objects: NetSuiteSelectorObject[]): NetSuiteSelectorObject[] {
|
||||
const unique = new Map<string, NetSuiteSelectorObject>()
|
||||
for (const object of objects) {
|
||||
if (!unique.has(object.id)) unique.set(object.id, object)
|
||||
}
|
||||
return [...unique.values()].sort(
|
||||
(left, right) => left.label.localeCompare(right.label) || left.id.localeCompare(right.id)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRecordTypes(data: unknown): NetSuiteSelectorObject[] {
|
||||
const objects: NetSuiteSelectorObject[] = []
|
||||
const names = new Set<string>()
|
||||
for (const item of requireItems(data, 'record-type catalog')) {
|
||||
const name = requireString(item.name, 'record type name', MAX_ID_LENGTH)
|
||||
if (!names.has(name)) {
|
||||
if (names.size >= MAX_RECORD_TYPES) {
|
||||
throw new Error('NetSuite returned too many record types')
|
||||
}
|
||||
names.add(name)
|
||||
objects.push({ id: name, label: name, detail: null })
|
||||
}
|
||||
}
|
||||
return dedupeAndSort(objects)
|
||||
}
|
||||
|
||||
function taskIdFromHref(href: unknown, origin: string, jobId: string): string {
|
||||
const hrefValue = requireString(href, 'asynchronous task link', 4_096)
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(hrefValue, origin)
|
||||
} catch {
|
||||
throw new Error('NetSuite returned a malformed task link')
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.origin !== origin ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
throw new Error('NetSuite returned an unsafe task link')
|
||||
}
|
||||
|
||||
const match = url.pathname.match(/^\/services\/rest\/async\/v1\/job\/([^/]+)\/task\/([^/]+)$/)
|
||||
if (!match?.[1] || !match[2]) {
|
||||
throw new Error('NetSuite returned an unexpected task link')
|
||||
}
|
||||
|
||||
let linkedJobId: string
|
||||
let taskId: string
|
||||
try {
|
||||
linkedJobId = decodeURIComponent(match[1])
|
||||
taskId = decodeURIComponent(match[2])
|
||||
} catch {
|
||||
throw new Error('NetSuite returned a malformed task link')
|
||||
}
|
||||
if (linkedJobId !== jobId || !taskId || taskId.length > MAX_ID_LENGTH) {
|
||||
throw new Error('NetSuite returned a task link outside the requested job')
|
||||
}
|
||||
|
||||
const canonicalPath = `/services/rest/async/v1/job/${encodeURIComponent(linkedJobId)}/task/${encodeURIComponent(taskId)}`
|
||||
if (
|
||||
url.pathname !== canonicalPath ||
|
||||
(hrefValue !== canonicalPath && hrefValue !== `${origin}${canonicalPath}`)
|
||||
) {
|
||||
throw new Error('NetSuite returned a noncanonical task link')
|
||||
}
|
||||
return taskId
|
||||
}
|
||||
|
||||
function normalizeAsyncTasks(
|
||||
data: unknown,
|
||||
instanceUrl: string,
|
||||
jobId: string
|
||||
): NetSuiteSelectorObject[] {
|
||||
const items = requireItems(data, 'asynchronous task collection')
|
||||
const origin = normalizeSuiteTalkUrl(instanceUrl)
|
||||
const objects = new Map<string, NetSuiteSelectorObject>()
|
||||
|
||||
for (const item of items) {
|
||||
if (!Array.isArray(item.links) || item.links.length === 0 || !item.links.every(isPlainRecord)) {
|
||||
throw new Error('NetSuite returned malformed asynchronous task links')
|
||||
}
|
||||
// Oracle documents a `self` link per task but never guarantees it is the
|
||||
// only relationship on the entry, so additional rels are skipped rather
|
||||
// than failing the whole picker.
|
||||
const selfLinks = item.links.filter((link) => link.rel === 'self')
|
||||
if (selfLinks.length === 0) {
|
||||
throw new Error('NetSuite returned an asynchronous task without a self link')
|
||||
}
|
||||
for (const link of selfLinks) {
|
||||
const id = taskIdFromHref(link.href, origin, jobId)
|
||||
if (!objects.has(id)) {
|
||||
if (objects.size >= MAX_ASYNC_TASKS) {
|
||||
throw new Error('NetSuite returned too many asynchronous tasks')
|
||||
}
|
||||
objects.set(id, { id, label: id, detail: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
return dedupeAndSort([...objects.values()])
|
||||
}
|
||||
|
||||
async function executeDiscoveryTool(
|
||||
body: NetSuiteObjectsSelectorBody,
|
||||
auth: NetSuiteAuthParams,
|
||||
signal: AbortSignal
|
||||
): Promise<ToolResponse> {
|
||||
throwIfAborted(signal)
|
||||
switch (body.kind) {
|
||||
case 'record_types': {
|
||||
const execute = netsuiteListRecordTypesTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite record-type tool is not executable')
|
||||
return execute(auth, signal)
|
||||
}
|
||||
case 'async_tasks': {
|
||||
const execute = netsuiteGetAsyncStatusTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite asynchronous-status tool is not executable')
|
||||
return execute({ ...auth, jobId: body.jobId, view: 'tasks' }, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function failedDiscoveryResponse(result: ToolResponse): NextResponse {
|
||||
const providerStatus =
|
||||
typeof result.output.status === 'number' && Number.isInteger(result.output.status)
|
||||
? result.output.status
|
||||
: 0
|
||||
if (providerStatus === 401) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'NetSuite rejected this credential. Reconnect it and try again.',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
if (providerStatus === 403) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NetSuite denied access to object discovery for this credential.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
if (providerStatus >= 400 && providerStatus < 500) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NetSuite could not list objects for this request.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'NetSuite object discovery failed.' }, { status: 502 })
|
||||
}
|
||||
|
||||
function credentialFailureResponse(error: unknown): NextResponse {
|
||||
if (error instanceof TokenServiceAccountValidationError) {
|
||||
if (error.code !== 'provider_unavailable') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not resolve the NetSuite credential. Reconnect it and try again.',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'The NetSuite credential service is temporarily unavailable.' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not resolve the NetSuite credential. Reconnect it and try again.',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the bounded NetSuite objects used by the block's record-type and
|
||||
* asynchronous-task pickers. Like Snowflake's selector endpoint, this
|
||||
* route owns authentication, credential resolution, provider access, and
|
||||
* response normalization directly; short-lived tokens never reach the client.
|
||||
*/
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const caller = await checkSessionOrInternalAuth(request, { requireWorkflowId: true })
|
||||
if (!caller.success || !caller.userId) {
|
||||
return NextResponse.json({ error: caller.error || 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
netsuiteObjectsSelectorContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
maxBodyBytes: SELECTOR_REQUEST_MAX_BYTES,
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{ error: getValidationErrorMessage(error, 'Invalid request') },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const body = parsed.data.body
|
||||
const { credential, workflowId, kind } = body
|
||||
|
||||
const authorization = await authorizeCredentialUse(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
callerUserId: caller.userId,
|
||||
})
|
||||
if (!authorization.ok || !authorization.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authorization.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const resolvedCredentialId = authorization.resolvedCredentialId ?? credential
|
||||
const resolvedCredential = await resolveOAuthAccountId(resolvedCredentialId)
|
||||
if (
|
||||
authorization.credentialType !== 'service_account' ||
|
||||
resolvedCredential?.credentialType !== 'service_account' ||
|
||||
resolvedCredential.providerId !== NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Select a NetSuite client-credentials service account.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
throwIfAborted(request.signal)
|
||||
let token
|
||||
try {
|
||||
token = await resolveCredentialAccessToken(
|
||||
resolvedCredentialId,
|
||||
authorization.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
} catch (error) {
|
||||
throwIfAborted(request.signal)
|
||||
logger.warn('Failed to resolve NetSuite selector credential', {
|
||||
credentialId: resolvedCredentialId,
|
||||
kind,
|
||||
errorType: error instanceof Error ? error.name : 'unknown',
|
||||
})
|
||||
if (error instanceof TokenServiceAccountValidationError) {
|
||||
return credentialFailureResponse(error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throwIfAborted(request.signal)
|
||||
if (!token?.accessToken || !token.instanceUrl) {
|
||||
return credentialFailureResponse(null)
|
||||
}
|
||||
|
||||
const auth: NetSuiteAuthParams = {
|
||||
oauthCredential: resolvedCredentialId,
|
||||
accessToken: token.accessToken,
|
||||
instanceUrl: token.instanceUrl,
|
||||
}
|
||||
|
||||
const result: ToolResponse = await executeDiscoveryTool(body, auth, request.signal)
|
||||
throwIfAborted(request.signal)
|
||||
if (!result.success) return failedDiscoveryResponse(result)
|
||||
|
||||
try {
|
||||
const data = result.output.data as unknown
|
||||
const objects =
|
||||
body.kind === 'record_types'
|
||||
? normalizeRecordTypes(data)
|
||||
: normalizeAsyncTasks(data, token.instanceUrl, body.jobId)
|
||||
return NextResponse.json({ objects })
|
||||
} catch (error) {
|
||||
logger.error('NetSuite selector response was invalid', {
|
||||
credentialId: resolvedCredentialId,
|
||||
kind,
|
||||
errorType: error instanceof Error ? error.name : 'unknown',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'NetSuite returned an invalid object-discovery response.' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,7 @@ import { MongoDBBlock, MongoDBBlockMeta } from '@/blocks/blocks/mongodb'
|
||||
import { MothershipBlock } from '@/blocks/blocks/mothership'
|
||||
import { MySQLBlock, MySQLBlockMeta } from '@/blocks/blocks/mysql'
|
||||
import { Neo4jBlock, Neo4jBlockMeta } from '@/blocks/blocks/neo4j'
|
||||
import { NetSuiteBlock, NetSuiteBlockMeta } from '@/blocks/blocks/netsuite'
|
||||
import { NeverBounceBlock, NeverBounceBlockMeta } from '@/blocks/blocks/neverbounce'
|
||||
import { NewRelicBlock, NewRelicBlockMeta } from '@/blocks/blocks/new_relic'
|
||||
import { NoteBlock } from '@/blocks/blocks/note'
|
||||
@@ -545,6 +546,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
|
||||
mothership: MothershipBlock,
|
||||
mysql: MySQLBlock,
|
||||
neo4j: Neo4jBlock,
|
||||
netsuite: NetSuiteBlock,
|
||||
new_relic: NewRelicBlock,
|
||||
note: NoteBlock,
|
||||
notion: NotionBlock,
|
||||
@@ -848,6 +850,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
|
||||
mongodb: MongoDBBlockMeta,
|
||||
mysql: MySQLBlockMeta,
|
||||
neo4j: Neo4jBlockMeta,
|
||||
netsuite: NetSuiteBlockMeta,
|
||||
neverbounce: NeverBounceBlockMeta,
|
||||
new_relic: NewRelicBlockMeta,
|
||||
notion: NotionBlockMeta,
|
||||
|
||||
@@ -8625,6 +8625,17 @@ export function NewRelicIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function NetSuiteIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 93.9 59.4' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fill='#C74634'
|
||||
d='M30.5 59.4H65c16.4-.4 29.3-14.1 28.9-30.4C93.5 13.1 80.7.4 65 0H30.5C14.1-.4.4 12.5 0 28.9s12.5 30 28.9 30.4c.5.1 1 .1 1.6.1m33.7-10.5h-33c-10.6-.3-18.9-9.2-18.6-19.8C13 19 21.1 10.8 31.2 10.5h33c10.6-.3 19.5 8 19.8 18.6s-8 19.5-18.6 19.8z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WizaIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 51 49' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type NetSuiteObjectsSelectorBody,
|
||||
type NetSuiteSelectorKind,
|
||||
netsuiteObjectsSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/netsuite'
|
||||
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
|
||||
import type {
|
||||
SelectorDefinition,
|
||||
SelectorKey,
|
||||
SelectorOption,
|
||||
SelectorQueryArgs,
|
||||
} from '@/hooks/selectors/types'
|
||||
|
||||
type NetSuiteSelectorKey = Extract<SelectorKey, `netsuite.${string}`>
|
||||
|
||||
interface NetSuiteSelectorSpec {
|
||||
kind: NetSuiteSelectorKind
|
||||
requiresJob: boolean
|
||||
}
|
||||
|
||||
const NETSUITE_SELECTOR_SPECS: Record<NetSuiteSelectorKey, NetSuiteSelectorSpec> = {
|
||||
'netsuite.recordTypes': { kind: 'record_types', requiresJob: false },
|
||||
'netsuite.asyncTasks': { kind: 'async_tasks', requiresJob: true },
|
||||
}
|
||||
|
||||
function scopeSatisfied(spec: NetSuiteSelectorSpec, args: SelectorQueryArgs): boolean {
|
||||
return Boolean(
|
||||
args.context.oauthCredential &&
|
||||
args.context.workflowId &&
|
||||
(!spec.requiresJob || args.context.jobId)
|
||||
)
|
||||
}
|
||||
|
||||
function toOption(object: { id: string; label: string; detail: string | null }): SelectorOption {
|
||||
return {
|
||||
id: object.id,
|
||||
label: object.label,
|
||||
...(object.detail ? { meta: { detail: object.detail } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildSelector(key: NetSuiteSelectorKey): SelectorDefinition {
|
||||
const spec = NETSUITE_SELECTOR_SPECS[key]
|
||||
|
||||
const fetchObjects = async ({ context, signal }: SelectorQueryArgs) => {
|
||||
const credential = ensureCredential(context, key)
|
||||
if (!context.workflowId) throw new Error(`Missing workflow ID for selector ${key}`)
|
||||
|
||||
let body: NetSuiteObjectsSelectorBody
|
||||
if (spec.kind === 'async_tasks') {
|
||||
if (!context.jobId) throw new Error(`Missing job ID for selector ${key}`)
|
||||
body = {
|
||||
credential,
|
||||
workflowId: context.workflowId,
|
||||
kind: spec.kind,
|
||||
jobId: context.jobId,
|
||||
}
|
||||
} else {
|
||||
body = { credential, workflowId: context.workflowId, kind: spec.kind }
|
||||
}
|
||||
|
||||
return requestJson(netsuiteObjectsSelectorContract, { body, signal })
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
contracts: [netsuiteObjectsSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
key,
|
||||
context.workflowId ?? 'none',
|
||||
context.oauthCredential ?? 'none',
|
||||
...(spec.requiresJob ? [context.jobId ?? 'none'] : []),
|
||||
],
|
||||
enabled: (args) => scopeSatisfied(spec, args),
|
||||
fetchList: async (args) => (await fetchObjects(args)).objects.map(toOption),
|
||||
fetchById: async (args) => {
|
||||
if (!args.detailId || !scopeSatisfied(spec, args)) return null
|
||||
const match = (await fetchObjects(args)).objects.find((object) => object.id === args.detailId)
|
||||
return match ? toOption(match) : null
|
||||
},
|
||||
resolvesUnknownIds: true,
|
||||
}
|
||||
}
|
||||
|
||||
export const netsuiteSelectors = {
|
||||
'netsuite.recordTypes': buildSelector('netsuite.recordTypes'),
|
||||
'netsuite.asyncTasks': buildSelector('netsuite.asyncTasks'),
|
||||
} satisfies Record<NetSuiteSelectorKey, SelectorDefinition>
|
||||
@@ -13,6 +13,7 @@ import { knowledgeSelectors } from '@/hooks/selectors/providers/knowledge/select
|
||||
import { linearSelectors } from '@/hooks/selectors/providers/linear/selectors'
|
||||
import { microsoftSelectors } from '@/hooks/selectors/providers/microsoft/selectors'
|
||||
import { mondaySelectors } from '@/hooks/selectors/providers/monday/selectors'
|
||||
import { netsuiteSelectors } from '@/hooks/selectors/providers/netsuite/selectors'
|
||||
import { notionSelectors } from '@/hooks/selectors/providers/notion/selectors'
|
||||
import { pipedriveSelectors } from '@/hooks/selectors/providers/pipedrive/selectors'
|
||||
import { sharepointSelectors } from '@/hooks/selectors/providers/sharepoint/selectors'
|
||||
@@ -51,6 +52,7 @@ export const selectorRegistry = {
|
||||
...wealthboxSelectors,
|
||||
...jiraSelectors,
|
||||
...mondaySelectors,
|
||||
...netsuiteSelectors,
|
||||
...linearSelectors,
|
||||
...knowledgeSelectors,
|
||||
...webflowSelectors,
|
||||
|
||||
@@ -23,6 +23,8 @@ export type SelectorKey =
|
||||
| 'microsoft.planner.plans'
|
||||
| 'notion.databases'
|
||||
| 'notion.pages'
|
||||
| 'netsuite.recordTypes'
|
||||
| 'netsuite.asyncTasks'
|
||||
| 'pipedrive.pipelines'
|
||||
| 'sharepoint.lists'
|
||||
| 'trello.boards'
|
||||
@@ -111,6 +113,8 @@ export interface SelectorContext {
|
||||
logGroupName?: string
|
||||
mcpServerId?: string
|
||||
tableId?: string
|
||||
/** NetSuite asynchronous job whose bounded task list a picker enumerates. */
|
||||
jobId?: string
|
||||
/** Snowflake database holding the objects a picker enumerates. */
|
||||
database?: string
|
||||
/** Snowflake schema holding the objects a picker enumerates. */
|
||||
|
||||
@@ -132,6 +132,7 @@ export const createCredentialBodySchema = z
|
||||
botToken: z.string().trim().min(1).optional(),
|
||||
clientId: z.string().trim().min(1).max(512).optional(),
|
||||
clientSecret: z.string().trim().min(1).max(1024).optional(),
|
||||
certificateId: z.string().trim().min(1).max(512).optional(),
|
||||
orgId: z.string().trim().min(1).max(255).optional(),
|
||||
/** Optional provider region selector (Zoho Desk data center). */
|
||||
dataCenter: z.string().trim().min(1).max(32).optional(),
|
||||
@@ -142,7 +143,7 @@ export const createCredentialBodySchema = z
|
||||
* provider's default rather than failing, so this only bounds length.
|
||||
*/
|
||||
authMethod: z.string().trim().min(1).max(64).optional(),
|
||||
/** PEM private key for key-based grants (Salesforce JWT bearer). */
|
||||
/** PEM private key for certificate/JWT-based grants (for example Salesforce or NetSuite). */
|
||||
privateKey: z.string().trim().min(1).max(8192).optional(),
|
||||
/** Run-as username for key-based grants (Salesforce JWT `sub`). */
|
||||
username: z.string().trim().min(1).max(255).optional(),
|
||||
@@ -219,6 +220,7 @@ export const updateCredentialByIdBodySchema = z
|
||||
/** Client-credential service-account secret rotation (reconnect). */
|
||||
clientId: z.string().trim().min(1).max(512).optional(),
|
||||
clientSecret: z.string().trim().min(1).max(1024).optional(),
|
||||
certificateId: z.string().trim().min(1).max(512).optional(),
|
||||
orgId: z.string().trim().min(1).max(255).optional(),
|
||||
dataCenter: z.string().trim().min(1).max(32).optional(),
|
||||
authMethod: z.string().trim().min(1).max(64).optional(),
|
||||
@@ -237,6 +239,7 @@ export const updateCredentialByIdBodySchema = z
|
||||
data.domain !== undefined ||
|
||||
data.clientId !== undefined ||
|
||||
data.clientSecret !== undefined ||
|
||||
data.certificateId !== undefined ||
|
||||
data.orgId !== undefined ||
|
||||
data.dataCenter !== undefined ||
|
||||
data.authMethod !== undefined ||
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
mondayBoardsSelectorContract,
|
||||
mondayGroupsSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/monday'
|
||||
import { netsuiteObjectsSelectorContract } from '@/lib/api/contracts/selectors/netsuite'
|
||||
import {
|
||||
notionDatabasesSelectorContract,
|
||||
notionPagesSelectorContract,
|
||||
@@ -131,6 +132,7 @@ export * from '@/lib/api/contracts/selectors/knowledge'
|
||||
export * from '@/lib/api/contracts/selectors/linear'
|
||||
export * from '@/lib/api/contracts/selectors/microsoft'
|
||||
export * from '@/lib/api/contracts/selectors/monday'
|
||||
export * from '@/lib/api/contracts/selectors/netsuite'
|
||||
export * from '@/lib/api/contracts/selectors/notion'
|
||||
export * from '@/lib/api/contracts/selectors/oauth'
|
||||
export * from '@/lib/api/contracts/selectors/pipedrive'
|
||||
@@ -200,6 +202,7 @@ export const selectorContractsByPath = {
|
||||
'/api/tools/jira/issues:POST': jiraIssueSelectorContract,
|
||||
'/api/tools/monday/boards': mondayBoardsSelectorContract,
|
||||
'/api/tools/monday/groups': mondayGroupsSelectorContract,
|
||||
'/api/tools/netsuite/objects': netsuiteObjectsSelectorContract,
|
||||
'/api/tools/linear/teams': linearTeamsSelectorContract,
|
||||
'/api/tools/linear/projects': linearProjectsSelectorContract,
|
||||
'/api/tools/confluence/pages': confluencePagesSelectorContract,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
import { workflowIdSchema } from '@/lib/api/contracts/primitives'
|
||||
import { definePostSelector } from '@/lib/api/contracts/selectors/shared'
|
||||
import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types'
|
||||
|
||||
export const NETSUITE_SELECTOR_KINDS = ['record_types', 'async_tasks'] as const
|
||||
|
||||
const credentialSchema = z
|
||||
.string({ error: 'Credential is required' })
|
||||
.trim()
|
||||
.min(1, 'Credential is required')
|
||||
.max(128, 'Credential ID is too long')
|
||||
|
||||
const boundedWorkflowIdSchema = workflowIdSchema.trim().max(128, 'Workflow ID is too long')
|
||||
|
||||
const commonBodyShape = {
|
||||
credential: credentialSchema,
|
||||
workflowId: boundedWorkflowIdSchema,
|
||||
} as const
|
||||
|
||||
export const netsuiteObjectsBodySchema = z.discriminatedUnion('kind', [
|
||||
z.object({ ...commonBodyShape, kind: z.literal('record_types') }).strict(),
|
||||
z
|
||||
.object({
|
||||
...commonBodyShape,
|
||||
kind: z.literal('async_tasks'),
|
||||
jobId: z
|
||||
.string({ error: 'Job ID is required to list asynchronous tasks' })
|
||||
.trim()
|
||||
.min(1, 'Job ID is required to list asynchronous tasks')
|
||||
.max(512, 'Job ID is too long'),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
|
||||
export const netsuiteSelectorObjectSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(512),
|
||||
label: z.string().min(1).max(1_000),
|
||||
detail: z.string().max(2_000).nullable(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const netsuiteObjectsSelectorContract = definePostSelector(
|
||||
'/api/tools/netsuite/objects',
|
||||
netsuiteObjectsBodySchema,
|
||||
z.object({ objects: z.array(netsuiteSelectorObjectSchema).max(1_000) }).strict()
|
||||
)
|
||||
|
||||
export type NetSuiteSelectorKind = (typeof NETSUITE_SELECTOR_KINDS)[number]
|
||||
export type NetSuiteObjectsSelectorBody = ContractBodyInput<typeof netsuiteObjectsSelectorContract>
|
||||
export type NetSuiteObjectsSelectorResponse = ContractJsonResponse<
|
||||
typeof netsuiteObjectsSelectorContract
|
||||
>
|
||||
@@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BOX_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
getClientCredentialAccountDescriptor,
|
||||
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
normalizeNetSuiteSuiteTalkOrigin,
|
||||
partitionClientCredentialFields,
|
||||
resolveClientCredentialAuthMethod,
|
||||
resolveSalesforceAuthMethod,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
const salesforce = getClientCredentialAccountDescriptor(SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID)!
|
||||
const box = getClientCredentialAccountDescriptor(BOX_SERVICE_ACCOUNT_PROVIDER_ID)!
|
||||
const zohoDesk = getClientCredentialAccountDescriptor(ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID)!
|
||||
const netSuite = getClientCredentialAccountDescriptor(NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)!
|
||||
|
||||
const ids = (fields: { id: string }[]) => fields.map((field) => field.id)
|
||||
|
||||
@@ -38,6 +41,16 @@ describe('partitionClientCredentialFields', () => {
|
||||
const { required } = partitionClientCredentialFields(box, 'jwt_bearer')
|
||||
expect(ids(required)).toEqual(['clientId', 'clientSecret', 'orgId'])
|
||||
})
|
||||
|
||||
it('declares the complete NetSuite certificate credential', () => {
|
||||
const { visible, required } = partitionClientCredentialFields(netSuite, undefined)
|
||||
expect(ids(visible)).toEqual(['orgId', 'clientId', 'certificateId', 'privateKey'])
|
||||
expect(ids(required)).toEqual(['orgId', 'clientId', 'certificateId', 'privateKey'])
|
||||
expect(netSuite.fields.find((field) => field.id === 'privateKey')).toMatchObject({
|
||||
secret: true,
|
||||
multiline: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Salesforce, which offers two grants', () => {
|
||||
@@ -77,6 +90,25 @@ describe('partitionClientCredentialFields', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeNetSuiteSuiteTalkOrigin', () => {
|
||||
it('normalizes an account-specific HTTPS Company URL', () => {
|
||||
expect(
|
||||
normalizeNetSuiteSuiteTalkOrigin(' https://1234567-SB1.suitetalk.api.netsuite.com/ ')
|
||||
).toBe('https://1234567-sb1.suitetalk.api.netsuite.com')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://1234567.suitetalk.api.netsuite.com',
|
||||
'https://suitetalk.api.netsuite.com',
|
||||
'https://1234567.suitetalk.api.netsuite.com/services/rest/record/v1',
|
||||
'https://1234567.suitetalk.api.netsuite.com?account=other',
|
||||
'https://1234567.suitetalk.api.netsuite.com.evil.example',
|
||||
'https://user@1234567.suitetalk.api.netsuite.com',
|
||||
])('rejects the non-authoritative SuiteTalk URL %j', (value) => {
|
||||
expect(normalizeNetSuiteSuiteTalkOrigin(value)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveClientCredentialAuthMethod', () => {
|
||||
it('returns undefined for a provider that declares no method selector', () => {
|
||||
expect(resolveClientCredentialAuthMethod(box, 'jwt_bearer')).toBeUndefined()
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* Client-safe descriptors for client-credentials service-account providers.
|
||||
*
|
||||
* A client-credential account is a `service_account`-type credential where a
|
||||
* workspace admin pastes an OAuth client id + client secret + provider org
|
||||
* identifier instead of a long-lived token. Unlike the token-paste family
|
||||
* workspace admin supplies an OAuth client identity plus a shared secret or
|
||||
* signing key and provider account identifier. Unlike the token-paste family
|
||||
* (whose stored secret IS the access token), these credentials mint a
|
||||
* short-lived access token on demand via the provider's client-credentials
|
||||
* grant (Zoom Server-to-Server OAuth, Box CCG). This module holds only
|
||||
* UI/contract metadata (field lists, labels, docs links); the server-side
|
||||
* minting registry lives in `@/lib/credentials/client-credential-accounts/server`.
|
||||
* short-lived access token on demand. This module holds only UI/contract
|
||||
* metadata (field lists, labels, docs links); the server-side minting registry
|
||||
* lives in `@/lib/credentials/client-credential-accounts/server`.
|
||||
*/
|
||||
|
||||
/** Discriminator stored inside every encrypted client-credential secret blob. */
|
||||
@@ -18,6 +17,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account'
|
||||
export type ClientCredentialAccountFieldId =
|
||||
| 'clientId'
|
||||
| 'clientSecret'
|
||||
| 'certificateId'
|
||||
| 'orgId'
|
||||
| 'dataCenter'
|
||||
| 'authMethod'
|
||||
@@ -110,12 +110,49 @@ export const ZOOM_SERVICE_ACCOUNT_PROVIDER_ID = 'zoom-service-account' as const
|
||||
export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
|
||||
export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
|
||||
export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const
|
||||
export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const
|
||||
|
||||
export type ClientCredentialAccountProviderId =
|
||||
| typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
|
||||
/**
|
||||
* Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and
|
||||
* REST endpoints. The account label may include a sandbox suffix such as
|
||||
* `-sb1`; paths, ports, credentials, query strings, and fragments are never
|
||||
* accepted because the stored origin later receives a bearer token.
|
||||
*/
|
||||
export const NETSUITE_SUITETALK_ORIGIN_REGEX =
|
||||
/^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.suitetalk\.api\.netsuite\.com$/
|
||||
|
||||
/**
|
||||
* Normalizes an account-specific SuiteTalk URL to its HTTPS origin. Returns
|
||||
* `undefined` rather than throwing so the client-side format hint and the
|
||||
* server-side minter can share the exact same admission rule.
|
||||
*/
|
||||
export function normalizeNetSuiteSuiteTalkOrigin(rawUrl: string): string | undefined {
|
||||
try {
|
||||
const parsed = new URL(rawUrl.trim())
|
||||
if (
|
||||
parsed.protocol !== 'https:' ||
|
||||
parsed.port ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.search ||
|
||||
parsed.hash ||
|
||||
(parsed.pathname !== '' && parsed.pathname !== '/') ||
|
||||
!NETSUITE_SUITETALK_ORIGIN_REGEX.test(parsed.origin)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return parsed.origin
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowed My Domain host shapes: one org label (optionally with a
|
||||
@@ -451,6 +488,49 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
|
||||
docsUrl: 'https://docs.sim.ai/integrations/zoho-desk-service-account',
|
||||
helpText: 'Zoho Desk triggers still require an OAuth connection, which is US-only.',
|
||||
},
|
||||
[NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID]: {
|
||||
providerId: NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
serviceLabel: 'Oracle NetSuite',
|
||||
connectNoun: 'OAuth certificate',
|
||||
fields: [
|
||||
{
|
||||
id: 'orgId',
|
||||
label: 'SuiteTalk URL',
|
||||
placeholder: 'https://1234567-sb1.suitetalk.api.netsuite.com',
|
||||
secret: false,
|
||||
hintPattern: NETSUITE_SUITETALK_ORIGIN_REGEX,
|
||||
hintNormalize: (value) =>
|
||||
normalizeNetSuiteSuiteTalkOrigin(value) ?? value.trim().toLowerCase(),
|
||||
hintMessage:
|
||||
'Expected the HTTPS SuiteTalk Company URL with no path, port, query, or fragment.',
|
||||
},
|
||||
{
|
||||
id: 'clientId',
|
||||
label: 'Client ID',
|
||||
placeholder: 'Paste the integration record client ID',
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
id: 'certificateId',
|
||||
label: 'Certificate ID',
|
||||
placeholder: 'Paste the certificate mapping ID',
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
id: 'privateKey',
|
||||
label: 'Private key',
|
||||
placeholder: '-----BEGIN PRIVATE KEY-----',
|
||||
secret: true,
|
||||
multiline: true,
|
||||
hintPattern: /-----BEGIN (?:EC |RSA )?PRIVATE KEY-----/,
|
||||
hintMessage: 'Expected the PEM private key paired with the uploaded certificate.',
|
||||
hint: 'Must match the certificate mapping in this NetSuite account and environment.',
|
||||
},
|
||||
],
|
||||
docsUrl: 'https://docs.sim.ai/integrations/netsuite-service-account',
|
||||
helpText:
|
||||
'Use the account-specific SuiteTalk URL and the client ID, certificate ID, and private key from one OAuth 2.0 client-credentials mapping.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { generateKeyPairSync } from 'node:crypto'
|
||||
import { jwtVerify } from 'jose'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite'
|
||||
|
||||
const ORIGIN = 'https://1234567-sb1.suitetalk.api.netsuite.com'
|
||||
const TOKEN_URL = `${ORIGIN}/services/rest/auth/oauth2/v1/token`
|
||||
const rsaKeyPair = generateKeyPairSync('rsa', { modulusLength: 3072 })
|
||||
const RSA_PRIVATE_KEY = rsaKeyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
const UNSUPPORTED_RSA_PRIVATE_KEY = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
||||
.privateKey.export({ type: 'pkcs8', format: 'pem' })
|
||||
.toString()
|
||||
const UNSUPPORTED_EC_PRIVATE_KEY = generateKeyPairSync('ec', { namedCurve: 'secp256k1' })
|
||||
.privateKey.export({ type: 'pkcs8', format: 'pem' })
|
||||
.toString()
|
||||
const EC_KEY_CASES = (
|
||||
[
|
||||
{ namedCurve: 'P-256', algorithm: 'ES256' },
|
||||
{ namedCurve: 'P-384', algorithm: 'ES384' },
|
||||
{ namedCurve: 'P-521', algorithm: 'ES512' },
|
||||
] as const
|
||||
).map(({ namedCurve, algorithm }) => {
|
||||
const pair = generateKeyPairSync('ec', { namedCurve })
|
||||
return {
|
||||
namedCurve,
|
||||
algorithm,
|
||||
privateKey: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
publicKey: pair.publicKey,
|
||||
}
|
||||
})
|
||||
|
||||
const FIELDS = {
|
||||
orgId: ORIGIN,
|
||||
clientId: 'client-id',
|
||||
certificateId: 'certificate-id',
|
||||
privateKey: RSA_PRIVATE_KEY,
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function assertionFrom(init: RequestInit): string {
|
||||
const body = new URLSearchParams(String(init.body))
|
||||
expect(body.get('grant_type')).toBe('client_credentials')
|
||||
expect(body.get('client_assertion_type')).toBe(
|
||||
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||
)
|
||||
const assertion = body.get('client_assertion')
|
||||
expect(assertion).toBeTruthy()
|
||||
return assertion as string
|
||||
}
|
||||
|
||||
describe('mintNetSuiteServiceAccountToken', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('signs the Oracle client assertion with PS256 and returns account metadata', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: 1800, token_type: 'Bearer' })
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await mintNetSuiteServiceAccountToken(FIELDS)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe(TOKEN_URL)
|
||||
expect(init).toMatchObject({
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal)
|
||||
const assertion = assertionFrom(init)
|
||||
const verified = await jwtVerify(assertion, rsaKeyPair.publicKey, {
|
||||
algorithms: ['PS256'],
|
||||
audience: TOKEN_URL,
|
||||
issuer: FIELDS.clientId,
|
||||
})
|
||||
expect(verified.protectedHeader).toMatchObject({
|
||||
typ: 'JWT',
|
||||
alg: 'PS256',
|
||||
kid: FIELDS.certificateId,
|
||||
})
|
||||
expect(verified.payload.scope).toBe('rest_webservices')
|
||||
expect(verified.payload.jti).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
)
|
||||
expect(verified.payload.exp).toBe((verified.payload.iat as number) + 300)
|
||||
expect(result).toEqual({
|
||||
accessToken: 'netsuite-access',
|
||||
expiresInSeconds: 1800,
|
||||
instanceUrl: ORIGIN,
|
||||
identity: {
|
||||
displayName: 'Oracle NetSuite 1234567-sb1',
|
||||
principal: {
|
||||
kind: 'tenant',
|
||||
id: '1234567-sb1',
|
||||
label: '1234567-sb1.suitetalk.api.netsuite.com',
|
||||
},
|
||||
auditMetadata: {
|
||||
netSuiteAccountId: '1234567-sb1',
|
||||
netSuiteSuiteTalkOrigin: ORIGIN,
|
||||
},
|
||||
storedMetadata: {
|
||||
accountId: '1234567-sb1',
|
||||
suiteTalkOrigin: ORIGIN,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each(EC_KEY_CASES)(
|
||||
'signs a $namedCurve assertion with $algorithm',
|
||||
async ({ algorithm, privateKey, publicKey }) => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: 3600, token_type: 'bearer' })
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await mintNetSuiteServiceAccountToken({ ...FIELDS, privateKey }, { skipIdentity: true })
|
||||
|
||||
const assertion = assertionFrom(fetchMock.mock.calls[0][1] as RequestInit)
|
||||
const verified = await jwtVerify(assertion, publicKey, {
|
||||
algorithms: [algorithm],
|
||||
audience: TOKEN_URL,
|
||||
issuer: FIELDS.clientId,
|
||||
})
|
||||
expect(verified.protectedHeader).toMatchObject({
|
||||
typ: 'JWT',
|
||||
alg: algorithm,
|
||||
kid: FIELDS.certificateId,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('accepts Oracle-supported 4096-bit RSA keys with PS256', async () => {
|
||||
const pair = generateKeyPairSync('rsa', { modulusLength: 4096 })
|
||||
const privateKey = pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: 3600, token_type: 'Bearer' })
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await mintNetSuiteServiceAccountToken({ ...FIELDS, privateKey }, { skipIdentity: true })
|
||||
|
||||
const assertion = assertionFrom(fetchMock.mock.calls[0][1] as RequestInit)
|
||||
await expect(
|
||||
jwtVerify(assertion, pair.publicKey, {
|
||||
algorithms: ['PS256'],
|
||||
audience: TOKEN_URL,
|
||||
issuer: FIELDS.clientId,
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('omits connect-time identity when resolving an execution token', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: 3600, token_type: 'Bearer' })
|
||||
)
|
||||
)
|
||||
|
||||
await expect(mintNetSuiteServiceAccountToken(FIELDS, { skipIdentity: true })).resolves.toEqual({
|
||||
accessToken: 'netsuite-access',
|
||||
expiresInSeconds: 3600,
|
||||
instanceUrl: ORIGIN,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://1234567.suitetalk.api.netsuite.com',
|
||||
'https://evil.example',
|
||||
`${ORIGIN}/services/rest/record/v1/customer`,
|
||||
`${ORIGIN}?account=other`,
|
||||
'https://user:password@1234567.suitetalk.api.netsuite.com',
|
||||
])('rejects the SuiteTalk URL %j before fetching', async (orgId) => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(mintNetSuiteServiceAccountToken({ ...FIELDS, orgId })).rejects.toMatchObject({
|
||||
code: 'site_not_found',
|
||||
status: 400,
|
||||
})
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[UNSUPPORTED_RSA_PRIVATE_KEY, 'RSA private key must be 3072 or 4096 bits'],
|
||||
[UNSUPPORTED_EC_PRIVATE_KEY, 'EC private key must use P-256, P-384, or P-521'],
|
||||
])('rejects an unsupported key before fetching', async (privateKey, reason) => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(mintNetSuiteServiceAccountToken({ ...FIELDS, privateKey })).rejects.toMatchObject({
|
||||
code: 'invalid_credentials',
|
||||
logDetail: expect.objectContaining({ reason }),
|
||||
})
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[400, 'invalid_credentials'],
|
||||
[401, 'invalid_credentials'],
|
||||
[408, 'provider_unavailable'],
|
||||
[429, 'provider_unavailable'],
|
||||
[503, 'provider_unavailable'],
|
||||
] as const)('classifies an HTTP %i token failure as %s', async (status, code) => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ error: 'denied' }, status)))
|
||||
|
||||
await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({ code, status })
|
||||
})
|
||||
|
||||
it('redacts credentials and a reflected assertion from provider error metadata', async () => {
|
||||
let assertion = ''
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, init: RequestInit) => {
|
||||
assertion = assertionFrom(init)
|
||||
return new Response(
|
||||
`${FIELDS.clientId} ${FIELDS.certificateId} ${FIELDS.privateKey} ${assertion}`,
|
||||
{ status: 400 }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const error = await mintNetSuiteServiceAccountToken(FIELDS).catch((caught: unknown) => caught)
|
||||
expect(error).toMatchObject({ code: 'invalid_credentials' })
|
||||
const detail = JSON.stringify((error as { logDetail?: unknown }).logDetail)
|
||||
expect(detail).not.toContain(FIELDS.clientId)
|
||||
expect(detail).not.toContain(FIELDS.certificateId)
|
||||
expect(detail).not.toContain('BEGIN PRIVATE KEY')
|
||||
expect(detail).not.toContain(assertion)
|
||||
})
|
||||
|
||||
it('bounds provider error bodies before attaching safe metadata', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response('x'.repeat(64 * 1024 + 1), { status: 400 }))
|
||||
)
|
||||
|
||||
const error = await mintNetSuiteServiceAccountToken(FIELDS).catch((caught: unknown) => caught)
|
||||
expect(error).toMatchObject({
|
||||
code: 'invalid_credentials',
|
||||
logDetail: {
|
||||
step: 'netsuite_token_mint',
|
||||
body: 'provider error response exceeded the allowed size or could not be read',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[7200, 3600],
|
||||
[60, 60],
|
||||
])('caps a valid expires_in=%s at %i seconds', async (expiresIn, expected) => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
access_token: 'netsuite-access',
|
||||
expires_in: expiresIn,
|
||||
token_type: expiresIn === 60 ? 'bEaReR' : 'Bearer',
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const result = await mintNetSuiteServiceAccountToken(FIELDS, { skipIdentity: true })
|
||||
expect(result.expiresInSeconds).toBe(expected)
|
||||
})
|
||||
|
||||
it('maps invalid, missing, and oversized success bodies to provider_unavailable', async () => {
|
||||
const responses = [
|
||||
new Response('not-json', { status: 200 }),
|
||||
jsonResponse(null),
|
||||
jsonResponse({ expires_in: 3600, token_type: 'Bearer' }),
|
||||
jsonResponse({ access_token: ' ', expires_in: 3600, token_type: 'Bearer' }),
|
||||
jsonResponse({ access_token: 'netsuite-access', token_type: 'Bearer' }),
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: 0, token_type: 'Bearer' }),
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: -1, token_type: 'Bearer' }),
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: '3600', token_type: 'Bearer' }),
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: null, token_type: 'Bearer' }),
|
||||
new Response('{"access_token":"netsuite-access","expires_in":1e999,"token_type":"Bearer"}', {
|
||||
status: 200,
|
||||
}),
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: 3600 }),
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: 3600, token_type: 1 }),
|
||||
jsonResponse({ access_token: 'netsuite-access', expires_in: 3600, token_type: 'mac' }),
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'x'.repeat(1024 * 1024 + 1),
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
}),
|
||||
{ status: 200 }
|
||||
),
|
||||
]
|
||||
const fetchMock = vi.fn()
|
||||
for (const response of responses) fetchMock.mockResolvedValueOnce(response)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
for (const _response of responses) {
|
||||
await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'provider_unavailable',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('maps a network or timeout rejection to provider_unavailable without leaking details', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error(`failed for ${FIELDS.clientId}`)))
|
||||
|
||||
await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'provider_unavailable',
|
||||
status: 502,
|
||||
logDetail: { step: 'netsuite_token_mint', reason: 'network error reaching provider' },
|
||||
})
|
||||
})
|
||||
|
||||
it('applies the 30-second exchange deadline to the provider request', async () => {
|
||||
const controller = new AbortController()
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init: RequestInit) => {
|
||||
const signal = init.signal as AbortSignal
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
controller.abort(new DOMException('timed out', 'TimeoutError'))
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'provider_unavailable',
|
||||
status: 502,
|
||||
})
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(30_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { createPrivateKey, type KeyObject } from 'node:crypto'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { SignJWT } from 'jose'
|
||||
import {
|
||||
DEFAULT_MAX_ERROR_BODY_BYTES,
|
||||
readResponseJsonWithLimit,
|
||||
readResponseTextWithLimit,
|
||||
} from '@/lib/core/utils/stream-limits'
|
||||
import { normalizeNetSuiteSuiteTalkOrigin } from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import type {
|
||||
ClientCredentialAccountFields,
|
||||
ClientCredentialAccountMintOptions,
|
||||
ClientCredentialAccountMintResult,
|
||||
} from '@/lib/credentials/client-credential-accounts/server'
|
||||
import { tenantPrincipal } from '@/lib/credentials/principal'
|
||||
import {
|
||||
isTransientProviderStatus,
|
||||
TokenServiceAccountValidationError,
|
||||
} from '@/lib/credentials/token-service-accounts/errors'
|
||||
|
||||
const TOKEN_PATH = '/services/rest/auth/oauth2/v1/token'
|
||||
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
|
||||
const TOKEN_RESPONSE_MAX_BYTES = 1024 * 1024
|
||||
const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 3_600
|
||||
const JWT_ASSERTION_LIFETIME_SECONDS = 300
|
||||
const TOKEN_MINT_STEP = 'netsuite_token_mint'
|
||||
const SUPPORTED_RSA_MODULUS_LENGTHS = new Set([3072, 4096])
|
||||
const EC_ALGORITHM_BY_CURVE = new Map<string, 'ES256' | 'ES384' | 'ES512'>([
|
||||
['prime256v1', 'ES256'],
|
||||
['secp256r1', 'ES256'],
|
||||
['p-256', 'ES256'],
|
||||
['secp384r1', 'ES384'],
|
||||
['p-384', 'ES384'],
|
||||
['secp521r1', 'ES512'],
|
||||
['p-521', 'ES512'],
|
||||
])
|
||||
|
||||
interface NetSuiteTokenResponse {
|
||||
access_token?: unknown
|
||||
expires_in?: unknown
|
||||
token_type?: unknown
|
||||
}
|
||||
|
||||
type NetSuiteJwtAlgorithm = 'PS256' | 'ES256' | 'ES384' | 'ES512'
|
||||
|
||||
function invalidCredentials(reason: string): TokenServiceAccountValidationError {
|
||||
return new TokenServiceAccountValidationError('invalid_credentials', 400, {
|
||||
step: TOKEN_MINT_STEP,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
function loadNetSuitePrivateKey(privateKeyPem: string | undefined): KeyObject {
|
||||
if (!privateKeyPem?.trim()) {
|
||||
throw invalidCredentials('private key is required')
|
||||
}
|
||||
if (/ENCRYPTED PRIVATE KEY/.test(privateKeyPem)) {
|
||||
throw invalidCredentials('private key must not be passphrase-protected')
|
||||
}
|
||||
try {
|
||||
return createPrivateKey(privateKeyPem.trim())
|
||||
} catch {
|
||||
throw invalidCredentials('private key is not a readable PEM key')
|
||||
}
|
||||
}
|
||||
|
||||
function getNetSuiteJwtAlgorithm(privateKey: KeyObject): NetSuiteJwtAlgorithm {
|
||||
const keyType = privateKey.asymmetricKeyType
|
||||
if (keyType === 'rsa' || keyType === 'rsa-pss') {
|
||||
const modulusLength = privateKey.asymmetricKeyDetails?.modulusLength
|
||||
if (modulusLength && SUPPORTED_RSA_MODULUS_LENGTHS.has(modulusLength)) return 'PS256'
|
||||
throw invalidCredentials('RSA private key must be 3072 or 4096 bits')
|
||||
}
|
||||
|
||||
if (keyType === 'ec') {
|
||||
const namedCurve = privateKey.asymmetricKeyDetails?.namedCurve?.toLowerCase()
|
||||
const algorithm = namedCurve ? EC_ALGORITHM_BY_CURVE.get(namedCurve) : undefined
|
||||
if (algorithm) return algorithm
|
||||
throw invalidCredentials('EC private key must use P-256, P-384, or P-521')
|
||||
}
|
||||
|
||||
throw invalidCredentials(
|
||||
'private key must use 3072- or 4096-bit RSA or the P-256, P-384, or P-521 EC curve'
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeNetSuiteTokenError(
|
||||
value: string,
|
||||
fields: ClientCredentialAccountFields,
|
||||
assertion?: string
|
||||
): string {
|
||||
let sanitized = value
|
||||
.replace(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, '[REDACTED]')
|
||||
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED]')
|
||||
for (const credential of [fields.clientId, fields.certificateId, fields.privateKey, assertion]) {
|
||||
const secret = credential?.trim()
|
||||
if (secret && secret.length >= 3) sanitized = sanitized.split(secret).join('[REDACTED]')
|
||||
}
|
||||
return truncate(sanitized.replace(/\s+/g, ' ').trim(), 500) || 'empty provider error'
|
||||
}
|
||||
|
||||
async function exchangeNetSuiteToken(
|
||||
tokenUrl: string,
|
||||
assertion: string,
|
||||
fields: ClientCredentialAccountFields
|
||||
): Promise<{ accessToken: string; expiresInSeconds: number }> {
|
||||
const signal = AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
client_assertion: assertion,
|
||||
}).toString(),
|
||||
redirect: 'error',
|
||||
signal,
|
||||
})
|
||||
} catch {
|
||||
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
|
||||
step: TOKEN_MINT_STEP,
|
||||
reason: 'network error reaching provider',
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body = ''
|
||||
try {
|
||||
body = await readResponseTextWithLimit(response, {
|
||||
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
|
||||
label: 'NetSuite token error response',
|
||||
signal,
|
||||
})
|
||||
} catch {
|
||||
body = 'provider error response exceeded the allowed size or could not be read'
|
||||
}
|
||||
const code =
|
||||
response.status >= 400 && response.status < 500 && !isTransientProviderStatus(response.status)
|
||||
? 'invalid_credentials'
|
||||
: 'provider_unavailable'
|
||||
throw new TokenServiceAccountValidationError(code, response.status, {
|
||||
step: TOKEN_MINT_STEP,
|
||||
body: sanitizeNetSuiteTokenError(body, fields, assertion),
|
||||
})
|
||||
}
|
||||
|
||||
let payload: NetSuiteTokenResponse
|
||||
try {
|
||||
payload = await readResponseJsonWithLimit<NetSuiteTokenResponse>(response, {
|
||||
maxBytes: TOKEN_RESPONSE_MAX_BYTES,
|
||||
label: 'NetSuite token response',
|
||||
signal,
|
||||
})
|
||||
} catch {
|
||||
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
|
||||
step: TOKEN_MINT_STEP,
|
||||
reason: 'provider returned an invalid or oversized token response',
|
||||
})
|
||||
}
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
Array.isArray(payload) ||
|
||||
typeof payload.access_token !== 'string' ||
|
||||
!payload.access_token.trim()
|
||||
) {
|
||||
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
|
||||
step: TOKEN_MINT_STEP,
|
||||
reason: 'token response missing access_token',
|
||||
})
|
||||
}
|
||||
if (
|
||||
typeof payload.expires_in !== 'number' ||
|
||||
!Number.isFinite(payload.expires_in) ||
|
||||
payload.expires_in <= 0
|
||||
) {
|
||||
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
|
||||
step: TOKEN_MINT_STEP,
|
||||
reason: 'token response missing a positive finite expires_in',
|
||||
})
|
||||
}
|
||||
if (
|
||||
typeof payload.token_type !== 'string' ||
|
||||
payload.token_type.trim().toLowerCase() !== 'bearer'
|
||||
) {
|
||||
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
|
||||
step: TOKEN_MINT_STEP,
|
||||
reason: 'token response missing bearer token_type',
|
||||
})
|
||||
}
|
||||
return {
|
||||
accessToken: payload.access_token.trim(),
|
||||
expiresInSeconds: Math.min(payload.expires_in, DEFAULT_TOKEN_EXPIRES_IN_SECONDS),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a short-lived SuiteTalk REST token with NetSuite's OAuth 2.0
|
||||
* client-credentials certificate flow. The account-specific SuiteTalk origin
|
||||
* determines both the token audience and the API origin returned to tools, so
|
||||
* no bearer token can be redirected to a caller-controlled host.
|
||||
*/
|
||||
export async function mintNetSuiteServiceAccountToken(
|
||||
fields: ClientCredentialAccountFields,
|
||||
options?: ClientCredentialAccountMintOptions
|
||||
): Promise<ClientCredentialAccountMintResult> {
|
||||
const origin = normalizeNetSuiteSuiteTalkOrigin(fields.orgId)
|
||||
if (!origin) {
|
||||
throw new TokenServiceAccountValidationError('site_not_found', 400, {
|
||||
step: 'netsuite_host_validation',
|
||||
reason: 'SuiteTalk URL must be an account-specific HTTPS origin with no extra URL parts',
|
||||
})
|
||||
}
|
||||
const clientId = fields.clientId.trim()
|
||||
const certificateId = fields.certificateId?.trim()
|
||||
if (!clientId || !certificateId) {
|
||||
throw invalidCredentials('client ID and certificate ID are required')
|
||||
}
|
||||
|
||||
const privateKey = loadNetSuitePrivateKey(fields.privateKey)
|
||||
const algorithm = getNetSuiteJwtAlgorithm(privateKey)
|
||||
const tokenUrl = `${origin}${TOKEN_PATH}`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
let assertion: string
|
||||
try {
|
||||
assertion = await new SignJWT({ scope: 'rest_webservices' })
|
||||
.setProtectedHeader({ typ: 'JWT', alg: algorithm, kid: certificateId })
|
||||
.setIssuer(clientId)
|
||||
.setAudience(tokenUrl)
|
||||
.setJti(generateId())
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + JWT_ASSERTION_LIFETIME_SECONDS)
|
||||
.sign(privateKey)
|
||||
} catch {
|
||||
throw invalidCredentials('private key could not sign a NetSuite client assertion')
|
||||
}
|
||||
|
||||
const minted = await exchangeNetSuiteToken(tokenUrl, assertion, fields)
|
||||
const hostname = new URL(origin).hostname
|
||||
const accountId = hostname.slice(0, -'.suitetalk.api.netsuite.com'.length)
|
||||
|
||||
return {
|
||||
...minted,
|
||||
instanceUrl: origin,
|
||||
...(!options?.skipIdentity
|
||||
? {
|
||||
identity: {
|
||||
displayName: `Oracle NetSuite ${accountId}`,
|
||||
principal: tenantPrincipal(accountId, hostname),
|
||||
auditMetadata: {
|
||||
netSuiteAccountId: accountId,
|
||||
netSuiteSuiteTalkOrigin: origin,
|
||||
},
|
||||
storedMetadata: { accountId, suiteTalkOrigin: origin },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
@@ -92,4 +92,29 @@ describe('parseClientCredentialAccountSecretBlob', () => {
|
||||
expect(parsed.clientSecret).toBe('secret')
|
||||
expect(parsed.authMethod).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires every descriptor field for a NetSuite certificate blob', () => {
|
||||
const netSuiteBlob = blob({
|
||||
providerId: 'netsuite-service-account',
|
||||
clientSecret: undefined,
|
||||
orgId: 'https://1234567.suitetalk.api.netsuite.com',
|
||||
certificateId: 'cert-1',
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----',
|
||||
})
|
||||
expect(
|
||||
parseClientCredentialAccountSecretBlob(netSuiteBlob, 'netsuite-service-account')
|
||||
).toMatchObject({ certificateId: 'cert-1' })
|
||||
|
||||
expect(() =>
|
||||
parseClientCredentialAccountSecretBlob(
|
||||
blob({
|
||||
providerId: 'netsuite-service-account',
|
||||
clientSecret: undefined,
|
||||
orgId: 'https://1234567.suitetalk.api.netsuite.com',
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----',
|
||||
}),
|
||||
'netsuite-service-account'
|
||||
)
|
||||
).toThrow(MALFORMED)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,16 @@ import {
|
||||
BOX_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE,
|
||||
type ClientCredentialAccountProviderId,
|
||||
getClientCredentialAccountDescriptor,
|
||||
isClientCredentialAccountProviderId,
|
||||
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
partitionClientCredentialFields,
|
||||
SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
} from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box'
|
||||
import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite'
|
||||
import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce'
|
||||
import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk'
|
||||
import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom'
|
||||
@@ -16,14 +20,17 @@ import type { ServiceAccountPrincipal } from '@/lib/credentials/principal'
|
||||
/** Raw fields a client-credential minter receives (already trimmed). */
|
||||
export interface ClientCredentialAccountFields {
|
||||
clientId: string
|
||||
/** Certificate mapping identifier used as the JWT `kid` by NetSuite. */
|
||||
certificateId?: string
|
||||
/**
|
||||
* Absent only when the provider's selected {@link authMethod} authenticates
|
||||
* with key material instead of a shared secret (Salesforce JWT bearer).
|
||||
* Absent when the provider authenticates with key material instead of a
|
||||
* shared secret (for example NetSuite or Salesforce JWT bearer).
|
||||
*/
|
||||
clientSecret?: string
|
||||
/**
|
||||
* Provider-specific org identifier (Zoom Account ID, Box Enterprise ID,
|
||||
* Salesforce My Domain host, Zoho Desk organization ID).
|
||||
* Salesforce My Domain host, Zoho Desk organization ID, or NetSuite
|
||||
* SuiteTalk origin).
|
||||
*/
|
||||
orgId: string
|
||||
/**
|
||||
@@ -40,8 +47,9 @@ export interface ClientCredentialAccountFields {
|
||||
*/
|
||||
authMethod?: string
|
||||
/**
|
||||
* PEM private key signing the assertion, for key-based grants (Salesforce
|
||||
* JWT bearer). Mutually exclusive with {@link clientSecret} in practice.
|
||||
* PEM private key signing the assertion for key-based providers and grants
|
||||
* (NetSuite or Salesforce JWT bearer). Mutually exclusive with
|
||||
* {@link clientSecret} in practice.
|
||||
*/
|
||||
privateKey?: string
|
||||
/** Username a key-based grant authenticates as (Salesforce JWT `sub`). */
|
||||
@@ -76,8 +84,8 @@ export interface ClientCredentialAccountMintResult {
|
||||
accessToken: string
|
||||
expiresInSeconds: number
|
||||
/**
|
||||
* Provider API base URL the minted token must be used against (Salesforce
|
||||
* `instance_url`), forwarded to tools alongside the token.
|
||||
* Provider API origin the minted token must be used against (Salesforce or
|
||||
* NetSuite), forwarded to tools alongside the token.
|
||||
*/
|
||||
instanceUrl?: string
|
||||
/**
|
||||
@@ -121,6 +129,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record<
|
||||
[BOX_SERVICE_ACCOUNT_PROVIDER_ID]: mintBoxServiceAccountToken,
|
||||
[SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken,
|
||||
[ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: mintZohoDeskServiceAccountToken,
|
||||
[NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID]: mintNetSuiteServiceAccountToken,
|
||||
}
|
||||
|
||||
export function getClientCredentialAccountMinter(
|
||||
@@ -140,6 +149,7 @@ export interface ClientCredentialAccountSecretBlob {
|
||||
type: typeof CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE
|
||||
providerId: string
|
||||
clientId: string
|
||||
certificateId?: string
|
||||
/** Absent on key-based credentials, which carry a {@link privateKey} instead. */
|
||||
clientSecret?: string
|
||||
orgId: string
|
||||
@@ -166,13 +176,20 @@ export function parseClientCredentialAccountSecretBlob(
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw malformed
|
||||
}
|
||||
// Requiring `clientSecret` outright would reject every key-based credential.
|
||||
const descriptor = getClientCredentialAccountDescriptor(expectedProviderId)
|
||||
if (
|
||||
parsed.type !== CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE ||
|
||||
parsed.providerId !== expectedProviderId ||
|
||||
!parsed.clientId ||
|
||||
!parsed.orgId ||
|
||||
(!parsed.clientSecret && !parsed.privateKey)
|
||||
!descriptor
|
||||
) {
|
||||
throw malformed
|
||||
}
|
||||
const { required } = partitionClientCredentialFields(descriptor, parsed.authMethod)
|
||||
if (
|
||||
required.some((field) => {
|
||||
const value = parsed[field.id]
|
||||
return typeof value !== 'string' || !value.trim()
|
||||
})
|
||||
) {
|
||||
throw malformed
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface PerformCreateCredentialParams {
|
||||
botToken?: string
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
certificateId?: string
|
||||
orgId?: string
|
||||
dataCenter?: string
|
||||
authMethod?: string
|
||||
@@ -250,6 +251,7 @@ export async function performCreateCredential(
|
||||
serviceAccountJson: params.serviceAccountJson,
|
||||
clientId: params.clientId,
|
||||
clientSecret: params.clientSecret,
|
||||
certificateId: params.certificateId,
|
||||
orgId: params.orgId,
|
||||
dataCenter: params.dataCenter,
|
||||
authMethod: params.authMethod,
|
||||
|
||||
@@ -369,6 +369,34 @@ describe('performUpdateCredential — service-account secret rotation', () => {
|
||||
expect(mockDecryptSecret).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('threads a NetSuite certificate ID through reconnect', async () => {
|
||||
mockCredential({
|
||||
providerId: 'netsuite-service-account',
|
||||
displayName: 'Production NetSuite',
|
||||
})
|
||||
mockIsClientCredentialAccountProviderId.mockReturnValue(true)
|
||||
mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({
|
||||
providerId: 'netsuite-service-account',
|
||||
encryptedServiceAccountKey: 'new-cipher',
|
||||
displayName: 'Production NetSuite',
|
||||
auditMetadata: {},
|
||||
})
|
||||
|
||||
await performUpdateCredential({
|
||||
credentialId: 'cred-1',
|
||||
userId: 'user-1',
|
||||
orgId: 'https://1234567.suitetalk.api.netsuite.com',
|
||||
clientId: 'client-id',
|
||||
certificateId: 'certificate-id',
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----rotated',
|
||||
})
|
||||
|
||||
expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith(
|
||||
'netsuite-service-account',
|
||||
expect.objectContaining({ certificateId: 'certificate-id' })
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a rebuild failure as a validation error and writes nothing', async () => {
|
||||
mockCredential()
|
||||
mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL })
|
||||
|
||||
@@ -142,6 +142,7 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams {
|
||||
/** Client-credential service-account secret rotation (reconnect). */
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
certificateId?: string
|
||||
orgId?: string
|
||||
dataCenter?: string
|
||||
authMethod?: string
|
||||
@@ -206,6 +207,7 @@ export async function performUpdateCredential(
|
||||
params.domain !== undefined ||
|
||||
params.clientId !== undefined ||
|
||||
params.clientSecret !== undefined ||
|
||||
params.certificateId !== undefined ||
|
||||
params.orgId !== undefined ||
|
||||
params.dataCenter !== undefined ||
|
||||
params.authMethod !== undefined ||
|
||||
@@ -256,6 +258,7 @@ export async function performUpdateCredential(
|
||||
serviceAccountJson: params.serviceAccountJson,
|
||||
clientId: params.clientId,
|
||||
clientSecret: params.clientSecret,
|
||||
certificateId: params.certificateId,
|
||||
orgId: params.orgId,
|
||||
dataCenter: needsStoredDataCenter
|
||||
? readStoredField(storedBlob, 'dataCenter')
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ServiceAccountFieldId =
|
||||
| 'botToken'
|
||||
| 'clientId'
|
||||
| 'clientSecret'
|
||||
| 'certificateId'
|
||||
| 'orgId'
|
||||
| 'dataCenter'
|
||||
| 'authMethod'
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('isServiceAccountProviderId', () => {
|
||||
expect(isServiceAccountProviderId('slack-custom-bot')).toBe(true)
|
||||
expect(isServiceAccountProviderId('notion-service-account')).toBe(true)
|
||||
expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true)
|
||||
expect(isServiceAccountProviderId('netsuite-service-account')).toBe(true)
|
||||
})
|
||||
|
||||
it('is case- and whitespace-insensitive', () => {
|
||||
@@ -50,6 +51,7 @@ describe('getServiceAccountConnectNoun', () => {
|
||||
|
||||
it('names the client-credential secret', () => {
|
||||
expect(getServiceAccountConnectNoun('zoom-service-account')).toBe('server-to-server app')
|
||||
expect(getServiceAccountConnectNoun('netsuite-service-account')).toBe('OAuth certificate')
|
||||
})
|
||||
|
||||
it('calls a custom Slack bot a custom bot', () => {
|
||||
|
||||
@@ -41,7 +41,9 @@ vi.mock('@/lib/api/server', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({
|
||||
getClientCredentialAccountMinter: (providerId: string) =>
|
||||
providerId === 'zoom-service-account' || providerId === 'box-service-account'
|
||||
providerId === 'zoom-service-account' ||
|
||||
providerId === 'box-service-account' ||
|
||||
providerId === 'netsuite-service-account'
|
||||
? mockClientCredentialMinter
|
||||
: undefined,
|
||||
}))
|
||||
@@ -227,6 +229,38 @@ describe('verifyAndBuildServiceAccountSecret', () => {
|
||||
expect(blob.metadata).toEqual({ principalKind: 'none' })
|
||||
})
|
||||
|
||||
it('threads NetSuite certificate material into the minter and encrypted blob', async () => {
|
||||
mockClientCredentialMinter.mockResolvedValue({
|
||||
accessToken: 'minted',
|
||||
expiresInSeconds: 3600,
|
||||
instanceUrl: 'https://1234567.suitetalk.api.netsuite.com',
|
||||
identity: {
|
||||
displayName: 'Oracle NetSuite 1234567',
|
||||
principal: { kind: 'tenant', id: '1234567' },
|
||||
auditMetadata: { netSuiteAccountId: '1234567' },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await verifyAndBuildServiceAccountSecret('netsuite-service-account', {
|
||||
orgId: ' https://1234567.suitetalk.api.netsuite.com/ ',
|
||||
clientId: ' client-id ',
|
||||
certificateId: ' certificate-id ',
|
||||
privateKey: ' -----BEGIN PRIVATE KEY-----key ',
|
||||
})
|
||||
|
||||
expect(mockClientCredentialMinter).toHaveBeenCalledWith({
|
||||
orgId: 'https://1234567.suitetalk.api.netsuite.com/',
|
||||
clientId: 'client-id',
|
||||
certificateId: 'certificate-id',
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----key',
|
||||
})
|
||||
expect(JSON.parse(result.encryptedServiceAccountKey)).toMatchObject({
|
||||
providerId: 'netsuite-service-account',
|
||||
certificateId: 'certificate-id',
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----key',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when client-credential required fields are missing, without minting', async () => {
|
||||
await expect(
|
||||
verifyAndBuildServiceAccountSecret('zoom-service-account', {
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface ServiceAccountSecretFields {
|
||||
serviceAccountJson?: string
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
certificateId?: string
|
||||
orgId?: string
|
||||
dataCenter?: string
|
||||
authMethod?: string
|
||||
@@ -262,12 +263,11 @@ async function buildTokenServiceAccountSecret(
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a client-credential service-account secret (OAuth client id/secret +
|
||||
* provider org identifier) for any provider registered in
|
||||
* `CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS`: verifies the triple by minting a
|
||||
* real access token via the provider's registered minter (also capturing the
|
||||
* derived identity for the display name and audit log), then persists the raw
|
||||
* fields in the encrypted blob so execution-time resolution can re-mint.
|
||||
* Builds a client-credential service-account secret for any provider registered
|
||||
* in `CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS`: verifies the provider-specific
|
||||
* descriptor fields by minting a real access token (also capturing the derived
|
||||
* identity for the display name and audit log), then persists those fields in
|
||||
* the encrypted blob so execution-time resolution can re-mint.
|
||||
*/
|
||||
async function buildClientCredentialAccountSecret(
|
||||
providerId: string,
|
||||
@@ -295,6 +295,9 @@ async function buildClientCredentialAccountSecret(
|
||||
// the unused one encrypted at rest on the credential.
|
||||
const submitted: ClientCredentialAccountFields = {
|
||||
clientId: fields.clientId?.trim() ?? '',
|
||||
certificateId: usesField('certificateId')
|
||||
? fields.certificateId?.trim() || undefined
|
||||
: undefined,
|
||||
orgId: fields.orgId?.trim() ?? '',
|
||||
dataCenter: fields.dataCenter?.trim() || undefined,
|
||||
authMethod: resolvedAuthMethod,
|
||||
|
||||
@@ -63,6 +63,9 @@ const EXPECTED_COVERAGE: Record<string, string[]> = {
|
||||
'linear-service-account': ['linear'],
|
||||
'monday-service-account': ['monday'],
|
||||
'notion-service-account': ['notion'],
|
||||
// NetSuite remains an API-key catalog integration, like Snowflake, while its
|
||||
// block uses the shared reusable-credential selector.
|
||||
'netsuite-service-account': [],
|
||||
'pipedrive-service-account': ['pipedrive'],
|
||||
'salesforce-service-account': ['salesforce'],
|
||||
'shopify-service-account': ['shopify'],
|
||||
@@ -95,6 +98,16 @@ const serviceAccount = (providerId: string) => ({
|
||||
})
|
||||
|
||||
describe('service-account coverage', () => {
|
||||
it('exposes NetSuite reusable credentials without changing its API-key catalog class', () => {
|
||||
const netSuiteIntegration = INTEGRATIONS.find((integration) => integration.type === 'netsuite')
|
||||
expect(netSuiteIntegration?.authType).toBe('api-key')
|
||||
expect(OAUTH_PROVIDERS.netsuite.services.netsuite).toMatchObject({
|
||||
providerId: 'netsuite',
|
||||
serviceAccountProviderId: 'netsuite-service-account',
|
||||
authType: 'service_account',
|
||||
})
|
||||
})
|
||||
|
||||
it('pins the table to exactly the registered service-account provider ids', () => {
|
||||
expect(REGISTERED_SERVICE_ACCOUNT_IDS).toEqual(Object.keys(EXPECTED_COVERAGE).sort())
|
||||
})
|
||||
|
||||
@@ -155,6 +155,7 @@ import {
|
||||
MongoDBIcon,
|
||||
MySQLIcon,
|
||||
Neo4jIcon,
|
||||
NetSuiteIcon,
|
||||
NeverBounceIcon,
|
||||
NewRelicIcon,
|
||||
NotionIcon,
|
||||
@@ -424,6 +425,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
mongodb: MongoDBIcon,
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
netsuite: NetSuiteIcon,
|
||||
neverbounce: NeverBounceIcon,
|
||||
new_relic: NewRelicIcon,
|
||||
notion: NotionIcon,
|
||||
|
||||
@@ -13763,6 +13763,133 @@
|
||||
"integrationType": "documents",
|
||||
"tags": ["microsoft-365", "cloud", "document-processing"]
|
||||
},
|
||||
{
|
||||
"type": "netsuite",
|
||||
"slug": "oracle-netsuite",
|
||||
"name": "Oracle NetSuite",
|
||||
"description": "Manage NetSuite records, queries, datasets, batches, and async jobs",
|
||||
"longDescription": "Connect a reusable Oracle NetSuite service-account credential to SuiteTalk REST Web Services. Read and write account-specific records, execute SuiteQL and SuiteAnalytics datasets, run asynchronous record batches, inspect metadata, and monitor async jobs.",
|
||||
"bgColor": "#FFFFFF",
|
||||
"iconName": "NetSuiteIcon",
|
||||
"docsUrl": "https://docs.sim.ai/integrations/netsuite",
|
||||
"operations": [
|
||||
{
|
||||
"name": "List/Search Records",
|
||||
"description": "List one page of a NetSuite record collection, optionally filtered with a q expression."
|
||||
},
|
||||
{
|
||||
"name": "Get Record",
|
||||
"description": "Retrieve one NetSuite record by internal or external ID."
|
||||
},
|
||||
{
|
||||
"name": "Create Record",
|
||||
"description": "Create a NetSuite record using the account-specific record metadata schema."
|
||||
},
|
||||
{
|
||||
"name": "Update Record",
|
||||
"description": "Update fields on an existing NetSuite record with PATCH."
|
||||
},
|
||||
{
|
||||
"name": "Upsert Record",
|
||||
"description": "Create or update a NetSuite record by external ID with PUT."
|
||||
},
|
||||
{
|
||||
"name": "Delete Record",
|
||||
"description": "Delete one NetSuite record by internal or external ID."
|
||||
},
|
||||
{
|
||||
"name": "Get Subresource",
|
||||
"description": "Retrieve a record sublist, subrecord, referenced record, or nested subresource."
|
||||
},
|
||||
{
|
||||
"name": "Get Record Form",
|
||||
"description": "Return a prepopulated create form, or an edit form when a record ID is supplied."
|
||||
},
|
||||
{
|
||||
"name": "Get Select Options",
|
||||
"description": "Retrieve valid select values for one or more fields on a new or existing record."
|
||||
},
|
||||
{
|
||||
"name": "Attach Record or File",
|
||||
"description": "Attach a contact or file to another NetSuite record."
|
||||
},
|
||||
{
|
||||
"name": "Detach Record or File",
|
||||
"description": "Detach a contact or file from another NetSuite record."
|
||||
},
|
||||
{
|
||||
"name": "Execute Record Action",
|
||||
"description": "Execute a supported NetSuite record action such as approve, reject, or confirm."
|
||||
},
|
||||
{
|
||||
"name": "Transform Record",
|
||||
"description": "Transform a supported source record into another NetSuite record type."
|
||||
},
|
||||
{
|
||||
"name": "Batch Get Records",
|
||||
"description": "Submit an asynchronous request to retrieve up to 100 records of one type."
|
||||
},
|
||||
{
|
||||
"name": "Batch Create Records",
|
||||
"description": "Submit an asynchronous batch that creates up to 100 records of one type."
|
||||
},
|
||||
{
|
||||
"name": "Batch Update Records",
|
||||
"description": "Submit an asynchronous batch that updates up to 100 records of one type."
|
||||
},
|
||||
{
|
||||
"name": "Batch Upsert Records",
|
||||
"description": "Submit an asynchronous batch that creates or updates up to 100 records by external ID."
|
||||
},
|
||||
{
|
||||
"name": "Batch Delete Records",
|
||||
"description": "Submit an asynchronous request to delete up to 100 records of one type."
|
||||
},
|
||||
{
|
||||
"name": "Execute SuiteQL",
|
||||
"description": "Execute one page of a SuiteQL query through SuiteTalk REST web services."
|
||||
},
|
||||
{
|
||||
"name": "List SuiteAnalytics Datasets",
|
||||
"description": "List one page of SuiteAnalytics Workbook datasets available to the authenticated role."
|
||||
},
|
||||
{
|
||||
"name": "Execute SuiteAnalytics Dataset",
|
||||
"description": "Execute one page of a standard or custom SuiteAnalytics Workbook dataset."
|
||||
},
|
||||
{
|
||||
"name": "List Record Types",
|
||||
"description": "List record types exposed to the authenticated role by the REST metadata catalog."
|
||||
},
|
||||
{
|
||||
"name": "Get Record Metadata",
|
||||
"description": "Retrieve account-specific metadata for one NetSuite record type."
|
||||
},
|
||||
{
|
||||
"name": "Get Async Status",
|
||||
"description": "Retrieve job status, list job tasks, or retrieve one task status."
|
||||
},
|
||||
{
|
||||
"name": "Get Async Operation Result",
|
||||
"description": "Retrieve the provider response for one task within a completed asynchronous job."
|
||||
},
|
||||
{
|
||||
"name": "Get Server Time",
|
||||
"description": "Retrieve the current UTC time from the NetSuite server."
|
||||
},
|
||||
{
|
||||
"name": "Get Governance Limits",
|
||||
"description": "Retrieve REST web-services concurrency limits for the NetSuite account and integration; NetSuite requires an Administrator role."
|
||||
}
|
||||
],
|
||||
"operationCount": 27,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
"category": "tools",
|
||||
"integrationType": "commerce",
|
||||
"tags": ["automation", "data-analytics", "payments"]
|
||||
},
|
||||
{
|
||||
"type": "outlook",
|
||||
"slug": "outlook",
|
||||
|
||||
@@ -352,7 +352,7 @@ export interface ServiceAccountTokenResult {
|
||||
cloudId?: string
|
||||
/** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */
|
||||
domain?: string
|
||||
/** Salesforce only — the org's instance URL the token must be used against. */
|
||||
/** Salesforce or NetSuite — the provider origin the token must be used against. */
|
||||
instanceUrl?: string
|
||||
/**
|
||||
* Zoho Desk only — the data-center-scoped Desk REST base the token must be
|
||||
@@ -400,7 +400,7 @@ interface CachedClientCredentialToken {
|
||||
* the cached token belongs to the old app and must be re-minted.
|
||||
*/
|
||||
secretFingerprint: string
|
||||
/** Salesforce only — the instance URL returned alongside the minted token. */
|
||||
/** Salesforce or NetSuite — the provider origin returned alongside the minted token. */
|
||||
instanceUrl?: string
|
||||
/** Zoho Desk only — the Desk REST base derived from the token's api_domain. */
|
||||
apiDomain?: string
|
||||
@@ -415,11 +415,11 @@ interface FailedClientCredentialMint {
|
||||
|
||||
/**
|
||||
* Per-instance cache of minted client-credential access tokens (Zoom S2S,
|
||||
* Box CCG, Salesforce client-credentials), keyed by credential id. Entries are
|
||||
* Box CCG, Salesforce, NetSuite), keyed by credential id. Entries are
|
||||
* served while more than {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} of
|
||||
* validity remains, so a hot credential mints roughly once per token TTL
|
||||
* (~1h for Zoom/Box; Salesforce reports a conservative 10-minute TTL because
|
||||
* its responses never carry an expiry) per instance.
|
||||
* (~1h for Zoom/Box/NetSuite; Salesforce reports a conservative 10-minute TTL
|
||||
* because its responses never carry an expiry) per instance.
|
||||
*
|
||||
* Every resolution re-reads the credential row (a cheap indexed PK select —
|
||||
* the mint is the expensive part) and validates the cached entry's secret
|
||||
@@ -525,6 +525,7 @@ async function resolveClientCredentialAccountToken(
|
||||
{
|
||||
clientId: blob.clientId,
|
||||
clientSecret: blob.clientSecret,
|
||||
certificateId: blob.certificateId,
|
||||
orgId: blob.orgId,
|
||||
dataCenter: blob.dataCenter,
|
||||
authMethod: blob.authMethod,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
MicrosoftSharepointIcon,
|
||||
MicrosoftTeamsIcon,
|
||||
MondayIcon,
|
||||
NetSuiteIcon,
|
||||
NotionIcon,
|
||||
OutlookIcon,
|
||||
PipedriveIcon,
|
||||
@@ -861,6 +862,24 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
defaultService: 'snowflake',
|
||||
},
|
||||
netsuite: {
|
||||
name: 'Oracle NetSuite',
|
||||
icon: NetSuiteIcon,
|
||||
services: {
|
||||
netsuite: {
|
||||
name: 'Oracle NetSuite',
|
||||
description:
|
||||
'Manage NetSuite records, queries, datasets, batches, metadata, and asynchronous jobs.',
|
||||
providerId: 'netsuite',
|
||||
serviceAccountProviderId: 'netsuite-service-account',
|
||||
icon: NetSuiteIcon,
|
||||
baseProviderIcon: NetSuiteIcon,
|
||||
scopes: [],
|
||||
authType: 'service_account',
|
||||
},
|
||||
},
|
||||
defaultService: 'netsuite',
|
||||
},
|
||||
reddit: {
|
||||
name: 'Reddit',
|
||||
icon: RedditIcon,
|
||||
|
||||
@@ -114,6 +114,15 @@ describe('buildSelectorContextFromBlock', () => {
|
||||
expect(ctx.workspaceId).toBe('ws-123')
|
||||
})
|
||||
|
||||
it('exposes the NetSuite async job ID to dependent task selectors', () => {
|
||||
const ctx = buildSelectorContextFromBlock('netsuite', {
|
||||
operation: { id: 'operation', type: 'dropdown', value: 'netsuite_get_async_status' },
|
||||
jobId: { id: 'jobId', type: 'short-input', value: 'job-7' },
|
||||
})
|
||||
|
||||
expect(ctx.jobId).toBe('job-7')
|
||||
})
|
||||
|
||||
it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => {
|
||||
const ctx = buildSelectorContextFromBlock('knowledge', {
|
||||
operation: { id: 'operation', type: 'dropdown', value: 'search' },
|
||||
|
||||
@@ -37,6 +37,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
|
||||
'awsRegion',
|
||||
'logGroupName',
|
||||
'tableId',
|
||||
'jobId',
|
||||
'orgId',
|
||||
'database',
|
||||
'schema',
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,96 @@
|
||||
import type { NetSuiteAttachParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizeRelatedType,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteAttachRecordTool: ToolConfig<NetSuiteAttachParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_attach_record',
|
||||
name: 'NetSuite Attach Record or File',
|
||||
description: 'Attach a contact or file to another NetSuite record.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite internal ID or an external-ID reference beginning with eid:',
|
||||
},
|
||||
relatedType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Related resource type: contact or file',
|
||||
},
|
||||
relatedId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Internal ID, or external ID prefixed with eid:, of the contact or file',
|
||||
},
|
||||
roleId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional contact role internal ID',
|
||||
},
|
||||
roleExternalId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional contact role external ID',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const relatedType = normalizeRelatedType(params.relatedType)
|
||||
const roleId = optionalTrim(params.roleId)
|
||||
const roleExternalId = optionalTrim(params.roleExternalId)
|
||||
if (roleId && roleExternalId) {
|
||||
throw new Error('Provide either a contact role ID or external ID, not both')
|
||||
}
|
||||
if (relatedType === 'file' && (roleId || roleExternalId)) {
|
||||
throw new Error('Contact roles cannot be provided when attaching a file')
|
||||
}
|
||||
return {
|
||||
method: 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
{ value: '!attach', label: 'Attach operation' },
|
||||
{ value: relatedType, label: 'Related type' },
|
||||
{ value: params.relatedId, label: 'Related ID' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
body: roleId
|
||||
? { role: { id: roleId } }
|
||||
: roleExternalId
|
||||
? { role: { externalId: roleExternalId } }
|
||||
: {},
|
||||
}
|
||||
},
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 204 No Content response',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { NetSuiteBatchWriteParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildBatchWriteRequest,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteBatchCreateRecordsTool: ToolConfig<
|
||||
NetSuiteBatchWriteParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_batch_create_records',
|
||||
name: 'NetSuite Batch Create Records',
|
||||
description: 'Submit an asynchronous batch that creates up to 100 records of one type.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Array of 1-100 records matching the account-specific metadata schema',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
idempotencyKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional unique idempotency key for retrying the batch',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(params, () => buildBatchWriteRequest('POST', params), signal),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 202 Accepted submission response',
|
||||
nullable: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job URL from the Location response header',
|
||||
optional: true,
|
||||
},
|
||||
jobId: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job ID parsed from the Location header',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { NetSuiteBatchDeleteParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizeBatchIds,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteBatchDeleteRecordsTool: ToolConfig<
|
||||
NetSuiteBatchDeleteParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_batch_delete_records',
|
||||
name: 'NetSuite Batch Delete Records',
|
||||
description: 'Submit an asynchronous request to delete up to 100 records of one type.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
ids: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Up to 100 comma-separated internal IDs or eid: external-ID references',
|
||||
},
|
||||
idempotencyKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional unique idempotency key for retrying the batch',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key')
|
||||
return {
|
||||
method: 'DELETE',
|
||||
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
|
||||
success: { status: 202, body: 'none' },
|
||||
responseLocation: 'async-job',
|
||||
query: { ids: normalizeBatchIds(params.ids) },
|
||||
headers: {
|
||||
Prefer: 'respond-async',
|
||||
...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 202 Accepted submission response',
|
||||
nullable: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job URL from the Location response header',
|
||||
optional: true,
|
||||
},
|
||||
jobId: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job ID parsed from the Location header',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { NetSuiteBatchGetParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizeBatchIds,
|
||||
normalizeOptionalBoolean,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteBatchGetRecordsTool: ToolConfig<NetSuiteBatchGetParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_batch_get_records',
|
||||
name: 'NetSuite Batch Get Records',
|
||||
description: 'Submit an asynchronous request to retrieve up to 100 records of one type.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
ids: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Up to 100 comma-separated internal IDs or eid: external-ID references',
|
||||
},
|
||||
fields: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated record fields to return',
|
||||
},
|
||||
expand: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated resources to expand when supported by the record metadata',
|
||||
},
|
||||
expandSubResources: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to expand sublists and subrecords in the response',
|
||||
},
|
||||
idempotencyKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional unique idempotency key for retrying the asynchronous request',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key')
|
||||
return {
|
||||
method: 'GET',
|
||||
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
|
||||
success: { status: 202, body: 'none' },
|
||||
responseLocation: 'async-job',
|
||||
query: {
|
||||
expandRecords: true,
|
||||
ids: normalizeBatchIds(params.ids),
|
||||
fields: optionalTrim(params.fields, 'Fields'),
|
||||
expand: optionalTrim(params.expand, 'Expand'),
|
||||
expandSubResources: normalizeOptionalBoolean(
|
||||
params.expandSubResources,
|
||||
'Expand subresources'
|
||||
),
|
||||
},
|
||||
headers: {
|
||||
Prefer: 'respond-async',
|
||||
...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 202 Accepted submission response',
|
||||
nullable: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job URL from the Location response header',
|
||||
optional: true,
|
||||
},
|
||||
jobId: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job ID parsed from the Location header',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { NetSuiteBatchWriteParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildBatchWriteRequest,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteBatchUpdateRecordsTool: ToolConfig<
|
||||
NetSuiteBatchWriteParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_batch_update_records',
|
||||
name: 'NetSuite Batch Update Records',
|
||||
description: 'Submit an asynchronous batch that updates up to 100 records of one type.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Array of 1-100 records; every item must include an internal or external ID',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
idempotencyKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional unique idempotency key for retrying the batch',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(params, () => buildBatchWriteRequest('PATCH', params), signal),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 202 Accepted submission response',
|
||||
nullable: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job URL from the Location response header',
|
||||
optional: true,
|
||||
},
|
||||
jobId: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job ID parsed from the Location header',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { NetSuiteBatchWriteParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildBatchWriteRequest,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteBatchUpsertRecordsTool: ToolConfig<
|
||||
NetSuiteBatchWriteParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_batch_upsert_records',
|
||||
name: 'NetSuite Batch Upsert Records',
|
||||
description:
|
||||
'Submit an asynchronous batch that creates or updates up to 100 records by external ID.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Array of 1-100 records; every item must include externalId',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
idempotencyKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional unique idempotency key for retrying the batch',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(params, () => buildBatchWriteRequest('PUT', params), signal),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 202 Accepted submission response',
|
||||
nullable: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job URL from the Location response header',
|
||||
optional: true,
|
||||
},
|
||||
jobId: {
|
||||
type: 'string',
|
||||
description: 'Asynchronous job ID parsed from the Location header',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { NetSuiteCreateRecordParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteCreateRecordTool: ToolConfig<NetSuiteCreateRecordParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_create_record',
|
||||
name: 'NetSuite Create Record',
|
||||
description: 'Create a NetSuite record using the account-specific record metadata schema.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
body: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Record fields matching the account-specific NetSuite metadata schema',
|
||||
},
|
||||
replace: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Comma-separated sublists whose default lines should be replaced',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const replace = optionalTrim(params.replace, 'Replace sublists')
|
||||
return {
|
||||
method: 'POST',
|
||||
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
|
||||
success: replace ? { status: 201, body: 'object' } : { status: 204, body: 'none' },
|
||||
responseLocation: 'resource',
|
||||
query: { replace },
|
||||
body: params.body,
|
||||
}
|
||||
},
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Empty for standard HTTP 204 creation; replacement creation can return the documented HTTP 201 post-state object',
|
||||
nullable: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Newly created record URL from the Location response header',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { NetSuiteDeleteRecordParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteDeleteRecordTool: ToolConfig<NetSuiteDeleteRecordParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_delete_record',
|
||||
name: 'NetSuite Delete Record',
|
||||
description: 'Delete one NetSuite record by internal or external ID.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite internal ID or an external-ID reference beginning with eid:',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'DELETE',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 204 No Content response',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { NetSuiteRelationshipParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizeRelatedType,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteDetachRecordTool: ToolConfig<NetSuiteRelationshipParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_detach_record',
|
||||
name: 'NetSuite Detach Record or File',
|
||||
description: 'Detach a contact or file from another NetSuite record.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite internal ID or an external-ID reference beginning with eid:',
|
||||
},
|
||||
relatedType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Related resource type: contact or file',
|
||||
},
|
||||
relatedId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Internal ID, or external ID prefixed with eid:, of the contact or file',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
{ value: '!detach', label: 'Detach operation' },
|
||||
{ value: normalizeRelatedType(params.relatedType), label: 'Related type' },
|
||||
{ value: params.relatedId, label: 'Related ID' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 204 No Content response',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { NetSuiteExecuteActionParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
requiredTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteExecuteActionTool: ToolConfig<NetSuiteExecuteActionParams, NetSuiteResponse> =
|
||||
{
|
||||
id: 'netsuite_execute_action',
|
||||
name: 'NetSuite Execute Record Action',
|
||||
description: 'Execute a supported NetSuite record action such as approve, reject, or confirm.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite internal ID or an external-ID reference beginning with eid:',
|
||||
},
|
||||
action: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite record action ID without the @ prefix',
|
||||
},
|
||||
body: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Parameters accepted by the selected NetSuite record action',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
{ value: `@${requiredTrim(params.action, 'Action')}`, label: 'Action' }
|
||||
),
|
||||
success: { status: 200, body: 'object', validator: 'record-action' },
|
||||
body: params.body ?? {},
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Documented NetSuite record-action response',
|
||||
nullable: true,
|
||||
properties: {
|
||||
result: {
|
||||
type: 'boolean',
|
||||
description: 'True when NetSuite completed the record action',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { NetSuiteExecuteDatasetParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
encodePathSegment,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizePagination,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteExecuteDatasetTool: ToolConfig<
|
||||
NetSuiteExecuteDatasetParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_execute_dataset',
|
||||
name: 'NetSuite Execute SuiteAnalytics Dataset',
|
||||
description: 'Execute one page of a standard or custom SuiteAnalytics Workbook dataset.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
datasetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'SuiteAnalytics dataset script ID',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 100,
|
||||
description: 'Results to return in this page (1-1000; default 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 0,
|
||||
description:
|
||||
'Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: `/services/rest/query/v1/dataset/${encodePathSegment(params.datasetId, 'Dataset ID')}/result`,
|
||||
success: { status: 200, body: 'object', validator: 'collection-page' },
|
||||
query: normalizePagination(params.limit, params.offset),
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'One documented NetSuite collection page',
|
||||
nullable: true,
|
||||
properties: {
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Oracle HATEOAS links for the response',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'Items in this page; item fields depend on the record, query, or dataset',
|
||||
optional: true,
|
||||
items: { type: 'json', description: 'Account-specific NetSuite item' },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of items in this page', optional: true },
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether another page is available',
|
||||
optional: true,
|
||||
},
|
||||
offset: { type: 'number', description: 'Offset of this page', optional: true },
|
||||
totalResults: {
|
||||
type: 'number',
|
||||
description: 'Total number of matching items',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { NetSuiteResponse, NetSuiteSuiteQLParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizePagination,
|
||||
requiredTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteExecuteSuiteQLTool: ToolConfig<NetSuiteSuiteQLParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_execute_suiteql',
|
||||
name: 'NetSuite Execute SuiteQL',
|
||||
description: 'Execute one page of a SuiteQL query through SuiteTalk REST web services.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'SuiteQL SELECT query; use a complete unique ORDER BY when retrieving multiple pages',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 100,
|
||||
description: 'Results to return in this page (1-1000; default 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 0,
|
||||
description:
|
||||
'Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'POST',
|
||||
path: '/services/rest/query/v1/suiteql',
|
||||
success: { status: 200, body: 'object', validator: 'suiteql-page' },
|
||||
query: normalizePagination(params.limit, params.offset),
|
||||
headers: { Prefer: 'transient' },
|
||||
body: { q: requiredTrim(params.query, 'SuiteQL query') },
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'One documented NetSuite collection page',
|
||||
nullable: true,
|
||||
properties: {
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Oracle HATEOAS links for the response',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'Items in this page; item fields depend on the record, query, or dataset',
|
||||
optional: true,
|
||||
items: { type: 'json', description: 'Account-specific NetSuite item' },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of items in this page', optional: true },
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether another page is available',
|
||||
optional: true,
|
||||
},
|
||||
offset: { type: 'number', description: 'Offset of this page', optional: true },
|
||||
totalResults: {
|
||||
type: 'number',
|
||||
description: 'Total number of matching items',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { NetSuiteGetAsyncResultParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
encodePathSegment,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteGetAsyncResultTool: ToolConfig<
|
||||
NetSuiteGetAsyncResultParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_get_async_result',
|
||||
name: 'NetSuite Get Async Operation Result',
|
||||
description: 'Retrieve the provider response for one task within a completed asynchronous job.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
jobId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Asynchronous job ID',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Task ID within the asynchronous job',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: `/services/rest/async/v1/job/${encodePathSegment(params.jobId, 'Job ID')}/task/${encodePathSegment(params.taskId, 'Task ID')}/result`,
|
||||
success: { status: 200, body: 'optional-object' },
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Result payload for the submitted asynchronous operation; record fields are account-specific and dynamic',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { NetSuiteGetAsyncStatusParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
encodePathSegment,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
requiredTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteGetAsyncStatusTool: ToolConfig<
|
||||
NetSuiteGetAsyncStatusParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_get_async_status',
|
||||
name: 'NetSuite Get Async Status',
|
||||
description: 'Retrieve job status, list job tasks, or retrieve one task status.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
jobId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Asynchronous job ID',
|
||||
},
|
||||
view: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 'job',
|
||||
description: 'Retrieve job status, list tasks for the job, or retrieve one task status',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Task ID; required when view is task',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const view = params.view ?? 'job'
|
||||
if (view !== 'job' && view !== 'tasks' && view !== 'task') {
|
||||
throw new Error('Async status view must be job, tasks, or task')
|
||||
}
|
||||
const jobPath = `/services/rest/async/v1/job/${encodePathSegment(params.jobId, 'Job ID')}`
|
||||
if (view === 'job') {
|
||||
return {
|
||||
method: 'GET',
|
||||
path: jobPath,
|
||||
success: { status: 200, body: 'object', validator: 'async-job' },
|
||||
}
|
||||
}
|
||||
const taskPath =
|
||||
view === 'task'
|
||||
? `/${encodePathSegment(requiredTrim(params.taskId ?? '', 'Task ID'), 'Task ID')}`
|
||||
: ''
|
||||
return {
|
||||
method: 'GET',
|
||||
path: `${jobPath}/task${taskPath}`,
|
||||
success: {
|
||||
status: 200,
|
||||
body: 'object',
|
||||
validator: view === 'task' ? 'async-task' : 'async-task-collection',
|
||||
},
|
||||
}
|
||||
},
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Documented NetSuite asynchronous job, task collection, or task status',
|
||||
nullable: true,
|
||||
properties: {
|
||||
completed: {
|
||||
type: 'boolean',
|
||||
description: 'Whether processing has completed',
|
||||
optional: true,
|
||||
},
|
||||
endTime: { type: 'string', description: 'Task completion time', optional: true },
|
||||
id: { type: 'string', description: 'Asynchronous job or task ID', optional: true },
|
||||
progress: { type: 'string', description: 'Current task progress state', optional: true },
|
||||
startTime: { type: 'string', description: 'Task start time', optional: true },
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Number of task collection entries returned',
|
||||
optional: true,
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'Collection entries containing links to one or more asynchronous tasks',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'json',
|
||||
properties: {
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Links to individual tasks',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'HATEOAS links for the job or task collection',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
task: {
|
||||
type: 'object',
|
||||
description: 'Link container for the tasks belonging to this asynchronous job',
|
||||
optional: true,
|
||||
properties: {
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Links to the job task collection',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { NetSuiteResponse, NetSuiteSystemParams } from '@/tools/netsuite/types'
|
||||
import { executeNetSuiteRequest, netsuiteAuthParamFields } from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteGetGovernanceLimitsTool: ToolConfig<NetSuiteSystemParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_get_governance_limits',
|
||||
name: 'NetSuite Get Governance Limits',
|
||||
description:
|
||||
'Retrieve REST web-services concurrency limits for the NetSuite account and integration; NetSuite requires an Administrator role.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: '/services/rest/system/v1/governanceLimits',
|
||||
success: { status: 200, body: 'object', validator: 'governance-limits' },
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Documented NetSuite governance limits',
|
||||
nullable: true,
|
||||
properties: {
|
||||
accountConcurrencyLimit: { type: 'number', description: 'Account concurrency limit' },
|
||||
accountUnallocatedConcurrencyLimit: {
|
||||
type: 'number',
|
||||
description: 'Account concurrency not allocated to integrations',
|
||||
},
|
||||
integrationConcurrencyLimit: {
|
||||
type: 'number',
|
||||
description: 'Concurrency allocated to this integration',
|
||||
optional: true,
|
||||
},
|
||||
integrationLimitType: {
|
||||
type: 'string',
|
||||
description: 'Limit assignment: integrationSpecific, accountLimit, or internal',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { NetSuiteGetRecordParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizeOptionalBoolean,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteGetRecordTool: ToolConfig<NetSuiteGetRecordParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_get_record',
|
||||
name: 'NetSuite Get Record',
|
||||
description: 'Retrieve one NetSuite record by internal or external ID.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite internal ID or an external-ID reference beginning with eid:',
|
||||
},
|
||||
fields: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated record fields to return',
|
||||
},
|
||||
expand: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated resources to expand when supported by the record metadata',
|
||||
},
|
||||
expandSubResources: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to expand sublists and subrecords in the response',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' }
|
||||
),
|
||||
success: { status: 200, body: 'object' },
|
||||
query: {
|
||||
fields: optionalTrim(params.fields),
|
||||
expand: optionalTrim(params.expand),
|
||||
expandSubResources: normalizeOptionalBoolean(
|
||||
params.expandSubResources,
|
||||
'Expand subresources'
|
||||
),
|
||||
},
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'NetSuite response body; record fields are account-specific and dynamic',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { NetSuiteGetRecordFormParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizeOptionalBoolean,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteGetRecordFormTool: ToolConfig<NetSuiteGetRecordFormParams, NetSuiteResponse> =
|
||||
{
|
||||
id: 'netsuite_get_record_form',
|
||||
name: 'NetSuite Get Record Form',
|
||||
description: 'Return a prepopulated create form, or an edit form when a record ID is supplied.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Existing record ID; omit to request a create form',
|
||||
},
|
||||
body: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Record fields matching the account-specific NetSuite metadata schema',
|
||||
},
|
||||
fields: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated record fields to return',
|
||||
},
|
||||
expand: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated resources to expand when supported by the record metadata',
|
||||
},
|
||||
expandSubResources: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to expand sublists and subrecords in the response',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const recordId = optionalTrim(params.recordId)
|
||||
return {
|
||||
method: recordId ? 'PATCH' : 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
...(recordId ? [{ value: recordId, label: 'Record ID' }] : [])
|
||||
),
|
||||
success: { status: 200, body: 'object' },
|
||||
query: {
|
||||
fields: optionalTrim(params.fields),
|
||||
expand: optionalTrim(params.expand),
|
||||
expandSubResources: normalizeOptionalBoolean(
|
||||
params.expandSubResources,
|
||||
'Expand subresources'
|
||||
),
|
||||
},
|
||||
headers: {
|
||||
Accept: `application/vnd.oracle.resource+json; type=${recordId ? 'edit-form' : 'create-form'}`,
|
||||
},
|
||||
body: params.body ?? {},
|
||||
}
|
||||
},
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'NetSuite response body; record fields are account-specific and dynamic',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { NetSuiteGetRecordMetadataParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
encodePathSegment,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const METADATA_ACCEPT = {
|
||||
default: 'application/json',
|
||||
openapi: 'application/swagger+json',
|
||||
json_schema: 'application/schema+json',
|
||||
} as const
|
||||
|
||||
function getMetadataAccept(format: NetSuiteGetRecordMetadataParams['format']): string {
|
||||
const accept = METADATA_ACCEPT[format ?? 'default']
|
||||
if (!accept) throw new Error('Metadata format must be default, openapi, or json_schema')
|
||||
return accept
|
||||
}
|
||||
|
||||
export const netsuiteGetRecordMetadataTool: ToolConfig<
|
||||
NetSuiteGetRecordMetadataParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_get_record_metadata',
|
||||
name: 'NetSuite Get Record Metadata',
|
||||
description: 'Retrieve account-specific metadata for one NetSuite record type.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
format: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 'default',
|
||||
description: 'Metadata representation: default, openapi, or json_schema',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: `/services/rest/record/v1/metadata-catalog/${encodePathSegment(params.recordType, 'Record type')}`,
|
||||
success: { status: 200, body: 'object' },
|
||||
headers: { Accept: getMetadataAccept(params.format) },
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'NetSuite response body; record fields are account-specific and dynamic',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { NetSuiteGetSelectOptionsParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizePagination,
|
||||
optionalTrim,
|
||||
requiredTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteGetSelectOptionsTool: ToolConfig<
|
||||
NetSuiteGetSelectOptionsParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_get_select_options',
|
||||
name: 'NetSuite Get Select Options',
|
||||
description: 'Retrieve valid select values for one or more fields on a new or existing record.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Existing record ID; omit to evaluate options for a new record',
|
||||
},
|
||||
fields: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated select field IDs',
|
||||
},
|
||||
q: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional select-option filter using CONTAIN, IS, or START_WITH',
|
||||
},
|
||||
body: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Record fields matching the account-specific NetSuite metadata schema',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 100,
|
||||
description: 'Results to return in this page (1-1000; default 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 0,
|
||||
description:
|
||||
'Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const recordId = optionalTrim(params.recordId)
|
||||
const pagination = normalizePagination(params.limit, params.offset)
|
||||
return {
|
||||
method: recordId ? 'PATCH' : 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
...(recordId ? [{ value: recordId, label: 'Record ID' }] : [])
|
||||
),
|
||||
success: { status: 200, body: 'object' },
|
||||
query: {
|
||||
...pagination,
|
||||
fields: requiredTrim(params.fields, 'Fields'),
|
||||
q: optionalTrim(params.q),
|
||||
},
|
||||
headers: { Accept: 'application/vnd.oracle.resource+json; type=select-options' },
|
||||
body: params.body ?? {},
|
||||
}
|
||||
},
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Select-options response keyed by requested field ID; each dynamic field contains an _selectOptions object with links, items, count, offset, hasMore, and totalResults',
|
||||
nullable: true,
|
||||
properties: {
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Oracle HATEOAS links for the response',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NetSuiteResponse, NetSuiteSystemParams } from '@/tools/netsuite/types'
|
||||
import { executeNetSuiteRequest, netsuiteAuthParamFields } from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteGetServerTimeTool: ToolConfig<NetSuiteSystemParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_get_server_time',
|
||||
name: 'NetSuite Get Server Time',
|
||||
description: 'Retrieve the current UTC time from the NetSuite server.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: '/services/rest/system/v1/serverTime',
|
||||
success: { status: 200, body: 'object', validator: 'server-time' },
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'NetSuite server time response',
|
||||
nullable: true,
|
||||
properties: {
|
||||
serverTime: { type: 'string', description: 'Current NetSuite server time in UTC' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { NetSuiteGetSubresourceParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
buildSubresourcePath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteGetSubresourceTool: ToolConfig<
|
||||
NetSuiteGetSubresourceParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_get_subresource',
|
||||
name: 'NetSuite Get Subresource',
|
||||
description: 'Retrieve a record sublist, subrecord, referenced record, or nested subresource.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite internal ID or an external-ID reference beginning with eid:',
|
||||
},
|
||||
subresourcePath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Slash-separated subresource path, such as item or item/1/inventoryDetail',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
...buildSubresourcePath(params.subresourcePath)
|
||||
),
|
||||
success: { status: 200, body: 'object' },
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'NetSuite response body; record fields are account-specific and dynamic',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export { netsuiteAttachRecordTool } from './attach_record'
|
||||
export { netsuiteBatchCreateRecordsTool } from './batch_create_records'
|
||||
export { netsuiteBatchDeleteRecordsTool } from './batch_delete_records'
|
||||
export { netsuiteBatchGetRecordsTool } from './batch_get_records'
|
||||
export { netsuiteBatchUpdateRecordsTool } from './batch_update_records'
|
||||
export { netsuiteBatchUpsertRecordsTool } from './batch_upsert_records'
|
||||
export { netsuiteCreateRecordTool } from './create_record'
|
||||
export { netsuiteDeleteRecordTool } from './delete_record'
|
||||
export { netsuiteDetachRecordTool } from './detach_record'
|
||||
export { netsuiteExecuteActionTool } from './execute_action'
|
||||
export { netsuiteExecuteDatasetTool } from './execute_dataset'
|
||||
export { netsuiteExecuteSuiteQLTool } from './execute_suiteql'
|
||||
export { netsuiteGetAsyncResultTool } from './get_async_result'
|
||||
export { netsuiteGetAsyncStatusTool } from './get_async_status'
|
||||
export { netsuiteGetGovernanceLimitsTool } from './get_governance_limits'
|
||||
export { netsuiteGetRecordTool } from './get_record'
|
||||
export { netsuiteGetRecordFormTool } from './get_record_form'
|
||||
export { netsuiteGetRecordMetadataTool } from './get_record_metadata'
|
||||
export { netsuiteGetSelectOptionsTool } from './get_select_options'
|
||||
export { netsuiteGetServerTimeTool } from './get_server_time'
|
||||
export { netsuiteGetSubresourceTool } from './get_subresource'
|
||||
export { netsuiteListDatasetsTool } from './list_datasets'
|
||||
export { netsuiteListRecordTypesTool } from './list_record_types'
|
||||
export { netsuiteListRecordsTool } from './list_records'
|
||||
export { netsuiteTransformRecordTool } from './transform_record'
|
||||
export * from './types'
|
||||
export { netsuiteUpdateRecordTool } from './update_record'
|
||||
export { netsuiteUpsertRecordTool } from './upsert_record'
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { NetSuiteListDatasetsParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizePagination,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteListDatasetsTool: ToolConfig<NetSuiteListDatasetsParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_list_datasets',
|
||||
name: 'NetSuite List SuiteAnalytics Datasets',
|
||||
description:
|
||||
'List one page of SuiteAnalytics Workbook datasets available to the authenticated role.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 100,
|
||||
description: 'Results to return in this page (1-1000; default 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 0,
|
||||
description:
|
||||
'Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: '/services/rest/query/v1/dataset/',
|
||||
success: { status: 200, body: 'object', validator: 'collection-page' },
|
||||
query: normalizePagination(params.limit, params.offset),
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'One documented NetSuite collection page',
|
||||
nullable: true,
|
||||
properties: {
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Oracle HATEOAS links for the response',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'Items in this page; item fields depend on the record, query, or dataset',
|
||||
optional: true,
|
||||
items: { type: 'json', description: 'Account-specific NetSuite item' },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of items in this page', optional: true },
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether another page is available',
|
||||
optional: true,
|
||||
},
|
||||
offset: { type: 'number', description: 'Offset of this page', optional: true },
|
||||
totalResults: {
|
||||
type: 'number',
|
||||
description: 'Total number of matching items',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { NetSuiteListRecordTypesParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import { executeNetSuiteRequest, netsuiteAuthParamFields } from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteListRecordTypesTool: ToolConfig<
|
||||
NetSuiteListRecordTypesParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_list_record_types',
|
||||
name: 'NetSuite List Record Types',
|
||||
description: 'List record types exposed to the authenticated role by the REST metadata catalog.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: '/services/rest/record/v1/metadata-catalog',
|
||||
success: { status: 200, body: 'object', validator: 'metadata-catalog' },
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'NetSuite REST metadata catalog',
|
||||
nullable: true,
|
||||
properties: {
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Oracle HATEOAS links for the response',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'Record types exposed to the authenticated role',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'REST record type script ID' },
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Oracle HATEOAS links for the response',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
mediaType: {
|
||||
type: 'string',
|
||||
description: 'Media type advertised for the linked metadata resource',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { NetSuiteListRecordsParams, NetSuiteResponse } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
normalizePagination,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteListRecordsTool: ToolConfig<NetSuiteListRecordsParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_list_records',
|
||||
name: 'NetSuite List/Search Records',
|
||||
description:
|
||||
'List one page of a NetSuite record collection, optionally filtered with a q expression.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
q: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite record collection filter expression',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 100,
|
||||
description: 'Results to return in this page (1-1000; default 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 0,
|
||||
description:
|
||||
'Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
|
||||
success: { status: 200, body: 'object', validator: 'collection-page' },
|
||||
query: {
|
||||
...normalizePagination(params.limit, params.offset),
|
||||
q: optionalTrim(params.q, 'Filter'),
|
||||
},
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'One documented NetSuite collection page',
|
||||
nullable: true,
|
||||
properties: {
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Oracle HATEOAS links for the response',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'Matching record references in this page',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'NetSuite record ID' },
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'Oracle HATEOAS links for the record',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rel: { type: 'string', description: 'Link relationship', optional: true },
|
||||
href: { type: 'string', description: 'Link target', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
count: { type: 'number', description: 'Number of items in this page', optional: true },
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether another page is available',
|
||||
optional: true,
|
||||
},
|
||||
offset: { type: 'number', description: 'Offset of this page', optional: true },
|
||||
totalResults: {
|
||||
type: 'number',
|
||||
description: 'Total number of matching items',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
import type { NetSuiteResponse, NetSuiteTransformRecordParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteTransformRecordTool: ToolConfig<
|
||||
NetSuiteTransformRecordParams,
|
||||
NetSuiteResponse
|
||||
> = {
|
||||
id: 'netsuite_transform_record',
|
||||
name: 'NetSuite Transform Record',
|
||||
description: 'Transform a supported source record into another NetSuite record type.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite internal ID or an external-ID reference beginning with eid:',
|
||||
},
|
||||
targetRecordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Target record type supported by the source record metadata',
|
||||
},
|
||||
body: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Record fields matching the account-specific NetSuite metadata schema',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Source record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
{ value: '!transform', label: 'Transform operation' },
|
||||
{ value: params.targetRecordType, label: 'Target record type' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
responseLocation: 'resource-optional',
|
||||
body: params.body ?? {},
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 204 No Content response',
|
||||
nullable: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'URL of the transformed record, when NetSuite returns a Location header',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface NetSuiteAuthParams {
|
||||
/** ID of the selected reusable NetSuite service-account credential. */
|
||||
oauthCredential: string
|
||||
/** Short-lived access token injected by the executor from the credential. */
|
||||
accessToken?: string
|
||||
/** SuiteTalk account origin injected by the executor from the credential. */
|
||||
instanceUrl?: string
|
||||
}
|
||||
|
||||
export interface NetSuitePaginationParams {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface NetSuiteRecordQueryParams {
|
||||
fields?: string
|
||||
expand?: string
|
||||
expandSubResources?: boolean
|
||||
}
|
||||
|
||||
export type NetSuiteJsonObject = Record<string, unknown>
|
||||
|
||||
export interface NetSuiteResponse extends ToolResponse {
|
||||
output: {
|
||||
status: number
|
||||
data: unknown | null
|
||||
location?: string
|
||||
jobId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface NetSuiteListRecordsParams extends NetSuiteAuthParams, NetSuitePaginationParams {
|
||||
recordType: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
export interface NetSuiteGetRecordParams extends NetSuiteAuthParams, NetSuiteRecordQueryParams {
|
||||
recordType: string
|
||||
recordId: string
|
||||
}
|
||||
|
||||
export interface NetSuiteCreateRecordParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
body: NetSuiteJsonObject
|
||||
replace?: string
|
||||
}
|
||||
|
||||
export interface NetSuiteUpdateRecordParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
recordId: string
|
||||
body: NetSuiteJsonObject
|
||||
replace?: string
|
||||
}
|
||||
|
||||
export interface NetSuiteUpsertRecordParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
externalId: string
|
||||
body: NetSuiteJsonObject
|
||||
}
|
||||
|
||||
export interface NetSuiteDeleteRecordParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
recordId: string
|
||||
}
|
||||
|
||||
export interface NetSuiteGetSubresourceParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
recordId: string
|
||||
subresourcePath: string
|
||||
}
|
||||
|
||||
export interface NetSuiteGetRecordFormParams extends NetSuiteAuthParams, NetSuiteRecordQueryParams {
|
||||
recordType: string
|
||||
recordId?: string
|
||||
body?: NetSuiteJsonObject
|
||||
}
|
||||
|
||||
export interface NetSuiteGetSelectOptionsParams
|
||||
extends NetSuiteAuthParams,
|
||||
NetSuitePaginationParams {
|
||||
recordType: string
|
||||
fields: string
|
||||
recordId?: string
|
||||
q?: string
|
||||
body?: NetSuiteJsonObject
|
||||
}
|
||||
|
||||
export interface NetSuiteRelationshipParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
recordId: string
|
||||
relatedType: 'contact' | 'file'
|
||||
relatedId: string
|
||||
}
|
||||
|
||||
export interface NetSuiteAttachParams extends NetSuiteRelationshipParams {
|
||||
roleId?: string
|
||||
roleExternalId?: string
|
||||
}
|
||||
|
||||
export interface NetSuiteExecuteActionParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
recordId: string
|
||||
action: string
|
||||
body?: NetSuiteJsonObject
|
||||
}
|
||||
|
||||
export interface NetSuiteTransformRecordParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
recordId: string
|
||||
targetRecordType: string
|
||||
body?: NetSuiteJsonObject
|
||||
}
|
||||
|
||||
export interface NetSuiteBatchGetParams extends NetSuiteAuthParams, NetSuiteRecordQueryParams {
|
||||
recordType: string
|
||||
ids: string
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export interface NetSuiteBatchWriteParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
items: NetSuiteJsonObject[]
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export interface NetSuiteBatchDeleteParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
ids: string
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export interface NetSuiteSuiteQLParams extends NetSuiteAuthParams, NetSuitePaginationParams {
|
||||
query: string
|
||||
}
|
||||
|
||||
export interface NetSuiteListDatasetsParams extends NetSuiteAuthParams, NetSuitePaginationParams {}
|
||||
|
||||
export interface NetSuiteExecuteDatasetParams extends NetSuiteAuthParams, NetSuitePaginationParams {
|
||||
datasetId: string
|
||||
}
|
||||
|
||||
export interface NetSuiteListRecordTypesParams extends NetSuiteAuthParams {}
|
||||
|
||||
export type NetSuiteMetadataFormat = 'default' | 'openapi' | 'json_schema'
|
||||
|
||||
export interface NetSuiteGetRecordMetadataParams extends NetSuiteAuthParams {
|
||||
recordType: string
|
||||
format?: NetSuiteMetadataFormat
|
||||
}
|
||||
|
||||
export interface NetSuiteGetAsyncStatusParams extends NetSuiteAuthParams {
|
||||
jobId: string
|
||||
view?: 'job' | 'tasks' | 'task'
|
||||
taskId?: string
|
||||
}
|
||||
|
||||
export interface NetSuiteGetAsyncResultParams extends NetSuiteAuthParams {
|
||||
jobId: string
|
||||
taskId: string
|
||||
}
|
||||
|
||||
export interface NetSuiteSystemParams extends NetSuiteAuthParams {}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { NetSuiteResponse, NetSuiteUpdateRecordParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteUpdateRecordTool: ToolConfig<NetSuiteUpdateRecordParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_update_record',
|
||||
name: 'NetSuite Update Record',
|
||||
description: 'Update fields on an existing NetSuite record with PATCH.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite internal ID or an external-ID reference beginning with eid:',
|
||||
},
|
||||
body: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Record fields matching the account-specific NetSuite metadata schema',
|
||||
},
|
||||
replace: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Comma-separated sublists whose existing lines should be replaced',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'PATCH',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
responseLocation: 'resource',
|
||||
query: { replace: optionalTrim(params.replace, 'Replace sublists') },
|
||||
body: params.body,
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 204 No Content response',
|
||||
nullable: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Updated record URL from the Location response header',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { NetSuiteResponse, NetSuiteUpsertRecordParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
netsuiteAuthParamFields,
|
||||
requiredTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const netsuiteUpsertRecordTool: ToolConfig<NetSuiteUpsertRecordParams, NetSuiteResponse> = {
|
||||
id: 'netsuite_upsert_record',
|
||||
name: 'NetSuite Upsert Record',
|
||||
description: 'Create or update a NetSuite record by external ID with PUT.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
...netsuiteAuthParamFields,
|
||||
recordType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
|
||||
},
|
||||
externalId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'External ID without the eid: prefix',
|
||||
},
|
||||
body: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Record fields matching the account-specific NetSuite metadata schema',
|
||||
},
|
||||
},
|
||||
request: { url: () => '', method: 'POST', headers: () => ({}) },
|
||||
directExecution: (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'PUT',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: `eid:${requiredTrim(params.externalId, 'External ID')}`, label: 'External ID' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
responseLocation: 'resource-optional',
|
||||
body: params.body,
|
||||
}),
|
||||
signal
|
||||
),
|
||||
outputs: {
|
||||
status: { type: 'number', description: 'HTTP status returned by NetSuite' },
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Empty for the documented HTTP 204 No Content response',
|
||||
nullable: true,
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'URL of the created or updated record, when NetSuite returns a Location header',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { netsuiteBatchCreateRecordsTool } from '@/tools/netsuite/batch_create_records'
|
||||
import { netsuiteCreateRecordTool } from '@/tools/netsuite/create_record'
|
||||
import { netsuiteGetRecordTool } from '@/tools/netsuite/get_record'
|
||||
import { netsuiteGetServerTimeTool } from '@/tools/netsuite/get_server_time'
|
||||
import type { NetSuiteAuthParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildBatchWriteRequest,
|
||||
normalizeBatchIds,
|
||||
normalizeBatchItems,
|
||||
normalizePagination,
|
||||
normalizeSuiteTalkUrl,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
const AUTH: NetSuiteAuthParams = {
|
||||
oauthCredential: 'credential-id',
|
||||
accessToken: 'access-token',
|
||||
instanceUrl: 'https://1234567-sb1.suitetalk.api.netsuite.com',
|
||||
}
|
||||
|
||||
interface FetchCall {
|
||||
url: string
|
||||
init?: RequestInit
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200, headers?: HeadersInit): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
})
|
||||
}
|
||||
|
||||
function installFetch(
|
||||
apiResponses: Response[] = [jsonResponse({ serverTime: '2026-08-08T00:00:00Z' })]
|
||||
) {
|
||||
const calls: FetchCall[] = []
|
||||
let apiCount = 0
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : input.toString()
|
||||
calls.push({ url, init })
|
||||
const response = apiResponses[apiCount]
|
||||
apiCount += 1
|
||||
return response ?? jsonResponse({ ok: true })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { calls, fetchMock }
|
||||
}
|
||||
|
||||
async function executeServerTime(auth: NetSuiteAuthParams = AUTH, signal?: AbortSignal) {
|
||||
const execute = netsuiteGetServerTimeTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
return execute(auth, signal)
|
||||
}
|
||||
|
||||
describe('NetSuite shared executor', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('accepts only authoritative NetSuite SuiteTalk origins', () => {
|
||||
expect(normalizeSuiteTalkUrl(AUTH.instanceUrl ?? '')).toBe(AUTH.instanceUrl)
|
||||
expect(normalizeSuiteTalkUrl(`${AUTH.instanceUrl}/`)).toBe(AUTH.instanceUrl)
|
||||
for (const invalid of [
|
||||
'http://1234567.suitetalk.api.netsuite.com',
|
||||
'https://evil.example',
|
||||
`${AUTH.instanceUrl}/services/rest/record/v1/customer`,
|
||||
`${AUTH.instanceUrl}?account=other`,
|
||||
'https://user:password@1234567.suitetalk.api.netsuite.com',
|
||||
]) {
|
||||
expect(() => normalizeSuiteTalkUrl(invalid), invalid).toThrow('SuiteTalk URL')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses only the resolved short-lived token for SuiteTalk requests', async () => {
|
||||
const { calls } = installFetch()
|
||||
|
||||
const result = await executeServerTime()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe(`${AUTH.instanceUrl}/services/rest/system/v1/serverTime`)
|
||||
expect(new Headers(calls[0].init?.headers).get('authorization')).toBe(
|
||||
`Bearer ${AUTH.accessToken}`
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a 401 after exactly one mutation request without replaying it', async () => {
|
||||
const { calls } = installFetch([
|
||||
jsonResponse({ detail: 'The access token is invalid.' }, 401),
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: { Location: '/services/rest/record/v1/customer/647' },
|
||||
}),
|
||||
])
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
body: { companyName: 'Acme' },
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ success: false, output: { status: 401, data: null } })
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].init?.method).toBe('POST')
|
||||
expect(calls[0].init?.body).toBe(JSON.stringify({ companyName: 'Acme' }))
|
||||
})
|
||||
|
||||
it('combines workflow cancellation with the SuiteTalk timeout', async () => {
|
||||
const controller = new AbortController()
|
||||
const { calls } = installFetch()
|
||||
|
||||
await executeServerTime(AUTH, controller.signal)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const suiteTalkSignal = calls[0].init?.signal
|
||||
expect(suiteTalkSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(suiteTalkSignal).not.toBe(controller.signal)
|
||||
controller.abort(new Error('workflow canceled'))
|
||||
expect(suiteTalkSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds SuiteTalk calls even when the caller does not provide a signal', async () => {
|
||||
vi.useFakeTimers()
|
||||
let markApiStarted!: () => void
|
||||
const apiStarted = new Promise<void>((resolve) => {
|
||||
markApiStarted = resolve
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
markApiStarted()
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal?.reason ?? new Error('aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const pending = executeServerTime()
|
||||
await apiStarted
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
const result = await pending
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
output: { status: 0, data: null },
|
||||
})
|
||||
expect(result.error).toMatch(/timeout|timed out/i)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('settles promptly when the caller cancels an in-flight SuiteTalk request', async () => {
|
||||
const controller = new AbortController()
|
||||
let markApiStarted!: () => void
|
||||
const apiStarted = new Promise<void>((resolve) => {
|
||||
markApiStarted = resolve
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
markApiStarted()
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal?.reason ?? new Error('aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const pending = executeServerTime(AUTH, controller.signal)
|
||||
await apiStarted
|
||||
controller.abort(new Error('workflow canceled during SuiteTalk'))
|
||||
const result = await pending
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('workflow canceled during SuiteTalk')
|
||||
})
|
||||
|
||||
it('rejects an already-aborted execution before SuiteTalk traffic', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('already canceled'))
|
||||
|
||||
const result = await executeServerTime(AUTH, controller.signal)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('already canceled')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an undocumented success status for ordinary operations', async () => {
|
||||
const location =
|
||||
'https://1234567-sb1.suitetalk.api.netsuite.com/services/rest/async/v1/job/job-42'
|
||||
installFetch([new Response(null, { status: 204, headers: { Location: location } })])
|
||||
|
||||
const result = await executeServerTime()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.output).toEqual({ status: 204, data: null })
|
||||
expect(result.error).toContain('expected HTTP 200')
|
||||
})
|
||||
|
||||
it('does not parse a synchronous job record location as an asynchronous job ID', async () => {
|
||||
const location =
|
||||
'https://1234567-sb1.suitetalk.api.netsuite.com/services/rest/record/v1/job/456'
|
||||
installFetch([new Response(null, { status: 204, headers: { Location: location } })])
|
||||
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'job',
|
||||
body: { companyName: 'Implementation' },
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
output: { status: 204, data: null, location },
|
||||
})
|
||||
})
|
||||
|
||||
it('requires a valid same-origin resource Location on create success', async () => {
|
||||
const relativeLocation = '/services/rest/record/v1/customer/647'
|
||||
const { calls } = installFetch([
|
||||
new Response(null, { status: 204, headers: { Location: relativeLocation } }),
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: { Location: 'https://evil.example/services/rest/record/v1/customer/648' },
|
||||
}),
|
||||
])
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
|
||||
const relative = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
body: { companyName: 'Acme' },
|
||||
})
|
||||
const foreign = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
body: { companyName: 'Other' },
|
||||
})
|
||||
|
||||
expect(relative).toEqual({
|
||||
success: true,
|
||||
output: { status: 204, data: null, location: relativeLocation },
|
||||
})
|
||||
expect(foreign.success).toBe(false)
|
||||
expect(foreign.output).toEqual({ status: 204, data: null })
|
||||
expect(foreign.error).toContain('valid resource Location')
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('accepts the documented replacement-create 201 post-state with Location', async () => {
|
||||
const location = '/services/rest/record/v1/customer/647'
|
||||
installFetch([jsonResponse({ id: '647', companyName: 'Acme' }, 201, { Location: location })])
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
body: { companyName: 'Acme' },
|
||||
replace: 'addressbook',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
output: {
|
||||
status: 201,
|
||||
data: { id: '647', companyName: 'Acme' },
|
||||
location,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects oversized inline responses through the repository stream limiter', async () => {
|
||||
installFetch([
|
||||
new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(16 * 1024 * 1024 + 1) },
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await executeServerTime()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toMatch(/NetSuite response|16.*MiB|exceed/i)
|
||||
expect(result.output).toEqual({ status: 200, data: null })
|
||||
})
|
||||
|
||||
it('rejects oversized request bodies before SuiteTalk traffic', async () => {
|
||||
const { calls } = installFetch()
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
body: { memo: 'x'.repeat(16 * 1024 * 1024) },
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('request body exceeds the inline payload limit')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects accessor-backed and exotic request bodies without invoking custom code', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
let getterCalls = 0
|
||||
const accessorBody = {}
|
||||
Object.defineProperty(accessorBody, 'companyName', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
getterCalls += 1
|
||||
return 'Acme'
|
||||
},
|
||||
})
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
|
||||
const accessorResult = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
body: accessorBody,
|
||||
})
|
||||
const exoticResult = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
body: Object.create({ inherited: true }) as Record<string, unknown>,
|
||||
})
|
||||
|
||||
expect(accessorResult.success).toBe(false)
|
||||
expect(accessorResult.error).toContain('plain JSON')
|
||||
expect(exoticResult.success).toBe(false)
|
||||
expect(exoticResult.error).toContain('plain JSON')
|
||||
expect(getterCalls).toBe(0)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects cycles and custom JSON serialization before SuiteTalk traffic', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
const custom = { companyName: 'Acme', toJSON: () => ({ companyName: 'Other' }) }
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
|
||||
const cyclicResult = await execute({ ...AUTH, recordType: 'customer', body: cyclic })
|
||||
const customResult = await execute({ ...AUTH, recordType: 'customer', body: custom })
|
||||
|
||||
expect(cyclicResult.success).toBe(false)
|
||||
expect(cyclicResult.error).toContain('must not be cyclic')
|
||||
expect(customResult.success).toBe(false)
|
||||
expect(customResult.error).toContain('custom serialization')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves ordinary JSON omissions, array nulls, and shared references', async () => {
|
||||
const { calls } = installFetch([
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: { Location: '/services/rest/record/v1/customer/647' },
|
||||
}),
|
||||
])
|
||||
const shared = { label: 'shared' }
|
||||
const body = {
|
||||
omitted: undefined,
|
||||
functionValue: () => 'omitted',
|
||||
symbolValue: Symbol('omitted'),
|
||||
values: [undefined, () => 'null', Symbol('null'), shared],
|
||||
left: shared,
|
||||
right: shared,
|
||||
}
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
|
||||
const result = await execute({ ...AUTH, recordType: 'customer', body })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(calls[0].init?.body).toBe(JSON.stringify(body))
|
||||
})
|
||||
|
||||
it('enforces the Sim JSON value-count boundary before SuiteTalk traffic', async () => {
|
||||
const { calls } = installFetch([
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: { Location: '/services/rest/record/v1/customer/647' },
|
||||
}),
|
||||
])
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
const admittedBody = { values: Array.from({ length: 99_998 }, () => null) }
|
||||
const rejectedBody = { values: Array.from({ length: 99_999 }, () => null) }
|
||||
|
||||
const admitted = await execute({ ...AUTH, recordType: 'customer', body: admittedBody })
|
||||
const rejected = await execute({ ...AUTH, recordType: 'customer', body: rejectedBody })
|
||||
|
||||
expect(admitted.success).toBe(true)
|
||||
expect(rejected.success).toBe(false)
|
||||
expect(rejected.error).toContain('JSON complexity limit')
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('enforces the Sim JSON nesting boundary before SuiteTalk traffic', async () => {
|
||||
const { calls } = installFetch([
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: { Location: '/services/rest/record/v1/customer/647' },
|
||||
}),
|
||||
])
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
const nested = (depth: number): Record<string, unknown> => {
|
||||
let value: Record<string, unknown> = {}
|
||||
for (let index = 0; index < depth; index += 1) value = { nested: value }
|
||||
return value
|
||||
}
|
||||
|
||||
const admitted = await execute({ ...AUTH, recordType: 'customer', body: nested(100) })
|
||||
const rejected = await execute({ ...AUTH, recordType: 'customer', body: nested(101) })
|
||||
|
||||
expect(admitted.success).toBe(true)
|
||||
expect(rejected.success).toBe(false)
|
||||
expect(rejected.error).toContain('JSON nesting limit')
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not inspect later properties after the request byte budget is exhausted', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
let laterGetterCalls = 0
|
||||
const body: Record<string, unknown> = { memo: 'x'.repeat(16 * 1024 * 1024) }
|
||||
Object.defineProperty(body, 'later', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
laterGetterCalls += 1
|
||||
return 'too late'
|
||||
},
|
||||
})
|
||||
const execute = netsuiteCreateRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite tool is missing direct execution')
|
||||
|
||||
const result = await execute({ ...AUTH, recordType: 'customer', body })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('request body exceeds the inline payload limit')
|
||||
expect(laterGetterCalls).toBe(0)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects non-JSON success payloads instead of violating the json output contract', async () => {
|
||||
installFetch([new Response('not-json', { status: 200 })])
|
||||
|
||||
const result = await executeServerTime()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('non-JSON success response')
|
||||
expect(result.output).toEqual({ status: 200, data: null })
|
||||
})
|
||||
|
||||
it('preserves the HTTP status when an oversized Oracle error body is rejected', async () => {
|
||||
installFetch([
|
||||
new Response('{}', {
|
||||
status: 500,
|
||||
headers: { 'Content-Length': String(64 * 1024 + 1) },
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await executeServerTime()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.output).toEqual({ status: 500, data: null })
|
||||
expect(result.error).toContain('NetSuite error response')
|
||||
})
|
||||
|
||||
it('returns bounded, credential-sanitized Oracle errors', async () => {
|
||||
const leaked = `Rejected bearer ${AUTH.accessToken} for credential ${AUTH.oauthCredential}`
|
||||
const calls: FetchCall[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: input.toString(), init })
|
||||
return new Response(leaked, { status: 400 })
|
||||
})
|
||||
)
|
||||
|
||||
const result = await executeServerTime()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).not.toContain(AUTH.accessToken)
|
||||
expect(result.error).not.toContain(AUTH.oauthCredential)
|
||||
expect(result.error?.length).toBeLessThanOrEqual(1_000)
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bounds Oracle API error details', async () => {
|
||||
installFetch([jsonResponse({ detail: 'x'.repeat(5_000) }, 400)])
|
||||
|
||||
const result = await executeServerTime()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toMatch(/^NetSuite request failed \(400\):/)
|
||||
expect(result.error?.length).toBeLessThanOrEqual(1_000)
|
||||
})
|
||||
|
||||
it('returns Oracle error details with machine-readable codes and paths', async () => {
|
||||
installFetch([
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'https://www.rfc-editor.org/rfc/rfc9110.html#name-409-conflict',
|
||||
title: 'Conflict',
|
||||
status: 409,
|
||||
'o:errorDetails': [
|
||||
{
|
||||
detail: 'The customer reference is invalid.',
|
||||
'o:errorCode': 'INVALID_CONTENT',
|
||||
'o:errorPath': 'entity.id',
|
||||
},
|
||||
{
|
||||
detail: 'The subsidiary is required.',
|
||||
'o:errorCode': 'USER_ERROR',
|
||||
'o:errorPath': 'subsidiary',
|
||||
},
|
||||
],
|
||||
},
|
||||
409
|
||||
),
|
||||
])
|
||||
|
||||
const result = await executeServerTime()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe(
|
||||
'NetSuite request failed (409): The customer reference is invalid. [code=INVALID_CONTENT, path=entity.id]; The subsidiary is required. [code=USER_ERROR, path=subsidiary]'
|
||||
)
|
||||
})
|
||||
|
||||
it('recovers the original async job when an idempotent batch retry conflicts', async () => {
|
||||
const location =
|
||||
'https://1234567-sb1.suitetalk.api.netsuite.com/services/rest/async/v1/job/job-original'
|
||||
installFetch([
|
||||
jsonResponse(
|
||||
{
|
||||
status: 400,
|
||||
'o:errorDetails': [
|
||||
{
|
||||
detail: 'The idempotency key was already used.',
|
||||
'o:errorCode': 'IDEMPOTENCY_ERROR',
|
||||
},
|
||||
],
|
||||
},
|
||||
400,
|
||||
{ Location: location }
|
||||
),
|
||||
])
|
||||
|
||||
const execute = netsuiteBatchCreateRecordsTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite batch tool is missing direct execution')
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
items: [{ companyName: 'Acme' }],
|
||||
idempotencyKey: 'retry-acme-import',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
output: {
|
||||
status: 400,
|
||||
data: null,
|
||||
location,
|
||||
jobId: 'job-original',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('does not recover an idempotency error returned with an undocumented status', async () => {
|
||||
const location =
|
||||
'https://1234567-sb1.suitetalk.api.netsuite.com/services/rest/async/v1/job/job-original'
|
||||
installFetch([
|
||||
jsonResponse(
|
||||
{
|
||||
status: 409,
|
||||
'o:errorDetails': [
|
||||
{
|
||||
detail: 'The idempotency key was already used.',
|
||||
'o:errorCode': 'IDEMPOTENCY_ERROR',
|
||||
},
|
||||
],
|
||||
},
|
||||
409,
|
||||
{ Location: location }
|
||||
),
|
||||
])
|
||||
|
||||
const execute = netsuiteBatchCreateRecordsTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite batch tool is missing direct execution')
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
items: [{ companyName: 'Acme' }],
|
||||
idempotencyKey: 'retry-acme-import',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
output: {
|
||||
status: 409,
|
||||
data: null,
|
||||
location,
|
||||
jobId: 'job-original',
|
||||
},
|
||||
error:
|
||||
'NetSuite request failed (409): The idempotency key was already used. [code=IDEMPOTENCY_ERROR]',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts Oracle relative async locations and preserves the returned header value', async () => {
|
||||
const location = '/services/rest/async/v1/job/job-relative'
|
||||
installFetch([new Response(null, { status: 202, headers: { Location: location } })])
|
||||
|
||||
const execute = netsuiteBatchCreateRecordsTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite batch tool is missing direct execution')
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
items: [{ companyName: 'Acme' }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
output: { status: 202, data: null, location, jobId: 'job-relative' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects cross-origin async locations during idempotency recovery', async () => {
|
||||
const location = 'https://evil.example/services/rest/async/v1/job/job-foreign'
|
||||
installFetch([
|
||||
jsonResponse(
|
||||
{
|
||||
status: 400,
|
||||
'o:errorDetails': [
|
||||
{
|
||||
detail: 'The idempotency key was already used.',
|
||||
'o:errorCode': 'IDEMPOTENCY_ERROR',
|
||||
},
|
||||
],
|
||||
},
|
||||
400,
|
||||
{ Location: location }
|
||||
),
|
||||
])
|
||||
|
||||
const execute = netsuiteBatchCreateRecordsTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite batch tool is missing direct execution')
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
items: [{ companyName: 'Acme' }],
|
||||
idempotencyKey: 'retry-acme-import',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
output: { status: 400, data: null },
|
||||
error:
|
||||
'NetSuite request failed (400): The idempotency key was already used. [code=IDEMPOTENCY_ERROR]',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves HTTP status when an async Location header is malformed', async () => {
|
||||
installFetch([
|
||||
new Response(null, {
|
||||
status: 202,
|
||||
headers: { Location: '/services/rest/async/v1/job/%ZZ' },
|
||||
}),
|
||||
])
|
||||
|
||||
const execute = netsuiteBatchCreateRecordsTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite batch tool is missing direct execution')
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
items: [{ companyName: 'Acme' }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
output: { status: 202, data: null },
|
||||
error: 'NetSuite asynchronous response did not include a valid job Location header',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects async batch responses that do not include a pollable job location', async () => {
|
||||
installFetch([new Response(null, { status: 202 })])
|
||||
|
||||
const execute = netsuiteBatchCreateRecordsTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite batch tool is missing direct execution')
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
items: [{ companyName: 'Acme' }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
output: { status: 202, data: null },
|
||||
error: 'NetSuite asynchronous response did not include a valid job Location header',
|
||||
})
|
||||
})
|
||||
|
||||
it('requires HTTP 202 Accepted for asynchronous batch submissions', async () => {
|
||||
const location = '/services/rest/async/v1/job/job-wrong-status'
|
||||
installFetch([new Response(null, { status: 204, headers: { Location: location } })])
|
||||
|
||||
const execute = netsuiteBatchCreateRecordsTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite batch tool is missing direct execution')
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
items: [{ companyName: 'Acme' }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
output: { status: 204, data: null, location, jobId: 'job-wrong-status' },
|
||||
error: 'NetSuite returned HTTP 204; expected HTTP 202',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat unrelated location-bearing errors as idempotent replays', async () => {
|
||||
const location =
|
||||
'https://1234567-sb1.suitetalk.api.netsuite.com/services/rest/async/v1/job/job-error'
|
||||
installFetch([
|
||||
jsonResponse(
|
||||
{
|
||||
status: 400,
|
||||
'o:errorDetails': [
|
||||
{
|
||||
detail: 'The batch request is invalid.',
|
||||
'o:errorCode': 'INVALID_CONTENT',
|
||||
},
|
||||
],
|
||||
},
|
||||
400,
|
||||
{ Location: location }
|
||||
),
|
||||
])
|
||||
|
||||
const execute = netsuiteBatchCreateRecordsTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite batch tool is missing direct execution')
|
||||
const result = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
items: [{ companyName: 'Acme' }],
|
||||
idempotencyKey: 'retry-acme-import',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
output: {
|
||||
status: 400,
|
||||
data: null,
|
||||
location,
|
||||
jobId: 'job-error',
|
||||
},
|
||||
error: 'NetSuite request failed (400): The batch request is invalid. [code=INVALID_CONTENT]',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed origins before making a network request', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await executeServerTime({ ...AUTH, instanceUrl: 'https://evil.example' })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['oauthCredential', null, 'NetSuite credential'],
|
||||
['accessToken', null, 'NetSuite access token'],
|
||||
['instanceUrl', null, 'SuiteTalk URL'],
|
||||
] as const)(
|
||||
'returns a failed response envelope for malformed direct %s',
|
||||
async (field, value, errorText) => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await executeServerTime({ ...AUTH, [field]: value } as never)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
output: { status: 0, data: null },
|
||||
error: expect.stringContaining(errorText),
|
||||
})
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects whitespace-only injected authentication fields before network use', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await executeServerTime({ ...AUTH, accessToken: ' ' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
output: { status: 0, data: null },
|
||||
error: expect.stringContaining('NetSuite access token'),
|
||||
})
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omits null optional booleans and rejects other direct boolean values', async () => {
|
||||
const valid = installFetch([jsonResponse({ id: '7' })])
|
||||
const execute = netsuiteGetRecordTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite get-record tool is missing direct execution')
|
||||
|
||||
const omitted = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
recordId: '7',
|
||||
expandSubResources: null,
|
||||
} as never)
|
||||
|
||||
expect(omitted.success).toBe(true)
|
||||
expect(new URL(valid.calls[0].url).searchParams.has('expandSubResources')).toBe(false)
|
||||
|
||||
const invalidFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', invalidFetch)
|
||||
const invalid = await execute({
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
recordId: '7',
|
||||
expandSubResources: 'yes',
|
||||
} as never)
|
||||
|
||||
expect(invalid.success).toBe(false)
|
||||
expect(invalid.error).toContain('Expand subresources must be a boolean')
|
||||
expect(invalidFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NetSuite request bounds', () => {
|
||||
it("applies Sim's page-size default within Oracle's paging limits", () => {
|
||||
expect(normalizePagination()).toEqual({ limit: 100, offset: 0 })
|
||||
expect(normalizePagination(1_000, 2_000)).toEqual({ limit: 1_000, offset: 2_000 })
|
||||
expect(normalizePagination(1_000, 99_000)).toEqual({ limit: 1_000, offset: 99_000 })
|
||||
expect(normalizePagination(1, 999)).toEqual({ limit: 1, offset: 999 })
|
||||
expect(() => normalizePagination(1_001, 0)).toThrow('Limit')
|
||||
expect(() => normalizePagination(100, 50)).toThrow('Offset')
|
||||
expect(() => normalizePagination(100, -100)).toThrow('Offset')
|
||||
expect(() => normalizePagination(1, 1_000)).toThrow('first 1000 pages')
|
||||
expect(() => normalizePagination(1_000, 100_000)).toThrow('100,000')
|
||||
expect(() => normalizePagination(100, 100_000)).toThrow('100,000')
|
||||
expect(() => normalizePagination(1_000, 1_000_000)).toThrow('100,000')
|
||||
})
|
||||
|
||||
it('enforces the 100-record batch maximum for IDs and JSON items', () => {
|
||||
const hundredIds = Array.from({ length: 100 }, (_, index) => String(index + 1)).join(',')
|
||||
const hundredItems = Array.from({ length: 100 }, (_, index) => ({ id: index + 1 }))
|
||||
expect(normalizeBatchIds(hundredIds).split(',')).toHaveLength(100)
|
||||
expect(normalizeBatchItems(hundredItems)).toHaveLength(100)
|
||||
expect(() => normalizeBatchIds(`${hundredIds},101`)).toThrow('100')
|
||||
expect(() => normalizeBatchIds('1,,2')).toThrow('empty comma-separated values')
|
||||
expect(() => normalizeBatchIds('1,2,')).toThrow('empty comma-separated values')
|
||||
expect(() => normalizeBatchItems([...hundredItems, { id: 101 }])).toThrow('100')
|
||||
expect(() => normalizeBatchItems([])).toThrow('between 1 and 100')
|
||||
})
|
||||
|
||||
it('validates identifiers required by update and upsert batches', () => {
|
||||
const params = {
|
||||
...AUTH,
|
||||
recordType: 'customer',
|
||||
items: [{ companyName: 'Acme' }],
|
||||
}
|
||||
|
||||
expect(() => buildBatchWriteRequest('POST', params)).not.toThrow()
|
||||
expect(() => buildBatchWriteRequest('PATCH', params)).toThrow(
|
||||
'Batch item 1 must include an id or non-empty string externalId'
|
||||
)
|
||||
expect(() =>
|
||||
buildBatchWriteRequest('PATCH', { ...params, items: [{ externalId: 'EXT-7' }] })
|
||||
).not.toThrow()
|
||||
expect(() => buildBatchWriteRequest('PUT', { ...params, items: [{ id: '7' }] })).toThrow(
|
||||
'Batch item 1 must include a non-empty string externalId'
|
||||
)
|
||||
expect(() =>
|
||||
buildBatchWriteRequest('PUT', { ...params, items: [{ externalId: ' ' }] })
|
||||
).toThrow('Batch item 1 must include a non-empty string externalId')
|
||||
expect(() => buildBatchWriteRequest('PUT', { ...params, items: [{ externalId: 7 }] })).toThrow(
|
||||
'Batch item 1 must include a non-empty string externalId'
|
||||
)
|
||||
expect(() =>
|
||||
buildBatchWriteRequest('PUT', { ...params, items: [{ externalId: 'EXT-7' }] })
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
buildBatchWriteRequest('PUT', {
|
||||
...params,
|
||||
items: [{ id: '7', externalId: 'EXT-7' }],
|
||||
})
|
||||
).toThrow('must not include id')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,926 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import {
|
||||
DEFAULT_MAX_ERROR_BODY_BYTES,
|
||||
readResponseTextWithLimit,
|
||||
} from '@/lib/core/utils/stream-limits'
|
||||
import { normalizeNetSuiteSuiteTalkOrigin } from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits'
|
||||
import type {
|
||||
NetSuiteAuthParams,
|
||||
NetSuiteBatchWriteParams,
|
||||
NetSuiteJsonObject,
|
||||
NetSuiteResponse,
|
||||
} from '@/tools/netsuite/types'
|
||||
import type { HttpMethod, ToolConfig } from '@/tools/types'
|
||||
|
||||
const SUITETALK_REQUEST_TIMEOUT_MS = 30_000
|
||||
const DEFAULT_PAGE_LIMIT = 100
|
||||
const MAX_PAGE_LIMIT = 1_000
|
||||
const MAX_PAGE_COUNT = 1_000
|
||||
const MAX_RESULT_COUNT = 100_000
|
||||
const MAX_BATCH_RECORDS = 100
|
||||
const MAX_JSON_NESTING_DEPTH = 100
|
||||
const MAX_JSON_NODE_COUNT = 100_000
|
||||
type NetSuiteSuccessValidator =
|
||||
| 'collection-page'
|
||||
| 'suiteql-page'
|
||||
| 'record-action'
|
||||
| 'metadata-catalog'
|
||||
| 'async-job'
|
||||
| 'async-task-collection'
|
||||
| 'async-task'
|
||||
| 'server-time'
|
||||
| 'governance-limits'
|
||||
|
||||
interface NetSuiteSuccessCase {
|
||||
status: number
|
||||
body: 'none' | 'object' | 'optional-object'
|
||||
validator?: NetSuiteSuccessValidator
|
||||
}
|
||||
|
||||
interface NetSuiteRequest {
|
||||
method: HttpMethod
|
||||
path: string
|
||||
success: NetSuiteSuccessCase | readonly NetSuiteSuccessCase[]
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
/**
|
||||
* How a `Location` response header is treated. Oracle documents the header
|
||||
* for {@link https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545141395.html create}
|
||||
* and {@link https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545142173.html update},
|
||||
* so `resource` requires it. Upsert and transform also produce a record but
|
||||
* Oracle documents no response headers for them, so `resource-optional`
|
||||
* surfaces the header when NetSuite sends it without failing when it does not.
|
||||
*/
|
||||
responseLocation?: 'resource' | 'resource-optional' | 'async-job'
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication fields shared by every NetSuite tool. Long-lived OAuth
|
||||
* signing material stays in the selected reusable credential; tools receive
|
||||
* only the short-lived token and SuiteTalk origin injected by the executor.
|
||||
*/
|
||||
export const netsuiteAuthParamFields = {
|
||||
oauthCredential: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'NetSuite OAuth 2.0 client-credentials service account',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Short-lived access token injected by the executor from the selected credential',
|
||||
},
|
||||
instanceUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'SuiteTalk account origin injected by the executor from the selected credential',
|
||||
},
|
||||
} satisfies ToolConfig['params']
|
||||
|
||||
export function encodePathSegment(value: string, label: string): string {
|
||||
const trimmed = requiredTrim(value, label)
|
||||
if (trimmed === '.' || trimmed === '..') {
|
||||
throw new Error(`${label} cannot be a dot path segment`)
|
||||
}
|
||||
if (trimmed.startsWith('eid:') && !/^[A-Za-z0-9_-]+$/.test(trimmed.slice(4))) {
|
||||
throw new Error(
|
||||
`${label} external ID must contain only letters, numbers, underscores, and hyphens`
|
||||
)
|
||||
}
|
||||
return encodeURIComponent(trimmed).replace(/%3A/gi, ':').replace(/%40/gi, '@')
|
||||
}
|
||||
|
||||
export function buildRecordPath(...segments: Array<{ value: string; label: string }>): string {
|
||||
return `/services/rest/record/v1/${segments
|
||||
.map(({ value, label }) => encodePathSegment(value, label))
|
||||
.join('/')}`
|
||||
}
|
||||
|
||||
export function buildSubresourcePath(value: string): Array<{ value: string; label: string }> {
|
||||
const segments = requiredTrim(value, 'Subresource path')
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
if (segments.length === 0) throw new Error('Subresource path is required')
|
||||
return segments.map((segment) => ({ value: segment, label: 'Subresource path segment' }))
|
||||
}
|
||||
|
||||
export function normalizePagination(
|
||||
requestedLimit?: number,
|
||||
requestedOffset?: number
|
||||
): { limit: number; offset: number } {
|
||||
const limit = requestedLimit ?? DEFAULT_PAGE_LIMIT
|
||||
const offset = requestedOffset ?? 0
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_LIMIT) {
|
||||
throw new Error(`Limit must be an integer between 1 and ${MAX_PAGE_LIMIT}`)
|
||||
}
|
||||
if (!Number.isInteger(offset) || offset < 0 || offset % limit !== 0) {
|
||||
throw new Error('Offset must be a non-negative integer divisible by limit')
|
||||
}
|
||||
if (offset + limit > MAX_RESULT_COUNT) {
|
||||
throw new Error(`Offset and limit must stay within NetSuite's first 100,000 results`)
|
||||
}
|
||||
if (offset / limit >= MAX_PAGE_COUNT) {
|
||||
throw new Error(`Offset must select one of NetSuite's first ${MAX_PAGE_COUNT} pages`)
|
||||
}
|
||||
return { limit, offset }
|
||||
}
|
||||
|
||||
export function normalizeBatchItems(items: NetSuiteJsonObject[]): NetSuiteJsonObject[] {
|
||||
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_BATCH_RECORDS) {
|
||||
throw new Error(`Batch items must contain between 1 and ${MAX_BATCH_RECORDS} records`)
|
||||
}
|
||||
if (items.some((item) => !isJsonObject(item))) {
|
||||
throw new Error('Every batch item must be a JSON object')
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function buildBatchWriteRequest(
|
||||
method: 'POST' | 'PATCH' | 'PUT',
|
||||
params: NetSuiteBatchWriteParams
|
||||
): NetSuiteRequest {
|
||||
const items = normalizeBatchItems(params.items)
|
||||
const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key')
|
||||
validateBatchWriteIdentifiers(method, items)
|
||||
return {
|
||||
method,
|
||||
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
|
||||
success: { status: 202, body: 'none' },
|
||||
responseLocation: 'async-job',
|
||||
headers: {
|
||||
Prefer: 'respond-async',
|
||||
'Content-Type': 'application/vnd.oracle.resource+json; type=collection',
|
||||
...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}),
|
||||
},
|
||||
body: { items },
|
||||
}
|
||||
}
|
||||
|
||||
function validateBatchWriteIdentifiers(
|
||||
method: 'POST' | 'PATCH' | 'PUT',
|
||||
items: NetSuiteJsonObject[]
|
||||
): void {
|
||||
if (method === 'POST') return
|
||||
|
||||
const invalidIndex = items.findIndex((item) => {
|
||||
if (method === 'PUT') {
|
||||
return !isNonEmptyString(item.externalId) || item.id !== undefined
|
||||
}
|
||||
return !hasBatchInternalId(item.id) && !isNonEmptyString(item.externalId)
|
||||
})
|
||||
if (invalidIndex === -1) return
|
||||
|
||||
const requirement =
|
||||
method === 'PUT'
|
||||
? 'a non-empty string externalId and must not include id'
|
||||
: 'an id or non-empty string externalId'
|
||||
throw new Error(`Batch item ${invalidIndex + 1} must include ${requirement}`)
|
||||
}
|
||||
|
||||
function hasBatchInternalId(value: unknown): boolean {
|
||||
if (isNonEmptyString(value)) return true
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && Boolean(value.trim())
|
||||
}
|
||||
|
||||
export function normalizeBatchIds(ids: string): string {
|
||||
const values = requiredTrim(ids, 'IDs')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
if (values.some((id) => !id)) {
|
||||
throw new Error('IDs must not contain empty comma-separated values')
|
||||
}
|
||||
if (values.length < 1 || values.length > MAX_BATCH_RECORDS) {
|
||||
throw new Error(`IDs must contain between 1 and ${MAX_BATCH_RECORDS} comma-separated values`)
|
||||
}
|
||||
return values.join(',')
|
||||
}
|
||||
|
||||
export function optionalTrim(value?: string, label = 'Value'): string | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value !== 'string') throw new Error(`${label} must be a string`)
|
||||
const trimmed = value.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
export function requiredTrim(value: string, label: string): string {
|
||||
if (typeof value !== 'string') throw new Error(`${label} must be a string`)
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) throw new Error(`${label} is required`)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function normalizeOptionalBoolean(value: unknown, label: string): boolean | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be a boolean`)
|
||||
return value
|
||||
}
|
||||
|
||||
export function normalizeRelatedType(value: string): 'contact' | 'file' {
|
||||
const normalized = requiredTrim(value, 'Related type').toLowerCase()
|
||||
if (normalized !== 'contact' && normalized !== 'file') {
|
||||
throw new Error('Related type must be contact or file')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeSuiteTalkUrl(suiteTalkUrl: string): string {
|
||||
const value = requiredTrim(suiteTalkUrl, 'SuiteTalk URL')
|
||||
const origin = normalizeNetSuiteSuiteTalkOrigin(value)
|
||||
if (origin) return origin
|
||||
throw new Error(
|
||||
'SuiteTalk URL must be an HTTPS NetSuite Company URL with no path, query, or fragment'
|
||||
)
|
||||
}
|
||||
|
||||
export async function executeNetSuiteRequest(
|
||||
auth: NetSuiteAuthParams,
|
||||
buildRequest: () => NetSuiteRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<NetSuiteResponse> {
|
||||
let normalizedAuth: NetSuiteAuthParams | undefined
|
||||
let suiteTalkTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const request = buildRequest()
|
||||
if (request.body !== undefined && !isJsonObject(request.body)) {
|
||||
throw new Error('NetSuite request body must be a JSON object')
|
||||
}
|
||||
const serializedBody = serializeRequestBody(request.body)
|
||||
normalizedAuth = normalizeAuthParams(auth)
|
||||
const origin = normalizeSuiteTalkUrl(normalizedAuth.instanceUrl ?? '')
|
||||
const accessToken = requiredTrim(normalizedAuth.accessToken ?? '', 'NetSuite access token')
|
||||
if (signal?.aborted) throw signal.reason ?? new Error('Aborted')
|
||||
const suiteTalkTimeoutController = new AbortController()
|
||||
suiteTalkTimeoutId = setTimeout(
|
||||
() =>
|
||||
suiteTalkTimeoutController.abort(
|
||||
new DOMException('NetSuite SuiteTalk request timed out', 'TimeoutError')
|
||||
),
|
||||
SUITETALK_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
const suiteTalkSignal = signal
|
||||
? AbortSignal.any([signal, suiteTalkTimeoutController.signal])
|
||||
: suiteTalkTimeoutController.signal
|
||||
const response = await sendSuiteTalkRequest(
|
||||
origin,
|
||||
request,
|
||||
serializedBody,
|
||||
accessToken,
|
||||
suiteTalkSignal
|
||||
)
|
||||
|
||||
const { location, jobId } = validateResponseLocation(
|
||||
response.headers.get('location'),
|
||||
origin,
|
||||
request.responseLocation
|
||||
)
|
||||
let data: unknown | null
|
||||
try {
|
||||
data = await readSuiteTalkBody(response, suiteTalkSignal)
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
status: response.status,
|
||||
data: null,
|
||||
...(location ? { location } : {}),
|
||||
...(jobId ? { jobId } : {}),
|
||||
},
|
||||
error: sanitizeErrorText(
|
||||
getErrorMessage(error, 'NetSuite response processing failed'),
|
||||
normalizedAuth
|
||||
),
|
||||
}
|
||||
}
|
||||
if (
|
||||
!response.ok &&
|
||||
location &&
|
||||
jobId &&
|
||||
isIdempotentJobReplay(request, response.status, data)
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
output: { status: response.status, data: null, location, jobId },
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
status: response.status,
|
||||
data: null,
|
||||
...(location ? { location } : {}),
|
||||
...(jobId ? { jobId } : {}),
|
||||
},
|
||||
error: extractNetSuiteError(data, response.status, normalizedAuth),
|
||||
}
|
||||
}
|
||||
|
||||
const successCase = getSuccessCase(request.success, response.status)
|
||||
if (!successCase) {
|
||||
const expectedStatuses = getSuccessCases(request.success)
|
||||
.map(({ status }) => `HTTP ${status}`)
|
||||
.join(' or ')
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
status: response.status,
|
||||
data,
|
||||
...(location ? { location } : {}),
|
||||
...(jobId ? { jobId } : {}),
|
||||
},
|
||||
error: `NetSuite returned HTTP ${response.status}; expected ${expectedStatuses}`,
|
||||
}
|
||||
}
|
||||
|
||||
const contractError = validateSuccessBody(successCase, data)
|
||||
if (contractError) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
status: response.status,
|
||||
data,
|
||||
...(location ? { location } : {}),
|
||||
...(jobId ? { jobId } : {}),
|
||||
},
|
||||
error: contractError,
|
||||
}
|
||||
}
|
||||
|
||||
if (request.responseLocation === 'resource' && !location) {
|
||||
return {
|
||||
success: false,
|
||||
output: { status: response.status, data },
|
||||
error: 'NetSuite success response did not include a valid resource Location header',
|
||||
}
|
||||
}
|
||||
|
||||
if (expectsAsyncJob(request) && (!location || !jobId)) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
status: response.status,
|
||||
data,
|
||||
...(location ? { location } : {}),
|
||||
},
|
||||
error: 'NetSuite asynchronous response did not include a valid job Location header',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
status: response.status,
|
||||
data,
|
||||
...(location ? { location } : {}),
|
||||
...(jobId ? { jobId } : {}),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { status: 0, data: null },
|
||||
error: sanitizeErrorText(
|
||||
getErrorMessage(error, 'NetSuite request failed'),
|
||||
normalizedAuth ?? auth
|
||||
),
|
||||
}
|
||||
} finally {
|
||||
if (suiteTalkTimeoutId) clearTimeout(suiteTalkTimeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSuiteTalkRequest(
|
||||
origin: string,
|
||||
request: NetSuiteRequest,
|
||||
serializedBody: string | undefined,
|
||||
accessToken: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Response> {
|
||||
if (!request.path.startsWith('/services/rest/') || request.path.includes('://')) {
|
||||
throw new Error('Invalid NetSuite REST path')
|
||||
}
|
||||
const url = new URL(request.path, origin)
|
||||
for (const [key, value] of Object.entries(request.query ?? {})) {
|
||||
if (value !== undefined && value !== '') url.searchParams.set(key, String(value))
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...request.headers,
|
||||
}
|
||||
const hasBody = serializedBody !== undefined
|
||||
if (hasBody) {
|
||||
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'
|
||||
if (request.path.startsWith('/services/rest/record/')) {
|
||||
headers['X-NetSuite-PropertyNameValidation'] = 'error'
|
||||
}
|
||||
}
|
||||
return fetch(url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
...(serializedBody !== undefined ? { body: serializedBody } : {}),
|
||||
redirect: 'error',
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
async function readSuiteTalkBody(
|
||||
response: Response,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown | null> {
|
||||
if (
|
||||
response.status === 204 ||
|
||||
response.status === 205 ||
|
||||
response.headers.get('content-length') === '0'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const text = await readResponseTextWithLimit(response, {
|
||||
maxBytes: response.ok ? MAX_INLINE_MATERIALIZATION_BYTES : DEFAULT_MAX_ERROR_BODY_BYTES,
|
||||
label: response.ok ? 'NetSuite response' : 'NetSuite error response',
|
||||
signal,
|
||||
})
|
||||
if (!text.trim()) return null
|
||||
try {
|
||||
return JSON.parse(text) as unknown
|
||||
} catch {
|
||||
if (response.ok) throw new Error('NetSuite returned a non-JSON success response')
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function serializeRequestBody(body: unknown): string | undefined {
|
||||
if (body === undefined) return undefined
|
||||
assertJsonBodyWithinLimit(body)
|
||||
const serializedBody = JSON.stringify(body)
|
||||
if (serializedBody === undefined) {
|
||||
throw new Error('NetSuite request body must be JSON serializable')
|
||||
}
|
||||
if (Buffer.byteLength(serializedBody, 'utf8') > MAX_INLINE_MATERIALIZATION_BYTES) {
|
||||
throw new Error('NetSuite request body exceeds the inline payload limit')
|
||||
}
|
||||
return serializedBody
|
||||
}
|
||||
|
||||
interface JsonBudgetState {
|
||||
bytes: number
|
||||
nodes: number
|
||||
ancestors: WeakSet<object>
|
||||
}
|
||||
|
||||
type JsonBudgetFrame =
|
||||
| { kind: 'value'; value: unknown; depth: number }
|
||||
| { kind: 'array'; value: unknown[]; index: number; depth: number }
|
||||
| {
|
||||
kind: 'object'
|
||||
value: Record<string, unknown>
|
||||
keys: IterableIterator<string>
|
||||
emitted: boolean
|
||||
depth: number
|
||||
}
|
||||
|
||||
function assertJsonBodyWithinLimit(body: unknown): void {
|
||||
const state: JsonBudgetState = { bytes: 0, nodes: 0, ancestors: new WeakSet<object>() }
|
||||
const frames: JsonBudgetFrame[] = [{ kind: 'value', value: body, depth: 0 }]
|
||||
|
||||
while (frames.length > 0) {
|
||||
const frame = frames.pop()
|
||||
if (!frame) break
|
||||
|
||||
if (frame.kind === 'array') {
|
||||
if (frame.index >= frame.value.length) {
|
||||
addJsonBytes(state, 1)
|
||||
state.ancestors.delete(frame.value)
|
||||
continue
|
||||
}
|
||||
if (frame.index > 0) addJsonBytes(state, 1)
|
||||
const descriptor = Object.getOwnPropertyDescriptor(frame.value, String(frame.index))
|
||||
if (descriptor?.get || descriptor?.set) throwNonPlainJsonError()
|
||||
const value = descriptor ? descriptor.value : null
|
||||
frames.push({ ...frame, index: frame.index + 1 })
|
||||
frames.push({
|
||||
kind: 'value',
|
||||
value: isOmittedObjectJsonValue(value) ? null : value,
|
||||
depth: frame.depth + 1,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (frame.kind === 'object') {
|
||||
let next = frame.keys.next()
|
||||
while (!next.done) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(frame.value, next.value)
|
||||
if (descriptor?.get || descriptor?.set) throwNonPlainJsonError()
|
||||
const value = descriptor?.value
|
||||
if (!isOmittedObjectJsonValue(value)) {
|
||||
if (frame.emitted) addJsonBytes(state, 1)
|
||||
addJsonBytes(state, jsonStringByteLength(next.value) + 1)
|
||||
frames.push({ ...frame, emitted: true })
|
||||
frames.push({ kind: 'value', value, depth: frame.depth + 1 })
|
||||
break
|
||||
}
|
||||
next = frame.keys.next()
|
||||
}
|
||||
if (next.done) {
|
||||
addJsonBytes(state, 1)
|
||||
state.ancestors.delete(frame.value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
admitJsonNode(state)
|
||||
const { value, depth } = frame
|
||||
if (depth > MAX_JSON_NESTING_DEPTH) {
|
||||
throw new Error(`NetSuite request body exceeds the JSON nesting limit`)
|
||||
}
|
||||
if (value === null) {
|
||||
addJsonBytes(state, 4)
|
||||
} else if (typeof value === 'string') {
|
||||
addJsonBytes(state, jsonStringByteLength(value))
|
||||
} else if (typeof value === 'boolean') {
|
||||
addJsonBytes(state, value ? 4 : 5)
|
||||
} else if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) throwNonPlainJsonError()
|
||||
addJsonBytes(state, JSON.stringify(value).length)
|
||||
} else if (Array.isArray(value)) {
|
||||
rejectCustomJsonSerialization(value)
|
||||
if (state.ancestors.has(value)) throw new Error('NetSuite request body must not be cyclic')
|
||||
if (value.length * 2 + 1 > MAX_INLINE_MATERIALIZATION_BYTES - state.bytes) {
|
||||
throwRequestBodyLimitError()
|
||||
}
|
||||
state.ancestors.add(value)
|
||||
addJsonBytes(state, 1)
|
||||
frames.push({ kind: 'array', value, index: 0, depth })
|
||||
} else if (isJsonObject(value)) {
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) throwNonPlainJsonError()
|
||||
rejectCustomJsonSerialization(value)
|
||||
if (state.ancestors.has(value)) throw new Error('NetSuite request body must not be cyclic')
|
||||
state.ancestors.add(value)
|
||||
addJsonBytes(state, 1)
|
||||
frames.push({
|
||||
kind: 'object',
|
||||
value,
|
||||
keys: enumerableOwnStringKeys(value),
|
||||
emitted: false,
|
||||
depth,
|
||||
})
|
||||
} else {
|
||||
throwNonPlainJsonError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* enumerableOwnStringKeys(value: Record<string, unknown>): IterableIterator<string> {
|
||||
for (const key in value) {
|
||||
if (Object.hasOwn(value, key)) yield key
|
||||
}
|
||||
}
|
||||
|
||||
function rejectCustomJsonSerialization(value: object): void {
|
||||
if (Object.hasOwn(value, 'toJSON')) throwNonPlainJsonError()
|
||||
}
|
||||
|
||||
function isOmittedObjectJsonValue(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'function' || typeof value === 'symbol'
|
||||
}
|
||||
|
||||
function admitJsonNode(state: JsonBudgetState): void {
|
||||
state.nodes += 1
|
||||
if (state.nodes > MAX_JSON_NODE_COUNT) {
|
||||
throw new Error('NetSuite request body exceeds the JSON complexity limit')
|
||||
}
|
||||
}
|
||||
|
||||
function addJsonBytes(state: JsonBudgetState, bytes: number): void {
|
||||
state.bytes += bytes
|
||||
if (state.bytes > MAX_INLINE_MATERIALIZATION_BYTES) throwRequestBodyLimitError()
|
||||
}
|
||||
|
||||
function jsonStringByteLength(value: string): number {
|
||||
let bytes = 2
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code === 0x22 || code === 0x5c) {
|
||||
bytes += 2
|
||||
} else if (code < 0x20) {
|
||||
bytes +=
|
||||
code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6
|
||||
} else if (code < 0x80) {
|
||||
bytes += 1
|
||||
} else if (code < 0x800) {
|
||||
bytes += 2
|
||||
} else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
bytes += 6
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
bytes += 6
|
||||
} else {
|
||||
bytes += 3
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function throwRequestBodyLimitError(): never {
|
||||
throw new Error('NetSuite request body exceeds the inline payload limit')
|
||||
}
|
||||
|
||||
function throwNonPlainJsonError(): never {
|
||||
throw new Error(
|
||||
'NetSuite request body must contain plain JSON data without accessors or custom serialization'
|
||||
)
|
||||
}
|
||||
|
||||
function getSuccessCases(contract: NetSuiteRequest['success']): readonly NetSuiteSuccessCase[] {
|
||||
return Array.isArray(contract) ? contract : [contract as NetSuiteSuccessCase]
|
||||
}
|
||||
|
||||
function getSuccessCase(
|
||||
contract: NetSuiteRequest['success'],
|
||||
status: number
|
||||
): NetSuiteSuccessCase | undefined {
|
||||
return getSuccessCases(contract).find((candidate) => candidate.status === status)
|
||||
}
|
||||
|
||||
function validateSuccessBody(
|
||||
successCase: NetSuiteSuccessCase,
|
||||
data: unknown | null
|
||||
): string | null {
|
||||
if (successCase.body === 'none' && data !== null) {
|
||||
return `NetSuite HTTP ${successCase.status} response unexpectedly included a body`
|
||||
}
|
||||
if (successCase.body === 'object' && !isJsonObject(data)) {
|
||||
return `NetSuite HTTP ${successCase.status} response did not include the documented JSON object`
|
||||
}
|
||||
if (successCase.body === 'optional-object' && data !== null && !isJsonObject(data)) {
|
||||
return `NetSuite HTTP ${successCase.status} response was neither empty nor a JSON object`
|
||||
}
|
||||
if (!successCase.validator || !isJsonObject(data)) return null
|
||||
|
||||
switch (successCase.validator) {
|
||||
case 'collection-page':
|
||||
return validateCollectionPage(data, { label: 'collection page', requireHasMore: true })
|
||||
case 'suiteql-page':
|
||||
return validateCollectionPage(data, { label: 'SuiteQL page', requireHasMore: false })
|
||||
case 'record-action':
|
||||
return data.result === true
|
||||
? null
|
||||
: 'NetSuite record-action success response did not include result: true'
|
||||
case 'metadata-catalog':
|
||||
return validateMetadataCatalog(data)
|
||||
case 'async-job':
|
||||
return validateRequiredProperties(
|
||||
data,
|
||||
{
|
||||
completed: 'boolean',
|
||||
id: 'string',
|
||||
progress: 'string',
|
||||
task: 'object',
|
||||
links: 'array',
|
||||
},
|
||||
'asynchronous job'
|
||||
)
|
||||
case 'async-task-collection':
|
||||
return validateRequiredProperties(
|
||||
data,
|
||||
{ count: 'number', items: 'array', links: 'array' },
|
||||
'asynchronous task collection'
|
||||
)
|
||||
case 'async-task':
|
||||
return validateRequiredProperties(
|
||||
data,
|
||||
{ completed: 'boolean', id: 'string', progress: 'string', links: 'array' },
|
||||
'asynchronous task'
|
||||
)
|
||||
case 'server-time':
|
||||
return typeof data.serverTime === 'string' && Boolean(data.serverTime.trim())
|
||||
? null
|
||||
: 'NetSuite server-time response did not include serverTime'
|
||||
case 'governance-limits': {
|
||||
const requiredError = validateRequiredProperties(
|
||||
data,
|
||||
{
|
||||
accountConcurrencyLimit: 'number',
|
||||
accountUnallocatedConcurrencyLimit: 'number',
|
||||
integrationLimitType: 'string',
|
||||
},
|
||||
'governance-limits'
|
||||
)
|
||||
if (requiredError) return requiredError
|
||||
return ['integrationSpecific', 'accountLimit', 'internal'].includes(
|
||||
data.integrationLimitType as string
|
||||
)
|
||||
? null
|
||||
: 'NetSuite governance-limits response included an unknown integrationLimitType'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateMetadataCatalog(data: Record<string, unknown>): string | null {
|
||||
if (!Array.isArray(data.items)) {
|
||||
return 'NetSuite metadata catalog response did not include an items array'
|
||||
}
|
||||
if (data.links !== undefined && !Array.isArray(data.links)) {
|
||||
return 'NetSuite metadata catalog response included invalid links'
|
||||
}
|
||||
for (const item of data.items) {
|
||||
if (!isJsonObject(item) || !isNonEmptyString(item.name)) {
|
||||
return 'NetSuite metadata catalog response included an invalid record type'
|
||||
}
|
||||
if (item.links !== undefined && !Array.isArray(item.links)) {
|
||||
return 'NetSuite metadata catalog response included invalid record-type links'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a documented NetSuite collection page.
|
||||
*
|
||||
* Oracle documents `hasMore` on record collections and SuiteAnalytics dataset
|
||||
* pages, but its SuiteQL reference lists only `links`, `count`, `offset`,
|
||||
* `totalResults`, and `items`. SuiteQL therefore validates `hasMore` only when
|
||||
* the account actually returns it, so a documented SuiteQL page is never
|
||||
* reported as a failed request.
|
||||
* @see https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156414087576.html
|
||||
* @see https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157909186990.html
|
||||
*/
|
||||
function validateCollectionPage(
|
||||
data: Record<string, unknown>,
|
||||
{ label, requireHasMore }: { label: string; requireHasMore: boolean }
|
||||
): string | null {
|
||||
const properties: Record<string, 'array' | 'boolean' | 'number' | 'object' | 'string'> = {
|
||||
links: 'array',
|
||||
items: 'array',
|
||||
count: 'number',
|
||||
offset: 'number',
|
||||
totalResults: 'number',
|
||||
}
|
||||
if (requireHasMore) properties.hasMore = 'boolean'
|
||||
|
||||
const error = validateRequiredProperties(data, properties, label)
|
||||
if (error) return error
|
||||
if (!requireHasMore && data.hasMore !== undefined && typeof data.hasMore !== 'boolean') {
|
||||
return `NetSuite ${label} response did not include a valid hasMore`
|
||||
}
|
||||
for (const key of ['count', 'offset', 'totalResults'] as const) {
|
||||
const value = data[key]
|
||||
if (!Number.isInteger(value) || (value as number) < 0) {
|
||||
return `NetSuite ${label} response included an invalid ${key}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validateRequiredProperties(
|
||||
data: Record<string, unknown>,
|
||||
properties: Record<string, 'array' | 'boolean' | 'number' | 'object' | 'string'>,
|
||||
label: string
|
||||
): string | null {
|
||||
for (const [key, type] of Object.entries(properties)) {
|
||||
const value = data[key]
|
||||
const valid =
|
||||
type === 'array'
|
||||
? Array.isArray(value)
|
||||
: type === 'object'
|
||||
? isJsonObject(value)
|
||||
: type === 'number'
|
||||
? typeof value === 'number' && Number.isFinite(value)
|
||||
: typeof value === type
|
||||
if (!valid) return `NetSuite ${label} response did not include a valid ${key}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function expectsAsyncJob(request: NetSuiteRequest): boolean {
|
||||
return request.responseLocation === 'async-job'
|
||||
}
|
||||
|
||||
function normalizeAuthParams(auth: NetSuiteAuthParams): NetSuiteAuthParams {
|
||||
if (!isJsonObject(auth)) throw new Error('NetSuite credentials are required')
|
||||
return {
|
||||
oauthCredential: requiredTrim(auth.oauthCredential, 'NetSuite credential'),
|
||||
accessToken: requiredTrim(auth.accessToken ?? '', 'NetSuite access token'),
|
||||
instanceUrl: requiredTrim(auth.instanceUrl ?? '', 'SuiteTalk URL'),
|
||||
}
|
||||
}
|
||||
|
||||
function validateResponseLocation(
|
||||
headerValue: string | null,
|
||||
origin: string,
|
||||
mode?: NetSuiteRequest['responseLocation']
|
||||
): { location?: string; jobId?: string } {
|
||||
if (!mode || !headerValue?.trim()) return {}
|
||||
const location = headerValue.trim()
|
||||
try {
|
||||
const parsed = new URL(location, origin)
|
||||
if (
|
||||
parsed.protocol !== 'https:' ||
|
||||
parsed.origin !== origin ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.hash
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
if (mode === 'resource' || mode === 'resource-optional') {
|
||||
return parsed.pathname.startsWith('/services/rest/record/v1/') ? { location } : {}
|
||||
}
|
||||
if (parsed.search) return {}
|
||||
const match = parsed.pathname.match(/^\/services\/rest\/async\/v1\/job\/([^/]+)$/)
|
||||
if (!match?.[1]) return {}
|
||||
const jobId = decodeURIComponent(match[1])
|
||||
if (!jobId || jobId.includes('/') || jobId.includes('?') || jobId.includes('#')) return {}
|
||||
return { location, jobId }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function isIdempotentJobReplay(request: NetSuiteRequest, status: number, data: unknown): boolean {
|
||||
if (status !== 400) return false
|
||||
const hasIdempotencyKey = Object.entries(request.headers ?? {}).some(
|
||||
([name, value]) => name.toLowerCase() === 'x-netsuite-idempotency-key' && Boolean(value.trim())
|
||||
)
|
||||
if (!hasIdempotencyKey || !isJsonObject(data)) return false
|
||||
|
||||
const errorDetails = data['o:errorDetails']
|
||||
return (
|
||||
Array.isArray(errorDetails) &&
|
||||
errorDetails.some(
|
||||
(detail) => isJsonObject(detail) && detail['o:errorCode'] === 'IDEMPOTENCY_ERROR'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function extractNetSuiteError(data: unknown, status: number, auth: NetSuiteAuthParams): string {
|
||||
if (isJsonObject(data)) {
|
||||
const errorDetails = data['o:errorDetails']
|
||||
if (Array.isArray(errorDetails)) {
|
||||
const summaries = errorDetails.flatMap((detail) => {
|
||||
if (!isJsonObject(detail)) return []
|
||||
const message = typeof detail.detail === 'string' ? detail.detail.trim() : ''
|
||||
const context = [
|
||||
['code', detail['o:errorCode']],
|
||||
['path', detail['o:errorPath']],
|
||||
['URL path', detail['o:urlPath']],
|
||||
['header', detail['o:errorHeader']],
|
||||
['query parameter', detail['o:errorQueryParam']],
|
||||
].flatMap(([label, value]) =>
|
||||
typeof value === 'string' && value.trim() ? [`${label}=${value.trim()}`] : []
|
||||
)
|
||||
if (!message && context.length === 0) return []
|
||||
return [`${message || 'NetSuite error'}${context.length ? ` [${context.join(', ')}]` : ''}`]
|
||||
})
|
||||
if (summaries.length > 0) {
|
||||
return `NetSuite request failed (${status}): ${sanitizeErrorText(summaries.join('; '), auth)}`
|
||||
}
|
||||
}
|
||||
for (const key of ['detail', 'title', 'error_description', 'message']) {
|
||||
if (typeof data[key] === 'string' && data[key].trim()) {
|
||||
const errorCode =
|
||||
typeof data['o:errorCode'] === 'string' && data['o:errorCode'].trim()
|
||||
? ` [code=${data['o:errorCode'].trim()}]`
|
||||
: ''
|
||||
return `NetSuite request failed (${status}): ${sanitizeErrorText(`${data[key]}${errorCode}`, auth)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof data === 'string' && data.trim()) {
|
||||
return `NetSuite request failed (${status}): ${sanitizeErrorText(data, auth)}`
|
||||
}
|
||||
return `NetSuite request failed with HTTP ${status}`
|
||||
}
|
||||
|
||||
function sanitizeErrorText(value: string, auth?: NetSuiteAuthParams): string {
|
||||
let sanitized = value
|
||||
.replace(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, '[REDACTED]')
|
||||
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED]')
|
||||
for (const credential of auth ? [auth.oauthCredential, auth.accessToken, auth.instanceUrl] : []) {
|
||||
if (typeof credential !== 'string') continue
|
||||
const secret = credential.trim()
|
||||
if (secret.length >= 3) sanitized = sanitized.split(secret).join('[REDACTED]')
|
||||
}
|
||||
return truncate(sanitized.replace(/\s+/g, ' ').trim(), 900) || 'Unknown error'
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -2750,6 +2750,35 @@ import {
|
||||
neo4jQueryTool,
|
||||
neo4jUpdateTool,
|
||||
} from '@/tools/neo4j'
|
||||
import {
|
||||
netsuiteAttachRecordTool,
|
||||
netsuiteBatchCreateRecordsTool,
|
||||
netsuiteBatchDeleteRecordsTool,
|
||||
netsuiteBatchGetRecordsTool,
|
||||
netsuiteBatchUpdateRecordsTool,
|
||||
netsuiteBatchUpsertRecordsTool,
|
||||
netsuiteCreateRecordTool,
|
||||
netsuiteDeleteRecordTool,
|
||||
netsuiteDetachRecordTool,
|
||||
netsuiteExecuteActionTool,
|
||||
netsuiteExecuteDatasetTool,
|
||||
netsuiteExecuteSuiteQLTool,
|
||||
netsuiteGetAsyncResultTool,
|
||||
netsuiteGetAsyncStatusTool,
|
||||
netsuiteGetGovernanceLimitsTool,
|
||||
netsuiteGetRecordFormTool,
|
||||
netsuiteGetRecordMetadataTool,
|
||||
netsuiteGetRecordTool,
|
||||
netsuiteGetSelectOptionsTool,
|
||||
netsuiteGetServerTimeTool,
|
||||
netsuiteGetSubresourceTool,
|
||||
netsuiteListDatasetsTool,
|
||||
netsuiteListRecordsTool,
|
||||
netsuiteListRecordTypesTool,
|
||||
netsuiteTransformRecordTool,
|
||||
netsuiteUpdateRecordTool,
|
||||
netsuiteUpsertRecordTool,
|
||||
} from '@/tools/netsuite'
|
||||
import { neverbounceGetCreditsTool, neverbounceVerifyEmailTool } from '@/tools/neverbounce'
|
||||
import {
|
||||
newRelicCreateDeploymentEventTool,
|
||||
@@ -6627,6 +6656,33 @@ export const tools: Record<string, ToolConfig> = {
|
||||
neo4j_delete: neo4jDeleteTool,
|
||||
neo4j_execute: neo4jExecuteTool,
|
||||
neo4j_introspect: neo4jIntrospectTool,
|
||||
netsuite_attach_record: netsuiteAttachRecordTool,
|
||||
netsuite_batch_create_records: netsuiteBatchCreateRecordsTool,
|
||||
netsuite_batch_delete_records: netsuiteBatchDeleteRecordsTool,
|
||||
netsuite_batch_get_records: netsuiteBatchGetRecordsTool,
|
||||
netsuite_batch_update_records: netsuiteBatchUpdateRecordsTool,
|
||||
netsuite_batch_upsert_records: netsuiteBatchUpsertRecordsTool,
|
||||
netsuite_create_record: netsuiteCreateRecordTool,
|
||||
netsuite_delete_record: netsuiteDeleteRecordTool,
|
||||
netsuite_detach_record: netsuiteDetachRecordTool,
|
||||
netsuite_execute_action: netsuiteExecuteActionTool,
|
||||
netsuite_execute_dataset: netsuiteExecuteDatasetTool,
|
||||
netsuite_execute_suiteql: netsuiteExecuteSuiteQLTool,
|
||||
netsuite_get_async_result: netsuiteGetAsyncResultTool,
|
||||
netsuite_get_async_status: netsuiteGetAsyncStatusTool,
|
||||
netsuite_get_governance_limits: netsuiteGetGovernanceLimitsTool,
|
||||
netsuite_get_record: netsuiteGetRecordTool,
|
||||
netsuite_get_record_form: netsuiteGetRecordFormTool,
|
||||
netsuite_get_record_metadata: netsuiteGetRecordMetadataTool,
|
||||
netsuite_get_select_options: netsuiteGetSelectOptionsTool,
|
||||
netsuite_get_server_time: netsuiteGetServerTimeTool,
|
||||
netsuite_get_subresource: netsuiteGetSubresourceTool,
|
||||
netsuite_list_datasets: netsuiteListDatasetsTool,
|
||||
netsuite_list_records: netsuiteListRecordsTool,
|
||||
netsuite_list_record_types: netsuiteListRecordTypesTool,
|
||||
netsuite_transform_record: netsuiteTransformRecordTool,
|
||||
netsuite_update_record: netsuiteUpdateRecordTool,
|
||||
netsuite_upsert_record: netsuiteUpsertRecordTool,
|
||||
new_relic_create_deployment_event: newRelicCreateDeploymentEventTool,
|
||||
new_relic_get_entity: newRelicGetEntityTool,
|
||||
new_relic_nrql_query: newRelicNrqlQueryTool,
|
||||
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 1105,
|
||||
zodRoutes: 1105,
|
||||
totalRoutes: 1106,
|
||||
zodRoutes: 1106,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ const HANDWRITTEN_INTEGRATION_DOCS = new Set([
|
||||
'hubspot-setup',
|
||||
'linear-service-account',
|
||||
'monday-service-account',
|
||||
'netsuite-service-account',
|
||||
'notion-service-account',
|
||||
'pipedrive-service-account',
|
||||
'salesforce-service-account',
|
||||
|
||||
Reference in New Issue
Block a user