This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -92,15 +92,15 @@ export class GoogleCalendarConnector extends BaseConnector {
private clientId: string | null = null;
private clientSecret: string | null = null;
private vault: VaultStore | null = null;
private credentialGeneration = 0;
async connect(vault: VaultStore): Promise<void> {
this.credentialGeneration += 1;
this.vault = vault;
const cred = vault.getConnectorCredential(this.id);
if (cred) {
this.accessToken = cred.value;
this.refreshToken = cred.refreshToken ?? null;
this.expiresAt = cred.expiresAt ?? null;
}
this.accessToken = cred?.value ?? null;
this.refreshToken = cred?.refreshToken ?? null;
this.expiresAt = cred?.expiresAt ?? null;
const clientIdEntry = vault.get(`connector:${this.id}:client_id`);
this.clientId = clientIdEntry?.value ?? null;
@@ -170,13 +170,18 @@ export class GoogleCalendarConnector extends BaseConnector {
throw new Error('Cannot refresh token — missing refresh_token, client_id, or client_secret');
}
const credentialGeneration = this.credentialGeneration;
const accessToken = this.accessToken;
const refreshToken = this.refreshToken;
const vault = this.vault;
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
refresh_token: this.refreshToken,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
signal: AbortSignal.timeout(10000),
@@ -185,13 +190,23 @@ export class GoogleCalendarConnector extends BaseConnector {
if (!res.ok) throw new Error(`Token refresh failed: ${res.status}`);
const data = await res.json() as { access_token: string; expires_in: number; refresh_token?: string };
const currentCredential = vault?.getConnectorCredential(this.id);
if (
this.credentialGeneration !== credentialGeneration
|| !currentCredential
|| currentCredential.value !== accessToken
|| (currentCredential.refreshToken ?? null) !== refreshToken
) {
throw new Error('Connector credentials changed during token refresh');
}
this.accessToken = data.access_token;
this.expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();
if (data.refresh_token) this.refreshToken = data.refresh_token;
// Persist updated tokens back to vault
if (this.vault) {
this.vault.setConnectorCredential(this.id, {
if (vault) {
vault.setConnectorCredential(this.id, {
type: 'oauth2',
value: this.accessToken,
refreshToken: this.refreshToken ?? undefined,

View File

@@ -5,9 +5,41 @@
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
import type { VaultStore } from '@waggle/core';
import type { ConnectorHealth } from '@waggle/shared';
import type { ConnectorDefinition, ConnectorHealth, ConnectorStatus } from '@waggle/shared';
export class JiraConnector extends BaseConnector {
static normalizeSiteOrigin(value: unknown): string | null {
if (typeof value !== 'string') return null;
const candidate = value.trim();
const originMatch = /^https:\/\/([^/?#]+)\/?$/i.exec(candidate);
if (!originMatch || originMatch[1].includes('@') || originMatch[1].includes(':')) return null;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
const hostname = parsed.hostname.toLowerCase();
const labels = hostname.split('.');
if (
parsed.protocol !== 'https:'
|| parsed.username !== ''
|| parsed.password !== ''
|| parsed.port !== ''
|| parsed.pathname !== '/'
|| parsed.search !== ''
|| parsed.hash !== ''
|| !hostname.endsWith('.atlassian.net')
|| labels.some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
) {
return null;
}
return `https://${hostname}`;
}
readonly id = 'jira';
readonly name = 'Jira';
readonly description = "Manage Jira issues, projects, and sprints. Search issues with JQL, create and update tickets, transition statuses, and add comments across all Jira projects.";
@@ -91,6 +123,13 @@ export class JiraConnector extends BaseConnector {
private authHeader: string | null = null;
private baseUrl: string | null = null;
override toDefinition(status: ConnectorStatus): ConnectorDefinition {
const effectiveStatus = status === 'connected' && (!this.authHeader || !this.baseUrl)
? 'disconnected'
: status;
return super.toDefinition(effectiveStatus);
}
async connect(vault: VaultStore): Promise<void> {
const cred = vault.getConnectorCredential(this.id);
if (!cred) {
@@ -100,15 +139,16 @@ export class JiraConnector extends BaseConnector {
}
const emailEntry = vault.get(`connector:${this.id}:email`);
const email = emailEntry?.value ?? '';
const apiToken = cred.value;
const email = emailEntry?.value.trim() ?? '';
const apiToken = cred.value.trim();
// Jira Cloud uses email:apiToken as basic auth
this.authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`;
// Base URL from vault or default
// Jira Cloud uses email:apiToken as basic auth and only accepts a tenant
// origin under *.atlassian.net.
const urlEntry = vault.get(`connector:${this.id}:base_url`);
this.baseUrl = urlEntry?.value ?? null;
this.baseUrl = JiraConnector.normalizeSiteOrigin(urlEntry?.value);
this.authHeader = email && apiToken && this.baseUrl
? `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`
: null;
}
async healthCheck(): Promise<ConnectorHealth> {

View File

@@ -179,11 +179,20 @@ export class LinearConnector extends BaseConnector {
private async listIssues(params: Record<string, unknown>): Promise<ConnectorResult> {
const first = (params.first as number) ?? 50;
const filter: string[] = [];
if (params.teamId) filter.push(`team: { id: { eq: "${params.teamId}" } }`);
if (params.state) filter.push(`state: { name: { eq: "${params.state}" } }`);
const filterClause = filter.length ? `(filter: { ${filter.join(', ')} }, first: ${first})` : `(first: ${first})`;
return this.graphql(`{ issues${filterClause} { nodes { id identifier title state { name } priority assignee { name } createdAt } } }`);
const filter: Record<string, unknown> = {};
if (params.teamId) filter.team = { id: { eq: params.teamId } };
if (params.state) filter.state = { name: { eq: params.state } };
const hasFilter = Object.keys(filter).length > 0;
const variables: Record<string, unknown> = { first };
if (hasFilter) variables.filter = filter;
const filterDefinition = hasFilter ? ', $filter: IssueFilter' : '';
const filterArgument = hasFilter ? ', filter: $filter' : '';
return this.graphql(
`query ListIssues($first: Int${filterDefinition}) { issues(first: $first${filterArgument}) { nodes { id identifier title state { name } priority assignee { name } createdAt } } }`,
variables,
);
}
private async createIssue(params: Record<string, unknown>): Promise<ConnectorResult> {
@@ -227,11 +236,17 @@ export class LinearConnector extends BaseConnector {
private async listProjects(params: Record<string, unknown>): Promise<ConnectorResult> {
const first = (params.first as number) ?? 50;
return this.graphql(`{ projects(first: ${first}) { nodes { id name state startDate targetDate } } }`);
return this.graphql(
`query ListProjects($first: Int) { projects(first: $first) { nodes { id name state startDate targetDate } } }`,
{ first },
);
}
private async listTeams(params: Record<string, unknown>): Promise<ConnectorResult> {
const first = (params.first as number) ?? 50;
return this.graphql(`{ teams(first: ${first}) { nodes { id name key description } } }`);
return this.graphql(
`query ListTeams($first: Int) { teams(first: $first) { nodes { id name key description } } }`,
{ first },
);
}
}

View File

@@ -8,6 +8,7 @@ import type { VaultStore } from '@waggle/core';
import type { ConnectorHealth } from '@waggle/shared';
const API_URL = 'https://api.monday.com/v2';
const BOARD_KINDS = new Set(['public', 'private', 'share']);
export class MondayConnector extends BaseConnector {
readonly id = 'monday';
@@ -167,8 +168,18 @@ export class MondayConnector extends BaseConnector {
private async listBoards(params: Record<string, unknown>): Promise<ConnectorResult> {
const limit = (params.limit as number) ?? 25;
const page = (params.page as number) ?? 1;
const kindFilter = params.board_kind ? `, board_kind: ${params.board_kind}` : '';
return this.graphql(`{ boards(limit: ${limit}, page: ${page}${kindFilter}) { id name state board_kind columns { id title type } groups { id title } } }`);
const boardKind = params.board_kind;
if (boardKind !== undefined && (typeof boardKind !== 'string' || !BOARD_KINDS.has(boardKind))) {
return { success: false, error: 'Invalid board_kind' };
}
const kindDefinition = boardKind ? ', $boardKind: BoardKind' : '';
const kindFilter = boardKind ? ', board_kind: $boardKind' : '';
const variables: Record<string, unknown> = { limit, page };
if (boardKind) variables.boardKind = boardKind;
return this.graphql(
`query ListBoards($limit: Int, $page: Int${kindDefinition}) { boards(limit: $limit, page: $page${kindFilter}) { id name state board_kind columns { id title type } groups { id title } } }`,
variables,
);
}
private async listItems(params: Record<string, unknown>): Promise<ConnectorResult> {
@@ -176,35 +187,52 @@ export class MondayConnector extends BaseConnector {
const boardId = params.boardId;
if (params.groupId) {
return this.graphql(
`{ boards(ids: [${boardId}]) { groups(ids: ["${params.groupId}"]) { items_page(limit: ${limit}) { items { id name column_values { id text value } } } } } }`,
`query ListGroupItems($boardId: ID!, $groupId: String!, $limit: Int) { boards(ids: [$boardId]) { groups(ids: [$groupId]) { items_page(limit: $limit) { items { id name column_values { id text value } } } } } }`,
{ boardId, groupId: params.groupId, limit },
);
}
return this.graphql(
`{ boards(ids: [${boardId}]) { items_page(limit: ${limit}) { items { id name group { id title } column_values { id text value } } } } }`,
`query ListItems($boardId: ID!, $limit: Int) { boards(ids: [$boardId]) { items_page(limit: $limit) { items { id name group { id title } column_values { id text value } } } } }`,
{ boardId, limit },
);
}
private async createItem(params: Record<string, unknown>): Promise<ConnectorResult> {
const { boardId, itemName, groupId, columnValues } = params;
let mutation = `mutation { create_item(board_id: ${boardId}, item_name: "${String(itemName).replace(/"/g, '\\"')}"`;
if (groupId) mutation += `, group_id: "${groupId}"`;
if (columnValues) mutation += `, column_values: ${JSON.stringify(String(columnValues))}`;
mutation += `) { id name } }`;
return this.graphql(mutation);
const definitions = ['$boardId: ID!', '$itemName: String!'];
const arguments_ = ['board_id: $boardId', 'item_name: $itemName'];
const variables: Record<string, unknown> = { boardId, itemName };
if (groupId) {
definitions.push('$groupId: String');
arguments_.push('group_id: $groupId');
variables.groupId = groupId;
}
if (columnValues) {
definitions.push('$columnValues: JSON');
arguments_.push('column_values: $columnValues');
variables.columnValues = columnValues;
}
return this.graphql(
`mutation CreateItem(${definitions.join(', ')}) { create_item(${arguments_.join(', ')}) { id name } }`,
variables,
);
}
private async updateItem(params: Record<string, unknown>): Promise<ConnectorResult> {
const { boardId, itemId, columnValues } = params;
return this.graphql(
`mutation { change_multiple_column_values(board_id: ${boardId}, item_id: ${itemId}, column_values: ${JSON.stringify(String(columnValues))}) { id name } }`,
`mutation UpdateItem($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id name } }`,
{ boardId, itemId, columnValues },
);
}
private async searchItems(params: Record<string, unknown>): Promise<ConnectorResult> {
const limit = (params.limit as number) ?? 25;
const query = String(params.query).replace(/"/g, '\\"');
return this.graphql(
`{ items_page_by_column_values(limit: ${limit}, board_id: 0, columns: [{column_id: "name", column_values: ["${query}"]}]) { items { id name board { id name } column_values { id text value } } } }`,
`query SearchItems($limit: Int, $query: String!) { items_page_by_column_values(limit: $limit, board_id: 0, columns: [{column_id: "name", column_values: [$query]}]) { items { id name board { id name } column_values { id text value } } } }`,
{ limit, query: params.query },
);
}
}

View File

@@ -12,6 +12,13 @@ import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../co
import type { VaultStore } from '@waggle/core';
import type { ConnectorHealth } from '@waggle/shared';
function isContained(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative !== '..'
&& !relative.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relative);
}
export class ObsidianConnector extends BaseConnector {
readonly id = 'obsidian';
readonly name = 'Obsidian';
@@ -143,10 +150,39 @@ export class ObsidianConnector extends BaseConnector {
/** Resolve a relative path safely within the vault directory */
private resolveSafe(relativePath: string): string | null {
const resolved = path.resolve(this.vaultPath!, relativePath);
// Guard against path traversal
if (!resolved.startsWith(this.vaultPath!)) return null;
return resolved;
if (
path.posix.isAbsolute(relativePath)
|| path.win32.isAbsolute(relativePath)
|| relativePath.split(/[\\/]/).some(part => part.includes(':'))
) return null;
const vaultRoot = path.resolve(this.vaultPath!);
const resolved = path.resolve(vaultRoot, relativePath.replace(/[\\/]+/g, path.sep));
if (!isContained(vaultRoot, resolved)) return null;
try {
const realVault = fs.realpathSync.native(vaultRoot);
let existingAncestor = resolved;
while (true) {
try {
fs.lstatSync(existingAncestor);
break;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return null;
const parent = path.dirname(existingAncestor);
if (parent === existingAncestor) return null;
existingAncestor = parent;
}
}
const realAncestor = fs.realpathSync.native(existingAncestor);
if (!isContained(realVault, realAncestor)) return null;
return resolved;
} catch {
// Includes dangling links and races where an ancestor disappears.
return null;
}
}
/** Recursively collect all .md files under a directory */

View File

@@ -5,12 +5,119 @@
*/
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
import { safeFetch } from '../url-egress-guard.js';
import type { VaultStore } from '@waggle/core';
import type { ConnectorHealth } from '@waggle/shared';
import type { ConnectorDefinition, ConnectorHealth, ConnectorStatus } from '@waggle/shared';
const API_VERSION = 'v59.0';
const MAX_LIST_LIMIT = 2_000;
const MAX_SOQL_LENGTH = 20_000;
const MAX_FIELD_LIST_LENGTH = 2_048;
const MAX_FIELDS = 200;
const SALESFORCE_IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]{0,79}$/;
function requireIdentifier(value: unknown, label: string): string {
if (typeof value !== 'string' || !SALESFORCE_IDENTIFIER.test(value)) {
throw new TypeError(`Invalid Salesforce ${label}`);
}
return value;
}
function requireRecordId(value: unknown): string {
if (typeof value !== 'string' || !/^[A-Za-z0-9]{15}(?:[A-Za-z0-9]{3})?$/.test(value)) {
throw new TypeError('Invalid Salesforce record ID');
}
return value;
}
function requireFieldList(value: unknown, defaultFields?: string): string {
const candidate = value === undefined ? defaultFields : value;
if (typeof candidate !== 'string' || candidate.length === 0 || candidate.length > MAX_FIELD_LIST_LENGTH) {
throw new TypeError('Invalid Salesforce field list');
}
const fields = candidate.split(',').map(field => field.trim());
if (
fields.length === 0
|| fields.length > MAX_FIELDS
|| fields.some(field => {
const segments = field.split('.');
return segments.length > 6 || segments.some(segment => !SALESFORCE_IDENTIFIER.test(segment));
})
) {
throw new TypeError('Invalid Salesforce field list');
}
return fields.join(',');
}
function requireFieldMap(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('Invalid Salesforce fields');
}
const prototype = Object.getPrototypeOf(value);
const fields = value as Record<string, unknown>;
const names = Object.keys(fields);
if (
(prototype !== Object.prototype && prototype !== null)
|| names.length === 0
|| names.length > MAX_FIELDS
|| names.some(name => !SALESFORCE_IDENTIFIER.test(name))
) {
throw new TypeError('Invalid Salesforce fields');
}
return fields;
}
function requireListLimit(value: unknown): number {
const limit = value === undefined ? 25 : value;
if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) {
throw new TypeError(`Salesforce limit must be an integer from 1 to ${MAX_LIST_LIMIT}`);
}
return limit;
}
function requireSoqlQuery(value: unknown): string {
if (typeof value !== 'string') throw new TypeError('Invalid Salesforce SOQL query');
const query = value.trim();
if (query.length === 0 || query.length > MAX_SOQL_LENGTH) {
throw new TypeError('Invalid Salesforce SOQL query');
}
return query;
}
export class SalesforceConnector extends BaseConnector {
static normalizeInstanceOrigin(value: unknown): string | null {
if (typeof value !== 'string') return null;
const candidate = value.trim();
const originMatch = /^https:\/\/([^/?#]+)\/?$/i.exec(candidate);
if (!originMatch || originMatch[1].includes('@') || originMatch[1].includes(':')) return null;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
const hostname = parsed.hostname.toLowerCase();
const labels = hostname.split('.');
if (
parsed.protocol !== 'https:'
|| parsed.username !== ''
|| parsed.password !== ''
|| parsed.port !== ''
|| parsed.pathname !== '/'
|| parsed.search !== ''
|| parsed.hash !== ''
|| !hostname.endsWith('.salesforce.com')
|| labels.some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
) {
return null;
}
return `https://${hostname}`;
}
readonly id = 'salesforce';
readonly name = 'Salesforce';
readonly description = "Query and manage Salesforce objects using SOQL. Access leads, contacts, opportunities, accounts, and custom objects with full CRM visibility.";
@@ -31,14 +138,15 @@ export class SalesforceConnector extends BaseConnector {
},
required: ['query'],
},
riskLevel: 'low',
// Arbitrary SOQL can expose any object/field visible to the credential.
riskLevel: 'high',
},
{
name: 'list_contacts',
description: 'List contacts with optional limit',
inputSchema: {
properties: {
limit: { type: 'number', description: 'Max results (default 25)' },
limit: { type: 'number', description: `Max results (default 25, max ${MAX_LIST_LIMIT})` },
fields: { type: 'string', description: 'Comma-separated field names (default: Id,Name,Email,Phone)' },
},
},
@@ -50,7 +158,7 @@ export class SalesforceConnector extends BaseConnector {
inputSchema: {
properties: {
objectType: { type: 'string', description: 'Salesforce object type (e.g., "Contact", "Account", "Lead")' },
recordId: { type: 'string', description: 'Salesforce record ID (18-char)' },
recordId: { type: 'string', description: 'Salesforce record ID (15 or 18 characters)' },
fields: { type: 'string', description: 'Comma-separated field names to retrieve' },
},
required: ['objectType', 'recordId'],
@@ -87,7 +195,7 @@ export class SalesforceConnector extends BaseConnector {
description: 'List opportunities with optional limit',
inputSchema: {
properties: {
limit: { type: 'number', description: 'Max results (default 25)' },
limit: { type: 'number', description: `Max results (default 25, max ${MAX_LIST_LIMIT})` },
fields: { type: 'string', description: 'Comma-separated field names (default: Id,Name,StageName,Amount,CloseDate)' },
},
},
@@ -98,13 +206,20 @@ export class SalesforceConnector extends BaseConnector {
private token: string | null = null;
private instanceUrl: string | null = null;
override toDefinition(status: ConnectorStatus): ConnectorDefinition {
const effectiveStatus = status === 'connected' && (!this.token || !this.instanceUrl)
? 'disconnected'
: status;
return super.toDefinition(effectiveStatus);
}
async connect(vault: VaultStore): Promise<void> {
const cred = vault.getConnectorCredential(this.id);
this.token = cred?.value ?? null;
// Instance URL from vault metadata (e.g., "https://mycompany.salesforce.com")
const urlEntry = vault.get(`connector:${this.id}:instance_url`);
this.instanceUrl = urlEntry?.value ?? null;
this.instanceUrl = SalesforceConnector.normalizeInstanceOrigin(urlEntry?.value);
}
async healthCheck(): Promise<ConnectorHealth> {
@@ -117,10 +232,10 @@ export class SalesforceConnector extends BaseConnector {
if (this.token && this.instanceUrl) {
try {
const res = await fetch(`${this.instanceUrl}/services/data/${API_VERSION}/limits`, {
const res = await safeFetch(`${this.instanceUrl}/services/data/${API_VERSION}/limits`, {
headers: this.headers(),
signal: AbortSignal.timeout(5000),
});
}, { maxRedirects: 0 });
if (!res.ok) {
health.status = 'error';
health.error = `Salesforce API returned ${res.status}`;
@@ -163,11 +278,11 @@ export class SalesforceConnector extends BaseConnector {
private async soqlQuery(params: Record<string, unknown>): Promise<ConnectorResult> {
try {
const query = encodeURIComponent(String(params.query));
const res = await fetch(`${this.apiBase}/query?q=${query}`, {
const query = encodeURIComponent(requireSoqlQuery(params.query));
const res = await safeFetch(`${this.apiBase}/query?q=${query}`, {
headers: this.headers(),
signal: AbortSignal.timeout(10000),
});
}, { maxRedirects: 0 });
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
return { success: true, data: await res.json() };
} catch (err: unknown) {
@@ -177,13 +292,14 @@ export class SalesforceConnector extends BaseConnector {
private async listObjects(objectType: string, params: Record<string, unknown>, defaultFields: string): Promise<ConnectorResult> {
try {
const limit = (params.limit as number) ?? 25;
const fields = (params.fields as string) ?? defaultFields;
const soql = `SELECT ${fields} FROM ${objectType} ORDER BY CreatedDate DESC LIMIT ${limit}`;
const res = await fetch(`${this.apiBase}/query?q=${encodeURIComponent(soql)}`, {
const limit = requireListLimit(params.limit);
const fields = requireFieldList(params.fields, defaultFields);
const safeObjectType = requireIdentifier(objectType, 'object type');
const soql = `SELECT ${fields} FROM ${safeObjectType} ORDER BY CreatedDate DESC LIMIT ${limit}`;
const res = await safeFetch(`${this.apiBase}/query?q=${encodeURIComponent(soql)}`, {
headers: this.headers(),
signal: AbortSignal.timeout(10000),
});
}, { maxRedirects: 0 });
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
return { success: true, data: await res.json() };
} catch (err: unknown) {
@@ -193,14 +309,14 @@ export class SalesforceConnector extends BaseConnector {
private async getRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
try {
const objectType = String(params.objectType);
const recordId = String(params.recordId);
let url = `${this.apiBase}/sobjects/${objectType}/${recordId}`;
if (params.fields) url += `?fields=${encodeURIComponent(String(params.fields))}`;
const res = await fetch(url, {
const objectType = requireIdentifier(params.objectType, 'object type');
const recordId = requireRecordId(params.recordId);
let url = `${this.apiBase}/sobjects/${encodeURIComponent(objectType)}/${encodeURIComponent(recordId)}`;
if (params.fields !== undefined) url += `?fields=${encodeURIComponent(requireFieldList(params.fields))}`;
const res = await safeFetch(url, {
headers: this.headers(),
signal: AbortSignal.timeout(10000),
});
}, { maxRedirects: 0 });
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
return { success: true, data: await res.json() };
} catch (err: unknown) {
@@ -210,14 +326,14 @@ export class SalesforceConnector extends BaseConnector {
private async createRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
try {
const objectType = String(params.objectType);
const fields = params.fields as Record<string, unknown>;
const res = await fetch(`${this.apiBase}/sobjects/${objectType}`, {
const objectType = requireIdentifier(params.objectType, 'object type');
const fields = requireFieldMap(params.fields);
const res = await safeFetch(`${this.apiBase}/sobjects/${encodeURIComponent(objectType)}`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify(fields),
signal: AbortSignal.timeout(10000),
});
}, { maxRedirects: 0 });
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
return { success: true, data: await res.json() };
} catch (err: unknown) {
@@ -227,15 +343,15 @@ export class SalesforceConnector extends BaseConnector {
private async updateRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
try {
const objectType = String(params.objectType);
const recordId = String(params.recordId);
const fields = params.fields as Record<string, unknown>;
const res = await fetch(`${this.apiBase}/sobjects/${objectType}/${recordId}`, {
const objectType = requireIdentifier(params.objectType, 'object type');
const recordId = requireRecordId(params.recordId);
const fields = requireFieldMap(params.fields);
const res = await safeFetch(`${this.apiBase}/sobjects/${encodeURIComponent(objectType)}/${encodeURIComponent(recordId)}`, {
method: 'PATCH',
headers: this.headers(),
body: JSON.stringify(fields),
signal: AbortSignal.timeout(10000),
});
}, { maxRedirects: 0 });
// Salesforce returns 204 No Content on successful update
if (res.status !== 204 && !res.ok) {
return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };