feat(windchill): add document integration (#6577)

* feat(windchill): add document integration

* fix(windchill): align tool contracts and docs

* fix(windchill): use official integration icon

* fix(windchill): correct response and paging semantics

* fix(windchill): align execution and API contracts

* refactor(windchill): inline route authentication

* fix(windchill): correct OData query encoding, content download, and cleared-field handling

Validated the integration end to end against PTC Windchill REST Services 2.7
documentation and fixed every divergence found.

Protocol correctness:
- Encode OData query spaces as %20 rather than the form-encoded `+` that
  URLSearchParams emits. Every multi-token $filter and $orderby reached
  Windchill as a literal `+` and could not match.
- Download content through the documented typed navigation
  `<content>/PTC.ApplicationData/Content/URL`, which returns a signed vault
  URL, instead of a `$value` segment that WRS does not implement. The
  resolved URL is pinned to the configured HTTPS origin.
- Terminate every Stage 2 CacheDescriptor_array entry with `;` to match the
  documented grammar.
- Raise the $top bound to Windchill's documented 2000 maximum, keeping 200 as
  the default page size.

Cleared-field handling:
- The executor merges raw block inputs before the block's param transform, so
  omitting a key could not clear it. A cleared numeric or boolean field
  reached the URL builder as '' and threw, and cleared optional strings failed
  contract validation. Coercions now emit an explicit undefined, and the
  internal-route body strips blanks centrally.

Robustness and contracts:
- Bound the document-structure walk to the depth actually requested.
- Loosen response schemas that re-applied request-side bounds to
  provider-returned values, which turned committed mutations into opaque
  parse failures.
- Return contract-shaped bodies for oversized, malformed, and unhandled
  request failures.
- Normalize downloaded content types and drop charset parameters.

Presentation and docs:
- Square the icon to a centred tile on white.
- Replace WT.Document and PATCH-compatible jargon with plain language.
- Fix canvas sentence noun stutters on the bulk operations.
- Correct the revision skill's unverified working-copy claim to read the OID
  back rather than assume it, and add retirement and stale-checkout skills.
- Add a manual intro section to the integration docs page.

* fix(windchill): align tool copy with the docs page and rebase the route baseline

Tool descriptions feed both the integration catalog and the generated docs page,
so the plain-language pass had to reach them too: drop WT.Document and
PATCH-compatible from the operation copy, and correct the $top bound the
descriptions still advertised as 200.

Correct the docs intro's attachment wording, gloss OData on first use, and
attribute the bulk-atomicity claim to PTC's documented behavior.

Raise the API route-count baseline, which staging advanced while this branch
was behind.

* feat(windchill): add update common properties

Name, Number, and Organization are rejected by the PATCH-based update
operation, and the rejection message told users to reach for Windchill's
UpdateCommonProperties action that the integration did not expose. Add it.

PTC documents UpdateCommonProperties as a bound DocMgmt action taking an
Updates wrapper, available when hasCommonProperties is set on the Documents
entity, and refused while the document is checked out. The subblock and param
descriptions carry that constraint, and the rejection message now names the
operation that does the job.

* test(windchill): assert block and tool params stay aligned for every operation

Validating the new operation surfaced that nothing enforced the block-to-tool
alignment the review process had been checking by hand. Assert it for all 27
operations instead: every required tool param has a required, non-advanced
input under that operation's condition, and no operation shows an input its
tool cannot accept.

Both fail on a deliberately broken condition or a dropped required flag.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
Bill Leoutsakos
2026-08-12 13:18:31 -07:00
committed by GitHub
co-authored by Bill Leoutsakos Waleed Latif
parent 9aa16e381c
commit 2805a8def9
50 changed files with 8309 additions and 6 deletions
+26
View File
@@ -2494,6 +2494,32 @@ export function DocumentIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function WindchillIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='79 57 88 88'
role='img'
aria-label='Windchill icon'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<rect x='79' y='57' width='88' height='88' fill='#FFFFFF' />
<g fill='#3D4647'>
<polygon points='137.2,86.6 137.2,68.5 122.8,60.2 107.1,69.2' />
<polygon points='142.7,106.4 158.4,97.3 158.4,80.7 142.7,71.6' />
<polygon points='108.5,115.8 108.5,133.9 122.8,142.2 138.5,133.2' />
<polygon points='128.2,121 143.9,130.1 158.4,121.7 158.4,103.6' />
<polygon points='117.4,81.4 101.7,72.4 87.3,80.7 87.3,98.8' />
<polygon points='103,96 87.3,105.1 87.3,121.7 103,130.8' />
</g>
<polygon
fill='#40AA1D'
points='137.2,109.5 122.8,117.8 108.4,109.5 108.4,92.9 122.8,84.6 137.2,92.9'
/>
</svg>
)
}
export function MintlifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 19 19' fill='none'>
+2
View File
@@ -245,6 +245,7 @@ import {
WebhookIcon,
WhatsAppIcon,
WikipediaIcon,
WindchillIcon,
WizaIcon,
WordpressIcon,
WorkdayIcon,
@@ -538,6 +539,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
webflow: WebflowIcon,
whatsapp: WhatsAppIcon,
wikipedia: WikipediaIcon,
windchill: WindchillIcon,
wiza: WizaIcon,
wordpress: WordpressIcon,
workday: WorkdayIcon,
@@ -259,6 +259,7 @@
"webflow-service-account",
"whatsapp",
"wikipedia",
"windchill",
"wiza",
"wordpress",
"workday",
@@ -0,0 +1,937 @@
---
title: Windchill
description: Manage documents, revisions, and content in PTC Windchill
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="windchill"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[PTC Windchill](https://www.ptc.com/en/products/windchill) is the product lifecycle management system manufacturers use as the system of record for engineering data. Documents in Windchill are controlled objects: each one carries a number, a revision and iteration, a lifecycle state, folder placement, security labels, and a checkout status that decides who is allowed to change it right now.
This integration talks to Windchill REST Services (WRS) 2.7 over OData — the query protocol Windchill exposes its data through — using a Basic-authenticated service account. Point it at a complete versioned service root — `https://your-host/Windchill/servlet/odata/v6` — and your agents can:
- **Find and read documents**: list documents with an OData filter, sort order, field selection, page size, and a latest-version-only switch; fetch a single document by its object identifier (OID); and walk a document's structure through its usage links to see child documents with their versions and states.
- **Create and update**: create one document or a batch of them in a container and optional folder, and patch editable attributes on one or many documents. Name, Number, and Organization are rejected here and have their own operation, because Windchill changes those through a separate action and refuses it while a document is checked out.
- **Run the version and lifecycle cycle**: check documents out and back in with notes, undo a checkout, revise to the next revision, read the lifecycle states a document is actually allowed to move to, and transition it to one of them.
- **Move files**: download a document's primary content, or a specific attachment by its OID, into a Sim file — and upload files as primary content or attachments. Sim handles Windchill's CSRF token and its multi-step upload handshake for you.
Bulk actions are atomic on Windchill's side: PTC documents that if the action fails for any object in the collection, the entire action is rolled back and nothing changes.
Two limits are worth knowing before you build. Windchill identifies everything by OID (`OR:wt.doc.WTDocument:48796581`), so most operations need an OID you got from a list or get call rather than a document number. And this integration supports Basic authentication only — Windchill deployments fronted by OAuth are not currently supported.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate PTC Windchill REST Services 2.7 document management into your workflow using Basic authentication. Read and update document metadata, perform version and lifecycle actions, and transfer primary content and attachments. Windchill OAuth deployments are not currently supported.
## Actions
### Windchill List Documents
List documents with an OData query, sorting, and pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `select` | string | No | Comma-separated normalized document properties to return |
| `filter` | string | No | OData $filter expression |
| `orderBy` | string | No | OData $orderby expression |
| `top` | number | No | Maximum documents in the OData result set \($top\), from 1 to 2000 |
| `skip` | number | No | Documents to skip |
| `count` | boolean | No | Ask Windchill to include the total matching count |
| `latestVersion` | boolean | No | Return only the latest version of matching documents |
| `nextLink` | string | No | Verified @odata.nextLink from a previous list response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `documents` | array | Windchill documents |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
| `pageInfo` | object | OData pagination information |
| ↳ `count` | number | Number of items returned in this page |
| ↳ `totalCount` | number | Total matching items |
| ↳ `nextLink` | string | URL returned by Windchill for the next page |
### Windchill Get Document
Get a WT.Document by OID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `select` | string | No | Comma-separated normalized document properties to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `document` | object | Windchill document |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Get Document Structure
Retrieve recursive document usage links and their parent and child documents
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `structureDepth` | number | No | Document structure expansion depth, from 1 to 3 |
| `nextLink` | string | No | Verified @odata.nextLink from a previous structure response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `structure` | array | Document usage links, including recursively expanded child links |
| ↳ `id` | string | Document usage link OID |
| ↳ `parent` | object | Parent document |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
| ↳ `child` | object | Child document |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
| ↳ `children` | array | Nested child usage links with the same recursive shape |
| `pageInfo` | object | OData pagination information |
| ↳ `count` | number | Number of items returned in this page |
| ↳ `totalCount` | number | Total matching items |
| ↳ `nextLink` | string | URL returned by Windchill for the next page |
### Windchill Get Valid State Transitions
Get lifecycle states a document can transition to from its current state
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `states` | array | Valid lifecycle transitions |
| ↳ `value` | string | Internal state value |
| ↳ `display` | string | Displayed state value |
### Windchill Get Primary Content
Get primary-content metadata for a document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `content` | object | Primary-content metadata |
| ↳ `id` | string | Content object identifier |
| ↳ `fileName` | string | Content file name |
| ↳ `description` | string | Content description |
| ↳ `format` | string | Windchill content format |
| ↳ `mimeType` | string | Content MIME type |
| ↳ `fileSize` | number | Content size in bytes |
| ↳ `contentType` | string | Windchill OData content entity type |
| ↳ `displayName` | string | Displayed content name |
| ↳ `urlLocation` | string | URL-data location |
| ↳ `externalLocation` | string | External-storage location |
### Windchill List Attachments
List attachment metadata for a document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `nextLink` | string | No | Verified @odata.nextLink from a previous attachment response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `attachments` | array | Document attachments |
| ↳ `id` | string | Content object identifier |
| ↳ `fileName` | string | Content file name |
| ↳ `description` | string | Content description |
| ↳ `format` | string | Windchill content format |
| ↳ `mimeType` | string | Content MIME type |
| ↳ `fileSize` | number | Content size in bytes |
| ↳ `contentType` | string | Windchill OData content entity type |
| ↳ `displayName` | string | Displayed content name |
| ↳ `urlLocation` | string | URL-data location |
| ↳ `externalLocation` | string | External-storage location |
| `pageInfo` | object | OData pagination information |
| ↳ `count` | number | Number of items returned in this page |
| ↳ `totalCount` | number | Total matching items |
| ↳ `nextLink` | string | URL returned by Windchill for the next page |
### Windchill Create Document
Create one WT.Document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `name` | string | Yes | Document name |
| `containerOid` | string | Yes | Container OID in which to create the document |
| `number` | string | No | Optional document number when manual numbering is enabled |
| `title` | string | No | Document title |
| `description` | string | No | Document description |
| `folderOid` | string | No | Optional folder OID for the new document |
| `attributes` | json | No | Optional installed Windchill document attributes as a JSON object |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Create Documents
Create several documents in one atomic Windchill request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documents` | array | Yes | Document inputs as a JSON array; each item requires name and containerOid |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Update Document
Update one document's editable attributes
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `attributes` | json | Yes | Editable attributes as a JSON object. Name, Number, and Organization require the Update Common Properties operation and are not supported here. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Update Common Properties
Update a document's Name, Number, and other common properties
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581. The document must not be checked out. |
| `commonProperties` | json | Yes | Common properties as a JSON object, for example \{"Name":"New name","Number":"NEW-001"\}. Enumerated properties take a value/display pair. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Update Documents
Update several documents' editable attributes in one atomic request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documents` | array | Yes | Document updates as a JSON array; each item requires id and the editable attributes to set. Name, Number, and Organization are not supported. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Delete Document
Delete one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
### Windchill Delete Documents
Delete multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
### Windchill Check Out Document
Check out one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `checkOutNote` | string | No | Checkout note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Check Out Documents
Check out multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
| `checkOutNote` | string | No | Checkout note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Check In Document
Check in one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `checkInNote` | string | No | Check-in note |
| `keepCheckedOut` | boolean | No | Keep the document checked out after checking it in |
| `checkOutNote` | string | No | Checkout note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Check In Documents
Check in multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
| `checkInNote` | string | No | Check-in note |
| `keepCheckedOut` | boolean | No | Keep the document checked out after checking it in |
| `checkOutNote` | string | No | Checkout note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Undo Check Out Document
Undo checkout for one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Undo Check Out Documents
Undo checkout for multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Revise Document
Create a new revision of one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `versionId` | string | No | Optional target revision identifier when override-on-revise is enabled |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Revise Documents
Create new revisions of multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Set Lifecycle State
Transition a document to a valid lifecycle state
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `stateValue` | string | Yes | Internal value of the target lifecycle state |
| `stateDisplay` | string | Yes | Display value of the target lifecycle state |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Update Document Security Labels
Update installed security-label attributes for one or more documents
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `securityLabelUpdates` | array | Yes | Array of document IDs and installed security-label values |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Download Primary Content
Download primary content into a canonical UserFile
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `fileName` | string | No | Optional downloaded file name override |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `file` | file | Downloaded content stored as a canonical UserFile |
| `fileName` | string | Downloaded file name |
| `mimeType` | string | Downloaded content MIME type |
### Windchill Upload Primary Content
Upload a primary-content file to a document that has none
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `primaryFile` | file | Yes | Primary content file to upload |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the upload |
| `uploadedFileNames` | array | Names of files accepted by Windchill |
### Windchill Download Attachment
Download a document attachment into a canonical UserFile
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `attachmentOid` | string | Yes | Windchill attachment content OID |
| `fileName` | string | No | Optional downloaded file name override |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `file` | file | Downloaded content stored as a canonical UserFile |
| `fileName` | string | Downloaded file name |
| `mimeType` | string | Downloaded content MIME type |
### Windchill Upload Attachments
Upload one or more files as document attachments
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `attachmentFiles` | file[] | Yes | Attachment files to upload |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the upload |
| `uploadedFileNames` | array | Names of files accepted by Windchill |
@@ -0,0 +1,836 @@
/**
* @vitest-environment node
*/
import { createMockRequest as createTestingRequest, resetEnvMock } from '@sim/testing'
import { NextResponse } from 'next/server'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
const {
MockInvalidBindingError,
MockWindchillProviderError,
mockAssertToolFileAccess,
mockBindDelegation,
mockCreateWindchillSession,
mockDownloadServableFileFromStorage,
mockDownloadWindchillContent,
mockGetSession,
mockResolveWindchillContentUrl,
mockProcessFilesToUserFiles,
mockUploadCopilotFile,
mockUploadExecutionFile,
mockUploadWindchillContent,
mockWindchillMutationRequest,
} = vi.hoisted(() => {
class MockInvalidBindingError extends Error {}
class MockWindchillProviderError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
this.name = 'WindchillProviderError'
}
}
return {
MockInvalidBindingError,
MockWindchillProviderError,
mockAssertToolFileAccess: vi.fn(),
mockBindDelegation: vi.fn(),
mockCreateWindchillSession: vi.fn(),
mockDownloadServableFileFromStorage: vi.fn(),
mockDownloadWindchillContent: vi.fn(),
mockGetSession: vi.fn(),
mockResolveWindchillContentUrl: vi.fn(),
mockProcessFilesToUserFiles: vi.fn(),
mockUploadCopilotFile: vi.fn(),
mockUploadExecutionFile: vi.fn(),
mockUploadWindchillContent: vi.fn(),
mockWindchillMutationRequest: vi.fn(),
}
})
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/auth/internal-delegation', () => ({
bindInternalExecutorDelegation: mockBindDelegation,
InvalidInternalDelegationBindingError: MockInvalidBindingError,
}))
vi.unmock('@/lib/auth/internal')
vi.mock('@/app/api/files/authorization', () => ({
assertToolFileAccess: mockAssertToolFileAccess,
}))
vi.mock('@/lib/uploads/utils/file-utils', () => ({
processFilesToUserFiles: mockProcessFilesToUserFiles,
}))
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
downloadServableFileFromStorage: mockDownloadServableFileFromStorage,
}))
vi.mock('@/lib/uploads/utils/servable-file-response', () => ({
docNotReadyResponse: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/uploads/contexts/copilot', () => ({
uploadCopilotFile: mockUploadCopilotFile,
}))
vi.mock('@/lib/uploads/contexts/execution', () => ({
uploadExecutionFile: mockUploadExecutionFile,
}))
vi.mock('@/tools/windchill/utils.server', () => ({
createWindchillSession: mockCreateWindchillSession,
downloadWindchillContent: mockDownloadWindchillContent,
resolveWindchillContentUrl: mockResolveWindchillContentUrl,
sanitizeWindchillError: (message: string) => message.replace(/https?:\/\/\S+/g, '[redacted URL]'),
uploadWindchillContent: mockUploadWindchillContent,
windchillDocumentUrl: (baseUrl: string, documentOid: string) =>
`${baseUrl}/DocMgmt/Documents('${encodeURIComponent(documentOid)}')`,
windchillMutationRequest: mockWindchillMutationRequest,
WindchillProviderError: MockWindchillProviderError,
}))
import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal'
import { POST } from '@/app/api/tools/windchill/route'
const BASE_BODY = {
baseUrl: 'https://windchill.example.com/Windchill/servlet/odata/v6',
username: 'windchill-user',
password: 'not-a-real-password',
}
const DOCUMENT_OID = 'OR:wt.doc.WTDocument:1'
const SECOND_DOCUMENT_OID = 'OR:wt.doc.WTDocument:2'
let delegationToken = ''
let legacyInternalToken = ''
function createMockRequest(method: string, body: unknown, headers: Record<string, string> = {}) {
return createTestingRequest(method, body, {
authorization: `Bearer ${delegationToken}`,
...headers,
})
}
const MUTATION_CASES = [
{
operation: 'windchill_create_document',
input: { name: 'Specification', containerOid: 'OR:wt.pdmlink.PDMLinkProduct:1' },
url: '/DocMgmt/Documents',
method: 'POST',
},
{
operation: 'windchill_create_documents',
input: {
documents: [{ name: 'Specification', containerOid: 'OR:wt.pdmlink.PDMLinkProduct:1' }],
},
url: '/DocMgmt/CreateDocuments',
method: 'POST',
},
{
operation: 'windchill_update_document',
input: { documentOid: DOCUMENT_OID, attributes: { Title: 'Updated' } },
url: '/DocMgmt/Documents(',
method: 'PATCH',
},
{
operation: 'windchill_update_documents',
input: { documents: [{ id: DOCUMENT_OID, attributes: { Title: 'Updated' } }] },
url: '/DocMgmt/UpdateDocuments',
method: 'POST',
},
{
operation: 'windchill_update_common_properties',
input: { documentOid: DOCUMENT_OID, commonProperties: { Name: 'Renamed' } },
url: '/PTC.DocMgmt.UpdateCommonProperties',
method: 'POST',
},
{
operation: 'windchill_delete_document',
input: { documentOid: DOCUMENT_OID },
url: '/DocMgmt/Documents(',
method: 'DELETE',
},
{
operation: 'windchill_delete_documents',
input: { documentOids: [DOCUMENT_OID, SECOND_DOCUMENT_OID] },
url: '/DocMgmt/DeleteDocuments',
method: 'POST',
},
{
operation: 'windchill_check_out_document',
input: { documentOid: DOCUMENT_OID, checkOutNote: 'Editing' },
url: '/PTC.DocMgmt.CheckOut',
method: 'POST',
},
{
operation: 'windchill_check_out_documents',
input: { documentOids: [DOCUMENT_OID], checkOutNote: 'Editing' },
url: '/DocMgmt/CheckOutDocuments',
method: 'POST',
},
{
operation: 'windchill_check_in_document',
input: { documentOid: DOCUMENT_OID, checkInNote: 'Done', keepCheckedOut: false },
url: '/PTC.DocMgmt.CheckIn',
method: 'POST',
},
{
operation: 'windchill_check_in_documents',
input: { documentOids: [DOCUMENT_OID], checkInNote: 'Done' },
url: '/DocMgmt/CheckInDocuments',
method: 'POST',
},
{
operation: 'windchill_undo_check_out_document',
input: { documentOid: DOCUMENT_OID },
url: '/PTC.DocMgmt.UndoCheckOut',
method: 'POST',
},
{
operation: 'windchill_undo_check_out_documents',
input: { documentOids: [DOCUMENT_OID] },
url: '/DocMgmt/UndoCheckOutDocuments',
method: 'POST',
},
{
operation: 'windchill_revise_document',
input: { documentOid: DOCUMENT_OID, versionId: 'B' },
url: '/PTC.DocMgmt.Revise',
method: 'POST',
},
{
operation: 'windchill_revise_documents',
input: { documentOids: [DOCUMENT_OID] },
url: '/DocMgmt/ReviseDocuments',
method: 'POST',
},
{
operation: 'windchill_set_lifecycle_state',
input: { documentOid: DOCUMENT_OID, stateValue: 'RELEASED', stateDisplay: 'Released' },
url: '/PTC.DocMgmt.SetState',
method: 'POST',
},
{
operation: 'windchill_update_document_security_labels',
input: {
securityLabelUpdates: [{ id: DOCUMENT_OID, labels: { EXPORT_CONTROL: 'L1' } }],
},
url: '/DocMgmt/EditDocumentsSecurityLabels',
method: 'POST',
},
] as const
const MUTATION_PAYLOAD_CASES = [
{
operation: 'windchill_check_out_documents',
input: { documentOids: [DOCUMENT_OID], checkOutNote: 'Editing' },
body: { Documents: [{ ID: DOCUMENT_OID }], CheckOutNote: 'Editing' },
},
{
operation: 'windchill_check_in_document',
input: {
documentOid: DOCUMENT_OID,
checkInNote: 'Done',
keepCheckedOut: false,
checkOutNote: 'Continue editing',
},
body: {
CheckInNote: 'Done',
KeepCheckedOut: false,
CheckOutNote: 'Continue editing',
},
},
{
operation: 'windchill_revise_document',
input: { documentOid: DOCUMENT_OID, versionId: 'B' },
body: { VersionId: 'B' },
},
{
operation: 'windchill_update_common_properties',
input: {
documentOid: DOCUMENT_OID,
commonProperties: { Name: 'Renamed', Number: 'DOC-001' },
},
body: { Updates: { Name: 'Renamed', Number: 'DOC-001' } },
},
{
operation: 'windchill_revise_documents',
input: { documentOids: [DOCUMENT_OID] },
body: { Documents: [{ ID: DOCUMENT_OID }] },
},
{
operation: 'windchill_set_lifecycle_state',
input: { documentOid: DOCUMENT_OID, stateValue: 'RELEASED', stateDisplay: 'Released' },
body: { State: { Display: 'Released', Value: 'RELEASED' } },
},
{
operation: 'windchill_update_document_security_labels',
input: {
securityLabelUpdates: [{ id: DOCUMENT_OID, labels: { EXPORT_CONTROL: 'L1' } }],
},
body: { Documents: [{ EXPORT_CONTROL: 'L1', ID: DOCUMENT_OID }] },
},
] as const
beforeAll(async () => {
delegationToken = await generateInternalDelegationToken({
subjectUserId: 'user-1',
workflowId: '550e8400-e29b-41d4-a716-446655440001',
})
legacyInternalToken = await generateInternalToken()
})
afterAll(resetEnvMock)
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue(null)
mockBindDelegation.mockImplementation(async (delegation, options) => ({
kind: 'delegated',
serviceId: 'executor',
subjectUserId: delegation.subjectUserId,
workspaceId: '550e8400-e29b-41d4-a716-446655440000',
delegationId: delegation.delegationId,
audience: options.audience,
issuedAt: delegation.issuedAt,
expiresAt: delegation.expiresAt,
delegationContext: {
kind: 'workflow_execution',
workflowId: delegation.workflowId,
executionId: delegation.executionId,
},
}))
mockCreateWindchillSession.mockResolvedValue({
nonceHeader: 'CSRF_NONCE',
nonceValue: 'nonce-value',
cookie: 'JSESSIONID=session-value',
})
mockWindchillMutationRequest.mockResolvedValue({ value: [{ ID: DOCUMENT_OID }] })
mockAssertToolFileAccess.mockResolvedValue(null)
mockProcessFilesToUserFiles.mockReturnValue([
{
key: 'workspace/workspace-1/specification.pdf',
name: 'specification.pdf',
size: 3,
type: 'application/pdf',
},
])
mockDownloadServableFileFromStorage.mockResolvedValue({
buffer: Buffer.from('pdf'),
contentType: 'application/pdf',
})
mockUploadWindchillContent.mockResolvedValue(['specification.pdf'])
mockResolveWindchillContentUrl.mockImplementation(
async ({ contentPath }: { contentPath: string }) =>
`https://windchill.example.com/Windchill/servlet/WindchillGW/download?from=${encodeURIComponent(contentPath)}`
)
mockDownloadWindchillContent.mockResolvedValue({
buffer: Buffer.from('pdf'),
contentType: 'application/pdf',
contentDisposition: 'attachment; filename="specification.pdf"',
})
mockUploadCopilotFile.mockResolvedValue({
id: 'file-1',
name: 'specification.pdf',
url: '/api/files/serve?key=copilot/specification.pdf',
size: 3,
type: 'application/pdf',
key: 'copilot/specification.pdf',
})
})
describe('POST /api/tools/windchill', () => {
it('authenticates before parsing the request body', async () => {
const response = await POST(createTestingRequest('POST', { operation: 'not-valid' }))
expect(response.status).toBe(401)
expect(await response.json()).toEqual({ success: false, error: 'Unauthorized' })
expect(mockCreateWindchillSession).not.toHaveBeenCalled()
})
it('binds executor identity and scope through the canonical delegation path', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_update_document',
documentOid: DOCUMENT_OID,
attributes: { Title: 'Updated' },
})
)
expect(response.status).toBe(200)
expect(mockBindDelegation).toHaveBeenCalledWith(expect.any(Object), {
audience: 'sim:windchill',
resourceScope: undefined,
})
})
it('rejects browser sessions and legacy internal tokens', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
const sessionResponse = await POST(createTestingRequest('POST', BASE_BODY))
const legacyResponse = await POST(
createTestingRequest('POST', BASE_BODY, {
authorization: `Bearer ${legacyInternalToken}`,
})
)
expect(sessionResponse.status).toBe(401)
expect(legacyResponse.status).toBe(401)
expect(mockBindDelegation).not.toHaveBeenCalled()
})
it('rejects malformed operation inputs at the shared contract boundary', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_update_document',
documentOid: DOCUMENT_OID,
attributes: {},
})
)
expect(response.status).toBe(400)
expect(mockCreateWindchillSession).not.toHaveBeenCalled()
})
it('rejects an invalid service root before reading a protected upload', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
baseUrl: `${BASE_BODY.baseUrl}?token=secret`,
operation: 'windchill_upload_primary_content',
documentOid: DOCUMENT_OID,
primaryFile: {
key: 'workspace/workspace-1/specification.pdf',
name: 'specification.pdf',
size: 3,
type: 'application/pdf',
},
})
)
expect(response.status).toBe(400)
expect(mockAssertToolFileAccess).not.toHaveBeenCalled()
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
})
it.each(MUTATION_CASES)(
'dispatches $operation through one CSRF-protected transaction',
async ({ operation, input, url, method }) => {
const response = await POST(createMockRequest('POST', { ...BASE_BODY, operation, ...input }))
expect(response.status).toBe(200)
expect((await response.json()).success).toBe(true)
expect(mockCreateWindchillSession).toHaveBeenCalledTimes(1)
expect(mockWindchillMutationRequest).toHaveBeenCalledTimes(1)
expect(mockWindchillMutationRequest.mock.calls[0][0].url).toContain(url)
expect(mockWindchillMutationRequest.mock.calls[0][0].method).toBe(method)
}
)
it.each(MUTATION_PAYLOAD_CASES)(
'encodes the exact $operation action payload',
async ({ operation, input, body }) => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation,
...input,
})
)
expect(response.status).toBe(200)
expect(mockWindchillMutationRequest.mock.calls[0][0].body).toEqual(body)
}
)
it('maps create bindings and custom attributes without allowing them to replace bindings', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_create_document',
name: 'Specification',
containerOid: 'OR:wt.pdmlink.PDMLinkProduct:1',
folderOid: 'OR:wt.folder.SubFolder:2',
attributes: { CustomString: 'value' },
})
)
expect(response.status).toBe(200)
expect((await response.json()).output.affectedIds).toEqual([DOCUMENT_OID])
expect(mockWindchillMutationRequest.mock.calls[0][0].body).toEqual({
CustomString: 'value',
Name: 'Specification',
'Context@odata.bind': "Containers('OR%3Awt.pdmlink.PDMLinkProduct%3A1')",
'Folder@odata.bind': "Folders('OR%3Awt.folder.SubFolder%3A2')",
})
})
it('returns operation-specific single, bulk, and delete mutation shapes', async () => {
const singleResponse = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_update_document',
documentOid: DOCUMENT_OID,
attributes: { Title: 'Updated' },
})
)
const singleOutput = (await singleResponse.json()).output
expect(singleOutput.document).toMatchObject({ id: DOCUMENT_OID })
expect(singleOutput).not.toHaveProperty('documents')
const bulkResponse = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_update_documents',
documents: [{ id: DOCUMENT_OID, attributes: { Title: 'Updated' } }],
})
)
const bulkOutput = (await bulkResponse.json()).output
expect(bulkOutput.documents).toEqual([expect.objectContaining({ id: DOCUMENT_OID })])
expect(bulkOutput).not.toHaveProperty('document')
const deleteResponse = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_delete_document',
documentOid: DOCUMENT_OID,
})
)
const deleteOutput = (await deleteResponse.json()).output
expect(deleteOutput.affectedIds).toEqual([DOCUMENT_OID])
expect(deleteOutput).not.toHaveProperty('document')
expect(deleteOutput).not.toHaveProperty('documents')
})
it('authorizes and reads a UserFile before starting the upload transaction', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_primary_content',
documentOid: DOCUMENT_OID,
primaryFile: {
key: 'workspace/workspace-1/specification.pdf',
name: 'specification.pdf',
size: 3,
type: 'application/pdf',
},
})
)
expect(response.status).toBe(200)
expect(mockAssertToolFileAccess).toHaveBeenCalledWith(
'workspace/workspace-1/specification.pdf',
'user-1',
expect.any(String),
expect.anything()
)
expect(mockDownloadServableFileFromStorage).toHaveBeenCalledTimes(1)
expect(mockUploadWindchillContent).toHaveBeenCalledWith(
expect.objectContaining({
documentOid: DOCUMENT_OID,
primaryContent: true,
files: [
expect.objectContaining({
name: 'specification.pdf',
mimeType: 'application/pdf',
size: 3,
}),
],
})
)
})
it('uploads multiple authorized files as attachments', async () => {
mockProcessFilesToUserFiles.mockReturnValueOnce([
{
key: 'workspace/workspace-1/one.txt',
name: 'one.txt',
size: 3,
type: 'text/plain',
},
{
key: 'workspace/workspace-1/two.txt',
name: 'two.txt',
size: 3,
type: 'text/plain',
},
])
mockDownloadServableFileFromStorage.mockResolvedValue({
buffer: Buffer.from('txt'),
contentType: 'text/plain',
})
mockUploadWindchillContent.mockResolvedValueOnce(['one.txt', 'two.txt'])
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_attachments',
documentOid: DOCUMENT_OID,
attachmentFiles: [
{ key: 'workspace/workspace-1/one.txt', name: 'one.txt', size: 3 },
{ key: 'workspace/workspace-1/two.txt', name: 'two.txt', size: 3 },
],
})
)
expect(response.status).toBe(200)
expect(mockAssertToolFileAccess).toHaveBeenCalledTimes(2)
expect(mockDownloadServableFileFromStorage).toHaveBeenCalledTimes(2)
expect(mockUploadWindchillContent).toHaveBeenCalledWith(
expect.objectContaining({ primaryContent: false })
)
expect(mockDownloadServableFileFromStorage.mock.calls[0][3]).toEqual({
maxBytes: MAX_FILE_SIZE,
})
expect(mockDownloadServableFileFromStorage.mock.calls[1][3]).toEqual({
maxBytes: MAX_FILE_SIZE - 3,
})
})
it('rejects attachment counts above the contract limit before reading storage', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_attachments',
documentOid: DOCUMENT_OID,
attachmentFiles: Array.from({ length: 11 }, (_, index) => ({
key: `workspace/workspace-1/${index}.txt`,
name: `${index}.txt`,
size: 1,
})),
})
)
expect(response.status).toBe(400)
expect(mockAssertToolFileAccess).not.toHaveBeenCalled()
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
})
it('rejects declared aggregate upload size before reading storage', async () => {
mockProcessFilesToUserFiles.mockReturnValueOnce([
{
key: 'workspace/workspace-1/oversized.bin',
name: 'oversized.bin',
size: MAX_FILE_SIZE + 1,
type: 'application/octet-stream',
},
])
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_primary_content',
documentOid: DOCUMENT_OID,
primaryFile: {
key: 'workspace/workspace-1/oversized.bin',
name: 'oversized.bin',
size: MAX_FILE_SIZE + 1,
},
})
)
expect(response.status).toBe(413)
expect(mockAssertToolFileAccess).not.toHaveBeenCalled()
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
})
it('stops an under-reported upload at the remaining aggregate byte budget', async () => {
mockProcessFilesToUserFiles.mockReturnValueOnce([
{ key: 'workspace/workspace-1/one.txt', name: 'one.txt', size: 1, type: 'text/plain' },
{ key: 'workspace/workspace-1/two.txt', name: 'two.txt', size: 1, type: 'text/plain' },
{
key: 'workspace/workspace-1/three.txt',
name: 'three.txt',
size: 1,
type: 'text/plain',
},
])
mockDownloadServableFileFromStorage
.mockResolvedValueOnce({ buffer: Buffer.from('one'), contentType: 'text/plain' })
.mockRejectedValueOnce(
new PayloadSizeLimitError({
label: 'Uploaded file',
maxBytes: MAX_FILE_SIZE - 3,
observedBytes: MAX_FILE_SIZE - 2,
})
)
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_attachments',
documentOid: DOCUMENT_OID,
attachmentFiles: [
{ key: 'workspace/workspace-1/one.txt', name: 'one.txt', size: 1 },
{ key: 'workspace/workspace-1/two.txt', name: 'two.txt', size: 1 },
{ key: 'workspace/workspace-1/three.txt', name: 'three.txt', size: 1 },
],
})
)
expect(response.status).toBe(413)
expect(mockDownloadServableFileFromStorage).toHaveBeenCalledTimes(2)
expect(mockDownloadServableFileFromStorage.mock.calls[1][3]).toEqual({
maxBytes: MAX_FILE_SIZE - 3,
})
expect(mockUploadWindchillContent).not.toHaveBeenCalled()
})
it('stops before storage or Windchill when file ownership is denied', async () => {
mockAssertToolFileAccess.mockResolvedValueOnce(
NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
)
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_primary_content',
documentOid: DOCUMENT_OID,
primaryFile: {
key: 'workspace/other/specification.pdf',
name: 'specification.pdf',
size: 3,
type: 'application/pdf',
},
})
)
expect(response.status).toBe(404)
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
expect(mockUploadWindchillContent).not.toHaveBeenCalled()
})
it('stores downloads as a UserFile instead of returning inline bytes', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_download_primary_content',
documentOid: DOCUMENT_OID,
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(mockResolveWindchillContentUrl).toHaveBeenCalledWith(
expect.objectContaining({
contentPath: expect.stringContaining('/PrimaryContent'),
})
)
expect(mockResolveWindchillContentUrl.mock.calls[0][0].contentPath).not.toContain('$value')
expect(mockDownloadWindchillContent).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('/WindchillGW/download'),
})
)
expect(mockUploadCopilotFile).toHaveBeenCalledWith(
expect.objectContaining({
buffer: Buffer.from('pdf'),
fileName: 'specification.pdf',
contentType: 'application/pdf',
userId: 'user-1',
})
)
expect(data.output.file).toMatchObject({ key: 'copilot/specification.pdf' })
expect(data.output.content).toBeUndefined()
})
it('downloads an attachment through its document-scoped content path', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_download_attachment',
documentOid: DOCUMENT_OID,
attachmentOid: 'OR:wt.content.ApplicationData:2',
})
)
expect(response.status).toBe(200)
expect(mockResolveWindchillContentUrl).toHaveBeenCalledWith(
expect.objectContaining({
contentPath: expect.stringContaining("/Attachments('OR%3Awt.content.ApplicationData%3A2')"),
})
)
expect(mockDownloadWindchillContent).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('/WindchillGW/download'),
})
)
})
it('uses execution storage derived from the bound delegation principal', async () => {
mockBindDelegation.mockResolvedValueOnce({
kind: 'delegated',
serviceId: 'executor',
subjectUserId: 'user-1',
workspaceId: '550e8400-e29b-41d4-a716-446655440000',
delegationId: 'delegation-1',
audience: 'sim:windchill',
issuedAt: new Date('2026-01-01T00:00:00.000Z'),
expiresAt: new Date('2027-01-01T00:00:00.000Z'),
delegationContext: {
kind: 'workflow_execution',
workflowId: '550e8400-e29b-41d4-a716-446655440001',
executionId: 'execution-1',
},
})
mockUploadExecutionFile.mockResolvedValueOnce({
id: 'file-2',
name: 'specification.pdf',
url: '/api/files/serve?key=execution/specification.pdf',
size: 3,
type: 'application/pdf',
key: 'execution/specification.pdf',
})
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_download_primary_content',
documentOid: DOCUMENT_OID,
workspaceId: 'forged-workspace',
workflowId: 'forged-workflow',
executionId: 'forged-execution',
})
)
expect(response.status).toBe(200)
expect(mockUploadExecutionFile).toHaveBeenCalledWith(
{
workspaceId: '550e8400-e29b-41d4-a716-446655440000',
workflowId: '550e8400-e29b-41d4-a716-446655440001',
executionId: 'execution-1',
},
Buffer.from('pdf'),
'specification.pdf',
'application/pdf',
'user-1'
)
expect(mockUploadCopilotFile).not.toHaveBeenCalled()
})
it('preserves sanitized provider status codes', async () => {
mockWindchillMutationRequest.mockRejectedValueOnce(
new MockWindchillProviderError('Windchill rejected the transition', 409)
)
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_set_lifecycle_state',
documentOid: DOCUMENT_OID,
stateValue: 'RELEASED',
stateDisplay: 'Released',
})
)
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
success: false,
error: 'Windchill rejected the transition',
})
})
})
+641
View File
@@ -0,0 +1,641 @@
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import type {
WindchillOperationBody,
WindchillOperationResponse,
} from '@/lib/api/contracts/tools/windchill'
import { windchillOperationContract } from '@/lib/api/contracts/tools/windchill'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import {
createInternalSessionOrExecutorAuth,
InternalUnauthenticatedError,
} from '@/lib/api/server/routes'
import { generateRequestId } from '@/lib/core/utils/request'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
import type { RawFileInput } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { sanitizeFileName } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
import {
encodeWindchillOid,
normalizeServiceRoot,
normalizeWindchillDocument,
normalizeWindchillDocuments,
sanitizeWindchillError,
} from '@/tools/windchill/utils'
import {
createWindchillSession,
downloadWindchillContent,
resolveWindchillContentUrl,
uploadWindchillContent,
WindchillProviderError,
type WindchillUploadFile,
windchillDocumentUrl,
windchillMutationRequest,
} from '@/tools/windchill/utils.server'
export const dynamic = 'force-dynamic'
export const maxDuration = 900
const logger = createLogger('WindchillAPI')
const windchillSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({
audience: 'sim:windchill',
})
async function authenticateWindchillExecutor(
request: NextRequest
): Promise<WorkflowExecutionDelegatedPrincipal> {
const principal = await windchillSessionOrExecutorAuth.authenticate(request, {})
if (
principal.kind !== 'delegated' ||
principal.serviceId !== 'executor' ||
!('delegationContext' in principal)
) {
throw new InternalUnauthenticatedError('Authentication required')
}
return principal
}
type WindchillRouteOutput = Extract<WindchillOperationResponse, { success: true }>['output']
type MutationOperation = Exclude<
WindchillRouteOutput['operation'],
| 'windchill_download_attachment'
| 'windchill_download_primary_content'
| 'windchill_upload_attachments'
| 'windchill_upload_primary_content'
>
const BULK_RESULT_OPERATIONS = [
'windchill_create_documents',
'windchill_update_documents',
'windchill_check_out_documents',
'windchill_check_in_documents',
'windchill_undo_check_out_documents',
'windchill_revise_documents',
'windchill_update_document_security_labels',
] as const satisfies readonly MutationOperation[]
const DELETE_OPERATIONS = [
'windchill_delete_document',
'windchill_delete_documents',
] as const satisfies readonly MutationOperation[]
type BulkResultOperation = (typeof BULK_RESULT_OPERATIONS)[number]
type DeleteOperation = (typeof DELETE_OPERATIONS)[number]
function isBulkResultOperation(operation: MutationOperation): operation is BulkResultOperation {
return BULK_RESULT_OPERATIONS.includes(operation as BulkResultOperation)
}
function isDeleteOperation(operation: MutationOperation): operation is DeleteOperation {
return DELETE_OPERATIONS.includes(operation as DeleteOperation)
}
function successResponse(output: WindchillRouteOutput) {
const body = { success: true, output } satisfies WindchillOperationResponse
return NextResponse.json(body)
}
function failureResponse(error: string, status: number) {
const body = {
success: false,
error: sanitizeWindchillError(error),
} satisfies WindchillOperationResponse
return NextResponse.json(body, { status })
}
function documentsById(documentOids: string[]) {
return documentOids.map((ID) => ({ ID }))
}
/** Keeps the media type and drops any `; charset=...` parameters Windchill cannot use. */
function safeMimeType(value: string | undefined): string {
const mediaType = value?.split(';', 1)[0]?.trim()
if (mediaType && /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(mediaType)) {
return mediaType
}
return 'application/octet-stream'
}
function mutationOutput(
operation: MutationOperation,
data: unknown,
fallbackIds: string[]
): WindchillRouteOutput {
const documents = normalizeWindchillDocuments(data)
const document = documents[0] ?? normalizeWindchillDocument(data)
const collectionIds = documents
.map((item) => item.id)
.filter((id): id is string => typeof id === 'string')
const returnedIds =
document?.id && !collectionIds.includes(document.id)
? [document.id, ...collectionIds]
: collectionIds
const affectedIds = returnedIds.length > 0 ? returnedIds : fallbackIds
if (isDeleteOperation(operation)) return { operation, affectedIds: fallbackIds }
if (isBulkResultOperation(operation)) {
return {
operation,
affectedIds,
...(documents.length > 0 ? { documents } : {}),
}
}
return {
operation,
affectedIds,
...(document ? { document } : {}),
}
}
async function executeMutation(
body: Exclude<
WindchillOperationBody,
| { operation: 'windchill_download_primary_content' }
| { operation: 'windchill_upload_primary_content' }
| { operation: 'windchill_download_attachment' }
| { operation: 'windchill_upload_attachments' }
>,
signal: AbortSignal
): Promise<WindchillRouteOutput> {
const session = await createWindchillSession(body, signal)
const root = normalizeServiceRoot(body.baseUrl)
switch (body.operation) {
case 'windchill_create_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/Documents`,
method: 'POST',
body: {
...(body.attributes ?? {}),
Name: body.name,
...(body.number ? { Number: body.number } : {}),
...(body.title ? { Title: body.title } : {}),
...(body.description ? { Description: body.description } : {}),
'Context@odata.bind': `Containers('${encodeWindchillOid(body.containerOid)}')`,
...(body.folderOid
? { 'Folder@odata.bind': `Folders('${encodeWindchillOid(body.folderOid)}')` }
: {}),
},
signal,
})
return mutationOutput(body.operation, data, [])
}
case 'windchill_create_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/CreateDocuments`,
method: 'POST',
body: {
Documents: body.documents.map((document) => ({
...(document.attributes ?? {}),
Name: document.name,
...(document.number ? { Number: document.number } : {}),
...(document.title ? { Title: document.title } : {}),
...(document.description ? { Description: document.description } : {}),
'Context@odata.bind': `Containers('${encodeWindchillOid(document.containerOid)}')`,
...(document.folderOid
? { 'Folder@odata.bind': `Folders('${encodeWindchillOid(document.folderOid)}')` }
: {}),
})),
},
signal,
})
return mutationOutput(body.operation, data, [])
}
case 'windchill_update_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: windchillDocumentUrl(root, body.documentOid),
method: 'PATCH',
body: body.attributes,
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_update_common_properties': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.UpdateCommonProperties`,
method: 'POST',
body: { Updates: body.commonProperties },
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_update_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/UpdateDocuments`,
method: 'POST',
body: {
Documents: body.documents.map((document) => ({
...document.attributes,
ID: document.id,
})),
},
signal,
})
return mutationOutput(
body.operation,
data,
body.documents.map((document) => document.id)
)
}
case 'windchill_delete_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: windchillDocumentUrl(root, body.documentOid),
method: 'DELETE',
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_delete_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/DeleteDocuments`,
method: 'POST',
body: { Documents: documentsById(body.documentOids) },
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_check_out_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.CheckOut`,
method: 'POST',
body: { ...(body.checkOutNote ? { CheckOutNote: body.checkOutNote } : {}) },
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_check_out_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/CheckOutDocuments`,
method: 'POST',
body: {
Documents: documentsById(body.documentOids),
...(body.checkOutNote ? { CheckOutNote: body.checkOutNote } : {}),
},
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_check_in_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.CheckIn`,
method: 'POST',
body: {
...(body.checkInNote ? { CheckInNote: body.checkInNote } : {}),
...(body.keepCheckedOut !== undefined ? { KeepCheckedOut: body.keepCheckedOut } : {}),
...(body.checkOutNote ? { CheckOutNote: body.checkOutNote } : {}),
},
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_check_in_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/CheckInDocuments`,
method: 'POST',
body: {
Documents: documentsById(body.documentOids),
...(body.checkInNote ? { CheckInNote: body.checkInNote } : {}),
...(body.keepCheckedOut !== undefined ? { KeepCheckedOut: body.keepCheckedOut } : {}),
...(body.checkOutNote ? { CheckOutNote: body.checkOutNote } : {}),
},
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_undo_check_out_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.UndoCheckOut`,
method: 'POST',
body: {},
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_undo_check_out_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/UndoCheckOutDocuments`,
method: 'POST',
body: { Documents: documentsById(body.documentOids) },
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_revise_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.Revise`,
method: 'POST',
body: { ...(body.versionId ? { VersionId: body.versionId } : {}) },
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_revise_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/ReviseDocuments`,
method: 'POST',
body: {
Documents: documentsById(body.documentOids),
},
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_set_lifecycle_state': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.SetState`,
method: 'POST',
body: { State: { Display: body.stateDisplay, Value: body.stateValue } },
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_update_document_security_labels': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/EditDocumentsSecurityLabels`,
method: 'POST',
body: {
Documents: body.securityLabelUpdates.map((update) => ({
...update.labels,
ID: update.id,
})),
},
signal,
})
return mutationOutput(
body.operation,
data,
body.securityLabelUpdates.map((update) => update.id)
)
}
}
}
async function loadUploadFiles(
inputs: RawFileInput[],
userId: string,
requestId: string
): Promise<WindchillUploadFile[] | NextResponse> {
let userFiles: UserFile[]
try {
userFiles = processFilesToUserFiles(inputs, requestId, logger)
} catch (error) {
return failureResponse(getErrorMessage(error, 'Invalid file input'), 400)
}
if (userFiles.length !== inputs.length) return failureResponse('Invalid file input', 400)
const declaredTotal = userFiles.reduce((total, file) => total + file.size, 0)
if (declaredTotal > MAX_FILE_SIZE) {
return failureResponse('Combined Windchill upload exceeds the maximum file size', 413)
}
const files: WindchillUploadFile[] = []
let actualTotal = 0
for (const userFile of userFiles) {
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
try {
const servable = await downloadServableFileFromStorage(userFile, requestId, logger, {
maxBytes: MAX_FILE_SIZE - actualTotal,
})
actualTotal += servable.buffer.length
if (actualTotal > MAX_FILE_SIZE) {
return failureResponse('Combined Windchill upload exceeds the maximum file size', 413)
}
files.push({
name: sanitizeFileName(userFile.name),
mimeType: safeMimeType(servable.contentType || userFile.type),
size: servable.buffer.length,
buffer: servable.buffer,
})
} catch (error) {
const notReady = docNotReadyResponse(error)
if (notReady) return notReady
return failureResponse(
getErrorMessage(error, 'Failed to read uploaded file'),
isPayloadSizeLimitError(error) ? 413 : 400
)
}
}
return files
}
function contentDispositionFileName(value: string | null): string | null {
if (!value) return null
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (encoded) {
try {
return decodeURIComponent(encoded)
} catch {
return encoded
}
}
return (
value.match(/filename\s*=\s*"([^"]+)"/i)?.[1] ??
value.match(/filename\s*=\s*([^;]+)/i)?.[1]?.trim() ??
null
)
}
async function storeDownloadedFile({
principal,
buffer,
fileName,
contentType,
}: {
principal: WorkflowExecutionDelegatedPrincipal
buffer: Buffer
fileName: string
contentType: string
}): Promise<UserFile> {
const { workflowId, executionId } = principal.delegationContext
if (executionId) {
return uploadExecutionFile(
{
workspaceId: principal.workspaceId,
workflowId,
executionId,
},
buffer,
fileName,
contentType,
principal.subjectUserId
)
}
return uploadCopilotFile({
buffer,
fileName,
contentType,
userId: principal.subjectUserId,
})
}
async function executeDownload(
body: Extract<
WindchillOperationBody,
| { operation: 'windchill_download_primary_content' }
| { operation: 'windchill_download_attachment' }
>,
principal: WorkflowExecutionDelegatedPrincipal,
signal: AbortSignal
): Promise<WindchillRouteOutput> {
const documentUrl = windchillDocumentUrl(body.baseUrl, body.documentOid)
const contentPath =
body.operation === 'windchill_download_primary_content'
? `${documentUrl}/PrimaryContent`
: `${documentUrl}/Attachments('${encodeWindchillOid(body.attachmentOid)}')`
const contentUrl = await resolveWindchillContentUrl({
params: body,
contentPath,
signal,
})
const downloaded = await downloadWindchillContent({
params: body,
url: contentUrl,
maxBytes: MAX_FILE_SIZE,
signal,
})
const fallback =
body.operation === 'windchill_download_primary_content'
? 'windchill-primary-content.bin'
: 'windchill-attachment.bin'
const fileName = sanitizeFileName(
body.fileName || contentDispositionFileName(downloaded.contentDisposition) || fallback
)
const mimeType = safeMimeType(downloaded.contentType)
const file = await storeDownloadedFile({
principal,
buffer: downloaded.buffer,
fileName,
contentType: mimeType,
})
return {
operation: body.operation,
file: { ...file },
fileName,
mimeType,
}
}
export const POST = withRouteHandler(
async (request: NextRequest) => {
const requestId = generateRequestId()
let principal: WorkflowExecutionDelegatedPrincipal
try {
principal = await authenticateWindchillExecutor(request)
} catch (error) {
if (error instanceof InternalUnauthenticatedError) {
return failureResponse(error.message, 401)
}
throw error
}
const parsed = await parseRequest(
windchillOperationContract,
request,
{},
{
validationErrorResponse: (error) =>
failureResponse(getValidationErrorMessage(error, 'Invalid Windchill request'), 400),
invalidJsonResponse: () =>
failureResponse('Windchill request body must be valid JSON', 400),
payloadTooLargeResponse: () => failureResponse('Windchill request body is too large', 413),
}
)
if (!parsed.success) return parsed.response
const body = parsed.data.body
try {
if (
body.operation === 'windchill_download_primary_content' ||
body.operation === 'windchill_download_attachment'
) {
return successResponse(await executeDownload(body, principal, request.signal))
}
if (
body.operation === 'windchill_upload_primary_content' ||
body.operation === 'windchill_upload_attachments'
) {
const inputs =
body.operation === 'windchill_upload_primary_content'
? [body.primaryFile]
: body.attachmentFiles
const files = await loadUploadFiles(inputs, principal.subjectUserId, requestId)
if (files instanceof NextResponse) return files
const uploadedFileNames = await uploadWindchillContent({
params: body,
documentOid: body.documentOid,
files,
primaryContent: body.operation === 'windchill_upload_primary_content',
signal: request.signal,
})
return successResponse({
operation: body.operation,
affectedIds: [body.documentOid],
uploadedFileNames,
})
}
return successResponse(await executeMutation(body, request.signal))
} catch (error) {
logger.error('Windchill operation failed', {
operation: body.operation,
error: sanitizeWindchillError(getErrorMessage(error, 'Windchill operation failed')),
})
if (error instanceof WindchillProviderError) {
const status = error.status >= 400 && error.status <= 599 ? error.status : 502
return failureResponse(error.message, status)
}
return failureResponse(
getErrorMessage(error, 'Windchill operation failed'),
isPayloadSizeLimitError(error) ? 413 : 500
)
}
},
{
unhandledErrorResponse: () => failureResponse('Windchill operation failed', 500),
}
)
File diff suppressed because it is too large Load Diff
+3
View File
@@ -333,6 +333,7 @@ import { WebflowBlock, WebflowBlockMeta } from '@/blocks/blocks/webflow'
import { WebhookRequestBlock } from '@/blocks/blocks/webhook_request'
import { WhatsAppBlock, WhatsAppBlockMeta } from '@/blocks/blocks/whatsapp'
import { WikipediaBlock, WikipediaBlockMeta } from '@/blocks/blocks/wikipedia'
import { WindchillBlock, WindchillBlockMeta } from '@/blocks/blocks/windchill'
import { WizaBlock, WizaBlockMeta } from '@/blocks/blocks/wiza'
import { WordPressBlock, WordPressBlockMeta } from '@/blocks/blocks/wordpress'
import { WorkdayBlock, WorkdayBlockMeta } from '@/blocks/blocks/workday'
@@ -659,6 +660,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
webhook_request: WebhookRequestBlock,
whatsapp: WhatsAppBlock,
wikipedia: WikipediaBlock,
windchill: WindchillBlock,
wiza: WizaBlock,
wordpress: WordPressBlock,
workday: WorkdayBlock,
@@ -931,6 +933,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
webflow: WebflowBlockMeta,
whatsapp: WhatsAppBlockMeta,
wikipedia: WikipediaBlockMeta,
windchill: WindchillBlockMeta,
wiza: WizaBlockMeta,
wordpress: WordPressBlockMeta,
workday: WorkdayBlockMeta,
+26
View File
@@ -2494,6 +2494,32 @@ export function DocumentIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function WindchillIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='79 57 88 88'
role='img'
aria-label='Windchill icon'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<rect x='79' y='57' width='88' height='88' fill='#FFFFFF' />
<g fill='#3D4647'>
<polygon points='137.2,86.6 137.2,68.5 122.8,60.2 107.1,69.2' />
<polygon points='142.7,106.4 158.4,97.3 158.4,80.7 142.7,71.6' />
<polygon points='108.5,115.8 108.5,133.9 122.8,142.2 138.5,133.2' />
<polygon points='128.2,121 143.9,130.1 158.4,121.7 158.4,103.6' />
<polygon points='117.4,81.4 101.7,72.4 87.3,80.7 87.3,98.8' />
<polygon points='103,96 87.3,105.1 87.3,121.7 103,130.8' />
</g>
<polygon
fill='#40AA1D'
points='137.2,109.5 122.8,117.8 108.4,109.5 108.4,92.9 122.8,84.6 137.2,92.9'
/>
</svg>
)
}
export function MintlifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 19 19' fill='none'>
@@ -0,0 +1,430 @@
import { z } from 'zod'
import { userFileSchema } from '@/lib/api/contracts/primitives'
import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { RawFileInputArraySchema, RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
const MAX_CREDENTIAL_LENGTH = 8192
const MAX_OID_LENGTH = 512
const MAX_TEXT_LENGTH = 4096
const MAX_ATTRIBUTES = 100
const MAX_ATTRIBUTE_DEPTH = 8
const MAX_ATTRIBUTE_NODES = 1000
const MAX_BULK_DOCUMENTS = 100
const MAX_ATTACHMENT_FILES = 10
function parseUrl(value: string): URL | null {
try {
return new URL(value)
} catch {
return null
}
}
const credentialSchema = z
.string({ error: 'Credential is required' })
.min(1, 'Credential is required')
.max(MAX_CREDENTIAL_LENGTH, 'Credential is too long')
const baseUrlSchema = z
.url('Windchill base URL must be a valid URL')
.max(2048, 'Windchill base URL is too long')
.refine((value) => value.startsWith('https://'), 'Windchill base URL must use HTTPS')
.refine((value) => {
const parsed = parseUrl(value)
return Boolean(parsed && !parsed.username && !parsed.password && !parsed.search && !parsed.hash)
}, 'Windchill base URL must not include credentials, query parameters, or a hash')
.refine((value) => {
const parsed = parseUrl(value)
return Boolean(parsed && /\/servlet\/odata\/v\d+\/?$/i.test(parsed.pathname))
}, 'Windchill base URL must end with a versioned /servlet/odata/vN path')
const oidSchema = z
.string({ error: 'Windchill OID is required' })
.trim()
.min(1, 'Windchill OID is required')
.max(MAX_OID_LENGTH, 'Windchill OID is too long')
.regex(/^[A-Za-z0-9_.:-]+$/, 'Windchill OID contains unsupported characters')
const optionalTextSchema = z.string().max(MAX_TEXT_LENGTH, 'Value is too long').optional()
const requiredTextSchema = z
.string({ error: 'Value is required' })
.trim()
.min(1, 'Value is required')
.max(MAX_TEXT_LENGTH, 'Value is too long')
type WindchillAttributeValue =
| string
| number
| boolean
| null
| WindchillAttributeValue[]
| { [key: string]: WindchillAttributeValue }
function isBoundedAttributeValue(value: unknown): value is WindchillAttributeValue {
const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]
let nodes = 0
while (pending.length > 0) {
const current = pending.pop()
if (!current || current.depth > MAX_ATTRIBUTE_DEPTH || ++nodes > MAX_ATTRIBUTE_NODES) {
return false
}
if (
current.value === null ||
typeof current.value === 'boolean' ||
(typeof current.value === 'number' && Number.isFinite(current.value))
) {
continue
}
if (typeof current.value === 'string') {
if (current.value.length > MAX_TEXT_LENGTH) return false
continue
}
if (Array.isArray(current.value)) {
if (current.value.length > MAX_ATTRIBUTES) return false
for (const item of current.value) pending.push({ value: item, depth: current.depth + 1 })
continue
}
if (typeof current.value !== 'object') return false
const entries = Object.entries(current.value)
if (entries.length > MAX_ATTRIBUTES) return false
for (const [key, nested] of entries) {
if (
key.length === 0 ||
key.length > 256 ||
['__proto__', 'constructor', 'prototype'].includes(key)
) {
return false
}
pending.push({ value: nested, depth: current.depth + 1 })
}
}
return true
}
const attributeValueSchema = z.custom<WindchillAttributeValue>(isBoundedAttributeValue, {
message: `Attribute values must be bounded JSON with at most ${MAX_ATTRIBUTE_DEPTH} nested levels`,
})
const attributeNameSchema = z
.string()
.min(1)
.max(256)
.regex(/^[A-Za-z_][A-Za-z0-9_.]*$/, 'Attribute name contains unsupported characters')
.refine(
(value) => !['__proto__', 'constructor', 'prototype'].includes(value),
'Attribute name is reserved'
)
const attributesSchema = z
.record(attributeNameSchema, attributeValueSchema)
.refine((attributes) => Object.keys(attributes).length <= MAX_ATTRIBUTES, {
message: `Attributes cannot contain more than ${MAX_ATTRIBUTES} fields`,
})
const COMMON_DOCUMENT_PROPERTIES = new Set(['Name', 'Number', 'Organization'])
const patchAttributesSchema = attributesSchema
.refine((attributes) => Object.keys(attributes).length > 0, {
message: 'At least one document attribute is required',
})
.refine(
(attributes) => !Object.keys(attributes).some((key) => COMMON_DOCUMENT_PROPERTIES.has(key)),
'Name, Number, and Organization must be changed with the Update Common Properties operation and are not supported here'
)
const commonPropertiesSchema = attributesSchema.refine(
(properties) => Object.keys(properties).length > 0,
{ message: 'At least one common property is required' }
)
const credentialsSchema = z.object({
baseUrl: baseUrlSchema,
username: credentialSchema,
password: credentialSchema,
})
const createDocumentInputSchema = z.object({
name: requiredTextSchema,
containerOid: oidSchema,
number: optionalTextSchema,
title: optionalTextSchema,
description: optionalTextSchema,
folderOid: oidSchema.optional(),
attributes: attributesSchema.optional(),
})
const updateDocumentInputSchema = z.object({
id: oidSchema,
attributes: patchAttributesSchema,
})
const documentOidsSchema = z
.array(oidSchema)
.min(1, 'At least one document OID is required')
.max(MAX_BULK_DOCUMENTS, `At most ${MAX_BULK_DOCUMENTS} documents can be processed at once`)
.refine((ids) => new Set(ids).size === ids.length, 'Document OIDs must be unique')
const commonMutationShapes = {
createDocument: credentialsSchema.extend({
operation: z.literal('windchill_create_document'),
name: requiredTextSchema,
containerOid: oidSchema,
number: optionalTextSchema,
title: optionalTextSchema,
description: optionalTextSchema,
folderOid: oidSchema.optional(),
attributes: attributesSchema.optional(),
}),
createDocuments: credentialsSchema.extend({
operation: z.literal('windchill_create_documents'),
documents: z
.array(createDocumentInputSchema)
.min(1, 'At least one document is required')
.max(MAX_BULK_DOCUMENTS),
}),
updateDocument: credentialsSchema.extend({
operation: z.literal('windchill_update_document'),
documentOid: oidSchema,
attributes: patchAttributesSchema,
}),
updateCommonProperties: credentialsSchema.extend({
operation: z.literal('windchill_update_common_properties'),
documentOid: oidSchema,
commonProperties: commonPropertiesSchema,
}),
updateDocuments: credentialsSchema.extend({
operation: z.literal('windchill_update_documents'),
documents: z
.array(updateDocumentInputSchema)
.min(1, 'At least one document is required')
.max(MAX_BULK_DOCUMENTS),
}),
deleteDocument: credentialsSchema.extend({
operation: z.literal('windchill_delete_document'),
documentOid: oidSchema,
}),
deleteDocuments: credentialsSchema.extend({
operation: z.literal('windchill_delete_documents'),
documentOids: documentOidsSchema,
}),
checkOutDocument: credentialsSchema.extend({
operation: z.literal('windchill_check_out_document'),
documentOid: oidSchema,
checkOutNote: optionalTextSchema,
}),
checkOutDocuments: credentialsSchema.extend({
operation: z.literal('windchill_check_out_documents'),
documentOids: documentOidsSchema,
checkOutNote: optionalTextSchema,
}),
checkInDocument: credentialsSchema.extend({
operation: z.literal('windchill_check_in_document'),
documentOid: oidSchema,
checkInNote: optionalTextSchema,
keepCheckedOut: z.boolean().optional(),
checkOutNote: optionalTextSchema,
}),
checkInDocuments: credentialsSchema.extend({
operation: z.literal('windchill_check_in_documents'),
documentOids: documentOidsSchema,
checkInNote: optionalTextSchema,
keepCheckedOut: z.boolean().optional(),
checkOutNote: optionalTextSchema,
}),
undoCheckOutDocument: credentialsSchema.extend({
operation: z.literal('windchill_undo_check_out_document'),
documentOid: oidSchema,
}),
undoCheckOutDocuments: credentialsSchema.extend({
operation: z.literal('windchill_undo_check_out_documents'),
documentOids: documentOidsSchema,
}),
reviseDocument: credentialsSchema.extend({
operation: z.literal('windchill_revise_document'),
documentOid: oidSchema,
versionId: optionalTextSchema,
}),
reviseDocuments: credentialsSchema.extend({
operation: z.literal('windchill_revise_documents'),
documentOids: documentOidsSchema,
}),
setLifecycleState: credentialsSchema.extend({
operation: z.literal('windchill_set_lifecycle_state'),
documentOid: oidSchema,
stateValue: requiredTextSchema,
stateDisplay: requiredTextSchema,
}),
updateSecurityLabels: credentialsSchema.extend({
operation: z.literal('windchill_update_document_security_labels'),
securityLabelUpdates: z
.array(
z.object({
id: oidSchema,
labels: z
.record(attributeNameSchema, z.string().max(MAX_TEXT_LENGTH))
.refine((labels) => Object.keys(labels).length > 0, {
message: 'At least one security label is required',
})
.refine((labels) => Object.keys(labels).length <= MAX_ATTRIBUTES, {
message: `Security labels cannot contain more than ${MAX_ATTRIBUTES} fields`,
}),
})
)
.min(1, 'At least one document security-label update is required')
.max(MAX_BULK_DOCUMENTS),
}),
} as const
const downloadPrimarySchema = credentialsSchema.extend({
operation: z.literal('windchill_download_primary_content'),
documentOid: oidSchema,
fileName: z.string().trim().min(1).max(255).optional(),
})
const uploadPrimarySchema = credentialsSchema.extend({
operation: z.literal('windchill_upload_primary_content'),
documentOid: oidSchema,
primaryFile: RawFileInputSchema,
})
const downloadAttachmentSchema = credentialsSchema.extend({
operation: z.literal('windchill_download_attachment'),
documentOid: oidSchema,
attachmentOid: oidSchema,
fileName: z.string().trim().min(1).max(255).optional(),
})
const uploadAttachmentsSchema = credentialsSchema.extend({
operation: z.literal('windchill_upload_attachments'),
documentOid: oidSchema,
attachmentFiles: RawFileInputArraySchema.min(1, 'At least one attachment file is required').max(
MAX_ATTACHMENT_FILES,
`At most ${MAX_ATTACHMENT_FILES} attachments can be uploaded at once`
),
})
export const windchillOperationBodySchema = z.discriminatedUnion('operation', [
commonMutationShapes.createDocument,
commonMutationShapes.createDocuments,
commonMutationShapes.updateDocument,
commonMutationShapes.updateCommonProperties,
commonMutationShapes.updateDocuments,
commonMutationShapes.deleteDocument,
commonMutationShapes.deleteDocuments,
commonMutationShapes.checkOutDocument,
commonMutationShapes.checkOutDocuments,
commonMutationShapes.checkInDocument,
commonMutationShapes.checkInDocuments,
commonMutationShapes.undoCheckOutDocument,
commonMutationShapes.undoCheckOutDocuments,
commonMutationShapes.reviseDocument,
commonMutationShapes.reviseDocuments,
commonMutationShapes.setLifecycleState,
commonMutationShapes.updateSecurityLabels,
downloadPrimarySchema,
uploadPrimarySchema,
downloadAttachmentSchema,
uploadAttachmentsSchema,
])
/**
* Response primitives are deliberately looser than their request counterparts: these values are
* whatever the customer's Windchill returned, so re-applying the request-side OID regex or text
* bounds would turn an already-committed mutation into an opaque parse failure.
*/
const returnedOidSchema = z.string().min(1)
const nullableId = z.string().nullable()
const nullableText = z.string().nullable()
const documentSchema = z.object({
id: nullableId,
name: nullableText,
number: nullableText,
title: nullableText,
description: nullableText,
state: nullableText,
stateDisplay: nullableText,
versionId: nullableText,
revision: nullableText,
version: nullableText,
latest: z.boolean().nullable(),
checkoutState: nullableText,
folderName: nullableText,
folderLocation: nullableText,
})
const affectedIdsSchema = z.array(returnedOidSchema)
const singleMutationOperationSchema = z.enum([
'windchill_create_document',
'windchill_update_document',
'windchill_update_common_properties',
'windchill_check_out_document',
'windchill_check_in_document',
'windchill_undo_check_out_document',
'windchill_revise_document',
'windchill_set_lifecycle_state',
])
const bulkMutationOperationSchema = z.enum([
'windchill_create_documents',
'windchill_update_documents',
'windchill_check_out_documents',
'windchill_check_in_documents',
'windchill_undo_check_out_documents',
'windchill_revise_documents',
'windchill_update_document_security_labels',
])
const deleteOperationSchema = z.enum(['windchill_delete_document', 'windchill_delete_documents'])
const downloadOperationSchema = z.enum([
'windchill_download_primary_content',
'windchill_download_attachment',
])
const uploadOperationSchema = z.enum([
'windchill_upload_primary_content',
'windchill_upload_attachments',
])
export const windchillOperationOutputSchema = z.discriminatedUnion('operation', [
z.object({
operation: singleMutationOperationSchema,
affectedIds: affectedIdsSchema,
document: documentSchema.optional(),
}),
z.object({
operation: bulkMutationOperationSchema,
affectedIds: affectedIdsSchema,
documents: z.array(documentSchema).optional(),
}),
z.object({ operation: deleteOperationSchema, affectedIds: affectedIdsSchema }),
z.object({
operation: downloadOperationSchema,
file: userFileSchema,
fileName: z.string().min(1),
mimeType: z.string().min(1),
}),
z.object({
operation: uploadOperationSchema,
affectedIds: affectedIdsSchema,
uploadedFileNames: z.array(z.string().min(1)),
}),
])
export const windchillOperationResponseSchema = z.discriminatedUnion('success', [
z.object({
success: z.literal(true),
output: windchillOperationOutputSchema,
}),
z.object({
success: z.literal(false),
error: z.string().min(1),
}),
])
export const windchillOperationContract = defineRouteContract({
method: 'POST',
path: '/api/tools/windchill',
body: windchillOperationBodySchema,
response: { mode: 'json', schema: windchillOperationResponseSchema },
})
export type WindchillOperationBody = ContractBodyInput<typeof windchillOperationContract>
export type WindchillOperationResponse = ContractJsonResponse<typeof windchillOperationContract>
@@ -242,6 +242,7 @@ import {
WebhookIcon,
WhatsAppIcon,
WikipediaIcon,
WindchillIcon,
WizaIcon,
WordpressIcon,
WorkdayIcon,
@@ -512,6 +513,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
webflow: WebflowIcon,
whatsapp: WhatsAppIcon,
wikipedia: WikipediaIcon,
windchill: WindchillIcon,
wiza: WizaIcon,
wordpress: WordpressIcon,
workday: WorkdayIcon,
+128 -1
View File
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-08-11",
"updatedAt": "2026-08-12",
"integrations": [
{
"type": "onepassword",
@@ -21940,6 +21940,133 @@
"integrationType": "search",
"tags": ["knowledge-base"]
},
{
"type": "windchill",
"slug": "windchill",
"name": "Windchill",
"description": "Manage documents, revisions, and content in PTC Windchill",
"longDescription": "Integrate PTC Windchill REST Services 2.7 document management into your workflow using Basic authentication. Read and update document metadata, perform version and lifecycle actions, and transfer primary content and attachments. Windchill OAuth deployments are not currently supported.",
"bgColor": "#FFFFFF",
"iconName": "WindchillIcon",
"docsUrl": "https://docs.sim.ai/integrations/windchill",
"operations": [
{
"name": "List Documents",
"description": "List documents with an OData query, sorting, and pagination"
},
{
"name": "Get Document",
"description": "Get a WT.Document by OID"
},
{
"name": "Get Document Structure",
"description": "Retrieve recursive document usage links and their parent and child documents"
},
{
"name": "Get Valid State Transitions",
"description": "Get lifecycle states a document can transition to from its current state"
},
{
"name": "Get Primary Content",
"description": "Get primary-content metadata for a document"
},
{
"name": "List Attachments",
"description": "List attachment metadata for a document"
},
{
"name": "Create Document",
"description": "Create one WT.Document"
},
{
"name": "Create Documents",
"description": "Create several documents in one atomic Windchill request"
},
{
"name": "Update Document",
"description": "Update one document's editable attributes"
},
{
"name": "Update Common Properties",
"description": "Update a document's Name, Number, and other common properties"
},
{
"name": "Update Documents",
"description": "Update several documents' editable attributes in one atomic request"
},
{
"name": "Delete Document",
"description": "Delete one document"
},
{
"name": "Delete Documents",
"description": "Delete multiple documents atomically"
},
{
"name": "Check Out Document",
"description": "Check out one document"
},
{
"name": "Check Out Documents",
"description": "Check out multiple documents atomically"
},
{
"name": "Check In Document",
"description": "Check in one document"
},
{
"name": "Check In Documents",
"description": "Check in multiple documents atomically"
},
{
"name": "Undo Check Out Document",
"description": "Undo checkout for one document"
},
{
"name": "Undo Check Out Documents",
"description": "Undo checkout for multiple documents atomically"
},
{
"name": "Revise Document",
"description": "Create a new revision of one document"
},
{
"name": "Revise Documents",
"description": "Create new revisions of multiple documents atomically"
},
{
"name": "Set Lifecycle State",
"description": "Transition a document to a valid lifecycle state"
},
{
"name": "Update Document Security Labels",
"description": "Update installed security-label attributes for one or more documents"
},
{
"name": "Download Primary Content",
"description": "Download primary content into a canonical UserFile"
},
{
"name": "Upload Primary Content",
"description": "Upload a primary-content file to a document that has none"
},
{
"name": "Download Attachment",
"description": "Download a document attachment into a canonical UserFile"
},
{
"name": "Upload Attachments",
"description": "Upload one or more files as document attachments"
}
],
"operationCount": 27,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationType": "documents",
"tags": ["content-management", "document-processing"]
},
{
"type": "wiza",
"slug": "wiza",
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
+56
View File
@@ -4768,6 +4768,35 @@ import {
wikipediaRandomPageTool,
wikipediaSearchTool,
} from '@/tools/wikipedia'
import {
windchillCheckInDocumentsTool,
windchillCheckInDocumentTool,
windchillCheckOutDocumentsTool,
windchillCheckOutDocumentTool,
windchillCreateDocumentsTool,
windchillCreateDocumentTool,
windchillDeleteDocumentsTool,
windchillDeleteDocumentTool,
windchillDownloadAttachmentTool,
windchillDownloadPrimaryContentTool,
windchillGetDocumentStructureTool,
windchillGetDocumentTool,
windchillGetPrimaryContentTool,
windchillGetValidStateTransitionsTool,
windchillListAttachmentsTool,
windchillListDocumentsTool,
windchillReviseDocumentsTool,
windchillReviseDocumentTool,
windchillSetLifecycleStateTool,
windchillUndoCheckOutDocumentsTool,
windchillUndoCheckOutDocumentTool,
windchillUpdateCommonPropertiesTool,
windchillUpdateDocumentSecurityLabelsTool,
windchillUpdateDocumentsTool,
windchillUpdateDocumentTool,
windchillUploadAttachmentsTool,
windchillUploadPrimaryContentTool,
} from '@/tools/windchill'
import {
wizaCompanyEnrichmentTool,
wizaGetCreditsTool,
@@ -8633,6 +8662,33 @@ export const tools: Record<string, ToolConfig> = {
wikipedia_search: wikipediaSearchTool,
wikipedia_content: wikipediaPageContentTool,
wikipedia_random: wikipediaRandomPageTool,
windchill_check_in_document: windchillCheckInDocumentTool,
windchill_check_in_documents: windchillCheckInDocumentsTool,
windchill_check_out_document: windchillCheckOutDocumentTool,
windchill_check_out_documents: windchillCheckOutDocumentsTool,
windchill_create_document: windchillCreateDocumentTool,
windchill_create_documents: windchillCreateDocumentsTool,
windchill_delete_document: windchillDeleteDocumentTool,
windchill_delete_documents: windchillDeleteDocumentsTool,
windchill_download_attachment: windchillDownloadAttachmentTool,
windchill_download_primary_content: windchillDownloadPrimaryContentTool,
windchill_get_document: windchillGetDocumentTool,
windchill_get_document_structure: windchillGetDocumentStructureTool,
windchill_get_primary_content: windchillGetPrimaryContentTool,
windchill_get_valid_state_transitions: windchillGetValidStateTransitionsTool,
windchill_list_attachments: windchillListAttachmentsTool,
windchill_list_documents: windchillListDocumentsTool,
windchill_revise_document: windchillReviseDocumentTool,
windchill_revise_documents: windchillReviseDocumentsTool,
windchill_set_lifecycle_state: windchillSetLifecycleStateTool,
windchill_undo_check_out_document: windchillUndoCheckOutDocumentTool,
windchill_undo_check_out_documents: windchillUndoCheckOutDocumentsTool,
windchill_update_common_properties: windchillUpdateCommonPropertiesTool,
windchill_update_document: windchillUpdateDocumentTool,
windchill_update_document_security_labels: windchillUpdateDocumentSecurityLabelsTool,
windchill_update_documents: windchillUpdateDocumentsTool,
windchill_upload_attachments: windchillUploadAttachmentsTool,
windchill_upload_primary_content: windchillUploadPrimaryContentTool,
wiza_company_enrichment: wizaCompanyEnrichmentTool,
wiza_get_credits: wizaGetCreditsTool,
wiza_individual_reveal: wizaIndividualRevealTool,
@@ -0,0 +1,72 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_SINGLE_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillCheckInDocumentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_check_in_document',
name: 'Windchill Check In Document',
description: 'Check in one document',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
checkInNote: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Check-in note',
},
keepCheckedOut: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Keep the document checked out after checking it in',
},
checkOutNote: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Checkout note',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_check_in_document', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_check_in_document', response),
outputs: WINDCHILL_SINGLE_MUTATION_OUTPUTS,
}
@@ -0,0 +1,72 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_BULK_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillCheckInDocumentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_check_in_documents',
name: 'Windchill Check In Documents',
description: 'Check in multiple documents atomically',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOids: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OIDs to process atomically',
},
checkInNote: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Check-in note',
},
keepCheckedOut: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Keep the document checked out after checking it in',
},
checkOutNote: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Checkout note',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_check_in_documents', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_check_in_documents', response),
outputs: WINDCHILL_BULK_MUTATION_OUTPUTS,
}
@@ -0,0 +1,60 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_SINGLE_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillCheckOutDocumentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_check_out_document',
name: 'Windchill Check Out Document',
description: 'Check out one document',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
checkOutNote: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Checkout note',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_check_out_document', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_check_out_document', response),
outputs: WINDCHILL_SINGLE_MUTATION_OUTPUTS,
}
@@ -0,0 +1,60 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_BULK_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillCheckOutDocumentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_check_out_documents',
name: 'Windchill Check Out Documents',
description: 'Check out multiple documents atomically',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOids: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OIDs to process atomically',
},
checkOutNote: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Checkout note',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_check_out_documents', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_check_out_documents', response),
outputs: WINDCHILL_BULK_MUTATION_OUTPUTS,
}
@@ -0,0 +1,90 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_SINGLE_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillCreateDocumentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_create_document',
name: 'Windchill Create Document',
description: 'Create one WT.Document',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Document name',
},
containerOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Container OID in which to create the document',
},
number: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional document number when manual numbering is enabled',
},
title: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Document title',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Document description',
},
folderOid: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional folder OID for the new document',
},
attributes: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Optional installed Windchill document attributes as a JSON object',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_create_document', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_create_document', response),
outputs: WINDCHILL_SINGLE_MUTATION_OUTPUTS,
}
@@ -0,0 +1,54 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_BULK_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillCreateDocumentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_create_documents',
name: 'Windchill Create Documents',
description: 'Create several documents in one atomic Windchill request',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documents: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'Document inputs as a JSON array; each item requires name and containerOid',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_create_documents', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_create_documents', response),
outputs: WINDCHILL_BULK_MUTATION_OUTPUTS,
}
@@ -0,0 +1,54 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_AFFECTED_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillDeleteDocumentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_delete_document',
name: 'Windchill Delete Document',
description: 'Delete one document',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_delete_document', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_delete_document', response),
outputs: WINDCHILL_AFFECTED_OUTPUTS,
}
@@ -0,0 +1,54 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_AFFECTED_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillDeleteDocumentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_delete_documents',
name: 'Windchill Delete Documents',
description: 'Delete multiple documents atomically',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOids: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OIDs to process atomically',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_delete_documents', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_delete_documents', response),
outputs: WINDCHILL_AFFECTED_OUTPUTS,
}
@@ -0,0 +1,66 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_FILE_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillDownloadAttachmentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_download_attachment',
name: 'Windchill Download Attachment',
description: 'Download a document attachment into a canonical UserFile',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
attachmentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Windchill attachment content OID',
},
fileName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional downloaded file name override',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_download_attachment', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_download_attachment', response),
outputs: WINDCHILL_FILE_OUTPUTS,
}
@@ -0,0 +1,60 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_FILE_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillDownloadPrimaryContentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_download_primary_content',
name: 'Windchill Download Primary Content',
description: 'Download primary content into a canonical UserFile',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
fileName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional downloaded file name override',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_download_primary_content', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_download_primary_content', response),
outputs: WINDCHILL_FILE_OUTPUTS,
}
+60
View File
@@ -0,0 +1,60 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_DOCUMENT_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillReadUrl,
transformWindchillDirectRead,
windchillReadHeaders,
} from '@/tools/windchill/utils'
export const windchillGetDocumentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_get_document',
name: 'Windchill Get Document',
description: 'Get a WT.Document by OID',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
select: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated normalized document properties to return',
},
},
request: {
url: (params) => buildWindchillReadUrl('windchill_get_document', params),
method: 'GET',
headers: windchillReadHeaders,
stripAuthOnRedirect: true,
retry: { enabled: true, maxRetries: 2, retryIdempotentOnly: true },
},
transformResponse: (response) => transformWindchillDirectRead('windchill_get_document', response),
outputs: WINDCHILL_DOCUMENT_OUTPUTS,
}
@@ -0,0 +1,67 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_STRUCTURE_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillReadUrl,
transformWindchillDirectRead,
windchillReadHeaders,
} from '@/tools/windchill/utils'
export const windchillGetDocumentStructureTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_get_document_structure',
name: 'Windchill Get Document Structure',
description: 'Retrieve recursive document usage links and their parent and child documents',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
structureDepth: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Document structure expansion depth, from 1 to 3',
},
nextLink: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Verified @odata.nextLink from a previous structure response',
},
},
request: {
url: (params) => buildWindchillReadUrl('windchill_get_document_structure', params),
method: 'GET',
headers: windchillReadHeaders,
stripAuthOnRedirect: true,
retry: { enabled: true, maxRetries: 2, retryIdempotentOnly: true },
},
transformResponse: (response) =>
transformWindchillDirectRead('windchill_get_document_structure', response),
outputs: WINDCHILL_STRUCTURE_OUTPUTS,
}
@@ -0,0 +1,55 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_PRIMARY_CONTENT_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillReadUrl,
transformWindchillDirectRead,
windchillReadHeaders,
} from '@/tools/windchill/utils'
export const windchillGetPrimaryContentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_get_primary_content',
name: 'Windchill Get Primary Content',
description: 'Get primary-content metadata for a document',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
},
request: {
url: (params) => buildWindchillReadUrl('windchill_get_primary_content', params),
method: 'GET',
headers: windchillReadHeaders,
stripAuthOnRedirect: true,
retry: { enabled: true, maxRetries: 2, retryIdempotentOnly: true },
},
transformResponse: (response) =>
transformWindchillDirectRead('windchill_get_primary_content', response),
outputs: WINDCHILL_PRIMARY_CONTENT_OUTPUTS,
}
@@ -0,0 +1,56 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_STATE_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillReadUrl,
transformWindchillDirectRead,
windchillReadHeaders,
} from '@/tools/windchill/utils'
export const windchillGetValidStateTransitionsTool: ToolConfig<WindchillParams, WindchillResponse> =
{
id: 'windchill_get_valid_state_transitions',
name: 'Windchill Get Valid State Transitions',
description: 'Get lifecycle states a document can transition to from its current state',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
},
request: {
url: (params) => buildWindchillReadUrl('windchill_get_valid_state_transitions', params),
method: 'GET',
headers: windchillReadHeaders,
stripAuthOnRedirect: true,
retry: { enabled: true, maxRetries: 2, retryIdempotentOnly: true },
},
transformResponse: (response) =>
transformWindchillDirectRead('windchill_get_valid_state_transitions', response),
outputs: WINDCHILL_STATE_OUTPUTS,
}
+28
View File
@@ -0,0 +1,28 @@
export { windchillCheckInDocumentTool } from '@/tools/windchill/check_in_document'
export { windchillCheckInDocumentsTool } from '@/tools/windchill/check_in_documents'
export { windchillCheckOutDocumentTool } from '@/tools/windchill/check_out_document'
export { windchillCheckOutDocumentsTool } from '@/tools/windchill/check_out_documents'
export { windchillCreateDocumentTool } from '@/tools/windchill/create_document'
export { windchillCreateDocumentsTool } from '@/tools/windchill/create_documents'
export { windchillDeleteDocumentTool } from '@/tools/windchill/delete_document'
export { windchillDeleteDocumentsTool } from '@/tools/windchill/delete_documents'
export { windchillDownloadAttachmentTool } from '@/tools/windchill/download_attachment'
export { windchillDownloadPrimaryContentTool } from '@/tools/windchill/download_primary_content'
export { windchillGetDocumentTool } from '@/tools/windchill/get_document'
export { windchillGetDocumentStructureTool } from '@/tools/windchill/get_document_structure'
export { windchillGetPrimaryContentTool } from '@/tools/windchill/get_primary_content'
export { windchillGetValidStateTransitionsTool } from '@/tools/windchill/get_valid_state_transitions'
export { windchillListAttachmentsTool } from '@/tools/windchill/list_attachments'
export { windchillListDocumentsTool } from '@/tools/windchill/list_documents'
export { windchillReviseDocumentTool } from '@/tools/windchill/revise_document'
export { windchillReviseDocumentsTool } from '@/tools/windchill/revise_documents'
export { windchillSetLifecycleStateTool } from '@/tools/windchill/set_lifecycle_state'
export * from '@/tools/windchill/types'
export { windchillUndoCheckOutDocumentTool } from '@/tools/windchill/undo_check_out_document'
export { windchillUndoCheckOutDocumentsTool } from '@/tools/windchill/undo_check_out_documents'
export { windchillUpdateCommonPropertiesTool } from '@/tools/windchill/update_common_properties'
export { windchillUpdateDocumentTool } from '@/tools/windchill/update_document'
export { windchillUpdateDocumentSecurityLabelsTool } from '@/tools/windchill/update_document_security_labels'
export { windchillUpdateDocumentsTool } from '@/tools/windchill/update_documents'
export { windchillUploadAttachmentsTool } from '@/tools/windchill/upload_attachments'
export { windchillUploadPrimaryContentTool } from '@/tools/windchill/upload_primary_content'
@@ -0,0 +1,61 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_ATTACHMENTS_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillReadUrl,
transformWindchillDirectRead,
windchillReadHeaders,
} from '@/tools/windchill/utils'
export const windchillListAttachmentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_list_attachments',
name: 'Windchill List Attachments',
description: 'List attachment metadata for a document',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
nextLink: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Verified @odata.nextLink from a previous attachment response',
},
},
request: {
url: (params) => buildWindchillReadUrl('windchill_list_attachments', params),
method: 'GET',
headers: windchillReadHeaders,
stripAuthOnRedirect: true,
retry: { enabled: true, maxRetries: 2, retryIdempotentOnly: true },
},
transformResponse: (response) =>
transformWindchillDirectRead('windchill_list_attachments', response),
outputs: WINDCHILL_ATTACHMENTS_OUTPUTS,
}
@@ -0,0 +1,97 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_LIST_DOCUMENTS_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillReadUrl,
transformWindchillDirectRead,
windchillReadHeaders,
} from '@/tools/windchill/utils'
export const windchillListDocumentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_list_documents',
name: 'Windchill List Documents',
description: 'List documents with an OData query, sorting, and pagination',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
select: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated normalized document properties to return',
},
filter: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'OData $filter expression',
},
orderBy: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'OData $orderby expression',
},
top: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum documents in the OData result set ($top), from 1 to 2000',
},
skip: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Documents to skip',
},
count: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Ask Windchill to include the total matching count',
},
latestVersion: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Return only the latest version of matching documents',
},
nextLink: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Verified @odata.nextLink from a previous list response',
},
},
request: {
url: (params) => buildWindchillReadUrl('windchill_list_documents', params),
method: 'GET',
headers: windchillReadHeaders,
stripAuthOnRedirect: true,
retry: { enabled: true, maxRetries: 2, retryIdempotentOnly: true },
},
transformResponse: (response) =>
transformWindchillDirectRead('windchill_list_documents', response),
outputs: WINDCHILL_LIST_DOCUMENTS_OUTPUTS,
}
+18
View File
@@ -0,0 +1,18 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.unmock('@/tools/registry')
import { tools } from '@/tools/registry'
import { WINDCHILL_OPERATIONS } from '@/tools/windchill/types'
describe('Windchill registry', () => {
it('registers every operation in the global tool registry', () => {
for (const operation of WINDCHILL_OPERATIONS) {
expect(tools[operation]?.id).toBe(operation)
}
})
})
@@ -0,0 +1,60 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_SINGLE_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillReviseDocumentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_revise_document',
name: 'Windchill Revise Document',
description: 'Create a new revision of one document',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
versionId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional target revision identifier when override-on-revise is enabled',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_revise_document', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_revise_document', response),
outputs: WINDCHILL_SINGLE_MUTATION_OUTPUTS,
}
@@ -0,0 +1,54 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_BULK_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillReviseDocumentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_revise_documents',
name: 'Windchill Revise Documents',
description: 'Create new revisions of multiple documents atomically',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOids: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OIDs to process atomically',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_revise_documents', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_revise_documents', response),
outputs: WINDCHILL_BULK_MUTATION_OUTPUTS,
}
@@ -0,0 +1,66 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_SINGLE_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillSetLifecycleStateTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_set_lifecycle_state',
name: 'Windchill Set Lifecycle State',
description: 'Transition a document to a valid lifecycle state',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
stateValue: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Internal value of the target lifecycle state',
},
stateDisplay: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Display value of the target lifecycle state',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_set_lifecycle_state', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_set_lifecycle_state', response),
outputs: WINDCHILL_SINGLE_MUTATION_OUTPUTS,
}
+380
View File
@@ -0,0 +1,380 @@
import type { RawFileInput } from '@/lib/uploads/utils/file-schemas'
import type { UserFile } from '@/executor/types'
import type { ToolOutputProperty, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types'
export const WINDCHILL_DOCUMENT_PROPERTIES = {
id: { type: 'string', description: 'Windchill object identifier', nullable: true },
name: { type: 'string', description: 'Document name', nullable: true },
number: { type: 'string', description: 'Document number', nullable: true },
title: { type: 'string', description: 'Document title', nullable: true },
description: { type: 'string', description: 'Document description', nullable: true },
state: { type: 'string', description: 'Internal life cycle state value', nullable: true },
stateDisplay: { type: 'string', description: 'Displayed life cycle state value', nullable: true },
versionId: { type: 'string', description: 'Version identifier', nullable: true },
revision: { type: 'string', description: 'Revision identifier', nullable: true },
version: { type: 'string', description: 'Version and iteration', nullable: true },
latest: { type: 'boolean', description: 'Whether this is the latest version', nullable: true },
checkoutState: { type: 'string', description: 'Checkout state', nullable: true },
folderName: { type: 'string', description: 'Folder name', nullable: true },
folderLocation: { type: 'string', description: 'Folder path', nullable: true },
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_CONTENT_PROPERTIES = {
id: { type: 'string', description: 'Content object identifier', nullable: true },
fileName: { type: 'string', description: 'Content file name', nullable: true },
description: { type: 'string', description: 'Content description', nullable: true },
format: { type: 'string', description: 'Windchill content format', nullable: true },
mimeType: { type: 'string', description: 'Content MIME type', nullable: true },
fileSize: { type: 'number', description: 'Content size in bytes', nullable: true },
contentType: {
type: 'string',
description: 'Windchill OData content entity type',
nullable: true,
},
displayName: { type: 'string', description: 'Displayed content name', nullable: true },
urlLocation: { type: 'string', description: 'URL-data location', nullable: true },
externalLocation: { type: 'string', description: 'External-storage location', nullable: true },
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_DOCUMENT_OUTPUTS = {
operation: { type: 'string', description: 'Windchill operation that was executed' },
document: {
type: 'object',
description: 'Windchill document',
properties: WINDCHILL_DOCUMENT_PROPERTIES,
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_LIST_DOCUMENTS_OUTPUTS = {
operation: { type: 'string', description: 'Windchill operation that was executed' },
documents: {
type: 'array',
description: 'Windchill documents',
items: { type: 'object', properties: WINDCHILL_DOCUMENT_PROPERTIES },
},
pageInfo: {
type: 'object',
description: 'OData pagination information',
properties: {
count: { type: 'number', description: 'Number of items returned in this page' },
totalCount: { type: 'number', description: 'Total matching items', nullable: true },
nextLink: {
type: 'string',
description: 'URL returned by Windchill for the next page',
nullable: true,
},
},
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_USAGE_LINK_PROPERTIES = {
id: { type: 'string', description: 'Document usage link OID', nullable: true },
parent: {
type: 'object',
description: 'Parent document',
nullable: true,
properties: WINDCHILL_DOCUMENT_PROPERTIES,
},
child: {
type: 'object',
description: 'Child document',
nullable: true,
properties: WINDCHILL_DOCUMENT_PROPERTIES,
},
children: {
type: 'array',
description: 'Nested child usage links with the same recursive shape',
items: { type: 'json' },
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_USAGE_LINK_OUTPUT = {
type: 'object',
description: 'Document usage link',
properties: WINDCHILL_USAGE_LINK_PROPERTIES,
} as const satisfies ToolOutputProperty
export const WINDCHILL_STRUCTURE_OUTPUTS = {
operation: { type: 'string', description: 'Windchill operation that was executed' },
structure: {
type: 'array',
description: 'Document usage links, including recursively expanded child links',
items: WINDCHILL_USAGE_LINK_OUTPUT,
},
pageInfo: {
type: 'object',
description: 'OData pagination information',
properties: {
count: { type: 'number', description: 'Number of items returned in this page' },
totalCount: { type: 'number', description: 'Total matching items', nullable: true },
nextLink: {
type: 'string',
description: 'URL returned by Windchill for the next page',
nullable: true,
},
},
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_STATE_OUTPUTS = {
operation: { type: 'string', description: 'Windchill operation that was executed' },
states: {
type: 'array',
description: 'Valid lifecycle transitions',
items: {
type: 'object',
properties: {
value: { type: 'string', description: 'Internal state value', nullable: true },
display: { type: 'string', description: 'Displayed state value', nullable: true },
},
},
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_PRIMARY_CONTENT_OUTPUTS = {
operation: { type: 'string', description: 'Windchill operation that was executed' },
content: {
type: 'object',
description: 'Primary-content metadata',
nullable: true,
properties: WINDCHILL_CONTENT_PROPERTIES,
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_ATTACHMENTS_OUTPUTS = {
operation: { type: 'string', description: 'Windchill operation that was executed' },
attachments: {
type: 'array',
description: 'Document attachments',
items: { type: 'object', properties: WINDCHILL_CONTENT_PROPERTIES },
},
pageInfo: {
type: 'object',
description: 'OData pagination information',
properties: {
count: { type: 'number', description: 'Number of items returned in this page' },
totalCount: { type: 'number', description: 'Total matching items', nullable: true },
nextLink: {
type: 'string',
description: 'URL returned by Windchill for the next page',
nullable: true,
},
},
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_AFFECTED_OUTPUTS = {
operation: { type: 'string', description: 'Windchill operation that was executed' },
affectedIds: {
type: 'array',
description: 'Document identifiers affected by the operation',
items: { type: 'string' },
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_SINGLE_MUTATION_OUTPUTS = {
...WINDCHILL_AFFECTED_OUTPUTS,
document: {
type: 'object',
description: 'Document returned by Windchill when the operation returns one',
optional: true,
properties: WINDCHILL_DOCUMENT_PROPERTIES,
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_BULK_MUTATION_OUTPUTS = {
...WINDCHILL_AFFECTED_OUTPUTS,
documents: {
type: 'array',
description: 'Documents returned by Windchill when the operation returns them',
optional: true,
items: { type: 'object', properties: WINDCHILL_DOCUMENT_PROPERTIES },
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_FILE_OUTPUTS = {
operation: { type: 'string', description: 'Windchill operation that was executed' },
file: { type: 'file', description: 'Downloaded content stored as a canonical UserFile' },
fileName: { type: 'string', description: 'Downloaded file name' },
mimeType: { type: 'string', description: 'Downloaded content MIME type' },
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_UPLOAD_OUTPUTS = {
operation: { type: 'string', description: 'Windchill operation that was executed' },
affectedIds: {
type: 'array',
description: 'Document identifiers affected by the upload',
items: { type: 'string' },
},
uploadedFileNames: {
type: 'array',
description: 'Names of files accepted by Windchill',
items: { type: 'string' },
},
} as const satisfies Record<string, ToolOutputProperty>
export const WINDCHILL_OPERATIONS = [
'windchill_list_documents',
'windchill_get_document',
'windchill_get_document_structure',
'windchill_get_valid_state_transitions',
'windchill_get_primary_content',
'windchill_list_attachments',
'windchill_create_document',
'windchill_create_documents',
'windchill_update_document',
'windchill_update_common_properties',
'windchill_update_documents',
'windchill_delete_document',
'windchill_delete_documents',
'windchill_check_out_document',
'windchill_check_out_documents',
'windchill_check_in_document',
'windchill_check_in_documents',
'windchill_undo_check_out_document',
'windchill_undo_check_out_documents',
'windchill_revise_document',
'windchill_revise_documents',
'windchill_set_lifecycle_state',
'windchill_update_document_security_labels',
'windchill_download_primary_content',
'windchill_upload_primary_content',
'windchill_download_attachment',
'windchill_upload_attachments',
] as const
export type WindchillOperation = (typeof WINDCHILL_OPERATIONS)[number]
export interface WindchillDocument {
id: string | null
name: string | null
number: string | null
title: string | null
description: string | null
state: string | null
stateDisplay: string | null
versionId: string | null
revision: string | null
version: string | null
latest: boolean | null
checkoutState: string | null
folderName: string | null
folderLocation: string | null
}
export interface WindchillContent {
id: string | null
fileName: string | null
description: string | null
format: string | null
mimeType: string | null
fileSize: number | null
contentType: string | null
displayName: string | null
urlLocation: string | null
externalLocation: string | null
}
export interface WindchillStateTransition {
value: string | null
display: string | null
}
export interface WindchillDocumentUsageLink {
id: string | null
parent: WindchillDocument | null
child: WindchillDocument | null
children: WindchillDocumentUsageLink[]
}
export interface WindchillPageInfo {
count: number
totalCount: number | null
nextLink: string | null
}
export type WindchillAttributeValue =
| string
| number
| boolean
| null
| WindchillAttributeValue[]
| { [key: string]: WindchillAttributeValue }
export interface WindchillCreateDocumentInput {
name: string
containerOid: string
number?: string
title?: string
description?: string
folderOid?: string
attributes?: Record<string, WindchillAttributeValue>
}
export interface WindchillUpdateDocumentInput {
id: string
attributes: Record<string, WindchillAttributeValue>
}
export interface WindchillSecurityLabelInput {
id: string
labels: Record<string, string>
}
export interface WindchillParams {
baseUrl: string
username: string
password: string
documentOid?: string
documentOids?: string[]
attachmentOid?: string
name?: string
number?: string
title?: string
description?: string
containerOid?: string
folderOid?: string
attributes?: Record<string, WindchillAttributeValue>
commonProperties?: Record<string, WindchillAttributeValue>
documents?: WindchillCreateDocumentInput[] | WindchillUpdateDocumentInput[]
checkOutNote?: string
checkInNote?: string
keepCheckedOut?: boolean
versionId?: string
stateValue?: string
stateDisplay?: string
securityLabelUpdates?: WindchillSecurityLabelInput[]
primaryFile?: RawFileInput
attachmentFiles?: RawFileInput[]
fileName?: string
select?: string
filter?: string
orderBy?: string
top?: number
skip?: number
count?: boolean
latestVersion?: boolean
nextLink?: string
structureDepth?: number
_context?: WorkflowToolExecutionContext
}
export interface WindchillOutput {
operation: WindchillOperation
document?: WindchillDocument
documents?: WindchillDocument[]
structure?: WindchillDocumentUsageLink[]
states?: WindchillStateTransition[]
content?: WindchillContent | null
attachments?: WindchillContent[]
pageInfo?: WindchillPageInfo
affectedIds?: string[]
uploadedFileNames?: string[]
file?: UserFile
fileName?: string
mimeType?: string
}
export interface WindchillResponse extends ToolResponse {
output: WindchillOutput
}
@@ -0,0 +1,54 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_SINGLE_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillUndoCheckOutDocumentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_undo_check_out_document',
name: 'Windchill Undo Check Out Document',
description: 'Undo checkout for one document',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_undo_check_out_document', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_undo_check_out_document', response),
outputs: WINDCHILL_SINGLE_MUTATION_OUTPUTS,
}
@@ -0,0 +1,54 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_BULK_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillUndoCheckOutDocumentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_undo_check_out_documents',
name: 'Windchill Undo Check Out Documents',
description: 'Undo checkout for multiple documents atomically',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOids: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OIDs to process atomically',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_undo_check_out_documents', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_undo_check_out_documents', response),
outputs: WINDCHILL_BULK_MUTATION_OUTPUTS,
}
@@ -0,0 +1,62 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_SINGLE_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillUpdateCommonPropertiesTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_update_common_properties',
name: 'Windchill Update Common Properties',
description: "Update a document's Name, Number, and other common properties",
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'WT.Document OID, for example OR:wt.doc.WTDocument:48796581. The document must not be checked out.',
},
commonProperties: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description:
'Common properties as a JSON object, for example {"Name":"New name","Number":"NEW-001"}. Enumerated properties take a value/display pair.',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_update_common_properties', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_update_common_properties', response),
outputs: WINDCHILL_SINGLE_MUTATION_OUTPUTS,
}
@@ -0,0 +1,61 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_SINGLE_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillUpdateDocumentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_update_document',
name: 'Windchill Update Document',
description: "Update one document's editable attributes",
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
attributes: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description:
'Editable attributes as a JSON object. Name, Number, and Organization require the Update Common Properties operation and are not supported here.',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_update_document', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_update_document', response),
outputs: WINDCHILL_SINGLE_MUTATION_OUTPUTS,
}
@@ -0,0 +1,58 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_BULK_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillUpdateDocumentSecurityLabelsTool: ToolConfig<
WindchillParams,
WindchillResponse
> = {
id: 'windchill_update_document_security_labels',
name: 'Windchill Update Document Security Labels',
description: 'Update installed security-label attributes for one or more documents',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
securityLabelUpdates: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'Array of document IDs and installed security-label values',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) =>
buildWindchillInternalBody('windchill_update_document_security_labels', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_update_document_security_labels', response),
outputs: WINDCHILL_BULK_MUTATION_OUTPUTS,
}
@@ -0,0 +1,55 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_BULK_MUTATION_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillUpdateDocumentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_update_documents',
name: 'Windchill Update Documents',
description: "Update several documents' editable attributes in one atomic request",
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documents: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description:
'Document updates as a JSON array; each item requires id and the editable attributes to set. Name, Number, and Organization are not supported.',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_update_documents', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_update_documents', response),
outputs: WINDCHILL_BULK_MUTATION_OUTPUTS,
}
@@ -0,0 +1,60 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_UPLOAD_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillUploadAttachmentsTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_upload_attachments',
name: 'Windchill Upload Attachments',
description: 'Upload one or more files as document attachments',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
attachmentFiles: {
type: 'file[]',
required: true,
visibility: 'user-only',
description: 'Attachment files to upload',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_upload_attachments', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_upload_attachments', response),
outputs: WINDCHILL_UPLOAD_OUTPUTS,
}
@@ -0,0 +1,60 @@
import type { ToolConfig } from '@/tools/types'
import {
WINDCHILL_UPLOAD_OUTPUTS,
type WindchillParams,
type WindchillResponse,
} from '@/tools/windchill/types'
import {
buildWindchillInternalBody,
transformWindchillInternalResponse,
} from '@/tools/windchill/utils'
export const windchillUploadPrimaryContentTool: ToolConfig<WindchillParams, WindchillResponse> = {
id: 'windchill_upload_primary_content',
name: 'Windchill Upload Primary Content',
description: 'Upload a primary-content file to a document that has none',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description:
'Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Windchill service-account password',
},
documentOid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'WT.Document OID, for example OR:wt.doc.WTDocument:48796581',
},
primaryFile: {
type: 'file',
required: true,
visibility: 'user-only',
description: 'Primary content file to upload',
},
},
request: {
url: '/api/tools/windchill',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => buildWindchillInternalBody('windchill_upload_primary_content', params),
internalAuth: 'executor_delegation',
},
transformResponse: (response) =>
transformWindchillInternalResponse('windchill_upload_primary_content', response),
outputs: WINDCHILL_UPLOAD_OUTPUTS,
}
+448
View File
@@ -0,0 +1,448 @@
import { generateShortId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import {
MAX_JSON_API_RESPONSE_BYTES,
type SecureFetchResponse,
secureFetchWithValidation,
} from '@/lib/core/security/input-validation.server'
import type { WindchillParams } from '@/tools/windchill/types'
import {
createBasicAuthHeader,
encodeWindchillOid,
normalizeServiceRoot,
sanitizeWindchillError,
} from '@/tools/windchill/utils'
const WINDCHILL_CONTROL_RESPONSE_BYTES = 2 * 1024 * 1024
const WINDCHILL_TIMEOUT_MS = 60_000
interface WindchillSession {
nonceHeader: string
nonceValue: string
cookie: string | null
}
export interface WindchillUploadFile {
name: string
mimeType: string
size: number
buffer: Buffer
}
export class WindchillProviderError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
this.name = 'WindchillProviderError'
}
}
function cookieHeader(response: SecureFetchResponse): string | null {
const cookies = response.headers
.getSetCookie()
.map((cookie) => cookie.split(';', 1)[0]?.trim())
.filter((cookie): cookie is string => Boolean(cookie))
return cookies.length > 0 ? cookies.join('; ') : null
}
async function responseBody(
response: SecureFetchResponse
): Promise<{ data: unknown; invalidJson: boolean }> {
const text = await response.text()
if (!text.trim()) return { data: null, invalidJson: false }
try {
return { data: JSON.parse(text) as unknown, invalidJson: false }
} catch {
return { data: text, invalidJson: true }
}
}
function providerMessage(data: unknown, response: SecureFetchResponse): string {
if (typeof data === 'string' && data.trim()) return data.trim()
if (isRecordLike(data)) {
if (typeof data.message === 'string' && data.message.trim()) return data.message.trim()
if (isRecordLike(data.error)) {
if (typeof data.error.message === 'string' && data.error.message.trim()) {
return data.error.message.trim()
}
if (
isRecordLike(data.error.message) &&
typeof data.error.message.value === 'string' &&
data.error.message.value.trim()
) {
return data.error.message.value.trim()
}
}
}
return `Windchill request failed with status ${response.status}`
}
async function checkedBody(response: SecureFetchResponse): Promise<unknown> {
const { data, invalidJson } = await responseBody(response)
if (!response.ok) {
throw new WindchillProviderError(
sanitizeWindchillError(providerMessage(data, response)),
response.status
)
}
if (invalidJson) {
throw new WindchillProviderError(
`Windchill returned invalid JSON with status ${response.status}`,
502
)
}
return data
}
function ptcRoot(baseUrl: string): string {
return normalizeServiceRoot(baseUrl).replace(/\/v\d+$/i, '')
}
export function windchillDocumentUrl(baseUrl: string, documentOid: string): string {
return `${normalizeServiceRoot(baseUrl)}/DocMgmt/Documents('${encodeWindchillOid(documentOid)}')`
}
export async function createWindchillSession(
params: Pick<WindchillParams, 'baseUrl' | 'username' | 'password'>,
signal?: AbortSignal
): Promise<WindchillSession> {
const response = await secureFetchWithValidation(
`${ptcRoot(params.baseUrl)}/PTC/GetCSRFToken()`,
{
method: 'GET',
headers: {
Authorization: createBasicAuthHeader(params.username, params.password),
Accept: 'application/json',
},
maxRedirects: 0,
maxResponseBytes: WINDCHILL_CONTROL_RESPONSE_BYTES,
timeout: WINDCHILL_TIMEOUT_MS,
signal,
},
'baseUrl'
)
const data = await checkedBody(response)
if (!isRecordLike(data)) {
throw new WindchillProviderError('Windchill returned an invalid CSRF response', 502)
}
const nonceHeader = data.NonceKey
const nonceValue = data.NonceValue
if (
typeof nonceHeader !== 'string' ||
!/^[A-Za-z0-9_-]{1,128}$/.test(nonceHeader) ||
typeof nonceValue !== 'string' ||
nonceValue.length === 0 ||
nonceValue.length > 8192
) {
throw new WindchillProviderError('Windchill returned an invalid CSRF token', 502)
}
return { nonceHeader, nonceValue, cookie: cookieHeader(response) }
}
export async function windchillMutationRequest({
params,
session,
url,
method,
body,
signal,
}: {
params: Pick<WindchillParams, 'username' | 'password'>
session: WindchillSession
url: string
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'
body?: unknown
signal?: AbortSignal
}): Promise<unknown> {
const headers: Record<string, string> = {
Authorization: createBasicAuthHeader(params.username, params.password),
Accept: 'application/json',
[session.nonceHeader]: session.nonceValue,
}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (session.cookie) headers.Cookie = session.cookie
const response = await secureFetchWithValidation(
url,
{
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
maxRedirects: 0,
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: WINDCHILL_TIMEOUT_MS,
signal,
},
'baseUrl'
)
return checkedBody(response)
}
function stageOneDescriptor(data: unknown): {
replicaUrl: string
masterUrl: string
streamIds: Array<string | number>
fileNames: Array<string | number>
} {
const descriptor = isRecordLike(data) && Array.isArray(data.value) ? data.value[0] : null
if (!isRecordLike(descriptor)) {
throw new WindchillProviderError('Windchill upload Stage 1 returned no cache descriptor', 502)
}
if (
typeof descriptor.ReplicaUrl !== 'string' ||
typeof descriptor.MasterUrl !== 'string' ||
!Array.isArray(descriptor.StreamIds) ||
!Array.isArray(descriptor.FileNames)
) {
throw new WindchillProviderError(
'Windchill upload Stage 1 returned an invalid cache descriptor',
502
)
}
if (descriptor.StreamIds.length !== descriptor.FileNames.length) {
throw new WindchillProviderError(
'Windchill upload Stage 1 returned mismatched file identifiers',
502
)
}
return {
replicaUrl: descriptor.ReplicaUrl,
masterUrl: descriptor.MasterUrl,
streamIds: descriptor.StreamIds.filter(
(value): value is string | number => typeof value === 'string' || typeof value === 'number'
),
fileNames: descriptor.FileNames.filter(
(value): value is string | number => typeof value === 'string' || typeof value === 'number'
),
}
}
function multipartBody(
descriptor: ReturnType<typeof stageOneDescriptor>,
files: WindchillUploadFile[]
): { contentType: string; body: Buffer } {
if (
descriptor.streamIds.length !== files.length ||
descriptor.fileNames.length !== files.length
) {
throw new WindchillProviderError('Windchill upload Stage 1 returned the wrong file count', 502)
}
const boundary = `sim-windchill-${generateShortId()}`
const chunks: Buffer[] = []
const appendField = (name: string, value: string) => {
chunks.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`
)
)
}
appendField('Master_URL', descriptor.masterUrl)
appendField(
'CacheDescriptor_array',
descriptor.streamIds
.map(
(streamId, index) =>
`${streamId}:${descriptor.fileNames[index]}:${streamId}:${files[index].size};`
)
.join(' ')
)
files.forEach((file, index) => {
chunks.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${descriptor.streamIds[index]}"; filename="${file.name.replace(/["\r\n]/g, '_')}"\r\nContent-Type: ${file.mimeType}\r\n\r\n`
),
file.buffer,
Buffer.from('\r\n')
)
})
chunks.push(Buffer.from(`--${boundary}--\r\n`))
return { contentType: `multipart/form-data; boundary=${boundary}`, body: Buffer.concat(chunks) }
}
function stageTwoContent(data: unknown): Array<{
streamId: string | number
fileSize: number
encodedInfo: string
}> {
if (!isRecordLike(data) || !Array.isArray(data.contentInfos)) {
throw new WindchillProviderError('Windchill upload Stage 2 returned invalid content info', 502)
}
return data.contentInfos.map((item) => {
if (
!isRecordLike(item) ||
(typeof item.streamId !== 'string' && typeof item.streamId !== 'number') ||
typeof item.fileSize !== 'number' ||
typeof item.encodedInfo !== 'string'
) {
throw new WindchillProviderError(
'Windchill upload Stage 2 returned invalid content info',
502
)
}
return { streamId: item.streamId, fileSize: item.fileSize, encodedInfo: item.encodedInfo }
})
}
export async function uploadWindchillContent({
params,
documentOid,
files,
primaryContent,
signal,
}: {
params: Pick<WindchillParams, 'baseUrl' | 'username' | 'password'>
documentOid: string
files: WindchillUploadFile[]
primaryContent: boolean
signal?: AbortSignal
}): Promise<string[]> {
const session = await createWindchillSession(params, signal)
const documentUrl = windchillDocumentUrl(params.baseUrl, documentOid)
const stageOne = await windchillMutationRequest({
params,
session,
url: `${documentUrl}/PTC.DocMgmt.UploadStage1Action`,
method: 'POST',
body: { NoOfFiles: files.length },
signal,
})
const descriptor = stageOneDescriptor(stageOne)
const multipart = multipartBody(descriptor, files)
const stageTwoResponse = await secureFetchWithValidation(
descriptor.replicaUrl,
{
method: 'POST',
headers: { 'Content-Type': multipart.contentType, Accept: 'application/json' },
body: multipart.body,
maxRedirects: 0,
maxResponseBytes: WINDCHILL_CONTROL_RESPONSE_BYTES,
timeout: WINDCHILL_TIMEOUT_MS,
signal,
},
'ReplicaUrl'
)
const uploaded = stageTwoContent(await checkedBody(stageTwoResponse))
if (uploaded.length !== files.length) {
throw new WindchillProviderError('Windchill upload Stage 2 returned the wrong file count', 502)
}
const byStreamId = new Map(uploaded.map((item) => [String(item.streamId), item]))
const contentInfo = descriptor.streamIds.map((streamId, index) => {
const uploadedItem = byStreamId.get(String(streamId))
if (!uploadedItem) {
throw new WindchillProviderError('Windchill upload Stage 2 omitted a file', 502)
}
return {
StreamId: streamId,
EncodedInfo: uploadedItem.encodedInfo,
FileName: files[index].name,
PrimaryContent: primaryContent,
MimeType: files[index].mimeType,
FileSize: uploadedItem.fileSize,
}
})
await windchillMutationRequest({
params,
session,
url: `${documentUrl}/PTC.DocMgmt.UploadStage3Action`,
method: 'POST',
body: { ContentInfo: contentInfo },
signal,
})
return files.map((file) => file.name)
}
/**
* Resolves the signed vault URL that serves a content item's bytes.
*
* Windchill has no OData media-stream segment for content. The documented path is a typed
* navigation to `Content/URL`, which returns a short-lived signed WindchillGW/WindchillAuthGW
* download URL on the same origin as the service root.
*/
export async function resolveWindchillContentUrl({
params,
contentPath,
signal,
}: {
params: Pick<WindchillParams, 'baseUrl' | 'username' | 'password'>
contentPath: string
signal?: AbortSignal
}): Promise<string> {
const response = await secureFetchWithValidation(
`${contentPath}/PTC.ApplicationData/Content/URL`,
{
method: 'GET',
headers: {
Authorization: createBasicAuthHeader(params.username, params.password),
Accept: 'application/json',
},
maxRedirects: 0,
maxResponseBytes: WINDCHILL_CONTROL_RESPONSE_BYTES,
timeout: WINDCHILL_TIMEOUT_MS,
signal,
},
'baseUrl'
)
const data = await checkedBody(response)
const value = isRecordLike(data) ? data.value : null
if (typeof value !== 'string' || value.length === 0) {
throw new WindchillProviderError('Windchill did not return a content download URL', 502)
}
const serviceRoot = new URL(normalizeServiceRoot(params.baseUrl))
let resolved: URL
try {
resolved = new URL(value, `${serviceRoot.toString()}/`)
} catch {
throw new WindchillProviderError('Windchill returned an invalid content download URL', 502)
}
if (
resolved.protocol !== 'https:' ||
resolved.origin !== serviceRoot.origin ||
resolved.username ||
resolved.password ||
resolved.hash
) {
throw new WindchillProviderError(
'Windchill content download URL must remain on the configured HTTPS origin',
502
)
}
return resolved.toString()
}
export async function downloadWindchillContent({
params,
url,
maxBytes,
signal,
}: {
params: Pick<WindchillParams, 'username' | 'password'>
url: string
maxBytes: number
signal?: AbortSignal
}): Promise<{
buffer: Buffer
contentType: string
contentDisposition: string | null
}> {
const response = await secureFetchWithValidation(
url,
{
method: 'GET',
headers: { Authorization: createBasicAuthHeader(params.username, params.password) },
stripAuthOnRedirect: true,
maxResponseBytes: maxBytes,
timeout: WINDCHILL_TIMEOUT_MS,
signal,
},
'contentUrl'
)
if (!response.ok) await checkedBody(response)
return {
buffer: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get('content-type') || 'application/octet-stream',
contentDisposition: response.headers.get('content-disposition'),
}
}
+562
View File
@@ -0,0 +1,562 @@
import { getErrorMessage } from '@sim/utils/errors'
import { isRecordLike, omit } from '@sim/utils/object'
import {
type WindchillOperationResponse,
windchillOperationResponseSchema,
} from '@/lib/api/contracts/tools/windchill'
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
import type {
WindchillContent,
WindchillDocument,
WindchillDocumentUsageLink,
WindchillOperation,
WindchillOutput,
WindchillParams,
WindchillResponse,
WindchillStateTransition,
} from '@/tools/windchill/types'
/**
* Deepest `DocUsageLinks` expansion this integration ever requests, and therefore the deepest
* nesting it will normalize. Bounding the walk keeps a pathologically nested provider response
* from recursing without limit.
*/
const MAX_STRUCTURE_DEPTH = 3
/** Windchill's documented maximum server page size (`Prefer: odata.maxpagesize`). */
const MAX_PAGE_SIZE = 2000
/** Page size requested when the caller does not choose one. */
const DEFAULT_PAGE_SIZE = 200
function stringValue(record: Record<string, unknown>, key: string): string | null {
return typeof record[key] === 'string' ? record[key] : null
}
function numberValue(record: Record<string, unknown>, key: string): number | null {
const value = record[key]
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function booleanValue(record: Record<string, unknown>, key: string): boolean | null {
return typeof record[key] === 'boolean' ? record[key] : null
}
const DOCUMENT_RESPONSE_PROPERTIES = [
'ID',
'Name',
'Number',
'Title',
'Description',
'State',
'VersionID',
'Revision',
'Version',
'Latest',
'CheckoutState',
'FolderName',
'FolderLocation',
] as const
const CONTENT_RESPONSE_PROPERTIES = [
'ID',
'FileName',
'Description',
'Format',
'MimeType',
'FileSize',
'@odata.type',
'DisplayName',
'UrlLocation',
'ExternalLocation',
] as const
function hasResponseProperty(
value: Record<string, unknown>,
properties: readonly string[]
): boolean {
return properties.some((property) => Object.hasOwn(value, property))
}
export function normalizeWindchillDocument(value: unknown): WindchillDocument | null {
if (!isRecordLike(value) || !hasResponseProperty(value, DOCUMENT_RESPONSE_PROPERTIES)) return null
const state = value.State
return {
id: stringValue(value, 'ID'),
name: stringValue(value, 'Name'),
number: stringValue(value, 'Number'),
title: stringValue(value, 'Title'),
description: stringValue(value, 'Description'),
state:
(isRecordLike(state) ? stringValue(state, 'Value') : null) ?? stringValue(value, 'State'),
stateDisplay: isRecordLike(state) ? stringValue(state, 'Display') : null,
versionId: stringValue(value, 'VersionID'),
revision: stringValue(value, 'Revision'),
version: stringValue(value, 'Version'),
latest: booleanValue(value, 'Latest'),
checkoutState: stringValue(value, 'CheckoutState'),
folderName: stringValue(value, 'FolderName'),
folderLocation: stringValue(value, 'FolderLocation'),
}
}
export function normalizeWindchillContent(value: unknown): WindchillContent | null {
if (!isRecordLike(value) || !hasResponseProperty(value, CONTENT_RESPONSE_PROPERTIES)) return null
return {
id: stringValue(value, 'ID'),
fileName: stringValue(value, 'FileName'),
description: stringValue(value, 'Description'),
format: stringValue(value, 'Format'),
mimeType: stringValue(value, 'MimeType'),
fileSize: numberValue(value, 'FileSize'),
contentType: stringValue(value, '@odata.type'),
displayName: stringValue(value, 'DisplayName'),
urlLocation: stringValue(value, 'UrlLocation'),
externalLocation: stringValue(value, 'ExternalLocation'),
}
}
/**
* Redacts this integration's own transport credentials out of an error string.
*
* Targets exactly three artifacts Sim itself puts on the wire: the `Basic` header built by
* {@link createBasicAuthHeader}, the CSRF nonce pair from `createWindchillSession`, and the
* short-lived signed vault URL from `resolveWindchillContentUrl`, whose bearer token lives in the
* query string. None of these can reach the shared resolved-secret registry — it matches
* `{{...}}`-resolved plaintext, never a base64 derivative or a provider-issued token.
*
* Applied to error strings only. Successful provider payloads are returned untouched.
*/
export function sanitizeWindchillError(message: string): string {
return message
.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => sanitizeUrlForLog(url, 512))
.replace(/\b(?:CSRF_NONCE|NonceValue|NonceKey)\b\s*[:=]\s*\S+/gi, '[redacted nonce]')
.replace(/\bBasic\s+[A-Za-z0-9+/=]+/gi, 'Basic [redacted]')
.slice(0, 4096)
}
function collection(value: unknown): unknown[] {
if (Array.isArray(value)) return value
if (isRecordLike(value) && Array.isArray(value.value)) return value.value
return []
}
function requiredCollection(value: unknown, operation: WindchillOperation): unknown[] {
if (Array.isArray(value)) return value
if (isRecordLike(value) && Array.isArray(value.value)) return value.value
throw new Error(`Windchill returned an incompatible response for ${operation}`)
}
function requireAllNormalized<T>(
values: unknown[],
normalize: (value: unknown) => T | null,
operation: WindchillOperation
): T[] {
const normalized = values.map(normalize)
if (normalized.some((value) => value === null)) {
throw new Error(`Windchill returned an incompatible response for ${operation}`)
}
return normalized as T[]
}
function normalizeState(value: unknown): WindchillStateTransition | null {
if (
!isRecordLike(value) ||
(!Object.hasOwn(value, 'Value') && !Object.hasOwn(value, 'Display'))
) {
return null
}
return {
value: stringValue(value, 'Value'),
display: stringValue(value, 'Display'),
}
}
function normalizeUsageLink(
value: unknown,
parentFallback: WindchillDocument | null = null,
depth = 0
): WindchillDocumentUsageLink | null {
if (
!isRecordLike(value) ||
(!Object.hasOwn(value, 'ID') &&
!Object.hasOwn(value, 'DocUsedBy') &&
!Object.hasOwn(value, 'DocUses'))
) {
return null
}
const childValue = value.DocUses
const child = normalizeWindchillDocument(childValue)
const children =
isRecordLike(childValue) && depth < MAX_STRUCTURE_DEPTH
? collection(childValue.DocUsageLinks)
.map((link) => normalizeUsageLink(link, child, depth + 1))
.filter((link): link is WindchillDocumentUsageLink => link !== null)
: []
return {
id: stringValue(value, 'ID'),
parent: normalizeWindchillDocument(value.DocUsedBy) ?? parentFallback,
child,
children,
}
}
export function normalizeWindchillDocuments(value: unknown): WindchillDocument[] {
return collection(value)
.map(normalizeWindchillDocument)
.filter((document): document is WindchillDocument => document !== null)
}
export function windchillPageInfo(value: unknown, pageCount: number) {
const record = isRecordLike(value) ? value : {}
return {
count: pageCount,
totalCount: numberValue(record, '@odata.count'),
nextLink: stringValue(record, '@odata.nextLink'),
}
}
export function normalizeServiceRoot(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, '')
const parsed = new URL(trimmed)
if (parsed.protocol !== 'https:') throw new Error('Windchill base URL must use HTTPS')
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error('Windchill base URL must not include credentials, query parameters, or a hash')
}
if (!/\/servlet\/odata\/v\d+$/i.test(parsed.pathname)) {
throw new Error('Windchill base URL must end with a versioned /servlet/odata/vN path')
}
return parsed.toString().replace(/\/$/, '')
}
export function createBasicAuthHeader(username: string, password: string): string {
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
}
export function encodeWindchillOid(oid: string): string {
const trimmed = oid.trim()
if (!/^[A-Za-z0-9_.:-]+$/.test(trimmed)) {
throw new Error('Windchill OID contains unsupported characters')
}
return encodeURIComponent(trimmed)
}
function documentPath(baseUrl: string, oid: string): string {
return `${normalizeServiceRoot(baseUrl)}/DocMgmt/Documents('${encodeWindchillOid(oid)}')`
}
function decodedPathname(pathname: string): string {
try {
return decodeURIComponent(pathname)
} catch {
return pathname
}
}
export function resolveWindchillNextLink(
baseUrl: string,
nextLink: string,
expectedCollectionUrl: string
): string {
const serviceRoot = new URL(normalizeServiceRoot(baseUrl))
const resolved = new URL(nextLink, `${serviceRoot.toString()}/`)
const expected = new URL(expectedCollectionUrl)
if (
resolved.protocol !== 'https:' ||
resolved.origin !== serviceRoot.origin ||
resolved.username ||
resolved.password ||
resolved.hash
) {
throw new Error('Windchill nextLink must remain on the configured HTTPS origin')
}
if (
expected.origin !== serviceRoot.origin ||
decodedPathname(resolved.pathname) !== decodedPathname(expected.pathname)
) {
throw new Error('Windchill nextLink must continue the originating collection')
}
return resolved.toString()
}
function structureExpand(depth: number): string {
let child = 'DocUses'
for (let level = 1; level < depth; level += 1) {
child = `DocUses($expand=DocUsageLinks($expand=${child}))`
}
return `DocUsedBy,${child}`
}
/**
* Serializes a built OData URL.
*
* `URLSearchParams` uses the form-urlencoded serializer, which writes a space as `+`. OData
* readers percent-decode but never form-decode, so `$orderby=Name desc` would reach Windchill as
* the literal `Name+desc`. Re-encoding the query's `+` as `%20` keeps multi-token `$filter` and
* `$orderby` expressions intact.
*/
function serializeODataUrl(url: URL): string {
if (!url.search) return url.toString()
return `${url.origin}${url.pathname}${url.search.replace(/\+/g, '%20')}`
}
/** Treats a cleared subblock (`''`) exactly like an absent one. */
function isBlank(value: unknown): boolean {
return value === undefined || value === null || value === ''
}
function integerInRange(value: number, field: string, minimum: number, maximum?: number): number {
if (!Number.isInteger(value) || value < minimum || (maximum !== undefined && value > maximum)) {
const range =
maximum === undefined ? `at least ${minimum}` : `between ${minimum} and ${maximum}`
throw new Error(`Windchill ${field} must be an integer ${range}`)
}
return value
}
const DOCUMENT_SELECT_PROPERTIES = new Set([
'ID',
'Name',
'Number',
'Title',
'Description',
'State',
'VersionID',
'Revision',
'Version',
'Latest',
'CheckoutState',
'FolderName',
'FolderLocation',
])
function normalizedSelect(select: string): string {
const properties = select
.split(',')
.map((property) => property.trim())
.filter(Boolean)
if (
properties.length === 0 ||
properties.some((property) => !DOCUMENT_SELECT_PROPERTIES.has(property))
) {
throw new Error(
`Windchill select supports only normalized document properties: ${[...DOCUMENT_SELECT_PROPERTIES].join(', ')}`
)
}
return [...new Set(properties)].join(',')
}
export function buildWindchillReadUrl(
operation: WindchillOperation,
params: WindchillParams
): string {
const root = normalizeServiceRoot(params.baseUrl)
if (operation === 'windchill_list_documents') {
const url = new URL(`${root}/DocMgmt/Documents`)
if (params.nextLink) return resolveWindchillNextLink(root, params.nextLink, url.toString())
if (params.select) url.searchParams.set('$select', normalizedSelect(params.select))
if (params.filter) url.searchParams.set('$filter', params.filter.trim())
if (params.orderBy) url.searchParams.set('$orderby', params.orderBy.trim())
if (!isBlank(params.top)) {
url.searchParams.set(
'$top',
String(integerInRange(params.top as number, 'top', 1, MAX_PAGE_SIZE))
)
}
if (!isBlank(params.skip)) {
url.searchParams.set('$skip', String(integerInRange(params.skip as number, 'skip', 0)))
}
if (!isBlank(params.count)) url.searchParams.set('$count', String(params.count))
if (!isBlank(params.latestVersion)) {
url.searchParams.set('ptc.search.latestversion', String(params.latestVersion))
}
return serializeODataUrl(url)
}
if (!params.documentOid) throw new Error('Document OID is required')
const path = documentPath(root, params.documentOid)
if (operation === 'windchill_get_document') {
const url = new URL(path)
if (params.select) url.searchParams.set('$select', normalizedSelect(params.select))
return serializeODataUrl(url)
}
if (operation === 'windchill_get_document_structure') {
const depth = integerInRange(
isBlank(params.structureDepth) ? 1 : (params.structureDepth as number),
'structureDepth',
1,
MAX_STRUCTURE_DEPTH
)
const url = new URL(`${path}/DocUsageLinks`)
if (params.nextLink) return resolveWindchillNextLink(root, params.nextLink, url.toString())
url.searchParams.set('$expand', structureExpand(depth))
return serializeODataUrl(url)
}
if (operation === 'windchill_get_valid_state_transitions') {
return `${path}/PTC.DocMgmt.GetValidStateTransitions()`
}
if (operation === 'windchill_get_primary_content') return `${path}/PrimaryContent`
if (operation === 'windchill_list_attachments') {
const url = `${path}/Attachments`
return params.nextLink ? resolveWindchillNextLink(root, params.nextLink, url) : url
}
throw new Error(`Operation ${operation} is not a direct Windchill read`)
}
export function normalizeWindchillReadOutput(
operation: WindchillOperation,
value: unknown
): WindchillOutput {
if (operation === 'windchill_list_documents') {
const documents = requireAllNormalized(
requiredCollection(value, operation),
normalizeWindchillDocument,
operation
)
return { operation, documents, pageInfo: windchillPageInfo(value, documents.length) }
}
if (operation === 'windchill_get_document') {
const document = normalizeWindchillDocument(value)
if (!document) throw new Error(`Windchill returned an incompatible response for ${operation}`)
return { operation, document }
}
if (operation === 'windchill_get_document_structure') {
const structure = requireAllNormalized(
requiredCollection(value, operation),
(link) => normalizeUsageLink(link),
operation
)
return { operation, structure, pageInfo: windchillPageInfo(value, structure.length) }
}
if (operation === 'windchill_get_valid_state_transitions') {
const states = requireAllNormalized(
requiredCollection(value, operation),
normalizeState,
operation
)
return { operation, states }
}
if (operation === 'windchill_get_primary_content') {
const contentValues =
Array.isArray(value) || (isRecordLike(value) && Array.isArray(value.value))
? requiredCollection(value, operation)
: null
if (contentValues?.length === 0) return { operation, content: null }
const content = normalizeWindchillContent(contentValues ? contentValues[0] : value)
if (!content) throw new Error(`Windchill returned an incompatible response for ${operation}`)
return { operation, content }
}
if (operation === 'windchill_list_attachments') {
const attachments = requireAllNormalized(
requiredCollection(value, operation),
normalizeWindchillContent,
operation
)
return { operation, attachments, pageInfo: windchillPageInfo(value, attachments.length) }
}
throw new Error(`Operation ${operation} does not have a direct-read response transform`)
}
/**
* Builds the internal-route body.
*
* Cleared subblocks arrive as `''`. The executor merges the raw block inputs before the block's
* own param transform, so a cleared optional field cannot be dropped upstream — strip blanks here,
* where every internal-route tool passes through, rather than trusting the caller.
*/
export function buildWindchillInternalBody(operation: WindchillOperation, params: WindchillParams) {
const supplied = Object.entries(omit(params, ['_context'])).filter(([, value]) => value !== '')
return { ...Object.fromEntries(supplied), operation }
}
function providerError(value: unknown, fallback: string): string {
if (!isRecordLike(value)) return fallback
if (typeof value.message === 'string') return value.message
if (isRecordLike(value.error)) {
if (typeof value.error.message === 'string') return value.error.message
if (isRecordLike(value.error.message) && typeof value.error.message.value === 'string') {
return value.error.message.value
}
}
return fallback
}
export function windchillReadHeaders(params: WindchillParams) {
return {
Authorization: createBasicAuthHeader(params.username, params.password),
Accept: 'application/json',
Prefer: `odata.maxpagesize=${isBlank(params.top) ? DEFAULT_PAGE_SIZE : params.top}`,
}
}
export async function transformWindchillDirectRead(
operation: WindchillOperation,
response: Response
): Promise<WindchillResponse> {
let data: unknown
try {
data = await response.json()
} catch {
if (response.ok) {
throw new Error(`Windchill returned invalid JSON with status ${response.status}`)
}
data = null
}
if (!response.ok) {
throw new Error(
sanitizeWindchillError(
providerError(data, `Windchill request failed with status ${response.status}`)
)
)
}
return { success: true, output: normalizeWindchillReadOutput(operation, data) }
}
export async function transformWindchillInternalResponse(
operation: WindchillOperation,
response: Response
): Promise<WindchillResponse> {
try {
const data: unknown = await response.json()
const parsed = windchillOperationResponseSchema.safeParse(data)
if (!parsed.success) {
return {
success: false,
output: { operation },
error: 'Windchill route returned an invalid response',
}
}
if (!response.ok || !parsed.data.success) {
return {
success: false,
output: { operation },
error:
parsed.data.success === false
? sanitizeWindchillError(parsed.data.error)
: `Windchill request failed with status ${response.status}`,
}
}
const result: WindchillOperationResponse = parsed.data
if (result.output.operation !== operation) {
return {
success: false,
output: { operation },
error: 'Windchill route returned a response for a different operation',
}
}
return { success: true, output: result.output }
} catch (error) {
return {
success: false,
output: { operation },
error: sanitizeWindchillError(
getErrorMessage(error, 'Failed to read Windchill route response')
),
}
}
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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: 1099,
zodRoutes: 1099,
totalRoutes: 1100,
zodRoutes: 1100,
nonZodRoutes: 0,
} as const