From 5f6fa0829344aea5e58fa15a01b32f6be6403145 Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Wed, 4 Feb 2026 11:03:14 +0200 Subject: [PATCH 01/13] feat(connectors): add OAuth connector resource schemas and file parsing Add connector resource module supporting 12 OAuth providers: googlecalendar, googledrive, gmail, googlesheets, googledocs, googleslides, slack, notion, salesforce, hubspot, linkedin, tiktok. - Zod discriminated union schema with type discriminator per provider - JSDoc links to official OAuth scope documentation for each provider - JSONC file reading with validation (filename must match type field) - API response schemas for upstream connector state - Unit tests with fixtures for valid, invalid, and mismatched connectors Part of: https://github.com/base44/cli/issues/184 Co-Authored-By: Claude Opus 4.5 --- plan.md | 689 ++++++++---------- src/core/resources/connector/config.ts | 77 ++ src/core/resources/connector/index.ts | 3 + src/core/resources/connector/resource.ts | 16 + src/core/resources/connector/schema.ts | 168 +++++ src/core/resources/index.ts | 1 + tests/core/connectors.spec.ts | 148 ++++ .../connector-type-mismatch/base44/.app.jsonc | 4 + .../connector-type-mismatch/config.jsonc | 3 + .../connectors/googlecalendar.jsonc | 5 + .../invalid-connector/base44/.app.jsonc | 4 + tests/fixtures/invalid-connector/config.jsonc | 3 + .../connectors/invalid.jsonc | 5 + .../with-connectors/base44/.app.jsonc | 4 + tests/fixtures/with-connectors/config.jsonc | 3 + .../connectors/googlecalendar.jsonc | 8 + .../with-connectors/connectors/notion.json | 4 + .../with-connectors/connectors/slack.jsonc | 8 + 18 files changed, 769 insertions(+), 384 deletions(-) create mode 100644 src/core/resources/connector/config.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 create mode 100644 tests/core/connectors.spec.ts create mode 100644 tests/fixtures/connector-type-mismatch/base44/.app.jsonc create mode 100644 tests/fixtures/connector-type-mismatch/config.jsonc create mode 100644 tests/fixtures/connector-type-mismatch/connectors/googlecalendar.jsonc create mode 100644 tests/fixtures/invalid-connector/base44/.app.jsonc create mode 100644 tests/fixtures/invalid-connector/config.jsonc create mode 100644 tests/fixtures/invalid-connector/connectors/invalid.jsonc create mode 100644 tests/fixtures/with-connectors/base44/.app.jsonc create mode 100644 tests/fixtures/with-connectors/config.jsonc create mode 100644 tests/fixtures/with-connectors/connectors/googlecalendar.jsonc create mode 100644 tests/fixtures/with-connectors/connectors/notion.json create mode 100644 tests/fixtures/with-connectors/connectors/slack.jsonc diff --git a/plan.md b/plan.md index edb9d136..9723c996 100644 --- a/plan.md +++ b/plan.md @@ -1,392 +1,313 @@ -# CLI Implementation Plan +# OAuth Connectors CLI Implementation Plan + +Reference issue: https://github.com/base44/cli/issues/184 ## Overview -Generate a comprehensive CLI tool that provides a unified interface for managing Base44 applications, entities, functions, deployments, and related services. - -## Core Architecture - -### Project Structure -- **Package**: `base44` - Single package published to npm -- **Core Module**: `src/core/` - Shared utilities, API clients, schemas, and configuration management -- **CLI Module**: `src/cli/` - CLI commands and entry point -- **Build System**: TypeScript compiler (`tsc`) for production builds -- **Package Manager**: npm for dependency management - -### CLI Framework -- **Technology**: TypeScript with Commander.js for CLI framework -- **Structure**: Command-based architecture with subcommands -- **CLI Name**: `base44` -- **User Prompts**: Use `@clack/prompts` for interactive user prompts -- **Package Distribution**: Support for multiple package managers - - Homebrew (brew) - macOS/Linux - - Scoop - Windows - - npm - Node.js ecosystem (package published to npm as `base44`) - -## Feature Implementation Plan - -### 1. Authentication & Identity -- **`base44 login`** - - Device-based authentication - - OAuth flow implementation - - Token storage and management - - Session persistence - -- **`base44 whoami`** - - Display current authenticated user - - Show account information - - Display active session details - -- **`base44 logout`** - - Clear authentication tokens - - Remove session data - - Logout from current device - -### 2. Entities Management -- **`base44 entities`** - - List all entities in the project - - Display entity hierarchy (e.g., entities → auth.group) - -- **`base44 entities pull`** - - Read schemas from remote - - Sync entity definitions - - Download entity configurations - - **Validate downloaded schemas using Zod** - Ensure schema integrity - -- **`base44 entities push`** - - Write schemas to remote - - Upload entity definitions - - **Validate before pushing using Zod schemas** - Local validation before upload - - Schema structure verification - -### 3. Functions Management -- **`base44 functions`** - - List all functions - - Display function hierarchy (e.g., functions → hello.IT) - - Show function metadata - -- **`base44 functions [function-name]`** - - View specific function details - - Show function code, triggers, and configuration - -- **Cron Jobs Support** - - Schedule management - - **Zod validation for cron expressions** - Validate cron syntax - - Job listing and status - -### 4. Development Environment -- **`base44 dev`** - - Start local development server - - Hot reload for functions - - Local testing environment - -- **`base44 dev --functions`** - - Development mode with function support - - Function hot-reloading - -### 5. Deployment -- **`base44 deploy`** - - Deploy entire application - - Environment selection (staging/production) - -- **`base44 deploy --client`** - - Deploy client-side application only - - Frontend build and deployment - -- **`base44 deploy --fullstack`** - - Deploy full-stack application - - Backend + Frontend deployment - - Database migrations - -### 6. Project Initialization -- **`base44 new`** / **`base44 init`** / **`base44 create`** - - Initialize new Base44 project - - Project scaffolding - -- **`base44 new --blank`** - - Create blank project template - - Minimal project structure - -- **`base44 new --example`** - - Create project from example template - - Pre-configured starter project - -### 7. Linking & Integration -- **`base44 link`** - - Link local project to remote Base44 project - - Establish connection between local and cloud - - Configure project association - -### 8. AI Features -- **`base44 ai`** - - AI-powered assistance - - Code generation and suggestions - -- **`base44 ai does [prompt]`** - - Execute AI commands - - Natural language to CLI actions - - Intelligent task automation - -### 9. Secrets Management -- **`base44 secrets`** - - List all secrets - - Display secret metadata (not values) - -- **`base44 secrets get [key]`** - - Retrieve specific secret value - - Secure secret retrieval - - Environment variable export option - -- **`base44 secrets set [key] [value]`** - - Set or update secret value - - Secure secret storage - - **Zod validation for secret keys and values** - Ensure proper format - - Encryption - - Interactive prompts for secret input using `@clack/prompts` - -### 10. Domains Management -- **`base44 domains`** - - List configured domains - - Show domain status and configuration - - Display SSL certificate information - -- **`base44 domains add [domain]`** - - Add new domain - - **Zod validation for domain format** - Ensure valid domain structure - - DNS configuration assistance - - Interactive domain setup using `@clack/prompts` - -- **`base44 domains remove [domain]`** - - Remove domain configuration - -## Implementation Phases - -### Phase 0: Skeleton -**Goal**: Set up the basic project structure and create placeholder commands for authentication. - -1. **Project Structure Setup** - - Create project folder structure: + +Add OAuth connectors as a CLI resource, allowing users to define connector configurations in `connectors/*.jsonc` files and push them to Base44 apps. + +--- + +## Task 1: Resource File Parsing + +### Objective +Read and validate connector JSONC files from the `connectors/` directory. + +### Subtasks + +1.1. **Define Zod schemas** + - `IntegrationTypeSchema` - enum of supported integration types: ``` - cli/ - ├── src/ - │ ├── core/ # Core module - │ │ ├── api/ # API client code - │ │ ├── config/ # Configuration management - │ │ ├── schemas/ # Zod schemas - │ │ ├── utils/ # Utility functions - │ │ ├── types/ # TypeScript type definitions - │ │ └── index.ts # Core module exports - │ └── cli/ # CLI module (main CLI) - │ ├── commands/ - │ │ └── auth/ - │ │ ├── login.ts - │ │ ├── whoami.ts - │ │ └── logout.ts - │ ├── utils/ # CLI-specific utilities - │ │ ├── index.ts - │ │ ├── packageVersion.ts - │ │ └── runCommand.ts - │ └── index.ts # Main CLI entry point (with shebang) - ├── dist/ # Build output - ├── package.json # Package configuration - ├── tsconfig.json # TypeScript configuration - ├── .gitignore - └── README.md + googlecalendar, googledrive, gmail, googlesheets, googledocs, googleslides, + slack, notion, salesforce, hubspot, linkedin, tiktok ``` - -2. **Build Process & Configuration** - - Set up TypeScript configuration (`tsconfig.json`) - - Configure output directory structure (`dist/cli/`) - - **ES Modules**: Package uses `"type": "module"` for ES module support - - **Development**: Use `tsx` for development/watch mode - - **Production**: Use `tsdown` to bundle all code and dependencies into single file - - **Zero Dependencies**: All packages bundled - -3. **Package.json Setup** - - **Package** (`base44`): - - All dependencies in `devDependencies` (bundled at build time): - - `zod` - Schema validation - - `commander` - CLI framework - - `@clack/prompts` - User prompts and UI components - - `chalk` - Terminal colors (Base44 brand color: #E86B3C) - - `json5` - JSONC/JSON5 config parsing - - `ky` - HTTP client - - `ejs` - Template rendering - - `globby` - File globbing - - `dotenv` - Environment variables - - Zero runtime `dependencies` - everything bundled - - Set up bin entry point for CLI executable (`./dist/cli/index.js`) - - Set up build and dev scripts - - **Shebang**: Main entry point (`src/cli/index.ts`) includes `#!/usr/bin/env node` - -4. **Authentication Commands (Implemented)** - - Create `base44 login` command - - Use Commander.js to register command - - Use `@clack/prompts` tasks for async operations - - Store auth data using `writeAuth` from `src/core/config/auth.js` - - Wrap with `runCommand` utility for consistent branding - - Create `base44 whoami` command - - Use Commander.js to register command - - Read auth data using `readAuth` from `src/core/config/auth.js` - - Display user information using `@clack/prompts` log - - Wrap with `runCommand` utility for consistent branding - - Create `base44 logout` command - - Use Commander.js to register command - - Delete auth data using `deleteAuth` from `src/core/config/auth.js` - - Wrap with `runCommand` utility for consistent branding - - Ensure all commands are properly registered in main CLI entry point - - Test that commands are accessible and show help text - -5. **Import Structure** - - Set up proper ES module imports/exports (`.js` extensions in imports) - - Create barrel exports for command modules if needed - - Ensure TypeScript path resolution works correctly - - Use ES module syntax throughout (`import`/`export`) - -**Deliverables**: -- ✅ Complete folder structure -- ✅ Working build process (tsc for production, tsx for development) -- ✅ Package.json with all scripts -- ✅ Three auth commands (login, whoami, logout) fully implemented -- ✅ CLI can be run and commands respond with help text -- ✅ Base44 branding via `runCommand` utility wrapper - -### Phase 1: Foundation -1. ✅ Implement Commander.js command framework -2. ✅ Integrate `@clack/prompts` for user interactions -3. ✅ Set up Zod schema validation infrastructure - - ✅ Create base schemas for auth data (`AuthDataSchema`) - - ✅ Create config file schemas - - ✅ Set up validation utilities -4. ✅ Create authentication system (`base44 login`, `base44 whoami`, `base44 logout`) - - ✅ Auth data stored in `~/.base44/auth/auth.json` - - ✅ Zod validation for auth data - - ✅ Cross-platform file system utilities - - ✅ Error handling with user-friendly messages -5. Package manager distribution setup (npm, brew, scoop) - -### Phase 2: Core Features -1. Entities management (`base44 entities`, `pull`, `push`) -2. Functions listing and management -3. Project initialization (`base44 new`, `base44 init`, `base44 create`) -4. Linking functionality (`base44 link`) - -### Phase 3: Development & Deployment -1. Development server (`base44 dev`) -2. Deployment commands (`base44 deploy --client`, `base44 deploy --fullstack`) -3. Cron job management integration - -### Phase 4: Advanced Features -1. Secrets management (`base44 secrets get`, `base44 secrets set`) -2. Domains management (`base44 domains`) -3. AI integration (`base44 ai`, `base44 ai does`) - -### Phase 5: Polish & Distribution -1. Error handling and validation -2. Help documentation and examples -3. Package distribution (brew, scoop, npm via GitHub Actions) -4. Testing and quality assurance - -## Technical Considerations - -### Configuration -- **Global Auth Config**: Stored in `~/.base44/auth/auth.json` (managed by `src/core/config/auth.ts`) -- Local config file (`.base44/config.json` or similar) - for project-specific settings -- Global config for user preferences -- Environment-specific settings -- **Zod schema validation for all configuration files** - Validate config structure and values -- Type-safe config access using Zod-inferred types -- **File System Utilities**: Cross-platform file operations in `src/core/utils/fs.ts` - -### API Integration -- REST API client for Base44 services (using `fetch` or `axios`) -- Authentication token management -- Rate limiting and retry logic -- Error handling and user feedback -- **Zod schema validation for all API responses** - Validate and type-check API responses at runtime -- TypeScript types generated from Zod schemas for type safety - -### Build & Distribution -- **Project Structure** - Single package with `core` and `cli` modules -- **ES Modules** - Package uses `"type": "module"` for native ES module support -- **Zero-Dependency Distribution** - All runtime dependencies bundled into single JS file -- **Build Tools**: - - Production: `tsdown` bundles all code and dependencies into `dist/cli/index.js` - - Development: `tsx` for fast watch mode and direct TypeScript execution - - Type checking: `tsc --noEmit` for validation without emitting files -- **CLI Entry Point**: `src/cli/index.ts` includes shebang (`#!/usr/bin/env node`) -- GitHub Actions for automated builds and npm releases - -### Security -- Secure credential storage -- Encrypted secret management -- Token refresh mechanisms -- **Zod-based input validation and sanitization** - Validate all user inputs and CLI arguments -- Schema validation for secrets and sensitive data - -### User Experience -- **Base44 Branding**: All commands wrapped with `runCommand` utility showing Base44 intro banner (color: #E86B3C) -- Clear error messages with try-catch error handling -- Progress indicators for long operations using `@clack/prompts` tasks -- Interactive prompts using `@clack/prompts` for better UX -- Comprehensive help system via Commander.js -- Spinners and loading states for async operations -- **Command Wrapper Pattern**: All commands use `runCommand()` utility for consistent branding and error handling - -## Schema Validation with Zod - -### API Response Validation -- Define Zod schemas for all API endpoints -- Validate API responses before processing -- Type-safe API client with inferred types from Zod schemas -- Clear error messages when API responses don't match expected schema -- Examples: - - `UserSchema` for authentication responses - - `EntitySchema` for entity definitions - - `FunctionSchema` for function metadata - - `DeploymentSchema` for deployment status - - `SecretSchema` for secrets management - - `DomainSchema` for domain configurations - -### Configuration File Validation -- Zod schemas for all configuration files: - - `.base44/config.json` - Project configuration - - Global config files - - Entity schema files - - Function configuration files -- Validate on read to catch configuration errors early -- Type-safe config access throughout the application - -### File Schema Validation -- Validate entity schema files before push operations -- Validate function definitions and configurations -- Validate project structure files -- Ensure data integrity before syncing with remote - -### Input Validation -- Validate CLI command arguments using Zod -- Validate user input from prompts -- Validate environment variables -- Validate secrets before storage + - `ConnectorResourceSchema` - object with `type` and `scopes` array + +1.2. **File structure** + ``` + connectors/ + ├── googlecalendar.jsonc + ├── slack.jsonc + └── notion.jsonc + ``` + +1.3. **Resource schema** + ```jsonc + // connectors/googlecalendar.jsonc + { + "type": "googlecalendar", + "scopes": [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events" + ] + } + ``` + +1.4. **Validation behavior** + | Scenario | Behavior | + |----------|----------| + | Unknown integration type | Error: reject file | + | Unknown scope for integration | Warning only (OAuth provider validates) | + | Empty scopes array | Warning (except Notion) | + | Missing `type` field | Error: reject file | + +1.5. **Implementation location** + - Create `src/resources/connectors/` directory + - Add `schema.ts` for Zod schemas + - Add `reader.ts` for file reading logic + +--- + +## Task 2: API Client + +### Objective +Add methods to communicate with apper backend endpoints. + +### Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/external-auth/auto-added-scopes` | GET | Get auto-added scopes mapping | +| `/api/apps/{app_id}/external-auth/list` | GET | List all connectors | +| `/api/apps/{app_id}/external-auth/initiate` | POST | Start OAuth flow | +| `/api/apps/{app_id}/external-auth/status` | GET | Poll for OAuth completion | +| `/api/apps/{app_id}/external-auth/integrations/{type}/remove` | DELETE | Hard delete connector | + +### Subtasks + +2.1. **Auto-added scopes endpoint** (no auth required) + ```typescript + async getAutoAddedScopes(): Promise> + ``` + - Response is highly cacheable (data rarely changes) + - Consider caching in CLI session + +2.2. **List connectors** + ```typescript + async listConnectors(appId: string): Promise + ``` + +2.3. **Initiate OAuth** + ```typescript + async initiateOAuth(appId: string, request: { + integration_type: string; + scopes: string[]; + force_reconnect: boolean; + }): Promise<{ + redirect_url: string; + connection_id: string; + already_authorized: boolean; + }> + ``` + +2.4. **Poll status** + ```typescript + async getOAuthStatus(appId: string, params: { + integration_type: string; + connection_id: string; + }): Promise<{ status: 'ACTIVE' | 'FAILED' | 'PENDING' }> + ``` + +2.5. **Hard delete** + ```typescript + async removeConnector(appId: string, type: string): Promise + ``` + +### Implementation location +- Add to existing API client or create `src/api/external-auth.ts` + +--- + +## Task 3: Push Comparison Logic + +### Objective +Compare local connector definitions with upstream state and determine required actions. + +### Scope Comparison Logic + +```typescript +function scopesMatch( + localScopes: string[], + upstreamScopes: string[], + autoAddedScopes: string[] +): boolean { + const expected = new Set([...localScopes, ...autoAddedScopes]); + const upstream = new Set(upstreamScopes); + + if (expected.size !== upstream.size) return false; + for (const scope of expected) { + if (!upstream.has(scope)) return false; + } + return true; +} +``` + +### Comparison Matrix + +| Local File | Upstream State | Scopes Match? | Action | +|------------|----------------|---------------|--------| +| Exists | Not exists | N/A | Prompt auth URL + poll | +| Exists | `DISCONNECTED` | N/A | Hard delete + prompt auth URL + poll | +| Exists | `EXPIRED` | N/A | Hard delete + prompt auth URL + poll | +| Exists | `ACTIVE` | Yes | No-op | +| Exists | `ACTIVE` | No | Hard delete + prompt auth URL + poll | +| Not exists | Exists (any) | N/A | Hard delete upstream | + +### Subtasks + +3.1. **Fetch auto-added scopes** + - Call `/api/external-auth/auto-added-scopes` + - Cache for session duration + +3.2. **Calculate expected scopes** + ```typescript + expectedScopes = localScopes ∪ autoAddedScopes + ``` + +3.3. **Compare and determine actions** + - For each local connector: determine if auth needed + - For each upstream-only connector: mark for deletion + +3.4. **Implementation location** + - Create `src/resources/connectors/push.ts` + +--- + +## Task 4: OAuth Flow Handling + +### Objective +Handle interactive OAuth authentication with URL display and polling. + +### Flow + +1. Display auth URL to user (open in browser or show URL) +2. Poll `/status` endpoint every 2 seconds +3. Use same timeout as device login auth +4. Verify approved scopes after completion + +### Post-Auth Verification + +```typescript +function verifyApprovedScopes( + localScopes: string[], + approvedScopes: string[], + autoAddedScopes: string[] +): boolean { + const expected = new Set([...localScopes, ...autoAddedScopes]); + const approved = new Set(approvedScopes); + + if (expected.size !== approved.size) return false; + for (const scope of expected) { + if (!approved.has(scope)) return false; + } + return true; +} +``` + +### Subtasks + +4.1. **Auth URL display** + - Show URL to user + - Optionally auto-open in browser + +4.2. **Polling loop** + - 2 second interval + - Timeout matching device auth + - Handle ACTIVE, FAILED, PENDING states + +4.3. **Sequential auth** + - If multiple connectors need auth, process one at a time + - Interactive state machine + +4.4. **Edge cases** + | Case | Handling | + |------|----------| + | Partial consent | Show `SCOPE_MISMATCH` status | + | Different user | Show `DIFFERENT_USER` status with email | + | Auth timeout | Show `PENDING_AUTH` status | + | Auth failed | Show `AUTH_FAILED` status | + +4.5. **Why always delete first?** + - Apper's `/initiate` merges new scopes with existing (for AI use case) + - CLI needs declarative state (JSONC = desired scopes) + - Delete first ensures exact scopes, not union of old + new + +--- + +## Task 5: Push Command Integration + +### Objective +Wire connectors into the existing push command with summary output. + +### Summary Output Format + +``` +Connectors push summary: + - googlecalendar: active (3 scopes) + - slack: active (4 scopes, re-authed) + - linkedin: scope mismatch (requested 5, approved 3) + - notion: auth not completed + - hubspot: deleted (no local definition) + +Some connectors need attention: + - linkedin: Approved scopes differ from requested. Update connectors/linkedin.jsonc or run push again. + - notion: Authentication not completed. Run push to retry. +``` + +### Status States + +| Status | Color | Description | +|--------|-------|-------------| +| `ACTIVE` | green | Connector active, scopes match | +| `SCOPE_MISMATCH` | yellow | Active but approved ≠ requested | +| `PENDING_AUTH` | red | Auth URL shown but not completed | +| `AUTH_FAILED` | red | OAuth flow failed | +| `DELETED` | dim | Removed from upstream | +| `DIFFERENT_USER` | red | Another user already authorized | + +### Subtasks + +5.1. **Integrate with push command** + - Add connectors to resource types handled by push + - No pull support (push-only resource) + +5.2. **Summary output** + - Use `@clack/prompts` for logging + - Color statuses with chalk + +5.3. **Attention section** + - Show actionable messages for non-success states + +--- + +## File Structure (Proposed) + +``` +src/ +├── resources/ +│ └── connectors/ +│ ├── schema.ts # Zod schemas +│ ├── reader.ts # File reading +│ ├── push.ts # Push logic +│ └── index.ts # Exports +├── api/ +│ └── external-auth.ts # API client methods +└── commands/ + └── push.ts # Updated to include connectors +``` + +--- ## Dependencies -### Core CLI (bundled - zero runtime dependencies) -All dependencies are bundled into a single file at build time using tsdown. - -- **commander** - CLI framework for command parsing and help generation -- **@clack/prompts** - Beautiful, accessible prompts and UI components -- **chalk** - Terminal colors (Base44 brand color: #E86B3C) -- **json5** - JSONC/JSON5 config file parsing (supports comments and trailing commas) -- **zod** - Schema validation for API responses, config files, and inputs -- **ky** - HTTP client for API communication -- **ejs** - Template rendering for project scaffolding -- **globby** - File globbing for resource discovery -- **dotenv** - Environment variable loading - -### Development -- **typescript** - TypeScript compiler and type system -- **tsx** - TypeScript execution for development/watch mode -- **tsdown** - Bundler (powered by Rolldown) for zero-dependency distribution -- **vitest** - Testing framework -- **@types/node** - TypeScript definitions for Node.js +- Existing: `zod`, `@clack/prompts`, `chalk` +- Backend: Issue #3325 for `/api/external-auth/auto-added-scopes` endpoint + +--- + +## Testing Considerations +- Unit tests for scope comparison logic +- Unit tests for Zod schema validation +- Integration tests for push flow (mock API) +- Manual testing of OAuth flow (requires real OAuth providers) diff --git a/src/core/resources/connector/config.ts b/src/core/resources/connector/config.ts new file mode 100644 index 00000000..1430ff5e --- /dev/null +++ b/src/core/resources/connector/config.ts @@ -0,0 +1,77 @@ +import { basename } from "node:path"; +import { globby } from "globby"; +import { SchemaValidationError } from "@/core/errors.js"; +import { CONFIG_FILE_EXTENSION_GLOB } from "../../consts.js"; +import { pathExists, readJsonFile } from "../../utils/fs.js"; +import type { ConnectorResource } from "./schema.js"; +import { ConnectorResourceSchema, IntegrationTypeSchema } from "./schema.js"; + +/** + * Read and validate a single connector file. + */ +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).replace(/\.(json|jsonc)$/, ""); + const typeResult = IntegrationTypeSchema.safeParse(filename); + + if (!typeResult.success) { + throw new SchemaValidationError( + `Connector filename "${filename}" is not a valid integration type`, + typeResult.error, + connectorPath + ); + } + + if (filename !== result.data.type) { + throw new Error( + `Connector filename "${filename}" does not match type "${result.data.type}" in ${connectorPath}` + ); + } + + return result.data; +} + +/** + * Read all connector files from a directory. + * Returns an empty array if the directory doesn't exist. + */ +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)) + ); + + // Check for duplicate types + const types = new Set(); + for (const connector of connectors) { + if (types.has(connector.type)) { + throw new Error(`Duplicate connector type "${connector.type}"`); + } + types.add(connector.type); + } + + return connectors; +} diff --git a/src/core/resources/connector/index.ts b/src/core/resources/connector/index.ts new file mode 100644 index 00000000..68412629 --- /dev/null +++ b/src/core/resources/connector/index.ts @@ -0,0 +1,3 @@ +export * from "./config.js"; +export * from "./resource.js"; +export * from "./schema.js"; diff --git a/src/core/resources/connector/resource.ts b/src/core/resources/connector/resource.ts new file mode 100644 index 00000000..2a12c6ff --- /dev/null +++ b/src/core/resources/connector/resource.ts @@ -0,0 +1,16 @@ +import type { Resource } from "../types.js"; +import { readAllConnectors } from "./config.js"; +import type { ConnectorResource } from "./schema.js"; + +/** + * Connector resource implementation. + * Note: Connectors are push-only (no pull support). + * The push function will be implemented when the OAuth flow is ready. + */ +export const connectorResource: Resource = { + readAll: readAllConnectors, + push: async () => { + // Push will be implemented in the OAuth flow task + throw new Error("Connector push not yet implemented"); + }, +}; diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts new file mode 100644 index 00000000..6daf9b71 --- /dev/null +++ b/src/core/resources/connector/schema.ts @@ -0,0 +1,168 @@ +import { z } from "zod"; + +// ─── CONNECTOR SCHEMAS PER INTEGRATION ──────────────────────────────────────── +// Each integration has a literal type discriminator. +// Scopes are provider-specific - see official docs for available scopes. + +/** Google Calendar - Scopes: https://developers.google.com/identity/protocols/oauth2/scopes#calendar */ +export const GoogleCalendarConnectorSchema = z.object({ + type: z.literal("googlecalendar"), + scopes: z.array(z.string()).default([]), +}); + +/** Google Drive - Scopes: https://developers.google.com/identity/protocols/oauth2/scopes#drive */ +export const GoogleDriveConnectorSchema = z.object({ + type: z.literal("googledrive"), + scopes: z.array(z.string()).default([]), +}); + +/** Gmail - Scopes: https://developers.google.com/identity/protocols/oauth2/scopes#gmail */ +export const GmailConnectorSchema = z.object({ + type: z.literal("gmail"), + scopes: z.array(z.string()).default([]), +}); + +/** Google Sheets - Scopes: https://developers.google.com/identity/protocols/oauth2/scopes#sheets */ +export const GoogleSheetsConnectorSchema = z.object({ + type: z.literal("googlesheets"), + scopes: z.array(z.string()).default([]), +}); + +/** Google Docs - Scopes: https://developers.google.com/identity/protocols/oauth2/scopes#docs */ +export const GoogleDocsConnectorSchema = z.object({ + type: z.literal("googledocs"), + scopes: z.array(z.string()).default([]), +}); + +/** Google Slides - Scopes: https://developers.google.com/identity/protocols/oauth2/scopes#slides */ +export const GoogleSlidesConnectorSchema = z.object({ + type: z.literal("googleslides"), + scopes: z.array(z.string()).default([]), +}); + +/** Slack - Scopes: https://api.slack.com/scopes */ +export const SlackConnectorSchema = z.object({ + type: z.literal("slack"), + scopes: z.array(z.string()).default([]), +}); + +/** Notion - Scopes: https://developers.notion.com/docs/authorization (page-based access model) */ +export const NotionConnectorSchema = z.object({ + type: z.literal("notion"), + scopes: z.array(z.string()).default([]), +}); + +/** Salesforce - Scopes: https://developer.salesforce.com/docs/platform/mobile-sdk/guide/oauth-scope-parameter-values.html */ +export const SalesforceConnectorSchema = z.object({ + type: z.literal("salesforce"), + scopes: z.array(z.string()).default([]), +}); + +/** HubSpot - Scopes: https://developers.hubspot.com/docs/api/scopes */ +export const HubspotConnectorSchema = z.object({ + type: z.literal("hubspot"), + scopes: z.array(z.string()).default([]), +}); + +/** LinkedIn - Scopes: https://learn.microsoft.com/en-us/linkedin/marketing/increasing-access */ +export const LinkedInConnectorSchema = z.object({ + type: z.literal("linkedin"), + scopes: z.array(z.string()).default([]), +}); + +/** TikTok - Scopes: https://developers.tiktok.com/doc/scopes-overview */ +export const TikTokConnectorSchema = z.object({ + type: z.literal("tiktok"), + scopes: z.array(z.string()).default([]), +}); + +// ─── DISCRIMINATED UNION ────────────────────────────────────────────────────── + +/** + * Local connector resource schema using discriminated union. + * Each integration type has its own schema with a literal type discriminator. + */ +export const ConnectorResourceSchema = z.discriminatedUnion("type", [ + GoogleCalendarConnectorSchema, + GoogleDriveConnectorSchema, + GmailConnectorSchema, + GoogleSheetsConnectorSchema, + GoogleDocsConnectorSchema, + GoogleSlidesConnectorSchema, + SlackConnectorSchema, + NotionConnectorSchema, + SalesforceConnectorSchema, + HubspotConnectorSchema, + LinkedInConnectorSchema, + TikTokConnectorSchema, +]); + +export type ConnectorResource = z.infer; + +/** + * 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; + +// ─── API RESPONSE SCHEMAS ───────────────────────────────────────────────────── + +/** + * Connector status from upstream API. + */ +export const ConnectorStatusSchema = z.enum([ + "ACTIVE", + "DISCONNECTED", + "EXPIRED", +]); + +export type ConnectorStatus = z.infer; + +/** + * Upstream connector from the list API. + */ +export const UpstreamConnectorSchema = z.object({ + integration_type: IntegrationTypeSchema, + status: ConnectorStatusSchema, + scopes: z.array(z.string()), + user_email: z.string().optional(), +}); + +export type UpstreamConnector = z.infer; + +/** + * Response from GET /api/apps/{app_id}/external-auth/list + */ +export const ListConnectorsResponseSchema = z.object({ + integrations: z.array(UpstreamConnectorSchema), +}); + +export type ListConnectorsResponse = z.infer< + typeof ListConnectorsResponseSchema +>; + +/** + * Response from GET /api/external-auth/auto-added-scopes + */ +export const AutoAddedScopesResponseSchema = z.record( + IntegrationTypeSchema, + z.array(z.string()) +); + +export type AutoAddedScopesResponse = z.infer< + typeof AutoAddedScopesResponseSchema +>; diff --git a/src/core/resources/index.ts b/src/core/resources/index.ts index 191bbdb9..93ddcc26 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"; diff --git a/tests/core/connectors.spec.ts b/tests/core/connectors.spec.ts new file mode 100644 index 00000000..7d496d3a --- /dev/null +++ b/tests/core/connectors.spec.ts @@ -0,0 +1,148 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { readAllConnectors } from "../../src/core/resources/connector/config.js"; +import { + ConnectorResourceSchema, + IntegrationTypeSchema, +} from "../../src/core/resources/connector/schema.js"; + +const FIXTURES_DIR = resolve(__dirname, "../fixtures"); + +describe("IntegrationTypeSchema", () => { + it("accepts valid integration types", () => { + const validTypes = [ + "googlecalendar", + "googledrive", + "gmail", + "googlesheets", + "googledocs", + "googleslides", + "slack", + "notion", + "salesforce", + "hubspot", + "linkedin", + "tiktok", + ]; + + for (const type of validTypes) { + expect(IntegrationTypeSchema.safeParse(type).success).toBe(true); + } + }); + + it("rejects invalid integration types", () => { + const invalidTypes = ["invalid", "google", "facebook", "twitter", ""]; + + for (const type of invalidTypes) { + expect(IntegrationTypeSchema.safeParse(type).success).toBe(false); + } + }); +}); + +describe("ConnectorResourceSchema", () => { + it("accepts valid connector with scopes", () => { + const connector = { + type: "googlecalendar", + scopes: [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", + ], + }; + + const result = ConnectorResourceSchema.safeParse(connector); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("googlecalendar"); + expect(result.data.scopes).toHaveLength(2); + } + }); + + it("accepts valid connector with empty scopes", () => { + const connector = { + type: "notion", + scopes: [], + }; + + const result = ConnectorResourceSchema.safeParse(connector); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.scopes).toEqual([]); + } + }); + + it("defaults scopes to empty array if not provided", () => { + const connector = { + type: "slack", + }; + + const result = ConnectorResourceSchema.safeParse(connector); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.scopes).toEqual([]); + } + }); + + it("rejects connector with invalid type", () => { + const connector = { + type: "invalid", + scopes: [], + }; + + const result = ConnectorResourceSchema.safeParse(connector); + expect(result.success).toBe(false); + }); + + it("rejects connector without type", () => { + const connector = { + scopes: [], + }; + + const result = ConnectorResourceSchema.safeParse(connector); + expect(result.success).toBe(false); + }); +}); + +describe("readAllConnectors", () => { + it("returns empty array for non-existent directory", async () => { + const connectors = await readAllConnectors("/non/existent/path"); + expect(connectors).toEqual([]); + }); + + it("reads connectors from directory", async () => { + const connectorsDir = resolve(FIXTURES_DIR, "with-connectors/connectors"); + const connectors = await readAllConnectors(connectorsDir); + + expect(connectors).toHaveLength(3); + + const types = connectors.map((c) => c.type).sort(); + expect(types).toEqual(["googlecalendar", "notion", "slack"]); + + const googleCalendar = connectors.find((c) => c.type === "googlecalendar"); + expect(googleCalendar?.scopes).toEqual([ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", + ]); + + const notion = connectors.find((c) => c.type === "notion"); + expect(notion?.scopes).toEqual([]); + }); + + it("throws error for invalid connector type", async () => { + const connectorsDir = resolve(FIXTURES_DIR, "invalid-connector/connectors"); + + await expect(readAllConnectors(connectorsDir)).rejects.toThrow( + "Invalid connector file" + ); + }); + + it("throws error when filename does not match type", async () => { + const connectorsDir = resolve( + FIXTURES_DIR, + "connector-type-mismatch/connectors" + ); + + await expect(readAllConnectors(connectorsDir)).rejects.toThrow( + /does not match type/ + ); + }); +}); diff --git a/tests/fixtures/connector-type-mismatch/base44/.app.jsonc b/tests/fixtures/connector-type-mismatch/base44/.app.jsonc new file mode 100644 index 00000000..d7852426 --- /dev/null +++ b/tests/fixtures/connector-type-mismatch/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/tests/fixtures/connector-type-mismatch/config.jsonc b/tests/fixtures/connector-type-mismatch/config.jsonc new file mode 100644 index 00000000..0f709d6f --- /dev/null +++ b/tests/fixtures/connector-type-mismatch/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Project with Type Mismatch Connector" +} diff --git a/tests/fixtures/connector-type-mismatch/connectors/googlecalendar.jsonc b/tests/fixtures/connector-type-mismatch/connectors/googlecalendar.jsonc new file mode 100644 index 00000000..fb7389bf --- /dev/null +++ b/tests/fixtures/connector-type-mismatch/connectors/googlecalendar.jsonc @@ -0,0 +1,5 @@ +// Type mismatch - filename is googlecalendar but type is slack +{ + "type": "slack", + "scopes": [] +} diff --git a/tests/fixtures/invalid-connector/base44/.app.jsonc b/tests/fixtures/invalid-connector/base44/.app.jsonc new file mode 100644 index 00000000..d7852426 --- /dev/null +++ b/tests/fixtures/invalid-connector/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/tests/fixtures/invalid-connector/config.jsonc b/tests/fixtures/invalid-connector/config.jsonc new file mode 100644 index 00000000..34efb6cf --- /dev/null +++ b/tests/fixtures/invalid-connector/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Project with Invalid Connector" +} diff --git a/tests/fixtures/invalid-connector/connectors/invalid.jsonc b/tests/fixtures/invalid-connector/connectors/invalid.jsonc new file mode 100644 index 00000000..123665f8 --- /dev/null +++ b/tests/fixtures/invalid-connector/connectors/invalid.jsonc @@ -0,0 +1,5 @@ +// Invalid connector - unknown integration type +{ + "type": "invalid", + "scopes": [] +} diff --git a/tests/fixtures/with-connectors/base44/.app.jsonc b/tests/fixtures/with-connectors/base44/.app.jsonc new file mode 100644 index 00000000..d7852426 --- /dev/null +++ b/tests/fixtures/with-connectors/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/tests/fixtures/with-connectors/config.jsonc b/tests/fixtures/with-connectors/config.jsonc new file mode 100644 index 00000000..c6c48d95 --- /dev/null +++ b/tests/fixtures/with-connectors/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Project with Connectors" +} diff --git a/tests/fixtures/with-connectors/connectors/googlecalendar.jsonc b/tests/fixtures/with-connectors/connectors/googlecalendar.jsonc new file mode 100644 index 00000000..ee7a4c68 --- /dev/null +++ b/tests/fixtures/with-connectors/connectors/googlecalendar.jsonc @@ -0,0 +1,8 @@ +// Google Calendar connector +{ + "type": "googlecalendar", + "scopes": [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events" + ] +} diff --git a/tests/fixtures/with-connectors/connectors/notion.json b/tests/fixtures/with-connectors/connectors/notion.json new file mode 100644 index 00000000..ae65dfd1 --- /dev/null +++ b/tests/fixtures/with-connectors/connectors/notion.json @@ -0,0 +1,4 @@ +{ + "type": "notion", + "scopes": [] +} diff --git a/tests/fixtures/with-connectors/connectors/slack.jsonc b/tests/fixtures/with-connectors/connectors/slack.jsonc new file mode 100644 index 00000000..afb9c923 --- /dev/null +++ b/tests/fixtures/with-connectors/connectors/slack.jsonc @@ -0,0 +1,8 @@ +// Slack connector +{ + "type": "slack", + "scopes": [ + "chat:write", + "channels:read" + ] +} From ce803ee82df4daf455b59595cd4edf1a82eb7f7f Mon Sep 17 00:00:00 2001 From: paveltarno Date: Sun, 8 Feb 2026 17:33:33 +0200 Subject: [PATCH 02/13] feat(connectors): add API client for connector management (#190) Add API client methods for OAuth connector operations: - listConnectors: list all connectors for current app - syncConnector: sync connector with exact scope matching - getOAuthStatus: poll OAuth authorization status - removeConnector: remove a connector integration Also update response schemas and clean up verbose comments. Co-authored-by: Claude Opus 4.5 --- src/core/resources/connector/api.ts | 128 +++++++++++++++++++++++ src/core/resources/connector/index.ts | 1 + src/core/resources/connector/resource.ts | 6 -- src/core/resources/connector/schema.ts | 60 +++++------ 4 files changed, 156 insertions(+), 39 deletions(-) create mode 100644 src/core/resources/connector/api.ts diff --git a/src/core/resources/connector/api.ts b/src/core/resources/connector/api.ts new file mode 100644 index 00000000..9de86e2f --- /dev/null +++ b/src/core/resources/connector/api.ts @@ -0,0 +1,128 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { + IntegrationType, + ListConnectorsResponse, + OAuthStatusResponse, + RemoveConnectorResponse, + SyncConnectorResponse, +} from "./schema.js"; +import { + ListConnectorsResponseSchema, + OAuthStatusResponseSchema, + RemoveConnectorResponseSchema, + SyncConnectorResponseSchema, +} from "./schema.js"; + +/** + * List all connectors for the current app. + * GET /api/apps/{app_id}/external-auth/list + */ +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; +} + +export async function syncConnector( + integrationType: IntegrationType, + scopes: string[] +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post("external-auth/sync", { + json: { + integration_type: integrationType, + scopes, + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "syncing connector"); + } + + const result = SyncConnectorResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error + ); + } + + return result.data; +} + +export async function getOAuthStatus( + 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, "checking OAuth status"); + } + + const result = OAuthStatusResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error + ); + } + + return result.data; +} + +export async function removeConnector( + integrationType: IntegrationType +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.delete( + `external-auth/integrations/${integrationType}/remove` + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "removing connector"); + } + + const result = RemoveConnectorResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error + ); + } + + return result.data; +} diff --git a/src/core/resources/connector/index.ts b/src/core/resources/connector/index.ts index 68412629..8182de95 100644 --- a/src/core/resources/connector/index.ts +++ b/src/core/resources/connector/index.ts @@ -1,3 +1,4 @@ +export * from "./api.js"; export * from "./config.js"; export * from "./resource.js"; export * from "./schema.js"; diff --git a/src/core/resources/connector/resource.ts b/src/core/resources/connector/resource.ts index 2a12c6ff..3fb5bce9 100644 --- a/src/core/resources/connector/resource.ts +++ b/src/core/resources/connector/resource.ts @@ -2,15 +2,9 @@ import type { Resource } from "../types.js"; import { readAllConnectors } from "./config.js"; import type { ConnectorResource } from "./schema.js"; -/** - * Connector resource implementation. - * Note: Connectors are push-only (no pull support). - * The push function will be implemented when the OAuth flow is ready. - */ export const connectorResource: Resource = { readAll: readAllConnectors, push: async () => { - // Push will be implemented in the OAuth flow task throw new Error("Connector push not yet implemented"); }, }; diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts index 6daf9b71..c47f16a5 100644 --- a/src/core/resources/connector/schema.ts +++ b/src/core/resources/connector/schema.ts @@ -1,9 +1,5 @@ import { z } from "zod"; -// ─── CONNECTOR SCHEMAS PER INTEGRATION ──────────────────────────────────────── -// Each integration has a literal type discriminator. -// Scopes are provider-specific - see official docs for available scopes. - /** Google Calendar - Scopes: https://developers.google.com/identity/protocols/oauth2/scopes#calendar */ export const GoogleCalendarConnectorSchema = z.object({ type: z.literal("googlecalendar"), @@ -76,12 +72,6 @@ export const TikTokConnectorSchema = z.object({ scopes: z.array(z.string()).default([]), }); -// ─── DISCRIMINATED UNION ────────────────────────────────────────────────────── - -/** - * Local connector resource schema using discriminated union. - * Each integration type has its own schema with a literal type discriminator. - */ export const ConnectorResourceSchema = z.discriminatedUnion("type", [ GoogleCalendarConnectorSchema, GoogleDriveConnectorSchema, @@ -99,9 +89,6 @@ export const ConnectorResourceSchema = z.discriminatedUnion("type", [ export type ConnectorResource = z.infer; -/** - * Supported OAuth integration types. - */ export const IntegrationTypeSchema = z.enum([ "googlecalendar", "googledrive", @@ -119,11 +106,6 @@ export const IntegrationTypeSchema = z.enum([ export type IntegrationType = z.infer; -// ─── API RESPONSE SCHEMAS ───────────────────────────────────────────────────── - -/** - * Connector status from upstream API. - */ export const ConnectorStatusSchema = z.enum([ "ACTIVE", "DISCONNECTED", @@ -132,9 +114,6 @@ export const ConnectorStatusSchema = z.enum([ export type ConnectorStatus = z.infer; -/** - * Upstream connector from the list API. - */ export const UpstreamConnectorSchema = z.object({ integration_type: IntegrationTypeSchema, status: ConnectorStatusSchema, @@ -144,9 +123,6 @@ export const UpstreamConnectorSchema = z.object({ export type UpstreamConnector = z.infer; -/** - * Response from GET /api/apps/{app_id}/external-auth/list - */ export const ListConnectorsResponseSchema = z.object({ integrations: z.array(UpstreamConnectorSchema), }); @@ -155,14 +131,32 @@ export type ListConnectorsResponse = z.infer< typeof ListConnectorsResponseSchema >; -/** - * Response from GET /api/external-auth/auto-added-scopes - */ -export const AutoAddedScopesResponseSchema = z.record( - IntegrationTypeSchema, - z.array(z.string()) -); +export const SyncConnectorResponseSchema = z.object({ + redirect_url: z.string().nullable(), + connection_id: z.string().nullable(), + already_authorized: z.boolean(), + error: z.literal("different_user").optional(), + error_message: z.string().optional(), + other_user_email: z.string().optional(), +}); + +export type SyncConnectorResponse = z.infer; + +export const OAuthPollingStatusSchema = z.enum(["ACTIVE", "FAILED", "PENDING"]); + +export type OAuthPollingStatus = z.infer; + +export const OAuthStatusResponseSchema = z.object({ + status: OAuthPollingStatusSchema, +}); + +export type OAuthStatusResponse = z.infer; + +export const RemoveConnectorResponseSchema = z.object({ + status: z.literal("removed"), + integration_type: IntegrationTypeSchema, +}); -export type AutoAddedScopesResponse = z.infer< - typeof AutoAddedScopesResponseSchema +export type RemoveConnectorResponse = z.infer< + typeof RemoveConnectorResponseSchema >; From 740098185d1d39f3b71b203a3b773004b905015e Mon Sep 17 00:00:00 2001 From: paveltarno Date: Sun, 8 Feb 2026 17:51:11 +0200 Subject: [PATCH 03/13] feat(connectors): implement push logic for syncing connectors (#191) * feat(connectors): implement push logic for syncing connectors Add pushConnectors function that: - Syncs all local connectors via /sync endpoint - Removes upstream-only connectors not in local config - Returns typed results (synced, removed, needs_oauth, error) Includes unit tests covering all scenarios. Co-Authored-By: Claude Opus 4.5 * feat(connectors): add OAuth flow handling with browser redirect and polling (#192) * feat(connectors): add OAuth flow handling with browser redirect and polling Add runOAuthFlow function that: - Opens OAuth redirect URL in browser - Polls getOAuthStatus until ACTIVE or FAILED - Returns PENDING on timeout (5 minutes) Uses p-wait-for TimeoutError for robust timeout detection. Co-Authored-By: Claude Opus 4.5 * connectors: base44 connectors push (#194) * final connector work sofi 1 * scopes --------- Co-authored-by: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- src/cli/commands/connectors/index.ts | 9 + src/cli/commands/connectors/push.ts | 161 +++++++++++ src/cli/program.ts | 4 + src/core/project/config.ts | 5 +- src/core/project/schema.ts | 1 + src/core/project/types.ts | 2 + src/core/resources/connector/api.ts | 26 +- src/core/resources/connector/config.ts | 25 +- src/core/resources/connector/index.ts | 2 + src/core/resources/connector/oauth.ts | 46 +++ src/core/resources/connector/push.ts | 89 ++++++ src/core/resources/connector/resource.ts | 5 +- src/core/resources/connector/schema.ts | 32 ++- tests/cli/connectors_push.spec.ts | 122 ++++++++ tests/cli/testkit/Base44APIMock.ts | 89 ++++++ tests/core/connectors.spec.ts | 271 +++++++++++++++++- .../connector-type-mismatch/base44/.app.jsonc | 4 - .../connector-type-mismatch/config.jsonc | 3 - .../connectors/googlecalendar.jsonc | 5 - 19 files changed, 826 insertions(+), 75 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/oauth.ts create mode 100644 src/core/resources/connector/push.ts create mode 100644 tests/cli/connectors_push.spec.ts delete mode 100644 tests/fixtures/connector-type-mismatch/base44/.app.jsonc delete mode 100644 tests/fixtures/connector-type-mismatch/config.jsonc delete mode 100644 tests/fixtures/connector-type-mismatch/connectors/googlecalendar.jsonc diff --git a/src/cli/commands/connectors/index.ts b/src/cli/commands/connectors/index.ts new file mode 100644 index 00000000..191915b9 --- /dev/null +++ b/src/cli/commands/connectors/index.ts @@ -0,0 +1,9 @@ +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { getConnectorsPushCommand } from "./push.js"; + +export function getConnectorsCommand(context: CLIContext): Command { + return new Command("connectors") + .description("Manage project connectors (OAuth integrations)") + .addCommand(getConnectorsPushCommand(context)); +} diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts new file mode 100644 index 00000000..55f04e61 --- /dev/null +++ b/src/cli/commands/connectors/push.ts @@ -0,0 +1,161 @@ +import { confirm, isCancel, log } from "@clack/prompts"; +import chalk from "chalk"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand, runTask } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { readProjectConfig } from "@/core/index.js"; +import { + type ConnectorOAuthStatus, + type ConnectorSyncResult, + type IntegrationType, + pushConnectors, + runOAuthFlow, +} from "@/core/resources/connector/index.js"; + +type PendingOAuthResult = ConnectorSyncResult & { + redirectUrl: string; + connectionId: string; +}; + +function isPendingOAuth(r: ConnectorSyncResult): r is PendingOAuthResult { + return r.action === "needs_oauth" && !!r.redirectUrl && !!r.connectionId; +} + +function printSummary( + results: ConnectorSyncResult[], + oauthOutcomes: Map +): void { + const synced: IntegrationType[] = []; + const added: IntegrationType[] = []; + const removed: IntegrationType[] = []; + const failed: { type: IntegrationType; error?: string }[] = []; + + for (const r of results) { + const oauthStatus = oauthOutcomes.get(r.type); + + if (r.action === "synced") { + synced.push(r.type); + } else if (r.action === "removed") { + removed.push(r.type); + } else if (r.action === "error") { + failed.push({ type: r.type, error: r.error }); + } else if (r.action === "needs_oauth") { + if (oauthStatus === "ACTIVE") { + added.push(r.type); + } else if (oauthStatus === "PENDING") { + failed.push({ type: r.type, error: "authorization timed out" }); + } else if (oauthStatus === "FAILED") { + failed.push({ type: r.type, error: "authorization failed" }); + } else { + failed.push({ type: r.type, error: "needs authorization" }); + } + } + } + + log.info(""); + log.info(chalk.bold("Summary:")); + + if (synced.length > 0) { + log.info(chalk.green(` Synced: ${synced.join(", ")}`)); + } + if (added.length > 0) { + log.info(chalk.green(` Added: ${added.join(", ")}`)); + } + if (removed.length > 0) { + log.info(chalk.dim(` Removed: ${removed.join(", ")}`)); + } + for (const r of failed) { + log.info(chalk.red(` Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`)); + } +} + +async function pushConnectorsAction(): Promise { + const { connectors } = await readProjectConfig(); + + if (connectors.length === 0) { + log.info( + "No local connectors found - checking for remote connectors to remove" + ); + } else { + const connectorNames = connectors.map((c) => c.type).join(", "); + log.info( + `Found ${connectors.length} connectors to push: ${connectorNames}` + ); + } + + const { results } = await runTask( + "Pushing connectors to Base44", + async () => { + return await pushConnectors(connectors); + }, + { + successMessage: "Connectors pushed", + errorMessage: "Failed to push connectors", + } + ); + + const oauthOutcomes = new Map(); + const needsOAuth = results.filter(isPendingOAuth); + let outroMessage = "Connectors pushed to Base44"; + + if (needsOAuth.length > 0) { + log.info(""); + log.info( + chalk.yellow( + `${needsOAuth.length} connector(s) require authorization in your browser:` + ) + ); + for (const connector of needsOAuth) { + log.info(` ${connector.type}: ${chalk.dim(connector.redirectUrl)}`); + } + + const pending = needsOAuth.map((c) => c.type).join(", "); + + if (process.env.CI) { + outroMessage = `Skipped OAuth in CI. Pending: ${pending}. Run 'base44 connectors push' locally to authorize.`; + } else { + const shouldAuth = await confirm({ + message: "Open browser to authorize now?", + }); + + if (isCancel(shouldAuth) || !shouldAuth) { + outroMessage = `Authorization skipped. Pending: ${pending}. Run 'base44 connectors push' again to complete.`; + } else { + for (const connector of needsOAuth) { + log.info(`\nOpening browser for ${connector.type}...`); + + const oauthResult = await runTask( + `Waiting for ${connector.type} authorization...`, + async () => { + return await runOAuthFlow({ + type: connector.type, + redirectUrl: connector.redirectUrl, + connectionId: connector.connectionId, + }); + }, + { + successMessage: `${connector.type} authorization complete`, + errorMessage: `${connector.type} authorization failed`, + } + ); + + oauthOutcomes.set(connector.type, oauthResult.status); + } + } + } + } + + printSummary(results, oauthOutcomes); + return { outroMessage }; +} + +export function getConnectorsPushCommand(context: CLIContext): Command { + return new Command("push") + .description( + "Push local connectors to Base44 (syncs scopes and removes unlisted)" + ) + .action(async () => { + await runCommand(pushConnectorsAction, { requireAuth: true }, context); + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 8abbecc0..1afbf5e2 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 { getConnectorsCommand } 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"; @@ -48,6 +49,9 @@ export function createProgram(context: CLIContext): Command { // Register agents commands program.addCommand(getAgentsCommand(context)); + // Register connectors commands + program.addCommand(getConnectorsCommand(context)); + // Register functions commands program.addCommand(getFunctionsDeployCommand(context)); diff --git a/src/core/project/config.ts b/src/core/project/config.ts index 193575e9..4ebe801e 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/schema.ts b/src/core/project/schema.ts index 4e8d6fe1..37db1716 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 c910aa47..f1e8fa5c 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 index 9de86e2f..a3dc2ad9 100644 --- a/src/core/resources/connector/api.ts +++ b/src/core/resources/connector/api.ts @@ -6,13 +6,13 @@ import type { ListConnectorsResponse, OAuthStatusResponse, RemoveConnectorResponse, - SyncConnectorResponse, + SetConnectorResponse, } from "./schema.js"; import { ListConnectorsResponseSchema, OAuthStatusResponseSchema, RemoveConnectorResponseSchema, - SyncConnectorResponseSchema, + SetConnectorResponseSchema, } from "./schema.js"; /** @@ -41,25 +41,27 @@ export async function listConnectors(): Promise { return result.data; } -export async function syncConnector( +export async function setConnector( integrationType: IntegrationType, scopes: string[] -): Promise { +): Promise { const appClient = getAppClient(); let response: KyResponse; try { - response = await appClient.post("external-auth/sync", { - json: { - integration_type: integrationType, - scopes, - }, - }); + response = await appClient.put( + `external-auth/integrations/${integrationType}`, + { + json: { + scopes, + }, + } + ); } catch (error) { - throw await ApiError.fromHttpError(error, "syncing connector"); + throw await ApiError.fromHttpError(error, "setting connector"); } - const result = SyncConnectorResponseSchema.safeParse(await response.json()); + const result = SetConnectorResponseSchema.safeParse(await response.json()); if (!result.success) { throw new SchemaValidationError( diff --git a/src/core/resources/connector/config.ts b/src/core/resources/connector/config.ts index 1430ff5e..78cdee84 100644 --- a/src/core/resources/connector/config.ts +++ b/src/core/resources/connector/config.ts @@ -1,14 +1,10 @@ -import { basename } from "node:path"; import { globby } from "globby"; import { SchemaValidationError } from "@/core/errors.js"; import { CONFIG_FILE_EXTENSION_GLOB } from "../../consts.js"; import { pathExists, readJsonFile } from "../../utils/fs.js"; import type { ConnectorResource } from "./schema.js"; -import { ConnectorResourceSchema, IntegrationTypeSchema } from "./schema.js"; +import { ConnectorResourceSchema } from "./schema.js"; -/** - * Read and validate a single connector file. - */ async function readConnectorFile( connectorPath: string ): Promise { @@ -23,24 +19,6 @@ async function readConnectorFile( ); } - // Validate that filename matches the type - const filename = basename(connectorPath).replace(/\.(json|jsonc)$/, ""); - const typeResult = IntegrationTypeSchema.safeParse(filename); - - if (!typeResult.success) { - throw new SchemaValidationError( - `Connector filename "${filename}" is not a valid integration type`, - typeResult.error, - connectorPath - ); - } - - if (filename !== result.data.type) { - throw new Error( - `Connector filename "${filename}" does not match type "${result.data.type}" in ${connectorPath}` - ); - } - return result.data; } @@ -64,7 +42,6 @@ export async function readAllConnectors( files.map((filePath) => readConnectorFile(filePath)) ); - // Check for duplicate types const types = new Set(); for (const connector of connectors) { if (types.has(connector.type)) { diff --git a/src/core/resources/connector/index.ts b/src/core/resources/connector/index.ts index 8182de95..417096fc 100644 --- a/src/core/resources/connector/index.ts +++ b/src/core/resources/connector/index.ts @@ -1,4 +1,6 @@ export * from "./api.js"; export * from "./config.js"; +export * from "./oauth.js"; +export * from "./push.js"; export * from "./resource.js"; export * from "./schema.js"; diff --git a/src/core/resources/connector/oauth.ts b/src/core/resources/connector/oauth.ts new file mode 100644 index 00000000..6efd2d37 --- /dev/null +++ b/src/core/resources/connector/oauth.ts @@ -0,0 +1,46 @@ +import open from "open"; +import pWaitFor, { TimeoutError } from "p-wait-for"; +import { getOAuthStatus } from "./api.js"; +import type { ConnectorOAuthStatus, IntegrationType } from "./schema.js"; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes + +export interface OAuthFlowParams { + type: IntegrationType; + redirectUrl: string; + connectionId: string; +} + +export interface OAuthFlowResult { + type: IntegrationType; + status: ConnectorOAuthStatus; +} + +export async function runOAuthFlow( + params: OAuthFlowParams +): Promise { + await open(params.redirectUrl); + + let finalStatus: ConnectorOAuthStatus = "PENDING"; + + await pWaitFor( + async () => { + const response = await getOAuthStatus(params.type, params.connectionId); + finalStatus = response.status; + return response.status !== "PENDING"; + }, + { + interval: POLL_INTERVAL_MS, + timeout: POLL_TIMEOUT_MS, + } + ).catch((err) => { + if (err instanceof TimeoutError) { + finalStatus = "PENDING"; + } else { + throw err; + } + }); + + return { type: params.type, status: finalStatus }; +} diff --git a/src/core/resources/connector/push.ts b/src/core/resources/connector/push.ts new file mode 100644 index 00000000..f386447c --- /dev/null +++ b/src/core/resources/connector/push.ts @@ -0,0 +1,89 @@ +import { listConnectors, removeConnector, setConnector } from "./api.js"; +import type { + ConnectorResource, + IntegrationType, + SetConnectorResponse, +} from "./schema.js"; + +export interface ConnectorSyncResult { + type: IntegrationType; + action: "synced" | "removed" | "needs_oauth" | "error"; + redirectUrl?: string; + connectionId?: string; + error?: string; +} + +export interface PushConnectorsResponse { + results: ConnectorSyncResult[]; +} + +export async function pushConnectors( + connectors: ConnectorResource[] +): Promise { + const results: ConnectorSyncResult[] = []; + const upstream = await listConnectors(); + const localTypes = new Set(connectors.map((c) => c.type)); + + for (const connector of connectors) { + try { + const response = await setConnector(connector.type, connector.scopes); + results.push(setResponseToResult(connector.type, response)); + } catch (err) { + results.push({ + type: connector.type, + action: "error", + error: err instanceof Error ? err.message : String(err), + }); + } + } + + for (const upstreamConnector of upstream.integrations) { + if (!localTypes.has(upstreamConnector.integration_type)) { + try { + await removeConnector(upstreamConnector.integration_type); + results.push({ + type: upstreamConnector.integration_type, + action: "removed", + }); + } catch (err) { + results.push({ + type: upstreamConnector.integration_type, + action: "error", + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + return { results }; +} + +function setResponseToResult( + type: IntegrationType, + response: SetConnectorResponse +): ConnectorSyncResult { + if (response.error === "different_user") { + return { + type, + action: "error", + error: + response.error_message || + `Already connected by ${response.other_user_email}`, + }; + } + + if (response.already_authorized) { + return { type, action: "synced" }; + } + + if (response.redirect_url) { + return { + type, + action: "needs_oauth", + redirectUrl: response.redirect_url, + connectionId: response.connection_id ?? undefined, + }; + } + + return { type, action: "synced" }; +} diff --git a/src/core/resources/connector/resource.ts b/src/core/resources/connector/resource.ts index 3fb5bce9..14f57e1a 100644 --- a/src/core/resources/connector/resource.ts +++ b/src/core/resources/connector/resource.ts @@ -1,10 +1,9 @@ import type { Resource } from "../types.js"; import { readAllConnectors } from "./config.js"; +import { pushConnectors } from "./push.js"; import type { ConnectorResource } from "./schema.js"; export const connectorResource: Resource = { readAll: readAllConnectors, - push: async () => { - throw new Error("Connector push not yet implemented"); - }, + push: pushConnectors, }; diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts index c47f16a5..ff5be687 100644 --- a/src/core/resources/connector/schema.ts +++ b/src/core/resources/connector/schema.ts @@ -42,7 +42,7 @@ export const SlackConnectorSchema = z.object({ scopes: z.array(z.string()).default([]), }); -/** Notion - Scopes: https://developers.notion.com/docs/authorization (page-based access model) */ +/** Notion - Scopes are preauthorized, no need to request them explicitly (values will be ignored) */ export const NotionConnectorSchema = z.object({ type: z.literal("notion"), scopes: z.array(z.string()).default([]), @@ -60,13 +60,13 @@ export const HubspotConnectorSchema = z.object({ scopes: z.array(z.string()).default([]), }); -/** LinkedIn - Scopes: https://learn.microsoft.com/en-us/linkedin/marketing/increasing-access */ +/** LinkedIn - Scopes: https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow */ export const LinkedInConnectorSchema = z.object({ type: z.literal("linkedin"), scopes: z.array(z.string()).default([]), }); -/** TikTok - Scopes: https://developers.tiktok.com/doc/scopes-overview */ +/** TikTok - Scopes: https://developers.tiktok.com/doc/tiktok-api-scopes */ export const TikTokConnectorSchema = z.object({ type: z.literal("tiktok"), scopes: z.array(z.string()).default([]), @@ -107,9 +107,9 @@ export const IntegrationTypeSchema = z.enum([ export type IntegrationType = z.infer; export const ConnectorStatusSchema = z.enum([ - "ACTIVE", - "DISCONNECTED", - "EXPIRED", + "active", + "disconnected", + "expired", ]); export type ConnectorStatus = z.infer; @@ -131,23 +131,27 @@ export type ListConnectorsResponse = z.infer< typeof ListConnectorsResponseSchema >; -export const SyncConnectorResponseSchema = z.object({ +export const SetConnectorResponseSchema = z.object({ redirect_url: z.string().nullable(), connection_id: z.string().nullable(), already_authorized: z.boolean(), - error: z.literal("different_user").optional(), - error_message: z.string().optional(), - other_user_email: z.string().optional(), + error: z.string().nullable().optional(), + error_message: z.string().nullable().optional(), + other_user_email: z.string().nullable().optional(), }); -export type SyncConnectorResponse = z.infer; +export type SetConnectorResponse = z.infer; -export const OAuthPollingStatusSchema = z.enum(["ACTIVE", "FAILED", "PENDING"]); +export const ConnectorOAuthStatusSchema = z.enum([ + "ACTIVE", + "FAILED", + "PENDING", +]); -export type OAuthPollingStatus = z.infer; +export type ConnectorOAuthStatus = z.infer; export const OAuthStatusResponseSchema = z.object({ - status: OAuthPollingStatusSchema, + status: ConnectorOAuthStatusSchema, }); export type OAuthStatusResponse = z.infer; diff --git a/tests/cli/connectors_push.spec.ts b/tests/cli/connectors_push.spec.ts new file mode 100644 index 00000000..33fa391b --- /dev/null +++ b/tests/cli/connectors_push.spec.ts @@ -0,0 +1,122 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("connectors push command", () => { + const t = setupCLITests(); + + it("shows message when no local connectors found", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockConnectorsList({ integrations: [] }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No local connectors found"); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 project found"); + }); + + it("finds and lists connectors in project", async () => { + await t.givenLoggedInWithProject(fixture("with-connectors")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockConnectorSet({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 3 connectors to push"); + }); + + it("displays synced connectors with checkmark", async () => { + await t.givenLoggedInWithProject(fixture("with-connectors")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockConnectorSet({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("googlecalendar"); + t.expectResult(result).toContain("slack"); + t.expectResult(result).toContain("notion"); + }); + + it("displays removed connectors", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockConnectorsList({ + integrations: [ + { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + ], + }); + t.api.mockConnectorRemove({ status: "removed", integration_type: "slack" }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("slack"); + t.expectResult(result).toContain("Removed:"); + }); + + it("displays error when sync fails", async () => { + await t.givenLoggedInWithProject(fixture("with-connectors")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockConnectorSetError({ + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("connectors", "push"); + + // Errors are handled per-connector, command still succeeds + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("googlecalendar"); + }); + + it("shows needs authorization when redirect_url is returned", async () => { + await t.givenLoggedInWithProject(fixture("with-connectors")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockConnectorSet({ + redirect_url: "https://accounts.google.com/oauth", + connection_id: "conn_123", + already_authorized: false, + }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("needs authorization"); + t.expectResult(result).toContain("Skipped OAuth in CI"); + }); + + it("shows error for different_user response", async () => { + await t.givenLoggedInWithProject(fixture("with-connectors")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockConnectorSet({ + redirect_url: null, + connection_id: null, + already_authorized: false, + error: "different_user", + error_message: "Already connected by another user", + other_user_email: "other@example.com", + }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Already connected by another user"); + }); +}); diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index 86b0b123..7f89e362 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -57,6 +57,33 @@ export interface AgentsFetchResponse { total: number; } +export interface ConnectorsListResponse { + integrations: Array<{ + integration_type: string; + status: string; + scopes: string[]; + user_email?: string; + }>; +} + +export interface ConnectorSetResponse { + redirect_url: string | null; + connection_id: string | null; + already_authorized: boolean; + error?: "different_user"; + error_message?: string; + other_user_email?: string; +} + +export interface ConnectorOAuthStatusResponse { + status: "ACTIVE" | "FAILED" | "PENDING"; +} + +export interface ConnectorRemoveResponse { + status: "removed"; + integration_type: string; +} + export interface CreateAppResponse { id: string; name: string; @@ -189,6 +216,50 @@ export class Base44APIMock { return this; } + // ─── CONNECTOR ENDPOINTS ────────────────────────────────── + + /** Mock GET /api/apps/{appId}/external-auth/list - List connectors */ + mockConnectorsList(response: ConnectorsListResponse): this { + this.handlers.push( + http.get(`${BASE_URL}/api/apps/${this.appId}/external-auth/list`, () => + HttpResponse.json(response) + ) + ); + return this; + } + + /** Mock PUT /api/apps/{appId}/external-auth/integrations/{type} - Set connector */ + mockConnectorSet(response: ConnectorSetResponse): this { + this.handlers.push( + http.put( + `${BASE_URL}/api/apps/${this.appId}/external-auth/integrations/:type`, + () => HttpResponse.json(response) + ) + ); + return this; + } + + /** Mock GET /api/apps/{appId}/external-auth/status - Get OAuth status */ + mockConnectorOAuthStatus(response: ConnectorOAuthStatusResponse): this { + this.handlers.push( + http.get(`${BASE_URL}/api/apps/${this.appId}/external-auth/status`, () => + HttpResponse.json(response) + ) + ); + return this; + } + + /** Mock DELETE /api/apps/{appId}/external-auth/integrations/{type}/remove */ + mockConnectorRemove(response: ConnectorRemoveResponse): this { + this.handlers.push( + http.delete( + `${BASE_URL}/api/apps/${this.appId}/external-auth/integrations/:type/remove`, + () => HttpResponse.json(response) + ) + ); + return this; + } + // ─── GENERAL ENDPOINTS ───────────────────────────────────── /** Mock POST /api/apps - Create new app */ @@ -302,6 +373,24 @@ export class Base44APIMock { return this.mockError("get", "/oauth/userinfo", error); } + /** Mock connectors list to return an error */ + mockConnectorsListError(error: ErrorResponse): this { + return this.mockError( + "get", + `/api/apps/${this.appId}/external-auth/list`, + error + ); + } + + /** Mock connector set to return an error */ + mockConnectorSetError(error: ErrorResponse): this { + return this.mockError( + "put", + `/api/apps/${this.appId}/external-auth/integrations/:type`, + error + ); + } + // ─── INTERNAL ────────────────────────────────────────────── /** Apply all registered handlers to MSW (called by CLITestkit.run()) */ diff --git a/tests/core/connectors.spec.ts b/tests/core/connectors.spec.ts index 7d496d3a..8bf6ea9b 100644 --- a/tests/core/connectors.spec.ts +++ b/tests/core/connectors.spec.ts @@ -1,11 +1,21 @@ import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../../src/core/resources/connector/api.js"; import { readAllConnectors } from "../../src/core/resources/connector/config.js"; import { + type OAuthFlowParams, + runOAuthFlow, +} from "../../src/core/resources/connector/oauth.js"; +import { pushConnectors } from "../../src/core/resources/connector/push.js"; +import { + type ConnectorResource, ConnectorResourceSchema, IntegrationTypeSchema, } from "../../src/core/resources/connector/schema.js"; +vi.mock("../../src/core/resources/connector/api.js"); +vi.mock("open", () => ({ default: vi.fn() })); + const FIXTURES_DIR = resolve(__dirname, "../fixtures"); describe("IntegrationTypeSchema", () => { @@ -134,15 +144,258 @@ describe("readAllConnectors", () => { "Invalid connector file" ); }); +}); - it("throws error when filename does not match type", async () => { - const connectorsDir = resolve( - FIXTURES_DIR, - "connector-type-mismatch/connectors" - ); +const mockListConnectors = vi.mocked(api.listConnectors); +const mockSetConnector = vi.mocked(api.setConnector); +const mockRemoveConnector = vi.mocked(api.removeConnector); - await expect(readAllConnectors(connectorsDir)).rejects.toThrow( - /does not match type/ - ); +describe("pushConnectors", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockListConnectors.mockResolvedValue({ integrations: [] }); + }); + + it("returns empty results when no local or upstream connectors", async () => { + const result = await pushConnectors([]); + expect(result.results).toEqual([]); + expect(mockListConnectors).toHaveBeenCalledOnce(); + }); + + it("syncs local connectors", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + ]; + mockSetConnector.mockResolvedValue({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + + const result = await pushConnectors(local); + + expect(mockSetConnector).toHaveBeenCalledWith("gmail", [ + "https://mail.google.com/", + ]); + expect(result.results).toEqual([{ type: "gmail", action: "synced" }]); + }); + + it("removes upstream-only connectors", async () => { + mockListConnectors.mockResolvedValue({ + integrations: [ + { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + ], + }); + mockRemoveConnector.mockResolvedValue({ + status: "removed", + integration_type: "slack", + }); + + const result = await pushConnectors([]); + + expect(mockRemoveConnector).toHaveBeenCalledWith("slack"); + expect(result.results).toEqual([{ type: "slack", action: "removed" }]); + }); + + it("syncs local and removes upstream-only", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + ]; + mockListConnectors.mockResolvedValue({ + integrations: [ + { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + ], + }); + mockSetConnector.mockResolvedValue({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + mockRemoveConnector.mockResolvedValue({ + status: "removed", + integration_type: "slack", + }); + + const result = await pushConnectors(local); + + expect(mockSetConnector).toHaveBeenCalledWith("gmail", [ + "https://mail.google.com/", + ]); + expect(mockRemoveConnector).toHaveBeenCalledWith("slack"); + expect(result.results).toEqual([ + { type: "gmail", action: "synced" }, + { type: "slack", action: "removed" }, + ]); + }); + + it("does not remove connectors that exist locally", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + ]; + mockListConnectors.mockResolvedValue({ + integrations: [ + { + integration_type: "gmail", + status: "ACTIVE", + scopes: ["https://mail.google.com/"], + }, + ], + }); + mockSetConnector.mockResolvedValue({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + + const result = await pushConnectors(local); + + expect(mockRemoveConnector).not.toHaveBeenCalled(); + expect(result.results).toEqual([{ type: "gmail", action: "synced" }]); + }); + + it("returns needs_oauth when redirect_url is present", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + ]; + mockSetConnector.mockResolvedValue({ + redirect_url: "https://accounts.google.com/oauth", + connection_id: "conn_123", + already_authorized: false, + }); + + const result = await pushConnectors(local); + + expect(result.results).toEqual([ + { + type: "gmail", + action: "needs_oauth", + redirectUrl: "https://accounts.google.com/oauth", + connectionId: "conn_123", + }, + ]); + }); + + it("returns error for different_user response", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + ]; + mockSetConnector.mockResolvedValue({ + redirect_url: null, + connection_id: null, + already_authorized: false, + error: "different_user", + error_message: "Already connected by another user", + other_user_email: "other@example.com", + }); + + const result = await pushConnectors(local); + + expect(result.results).toEqual([ + { + type: "gmail", + action: "error", + error: "Already connected by another user", + }, + ]); + }); + + it("handles sync errors gracefully", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + ]; + mockSetConnector.mockRejectedValue(new Error("Network error")); + + const result = await pushConnectors(local); + + expect(result.results).toEqual([ + { type: "gmail", action: "error", error: "Network error" }, + ]); + }); + + it("handles remove errors gracefully", async () => { + mockListConnectors.mockResolvedValue({ + integrations: [ + { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + ], + }); + mockRemoveConnector.mockRejectedValue(new Error("Remove failed")); + + const result = await pushConnectors([]); + + expect(result.results).toEqual([ + { type: "slack", action: "error", error: "Remove failed" }, + ]); + }); + + it("processes multiple local connectors", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + { type: "slack", scopes: ["chat:write"] }, + ]; + mockSetConnector.mockResolvedValue({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + + const result = await pushConnectors(local); + + expect(mockSetConnector).toHaveBeenCalledTimes(2); + expect(result.results).toEqual([ + { type: "gmail", action: "synced" }, + { type: "slack", action: "synced" }, + ]); + }); +}); + +const mockGetOAuthStatus = vi.mocked(api.getOAuthStatus); + +describe("runOAuthFlow", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("returns ACTIVE when OAuth completes successfully", async () => { + const params: OAuthFlowParams = { + type: "gmail", + redirectUrl: "https://accounts.google.com/oauth", + connectionId: "conn_123", + }; + mockGetOAuthStatus.mockResolvedValue({ status: "ACTIVE" }); + + const result = await runOAuthFlow(params); + + expect(result).toEqual({ type: "gmail", status: "ACTIVE" }); + expect(mockGetOAuthStatus).toHaveBeenCalledWith("gmail", "conn_123"); + }); + + it("returns FAILED when OAuth fails", async () => { + const params: OAuthFlowParams = { + type: "gmail", + redirectUrl: "https://accounts.google.com/oauth", + connectionId: "conn_123", + }; + mockGetOAuthStatus.mockResolvedValue({ status: "FAILED" }); + + const result = await runOAuthFlow(params); + + expect(result).toEqual({ type: "gmail", status: "FAILED" }); + }); + + it("polls until status changes from PENDING", async () => { + const params: OAuthFlowParams = { + type: "gmail", + redirectUrl: "https://accounts.google.com/oauth", + connectionId: "conn_123", + }; + mockGetOAuthStatus + .mockResolvedValueOnce({ status: "PENDING" }) + .mockResolvedValueOnce({ status: "PENDING" }) + .mockResolvedValueOnce({ status: "ACTIVE" }); + + const result = await runOAuthFlow(params); + + expect(result).toEqual({ type: "gmail", status: "ACTIVE" }); + expect(mockGetOAuthStatus).toHaveBeenCalledTimes(3); }); }); diff --git a/tests/fixtures/connector-type-mismatch/base44/.app.jsonc b/tests/fixtures/connector-type-mismatch/base44/.app.jsonc deleted file mode 100644 index d7852426..00000000 --- a/tests/fixtures/connector-type-mismatch/base44/.app.jsonc +++ /dev/null @@ -1,4 +0,0 @@ -// Base44 App Configuration -{ - "id": "test-app-id" -} diff --git a/tests/fixtures/connector-type-mismatch/config.jsonc b/tests/fixtures/connector-type-mismatch/config.jsonc deleted file mode 100644 index 0f709d6f..00000000 --- a/tests/fixtures/connector-type-mismatch/config.jsonc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "Project with Type Mismatch Connector" -} diff --git a/tests/fixtures/connector-type-mismatch/connectors/googlecalendar.jsonc b/tests/fixtures/connector-type-mismatch/connectors/googlecalendar.jsonc deleted file mode 100644 index fb7389bf..00000000 --- a/tests/fixtures/connector-type-mismatch/connectors/googlecalendar.jsonc +++ /dev/null @@ -1,5 +0,0 @@ -// Type mismatch - filename is googlecalendar but type is slack -{ - "type": "slack", - "scopes": [] -} From 96d03e764a8702a19ef5dcd9c77561dce067182c Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Mon, 9 Feb 2026 13:51:38 +0200 Subject: [PATCH 04/13] chore: add .worktrees to .gitignore Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 82e41184..56f85719 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,6 @@ coverage/ *.seed *.pid.lock +# Worktrees +.worktrees/ + From be72ab6ce3e73383128e3225a4b5d54b29625a87 Mon Sep 17 00:00:00 2001 From: paveltarno Date: Mon, 9 Feb 2026 17:34:14 +0200 Subject: [PATCH 05/13] fix(connectors): make scopes optional for Notion provider (#213) --- src/core/resources/connector/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts index ff5be687..2c6c2c53 100644 --- a/src/core/resources/connector/schema.ts +++ b/src/core/resources/connector/schema.ts @@ -42,7 +42,7 @@ export const SlackConnectorSchema = z.object({ scopes: z.array(z.string()).default([]), }); -/** Notion - Scopes are preauthorized, no need to request them explicitly (values will be ignored) */ +/** Notion - Scopes are preauthorized by Notion and don't need to be explicitly requested */ export const NotionConnectorSchema = z.object({ type: z.literal("notion"), scopes: z.array(z.string()).default([]), From 04a24983aeae0c6c8f5cfcbd0110623ad708a6e6 Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Mon, 9 Feb 2026 14:07:57 +0200 Subject: [PATCH 06/13] feat(connectors): support arbitrary OAuth providers Change provider field from closed enum to flexible union that accepts both known providers (googlecalendar, notion, slack, etc.) and any arbitrary provider string. This enables users to configure custom OAuth providers without waiting for first-class Base44 support. Schema changes: - Add GenericConnectorSchema for arbitrary provider types - Update ConnectorResourceSchema to union of specific + generic schemas - Update IntegrationTypeSchema to accept known enum OR any non-empty string - Only reject empty strings Test coverage: - Verify known providers continue to work - Verify arbitrary providers are accepted - Verify empty strings are rejected - All 137 tests passing Co-Authored-By: Claude Sonnet 4.5 --- src/core/resources/connector/schema.ts | 25 +++++++++++++-- tests/cli/connectors_push.spec.ts | 2 +- tests/core/connectors.spec.ts | 32 ++++++++++++------- .../connectors/invalid.jsonc | 3 +- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts index 2c6c2c53..ff015ccf 100644 --- a/src/core/resources/connector/schema.ts +++ b/src/core/resources/connector/schema.ts @@ -72,7 +72,17 @@ export const TikTokConnectorSchema = z.object({ scopes: z.array(z.string()).default([]), }); -export const ConnectorResourceSchema = z.discriminatedUnion("type", [ +/** Generic connector schema for arbitrary providers */ +const GenericConnectorSchema = z.object({ + type: z.string(), + scopes: z.array(z.string()).default([]), +}); + +/** + * Connector resource schema that accepts both known providers (with specific schemas) + * and arbitrary provider strings (with generic schema). + */ +export const ConnectorResourceSchema = z.union([ GoogleCalendarConnectorSchema, GoogleDriveConnectorSchema, GmailConnectorSchema, @@ -85,11 +95,13 @@ export const ConnectorResourceSchema = z.discriminatedUnion("type", [ HubspotConnectorSchema, LinkedInConnectorSchema, TikTokConnectorSchema, + GenericConnectorSchema, ]); export type ConnectorResource = z.infer; -export const IntegrationTypeSchema = z.enum([ +/** Known integration types with first-class support */ +export const KnownIntegrationTypes = [ "googlecalendar", "googledrive", "gmail", @@ -102,6 +114,15 @@ export const IntegrationTypeSchema = z.enum([ "hubspot", "linkedin", "tiktok", +] as const; + +/** + * Integration type schema that accepts both known providers and arbitrary strings. + * This allows users to use custom OAuth providers not yet supported by Base44. + */ +export const IntegrationTypeSchema = z.union([ + z.enum(KnownIntegrationTypes), + z.string().min(1), ]); export type IntegrationType = z.infer; diff --git a/tests/cli/connectors_push.spec.ts b/tests/cli/connectors_push.spec.ts index 33fa391b..5c09e9fd 100644 --- a/tests/cli/connectors_push.spec.ts +++ b/tests/cli/connectors_push.spec.ts @@ -59,7 +59,7 @@ describe("connectors push command", () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockConnectorsList({ integrations: [ - { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + { integration_type: "slack", status: "active", scopes: ["chat:write"] }, ], }); t.api.mockConnectorRemove({ status: "removed", integration_type: "slack" }); diff --git a/tests/core/connectors.spec.ts b/tests/core/connectors.spec.ts index 8bf6ea9b..55acb893 100644 --- a/tests/core/connectors.spec.ts +++ b/tests/core/connectors.spec.ts @@ -40,13 +40,17 @@ describe("IntegrationTypeSchema", () => { } }); - it("rejects invalid integration types", () => { - const invalidTypes = ["invalid", "google", "facebook", "twitter", ""]; + it("accepts arbitrary integration types (including custom providers)", () => { + const arbitraryTypes = ["invalid", "google", "facebook", "twitter", "custom-oauth-provider"]; - for (const type of invalidTypes) { - expect(IntegrationTypeSchema.safeParse(type).success).toBe(false); + for (const type of arbitraryTypes) { + expect(IntegrationTypeSchema.safeParse(type).success).toBe(true); } }); + + it("rejects only empty strings", () => { + expect(IntegrationTypeSchema.safeParse("").success).toBe(false); + }); }); describe("ConnectorResourceSchema", () => { @@ -92,14 +96,18 @@ describe("ConnectorResourceSchema", () => { } }); - it("rejects connector with invalid type", () => { + it("accepts connector with arbitrary provider type", () => { const connector = { - type: "invalid", - scopes: [], + type: "custom-oauth-provider", + scopes: ["scope1", "scope2"], }; const result = ConnectorResourceSchema.safeParse(connector); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("custom-oauth-provider"); + expect(result.data.scopes).toEqual(["scope1", "scope2"]); + } }); it("rejects connector without type", () => { @@ -183,7 +191,7 @@ describe("pushConnectors", () => { it("removes upstream-only connectors", async () => { mockListConnectors.mockResolvedValue({ integrations: [ - { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + { integration_type: "slack", status: "active", scopes: ["chat:write"] }, ], }); mockRemoveConnector.mockResolvedValue({ @@ -203,7 +211,7 @@ describe("pushConnectors", () => { ]; mockListConnectors.mockResolvedValue({ integrations: [ - { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + { integration_type: "slack", status: "active", scopes: ["chat:write"] }, ], }); mockSetConnector.mockResolvedValue({ @@ -236,7 +244,7 @@ describe("pushConnectors", () => { integrations: [ { integration_type: "gmail", - status: "ACTIVE", + status: "active", scopes: ["https://mail.google.com/"], }, ], @@ -315,7 +323,7 @@ describe("pushConnectors", () => { it("handles remove errors gracefully", async () => { mockListConnectors.mockResolvedValue({ integrations: [ - { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + { integration_type: "slack", status: "active", scopes: ["chat:write"] }, ], }); mockRemoveConnector.mockRejectedValue(new Error("Remove failed")); diff --git a/tests/fixtures/invalid-connector/connectors/invalid.jsonc b/tests/fixtures/invalid-connector/connectors/invalid.jsonc index 123665f8..7c08de16 100644 --- a/tests/fixtures/invalid-connector/connectors/invalid.jsonc +++ b/tests/fixtures/invalid-connector/connectors/invalid.jsonc @@ -1,5 +1,4 @@ -// Invalid connector - unknown integration type +// Invalid connector - missing type field { - "type": "invalid", "scopes": [] } From 1fbafd6b8ffaa9c73731f1c6ae5e211bdb9358e7 Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Tue, 10 Feb 2026 13:57:25 +0200 Subject: [PATCH 07/13] review fixes --- AGENTS.md | 11 +++++ src/cli/commands/connectors/push.ts | 21 ++++----- src/core/resources/connector/config.ts | 13 +++++- src/core/resources/connector/push.ts | 2 +- src/core/resources/connector/schema.ts | 4 +- tests/core/connectors.spec.ts | 45 ++++++++++++++++++- .../duplicate-connectors/base44/.app.jsonc | 4 ++ .../duplicate-connectors/config.jsonc | 3 ++ .../connectors/slack1.jsonc | 4 ++ .../connectors/slack2.jsonc | 4 ++ 10 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 tests/fixtures/duplicate-connectors/base44/.app.jsonc create mode 100644 tests/fixtures/duplicate-connectors/config.jsonc create mode 100644 tests/fixtures/duplicate-connectors/connectors/slack1.jsonc create mode 100644 tests/fixtures/duplicate-connectors/connectors/slack2.jsonc diff --git a/AGENTS.md b/AGENTS.md index e8559a90..66d1100c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,14 @@ cli/ │ │ │ │ ├── resource.ts │ │ │ │ ├── api.ts │ │ │ │ └── index.ts +│ │ │ ├── connector/ +│ │ │ │ ├── schema.ts +│ │ │ │ ├── config.ts +│ │ │ │ ├── resource.ts +│ │ │ │ ├── api.ts +│ │ │ │ ├── push.ts +│ │ │ │ ├── oauth.ts +│ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── site/ # Site deployment (NOT a Resource) │ │ │ ├── schema.ts # DeployResponse Zod schema @@ -121,6 +129,9 @@ cli/ │ │ │ ├── index.ts # getAgentsCommand(context) - parent command │ │ │ ├── pull.ts │ │ │ └── push.ts +│ │ ├── connectors/ +│ │ │ ├── index.ts # getConnectorsCommand(context) - parent command +│ │ │ └── push.ts │ │ ├── functions/ │ │ │ └── deploy.ts │ │ ├── site/ diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index 55f04e61..f03f0b75 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -1,8 +1,7 @@ import { confirm, isCancel, log } from "@clack/prompts"; -import chalk from "chalk"; import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; +import { runCommand, runTask, theme } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { readProjectConfig } from "@/core/index.js"; import { @@ -54,19 +53,19 @@ function printSummary( } log.info(""); - log.info(chalk.bold("Summary:")); + log.info(theme.styles.bold("Summary:")); if (synced.length > 0) { - log.info(chalk.green(` Synced: ${synced.join(", ")}`)); + log.success(`Synced: ${synced.join(", ")}`); } if (added.length > 0) { - log.info(chalk.green(` Added: ${added.join(", ")}`)); + log.success(`Added: ${added.join(", ")}`); } if (removed.length > 0) { - log.info(chalk.dim(` Removed: ${removed.join(", ")}`)); + log.info(theme.styles.dim(`Removed: ${removed.join(", ")}`)); } for (const r of failed) { - log.info(chalk.red(` Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`)); + log.error(`Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`); } } @@ -101,13 +100,11 @@ async function pushConnectorsAction(): Promise { if (needsOAuth.length > 0) { log.info(""); - log.info( - chalk.yellow( - `${needsOAuth.length} connector(s) require authorization in your browser:` - ) + log.warn( + `${needsOAuth.length} connector(s) require authorization in your browser:` ); for (const connector of needsOAuth) { - log.info(` ${connector.type}: ${chalk.dim(connector.redirectUrl)}`); + log.info(` ${connector.type}: ${theme.styles.dim(connector.redirectUrl)}`); } const pending = needsOAuth.map((c) => c.type).join(", "); diff --git a/src/core/resources/connector/config.ts b/src/core/resources/connector/config.ts index 78cdee84..fe593560 100644 --- a/src/core/resources/connector/config.ts +++ b/src/core/resources/connector/config.ts @@ -1,5 +1,5 @@ import { globby } from "globby"; -import { SchemaValidationError } from "@/core/errors.js"; +import { InvalidInputError, SchemaValidationError } from "@/core/errors.js"; import { CONFIG_FILE_EXTENSION_GLOB } from "../../consts.js"; import { pathExists, readJsonFile } from "../../utils/fs.js"; import type { ConnectorResource } from "./schema.js"; @@ -45,7 +45,16 @@ export async function readAllConnectors( const types = new Set(); for (const connector of connectors) { if (types.has(connector.type)) { - throw new Error(`Duplicate connector type "${connector.type}"`); + throw new InvalidInputError( + `Duplicate connector type "${connector.type}"`, + { + hints: [ + { + message: `Remove duplicate connectors with type "${connector.type}" - only one connector per type is allowed`, + }, + ], + } + ); } types.add(connector.type); } diff --git a/src/core/resources/connector/push.ts b/src/core/resources/connector/push.ts index f386447c..35b6fdfa 100644 --- a/src/core/resources/connector/push.ts +++ b/src/core/resources/connector/push.ts @@ -68,7 +68,7 @@ function setResponseToResult( action: "error", error: response.error_message || - `Already connected by ${response.other_user_email}`, + `Already connected by ${response.other_user_email ?? "another user"}`, }; } diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts index ff015ccf..c0d99137 100644 --- a/src/core/resources/connector/schema.ts +++ b/src/core/resources/connector/schema.ts @@ -74,7 +74,7 @@ export const TikTokConnectorSchema = z.object({ /** Generic connector schema for arbitrary providers */ const GenericConnectorSchema = z.object({ - type: z.string(), + type: z.string().min(1).regex(/^[a-z0-9_-]+$/i), scopes: z.array(z.string()).default([]), }); @@ -122,7 +122,7 @@ export const KnownIntegrationTypes = [ */ export const IntegrationTypeSchema = z.union([ z.enum(KnownIntegrationTypes), - z.string().min(1), + z.string().min(1).regex(/^[a-z0-9_-]+$/i), ]); export type IntegrationType = z.infer; diff --git a/tests/core/connectors.spec.ts b/tests/core/connectors.spec.ts index 55acb893..b5160550 100644 --- a/tests/core/connectors.spec.ts +++ b/tests/core/connectors.spec.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { InvalidInputError } from "../../src/core/errors.js"; import * as api from "../../src/core/resources/connector/api.js"; import { readAllConnectors } from "../../src/core/resources/connector/config.js"; import { @@ -48,9 +49,16 @@ describe("IntegrationTypeSchema", () => { } }); - it("rejects only empty strings", () => { + it("rejects empty strings", () => { expect(IntegrationTypeSchema.safeParse("").success).toBe(false); }); + + it("rejects path traversal strings", () => { + const malicious = ["../admin", "../../endpoint", "type/with/slashes", "type with spaces"]; + for (const type of malicious) { + expect(IntegrationTypeSchema.safeParse(type).success).toBe(false); + } + }); }); describe("ConnectorResourceSchema", () => { @@ -152,6 +160,17 @@ describe("readAllConnectors", () => { "Invalid connector file" ); }); + + it("throws InvalidInputError for duplicate connector types", async () => { + const connectorsDir = resolve(FIXTURES_DIR, "duplicate-connectors/connectors"); + + await expect(readAllConnectors(connectorsDir)).rejects.toThrow( + InvalidInputError + ); + await expect(readAllConnectors(connectorsDir)).rejects.toThrow( + 'Duplicate connector type "slack"' + ); + }); }); const mockListConnectors = vi.mocked(api.listConnectors); @@ -307,6 +326,30 @@ describe("pushConnectors", () => { ]); }); + it("returns fallback message when different_user has no error_message or email", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + ]; + mockSetConnector.mockResolvedValue({ + redirect_url: null, + connection_id: null, + already_authorized: false, + error: "different_user", + error_message: null, + other_user_email: null, + }); + + const result = await pushConnectors(local); + + expect(result.results).toEqual([ + { + type: "gmail", + action: "error", + error: "Already connected by another user", + }, + ]); + }); + it("handles sync errors gracefully", async () => { const local: ConnectorResource[] = [ { type: "gmail", scopes: ["https://mail.google.com/"] }, diff --git a/tests/fixtures/duplicate-connectors/base44/.app.jsonc b/tests/fixtures/duplicate-connectors/base44/.app.jsonc new file mode 100644 index 00000000..d7852426 --- /dev/null +++ b/tests/fixtures/duplicate-connectors/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/tests/fixtures/duplicate-connectors/config.jsonc b/tests/fixtures/duplicate-connectors/config.jsonc new file mode 100644 index 00000000..1a85a25d --- /dev/null +++ b/tests/fixtures/duplicate-connectors/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Project with Duplicate Connectors" +} diff --git a/tests/fixtures/duplicate-connectors/connectors/slack1.jsonc b/tests/fixtures/duplicate-connectors/connectors/slack1.jsonc new file mode 100644 index 00000000..e1a4e781 --- /dev/null +++ b/tests/fixtures/duplicate-connectors/connectors/slack1.jsonc @@ -0,0 +1,4 @@ +{ + "type": "slack", + "scopes": ["chat:write"] +} diff --git a/tests/fixtures/duplicate-connectors/connectors/slack2.jsonc b/tests/fixtures/duplicate-connectors/connectors/slack2.jsonc new file mode 100644 index 00000000..f4d41f57 --- /dev/null +++ b/tests/fixtures/duplicate-connectors/connectors/slack2.jsonc @@ -0,0 +1,4 @@ +{ + "type": "slack", + "scopes": ["channels:read"] +} From fccf20afd610c157f5eb1e6a8c90499eaad4c95b Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Tue, 10 Feb 2026 14:05:45 +0200 Subject: [PATCH 08/13] lint --- src/cli/commands/connectors/push.ts | 4 +++- src/core/resources/connector/schema.ts | 10 ++++++++-- tests/core/connectors.spec.ts | 20 +++++++++++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index f03f0b75..746280f5 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -104,7 +104,9 @@ async function pushConnectorsAction(): Promise { `${needsOAuth.length} connector(s) require authorization in your browser:` ); for (const connector of needsOAuth) { - log.info(` ${connector.type}: ${theme.styles.dim(connector.redirectUrl)}`); + log.info( + ` ${connector.type}: ${theme.styles.dim(connector.redirectUrl)}` + ); } const pending = needsOAuth.map((c) => c.type).join(", "); diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts index c0d99137..40c5d77a 100644 --- a/src/core/resources/connector/schema.ts +++ b/src/core/resources/connector/schema.ts @@ -74,7 +74,10 @@ export const TikTokConnectorSchema = z.object({ /** Generic connector schema for arbitrary providers */ const GenericConnectorSchema = z.object({ - type: z.string().min(1).regex(/^[a-z0-9_-]+$/i), + type: z + .string() + .min(1) + .regex(/^[a-z0-9_-]+$/i), scopes: z.array(z.string()).default([]), }); @@ -122,7 +125,10 @@ export const KnownIntegrationTypes = [ */ export const IntegrationTypeSchema = z.union([ z.enum(KnownIntegrationTypes), - z.string().min(1).regex(/^[a-z0-9_-]+$/i), + z + .string() + .min(1) + .regex(/^[a-z0-9_-]+$/i), ]); export type IntegrationType = z.infer; diff --git a/tests/core/connectors.spec.ts b/tests/core/connectors.spec.ts index b5160550..c0124d15 100644 --- a/tests/core/connectors.spec.ts +++ b/tests/core/connectors.spec.ts @@ -42,7 +42,13 @@ describe("IntegrationTypeSchema", () => { }); it("accepts arbitrary integration types (including custom providers)", () => { - const arbitraryTypes = ["invalid", "google", "facebook", "twitter", "custom-oauth-provider"]; + const arbitraryTypes = [ + "invalid", + "google", + "facebook", + "twitter", + "custom-oauth-provider", + ]; for (const type of arbitraryTypes) { expect(IntegrationTypeSchema.safeParse(type).success).toBe(true); @@ -54,7 +60,12 @@ describe("IntegrationTypeSchema", () => { }); it("rejects path traversal strings", () => { - const malicious = ["../admin", "../../endpoint", "type/with/slashes", "type with spaces"]; + const malicious = [ + "../admin", + "../../endpoint", + "type/with/slashes", + "type with spaces", + ]; for (const type of malicious) { expect(IntegrationTypeSchema.safeParse(type).success).toBe(false); } @@ -162,7 +173,10 @@ describe("readAllConnectors", () => { }); it("throws InvalidInputError for duplicate connector types", async () => { - const connectorsDir = resolve(FIXTURES_DIR, "duplicate-connectors/connectors"); + const connectorsDir = resolve( + FIXTURES_DIR, + "duplicate-connectors/connectors" + ); await expect(readAllConnectors(connectorsDir)).rejects.toThrow( InvalidInputError From 431ef2cf7b704eb5c5f3a7e8ef7a4c0da32435f7 Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Tue, 10 Feb 2026 14:54:57 +0200 Subject: [PATCH 09/13] remove push messages --- src/cli/commands/connectors/push.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index 746280f5..8b115be7 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -87,10 +87,6 @@ async function pushConnectorsAction(): Promise { "Pushing connectors to Base44", async () => { return await pushConnectors(connectors); - }, - { - successMessage: "Connectors pushed", - errorMessage: "Failed to push connectors", } ); From e4ed6bccfe4af0f4add1727087f39087c146acc0 Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Tue, 10 Feb 2026 14:59:39 +0200 Subject: [PATCH 10/13] notion is optional --- src/core/resources/connector/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts index 40c5d77a..3214832a 100644 --- a/src/core/resources/connector/schema.ts +++ b/src/core/resources/connector/schema.ts @@ -45,7 +45,7 @@ export const SlackConnectorSchema = z.object({ /** Notion - Scopes are preauthorized by Notion and don't need to be explicitly requested */ export const NotionConnectorSchema = z.object({ type: z.literal("notion"), - scopes: z.array(z.string()).default([]), + scopes: z.array(z.string()).default([]).optional(), }); /** Salesforce - Scopes: https://developer.salesforce.com/docs/platform/mobile-sdk/guide/oauth-scope-parameter-values.html */ From 8932a013756510e4a105bbe52183016ac0e411f0 Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Tue, 10 Feb 2026 15:10:32 +0200 Subject: [PATCH 11/13] minor changes --- src/core/resources/connector/schema.ts | 15 +++++---- tests/core/connectors.spec.ts | 42 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts index 3214832a..d7a675c2 100644 --- a/src/core/resources/connector/schema.ts +++ b/src/core/resources/connector/schema.ts @@ -72,12 +72,14 @@ export const TikTokConnectorSchema = z.object({ scopes: z.array(z.string()).default([]), }); +const CustomTypeSchema = z + .string() + .min(1) + .regex(/^[a-z0-9_-]+$/i); + /** Generic connector schema for arbitrary providers */ const GenericConnectorSchema = z.object({ - type: z - .string() - .min(1) - .regex(/^[a-z0-9_-]+$/i), + type: CustomTypeSchema, scopes: z.array(z.string()).default([]), }); @@ -125,10 +127,7 @@ export const KnownIntegrationTypes = [ */ export const IntegrationTypeSchema = z.union([ z.enum(KnownIntegrationTypes), - z - .string() - .min(1) - .regex(/^[a-z0-9_-]+$/i), + CustomTypeSchema, ]); export type IntegrationType = z.infer; diff --git a/tests/core/connectors.spec.ts b/tests/core/connectors.spec.ts index c0124d15..261dcf41 100644 --- a/tests/core/connectors.spec.ts +++ b/tests/core/connectors.spec.ts @@ -392,6 +392,48 @@ describe("pushConnectors", () => { ]); }); + it("syncs a custom (unknown) integration type", async () => { + const local: ConnectorResource[] = [ + { type: "custom-crm", scopes: ["read", "write"] }, + ]; + mockSetConnector.mockResolvedValue({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + + const result = await pushConnectors(local); + + expect(mockSetConnector).toHaveBeenCalledWith("custom-crm", [ + "read", + "write", + ]); + expect(result.results).toEqual([{ type: "custom-crm", action: "synced" }]); + }); + + it("removes an upstream custom (unknown) integration type not present locally", async () => { + mockListConnectors.mockResolvedValue({ + integrations: [ + { + integration_type: "custom-crm", + status: "active", + scopes: ["read"], + }, + ], + }); + mockRemoveConnector.mockResolvedValue({ + status: "removed", + integration_type: "custom-crm", + }); + + const result = await pushConnectors([]); + + expect(mockRemoveConnector).toHaveBeenCalledWith("custom-crm"); + expect(result.results).toEqual([ + { type: "custom-crm", action: "removed" }, + ]); + }); + it("processes multiple local connectors", async () => { const local: ConnectorResource[] = [ { type: "gmail", scopes: ["https://mail.google.com/"] }, From 23ec8daada76826e45c65aa8ee88df043cc5e818 Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Tue, 10 Feb 2026 17:13:21 +0200 Subject: [PATCH 12/13] tsc + lint --- src/core/resources/connector/push.ts | 2 +- tests/core/connectors.spec.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/resources/connector/push.ts b/src/core/resources/connector/push.ts index 35b6fdfa..542bdc35 100644 --- a/src/core/resources/connector/push.ts +++ b/src/core/resources/connector/push.ts @@ -26,7 +26,7 @@ export async function pushConnectors( for (const connector of connectors) { try { - const response = await setConnector(connector.type, connector.scopes); + const response = await setConnector(connector.type, connector.scopes ?? []); results.push(setResponseToResult(connector.type, response)); } catch (err) { results.push({ diff --git a/tests/core/connectors.spec.ts b/tests/core/connectors.spec.ts index 261dcf41..5b512e92 100644 --- a/tests/core/connectors.spec.ts +++ b/tests/core/connectors.spec.ts @@ -429,9 +429,7 @@ describe("pushConnectors", () => { const result = await pushConnectors([]); expect(mockRemoveConnector).toHaveBeenCalledWith("custom-crm"); - expect(result.results).toEqual([ - { type: "custom-crm", action: "removed" }, - ]); + expect(result.results).toEqual([{ type: "custom-crm", action: "removed" }]); }); it("processes multiple local connectors", async () => { From 3fc2e0c3e03ea29e3d8492214e49e1bcbb1dcc29 Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Tue, 10 Feb 2026 17:27:20 +0200 Subject: [PATCH 13/13] more lint --- src/core/resources/connector/push.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/resources/connector/push.ts b/src/core/resources/connector/push.ts index 542bdc35..84504df3 100644 --- a/src/core/resources/connector/push.ts +++ b/src/core/resources/connector/push.ts @@ -26,7 +26,10 @@ export async function pushConnectors( for (const connector of connectors) { try { - const response = await setConnector(connector.type, connector.scopes ?? []); + const response = await setConnector( + connector.type, + connector.scopes ?? [] + ); results.push(setResponseToResult(connector.type, response)); } catch (err) { results.push({