moving
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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') };
|
||||
|
||||
Reference in New Issue
Block a user