From ba2be5a5f2028afbe4b22a4f1d5b3f3afe8bfa34 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:35:45 +0000 Subject: [PATCH] feat: add OAuth connectors as CLI resource Implements OAuth connector management as a new CLI resource following the existing pattern for entities, functions, and agents. Changes: - Core resource structure (schema, config, api, deploy) - Zod schemas for connectors and API responses - File reading logic for connectors/*.jsonc files - API client functions (list, initiate, status, delete) - Push logic with OAuth flow (prompt URL, poll for completion) - Scope comparison accounting for auto-enhanced scopes - Edge case handling (different user, partial consent, scope mismatch) - CLI command: base44 connectors push - Integration with unified deploy command - Summary table output with status indicators Co-Authored-By: paveltarno --- src/cli/commands/connectors/index.ts | 1 + src/cli/commands/connectors/push.ts | 159 +++++++++++++++ src/cli/program.ts | 4 + src/core/project/config.ts | 5 +- src/core/project/deploy.ts | 9 +- src/core/project/schema.ts | 1 + src/core/project/types.ts | 2 + src/core/resources/connector/api.ts | 119 +++++++++++ src/core/resources/connector/config.ts | 51 +++++ src/core/resources/connector/deploy.ts | 241 +++++++++++++++++++++++ src/core/resources/connector/index.ts | 27 +++ src/core/resources/connector/resource.ts | 9 + src/core/resources/connector/schema.ts | 133 +++++++++++++ src/core/resources/index.ts | 1 + 14 files changed, 758 insertions(+), 4 deletions(-) create mode 100644 src/cli/commands/connectors/index.ts create mode 100644 src/cli/commands/connectors/push.ts create mode 100644 src/core/resources/connector/api.ts create mode 100644 src/core/resources/connector/config.ts create mode 100644 src/core/resources/connector/deploy.ts create mode 100644 src/core/resources/connector/index.ts create mode 100644 src/core/resources/connector/resource.ts create mode 100644 src/core/resources/connector/schema.ts diff --git a/src/cli/commands/connectors/index.ts b/src/cli/commands/connectors/index.ts new file mode 100644 index 0000000..89271d2 --- /dev/null +++ b/src/cli/commands/connectors/index.ts @@ -0,0 +1 @@ +export { getConnectorsPushCommand } from "./push.js"; diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts new file mode 100644 index 0000000..7650b1d --- /dev/null +++ b/src/cli/commands/connectors/push.ts @@ -0,0 +1,159 @@ +import { log } from "@clack/prompts"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { theme } from "@/cli/utils/theme.js"; +import { readProjectConfig } from "@/core/index.js"; +import type { + ConnectorPushResult, + ConnectorsPushSummary, +} from "@/core/resources/connector/index.js"; +import { pushConnectors } from "@/core/resources/connector/index.js"; + +/** + * Format a push result status with appropriate styling + */ +function formatStatus(result: ConnectorPushResult): string { + switch (result.status) { + case "ACTIVE": + return "✓ ACTIVE"; + case "SCOPE_MISMATCH": + return theme.colors.base44Orange("⚠ SCOPE_MISMATCH"); + case "PENDING_AUTH": + return theme.colors.base44Orange("✗ PENDING_AUTH"); + case "AUTH_FAILED": + return theme.colors.base44Orange("✗ AUTH_FAILED"); + case "DELETED": + return theme.styles.dim("🗑 DELETED"); + case "DIFFERENT_USER": + return theme.colors.base44Orange("✗ DIFFERENT_USER"); + } +} + +/** + * Print a summary table of push results + */ +function printSummaryTable(summary: ConnectorsPushSummary): void { + if (summary.results.length === 0) { + return; + } + + log.message(""); + log.message( + theme.styles.bold("┌─────────────────────────────────────────────────────────────────┐") + ); + log.message( + theme.styles.bold("│ Connectors Push Summary │") + ); + log.message( + theme.styles.bold("├──────────────────┬─────────────────┬────────────────────────────┤") + ); + log.message( + theme.styles.bold("│ Connector │ Status │ Details │") + ); + log.message( + theme.styles.bold("├──────────────────┼─────────────────┼────────────────────────────┤") + ); + + for (const result of summary.results) { + const connector = result.type.padEnd(16); + const statusText = formatStatus(result); + const status = statusText.padEnd(15); + const details = (result.details || result.error || "").substring(0, 28).padEnd(28); + log.message(`│ ${connector} │ ${status} │ ${details}│`); + } + + log.message( + theme.styles.bold("└──────────────────┴─────────────────┴────────────────────────────┘") + ); + log.message(""); +} + +/** + * Print warnings for connectors that need attention + */ +function printWarnings(summary: ConnectorsPushSummary): void { + const needsAttention = summary.results.filter( + (r) => + r.status === "SCOPE_MISMATCH" || + r.status === "PENDING_AUTH" || + r.status === "AUTH_FAILED" || + r.status === "DIFFERENT_USER" + ); + + if (needsAttention.length === 0) { + return; + } + + log.warn("Some connectors need attention:"); + for (const result of needsAttention) { + if (result.status === "SCOPE_MISMATCH") { + log.message( + ` • ${result.type}: Approved scopes differ from requested. Adjust connectors/${result.type}.jsonc or run \`base44 connectors push\` again to re-auth.` + ); + } else if (result.status === "PENDING_AUTH") { + log.message( + ` • ${result.type}: Authentication not completed. Run \`base44 connectors push\` to retry.` + ); + } else if (result.status === "AUTH_FAILED") { + log.message( + ` • ${result.type}: OAuth flow failed${result.error ? `: ${result.error}` : ""}.` + ); + } else if (result.status === "DIFFERENT_USER") { + log.message( + ` • ${result.type}: Another user already authorized this connector.` + ); + } + } +} + +async function pushConnectorsAction(): Promise { + const { connectors } = await readProjectConfig(); + + if (connectors.length === 0) { + return { outroMessage: "No connectors found in project" }; + } + + const connectorTypes = connectors.map((c) => c.type).join(", "); + log.info(`Found ${connectors.length} connectors to push: ${connectorTypes}`); + + // Push connectors (this handles OAuth flows interactively) + const summary = await pushConnectors(connectors); + + // Print summary table + printSummaryTable(summary); + + // Print warnings if needed + printWarnings(summary); + + if (summary.hasErrors) { + return { + outroMessage: theme.colors.base44Orange( + "Connectors push completed with errors" + ), + }; + } + + if (summary.hasWarnings) { + return { + outroMessage: theme.colors.base44Orange( + "Connectors push completed with warnings" + ), + }; + } + + return { outroMessage: "All connectors pushed successfully" }; +} + +export function getConnectorsPushCommand(context: CLIContext): Command { + return new Command("connectors") + .description("Manage OAuth connectors") + .addCommand( + new Command("push") + .description("Push local connectors to Base44") + .action(async () => { + await runCommand(pushConnectorsAction, { requireAuth: true }, context); + }) + ); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 8d01d27..61a83f5 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -3,6 +3,7 @@ import { getAgentsCommand } from "@/cli/commands/agents/index.js"; import { getLoginCommand } from "@/cli/commands/auth/login.js"; import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; +import { getConnectorsPushCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js"; @@ -44,6 +45,9 @@ export function createProgram(context: CLIContext): Command { // Register agents commands program.addCommand(getAgentsCommand(context)); + // Register connectors commands + program.addCommand(getConnectorsPushCommand(context)); + // Register functions commands program.addCommand(getFunctionsDeployCommand(context)); diff --git a/src/core/project/config.ts b/src/core/project/config.ts index 36738a0..5dc1abd 100644 --- a/src/core/project/config.ts +++ b/src/core/project/config.ts @@ -5,6 +5,7 @@ import { ConfigNotFoundError, SchemaValidationError } from "@/core/errors.js"; import { ProjectConfigSchema } from "@/core/project/schema.js"; import type { ProjectData, ProjectRoot } from "@/core/project/types.js"; import { agentResource } from "@/core/resources/agent/index.js"; +import { connectorResource } from "@/core/resources/connector/index.js"; import { entityResource } from "@/core/resources/entity/index.js"; import { functionResource } from "@/core/resources/function/index.js"; import { readJsonFile } from "@/core/utils/fs.js"; @@ -91,10 +92,11 @@ export async function readProjectConfig( const project = result.data; const configDir = dirname(configPath); - const [entities, functions, agents] = await Promise.all([ + const [entities, functions, agents, connectors] = await Promise.all([ entityResource.readAll(join(configDir, project.entitiesDir)), functionResource.readAll(join(configDir, project.functionsDir)), agentResource.readAll(join(configDir, project.agentsDir)), + connectorResource.readAll(join(configDir, project.connectorsDir)), ]); return { @@ -102,5 +104,6 @@ export async function readProjectConfig( entities, functions, agents, + connectors, }; } diff --git a/src/core/project/deploy.ts b/src/core/project/deploy.ts index 3f384f9..cc8aea4 100644 --- a/src/core/project/deploy.ts +++ b/src/core/project/deploy.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import type { ProjectData } from "@/core/project/types.js"; import { agentResource } from "@/core/resources/agent/index.js"; +import { connectorResource } from "@/core/resources/connector/index.js"; import { entityResource } from "@/core/resources/entity/index.js"; import { functionResource } from "@/core/resources/function/index.js"; import { deploySite } from "@/core/site/index.js"; @@ -12,13 +13,14 @@ import { deploySite } from "@/core/site/index.js"; * @returns true if there are entities, functions, agents, or a configured site to deploy */ export function hasResourcesToDeploy(projectData: ProjectData): boolean { - const { project, entities, functions, agents } = projectData; + const { project, entities, functions, agents, connectors } = projectData; const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasAgents = agents.length > 0; + const hasConnectors = connectors.length > 0; - return hasEntities || hasFunctions || hasAgents || hasSite; + return hasEntities || hasFunctions || hasAgents || hasConnectors || hasSite; } /** @@ -40,11 +42,12 @@ export interface DeployAllResult { export async function deployAll( projectData: ProjectData ): Promise { - const { project, entities, functions, agents } = projectData; + const { project, entities, functions, agents, connectors } = projectData; await entityResource.push(entities); await functionResource.push(functions); await agentResource.push(agents); + await connectorResource.push(connectors); if (project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); diff --git a/src/core/project/schema.ts b/src/core/project/schema.ts index a69af6f..287206e 100644 --- a/src/core/project/schema.ts +++ b/src/core/project/schema.ts @@ -32,6 +32,7 @@ export const ProjectConfigSchema = z.object({ entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), agentsDir: z.string().optional().default("agents"), + connectorsDir: z.string().optional().default("connectors"), }); export type SiteConfig = z.infer; diff --git a/src/core/project/types.ts b/src/core/project/types.ts index c910aa4..f1e8fa5 100644 --- a/src/core/project/types.ts +++ b/src/core/project/types.ts @@ -1,5 +1,6 @@ import type { ProjectConfig } from "@/core/project/schema.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; +import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; @@ -18,4 +19,5 @@ export interface ProjectData { entities: Entity[]; functions: BackendFunction[]; agents: AgentConfig[]; + connectors: ConnectorResource[]; } diff --git a/src/core/resources/connector/api.ts b/src/core/resources/connector/api.ts new file mode 100644 index 0000000..79c37d2 --- /dev/null +++ b/src/core/resources/connector/api.ts @@ -0,0 +1,119 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { + InitiateOAuthRequest, + InitiateOAuthResponse, + IntegrationType, + ListConnectorsResponse, + OAuthStatusResponse, +} from "@/core/resources/connector/schema.js"; +import { + InitiateOAuthResponseSchema, + ListConnectorsResponseSchema, + OAuthStatusResponseSchema, +} from "@/core/resources/connector/schema.js"; + +/** + * List all connectors for the current app + */ +export async function listConnectors(): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get("external-auth/list"); + } catch (error) { + throw await ApiError.fromHttpError(error, "listing connectors"); + } + + const result = ListConnectorsResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error + ); + } + + return result.data; +} + +/** + * Initiate OAuth flow for a connector + */ +export async function initiateOAuth( + request: InitiateOAuthRequest +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post("external-auth/initiate", { + json: request, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "initiating OAuth flow"); + } + + const result = InitiateOAuthResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error + ); + } + + return result.data; +} + +/** + * Poll OAuth status + */ +export async function pollOAuthStatus( + integrationType: IntegrationType, + connectionId: string +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get("external-auth/status", { + searchParams: { + integration_type: integrationType, + connection_id: connectionId, + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "polling OAuth status"); + } + + const result = OAuthStatusResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error + ); + } + + return result.data; +} + +/** + * Hard delete a connector (removes completely from upstream) + */ +export async function deleteConnector( + integrationType: IntegrationType +): Promise { + const appClient = getAppClient(); + + try { + await appClient.delete( + `external-auth/integrations/${integrationType}/remove` + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "deleting connector"); + } +} diff --git a/src/core/resources/connector/config.ts b/src/core/resources/connector/config.ts new file mode 100644 index 0000000..11cd0c5 --- /dev/null +++ b/src/core/resources/connector/config.ts @@ -0,0 +1,51 @@ +import { basename, extname } from "node:path"; +import { globby } from "globby"; +import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js"; +import { InvalidInputError, SchemaValidationError } from "@/core/errors.js"; +import type { ConnectorResource } from "@/core/resources/connector/schema.js"; +import { ConnectorResourceSchema } from "@/core/resources/connector/schema.js"; +import { pathExists, readJsonFile } from "@/core/utils/fs.js"; + +async function readConnectorFile( + connectorPath: string +): Promise { + const parsed = await readJsonFile(connectorPath); + const result = ConnectorResourceSchema.safeParse(parsed); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid connector file", + result.error, + connectorPath + ); + } + + // Validate that filename matches the type + const fileName = basename(connectorPath, extname(connectorPath)); + if (fileName !== result.data.type) { + throw new InvalidInputError( + `Connector file name "${fileName}" does not match type "${result.data.type}" in ${connectorPath}` + ); + } + + return result.data; +} + +export async function readAllConnectors( + connectorsDir: string +): Promise { + if (!(await pathExists(connectorsDir))) { + return []; + } + + const files = await globby(`*.${CONFIG_FILE_EXTENSION_GLOB}`, { + cwd: connectorsDir, + absolute: true, + }); + + const connectors = await Promise.all( + files.map((filePath) => readConnectorFile(filePath)) + ); + + return connectors; +} diff --git a/src/core/resources/connector/deploy.ts b/src/core/resources/connector/deploy.ts new file mode 100644 index 0000000..e083d30 --- /dev/null +++ b/src/core/resources/connector/deploy.ts @@ -0,0 +1,241 @@ +import * as prompts from "@clack/prompts"; +import { + deleteConnector, + initiateOAuth, + listConnectors, + pollOAuthStatus, +} from "@/core/resources/connector/api.js"; +import type { + ConnectorPushResult, + ConnectorResource, + ConnectorsPushSummary, + IntegrationType, + UpstreamIntegration, +} from "@/core/resources/connector/schema.js"; + +// Auto-added scopes by Apper for identity extraction +const AUTO_ADDED_SCOPES: Record = { + googlecalendar: ["email"], + googledrive: ["email"], + gmail: ["email"], + googlesheets: ["email"], + googledocs: ["email"], + googleslides: ["email"], + slack: ["users:read", "users:read.email"], + salesforce: ["openid", "profile", "email"], + hubspot: ["oauth"], + linkedin: ["openid", "profile", "email"], + tiktok: ["user.info.basic"], + notion: [], +}; + +/** + * Check if scopes match, accounting for auto-added scopes + */ +function scopesMatch( + localScopes: string[], + upstreamScopes: string[], + integrationType: IntegrationType +): boolean { + const autoAdded = AUTO_ADDED_SCOPES[integrationType] || []; + const expectedScopes = [...localScopes, ...autoAdded].sort(); + const actualScopes = [...upstreamScopes].sort(); + + return ( + expectedScopes.length === actualScopes.length && + expectedScopes.every((scope, i) => scope === actualScopes[i]) + ); +} + +/** + * Poll OAuth status with timeout + */ +async function waitForOAuthCompletion( + integrationType: IntegrationType, + connectionId: string, + timeoutMs = 300000 // 5 minutes +): Promise<"ACTIVE" | "FAILED" | "TIMEOUT"> { + const startTime = Date.now(); + const pollInterval = 2000; // 2 seconds + + while (Date.now() - startTime < timeoutMs) { + const statusResponse = await pollOAuthStatus(integrationType, connectionId); + + if (statusResponse.status === "ACTIVE") { + return "ACTIVE"; + } + + if (statusResponse.status === "FAILED") { + return "FAILED"; + } + + // Still pending, wait and poll again + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + return "TIMEOUT"; +} + +/** + * Handle OAuth flow for a connector + */ +async function handleOAuthFlow( + connector: ConnectorResource, + forceReconnect = false +): Promise { + try { + // Initiate OAuth + const initResponse = await initiateOAuth({ + integration_type: connector.type, + scopes: connector.scopes, + force_reconnect: forceReconnect, + }); + + // Show auth URL to user + prompts.log.info(`Please authorize ${connector.type}:`); + prompts.log.message(initResponse.redirect_url); + prompts.log.step("Waiting for authorization..."); + + // Poll for completion + const result = await waitForOAuthCompletion( + connector.type, + initResponse.connection_id + ); + + if (result === "ACTIVE") { + return { + type: connector.type, + status: "ACTIVE", + details: `${connector.scopes.length} scopes`, + }; + } + + if (result === "FAILED") { + return { + type: connector.type, + status: "AUTH_FAILED", + error: "OAuth flow failed", + }; + } + + // Timeout + return { + type: connector.type, + status: "PENDING_AUTH", + error: "User did not complete authorization", + }; + } catch (error) { + return { + type: connector.type, + status: "AUTH_FAILED", + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +/** + * Process a single connector + */ +async function processConnector( + connector: ConnectorResource, + upstream: UpstreamIntegration | undefined +): Promise { + // Case: No upstream or disconnected/expired -> needs auth + if ( + !upstream || + upstream.status === "DISCONNECTED" || + upstream.status === "EXPIRED" + ) { + return handleOAuthFlow(connector); + } + + // Case: Active upstream - check scopes + if (!scopesMatch(connector.scopes, upstream.scopes, connector.type)) { + // Scopes don't match - need re-auth + const reauthed = await handleOAuthFlow(connector, true); + + // If re-auth succeeded, check if scopes still mismatch (partial consent) + if (reauthed.status === "ACTIVE") { + // Re-fetch to get actual approved scopes + const list = await listConnectors(); + const updated = list.integrations.find( + (i) => i.integration_type === connector.type + ); + + if ( + updated && + !scopesMatch(connector.scopes, updated.scopes, connector.type) + ) { + return { + type: connector.type, + status: "SCOPE_MISMATCH", + details: `Requested ${connector.scopes.length}, approved ${updated.scopes.length}`, + }; + } + } + + return reauthed; + } + + // Scopes match, all good + return { + type: connector.type, + status: "ACTIVE", + details: `${connector.scopes.length} scopes`, + }; +} + +/** + * Push connectors to upstream + */ +export async function pushConnectors( + connectors: ConnectorResource[] +): Promise { + if (connectors.length === 0) { + return { results: [], hasWarnings: false, hasErrors: false }; + } + + // Fetch current upstream state + const upstreamList = await listConnectors(); + const upstreamMap = new Map( + upstreamList.integrations.map((i) => [i.integration_type, i]) + ); + + const localTypes = new Set(connectors.map((c) => c.type)); + const results: ConnectorPushResult[] = []; + + // Process local connectors sequentially (interactive flow) + for (const connector of connectors) { + const upstream = upstreamMap.get(connector.type); + const result = await processConnector(connector, upstream); + results.push(result); + } + + // Find connectors to delete (exist upstream but not locally) + for (const [type, _] of upstreamMap) { + if (!localTypes.has(type)) { + try { + await deleteConnector(type); + results.push({ + type, + status: "DELETED", + details: "Removed (no local definition)", + }); + } catch (error) { + results.push({ + type, + status: "AUTH_FAILED", + error: `Failed to delete: ${error instanceof Error ? error.message : "Unknown error"}`, + }); + } + } + } + + // Check for warnings/errors + const hasWarnings = results.some((r) => r.status === "SCOPE_MISMATCH"); + const hasErrors = results.some( + (r) => r.status === "AUTH_FAILED" || r.status === "PENDING_AUTH" + ); + + return { results, hasWarnings, hasErrors }; +} diff --git a/src/core/resources/connector/index.ts b/src/core/resources/connector/index.ts new file mode 100644 index 0000000..1ba0367 --- /dev/null +++ b/src/core/resources/connector/index.ts @@ -0,0 +1,27 @@ +export { readAllConnectors } from "./config.js"; +export { pushConnectors } from "./deploy.js"; +export { connectorResource } from "./resource.js"; +export type { + ConnectorPushResult, + ConnectorResource, + ConnectorsPushSummary, + ConnectorStatus, + InitiateOAuthRequest, + InitiateOAuthResponse, + IntegrationType, + ListConnectorsResponse, + OAuthStatus, + OAuthStatusResponse, + UpstreamIntegration, +} from "./schema.js"; +export { + ConnectorResourceSchema, + ConnectorStatusSchema, + InitiateOAuthRequestSchema, + InitiateOAuthResponseSchema, + IntegrationTypeSchema, + ListConnectorsResponseSchema, + OAuthStatusResponseSchema, + OAuthStatusSchema, + UpstreamIntegrationSchema, +} from "./schema.js"; diff --git a/src/core/resources/connector/resource.ts b/src/core/resources/connector/resource.ts new file mode 100644 index 0000000..f9e58a9 --- /dev/null +++ b/src/core/resources/connector/resource.ts @@ -0,0 +1,9 @@ +import { readAllConnectors } from "@/core/resources/connector/config.js"; +import { pushConnectors } from "@/core/resources/connector/deploy.js"; +import type { ConnectorResource } from "@/core/resources/connector/schema.js"; +import type { Resource } from "@/core/resources/types.js"; + +export const connectorResource: Resource = { + readAll: readAllConnectors, + push: pushConnectors, +}; diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts new file mode 100644 index 0000000..81521d1 --- /dev/null +++ b/src/core/resources/connector/schema.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; + +/** + * All supported OAuth integration types + */ +export const IntegrationTypeSchema = z.enum([ + "googlecalendar", + "googledrive", + "gmail", + "googlesheets", + "googledocs", + "googleslides", + "slack", + "notion", + "salesforce", + "hubspot", + "linkedin", + "tiktok", +]); + +export type IntegrationType = z.infer; + +/** + * Local connector resource file schema + * Represents a single connector configuration file (e.g., connectors/googlecalendar.jsonc) + */ +export const ConnectorResourceSchema = z.object({ + type: IntegrationTypeSchema, + scopes: z.array(z.string()).min(0), +}); + +export type ConnectorResource = z.infer; + +/** + * Upstream connector status from API + */ +export const ConnectorStatusSchema = z.enum([ + "ACTIVE", + "DISCONNECTED", + "EXPIRED", +]); + +export type ConnectorStatus = z.infer; + +/** + * Upstream integration from list endpoint + */ +export const UpstreamIntegrationSchema = z.object({ + integration_type: IntegrationTypeSchema, + status: ConnectorStatusSchema, + scopes: z.array(z.string()), + user_email: z.string().optional(), +}); + +export type UpstreamIntegration = z.infer; + +/** + * List connectors response + */ +export const ListConnectorsResponseSchema = z.object({ + integrations: z.array(UpstreamIntegrationSchema), +}); + +export type ListConnectorsResponse = z.infer< + typeof ListConnectorsResponseSchema +>; + +/** + * Initiate OAuth flow request + */ +export const InitiateOAuthRequestSchema = z.object({ + integration_type: IntegrationTypeSchema, + scopes: z.array(z.string()), + force_reconnect: z.boolean().optional(), +}); + +export type InitiateOAuthRequest = z.infer; + +/** + * Initiate OAuth flow response + */ +export const InitiateOAuthResponseSchema = z.object({ + redirect_url: z.string(), + connection_id: z.string(), + already_authorized: z.boolean(), +}); + +export type InitiateOAuthResponse = z.infer< + typeof InitiateOAuthResponseSchema +>; + +/** + * OAuth status from polling endpoint + */ +export const OAuthStatusSchema = z.enum(["ACTIVE", "FAILED", "PENDING"]); + +export type OAuthStatus = z.infer; + +/** + * OAuth status response + */ +export const OAuthStatusResponseSchema = z.object({ + status: OAuthStatusSchema, + error: z.string().optional(), + user_email: z.string().optional(), +}); + +export type OAuthStatusResponse = z.infer; + +/** + * Push result for a single connector + */ +export interface ConnectorPushResult { + type: IntegrationType; + status: + | "ACTIVE" + | "SCOPE_MISMATCH" + | "PENDING_AUTH" + | "AUTH_FAILED" + | "DELETED" + | "DIFFERENT_USER"; + details?: string; + error?: string; +} + +/** + * Overall push summary + */ +export interface ConnectorsPushSummary { + results: ConnectorPushResult[]; + hasWarnings: boolean; + hasErrors: boolean; +} diff --git a/src/core/resources/index.ts b/src/core/resources/index.ts index 191bbdb..93ddcc2 100644 --- a/src/core/resources/index.ts +++ b/src/core/resources/index.ts @@ -1,4 +1,5 @@ export * from "./agent/index.js"; +export * from "./connector/index.js"; export * from "./entity/index.js"; export * from "./function/index.js"; export type { Resource } from "./types.js";