diff --git a/.agents/skills/add-connector/SKILL.md b/.agents/skills/add-connector/SKILL.md index 0fccb938b11..2958623e156 100644 --- a/.agents/skills/add-connector/SKILL.md +++ b/.agents/skills/add-connector/SKILL.md @@ -12,8 +12,8 @@ You are an expert at adding knowledge base connectors to Sim. A connector syncs When the user asks you to create a connector: 1. Use Context7 or WebFetch to read the service's API documentation 2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth) -3. Create the connector directory and config -4. Register it in the connector registry +3. Create the connector directory: a client-safe `meta.ts` (declarative metadata) plus the runtime module that spreads it +4. Register it in BOTH the server registry and the client-safe meta registry ## Hard Rule: No Guessed Response Or Document Schemas @@ -32,13 +32,19 @@ If the source schema is unknown, do one of these instead: ## Directory Structure +Each connector is split into a client-safe metadata file and a server-only runtime file. This mirrors the `XBlockMeta` / `BLOCK_META_REGISTRY` split in `apps/sim/blocks` — client components (the knowledge UI) only need the metadata (icon, name, auth, config fields), so the runtime functions (which pull server-only helpers like `input-validation.server` → `undici` → `node:net`) must stay out of the client bundle. + Create files in `apps/sim/connectors/{service}/`: ``` connectors/{service}/ -├── index.ts # Barrel export -└── {service}.ts # ConnectorConfig definition +├── index.ts # Barrel export (re-exports the runtime connector) +├── meta.ts # ConnectorMeta — client-safe declarative metadata +└── {service}.ts # ConnectorConfig — spreads the meta + adds runtime functions ``` +- `meta.ts` exports `{service}ConnectorMeta: ConnectorMeta`. It imports ONLY the icon from `@/components/icons`, `import type { ConnectorMeta } from '@/connectors/types'`, and any pure-data constants. It must NEVER import server/runtime code. +- `{service}.ts` exports `{service}Connector: ConnectorConfig`. It imports the meta via `import { {service}ConnectorMeta } from '@/connectors/{service}/meta'`, spreads it as the first property, and holds the runtime functions (which may import server-only helpers like `@/lib/knowledge/documents/utils`). + ## Authentication Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`): @@ -55,19 +61,17 @@ For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The ### API key mode For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`. -## ConnectorConfig Structure +## Connector Structure (meta.ts + runtime) + +The declarative metadata lives in `meta.ts` (`ConnectorMeta`). The runtime functions live in `{service}.ts` (`ConnectorConfig`), which spreads the meta as its first property. -### OAuth connector example +### `meta.ts` — client-safe metadata ```typescript -import { createLogger } from '@sim/logger' import { {Service}Icon } from '@/components/icons' -import { fetchWithRetry } from '@/lib/knowledge/documents/utils' -import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' - -const logger = createLogger('{Service}Connector') +import type { ConnectorMeta } from '@/connectors/types' -export const {service}Connector: ConnectorConfig = { +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -85,6 +89,26 @@ export const {service}Connector: ConnectorConfig = { // Supports 'short-input' and 'dropdown' types ], + // Optional: tag definitions are metadata too — declare them here + // tagDefinitions: [ ... ], +} +``` + +Keep `meta.ts` free of any server/runtime import. Only the icon, the `ConnectorMeta` type, and pure-data constants belong here. + +### `{service}.ts` — runtime (OAuth example) + +```typescript +import { createLogger } from '@sim/logger' +import { fetchWithRetry } from '@/lib/knowledge/documents/utils' +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' + +const logger = createLogger('{Service}Connector') + +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, + listDocuments: async (accessToken, sourceConfig, cursor) => { // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed) // Or full documents with content (if list API returns content inline) @@ -111,8 +135,11 @@ Only map fields in `listDocuments`, `getDocument`, `validateConfig`, and `mapTag ### API key connector example +The split is identical — `auth` lives in `meta.ts`, runtime functions in `{service}.ts`. + ```typescript -export const {service}Connector: ConnectorConfig = { +// meta.ts +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -126,6 +153,11 @@ export const {service}Connector: ConnectorConfig = { }, configFields: [ /* ... */ ], +} + +// {service}.ts +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ }, getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ }, validateConfig: async (accessToken, sourceConfig) => { /* ... */ }, @@ -499,7 +531,9 @@ If the service already has an icon in `apps/sim/components/icons.tsx` (from a to ## Registering -Add one line to `apps/sim/connectors/registry.ts`: +Register in BOTH registries, keeping the same alphabetical-by-id ordering in each. + +1. **Server registry** — `apps/sim/connectors/registry.server.ts` (server-only full registry; holds full connectors with runtime functions, imported by the sync engine and knowledge API routes): ```typescript import { {service}Connector } from '@/connectors/{service}' @@ -510,6 +544,19 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { } ``` +2. **Client-safe meta registry** — `apps/sim/connectors/registry.ts` (imports each connector's `meta.ts` only, so client components can use it without pulling server-only code; the metadata counterpart to `BLOCK_META_REGISTRY`): + +```typescript +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' + +export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { + // ... existing connector metas ... + {service}: {service}ConnectorMeta, +} +``` + +`registry.ts` exports `CONNECTOR_META_REGISTRY: ConnectorMetaRegistry` plus the helpers `getConnectorMeta(id)` and `getAllConnectorMeta()`, importing each `@/connectors/{service}/meta` directly — never the runtime module. `registry.server.ts` exports `CONNECTOR_REGISTRY: ConnectorRegistry`. + ## Reference Implementations - **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination @@ -520,7 +567,8 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { ## Checklist -- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig +- [ ] Created `connectors/{service}/meta.ts` with `{service}ConnectorMeta: ConnectorMeta` (icon, name, auth, configFields, tagDefinitions) — no server/runtime imports +- [ ] Created `connectors/{service}/{service}.ts` with `{service}Connector: ConnectorConfig` spreading the meta + runtime functions - [ ] Created `connectors/{service}/index.ts` barrel export - [ ] **Auth configured correctly:** - OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts` @@ -542,4 +590,5 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { - [ ] All external API calls use `fetchWithRetry` (not raw `fetch`) - [ ] All optional config fields validated in `validateConfig` - [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG) -- [ ] Registered in `connectors/registry.ts` +- [ ] Registered the full connector in `connectors/registry.server.ts` +- [ ] Registered the meta in `connectors/registry.ts` (same alphabetical-by-id ordering as registry.server.ts) diff --git a/.agents/skills/validate-connector/SKILL.md b/.agents/skills/validate-connector/SKILL.md index 1a0024ace07..31d4f4ac4ef 100644 --- a/.agents/skills/validate-connector/SKILL.md +++ b/.agents/skills/validate-connector/SKILL.md @@ -21,10 +21,12 @@ When the user asks you to validate a connector: Read **every** file for the connector — do not skip any: ``` -apps/sim/connectors/{service}/{service}.ts # Connector implementation +apps/sim/connectors/{service}/meta.ts # ConnectorMeta — client-safe metadata (icon, name, auth, configFields, tagDefinitions) +apps/sim/connectors/{service}/{service}.ts # Connector implementation — spreads the meta + runtime functions apps/sim/connectors/{service}/index.ts # Barrel export -apps/sim/connectors/registry.ts # Connector registry entry -apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc. +apps/sim/connectors/registry.server.ts # Server-only full registry entry (CONNECTOR_REGISTRY; full connector) +apps/sim/connectors/registry.ts # Client-safe meta registry entry (CONNECTOR_META_REGISTRY) +apps/sim/connectors/types.ts # ConnectorMeta / ConnectorConfig interfaces, ExternalDocument, etc. apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.) apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS @@ -262,10 +264,18 @@ Connectors where the list API already returns content inline (e.g., Slack messag - [ ] Logs sync progress at `info` level - [ ] Logs errors at `warn` or `error` level with context +### Meta / Runtime Split +- [ ] `connectors/{service}/meta.ts` exports `{service}ConnectorMeta: ConnectorMeta` (id, name, description, version, icon, auth, configFields, and any `tagDefinitions` / `supportsIncrementalSync`) +- [ ] `meta.ts` imports ONLY the icon from `@/components/icons`, `ConnectorMeta` (type-only), and pure-data constants — NO server/runtime imports (`@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`, etc.); any such import in `meta.ts` is **critical** (breaks the client bundle) +- [ ] `connectors/{service}/{service}.ts` spreads `...{service}ConnectorMeta` as the first property and adds the runtime functions (`listDocuments`, `getDocument`, `validateConfig`, `mapTags?`) +- [ ] Metadata fields (id, name, auth, configFields, etc.) live ONLY in `meta.ts`, not duplicated in `{service}.ts` + ### Registry - [ ] Connector is exported from `connectors/{service}/index.ts` -- [ ] Connector is registered in `connectors/registry.ts` -- [ ] Registry key matches the connector's `id` field +- [ ] Full connector is registered in `connectors/registry.server.ts` (server-only registry, `CONNECTOR_REGISTRY`) +- [ ] Meta is registered in `connectors/registry.ts` (client-safe registry, `CONNECTOR_META_REGISTRY`), importing `@/connectors/{service}/meta` +- [ ] Both registries use the same key and it matches the connector's `id` field +- [ ] Both registries keep the same alphabetical-by-id ordering ## Step 11: Report and Fix @@ -284,6 +294,8 @@ Group findings by severity: - Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping - Per-document content download in `listDocuments` without `contentDeferred: true` — causes sync timeouts for large document sets - `contentHash` mismatch between `listDocuments` stub and `getDocument` return — causes unnecessary re-processing every sync +- Server/runtime import in `meta.ts` (e.g. `@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`) — pulls server-only code into the client bundle and breaks the build +- Connector missing from `connectors/registry.ts` (the client-safe meta registry) — or its entry there imports the runtime module instead of `meta.ts` — the knowledge UI can't render it **Warning** (incorrect behavior, data quality issues, or convention violations): - HTML content not stripped via `htmlToPlainText` @@ -323,7 +335,7 @@ After fixing, confirm: ## Checklist Summary -- [ ] Read connector implementation, types, utils, registry, and OAuth config +- [ ] Read connector meta.ts, implementation, types, utils, both registries, and OAuth config - [ ] Pulled and read official API documentation for the service - [ ] Validated every API endpoint URL, method, headers, and body against API docs - [ ] Validated input sanitization: no query/filter injection, URL fields normalized @@ -342,7 +354,8 @@ After fixing, confirm: - [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched - [ ] Validated error handling: graceful failures, no unhandled rejections - [ ] Validated logging: createLogger, no console.log -- [ ] Validated registry: correct export, correct key +- [ ] Validated meta/runtime split: `meta.ts` holds metadata with no server/runtime imports, `{service}.ts` spreads the meta + adds runtime functions +- [ ] Validated registry: exported from index.ts, full connector in `registry.server.ts`, meta in `registry.ts`, matching keys and alphabetical-by-id ordering in both - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run lint` after fixes diff --git a/.claude/commands/add-connector.md b/.claude/commands/add-connector.md index 8d667ebb7a2..9b7a5650d09 100644 --- a/.claude/commands/add-connector.md +++ b/.claude/commands/add-connector.md @@ -12,18 +12,24 @@ You are an expert at adding knowledge base connectors to Sim. A connector syncs When the user asks you to create a connector: 1. Use Context7 or WebFetch to read the service's API documentation 2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth) -3. Create the connector directory and config -4. Register it in the connector registry +3. Create the connector directory: a client-safe `meta.ts` (declarative metadata) plus the runtime module that spreads it +4. Register it in BOTH the server registry and the client-safe meta registry ## Directory Structure +Each connector is split into a client-safe metadata file and a server-only runtime file. This mirrors the `XBlockMeta` / `BLOCK_META_REGISTRY` split in `apps/sim/blocks` — client components (the knowledge UI) only need the metadata (icon, name, auth, config fields), so the runtime functions (which pull server-only helpers like `input-validation.server` → `undici` → `node:net`) must stay out of the client bundle. + Create files in `apps/sim/connectors/{service}/`: ``` connectors/{service}/ -├── index.ts # Barrel export -└── {service}.ts # ConnectorConfig definition +├── index.ts # Barrel export (re-exports the runtime connector) +├── meta.ts # ConnectorMeta — client-safe declarative metadata +└── {service}.ts # ConnectorConfig — spreads the meta + adds runtime functions ``` +- `meta.ts` exports `{service}ConnectorMeta: ConnectorMeta`. It imports ONLY the icon from `@/components/icons`, `import type { ConnectorMeta } from '@/connectors/types'`, and any pure-data constants. It must NEVER import server/runtime code. +- `{service}.ts` exports `{service}Connector: ConnectorConfig`. It imports the meta via `import { {service}ConnectorMeta } from '@/connectors/{service}/meta'`, spreads it as the first property, and holds the runtime functions (which may import server-only helpers like `@/lib/knowledge/documents/utils`). + ## Authentication Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`): @@ -40,19 +46,17 @@ For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The ### API key mode For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`. -## ConnectorConfig Structure +## Connector Structure (meta.ts + runtime) + +The declarative metadata lives in `meta.ts` (`ConnectorMeta`). The runtime functions live in `{service}.ts` (`ConnectorConfig`), which spreads the meta as its first property. -### OAuth connector example +### `meta.ts` — client-safe metadata ```typescript -import { createLogger } from '@sim/logger' import { {Service}Icon } from '@/components/icons' -import { fetchWithRetry } from '@/lib/knowledge/documents/utils' -import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' - -const logger = createLogger('{Service}Connector') +import type { ConnectorMeta } from '@/connectors/types' -export const {service}Connector: ConnectorConfig = { +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -70,6 +74,26 @@ export const {service}Connector: ConnectorConfig = { // Supports 'short-input', 'dropdown', and 'selector' types — see ConfigField Types below ], + // Optional: tag definitions are metadata too — declare them here + // tagDefinitions: [ ... ], +} +``` + +Keep `meta.ts` free of any server/runtime import. Only the icon, the `ConnectorMeta` type, and pure-data constants belong here. + +### `{service}.ts` — runtime (OAuth example) + +```typescript +import { createLogger } from '@sim/logger' +import { fetchWithRetry } from '@/lib/knowledge/documents/utils' +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' + +const logger = createLogger('{Service}Connector') + +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, + listDocuments: async (accessToken, sourceConfig, cursor) => { // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed) // Or full documents with content (if list API returns content inline) @@ -94,8 +118,11 @@ export const {service}Connector: ConnectorConfig = { ### API key connector example +The split is identical — `auth` lives in `meta.ts`, runtime functions in `{service}.ts`. + ```typescript -export const {service}Connector: ConnectorConfig = { +// meta.ts +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -109,6 +136,11 @@ export const {service}Connector: ConnectorConfig = { }, configFields: [ /* ... */ ], +} + +// {service}.ts +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ }, getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ }, validateConfig: async (accessToken, sourceConfig) => { /* ... */ }, @@ -512,7 +544,9 @@ If the service already has an icon in `apps/sim/components/icons.tsx` (from a to ## Registering -Add one line to `apps/sim/connectors/registry.ts`: +Register in BOTH registries, keeping the same alphabetical-by-id ordering in each. + +1. **Server registry** — `apps/sim/connectors/registry.server.ts` (server-only full registry; holds full connectors with runtime functions, imported by the sync engine and knowledge API routes): ```typescript import { {service}Connector } from '@/connectors/{service}' @@ -523,6 +557,19 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { } ``` +2. **Client-safe meta registry** — `apps/sim/connectors/registry.ts` (imports each connector's `meta.ts` only, so client components can use it without pulling server-only code; the metadata counterpart to `BLOCK_META_REGISTRY`): + +```typescript +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' + +export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { + // ... existing connector metas ... + {service}: {service}ConnectorMeta, +} +``` + +`registry.ts` exports `CONNECTOR_META_REGISTRY: ConnectorMetaRegistry` plus the helpers `getConnectorMeta(id)` and `getAllConnectorMeta()`, importing each `@/connectors/{service}/meta` directly — never the runtime module. `registry.server.ts` exports `CONNECTOR_REGISTRY: ConnectorRegistry`. + ## Reference Implementations - **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination @@ -533,7 +580,8 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { ## Checklist -- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig +- [ ] Created `connectors/{service}/meta.ts` with `{service}ConnectorMeta: ConnectorMeta` (icon, name, auth, configFields, tagDefinitions) — no server/runtime imports +- [ ] Created `connectors/{service}/{service}.ts` with `{service}Connector: ConnectorConfig` spreading the meta + runtime functions - [ ] Created `connectors/{service}/index.ts` barrel export - [ ] **Auth configured correctly:** - OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts` @@ -556,4 +604,5 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { - [ ] All external API calls use `fetchWithRetry` (not raw `fetch`) - [ ] All optional config fields validated in `validateConfig` - [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG) -- [ ] Registered in `connectors/registry.ts` +- [ ] Registered the full connector in `connectors/registry.server.ts` +- [ ] Registered the meta in `connectors/registry.ts` (same alphabetical-by-id ordering as registry.server.ts) diff --git a/.claude/commands/validate-connector.md b/.claude/commands/validate-connector.md index 6bf8dab4d53..eca711b4ddd 100644 --- a/.claude/commands/validate-connector.md +++ b/.claude/commands/validate-connector.md @@ -21,10 +21,12 @@ When the user asks you to validate a connector: Read **every** file for the connector — do not skip any: ``` -apps/sim/connectors/{service}/{service}.ts # Connector implementation +apps/sim/connectors/{service}/meta.ts # ConnectorMeta — client-safe metadata (icon, name, auth, configFields, tagDefinitions) +apps/sim/connectors/{service}/{service}.ts # Connector implementation — spreads the meta + runtime functions apps/sim/connectors/{service}/index.ts # Barrel export -apps/sim/connectors/registry.ts # Connector registry entry -apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc. +apps/sim/connectors/registry.server.ts # Server-only full registry entry (CONNECTOR_REGISTRY; full connector) +apps/sim/connectors/registry.ts # Client-safe meta registry entry (CONNECTOR_META_REGISTRY) +apps/sim/connectors/types.ts # ConnectorMeta / ConnectorConfig interfaces, ExternalDocument, etc. apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.) apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS @@ -240,10 +242,18 @@ This is the most common connector bug class — verify it explicitly against `sy - [ ] Logs sync progress at `info` level - [ ] Logs errors at `warn` or `error` level with context +### Meta / Runtime Split +- [ ] `connectors/{service}/meta.ts` exports `{service}ConnectorMeta: ConnectorMeta` (id, name, description, version, icon, auth, configFields, and any `tagDefinitions` / `supportsIncrementalSync`) +- [ ] `meta.ts` imports ONLY the icon from `@/components/icons`, `ConnectorMeta` (type-only), and pure-data constants — NO server/runtime imports (`@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`, etc.); any such import in `meta.ts` is **critical** (breaks the client bundle) +- [ ] `connectors/{service}/{service}.ts` spreads `...{service}ConnectorMeta` as the first property and adds the runtime functions (`listDocuments`, `getDocument`, `validateConfig`, `mapTags?`) +- [ ] Metadata fields (id, name, auth, configFields, etc.) live ONLY in `meta.ts`, not duplicated in `{service}.ts` + ### Registry - [ ] Connector is exported from `connectors/{service}/index.ts` -- [ ] Connector is registered in `connectors/registry.ts` -- [ ] Registry key matches the connector's `id` field +- [ ] Full connector is registered in `connectors/registry.server.ts` (server-only registry, `CONNECTOR_REGISTRY`) +- [ ] Meta is registered in `connectors/registry.ts` (client-safe registry, `CONNECTOR_META_REGISTRY`), importing `@/connectors/{service}/meta` +- [ ] Both registries use the same key and it matches the connector's `id` field +- [ ] Both registries keep the same alphabetical-by-id ordering ## Step 11: Report and Fix @@ -260,6 +270,8 @@ Group findings by severity: - Missing error handling that would crash the sync - `requiredScopes` not a subset of OAuth provider scopes - Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping +- Server/runtime import in `meta.ts` (e.g. `@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`) — pulls server-only code into the client bundle and breaks the build +- Connector missing from `connectors/registry.ts` (the client-safe meta registry) — or its entry there imports the runtime module instead of `meta.ts` — the knowledge UI can't render it **Warning** (incorrect behavior, data quality issues, or convention violations): - HTML content not stripped via `htmlToPlainText` @@ -299,7 +311,7 @@ After fixing, confirm: ## Checklist Summary -- [ ] Read connector implementation, types, utils, registry, and OAuth config +- [ ] Read connector meta.ts, implementation, types, utils, both registries, and OAuth config - [ ] Pulled and read official API documentation for the service - [ ] Validated every API endpoint URL, method, headers, and body against API docs - [ ] Validated input sanitization: no query/filter injection, URL fields normalized @@ -317,7 +329,8 @@ After fixing, confirm: - [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched - [ ] Validated error handling: graceful failures, no unhandled rejections - [ ] Validated logging: createLogger, no console.log -- [ ] Validated registry: correct export, correct key +- [ ] Validated meta/runtime split: `meta.ts` holds metadata with no server/runtime imports, `{service}.ts` spreads the meta + adds runtime functions +- [ ] Validated registry: exported from index.ts, full connector in `registry.server.ts`, meta in `registry.ts`, matching keys and alphabetical-by-id ordering in both - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run lint` after fixes diff --git a/.cursor/commands/add-connector.md b/.cursor/commands/add-connector.md index 9d29f5aab62..63ed2cec4ae 100644 --- a/.cursor/commands/add-connector.md +++ b/.cursor/commands/add-connector.md @@ -7,18 +7,24 @@ You are an expert at adding knowledge base connectors to Sim. A connector syncs When the user asks you to create a connector: 1. Use Context7 or WebFetch to read the service's API documentation 2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth) -3. Create the connector directory and config -4. Register it in the connector registry +3. Create the connector directory: a client-safe `meta.ts` (declarative metadata) plus the runtime module that spreads it +4. Register it in BOTH the server registry and the client-safe meta registry ## Directory Structure +Each connector is split into a client-safe metadata file and a server-only runtime file. This mirrors the `XBlockMeta` / `BLOCK_META_REGISTRY` split in `apps/sim/blocks` — client components (the knowledge UI) only need the metadata (icon, name, auth, config fields), so the runtime functions (which pull server-only helpers like `input-validation.server` → `undici` → `node:net`) must stay out of the client bundle. + Create files in `apps/sim/connectors/{service}/`: ``` connectors/{service}/ -├── index.ts # Barrel export -└── {service}.ts # ConnectorConfig definition +├── index.ts # Barrel export (re-exports the runtime connector) +├── meta.ts # ConnectorMeta — client-safe declarative metadata +└── {service}.ts # ConnectorConfig — spreads the meta + adds runtime functions ``` +- `meta.ts` exports `{service}ConnectorMeta: ConnectorMeta`. It imports ONLY the icon from `@/components/icons`, `import type { ConnectorMeta } from '@/connectors/types'`, and any pure-data constants. It must NEVER import server/runtime code. +- `{service}.ts` exports `{service}Connector: ConnectorConfig`. It imports the meta via `import { {service}ConnectorMeta } from '@/connectors/{service}/meta'`, spreads it as the first property, and holds the runtime functions (which may import server-only helpers like `@/lib/knowledge/documents/utils`). + ## Authentication Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`): @@ -35,19 +41,17 @@ For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The ### API key mode For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`. -## ConnectorConfig Structure +## Connector Structure (meta.ts + runtime) + +The declarative metadata lives in `meta.ts` (`ConnectorMeta`). The runtime functions live in `{service}.ts` (`ConnectorConfig`), which spreads the meta as its first property. -### OAuth connector example +### `meta.ts` — client-safe metadata ```typescript -import { createLogger } from '@sim/logger' import { {Service}Icon } from '@/components/icons' -import { fetchWithRetry } from '@/lib/knowledge/documents/utils' -import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' - -const logger = createLogger('{Service}Connector') +import type { ConnectorMeta } from '@/connectors/types' -export const {service}Connector: ConnectorConfig = { +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -65,6 +69,26 @@ export const {service}Connector: ConnectorConfig = { // Supports 'short-input' and 'dropdown' types ], + // Optional: tag definitions are metadata too — declare them here + // tagDefinitions: [ ... ], +} +``` + +Keep `meta.ts` free of any server/runtime import. Only the icon, the `ConnectorMeta` type, and pure-data constants belong here. + +### `{service}.ts` — runtime (OAuth example) + +```typescript +import { createLogger } from '@sim/logger' +import { fetchWithRetry } from '@/lib/knowledge/documents/utils' +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' + +const logger = createLogger('{Service}Connector') + +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, + listDocuments: async (accessToken, sourceConfig, cursor) => { // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed) // Or full documents with content (if list API returns content inline) @@ -89,8 +113,11 @@ export const {service}Connector: ConnectorConfig = { ### API key connector example +The split is identical — `auth` lives in `meta.ts`, runtime functions in `{service}.ts`. + ```typescript -export const {service}Connector: ConnectorConfig = { +// meta.ts +export const {service}ConnectorMeta: ConnectorMeta = { id: '{service}', name: '{Service}', description: 'Sync documents from {Service} into your knowledge base', @@ -104,6 +131,11 @@ export const {service}Connector: ConnectorConfig = { }, configFields: [ /* ... */ ], +} + +// {service}.ts +export const {service}Connector: ConnectorConfig = { + ...{service}ConnectorMeta, listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ }, getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ }, validateConfig: async (accessToken, sourceConfig) => { /* ... */ }, @@ -477,7 +509,9 @@ If the service already has an icon in `apps/sim/components/icons.tsx` (from a to ## Registering -Add one line to `apps/sim/connectors/registry.ts`: +Register in BOTH registries, keeping the same alphabetical-by-id ordering in each. + +1. **Server registry** — `apps/sim/connectors/registry.server.ts` (server-only full registry; holds full connectors with runtime functions, imported by the sync engine and knowledge API routes): ```typescript import { {service}Connector } from '@/connectors/{service}' @@ -488,6 +522,19 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { } ``` +2. **Client-safe meta registry** — `apps/sim/connectors/registry.ts` (imports each connector's `meta.ts` only, so client components can use it without pulling server-only code; the metadata counterpart to `BLOCK_META_REGISTRY`): + +```typescript +import { {service}ConnectorMeta } from '@/connectors/{service}/meta' + +export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { + // ... existing connector metas ... + {service}: {service}ConnectorMeta, +} +``` + +`registry.ts` exports `CONNECTOR_META_REGISTRY: ConnectorMetaRegistry` plus the helpers `getConnectorMeta(id)` and `getAllConnectorMeta()`, importing each `@/connectors/{service}/meta` directly — never the runtime module. `registry.server.ts` exports `CONNECTOR_REGISTRY: ConnectorRegistry`. + ## Reference Implementations - **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination @@ -498,7 +545,8 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { ## Checklist -- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig +- [ ] Created `connectors/{service}/meta.ts` with `{service}ConnectorMeta: ConnectorMeta` (icon, name, auth, configFields, tagDefinitions) — no server/runtime imports +- [ ] Created `connectors/{service}/{service}.ts` with `{service}Connector: ConnectorConfig` spreading the meta + runtime functions - [ ] Created `connectors/{service}/index.ts` barrel export - [ ] **Auth configured correctly:** - OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts` @@ -520,4 +568,5 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { - [ ] All external API calls use `fetchWithRetry` (not raw `fetch`) - [ ] All optional config fields validated in `validateConfig` - [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG) -- [ ] Registered in `connectors/registry.ts` +- [ ] Registered the full connector in `connectors/registry.server.ts` +- [ ] Registered the meta in `connectors/registry.ts` (same alphabetical-by-id ordering as registry.server.ts) diff --git a/.cursor/commands/validate-connector.md b/.cursor/commands/validate-connector.md index e1e319e2c0b..bfc4e2520a6 100644 --- a/.cursor/commands/validate-connector.md +++ b/.cursor/commands/validate-connector.md @@ -16,10 +16,12 @@ When the user asks you to validate a connector: Read **every** file for the connector — do not skip any: ``` -apps/sim/connectors/{service}/{service}.ts # Connector implementation +apps/sim/connectors/{service}/meta.ts # ConnectorMeta — client-safe metadata (icon, name, auth, configFields, tagDefinitions) +apps/sim/connectors/{service}/{service}.ts # Connector implementation — spreads the meta + runtime functions apps/sim/connectors/{service}/index.ts # Barrel export -apps/sim/connectors/registry.ts # Connector registry entry -apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc. +apps/sim/connectors/registry.server.ts # Server-only full registry entry (CONNECTOR_REGISTRY; full connector) +apps/sim/connectors/registry.ts # Client-safe meta registry entry (CONNECTOR_META_REGISTRY) +apps/sim/connectors/types.ts # ConnectorMeta / ConnectorConfig interfaces, ExternalDocument, etc. apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.) apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS @@ -228,10 +230,18 @@ For each API endpoint the connector calls: - [ ] Logs sync progress at `info` level - [ ] Logs errors at `warn` or `error` level with context +### Meta / Runtime Split +- [ ] `connectors/{service}/meta.ts` exports `{service}ConnectorMeta: ConnectorMeta` (id, name, description, version, icon, auth, configFields, and any `tagDefinitions` / `supportsIncrementalSync`) +- [ ] `meta.ts` imports ONLY the icon from `@/components/icons`, `ConnectorMeta` (type-only), and pure-data constants — NO server/runtime imports (`@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`, etc.); any such import in `meta.ts` is **critical** (breaks the client bundle) +- [ ] `connectors/{service}/{service}.ts` spreads `...{service}ConnectorMeta` as the first property and adds the runtime functions (`listDocuments`, `getDocument`, `validateConfig`, `mapTags?`) +- [ ] Metadata fields (id, name, auth, configFields, etc.) live ONLY in `meta.ts`, not duplicated in `{service}.ts` + ### Registry - [ ] Connector is exported from `connectors/{service}/index.ts` -- [ ] Connector is registered in `connectors/registry.ts` -- [ ] Registry key matches the connector's `id` field +- [ ] Full connector is registered in `connectors/registry.server.ts` (server-only registry, `CONNECTOR_REGISTRY`) +- [ ] Meta is registered in `connectors/registry.ts` (client-safe registry, `CONNECTOR_META_REGISTRY`), importing `@/connectors/{service}/meta` +- [ ] Both registries use the same key and it matches the connector's `id` field +- [ ] Both registries keep the same alphabetical-by-id ordering ## Step 11: Report and Fix @@ -248,6 +258,8 @@ Group findings by severity: - Missing error handling that would crash the sync - `requiredScopes` not a subset of OAuth provider scopes - Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping +- Server/runtime import in `meta.ts` (e.g. `@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`) — pulls server-only code into the client bundle and breaks the build +- Connector missing from `connectors/registry.ts` (the client-safe meta registry) — or its entry there imports the runtime module instead of `meta.ts` — the knowledge UI can't render it **Warning** (incorrect behavior, data quality issues, or convention violations): - HTML content not stripped via `htmlToPlainText` @@ -286,7 +298,7 @@ After fixing, confirm: ## Checklist Summary -- [ ] Read connector implementation, types, utils, registry, and OAuth config +- [ ] Read connector meta.ts, implementation, types, utils, both registries, and OAuth config - [ ] Pulled and read official API documentation for the service - [ ] Validated every API endpoint URL, method, headers, and body against API docs - [ ] Validated input sanitization: no query/filter injection, URL fields normalized @@ -304,7 +316,8 @@ After fixing, confirm: - [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched - [ ] Validated error handling: graceful failures, no unhandled rejections - [ ] Validated logging: createLogger, no console.log -- [ ] Validated registry: correct export, correct key +- [ ] Validated meta/runtime split: `meta.ts` holds metadata with no server/runtime imports, `{service}.ts` spreads the meta + adds runtime functions +- [ ] Validated registry: exported from index.ts, full connector in `registry.server.ts`, meta in `registry.ts`, matching keys and alphabetical-by-id ordering in both - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run lint` after fixes diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index b53f133f9ba..cd31b9713cf 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1960,10 +1960,10 @@ export function WhatsAppIcon(props: SVGProps) { export function SquareIcon(props: SVGProps) { return ( - + ) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts index 94e41e01ba9..f904b9b5d35 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -39,7 +39,7 @@ const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWrit vi.mock('@sim/db', () => ({ db: mockDbChain })) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) -vi.mock('@/connectors/registry', () => ({ +vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: { jira: { validateConfig: mockValidateConfig }, }, diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index d2e4ff37804..2e5bb4ee6cf 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -22,7 +22,7 @@ import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service' import { captureServerEvent } from '@/lib/posthog/server' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' -import { CONNECTOR_REGISTRY } from '@/connectors/registry' +import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' const logger = createLogger('KnowledgeConnectorByIdAPI') diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index 3a9d6d61785..8cc3cc59621 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -18,7 +18,7 @@ import { createTagDefinition } from '@/lib/knowledge/tags/service' import { captureServerEvent } from '@/lib/posthog/server' import { getCredential } from '@/app/api/auth/oauth/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' -import { CONNECTOR_REGISTRY } from '@/connectors/registry' +import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' const logger = createLogger('KnowledgeConnectorsAPI') diff --git a/apps/sim/app/api/tools/agiloft/attachment_info/route.ts b/apps/sim/app/api/tools/agiloft/attachment_info/route.ts new file mode 100644 index 00000000000..d1eb3a8cbbc --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/attachment_info/route.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftAttachmentInfoContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftAttachmentInfoResponse } from '@/tools/agiloft/types' +import { buildAttachmentInfoUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftAttachmentInfoAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn( + `[${requestId}] Unauthorized Agiloft attachment_info attempt: ${authResult.error}` + ) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftAttachmentInfoContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildAttachmentInfoUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { attachments: [], totalCount: 0 }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = (await response.json()) as Record + const result = (data.result ?? data) as Record + + const attachments: Array<{ position: number; name: string; size: number }> = [] + + if (Array.isArray(result)) { + for (let i = 0; i < result.length; i++) { + const item = result[i] as Record + attachments.push({ + position: (item.filePosition as number) ?? (item.position as number) ?? i, + name: + (item.fileName as string) ?? + (item.name as string) ?? + (item.filename as string) ?? + '', + size: (item.size as number) ?? (item.fileSize as number) ?? 0, + }) + } + } + + return { + success: data.success !== false, + output: { + attachments, + totalCount: attachments.length, + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error getting Agiloft attachment info:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/create_record/route.ts b/apps/sim/app/api/tools/agiloft/create_record/route.ts new file mode 100644 index 00000000000..bc2ad11c5e7 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/create_record/route.ts @@ -0,0 +1,101 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftCreateRecordContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftRecordResponse } from '@/tools/agiloft/types' +import { buildCreateRecordUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftCreateRecordAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Agiloft create_record attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftCreateRecordContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + let body: string + try { + body = JSON.stringify(JSON.parse(params.data)) + } catch { + return NextResponse.json({ + success: false, + output: { id: null, fields: {} }, + error: 'Invalid JSON in data parameter', + }) + } + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildCreateRecordUrl(base, params), + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body, + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { id: null, fields: {} }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = (await response.json()) as Record + const result = (data.result ?? data) as Record + const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null + + return { + success: data.success !== false, + output: { + id: id != null ? String(id) : null, + fields: result ?? {}, + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error creating Agiloft record:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/delete_record/route.ts b/apps/sim/app/api/tools/agiloft/delete_record/route.ts new file mode 100644 index 00000000000..01e87d66930 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/delete_record/route.ts @@ -0,0 +1,85 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftDeleteRecordContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftDeleteResponse } from '@/tools/agiloft/types' +import { buildDeleteRecordUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftDeleteRecordAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Agiloft delete_record attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftDeleteRecordContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildDeleteRecordUrl(base, params), + method: 'DELETE', + headers: { Accept: 'application/json' }, + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { id: params.recordId?.trim() ?? '', deleted: false }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + return { + success: true, + output: { + id: params.recordId?.trim() ?? '', + deleted: true, + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error deleting Agiloft record:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/get_choice_line_id/route.ts b/apps/sim/app/api/tools/agiloft/get_choice_line_id/route.ts new file mode 100644 index 00000000000..14ed52079f9 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/get_choice_line_id/route.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftGetChoiceLineIdContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftGetChoiceLineIdResponse } from '@/tools/agiloft/types' +import { buildGetChoiceLineIdUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftGetChoiceLineIdAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn( + `[${requestId}] Unauthorized Agiloft get_choice_line_id attempt: ${authResult.error}` + ) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftGetChoiceLineIdContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildGetChoiceLineIdUrl(base, params), + method: 'GET', + headers: { Accept: 'application/json' }, + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { choiceLineId: null }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = (await response.json()) as Record + const result = data.result ?? data + let choiceLineId: number | null = null + + if (typeof result === 'number') { + choiceLineId = result + } else if (typeof result === 'string') { + const parsed = Number(result) + choiceLineId = Number.isFinite(parsed) ? parsed : null + } else if (typeof result === 'object' && result !== null) { + const obj = result as Record + const idVal = obj.id ?? obj.choiceLineId ?? obj.lineId + if (typeof idVal === 'number') { + choiceLineId = idVal + } else if (typeof idVal === 'string') { + const parsed = Number(idVal) + choiceLineId = Number.isFinite(parsed) ? parsed : null + } + } + + if (choiceLineId === null) { + return { + success: false, + output: { choiceLineId: null }, + error: `No choice line ID found for value "${params.value}" in field "${params.fieldName}"`, + } + } + + return { + success: data.success !== false, + output: { choiceLineId }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error getting Agiloft choice line ID:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/lock_record/route.ts b/apps/sim/app/api/tools/agiloft/lock_record/route.ts new file mode 100644 index 00000000000..3b353211a1d --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/lock_record/route.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftLockRecordContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftLockResponse } from '@/tools/agiloft/types' +import { buildLockRecordUrl, getLockHttpMethod } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftLockRecordAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Agiloft lock_record attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftLockRecordContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildLockRecordUrl(base, params), + method: getLockHttpMethod(params.lockAction), + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { + id: params.recordId?.trim() ?? '', + lockStatus: 'UNKNOWN', + lockedBy: null, + lockExpiresInMinutes: null, + }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = (await response.json()) as Record + const result = (data.result ?? data) as Record + + return { + success: data.success !== false, + output: { + id: String(result.id ?? params.recordId?.trim() ?? ''), + lockStatus: + (result.lock_status as string) ?? (result.lockStatus as string) ?? 'UNKNOWN', + lockedBy: + (result.locked_by as string | null) ?? (result.lockedBy as string | null) ?? null, + lockExpiresInMinutes: + (result.lock_expires_in_minutes as number | null) ?? + (result.lockExpiresInMinutes as number | null) ?? + null, + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error locking Agiloft record:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/read_record/route.ts b/apps/sim/app/api/tools/agiloft/read_record/route.ts new file mode 100644 index 00000000000..ab612d0d276 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/read_record/route.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftReadRecordContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftRecordResponse } from '@/tools/agiloft/types' +import { buildReadRecordUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftReadRecordAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Agiloft read_record attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftReadRecordContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildReadRecordUrl(base, params), + method: 'GET', + headers: { Accept: 'application/json' }, + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { id: null, fields: {} }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = (await response.json()) as Record + const result = (data.result ?? data) as Record + const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null + + return { + success: data.success !== false, + output: { + id: id != null ? String(id) : null, + fields: result ?? {}, + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error reading Agiloft record:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/remove_attachment/route.ts b/apps/sim/app/api/tools/agiloft/remove_attachment/route.ts new file mode 100644 index 00000000000..702259e7a7d --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/remove_attachment/route.ts @@ -0,0 +1,102 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftRemoveAttachmentContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftRemoveAttachmentResponse } from '@/tools/agiloft/types' +import { buildRemoveAttachmentUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftRemoveAttachmentAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn( + `[${requestId}] Unauthorized Agiloft remove_attachment attempt: ${authResult.error}` + ) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftRemoveAttachmentContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildRemoveAttachmentUrl(base, params), + method: 'DELETE', + }), + async (response) => { + const text = await response.text() + + if (!response.ok) { + return { + success: false, + output: { + recordId: params.recordId?.trim() ?? '', + fieldName: params.fieldName?.trim() ?? '', + remainingAttachments: 0, + }, + error: `Agiloft error: ${response.status} - ${text}`, + } + } + + let remainingAttachments = 0 + try { + const data = JSON.parse(text) + const result = data.result ?? data + remainingAttachments = + typeof result === 'number' ? result : (result.count ?? result.remaining ?? 0) + } catch { + remainingAttachments = Number(text) || 0 + } + + return { + success: true, + output: { + recordId: params.recordId?.trim() ?? '', + fieldName: params.fieldName?.trim() ?? '', + remainingAttachments, + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error removing Agiloft attachment:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/saved_search/route.ts b/apps/sim/app/api/tools/agiloft/saved_search/route.ts new file mode 100644 index 00000000000..b51d478a287 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/saved_search/route.ts @@ -0,0 +1,104 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftSavedSearchContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftSavedSearchResponse } from '@/tools/agiloft/types' +import { buildSavedSearchUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftSavedSearchAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Agiloft saved_search attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftSavedSearchContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildSavedSearchUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { searches: [] }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = (await response.json()) as Record + const result = (data.result ?? data) as Record + + const searches: Array<{ + name: string + label: string + id: string | number + description: string | null + }> = [] + + if (Array.isArray(result)) { + for (const item of result as Record[]) { + searches.push({ + name: (item.name as string) ?? '', + label: (item.label as string) ?? (item.name as string) ?? '', + id: (item.id as string | number) ?? (item.ID as string | number) ?? '', + description: (item.description as string | null) ?? null, + }) + } + } + + return { + success: data.success !== false, + output: { + searches, + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error listing Agiloft saved searches:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/search_records/route.ts b/apps/sim/app/api/tools/agiloft/search_records/route.ts new file mode 100644 index 00000000000..137e54a4496 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/search_records/route.ts @@ -0,0 +1,129 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftSearchRecordsContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftSearchResponse } from '@/tools/agiloft/types' +import { buildSearchRecordsUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftSearchRecordsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Agiloft search_records attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftSearchRecordsContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildSearchRecordsUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { records: [], totalCount: 0, page: 0, limit: 25 }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = (await response.json()) as Record + const records: Record[] = [] + const result = (data.result ?? data) as Record + + if (Array.isArray(result)) { + for (const item of result as Record[]) { + records.push(item) + } + } else { + const lengthRaw = result.EWREST_length ?? data.EWREST_length + const count = typeof lengthRaw === 'string' ? Number(lengthRaw) : (lengthRaw as number) + if (typeof count === 'number' && Number.isFinite(count)) { + const source = (result.EWREST_length != null ? result : data) as Record + for (let i = 0; i < count; i++) { + const record: Record = {} + for (const key of Object.keys(source)) { + const match = key.match(/^EWREST_(.+)_(\d+)$/) + if (match && Number(match[2]) === i) { + record[match[1]] = source[key] + } + } + if (Object.keys(record).length > 0) { + records.push(record) + } + } + } + } + + const totalCountRaw = + result.totalCount ?? + result.total ?? + result.count ?? + result.EWREST_length ?? + data.totalCount ?? + data.total ?? + data.count ?? + data.EWREST_length ?? + records.length + const totalCount = + typeof totalCountRaw === 'string' ? Number(totalCountRaw) : (totalCountRaw as number) + const page = params.page ? Number(params.page) : 0 + const limit = params.limit ? Number(params.limit) : 25 + + return { + success: data.success !== false, + output: { + records, + totalCount, + page, + limit, + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error searching Agiloft records:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/select_records/route.ts b/apps/sim/app/api/tools/agiloft/select_records/route.ts new file mode 100644 index 00000000000..10371c29caa --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/select_records/route.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftSelectRecordsContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftSelectResponse } from '@/tools/agiloft/types' +import { buildSelectRecordsUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftSelectRecordsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Agiloft select_records attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftSelectRecordsContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildSelectRecordsUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { recordIds: [], totalCount: 0 }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = (await response.json()) as Record + const result = (data.result ?? data) as Record + const recordIds: string[] = [] + + if (Array.isArray(result)) { + for (const item of result as Record[]) { + const id = item.id ?? item.ID ?? item + recordIds.push(String(id)) + } + } else if (typeof result === 'object' && result !== null) { + let i = 0 + while (result[`id_${i}`] !== undefined || result[`EWREST_id_${i}`] !== undefined) { + const id = result[`id_${i}`] ?? result[`EWREST_id_${i}`] + recordIds.push(String(id)) + i++ + } + if (recordIds.length === 0 && result.id !== undefined) { + recordIds.push(String(result.id)) + } + } + + const totalCountRaw = + result.EWREST_id_length ?? + result.totalCount ?? + result.total ?? + result.count ?? + data.EWREST_id_length ?? + data.totalCount ?? + data.total ?? + data.count ?? + recordIds.length + + return { + success: data.success !== false, + output: { + recordIds, + totalCount: Number(totalCountRaw), + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error selecting Agiloft records:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/agiloft/update_record/route.ts b/apps/sim/app/api/tools/agiloft/update_record/route.ts new file mode 100644 index 00000000000..1691deade35 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/update_record/route.ts @@ -0,0 +1,101 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { agiloftUpdateRecordContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { AgiloftRecordResponse } from '@/tools/agiloft/types' +import { buildUpdateRecordUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftUpdateRecordAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Agiloft update_record attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + agiloftUpdateRecordContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + let body: string + try { + body = JSON.stringify(JSON.parse(params.data)) + } catch { + return NextResponse.json({ + success: false, + output: { id: null, fields: {} }, + error: 'Invalid JSON in data parameter', + }) + } + + const result = await executeAgiloftRequest( + params, + (base) => ({ + url: buildUpdateRecordUrl(base, params), + method: 'PUT', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body, + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { id: null, fields: {} }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = (await response.json()) as Record + const result = (data.result ?? data) as Record + const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null + + return { + success: data.success !== false, + output: { + id: id != null ? String(id) : null, + fields: result ?? {}, + }, + } + } + ) + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Error updating Agiloft record:`, error) + + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/grafana/update_alert_rule/route.ts b/apps/sim/app/api/tools/grafana/update_alert_rule/route.ts new file mode 100644 index 00000000000..095e8660c33 --- /dev/null +++ b/apps/sim/app/api/tools/grafana/update_alert_rule/route.ts @@ -0,0 +1,229 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { grafanaUpdateAlertRuleContract } from '@/lib/api/contracts/tools/grafana' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { mapAlertRule } from '@/tools/grafana/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('GrafanaUpdateAlertRuleAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn( + `[${requestId}] Unauthorized Grafana update alert rule attempt: ${authResult.error}` + ) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + grafanaUpdateAlertRuleContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const baseUrl = params.baseUrl.replace(/\/$/, '') + + const getHeaders: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + getHeaders['X-Grafana-Org-Id'] = params.organizationId + } + + const getUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}` + const getValidation = await validateUrlWithDNS(getUrl, 'baseUrl') + if (!getValidation.isValid || !getValidation.resolvedIP) { + return NextResponse.json({ + success: false, + output: {}, + error: `Invalid Grafana baseUrl: ${getValidation.error}`, + }) + } + + const getResponse = await secureFetchWithPinnedIP(getUrl, getValidation.resolvedIP, { + method: 'GET', + headers: getHeaders, + }) + + if (!getResponse.ok) { + const errorText = await getResponse.text() + return NextResponse.json({ + success: false, + output: {}, + error: `Failed to fetch existing alert rule: ${errorText}`, + }) + } + + const existingRule = (await getResponse.json()) as any + + if (!existingRule || !existingRule.uid) { + return NextResponse.json({ + success: false, + output: {}, + error: 'Failed to fetch existing alert rule', + }) + } + + const updatedRule: Record = { + ...existingRule, + } + + if (params.title) updatedRule.title = params.title + if (params.folderUid) updatedRule.folderUID = params.folderUid + if (params.ruleGroup) updatedRule.ruleGroup = params.ruleGroup + if (params.condition) updatedRule.condition = params.condition + if (params.forDuration) updatedRule.for = params.forDuration + if (params.noDataState) updatedRule.noDataState = params.noDataState + if (params.execErrState) updatedRule.execErrState = params.execErrState + if (params.isPaused !== undefined) updatedRule.isPaused = params.isPaused + if (params.keepFiringFor) updatedRule.keep_firing_for = params.keepFiringFor + if (params.missingSeriesEvalsToResolve !== undefined) { + updatedRule.missingSeriesEvalsToResolve = params.missingSeriesEvalsToResolve + } + + if (params.notificationSettings) { + try { + updatedRule.notification_settings = JSON.parse(params.notificationSettings) + } catch { + return NextResponse.json({ + success: false, + output: {}, + error: 'Invalid JSON for notificationSettings parameter', + }) + } + } + + if (params.record) { + try { + updatedRule.record = JSON.parse(params.record) + } catch { + return NextResponse.json({ + success: false, + output: {}, + error: 'Invalid JSON for record parameter', + }) + } + } + + if (params.data) { + try { + updatedRule.data = JSON.parse(params.data) + } catch { + return NextResponse.json({ + success: false, + output: {}, + error: 'Invalid JSON for data parameter', + }) + } + } + + if (params.annotations) { + try { + updatedRule.annotations = { + ...(existingRule.annotations || {}), + ...JSON.parse(params.annotations), + } + } catch { + return NextResponse.json({ + success: false, + output: {}, + error: 'Invalid JSON for annotations parameter', + }) + } + } + + if (params.labels) { + try { + updatedRule.labels = { + ...(existingRule.labels || {}), + ...JSON.parse(params.labels), + } + } catch { + return NextResponse.json({ + success: false, + output: {}, + error: 'Invalid JSON for labels parameter', + }) + } + } + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + headers['X-Grafana-Org-Id'] = params.organizationId + } + if (params.disableProvenance) { + headers['X-Disable-Provenance'] = 'true' + } + + const updateUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}` + const urlValidation = await validateUrlWithDNS(updateUrl, 'baseUrl') + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + return NextResponse.json({ + success: false, + output: {}, + error: `Invalid Grafana baseUrl: ${urlValidation.error}`, + }) + } + + const updateResponse = await secureFetchWithPinnedIP(updateUrl, urlValidation.resolvedIP, { + method: 'PUT', + headers, + body: JSON.stringify(updatedRule), + }) + + if (!updateResponse.ok) { + const errorText = await updateResponse.text() + return NextResponse.json({ + success: false, + output: {}, + error: `Failed to update alert rule: ${errorText}`, + }) + } + + const data = (await updateResponse.json()) as Record + return NextResponse.json({ success: true, output: mapAlertRule(data) }) + } catch (error) { + logger.error(`[${requestId}] Error updating Grafana alert rule:`, error) + return NextResponse.json({ + success: false, + output: {}, + error: getErrorMessage(error), + }) + } +}) diff --git a/apps/sim/app/api/tools/grafana/update_dashboard/route.ts b/apps/sim/app/api/tools/grafana/update_dashboard/route.ts new file mode 100644 index 00000000000..0f3d96b4464 --- /dev/null +++ b/apps/sim/app/api/tools/grafana/update_dashboard/route.ts @@ -0,0 +1,208 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { grafanaUpdateDashboardContract } from '@/lib/api/contracts/tools/grafana' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('GrafanaUpdateDashboardAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn( + `[${requestId}] Unauthorized Grafana update dashboard attempt: ${authResult.error}` + ) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + grafanaUpdateDashboardContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const baseUrl = params.baseUrl.replace(/\/$/, '') + + const getHeaders: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + getHeaders['X-Grafana-Org-Id'] = params.organizationId + } + + const getUrl = `${baseUrl}/api/dashboards/uid/${params.dashboardUid.trim()}` + const getValidation = await validateUrlWithDNS(getUrl, 'baseUrl') + if (!getValidation.isValid || !getValidation.resolvedIP) { + return NextResponse.json({ + success: false, + output: {}, + error: `Invalid Grafana baseUrl: ${getValidation.error}`, + }) + } + + const getResponse = await secureFetchWithPinnedIP(getUrl, getValidation.resolvedIP, { + method: 'GET', + headers: getHeaders, + }) + + if (!getResponse.ok) { + const errorText = await getResponse.text() + return NextResponse.json({ + success: false, + output: {}, + error: `Failed to fetch existing dashboard: ${errorText}`, + }) + } + + const existing = (await getResponse.json()) as any + const existingDashboard = existing.dashboard + const existingMeta = existing.meta + + if (!existingDashboard || !existingDashboard.uid) { + return NextResponse.json({ + success: false, + output: {}, + error: 'Failed to fetch existing dashboard', + }) + } + + const updatedDashboard: Record = { + ...existingDashboard, + } + + if (params.title) updatedDashboard.title = params.title + if (params.timezone) updatedDashboard.timezone = params.timezone + if (params.refresh) updatedDashboard.refresh = params.refresh + + if (params.tags) { + updatedDashboard.tags = params.tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t) + } + + if (params.panels) { + try { + updatedDashboard.panels = JSON.parse(params.panels) + } catch { + return NextResponse.json({ + success: false, + output: {}, + error: 'Invalid JSON for panels parameter', + }) + } + } + + if (existingDashboard.version) { + updatedDashboard.version = existingDashboard.version + } + + const body: Record = { + dashboard: updatedDashboard, + overwrite: params.overwrite === true, + } + + if (params.folderUid) { + body.folderUid = params.folderUid + } else if (existingMeta?.folderUid) { + body.folderUid = existingMeta.folderUid + } + + if (params.message) { + body.message = params.message + } + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + headers['X-Grafana-Org-Id'] = params.organizationId + } + + const updateUrl = `${baseUrl}/api/dashboards/db` + const urlValidation = await validateUrlWithDNS(updateUrl, 'baseUrl') + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + return NextResponse.json({ + success: false, + output: {}, + error: `Invalid Grafana baseUrl: ${urlValidation.error}`, + }) + } + + const updateResponse = await secureFetchWithPinnedIP(updateUrl, urlValidation.resolvedIP, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + if (!updateResponse.ok) { + const errorText = await updateResponse.text() + return NextResponse.json({ + success: false, + output: {}, + error: `Failed to update dashboard: ${errorText}`, + }) + } + + const data = (await updateResponse.json()) as { + id?: number + uid?: string + url?: string + status?: string + version?: number + slug?: string + } + + return NextResponse.json({ + success: true, + output: { + id: data.id, + uid: data.uid, + url: data.url, + status: data.status, + version: data.version, + slug: data.slug, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error updating Grafana dashboard:`, error) + return NextResponse.json({ + success: false, + output: {}, + error: getErrorMessage(error), + }) + } +}) diff --git a/apps/sim/app/api/tools/grafana/update_folder/route.ts b/apps/sim/app/api/tools/grafana/update_folder/route.ts new file mode 100644 index 00000000000..3e551e192aa --- /dev/null +++ b/apps/sim/app/api/tools/grafana/update_folder/route.ts @@ -0,0 +1,148 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { grafanaUpdateFolderContract } from '@/lib/api/contracts/tools/grafana' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('GrafanaUpdateFolderAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Grafana update folder attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + grafanaUpdateFolderContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const baseUrl = params.baseUrl.replace(/\/$/, '') + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + headers['X-Grafana-Org-Id'] = params.organizationId + } + + const folderUrl = `${baseUrl}/api/folders/${params.folderUid.trim()}` + const urlValidation = await validateUrlWithDNS(folderUrl, 'baseUrl') + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + return NextResponse.json({ + success: false, + output: {}, + error: `Invalid Grafana baseUrl: ${urlValidation.error}`, + }) + } + + const getResponse = await secureFetchWithPinnedIP(folderUrl, urlValidation.resolvedIP, { + method: 'GET', + headers, + }) + + if (!getResponse.ok) { + const errorText = await getResponse.text() + return NextResponse.json({ + success: false, + output: {}, + error: `Failed to fetch existing folder: ${errorText}`, + }) + } + + const existingFolder = (await getResponse.json()) as any + + if (!existingFolder || !existingFolder.uid) { + return NextResponse.json({ + success: false, + output: {}, + error: 'Failed to fetch existing folder', + }) + } + + const body: Record = { + title: params.title ?? existingFolder.title, + version: existingFolder.version, + overwrite: true, + } + + const updateResponse = await secureFetchWithPinnedIP(folderUrl, urlValidation.resolvedIP, { + method: 'PUT', + headers, + body: JSON.stringify(body), + }) + + if (!updateResponse.ok) { + const errorText = await updateResponse.text() + return NextResponse.json({ + success: false, + output: {}, + error: `Failed to update folder: ${errorText}`, + }) + } + + const data = (await updateResponse.json()) as Record + + return NextResponse.json({ + success: true, + output: { + id: (data.id as number) ?? null, + uid: (data.uid as string) ?? null, + title: (data.title as string) ?? null, + url: (data.url as string) ?? null, + parentUid: (data.parentUid as string) ?? null, + parents: (data.parents as { uid: string; title: string; url: string }[]) ?? [], + hasAcl: (data.hasAcl as boolean) ?? null, + canSave: (data.canSave as boolean) ?? null, + canEdit: (data.canEdit as boolean) ?? null, + canAdmin: (data.canAdmin as boolean) ?? null, + createdBy: (data.createdBy as string) ?? null, + created: (data.created as string) ?? null, + updatedBy: (data.updatedBy as string) ?? null, + updated: (data.updated as string) ?? null, + version: (data.version as number) ?? null, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error updating Grafana folder:`, error) + return NextResponse.json({ + success: false, + output: {}, + error: getErrorMessage(error), + }) + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index f62abbd9227..2266d160c7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -35,7 +35,7 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { CONNECTOR_REGISTRY } from '@/connectors/registry' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge' import { useBulkChunkOperation, @@ -278,7 +278,7 @@ export function Document({ const knowledgeBaseCrumbLabel = knowledgeBase?.name || knowledgeBaseName || '…' const documentCrumbLabel = documentData?.filename || documentName || '…' const ConnectorIcon = documentData?.connectorType - ? CONNECTOR_REGISTRY[documentData.connectorType]?.icon + ? CONNECTOR_META_REGISTRY[documentData.connectorType]?.icon : null const DocumentIcon = ConnectorIcon || getDocumentIcon(documentData?.mimeType ?? '', effectiveDocumentName) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 095979ed277..51572bc459e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -61,7 +61,7 @@ import { import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { CONNECTOR_REGISTRY } from '@/connectors/registry' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBase, useKnowledgeBaseDocuments, @@ -926,7 +926,7 @@ export function KnowledgeBase({ connectors.length > 0 ? ( <> {connectors.map((connector) => { - const def = CONNECTOR_REGISTRY[connector.connectorType] + const def = CONNECTOR_META_REGISTRY[connector.connectorType] const ConnectorIcon = def?.icon return (