Skip to content

Commit e2cf113

Browse files
committed
fix(connectors): correct defects found validating against live API docs
Validated all 10 new connectors against live provider documentation. Fixes below; each was verified against the vendor's published spec. Data loss (sync engine hard-deletes documents past an unflagged cap): - mintlify: an empty discovery (llms.txt AND sitemap both 404, or an HTML-200 llms.txt) returned zero documents without listingCapped, which reconciles every stored document away. Now throws. - zoho-desk: listingCapped never fired at the default caps, since 500/50 and 500/100 are exact multiples and the flag was only set on a mid-page overshoot. - trello: archived-list cards vanished from an unflagged listing, and listingCapped latched permanently on any list of 1000+ cards. - google-vault: a mid-sync config change left the cursor with no kind, discarding the rest of the matters page without the flag. - pagerduty: more=true with zero incidents ended the listing unflagged. Correctness: - zoho-desk: desk.zoho.ca does not resolve; Canada is desk.zohocloud.ca. Added the missing SG and AE data centers. - zoho-desk: modifiedTime is absent from GET /tickets, so the stub and hydrated hashes never matched and every ticket re-embedded each sync. - trello: dateLastActivity is documented to miss some edits, so the hash now folds in the badges counters that arrive with the listing. - microsoft-dataverse: every knowledge article has an internal root container that also sets islatestversion, duplicating every document. - pagerduty: the 10,000-record ceiling bounds offset + limit, so the guard admitted a request that 400s after any short page. - sftp: the incremental cutoff compared against a remote clock, silently skipping same-second writes on every subsequent sync. - box: extracted_text gave up before polling when a representation had not been generated yet. - mintlify: a site whose .md route 404s hydrated every page to nothing while validateConfig still passed. Security: - microsoft-dataverse: environmentUrl accepted any public HTTPS host while every request attaches a bearer token. Pinned to Microsoft's Dataverse domains. - sftp: getDocument used stat, which follows symlinks, so a link under rootPath resolved to its target. Now lstat. - sftp: filenames from readdir were composed into paths unchecked. - trello: user-supplied board ids were interpolated into URL paths raw. Resource bounds: - microsoft-excel: the usedRange metadata read was unbounded, so a silently-ignored $select would have buffered the whole grid. - sftp: no read timeout (readyTimeout covers only the handshake), an unbounded pending-directory queue, and oversized files growing the listing without limit. - microsoft-excel: getDocument refetched the workbook and sheet list per worksheet. Coverage: box file extensions, trello attachments and members, zoho-desk ticket resolution, google-slides WordArt, google-vault Chat holds, mintlify sitemap-index following.
1 parent c36c00d commit e2cf113

15 files changed

Lines changed: 852 additions & 247 deletions

File tree

apps/sim/connectors/box/box.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,17 @@ const PLAIN_TEXT_EXTENSIONS = new Set([
108108
])
109109

110110
/**
111-
* Binary/proprietary formats for which Box documents an `extracted_text`
112-
* representation (see the representation "Supported File Types" table). Content
113-
* for these is pulled from the representation rather than the raw bytes.
111+
* Formats Box lists with `Text? = Yes` in the representation "Supported File
112+
* Types" table whose raw bytes are not usable as UTF-8 (binary, proprietary, or
113+
* markup-heavy). Content for these is pulled from the `extracted_text`
114+
* representation rather than from `GET /files/:id/content`.
114115
*/
115116
const REPRESENTATION_EXTENSIONS = new Set([
116117
'boxcanvas',
117118
'boxnote',
118119
'doc',
119120
'docx',
121+
'fdx',
120122
'gdoc',
121123
'gsheet',
122124
'gslide',
@@ -130,6 +132,8 @@ const REPRESENTATION_EXTENSIONS = new Set([
130132
'ppt',
131133
'pptx',
132134
'rtf',
135+
'vi',
136+
'webdoc',
133137
'wpd',
134138
'xbd',
135139
'xdw',
@@ -329,14 +333,13 @@ async function fetchExtractedText(
329333
accessToken: string,
330334
entry: BoxRepresentationEntry
331335
): Promise<string | null> {
332-
const urlTemplate = entry.content?.url_template
333336
const infoUrl = entry.info?.url
334-
if (!urlTemplate) return null
335-
337+
let urlTemplate = entry.content?.url_template
336338
let state = entry.status?.state
339+
if (!urlTemplate && !infoUrl) return null
337340

338341
for (let attempt = 0; attempt <= REPRESENTATION_POLL_ATTEMPTS; attempt++) {
339-
if (state === 'success' || state === 'viewable') {
342+
if ((state === 'success' || state === 'viewable') && urlTemplate) {
340343
const buffer = await downloadWithinLimit(
341344
urlTemplate.replace('{+asset_path}', ''),
342345
accessToken
@@ -358,6 +361,7 @@ async function fetchExtractedText(
358361
} else {
359362
const info = (await response.json()) as BoxRepresentationEntry
360363
state = info.status?.state ?? 'pending'
364+
urlTemplate = info.content?.url_template ?? urlTemplate
361365
}
362366

363367
if (state !== 'success' && state !== 'viewable') {

apps/sim/connectors/google-slides/google-slides.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@ interface SlidesTableRow {
5454

5555
/**
5656
* A page element on a slide. Exactly one of the visual properties is set;
57-
* only `shape`, `table`, and `elementGroup` can carry extractable text.
57+
* only `shape`, `table`, `wordArt`, and `elementGroup` can carry extractable
58+
* text — `image`, `video`, `line`, `sheetsChart`, and `speakerSpotlight` do not.
5859
*/
5960
interface SlidesPageElement {
6061
objectId?: string
6162
shape?: { text?: SlidesTextContent }
6263
table?: { tableRows?: SlidesTableRow[] }
64+
wordArt?: { renderedText?: string }
6365
elementGroup?: { children?: SlidesPageElement[] }
6466
}
6567

@@ -108,6 +110,9 @@ function collectElementText(
108110
const shapeText = extractTextContent(element.shape?.text)
109111
if (shapeText.trim()) parts.push(shapeText)
110112

113+
const wordArtText = element.wordArt?.renderedText
114+
if (wordArtText?.trim()) parts.push(wordArtText.trim())
115+
111116
const rows = element.table?.tableRows
112117
if (rows) {
113118
for (const row of rows) {
@@ -443,8 +448,10 @@ export const googleSlidesConnector: ConnectorConfig = {
443448
} else {
444449
const probeParams = new URLSearchParams({
445450
pageSize: '1',
446-
q: `mimeType = '${PRESENTATION_MIME_TYPE}'`,
451+
q: `trashed = false and mimeType = '${PRESENTATION_MIME_TYPE}'`,
447452
fields: 'files(id)',
453+
supportsAllDrives: 'true',
454+
includeItemsFromAllDrives: 'true',
448455
})
449456
const response = await fetchWithRetry(
450457
`https://www.googleapis.com/drive/v3/files?${probeParams.toString()}`,

apps/sim/connectors/google-vault/google-vault.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ function renderHold(hold: VaultHold, matterId: string): string {
220220
String(hold.query.driveQuery.includeSharedDriveFiles)
221221
)
222222
}
223+
if (hold.query?.hangoutsChatQuery?.includeRooms !== undefined) {
224+
appendLine(lines, 'Includes chat spaces', String(hold.query.hangoutsChatQuery.includeRooms))
225+
}
223226
const coveredData = hold.query?.voiceQuery?.coveredData
224227
if (coveredData && coveredData.length > 0) {
225228
appendLine(lines, 'Covered Voice data', coveredData.join(', '))
@@ -474,13 +477,28 @@ export const googleVaultConnector: ConnectorConfig = {
474477
}
475478
} else {
476479
const matterIds = state.matterIds ?? []
477-
const matterId = matterIds[state.matterIndex ?? 0]
480+
const matterIndex = state.matterIndex ?? 0
481+
const matterId = matterIds[matterIndex]
478482
const kind = kinds[state.kindIndex ?? 0]
479483

480-
if (!matterId || !kind) {
484+
if (!matterId || kinds.length === 0) {
481485
nextState = state.nextMattersPageToken
482486
? { phase: 'matters', mattersPageToken: state.nextMattersPageToken }
483487
: undefined
488+
} else if (!kind) {
489+
/**
490+
* The enabled child kinds shrank while a cursor was in flight (the user
491+
* turned holds or saved queries off mid-sync), so the remaining children of
492+
* this matter are not listed. Skipping them silently would let deletion
493+
* reconciliation hard-delete still-existing documents.
494+
*/
495+
if (syncContext) syncContext.listingCapped = true
496+
nextState =
497+
matterIndex + 1 < matterIds.length
498+
? { ...state, matterIndex: matterIndex + 1, kindIndex: 0, childPageToken: undefined }
499+
: state.nextMattersPageToken
500+
? { phase: 'matters', mattersPageToken: state.nextMattersPageToken }
501+
: undefined
484502
} else {
485503
try {
486504
const page = await fetchChildPage(accessToken, matterId, kind, state.childPageToken)

apps/sim/connectors/microsoft-dataverse/meta.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export const microsoftDataverseConnectorMeta: ConnectorMeta = {
2323
type: 'short-input',
2424
required: true,
2525
placeholder: 'https://myorg.crm.dynamics.com',
26-
description: 'The base URL of your Dataverse or Dynamics 365 environment.',
26+
description:
27+
'The base URL of your Dataverse or Dynamics 365 environment, e.g. https://myorg.crm.dynamics.com.',
2728
},
2829
{
2930
id: 'tableName',
@@ -45,8 +46,9 @@ export const microsoftDataverseConnectorMeta: ConnectorMeta = {
4546
title: 'OData Filter',
4647
type: 'short-input',
4748
required: false,
48-
placeholder: 'e.g. statecode eq 0',
49-
description: 'Optional OData $filter expression appended to the connector filter.',
49+
placeholder: 'e.g. statecode eq 3',
50+
description:
51+
'Optional OData $filter expression combined with the connector filter. Knowledge articles sync in every state — add statecode eq 3 to index only published ones.',
5052
},
5153
{
5254
id: 'maxRecords',

apps/sim/connectors/microsoft-dataverse/microsoft-dataverse.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ interface DataverseTable {
5151
numberField?: string
5252
/** Option-set column whose formatted value is surfaced as the status tag. */
5353
statusField?: string
54-
/** Connector-owned filter always applied to listings and hydrations. */
54+
/**
55+
* Connector-owned `$filter` applied to every listing. `getDocument` retrieves by
56+
* primary key, which takes no filter, so this only ever shapes which records the
57+
* listing surfaces.
58+
*/
5559
baseFilter?: string
5660
}
5761

@@ -71,16 +75,23 @@ const TABLES: Record<string, DataverseTable> = {
7175
htmlFields: ['content'],
7276
numberField: 'articlepublicnumber',
7377
statusField: 'statecode',
74-
// Every published revision is stored as its own row; only the latest is indexable.
75-
baseFilter: 'islatestversion eq true',
78+
/**
79+
* Every article version is its own row, and Dataverse additionally creates one
80+
* hidden root record per article that acts as the container for all versions and
81+
* translations. Indexing either produces duplicates of the same article, so the
82+
* listing keeps only the latest non-root version.
83+
*
84+
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/knowledgearticle
85+
*/
86+
baseFilter: 'islatestversion eq true and isrootarticle eq false',
7687
},
7788
annotation: {
7889
entitySet: 'annotations',
7990
logicalName: 'annotation',
8091
idField: 'annotationid',
8192
titleField: 'subject',
8293
untitled: 'Untitled Note',
83-
listFields: ['annotationid', 'subject', 'modifiedon', 'objecttypecode'],
94+
listFields: ['annotationid', 'subject', 'modifiedon'],
8495
contentFields: ['notetext', 'filename', 'mimetype'],
8596
// Rich-text notes are stored as HTML; plain notes pass through unchanged.
8697
htmlFields: ['notetext'],
@@ -157,15 +168,31 @@ const FIELD_LABELS: Record<string, string> = {
157168
/** Raised when the user-supplied environment URL is structurally unusable. */
158169
class InvalidEnvironmentUrlError extends Error {}
159170

171+
/**
172+
* Registrable domains Microsoft serves Dataverse environments from — commercial and
173+
* regional clouds (`*.crm[N].dynamics.com`), China (21Vianet), US Government and DoD,
174+
* and the legacy German cloud. Matching on the registrable domain rather than each
175+
* regional `crmN` prefix keeps new Microsoft regions working without a code change.
176+
*/
177+
const DATAVERSE_HOST_SUFFIXES = [
178+
'.dynamics.com',
179+
'.dynamics.cn',
180+
'.dynamics.de',
181+
'.microsoftdynamics.us',
182+
'.appsplatform.us',
183+
] as const
184+
160185
/**
161186
* Normalizes a user-supplied Dataverse environment URL to a bare `https://host` origin.
162187
*
163-
* The value reaches the connector straight from the add-connector modal, so it is
164-
* validated structurally here and every request built from it goes through
165-
* `secureFetchWithRetry` (DNS + private-IP rejection + IP-pinned connection).
188+
* The value reaches the connector straight from the add-connector modal and every
189+
* request built from it carries the user's OAuth bearer token, so the host is pinned
190+
* to Microsoft's Dataverse domains — otherwise an arbitrary origin here would receive
191+
* that token. Requests additionally go through `secureFetchWithRetry` (DNS +
192+
* private-IP rejection + IP-pinned connection).
166193
*
167194
* @throws {InvalidEnvironmentUrlError} when the value is empty, non-HTTPS, carries
168-
* credentials, or is not a parseable absolute URL.
195+
* credentials, is not a parseable absolute URL, or is not a Dataverse host.
169196
*/
170197
function normalizeEnvironmentUrl(raw: unknown): string {
171198
const value = typeof raw === 'string' ? raw.trim() : ''
@@ -188,8 +215,11 @@ function normalizeEnvironmentUrl(raw: unknown): string {
188215
if (parsed.username || parsed.password) {
189216
throw new InvalidEnvironmentUrlError('Environment URL must not contain credentials')
190217
}
191-
if (!parsed.hostname) {
192-
throw new InvalidEnvironmentUrlError('Environment URL must include a host')
218+
const hostname = parsed.hostname.toLowerCase()
219+
if (!DATAVERSE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) {
220+
throw new InvalidEnvironmentUrlError(
221+
'Environment URL must be a Dataverse environment, e.g. https://myorg.crm.dynamics.com'
222+
)
193223
}
194224

195225
return parsed.origin
@@ -452,8 +482,11 @@ export const microsoftDataverseConnector: ConnectorConfig = {
452482
return null
453483
}
454484

455-
const select = [...table.listFields, ...table.contentFields].join(',')
456-
const url = `${environmentUrl}/api/data/${API_VERSION}/${table.entitySet}(${externalId})?$select=${encodeURIComponent(select)}`
485+
// Selects every listing column too: `recordToStub` rebuilds the stub from this
486+
// record, so `contentHash` only stays stable if `modifiedon` comes back here.
487+
const params = new URLSearchParams()
488+
params.set('$select', [...table.listFields, ...table.contentFields].join(','))
489+
const url = `${environmentUrl}/api/data/${API_VERSION}/${table.entitySet}(${externalId})?${params.toString()}`
457490

458491
const response = await dataverseGet(url, accessToken, false)
459492

apps/sim/connectors/microsoft-excel/meta.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ export const microsoftExcelConnectorMeta: ConnectorMeta = {
2020
title: 'Drive ID (SharePoint)',
2121
type: 'short-input',
2222
required: false,
23-
placeholder: 'Leave empty for OneDrive',
23+
placeholder: 'Leave empty for your own OneDrive',
2424
description:
25-
'The SharePoint document library (drive) ID holding the workbook. Leave empty to use your personal OneDrive.',
25+
'The SharePoint document library (drive) ID holding the workbook. Leave empty to use your own OneDrive for Business. Workbooks stored in consumer OneDrive are not supported by the Excel API.',
2626
},
2727
{
2828
id: 'spreadsheetSelector',

0 commit comments

Comments
 (0)