Compare commits

..

6 Commits

Author SHA1 Message Date
Waleed Latif
11d5b23d0a update docker publish yml 2025-05-08 08:08:05 -07:00
Waleed Latif
4687f5dd0f added additional error for cli script 2025-05-07 22:58:33 -07:00
Waleed Latif
7f948a3a48 updated README.md 2025-05-07 22:40:09 -07:00
Waleed Latif
b538e5d884 updated README.md 2025-05-07 18:24:55 -07:00
Waleed Latif
1606f19d12 updated dockerfile for devcontainer 2025-05-07 17:10:03 -07:00
Waleed Latif
a9c755c5d4 feat(docker): added docker image & npm pkg to install that image 2025-05-07 17:03:02 -07:00
1640 changed files with 97716 additions and 203962 deletions

View File

@@ -18,26 +18,14 @@ alias pgc="PGPASSWORD=postgres psql -h db -U postgres -d simstudio"
alias check-db="PGPASSWORD=postgres psql -h db -U postgres -c '\l'"
# Sim Studio specific aliases
alias logs="cd /workspace/apps/sim && tail -f logs/*.log 2>/dev/null || echo 'No log files found'"
alias sim-start="cd /workspace && bun run dev"
alias sim-migrate="cd /workspace/apps/sim && bunx drizzle-kit push"
alias sim-generate="cd /workspace/apps/sim && bunx drizzle-kit generate"
alias sim-rebuild="cd /workspace && bun run build && bun run start"
alias docs-dev="cd /workspace/apps/docs && bun run dev"
alias logs="cd /workspace/sim && tail -f logs/*.log 2>/dev/null || echo 'No log files found'"
alias sim-start="cd /workspace/sim && npm run dev"
alias sim-migrate="cd /workspace/sim && npx drizzle-kit push"
alias sim-generate="cd /workspace/sim && npx drizzle-kit generate"
alias sim-rebuild="cd /workspace/sim && npm run build && npm start"
# Turbo related commands
alias turbo-build="cd /workspace && bunx turbo run build"
alias turbo-dev="cd /workspace && bunx turbo run dev"
alias turbo-test="cd /workspace && bunx turbo run test"
# Bun specific commands
alias bun-update="cd /workspace && bun update"
alias bun-add="cd /workspace && bun add"
alias bun-pm="cd /workspace && bun pm"
alias bun-canary="bun upgrade --canary"
# Default to workspace directory
cd /workspace 2>/dev/null || true
# Default to sim directory
cd /workspace/sim 2>/dev/null || true
# Welcome message - only show once per session
if [ -z "$SIM_WELCOME_SHOWN" ]; then
@@ -48,22 +36,10 @@ if [ -z "$SIM_WELCOME_SHOWN" ]; then
echo "🚀 Welcome to Sim Studio development environment!"
echo ""
echo "Available commands:"
echo " sim-start - Start all apps in development mode"
echo " sim-migrate - Push schema changes to the database for sim app"
echo " sim-generate - Generate new migrations for sim app"
echo " sim-rebuild - Build and start all apps"
echo " docs-dev - Start only the docs app in development mode"
echo ""
echo "Turbo commands:"
echo " turbo-build - Build all apps using Turborepo"
echo " turbo-dev - Start development mode for all apps"
echo " turbo-test - Run tests for all packages"
echo ""
echo "Bun commands:"
echo " bun-update - Update dependencies"
echo " bun-add - Add a new dependency"
echo " bun-pm - Manage dependencies"
echo " bun-canary - Upgrade to the latest canary version of Bun"
echo " sim-start - Start the development server"
echo " sim-migrate - Push schema changes to the database"
echo " sim-generate - Generate new migrations"
echo " sim-rebuild - Build and start the production server"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
fi

View File

@@ -1,19 +1,20 @@
# Use the latest Bun canary image for development
FROM oven/bun:canary
FROM node:20-bullseye
# Avoid warnings by switching to noninteractive
ENV DEBIAN_FRONTEND=noninteractive
# Set Node.js memory limit
ENV NODE_OPTIONS="--max-old-space-size=4096"
# Install necessary packages for development
RUN apt-get update \
&& apt-get -y install --no-install-recommends \
git curl wget jq sudo postgresql-client vim nano \
bash-completion ca-certificates lsb-release gnupg \
git curl wget jq sudo postgresql-client \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user
ARG USERNAME=bun
ARG USERNAME=node
ARG USER_UID=1000
ARG USER_GID=$USER_UID
@@ -21,15 +22,11 @@ ARG USER_GID=$USER_UID
RUN echo "$USERNAME ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME
# Install global packages for development
RUN bun install -g turbo drizzle-kit typescript @types/node
# Make sure we have the latest npm
RUN npm install -g npm@latest
# Install bun completions
RUN bun completions > /etc/bash_completion.d/bun
# Set up shell environment
RUN echo "export PATH=$PATH:/home/$USERNAME/.bun/bin" >> /etc/profile
RUN echo "source /etc/profile" >> /etc/bash.bashrc
# Install global packages
RUN npm install -g drizzle-kit
# Switch back to dialog for any ad-hoc use of apt-get
ENV DEBIAN_FRONTEND=dialog
@@ -37,6 +34,4 @@ ENV DEBIAN_FRONTEND=dialog
WORKDIR /workspace
# Expose the ports we're interested in
EXPOSE 3000
EXPOSE 3001
EXPOSE 3002
EXPOSE 3000

View File

@@ -33,7 +33,7 @@ This directory contains configuration files for Visual Studio Code Dev Container
- Run database migrations
- Configure helpful aliases
5. Start the application with `sim-start` (alias for `bun run dev`)
5. Start the application with `sim-start` (alias for `npm run dev`)
### Development Commands

View File

@@ -10,8 +10,14 @@
"settings": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
"source.fixAll.eslint": true
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.profiles.linux": {
@@ -23,15 +29,16 @@
"terminal.integrated.shellIntegration.enabled": true
},
"extensions": [
"biomejs.biome",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next",
"github.copilot",
"github.copilot-chat",
"rvest.vs-code-prettier-eslint",
"mikestead.dotenv",
"dsznajder.es7-react-js-snippets",
"steoates.autoimport",
"oven.bun-vscode"
"steoates.autoimport"
]
}
},
@@ -42,11 +49,12 @@
"postStartCommand": "bash -c 'if [ ! -f ~/.bashrc ] || ! grep -q \"sim-start\" ~/.bashrc; then cp .devcontainer/.bashrc ~/.bashrc; fi'",
"remoteUser": "bun",
"remoteUser": "node",
"features": {
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/prulloac/devcontainer-features/bun:1": {
"ghcr.io/devcontainers-contrib/features/npm-package:1": {
"package": "typescript",
"version": "latest"
}
}

View File

@@ -1,3 +1,5 @@
version: '3.8'
services:
app:
build:
@@ -5,7 +7,6 @@ services:
dockerfile: .devcontainer/Dockerfile
volumes:
- ..:/workspace:cached
- bun-cache:/home/bun/.bun/cache:delegated
command: sleep infinity
environment:
- NODE_ENV=development
@@ -13,62 +14,15 @@ services:
- POSTGRES_URL=postgresql://postgres:postgres@db:5432/simstudio
- BETTER_AUTH_URL=http://localhost:3000
- NEXT_PUBLIC_APP_URL=http://localhost:3000
- BUN_INSTALL_CACHE_DIR=/home/bun/.bun/cache
depends_on:
db:
condition: service_healthy
realtime:
condition: service_healthy
migrations:
condition: service_completed_successfully
ports:
- "3000:3000"
- "3001:3001"
working_dir: /workspace
healthcheck:
test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3000']
interval: 90s
timeout: 5s
retries: 3
start_period: 10s
realtime:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
command: sleep infinity
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
- BETTER_AUTH_URL=http://localhost:3000
- NEXT_PUBLIC_APP_URL=http://localhost:3000
depends_on:
db:
condition: service_healthy
ports:
- "3002:3002"
working_dir: /workspace
healthcheck:
test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3002']
interval: 90s
timeout: 5s
retries: 3
start_period: 10s
migrations:
build:
context: ..
dockerfile: docker/db.Dockerfile
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
depends_on:
db:
condition: service_healthy
command: ['bun', 'run', 'db:migrate']
restart: 'no'
working_dir: /workspace/sim
db:
image: pgvector/pgvector:pg17
image: postgres:16
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
@@ -85,5 +39,4 @@ services:
retries: 5
volumes:
postgres-data:
bun-cache:
postgres-data:

View File

@@ -5,8 +5,8 @@ set -e
echo "🔧 Setting up Sim Studio development environment..."
# Change to the workspace root directory
cd /workspace
# Change to the sim directory
cd /workspace/sim
# Setup .bashrc
echo "📄 Setting up .bashrc with aliases..."
@@ -15,50 +15,27 @@ cp /workspace/.devcontainer/.bashrc ~/.bashrc
echo 'if [ -f ~/.bashrc ]; then . ~/.bashrc; fi' >> ~/.profile
# Clean and reinstall dependencies to ensure platform compatibility
echo "📦 Cleaning and reinstalling dependencies..."
echo "📦 Cleaning and reinstalling npm dependencies..."
if [ -d "node_modules" ]; then
echo "Removing existing node_modules to ensure platform compatibility..."
rm -rf node_modules
rm -rf apps/sim/node_modules
rm -rf apps/docs/node_modules
fi
# Ensure Bun cache directory exists and has correct permissions
mkdir -p ~/.bun/cache
chmod 700 ~/.bun ~/.bun/cache
# Install dependencies with platform-specific binaries
echo "Installing dependencies with Bun..."
bun install || {
echo "⚠️ bun install had issues but continuing setup..."
npm install || {
echo "⚠️ npm install had issues but continuing setup..."
}
# Check for native dependencies
echo "Checking for native dependencies compatibility..."
NATIVE_DEPS=$(grep '"trustedDependencies"' apps/sim/package.json || echo "")
if [ ! -z "$NATIVE_DEPS" ]; then
echo "⚠️ Native dependencies detected. Ensuring compatibility with Bun..."
for pkg in $(echo $NATIVE_DEPS | grep -oP '"[^"]*"' | tr -d '"' | grep -v "trustedDependencies"); do
echo "Checking compatibility for $pkg..."
done
fi
# Set up environment variables if .env doesn't exist for the sim app
if [ ! -f "apps/sim/.env" ]; then
# Set up environment variables if .env doesn't exist
if [ ! -f ".env" ]; then
echo "📄 Creating .env file from template..."
if [ -f "apps/sim/.env.example" ]; then
cp apps/sim/.env.example apps/sim/.env
else
echo "DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio" > apps/sim/.env
fi
cp .env.example .env 2>/dev/null || echo "DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio" > .env
fi
# Generate schema and run database migrations
echo "🗃️ Running database schema generation and migrations..."
echo "Generating schema..."
cd apps/sim
bunx drizzle-kit generate
cd ../..
npx drizzle-kit generate
echo "Waiting for database to be ready..."
# Try to connect to the database, but don't fail the script if it doesn't work
@@ -67,9 +44,7 @@ echo "Waiting for database to be ready..."
while [ $timeout -gt 0 ]; do
if PGPASSWORD=postgres psql -h db -U postgres -c '\q' 2>/dev/null; then
echo "Database is ready!"
cd apps/sim
DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio bunx drizzle-kit push
cd ../..
DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio npx drizzle-kit push
break
fi
echo "Database is unavailable - sleeping (${timeout}s remaining)"
@@ -86,14 +61,13 @@ echo "Waiting for database to be ready..."
cat << EOF >> ~/.bashrc
# Additional Sim Studio Development Aliases
alias migrate="cd /workspace/apps/sim && DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio bunx drizzle-kit push"
alias generate="cd /workspace/apps/sim && bunx drizzle-kit generate"
alias dev="cd /workspace && bun run dev"
alias build="cd /workspace && bun run build"
alias start="cd /workspace && bun run dev"
alias lint="cd /workspace/apps/sim && bun run lint"
alias test="cd /workspace && bun run test"
alias bun-update="cd /workspace && bun update"
alias migrate="cd /workspace/sim && DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio npx drizzle-kit push"
alias generate="cd /workspace/sim && npx drizzle-kit generate"
alias dev="cd /workspace/sim && npm run dev"
alias build="cd /workspace/sim && npm run build"
alias start="cd /workspace/sim && npm run start"
alias lint="cd /workspace/sim && npm run lint"
alias test="cd /workspace/sim && npm run test"
EOF
# Source the .bashrc to make aliases available immediately

View File

@@ -1,11 +1,12 @@
LICENSE
NOTICE
.prettierrc
.prettierignore
README.md
.gitignore
.husky
# Exclude files from Docker build
.git
.github
.devcontainer
.env.example
node_modules
node_modules
.next
.vercel
.husky
.env
.env.*
npm-debug.log
README.md
.devcontainer

View File

@@ -14,22 +14,22 @@ appearance, race, religion, or sexual identity and orientation.
Examples of behaviour that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologising to those affected by our mistakes,
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologising to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behaviour include:
- The use of sexualised language or imagery, and sexual attention or advances
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
* The use of sexualised language or imagery, and sexual attention or advances
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
@@ -112,4 +112,4 @@ the community.
This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version
[1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and
[2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md),
and was generated by [contributing.md](https://contributing.md/generator).
and was generated by [contributing.md](https://contributing.md/generator).

View File

@@ -3,7 +3,7 @@
Thank you for your interest in contributing to Sim Studio! Our goal is to provide developers with a powerful, user-friendly platform for building, testing, and optimizing agentic workflows. We welcome contributions in all forms—from bug fixes and design improvements to brand-new features.
> **Project Overview:**
> Sim Studio is a monorepo using Turborepo, containing the main application (`apps/sim/`), documentation (`apps/docs/`), and shared packages (`packages/`). The main application is built with Next.js (app router), ReactFlow, Zustand, Shadcn, and Tailwind CSS. Please ensure your contributions follow our best practices for clarity, maintainability, and consistency.
> Sim Studio is a monorepo containing the main application (`sim/`) and documentation (`docs/`). The main application is built with Next.js (app router), ReactFlow, Zustand, Shadcn, and Tailwind CSS. Please ensure your contributions follow our best practices for clarity, maintainability, and consistency.
---
@@ -130,69 +130,54 @@ Using clear and consistent commit messages makes it easier for everyone to under
To set up your local development environment:
### Option 1: Using NPM Package (Simplest)
### Option 1: Using Docker (Recommended)
The easiest way to run Sim Studio locally is using our NPM package:
Docker provides a consistent development environment with all dependencies pre-configured.
```bash
npx simstudio
```
1. **Clone the Repository:**
After running this command, open [http://localhost:3000/](http://localhost:3000/) in your browser.
```bash
git clone https://github.com/<your-username>/sim.git
cd sim
```
#### Options
2. **Start the Docker Environment:**
- `-p, --port <port>`: Specify the port to run Sim Studio on (default: 3000)
- `--no-pull`: Skip pulling the latest Docker images
```bash
docker compose up -d
```
#### Requirements
Or use the convenience script which handles environment setup and migrations:
- Docker must be installed and running on your machine
```bash
chmod +x scripts/start_simstudio_docker.sh
./scripts/start_simstudio_docker.sh
```
### Option 2: Using Docker Compose
This will:
```bash
# Clone the repository
git clone https://github.com/<your-username>/sim.git
cd sim
- Start a PostgreSQL database container
- Build and run the Next.js application with hot-reloading
- Set up all necessary environment variables
- Apply database migrations automatically
# Start Sim Studio
docker compose -f docker-compose.prod.yml up -d
```
3. **View Logs:**
Access the application at [http://localhost:3000/](http://localhost:3000/)
```bash
docker compose logs -f simstudio
```
#### Using Local Models
4. **Make Your Changes:**
- Edit files in your local directory
- Changes will be automatically reflected thanks to hot-reloading
To use local models with Sim Studio:
1. Pull models using our helper script:
```bash
./apps/sim/scripts/ollama_docker.sh pull <model_name>
```
2. Start Sim Studio with local model support:
```bash
# With NVIDIA GPU support
docker compose --profile local-gpu -f docker-compose.ollama.yml up -d
# Without GPU (CPU only)
docker compose --profile local-cpu -f docker-compose.ollama.yml up -d
# If hosting on a server, update the environment variables in the docker-compose.prod.yml file
# to include the server's public IP then start again (OLLAMA_URL to i.e. http://1.1.1.1:11434)
docker compose -f docker-compose.prod.yml up -d
```
### Option 3: Using VS Code / Cursor Dev Containers
### Option 2: Using VS Code / Cursor Dev Containers
Dev Containers provide a consistent and easy-to-use development environment:
1. **Prerequisites:**
- Visual Studio Code or Cursor
- Visual Studio Code
- Docker Desktop
- [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension for VS Code
@@ -203,53 +188,58 @@ Dev Containers provide a consistent and easy-to-use development environment:
git clone https://github.com/<your-username>/sim.git
cd sim
```
- Open the project in VS Code/Cursor
- Open the project in VS Code
- When prompted, click "Reopen in Container" (or press F1 and select "Remote-Containers: Reopen in Container")
- Wait for the container to build and initialize
- The development environment will be set up in the `sim/` directory
3. **Start Developing:**
- Run `bun run dev` in the terminal or use the `sim-start` alias
- All dependencies and configurations are automatically set up
- Use the provided aliases (like `sim-start`) to run common commands
- Your changes will be automatically hot-reloaded
4. **GitHub Codespaces:**
- This setup also works with GitHub Codespaces if you prefer development in the browser
- Just click "Code" → "Codespaces" → "Create codespace on main"
### Option 4: Manual Setup
### Option 3: Manual Setup
If you prefer not to use Docker or Dev Containers:
1. **Clone the Repository:**
```bash
git clone https://github.com/<your-username>/sim.git
cd sim
bun install
cd sim/sim
```
2. **Install Dependencies:**
2. **Set Up Environment:**
- Navigate to the app directory:
- Using NPM:
```bash
cd apps/sim
npm install
```
3. **Set Up Environment:**
- Copy `.env.example` to `.env`
- Configure required variables (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL)
- Configure database connection and other required authentication variables
3. **Set Up Database:**
4. **Set Up Database:**
```bash
bunx drizzle-kit push
```
- You need a PostgreSQL instance running
- Run migrations:
```bash
npm run db:push
```
4. **Run the Development Server:**
5. **Run the Development Server:**
```bash
bun run dev
```
- With NPM:
```bash
npm run dev
```
5. **Make Your Changes and Test Locally.**
6. **Make Your Changes and Test Locally.**
### Email Template Development
@@ -258,7 +248,7 @@ When working on email templates, you can preview them using a local email previe
1. **Run the Email Preview Server:**
```bash
bun run email:dev
npm run email:dev
```
2. **Access the Preview:**
@@ -279,26 +269,26 @@ Sim Studio is built in a modular fashion where blocks and tools extend the platf
### Where to Add Your Code
- **Blocks:** Create your new block file under the `/apps/sim/blocks/blocks` directory. The name of the file should match the provider name (e.g., `pinecone.ts`).
- **Tools:** Create a new directory under `/apps/sim/tools` with the same name as the provider (e.g., `/apps/sim/tools/pinecone`).
- **Blocks:** Create your new block file under the `/sim/blocks/blocks` directory. The name of the file should match the provider name (e.g., `pinecone.ts`).
- **Tools:** Create a new directory under `/sim/tools` with the same name as the provider (e.g., `/sim/tools/pinecone`).
In addition, you will need to update the registries:
- **Block Registry:** Update the blocks index (`/apps/sim/blocks/index.ts`) to include your new block.
- **Tool Registry:** Update the tools registry (`/apps/sim/tools/index.ts`) to add your new tool.
- **Block Registry:** Update the blocks index (`/sim/blocks/index.ts`) to include your new block.
- **Tool Registry:** Update the tools registry (`/sim/tools/index.ts`) to add your new tool.
### How to Create a New Block
1. **Create a New File:**
Create a file for your block named after the provider (e.g., `pinecone.ts`) in the `/apps/sim/blocks/blocks` directory.
Create a file for your block named after the provider (e.g., `pinecone.ts`) in the `/sim/blocks/blocks` directory.
2. **Create a New Icon:**
Create a new icon for your block in the `/apps/sim/components/icons.tsx` file. The icon should follow the same naming convention as the block (e.g., `PineconeIcon`).
Create a new icon for your block in the `/sim/components/icons.tsx` file. The icon should follow the same naming convention as the block (e.g., `PineconeIcon`).
3. **Define the Block Configuration:**
Your block should export a constant of type `BlockConfig`. For example:
```typescript:/apps/sim/blocks/blocks/pinecone.ts
```typescript:/sim/blocks/blocks/pinecone.ts
import { PineconeIcon } from '@/components/icons'
import { PineconeResponse } from '@/tools/pinecone/types'
import { BlockConfig } from '../types'
@@ -323,11 +313,11 @@ In addition, you will need to update the registries:
```
4. **Register Your Block:**
Add your block to the blocks registry (`/apps/sim/blocks/registry.ts`):
Add your block to the blocks registry (`/sim/blocks/registry.ts`):
```typescript:/apps/sim/blocks/registry.ts
```typescript:/sim/blocks/registry.ts
import { PineconeBlock } from './blocks/pinecone'
// Registry of all available blocks
export const registry: Record<string, BlockConfig> = {
// ... existing blocks
@@ -343,7 +333,7 @@ In addition, you will need to update the registries:
### How to Create a New Tool
1. **Create a New Directory:**
Create a directory under `/apps/sim/tools` with the same name as the provider (e.g., `/apps/sim/tools/pinecone`).
Create a directory under `/sim/tools` with the same name as the provider (e.g., `/sim/tools/pinecone`).
2. **Create Tool Files:**
Create separate files for each tool functionality with descriptive names (e.g., `fetch.ts`, `generate_embeddings.ts`, `search_text.ts`) in your tool directory.
@@ -354,7 +344,7 @@ In addition, you will need to update the registries:
4. **Create an Index File:**
Create an `index.ts` file in your tool directory that imports and exports all tools:
```typescript:/apps/sim/tools/pinecone/index.ts
```typescript:/sim/tools/pinecone/index.ts
import { fetchTool } from './fetch'
import { generateEmbeddingsTool } from './generate_embeddings'
import { searchTextTool } from './search_text'
@@ -365,7 +355,7 @@ In addition, you will need to update the registries:
5. **Define the Tool Configuration:**
Your tool should export a constant with a naming convention of `{toolName}Tool`. The tool ID should follow the format `{provider}_{tool_name}`. For example:
```typescript:/apps/sim/tools/pinecone/fetch.ts
```typescript:/sim/tools/pinecone/fetch.ts
import { ToolConfig, ToolResponse } from '../types'
import { PineconeParams, PineconeResponse } from './types'
@@ -394,9 +384,9 @@ In addition, you will need to update the registries:
```
6. **Register Your Tool:**
Update the tools registry in `/apps/sim/tools/index.ts` to include your new tool:
Update the tools registry in `/sim/tools/index.ts` to include your new tool:
```typescript:/apps/sim/tools/index.ts
```typescript:/sim/tools/index.ts
import { fetchTool, generateEmbeddingsTool, searchTextTool } from './pinecone'
// ... other imports
@@ -411,12 +401,6 @@ In addition, you will need to update the registries:
7. **Test Your Tool:**
Ensure that your tool functions correctly by making test requests and verifying the responses.
8. **Generate Documentation:**
Run the documentation generator to create docs for your new tool:
```bash
./scripts/generate-docs.sh
```
### Naming Conventions
Maintaining consistent naming across the codebase is critical for auto-generation of tools and documentation. Follow these naming guidelines:

View File

@@ -1,7 +1,7 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG]'
title: "[BUG]"
labels: bug
assignees: ''
---
@@ -11,7 +11,6 @@ A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
@@ -24,4 +23,4 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
Add any other context about the problem here.

View File

@@ -1,7 +1,7 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[REQUEST]'
title: "[REQUEST]"
labels: feature
assignees: ''
---
@@ -16,4 +16,4 @@ A clear and concise description of what you want to happen.
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
Add any other context or screenshots about the feature request here.

View File

@@ -26,7 +26,7 @@ Please describe the tests that you ran to verify your changes. Provide instructi
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] All tests pass locally and in CI (`bun run test`)
- [ ] All tests pass locally and in CI (`npm test`)
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published in downstream modules
- [ ] I have updated version numbers as needed (if needed)

4
.github/SECURITY.md vendored
View File

@@ -6,6 +6,7 @@
| ------- | ------------------ |
| 0.1.x | :white_check_mark: |
## Reporting a Vulnerability
We take the security of Sim Studio seriously. If you believe you've found a security vulnerability, please follow these steps:
@@ -15,7 +16,6 @@ We take the security of Sim Studio seriously. If you believe you've found a secu
2. **Email us directly** at security@simstudio.ai with details of the vulnerability.
3. **Include the following information** in your report:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
@@ -23,4 +23,4 @@ We take the security of Sim Studio seriously. If you believe you've found a secu
4. We will acknowledge receipt of your vulnerability report within 48 hours and provide an estimated timeline for a fix.
5. Once the vulnerability is fixed, we will notify you and publicly acknowledge your contribution (unless you prefer to remain anonymous).
5. Once the vulnerability is fixed, we will notify you and publicly acknowledge your contribution (unless you prefer to remain anonymous).

98
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/sim"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
# Disable version updates
open-pull-requests-limit: 0
labels:
- "dependencies"
- "security"
commit-message:
prefix: "fix(deps)"
prefix-development: "chore(deps)"
include: "scope"
groups:
dependencies:
applies-to: security-updates
patterns:
- "*"
# Documentation site dependencies (/docs)
- package-ecosystem: "npm"
directory: "/docs"
schedule:
interval: "weekly"
day: "wednesday"
# Disable version updates
open-pull-requests-limit: 0
labels:
- "dependencies"
- "security"
commit-message:
prefix: "docs(deps)"
include: "scope"
groups:
docs-dependencies:
applies-to: security-updates
patterns:
- "*"
# Root-level dependencies (if any)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "friday"
# Disable version updates
open-pull-requests-limit: 0
labels:
- "dependencies"
- "security"
commit-message:
prefix: "chore(deps)"
include: "scope"
groups:
root-dependencies:
applies-to: security-updates
patterns:
- "*"
# GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
# Disable version updates
open-pull-requests-limit: 0
labels:
- "dependencies"
- "security"
commit-message:
prefix: "ci(deps)"
groups:
actions:
applies-to: security-updates
patterns:
- "*"
# Docker containers (if applicable)
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "monthly"
# Disable version updates
open-pull-requests-limit: 0
labels:
- "dependencies"
- "security"
commit-message:
prefix: "docker(deps)"
groups:
docker:
applies-to: security-updates
patterns:
- "*"

View File

@@ -1,66 +0,0 @@
name: Build and Publish Docker Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- dockerfile: ./docker/app.Dockerfile
image: ghcr.io/simstudioai/simstudio
- dockerfile: ./docker/db.Dockerfile
image: ghcr.io/simstudioai/migrations
- dockerfile: ./docker/realtime.Dockerfile
image: ghcr.io/simstudioai/realtime
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ matrix.image }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}.{{minor}}.{{patch}}
type=sha,format=long
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -2,76 +2,82 @@ name: CI
on:
push:
branches: [main, staging]
branches: [main]
pull_request:
branches: [main, staging]
branches: [main]
jobs:
test:
name: Test and Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Node
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: latest
node-version: '20'
cache: 'npm'
cache-dependency-path: './sim/package-lock.json'
- name: Install dependencies
run: bun install
working-directory: ./sim
run: npm ci
- name: Fix Rollup module issue
working-directory: ./sim
run: |
rm -rf node_modules package-lock.json
npm install
- name: Run tests with coverage
working-directory: ./sim
env:
NODE_OPTIONS: '--no-warnings'
NEXT_PUBLIC_APP_URL: 'https://www.simstudio.ai'
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
run: bun run test
NODE_OPTIONS: "--no-warnings"
run: npm run test:coverage
- name: Build application
working-directory: ./sim
env:
NODE_OPTIONS: '--no-warnings'
NEXT_PUBLIC_APP_URL: 'https://www.simstudio.ai'
STRIPE_SECRET_KEY: 'dummy_key_for_ci_only'
STRIPE_WEBHOOK_SECRET: 'dummy_secret_for_ci_only'
RESEND_API_KEY: 'dummy_key_for_ci_only'
AWS_REGION: 'us-west-2'
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
run: bun run build
NODE_OPTIONS: "--no-warnings"
NEXT_PUBLIC_APP_URL: "https://www.simstudio.ai"
STRIPE_SECRET_KEY: "dummy_key_for_ci_only"
STRIPE_WEBHOOK_SECRET: "dummy_secret_for_ci_only"
RESEND_API_KEY: "dummy_key_for_ci_only"
AWS_REGION: "us-west-2"
run: npm run build
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v3
with:
directory: ./apps/sim/coverage
directory: ./sim/coverage
fail_ci_if_error: false
verbose: true
verbose: true
migrations:
name: Apply Database Migrations
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
bun-version: latest
node-version: '20'
cache: 'npm'
cache-dependency-path: './sim/package-lock.json'
- name: Install dependencies
run: bun install
working-directory: ./sim
run: npm ci
- name: Apply migrations
working-directory: ./apps/sim
working-directory: ./sim
env:
DATABASE_URL: ${{ github.ref == 'refs/heads/main' && secrets.DATABASE_URL || secrets.STAGING_DATABASE_URL }}
run: bunx drizzle-kit push
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npx drizzle-kit push

58
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Docker Build and Publish
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: simstudioai/sim
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
buildkitd-flags: --debug
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,58 +1,93 @@
name: Publish CLI Package
name: Publish Sim Studio CLI
on:
push:
branches: [main]
branches:
- main
paths:
- 'packages/cli/**'
- 'packages/simstudio/**'
- '.github/workflows/publish-cli.yml'
jobs:
publish-npm:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
fetch-depth: 0
- name: Setup Node.js for npm publishing
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
registry-url: 'https://registry.npmjs.org/'
node-version: '20'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: 'packages/simstudio/package-lock.json'
- name: Install dependencies
working-directory: packages/cli
run: bun install
working-directory: packages/simstudio
run: npm ci
- name: Build package
working-directory: packages/cli
run: bun run build
working-directory: packages/simstudio
run: npm run build
- name: Get package version
id: package_version
working-directory: packages/cli
- name: Get version
working-directory: packages/simstudio
id: get_version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Check if version already exists
id: version_check
- name: Check version update
working-directory: packages/simstudio
run: |
if npm view simstudio@${{ steps.package_version.outputs.version }} version &> /dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
if ! git diff ${{ github.event.before }} ${{ github.sha }} packages/simstudio/package.json | grep -q '"version":'; then
echo "::error::Version not updated in package.json"
exit 1
fi
- name: Run tests
working-directory: packages/simstudio
run: npm test
- name: Check for changes
working-directory: packages/simstudio
id: check_changes
run: |
if git diff --quiet ${{ github.event.before }} ${{ github.sha }} packages/simstudio/; then
echo "changes=false" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "changes=true" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
if: steps.version_check.outputs.exists == 'false'
working-directory: packages/cli
run: npm publish --access=public
if: steps.check_changes.outputs.changes == 'true'
working-directory: packages/simstudio
run: |
if ! npm publish; then
echo "::error::Failed to publish package"
exit 1
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Log skipped publish
if: steps.version_check.outputs.exists == 'true'
run: echo "Skipped publishing because version ${{ steps.package_version.outputs.version }} already exists on npm"
- name: Create Git tag
if: steps.check_changes.outputs.changes == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git tag -a "v${{ steps.get_version.outputs.version }}" -m "Release v${{ steps.get_version.outputs.version }}"
git push origin "v${{ steps.get_version.outputs.version }}"
- name: Create GitHub Release
if: steps.check_changes.outputs.changes == 'true'
uses: softprops/action-gh-release@v1
with:
name: "v${{ steps.get_version.outputs.version }}"
tag_name: "v${{ steps.get_version.outputs.version }}"
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,89 +0,0 @@
name: Publish Python SDK
on:
push:
branches: [main]
paths:
- 'packages/python-sdk/**'
jobs:
publish-pypi:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine pytest requests tomli
- name: Run tests
working-directory: packages/python-sdk
run: |
PYTHONPATH=. pytest tests/ -v
- name: Get package version
id: package_version
working-directory: packages/python-sdk
run: echo "version=$(python -c "import tomli; print(tomli.load(open('pyproject.toml', 'rb'))['project']['version'])")" >> $GITHUB_OUTPUT
- name: Check if version already exists
id: version_check
run: |
if pip index versions simstudio-sdk | grep -q "${{ steps.package_version.outputs.version }}"; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Build package
if: steps.version_check.outputs.exists == 'false'
working-directory: packages/python-sdk
run: python -m build
- name: Check package
if: steps.version_check.outputs.exists == 'false'
working-directory: packages/python-sdk
run: twine check dist/*
- name: Publish to PyPI
if: steps.version_check.outputs.exists == 'false'
working-directory: packages/python-sdk
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload dist/*
- name: Log skipped publish
if: steps.version_check.outputs.exists == 'true'
run: echo "Skipped publishing because version ${{ steps.package_version.outputs.version }} already exists on PyPI"
- name: Create GitHub Release
if: steps.version_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: python-sdk-v${{ steps.package_version.outputs.version }}
name: Python SDK v${{ steps.package_version.outputs.version }}
body: |
## Python SDK v${{ steps.package_version.outputs.version }}
Published simstudio-sdk==${{ steps.package_version.outputs.version }} to PyPI.
### Installation
```bash
pip install simstudio-sdk==${{ steps.package_version.outputs.version }}
```
### Documentation
See the [README](https://github.com/simstudio/sim/tree/main/packages/python-sdk) for usage instructions.
draft: false
prerelease: false

View File

@@ -1,85 +0,0 @@
name: Publish TypeScript SDK
on:
push:
branches: [main]
paths:
- 'packages/ts-sdk/**'
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Node.js for npm publishing
uses: actions/setup-node@v4
with:
node-version: '18'
registry-url: 'https://registry.npmjs.org/'
- name: Install dependencies
working-directory: packages/ts-sdk
run: bun install
- name: Run tests
working-directory: packages/ts-sdk
run: bun run test
- name: Build package
working-directory: packages/ts-sdk
run: bun run build
- name: Get package version
id: package_version
working-directory: packages/ts-sdk
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Check if version already exists
id: version_check
run: |
if npm view simstudio-ts-sdk@${{ steps.package_version.outputs.version }} version &> /dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
if: steps.version_check.outputs.exists == 'false'
working-directory: packages/ts-sdk
run: npm publish --access=public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Log skipped publish
if: steps.version_check.outputs.exists == 'true'
run: echo "Skipped publishing because version ${{ steps.package_version.outputs.version }} already exists on npm"
- name: Create GitHub Release
if: steps.version_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: typescript-sdk-v${{ steps.package_version.outputs.version }}
name: TypeScript SDK v${{ steps.package_version.outputs.version }}
body: |
## TypeScript SDK v${{ steps.package_version.outputs.version }}
Published simstudio-ts-sdk@${{ steps.package_version.outputs.version }} to npm.
### Installation
```bash
npm install simstudio-ts-sdk@${{ steps.package_version.outputs.version }}
```
### Documentation
See the [README](https://github.com/simstudio/sim/tree/main/packages/ts-sdk) for usage instructions.
draft: false
prerelease: false

46
.gitignore vendored
View File

@@ -1,23 +1,28 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/apps/**/node_modules
*/node_modules
docs/node_modules
/packages/**/node_modules
/scripts/node_modules
# bun specific
bun-debug.log*
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
/apps/**/coverage
# next.js
/.next/
/apps/**/out/
/apps/**/.next/
/apps/**/build
sim/.next/
sim/out/
sim/build
docs/.next/
docs/out/
docs/build
# production
/build
@@ -30,6 +35,12 @@ sim-standalone.tar.gz
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
*.env
@@ -49,20 +60,17 @@ next-env.d.ts
.cursorrules
# docs
/apps/docs/.source
/apps/docs/.contentlayer
/apps/docs/.content-collections
docs/.source
docs/.contentlayer
docs/.content-collections
# database instantiation
**/postgres_data/
# file uploads
uploads/
# collector configuration
collector-config.yaml
docker-compose.collector.yml
start-collector.sh
# Turborepo
.turbo
# VSCode
.vscode

View File

@@ -1 +1 @@
bun lint
cd sim && npx lint-staged

1
.npmrc
View File

@@ -1 +0,0 @@
ignore-scripts=true

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Set Node.js memory limit
ENV NODE_OPTIONS="--max-old-space-size=4096"
# Copy package files
COPY sim/package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application
COPY sim/ ./
# Generate database schema
RUN npx drizzle-kit generate
# Build the application
RUN npm run build
EXPOSE 3000
# Run migrations and start the app
CMD npx drizzle-kit push && npm run start

163
README.md
View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="apps/sim/public/static/sim.png" alt="Sim Studio Logo" width="500"/>
<img src="sim/public/static/sim.png" alt="Sim Studio Logo" width="500"/>
</p>
<p align="center">
@@ -14,135 +14,130 @@
<strong>Sim Studio</strong> is a lightweight, user-friendly platform for building AI agent workflows.
</p>
<p align="center">
<img src="apps/sim/public/static/demo.gif" alt="Sim Studio Demo" width="800"/>
</p>
## Getting Started
1. Use our [cloud-hosted version](https://simstudio.ai)
2. Self-host using one of the methods below
### Run on [Sim Studio Cloud](https://simstudio.ai)
## Self-Hosting Options
The fastest way to get started is to use our [cloud-hosted version](https://simstudio.ai) - no setup required!
### Option 1: NPM Package (Simplest)
### Self-host Sim Studio
The easiest way to run Sim Studio locally is using our [NPM package](https://www.npmjs.com/package/simstudio?activeTab=readme):
If you prefer to self-host, there are several options available:
### Option 1: Using CLI (Recommended)
The easiest way to self-host:
```bash
npx simstudio
# This will set up and run Sim Studio locally with minimal configuration
```
After running these commands, open [http://localhost:3000/](http://localhost:3000/) in your browser.
#### Options
- `-p, --port <port>`: Specify the port to run Sim Studio on (default: 3000)
- `--no-pull`: Skip pulling the latest Docker images
#### Requirements
- Docker must be installed and running on your machine
### Option 2: Docker Compose
### Option 2: Using Docker Compose
```bash
# Clone the repository
git clone https://github.com/simstudioai/sim.git
# Navigate to the project directory
cd sim
# Start Sim Studio
docker compose -f docker-compose.prod.yml up -d
# Create environment file (update BETTER_AUTH_SECRET and ENCRYPTION_KEY with secure random values)
cp sim/.env.example sim/.env
# Start with Docker Compose
docker compose up -d --build
```
Access the application at [http://localhost:3000/](http://localhost:3000/)
Once running, access the application at [http://localhost:3000/w/](http://localhost:3000/w/)
#### Using Local Models
## Working with Local Models
To use local models with Sim Studio:
1. Pull models using our helper script:
Sim Studio supports integration with local LLM models:
```bash
./apps/sim/scripts/ollama_docker.sh pull <model_name>
# Pull local models
./sim/scripts/ollama_docker.sh pull <model_name>
# Start with local model support
./start_simstudio_docker.sh --local
# For systems with NVIDIA GPU
docker compose up --profile local-gpu -d --build
# For CPU-only systems
docker compose up --profile local-cpu -d --build
```
2. Start Sim Studio with local model support:
### Connecting to Existing Ollama Instance
If you already have Ollama running locally:
```bash
# With NVIDIA GPU support
docker compose --profile local-gpu -f docker-compose.ollama.yml up -d
# Without GPU (CPU only)
docker compose --profile local-cpu -f docker-compose.ollama.yml up -d
# If hosting on a server, update the environment variables in the docker-compose.prod.yml file to include the server's public IP then start again (OLLAMA_URL to i.e. http://1.1.1.1:11434)
docker compose -f docker-compose.prod.yml up -d
# Using host networking (simplest)
docker compose up --profile local-cpu -d --build --network=host
```
### Option 3: Dev Containers
Or add this to your docker-compose.yml:
1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
2. Open the project and click "Reopen in Container" when prompted
3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
```yaml
services:
simstudio:
# ... existing configuration ...
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- OLLAMA_HOST=http://host.docker.internal:11434
```
### Option 4: Manual Setup
## Development Setup
1. Clone and install dependencies:
### Prerequisites
- Node.js 20+
- Docker (recommended)
- PostgreSQL (if not using Docker)
### Required Environment Variables
For local development, create a `.env` file with these minimum variables:
```env
DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
BETTER_AUTH_SECRET=<generate_a_secure_random_value>
ENCRYPTION_KEY=<generate_a_secure_random_value>
```
⚠️ **Note:** Without `RESEND_API_KEY`, verification codes will be logged to the console for local testing.
### Dev Container Option
1. Open in VS Code with the Remote-Containers extension
2. Click "Reopen in Container" when prompted
3. Run `npm run dev` or use the `sim-start` alias
### Manual Setup
```bash
git clone https://github.com/simstudioai/sim.git
cd sim
bun install
cd sim/sim
npm install
cp .env.example .env
npx drizzle-kit push
npm run dev
```
2. Set up environment:
## Troubleshooting
```bash
cd apps/sim
cp .env.example .env # Configure with required variables (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL)
```
Common issues and solutions:
3. Set up the database:
```bash
bunx drizzle-kit push
```
4. Start the development servers:
Next.js app:
```bash
bun run dev
```
Start the realtime server:
```bash
bun run dev:sockets
```
Run both together (recommended):
```bash
bun run dev:full
```
- **Authentication problems**: Check console logs for verification codes if `RESEND_API_KEY` is not set
- **Database connection errors**: Verify PostgreSQL is running and credentials are correct
- **Port conflicts**: Check if port 3000 is already in use by another application
## Tech Stack
- **Framework**: [Next.js](https://nextjs.org/) (App Router)
- **Runtime**: [Bun](https://bun.sh/)
- **Database**: PostgreSQL with [Drizzle ORM](https://orm.drizzle.team)
- **Authentication**: [Better Auth](https://better-auth.com)
- **UI**: [Shadcn](https://ui.shadcn.com/), [Tailwind CSS](https://tailwindcss.com)
- **State Management**: [Zustand](https://zustand-demo.pmnd.rs/)
- **Flow Editor**: [ReactFlow](https://reactflow.dev/)
- **Docs**: [Fumadocs](https://fumadocs.vercel.app/)
- **Monorepo**: [Turborepo](https://turborepo.org/)
- **Realtime**: [Socket.io](https://socket.io/)
## Contributing
@@ -152,4 +147,4 @@ We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTI
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
<p align="center">Made with ❤️ by the Sim Studio Team</p>
<p align="center">Made with ❤️ by the Sim Studio Team</p>

39
apps/docs/.gitignore vendored
View File

@@ -1,39 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# bun specific
.bun
bun.lockb
bun-debug.log*
# testing
/coverage
# next.js
/.next/
/out/
/build
# misc
.DS_Store
*.pem
# env files
.env
*.env
.env.local
.env.development
.env.test
.env.production
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Fumadocs
/.source/

View File

@@ -1,5 +0,0 @@
import type { ReactNode } from 'react'
export default function SlugLayout({ children }: { children: ReactNode }) {
return children
}

View File

@@ -1,18 +0,0 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
:root {
--color-fd-primary: #802fff; /* Purple from control-bar component */
}
/* Custom text highlighting styles */
.text-highlight {
color: var(--color-fd-primary);
}
/* Override marker color for highlighted lists */
.highlight-markers li::marker {
color: var(--color-fd-primary);
}
@source '../node_modules/fumadocs-ui/dist/**/*.js';

View File

@@ -1,18 +0,0 @@
import { notFound } from 'next/navigation'
import { type NextRequest, NextResponse } from 'next/server'
import { getLLMText } from '@/lib/llms'
import { source } from '@/lib/source'
export const revalidate = false
export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug?: string[] }> }) {
const { slug } = await params
const page = source.getPage(slug)
if (!page) notFound()
return new NextResponse(await getLLMText(page))
}
export function generateStaticParams() {
return source.generateParams()
}

View File

@@ -1,12 +0,0 @@
import { getLLMText } from '@/lib/llms'
import { source } from '@/lib/source'
// cached forever
export const revalidate = false
export async function GET() {
const scan = source.getPages().map(getLLMText)
const scanned = await Promise.all(scan)
return new Response(scanned.join('\n\n'))
}

View File

@@ -1,188 +0,0 @@
---
title: Response
description: Send a structured response back to API calls
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { ThemeImage } from '@/components/ui/theme-image'
The Response block is the final component in API-enabled workflows that transforms your workflow's variables into a structured HTTP response. This block serves as the endpoint that returns data, status codes, and headers back to API callers.
<ThemeImage
lightSrc="/static/light/response-light.png"
darkSrc="/static/dark/response-dark.png"
alt="Response Block"
width={430}
height={784}
/>
<Callout type="info">
Response blocks are terminal blocks - they mark the end of a workflow execution and cannot have further connections.
</Callout>
## Overview
The Response block serves as the final output mechanism for API workflows, enabling you to:
<Steps>
<Step>
<strong>Return structured data</strong>: Transform workflow variables into JSON responses
</Step>
<Step>
<strong>Set HTTP status codes</strong>: Control the response status (200, 400, 500, etc.)
</Step>
<Step>
<strong>Configure headers</strong>: Add custom HTTP headers to the response
</Step>
<Step>
<strong>Reference variables</strong>: Use workflow variables dynamically in the response
</Step>
</Steps>
## Configuration Options
### Response Data
The response data is the main content that will be sent back to the API caller. This should be formatted as JSON and can include:
- Static values
- Dynamic references to workflow variables using the `<variable.name>` syntax
- Nested objects and arrays
- Any valid JSON structure
### Status Code
Set the HTTP status code for the response. Common status codes include:
<Tabs items={['Success (2xx)', 'Client Error (4xx)', 'Server Error (5xx)']}>
<Tab>
<ul className="list-disc space-y-2 pl-6">
<li><strong>200</strong>: OK - Standard success response</li>
<li><strong>201</strong>: Created - Resource successfully created</li>
<li><strong>204</strong>: No Content - Success with no response body</li>
</ul>
</Tab>
<Tab>
<ul className="list-disc space-y-2 pl-6">
<li><strong>400</strong>: Bad Request - Invalid request parameters</li>
<li><strong>401</strong>: Unauthorized - Authentication required</li>
<li><strong>404</strong>: Not Found - Resource doesn't exist</li>
<li><strong>422</strong>: Unprocessable Entity - Validation errors</li>
</ul>
</Tab>
<Tab>
<ul className="list-disc space-y-2 pl-6">
<li><strong>500</strong>: Internal Server Error - Server-side error</li>
<li><strong>502</strong>: Bad Gateway - External service error</li>
<li><strong>503</strong>: Service Unavailable - Service temporarily down</li>
</ul>
</Tab>
</Tabs>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Default status code is 200 if not specified.
</p>
### Response Headers
Configure additional HTTP headers to include in the response.
Headers are configured as key-value pairs:
| Key | Value |
|-----|-------|
| Content-Type | application/json |
| Cache-Control | no-cache |
| X-API-Version | 1.0 |
## Inputs and Outputs
<Tabs items={['Inputs', 'Outputs']}>
<Tab>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>data</strong> (JSON, optional): The JSON data to send in the response body
</li>
<li>
<strong>status</strong> (number, optional): HTTP status code (default: 200)
</li>
<li>
<strong>headers</strong> (JSON, optional): Additional response headers
</li>
</ul>
</Tab>
<Tab>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>response</strong>: Complete response object containing:
<ul className="list-disc space-y-1 pl-6 mt-2">
<li><strong>data</strong>: The response body data</li>
<li><strong>status</strong>: HTTP status code</li>
<li><strong>headers</strong>: Response headers</li>
</ul>
</li>
</ul>
</Tab>
</Tabs>
## Variable References
Use the `<variable.name>` syntax to dynamically insert workflow variables into your response:
```json
{
"user": {
"id": "<variable.userId>",
"name": "<variable.userName>",
"email": "<variable.userEmail>"
},
"query": "<variable.searchQuery>",
"results": "<variable.searchResults>",
"totalFound": "<variable.resultCount>",
"processingTime": "<variable.executionTime>ms"
}
```
<Callout type="warning">
Variable names are case-sensitive and must match exactly with the variables available in your workflow.
</Callout>
## Example Usage
Here's an example of how a Response block might be configured for a user search API:
```yaml
data: |
{
"success": true,
"data": {
"users": "<variable.searchResults>",
"pagination": {
"page": "<variable.currentPage>",
"limit": "<variable.pageSize>",
"total": "<variable.totalUsers>"
}
},
"query": {
"searchTerm": "<variable.searchTerm>",
"filters": "<variable.appliedFilters>"
},
"timestamp": "<variable.timestamp>"
}
status: 200
headers:
- key: X-Total-Count
value: <variable.totalUsers>
- key: Cache-Control
value: public, max-age=300
```
## Best Practices
- **Use meaningful status codes**: Choose appropriate HTTP status codes that accurately reflect the outcome of the workflow
- **Structure your responses consistently**: Maintain a consistent JSON structure across all your API endpoints for better developer experience
- **Include relevant metadata**: Add timestamps and version information to help with debugging and monitoring
- **Handle errors gracefully**: Use conditional logic in your workflow to set appropriate error responses with descriptive messages
- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes

View File

@@ -1,231 +0,0 @@
---
title: Workflow
description: Execute other workflows as reusable components within your current workflow
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { ThemeImage } from '@/components/ui/theme-image'
The Workflow block allows you to execute other workflows as reusable components within your current workflow. This powerful feature enables modular design, code reuse, and the creation of complex nested workflows that can be composed from smaller, focused workflows.
<ThemeImage
lightSrc="/static/light/workflow-light.png"
darkSrc="/static/dark/workflow-dark.png"
alt="Workflow Block"
width={300}
height={175}
/>
<Callout type="info">
Workflow blocks enable modular design by allowing you to compose complex workflows from smaller, reusable components.
</Callout>
## Overview
The Workflow block serves as a bridge between workflows, enabling you to:
<Steps>
<Step>
<strong>Reuse existing workflows</strong>: Execute previously created workflows as components within new workflows
</Step>
<Step>
<strong>Create modular designs</strong>: Break down complex processes into smaller, manageable workflows
</Step>
<Step>
<strong>Maintain separation of concerns</strong>: Keep different business logic isolated in separate workflows
</Step>
<Step>
<strong>Enable team collaboration</strong>: Share and reuse workflows across different projects and team members
</Step>
</Steps>
## How It Works
The Workflow block:
1. Takes a reference to another workflow in your workspace
2. Passes input data from the current workflow to the child workflow
3. Executes the child workflow in an isolated context
4. Returns the results back to the parent workflow for further processing
## Configuration Options
### Workflow Selection
Choose which workflow to execute from a dropdown list of available workflows in your workspace. The list includes:
- All workflows you have access to in the current workspace
- Workflows shared with you by other team members
- Both enabled and disabled workflows (though only enabled workflows can be executed)
### Input Data
Define the data to pass to the child workflow:
- **Single Variable Input**: Select a variable or block output to pass to the child workflow
- **Variable References**: Use `<variable.name>` to reference workflow variables
- **Block References**: Use `<blockName.field>` to reference outputs from previous blocks
- **Automatic Mapping**: The selected data is automatically available as `start.input` in the child workflow
- **Optional**: The input field is optional - child workflows can run without input data
- **Type Preservation**: Variable types (strings, numbers, objects, etc.) are preserved when passed to the child workflow
### Examples of Input References
- `<variable.customerData>` - Pass a workflow variable
- `<dataProcessor.result>` - Pass the result from a previous block
- `<start.input>` - Pass the original workflow input
- `<apiCall.data.user>` - Pass a specific field from an API response
### Execution Context
The child workflow executes with:
- Its own isolated execution context
- Access to the same workspace resources (API keys, environment variables)
- Proper workspace membership and permission checks
- Independent logging and monitoring
## Safety and Limitations
To prevent infinite recursion and ensure system stability, the Workflow block includes several safety mechanisms:
<Callout type="warning">
**Cycle Detection**: The system automatically detects and prevents circular dependencies between workflows to avoid infinite loops.
</Callout>
- **Maximum Depth Limit**: Nested workflows are limited to a maximum depth of 10 levels
- **Cycle Detection**: Automatic detection and prevention of circular workflow dependencies
- **Timeout Protection**: Child workflows inherit timeout settings to prevent indefinite execution
- **Resource Limits**: Memory and execution time limits apply to prevent resource exhaustion
## Inputs and Outputs
<Tabs items={['Inputs', 'Outputs']}>
<Tab>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Workflow ID</strong>: The identifier of the workflow to execute
</li>
<li>
<strong>Input Variable</strong>: Variable or block reference to pass to the child workflow (e.g., `<variable.name>` or `<block.field>`)
</li>
</ul>
</Tab>
<Tab>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Response</strong>: The complete output from the child workflow execution
</li>
<li>
<strong>Child Workflow Name</strong>: The name of the executed child workflow
</li>
<li>
<strong>Success Status</strong>: Boolean indicating whether the child workflow completed successfully
</li>
<li>
<strong>Error Information</strong>: Details about any errors that occurred during execution
</li>
<li>
<strong>Execution Metadata</strong>: Information about execution time, resource usage, and performance
</li>
</ul>
</Tab>
</Tabs>
## Example Usage
Here's an example of how a Workflow block might be used to create a modular customer onboarding process:
### Parent Workflow: Customer Onboarding
```yaml
# Main customer onboarding workflow
blocks:
- type: workflow
name: "Validate Customer Data"
workflowId: "customer-validation-workflow"
input: "<variable.newCustomer>"
- type: workflow
name: "Setup Customer Account"
workflowId: "account-setup-workflow"
input: "<Validate Customer Data.result>"
- type: workflow
name: "Send Welcome Email"
workflowId: "welcome-email-workflow"
input: "<Setup Customer Account.result.accountDetails>"
```
### Child Workflow: Customer Validation
```yaml
# Reusable customer validation workflow
# Access the input data using: start.input
blocks:
- type: function
name: "Validate Email"
code: |
const customerData = start.input;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(customerData.email);
- type: api
name: "Check Credit Score"
url: "https://api.creditcheck.com/score"
method: "POST"
body: "<start.input>"
```
### Variable Reference Examples
```yaml
# Using workflow variables
input: "<variable.customerInfo>"
# Using block outputs
input: "<dataProcessor.cleanedData>"
# Using nested object properties
input: "<apiCall.data.user.profile>"
# Using array elements (if supported by the resolver)
input: "<listProcessor.items[0]>"
```
## Access Control and Permissions
The Workflow block respects workspace permissions and access controls:
- **Workspace Membership**: Only workflows within the same workspace can be executed
- **Permission Inheritance**: Child workflows inherit the execution permissions of the parent workflow
- **API Key Access**: Child workflows have access to the same API keys and environment variables as the parent
- **User Context**: The execution maintains the original user context for audit and logging purposes
## Best Practices
- **Keep workflows focused**: Design child workflows to handle specific, well-defined tasks
- **Minimize nesting depth**: Avoid deeply nested workflow hierarchies for better maintainability
- **Handle errors gracefully**: Implement proper error handling for child workflow failures
- **Document dependencies**: Clearly document which workflows depend on others
- **Version control**: Consider versioning strategies for workflows that are used as components
- **Test independently**: Ensure child workflows can be tested and validated independently
- **Monitor performance**: Be aware that nested workflows can impact overall execution time
## Common Patterns
### Microservice Architecture
Break down complex business processes into smaller, focused workflows that can be developed and maintained independently.
### Reusable Components
Create library workflows for common operations like data validation, email sending, or API integrations that can be reused across multiple projects.
### Conditional Execution
Use workflow blocks within conditional logic to execute different business processes based on runtime conditions.
### Parallel Processing
Combine workflow blocks with parallel execution to run multiple child workflows simultaneously for improved performance.
<Callout type="tip">
When designing modular workflows, think of each workflow as a function with clear inputs, outputs, and a single responsibility.
</Callout>

View File

@@ -1,200 +0,0 @@
---
title: Execution Basics
description: Understanding the fundamental execution flow in Sim Studio
---
import { Callout } from 'fumadocs-ui/components/callout'
import { File, Files, Folder } from 'fumadocs-ui/components/files'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import {
AgentIcon,
ApiIcon,
ChartBarIcon,
CodeIcon,
ConditionalIcon,
ConnectIcon,
} from '@/components/icons'
When you run a workflow in Sim Studio, the execution engine follows a systematic process to ensure blocks are executed in the correct order and data flows properly between them.
## Execution Flow
The execution of a workflow follows these key steps:
<Steps>
<Step>
### Validation Before execution begins, the workflow is validated to ensure it has: - An enabled
starter block with no incoming connections - Properly connected blocks with valid configurations
- No circular dependencies (except in intentional loops) - Valid input and output types between
connected blocks
</Step>
<Step>
### Initialization The execution context is created, which includes: - Environment variables for
the workflow - Input values from the starter block - Initial state for all blocks - Execution path
tracking - Loop iteration counters
</Step>
<Step>
### Block Execution Blocks are executed in topological order (based on dependencies): - The system
identifies which blocks can be executed next - Inputs for each block are resolved from previous
block outputs - Each block is executed by its specialized handler - Outputs are stored in the
execution context
</Step>
<Step>
### Path Determination As execution progresses, the system determines which paths to follow: -
Router and conditional blocks make decisions about execution paths - Only blocks on active paths
are executed - The path tracker maintains the current execution state
</Step>
<Step>
### Result Collection After all blocks have executed: - Final outputs are collected - Execution
logs are compiled - Performance metrics are calculated - Results are presented in the UI
</Step>
</Steps>
## Block Types and Execution
Different block types have different execution behaviors:
<Tabs items={['Orchestration Blocks', 'Processing Blocks', 'Integration Blocks']}>
<Tab>
<Card>
Orchestration blocks control the flow of execution through your workflow.
<Files>
<File
name="Starter Block"
icon={<ConnectIcon className="h-4 w-4" />}
annotation="Initiates workflow execution and provides initial input values. Every workflow must have exactly one starter block."
/>
<File
name="Router Block"
icon={<ConnectIcon className="h-4 w-4" />}
annotation="Directs execution along specific paths based on dynamic decisions. Uses an AI model to select one of multiple possible paths."
/>
<File
name="Condition Block"
icon={<ConditionalIcon className="h-4 w-4" />}
annotation="Executes different paths based on conditional logic. Evaluates JavaScript expressions to determine which path to follow."
/>
</Files>
</Card>
</Tab>
<Tab>
<Card>
Processing blocks transform data and generate new outputs.
<Files>
<File
name="Agent Block"
icon={<AgentIcon className="h-4 w-4" />}
annotation="Interacts with AI models to generate content. Executes prompts against various LLM providers."
/>
<File
name="Function Block"
icon={<CodeIcon className="h-4 w-4" />}
annotation="Executes custom JavaScript/TypeScript code. Runs in a secure sandbox environment with access to connected block outputs."
/>
<File
name="Evaluator Block"
icon={<ChartBarIcon className="h-4 w-4" />}
annotation="Assesses outputs against defined criteria. Uses AI to evaluate content based on custom metrics."
/>
</Files>
</Card>
</Tab>
<Tab>
<Card>
Integration blocks connect with external systems.
<Files>
<File
name="API Block"
icon={<ApiIcon className="h-4 w-4" />}
annotation="Makes HTTP requests to external services. Configurable with headers, body, and authentication."
/>
<File
name="Tool Blocks"
icon={<CodeIcon className="h-4 w-4" />}
annotation="Specialized blocks for specific services (Gmail, Slack, GitHub, etc.). Each has its own execution logic for the specific service."
/>
</Files>
</Card>
</Tab>
</Tabs>
## Execution Methods
Sim Studio offers multiple ways to trigger workflow execution:
### Manual Execution
Run workflows on-demand through the Sim Studio interface by clicking the "Run" button. This is perfect for:
- Testing during development
- One-off tasks
- Workflows that need human supervision
### Scheduled Execution
Configure workflows to run automatically on a specified schedule:
- Set up recurring executions using cron expressions
- Define start times and frequency
- Configure timezone settings
- Set minimum and maximum execution intervals
### API Endpoints
Each workflow can be exposed as an API endpoint:
- Get a unique URL for your workflow
- Configure authentication requirements
- Send custom inputs via POST requests
- Receive execution results as JSON responses
### Webhooks
Configure workflows to execute in response to external events:
- Set up webhook triggers from third-party services
- Process incoming webhook data as workflow input
- Configure webhook security settings
- Support for specialized webhooks (GitHub, Stripe, etc.)
<Callout type="info">
The execution method you choose depends on your workflow's purpose. Manual execution is great for
development, while scheduled execution, API endpoints, and webhooks are better for production use
cases.
</Callout>
## Execution Context
Each workflow execution maintains a detailed context that includes:
- **Block States**: Outputs and execution status of each block
- **Execution Path**: The active path through the workflow
- **Routing Decisions**: Records of which paths were selected
- **Environment Variables**: Configuration values for the workflow
- **Execution Logs**: Detailed records of each step in the execution
This context is maintained throughout the execution and is used to:
- Resolve inputs for blocks
- Determine which blocks to execute next
- Track the progress of execution
- Provide debugging information
- Store intermediate results
## Real-Time Execution Monitoring
As your workflow executes, you can monitor its progress in real-time:
- **Active Block Highlighting**: The currently executing block is highlighted
- **Live Logs**: Execution logs appear in real-time in the logs panel
- **Block States**: Visual indicators show each block's execution state
- **Performance Metrics**: Timing information for each block's execution
These monitoring features help you understand how your workflow is executing and identify any issues that arise.

View File

@@ -1,101 +0,0 @@
---
title: Getting Started
description: Build, test, and optimize your agentic workflows
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Card, Cards } from 'fumadocs-ui/components/card'
import { File, Files, Folder } from 'fumadocs-ui/components/files'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import {
AgentIcon,
ApiIcon,
ChartBarIcon,
CodeIcon,
ConditionalIcon,
ConnectIcon,
ExaAIIcon,
FirecrawlIcon,
GmailIcon,
NotionIcon,
PerplexityIcon,
SlackIcon,
} from '@/components/icons'
Sim Studio is a powerful, user-friendly platform for building, testing, and optimizing your agentic workflows. This documentation will help you understand how to use the various components of Sim Studio to create sophisticated agent-based applications.
<Callout type="info">
This guide will walk you through the essential concepts and help you get started building your
first workflow.
</Callout>
## Core Components
Sim Studio is built around two primary components:
### Blocks
Blocks are the fundamental building elements of your workflows. Each block serves a specific purpose:
<Files>
<File
name="Agent Block"
icon={<AgentIcon className="h-4 w-4" />}
annotation="Create AI agents using any LLM provider"
/>
<File
name="API Block"
icon={<ApiIcon className="h-4 w-4" />}
annotation="Connect to external services and APIs"
/>
<File
name="Condition Block"
icon={<ConditionalIcon className="h-4 w-4" />}
annotation="Add conditional branching to your workflows"
/>
<File
name="Function Block"
icon={<CodeIcon className="h-4 w-4" />}
annotation="Execute custom JavaScript/TypeScript code"
/>
<File
name="Evaluator Block"
icon={<ChartBarIcon className="h-4 w-4" />}
annotation="Assess responses against defined criteria"
/>
<File
name="Router Block"
icon={<ConnectIcon className="h-4 w-4" />}
annotation="Direct workflow execution based on input analysis"
/>
</Files>
### Tools
Tools extend the capabilities of agents. They provide additional functionality for agents by enabling you to interface with your favorite data sources and take action (e.g posting on X, sending an email)
<Files>
<File name="Gmail Tool" icon={<GmailIcon className="h-4 w-4" />} />
<File name="Firecrawl Tool" icon={<FirecrawlIcon className="h-4 w-4" />} />
<File name="Perplexity Tool" icon={<PerplexityIcon className="h-4 w-4" />} />
<File name="Notion Tool" icon={<NotionIcon className="h-4 w-4" />} />
<File name="Exa AI Tool" icon={<ExaAIIcon className="h-4 w-4" />} />
<File name="Slack Tool" icon={<SlackIcon className="h-4 w-4" />} />
</Files>
## Getting Started
<Steps>
<Step title="Create a new workflow">
Start by creating a new workflow in the Sim Studio dashboard.
</Step>
<Step title="Add your first block">Drag and drop a block from the sidebar onto the canvas.</Step>
<Step title="Configure the block">
Set up the block's parameters and inputs according to your needs.
</Step>
<Step title="Connect blocks">
Create connections between blocks to define the flow of data and execution.
</Step>
<Step title="Test your workflow">Run your workflow with test inputs to verify its behavior.</Step>
</Steps>

View File

@@ -1,409 +0,0 @@
---
title: Python SDK
description: The official Python SDK for Sim Studio
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
The official Python SDK for Sim Studio allows you to execute workflows programmatically from your Python applications.
<Callout type="info">
The Python SDK supports Python 3.8+ and provides synchronous workflow execution. All workflow executions are currently synchronous.
</Callout>
## Installation
Install the SDK using pip:
```bash
pip install simstudio-sdk
```
## Quick Start
Here's a simple example to get you started:
```python
from simstudio import SimStudioClient
# Initialize the client
client = SimStudioClient(
api_key="your-api-key-here",
base_url="https://simstudio.ai" # optional, defaults to https://simstudio.ai
)
# Execute a workflow
try:
result = client.execute_workflow("workflow-id")
print("Workflow executed successfully:", result)
except Exception as error:
print("Workflow execution failed:", error)
```
## API Reference
### SimStudioClient
#### Constructor
```python
SimStudioClient(api_key: str, base_url: str = "https://simstudio.ai")
```
**Parameters:**
- `api_key` (str): Your Sim Studio API key
- `base_url` (str, optional): Base URL for the Sim Studio API
#### Methods
##### execute_workflow()
Execute a workflow with optional input data.
```python
result = client.execute_workflow(
"workflow-id",
input_data={"message": "Hello, world!"},
timeout=30.0 # 30 seconds
)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow to execute
- `input_data` (dict, optional): Input data to pass to the workflow
- `timeout` (float, optional): Timeout in seconds (default: 30.0)
**Returns:** `WorkflowExecutionResult`
##### get_workflow_status()
Get the status of a workflow (deployment status, etc.).
```python
status = client.get_workflow_status("workflow-id")
print("Is deployed:", status.is_deployed)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow
**Returns:** `WorkflowStatus`
##### validate_workflow()
Validate that a workflow is ready for execution.
```python
is_ready = client.validate_workflow("workflow-id")
if is_ready:
# Workflow is deployed and ready
pass
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow
**Returns:** `bool`
##### execute_workflow_sync()
<Callout type="info">
Currently, this method is identical to `execute_workflow()` since all executions are synchronous. This method is provided for future compatibility when asynchronous execution is added.
</Callout>
Execute a workflow (currently synchronous, same as `execute_workflow()`).
```python
result = client.execute_workflow_sync(
"workflow-id",
input_data={"data": "some input"},
timeout=60.0
)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow to execute
- `input_data` (dict, optional): Input data to pass to the workflow
- `timeout` (float): Timeout for the initial request in seconds
**Returns:** `WorkflowExecutionResult`
##### set_api_key()
Update the API key.
```python
client.set_api_key("new-api-key")
```
##### set_base_url()
Update the base URL.
```python
client.set_base_url("https://my-custom-domain.com")
```
##### close()
Close the underlying HTTP session.
```python
client.close()
```
## Data Classes
### WorkflowExecutionResult
```python
@dataclass
class WorkflowExecutionResult:
success: bool
output: Optional[Any] = None
error: Optional[str] = None
logs: Optional[List[Any]] = None
metadata: Optional[Dict[str, Any]] = None
trace_spans: Optional[List[Any]] = None
total_duration: Optional[float] = None
```
### WorkflowStatus
```python
@dataclass
class WorkflowStatus:
is_deployed: bool
deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False
```
### SimStudioError
```python
class SimStudioError(Exception):
def __init__(self, message: str, code: Optional[str] = None, status: Optional[int] = None):
super().__init__(message)
self.code = code
self.status = status
```
## Examples
### Basic Workflow Execution
<Steps>
<Step title="Initialize the client">
Set up the SimStudioClient with your API key.
</Step>
<Step title="Validate the workflow">
Check if the workflow is deployed and ready for execution.
</Step>
<Step title="Execute the workflow">
Run the workflow with your input data.
</Step>
<Step title="Handle the result">
Process the execution result and handle any errors.
</Step>
</Steps>
```python
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
def run_workflow():
try:
# Check if workflow is ready
is_ready = client.validate_workflow("my-workflow-id")
if not is_ready:
raise Exception("Workflow is not deployed or ready")
# Execute the workflow
result = client.execute_workflow(
"my-workflow-id",
input_data={
"message": "Process this data",
"user_id": "12345"
}
)
if result.success:
print("Output:", result.output)
print("Duration:", result.metadata.get("duration") if result.metadata else None)
else:
print("Workflow failed:", result.error)
except Exception as error:
print("Error:", error)
run_workflow()
```
### Error Handling
Handle different types of errors that may occur during workflow execution:
```python
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
def execute_with_error_handling():
try:
result = client.execute_workflow("workflow-id")
return result
except SimStudioError as error:
if error.code == "UNAUTHORIZED":
print("Invalid API key")
elif error.code == "TIMEOUT":
print("Workflow execution timed out")
elif error.code == "USAGE_LIMIT_EXCEEDED":
print("Usage limit exceeded")
elif error.code == "INVALID_JSON":
print("Invalid JSON in request body")
else:
print(f"Workflow error: {error}")
raise
except Exception as error:
print(f"Unexpected error: {error}")
raise
```
### Context Manager Usage
Use the client as a context manager to automatically handle resource cleanup:
```python
from simstudio import SimStudioClient
import os
# Using context manager to automatically close the session
with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
result = client.execute_workflow("workflow-id")
print("Result:", result)
# Session is automatically closed here
```
### Batch Workflow Execution
Execute multiple workflows efficiently:
```python
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""
results = []
for workflow_id, input_data in workflow_data_pairs:
try:
# Validate workflow before execution
if not client.validate_workflow(workflow_id):
print(f"Skipping {workflow_id}: not deployed")
continue
result = client.execute_workflow(workflow_id, input_data)
results.append({
"workflow_id": workflow_id,
"success": result.success,
"output": result.output,
"error": result.error
})
except Exception as error:
results.append({
"workflow_id": workflow_id,
"success": False,
"error": str(error)
})
return results
# Example usage
workflows = [
("workflow-1", {"type": "analysis", "data": "sample1"}),
("workflow-2", {"type": "processing", "data": "sample2"}),
]
results = execute_workflows_batch(workflows)
for result in results:
print(f"Workflow {result['workflow_id']}: {'Success' if result['success'] else 'Failed'}")
```
### Environment Configuration
Configure the client using environment variables:
<Tabs items={['Development', 'Production']}>
<Tab value="Development">
```python
import os
from simstudio import SimStudioClient
# Development configuration
client = SimStudioClient(
api_key=os.getenv("SIMSTUDIO_API_KEY"),
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://simstudio.ai")
)
```
</Tab>
<Tab value="Production">
```python
import os
from simstudio import SimStudioClient
# Production configuration with error handling
api_key = os.getenv("SIMSTUDIO_API_KEY")
if not api_key:
raise ValueError("SIMSTUDIO_API_KEY environment variable is required")
client = SimStudioClient(
api_key=api_key,
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://simstudio.ai")
)
```
</Tab>
</Tabs>
## Getting Your API Key
<Steps>
<Step title="Log in to Sim Studio">
Navigate to [Sim Studio](https://simstudio.ai) and log in to your account.
</Step>
<Step title="Open your workflow">
Navigate to the workflow you want to execute programmatically.
</Step>
<Step title="Deploy your workflow">
Click on "Deploy" to deploy your workflow if it hasn't been deployed yet.
</Step>
<Step title="Create or select an API key">
During the deployment process, select or create an API key.
</Step>
<Step title="Copy the API key">
Copy the API key to use in your Python application.
</Step>
</Steps>
<Callout type="warning">
Keep your API key secure and never commit it to version control. Use environment variables or secure configuration management.
</Callout>
## Requirements
- Python 3.8+
- requests >= 2.25.0
## License
Apache-2.0

View File

@@ -1,598 +0,0 @@
---
title: TypeScript/JavaScript SDK
description: The official TypeScript/JavaScript SDK for Sim Studio
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
The official TypeScript/JavaScript SDK for Sim Studio allows you to execute workflows programmatically from your Node.js applications, web applications, and other JavaScript environments.
<Callout type="info">
The TypeScript SDK provides full type safety and supports both Node.js and browser environments. All workflow executions are currently synchronous.
</Callout>
## Installation
Install the SDK using your preferred package manager:
<Tabs items={['npm', 'yarn', 'bun']}>
<Tab value="npm">
```bash
npm install simstudio-ts-sdk
```
</Tab>
<Tab value="yarn">
```bash
yarn add simstudio-ts-sdk
```
</Tab>
<Tab value="bun">
```bash
bun add simstudio-ts-sdk
```
</Tab>
</Tabs>
## Quick Start
Here's a simple example to get you started:
```typescript
import { SimStudioClient } from 'simstudio-ts-sdk';
// Initialize the client
const client = new SimStudioClient({
apiKey: 'your-api-key-here',
baseUrl: 'https://simstudio.ai' // optional, defaults to https://simstudio.ai
});
// Execute a workflow
try {
const result = await client.executeWorkflow('workflow-id');
console.log('Workflow executed successfully:', result);
} catch (error) {
console.error('Workflow execution failed:', error);
}
```
## API Reference
### SimStudioClient
#### Constructor
```typescript
new SimStudioClient(config: SimStudioConfig)
```
**Configuration:**
- `config.apiKey` (string): Your Sim Studio API key
- `config.baseUrl` (string, optional): Base URL for the Sim Studio API (defaults to `https://simstudio.ai`)
#### Methods
##### executeWorkflow()
Execute a workflow with optional input data.
```typescript
const result = await client.executeWorkflow('workflow-id', {
input: { message: 'Hello, world!' },
timeout: 30000 // 30 seconds
});
```
**Parameters:**
- `workflowId` (string): The ID of the workflow to execute
- `options` (ExecutionOptions, optional):
- `input` (any): Input data to pass to the workflow
- `timeout` (number): Timeout in milliseconds (default: 30000)
**Returns:** `Promise<WorkflowExecutionResult>`
##### getWorkflowStatus()
Get the status of a workflow (deployment status, etc.).
```typescript
const status = await client.getWorkflowStatus('workflow-id');
console.log('Is deployed:', status.isDeployed);
```
**Parameters:**
- `workflowId` (string): The ID of the workflow
**Returns:** `Promise<WorkflowStatus>`
##### validateWorkflow()
Validate that a workflow is ready for execution.
```typescript
const isReady = await client.validateWorkflow('workflow-id');
if (isReady) {
// Workflow is deployed and ready
}
```
**Parameters:**
- `workflowId` (string): The ID of the workflow
**Returns:** `Promise<boolean>`
##### executeWorkflowSync()
<Callout type="info">
Currently, this method is identical to `executeWorkflow()` since all executions are synchronous. This method is provided for future compatibility when asynchronous execution is added.
</Callout>
Execute a workflow (currently synchronous, same as `executeWorkflow()`).
```typescript
const result = await client.executeWorkflowSync('workflow-id', {
input: { data: 'some input' },
timeout: 60000
});
```
**Parameters:**
- `workflowId` (string): The ID of the workflow to execute
- `options` (ExecutionOptions, optional):
- `input` (any): Input data to pass to the workflow
- `timeout` (number): Timeout for the initial request in milliseconds
**Returns:** `Promise<WorkflowExecutionResult>`
##### setApiKey()
Update the API key.
```typescript
client.setApiKey('new-api-key');
```
##### setBaseUrl()
Update the base URL.
```typescript
client.setBaseUrl('https://my-custom-domain.com');
```
## Types
### WorkflowExecutionResult
```typescript
interface WorkflowExecutionResult {
success: boolean;
output?: any;
error?: string;
logs?: any[];
metadata?: {
duration?: number;
executionId?: string;
[key: string]: any;
};
traceSpans?: any[];
totalDuration?: number;
}
```
### WorkflowStatus
```typescript
interface WorkflowStatus {
isDeployed: boolean;
deployedAt?: string;
isPublished: boolean;
needsRedeployment: boolean;
}
```
### SimStudioError
```typescript
class SimStudioError extends Error {
code?: string;
status?: number;
}
```
## Examples
### Basic Workflow Execution
<Steps>
<Step title="Initialize the client">
Set up the SimStudioClient with your API key.
</Step>
<Step title="Validate the workflow">
Check if the workflow is deployed and ready for execution.
</Step>
<Step title="Execute the workflow">
Run the workflow with your input data.
</Step>
<Step title="Handle the result">
Process the execution result and handle any errors.
</Step>
</Steps>
```typescript
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
});
async function runWorkflow() {
try {
// Check if workflow is ready
const isReady = await client.validateWorkflow('my-workflow-id');
if (!isReady) {
throw new Error('Workflow is not deployed or ready');
}
// Execute the workflow
const result = await client.executeWorkflow('my-workflow-id', {
input: {
message: 'Process this data',
userId: '12345'
}
});
if (result.success) {
console.log('Output:', result.output);
console.log('Duration:', result.metadata?.duration);
} else {
console.error('Workflow failed:', result.error);
}
} catch (error) {
console.error('Error:', error);
}
}
runWorkflow();
```
### Error Handling
Handle different types of errors that may occur during workflow execution:
```typescript
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
});
async function executeWithErrorHandling() {
try {
const result = await client.executeWorkflow('workflow-id');
return result;
} catch (error) {
if (error instanceof SimStudioError) {
switch (error.code) {
case 'UNAUTHORIZED':
console.error('Invalid API key');
break;
case 'TIMEOUT':
console.error('Workflow execution timed out');
break;
case 'USAGE_LIMIT_EXCEEDED':
console.error('Usage limit exceeded');
break;
case 'INVALID_JSON':
console.error('Invalid JSON in request body');
break;
default:
console.error('Workflow error:', error.message);
}
} else {
console.error('Unexpected error:', error);
}
throw error;
}
}
```
### Environment Configuration
Configure the client using environment variables:
<Tabs items={['Development', 'Production']}>
<Tab value="Development">
```typescript
import { SimStudioClient } from 'simstudio-ts-sdk';
// Development configuration
const apiKey = process.env.SIMSTUDIO_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL // optional
});
```
</Tab>
<Tab value="Production">
```typescript
import { SimStudioClient } from 'simstudio-ts-sdk';
// Production configuration with validation
const apiKey = process.env.SIMSTUDIO_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL || 'https://simstudio.ai'
});
```
</Tab>
</Tabs>
### Node.js Express Integration
Integrate with an Express.js server:
```typescript
import express from 'express';
import { SimStudioClient } from 'simstudio-ts-sdk';
const app = express();
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
});
app.use(express.json());
app.post('/execute-workflow', async (req, res) => {
try {
const { workflowId, input } = req.body;
const result = await client.executeWorkflow(workflowId, {
input,
timeout: 60000
});
res.json({
success: true,
data: result
});
} catch (error) {
console.error('Workflow execution error:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
```
### Next.js API Route
Use with Next.js API routes:
```typescript
// pages/api/workflow.ts or app/api/workflow/route.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { workflowId, input } = req.body;
const result = await client.executeWorkflow(workflowId, {
input,
timeout: 30000
});
res.status(200).json(result);
} catch (error) {
console.error('Error executing workflow:', error);
res.status(500).json({
error: 'Failed to execute workflow'
});
}
}
```
### Browser Usage
Use in the browser (with proper CORS configuration):
```typescript
import { SimStudioClient } from 'simstudio-ts-sdk';
// Note: In production, use a proxy server to avoid exposing API keys
const client = new SimStudioClient({
apiKey: 'your-public-api-key', // Use with caution in browser
baseUrl: 'https://simstudio.ai'
});
async function executeClientSideWorkflow() {
try {
const result = await client.executeWorkflow('workflow-id', {
input: {
userInput: 'Hello from browser'
}
});
console.log('Workflow result:', result);
// Update UI with result
document.getElementById('result')!.textContent =
JSON.stringify(result.output, null, 2);
} catch (error) {
console.error('Error:', error);
}
}
// Attach to button click
document.getElementById('executeBtn')?.addEventListener('click', executeClientSideWorkflow);
```
<Callout type="warning">
When using the SDK in the browser, be careful not to expose sensitive API keys. Consider using a backend proxy or public API keys with limited permissions.
</Callout>
### React Hook Example
Create a custom React hook for workflow execution:
```typescript
import { useState, useCallback } from 'react';
import { SimStudioClient, WorkflowExecutionResult } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.NEXT_PUBLIC_SIMSTUDIO_API_KEY!
});
interface UseWorkflowResult {
result: WorkflowExecutionResult | null;
loading: boolean;
error: Error | null;
executeWorkflow: (workflowId: string, input?: any) => Promise<void>;
}
export function useWorkflow(): UseWorkflowResult {
const [result, setResult] = useState<WorkflowExecutionResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const executeWorkflow = useCallback(async (workflowId: string, input?: any) => {
setLoading(true);
setError(null);
setResult(null);
try {
const workflowResult = await client.executeWorkflow(workflowId, {
input,
timeout: 30000
});
setResult(workflowResult);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, []);
return {
result,
loading,
error,
executeWorkflow
};
}
// Usage in component
function WorkflowComponent() {
const { result, loading, error, executeWorkflow } = useWorkflow();
const handleExecute = () => {
executeWorkflow('my-workflow-id', {
message: 'Hello from React!'
});
};
return (
<div>
<button onClick={handleExecute} disabled={loading}>
{loading ? 'Executing...' : 'Execute Workflow'}
</button>
{error && <div>Error: {error.message}</div>}
{result && (
<div>
<h3>Result:</h3>
<pre>{JSON.stringify(result, null, 2)}</pre>
</div>
)}
</div>
);
}
```
## Getting Your API Key
<Steps>
<Step title="Log in to Sim Studio">
Navigate to [Sim Studio](https://simstudio.ai) and log in to your account.
</Step>
<Step title="Open your workflow">
Navigate to the workflow you want to execute programmatically.
</Step>
<Step title="Deploy your workflow">
Click on "Deploy" to deploy your workflow if it hasn't been deployed yet.
</Step>
<Step title="Create or select an API key">
During the deployment process, select or create an API key.
</Step>
<Step title="Copy the API key">
Copy the API key to use in your TypeScript/JavaScript application.
</Step>
</Steps>
<Callout type="warning">
Keep your API key secure and never commit it to version control. Use environment variables or secure configuration management.
</Callout>
## Requirements
- Node.js 16+
- TypeScript 5.0+ (for TypeScript projects)
## TypeScript Support
The SDK is written in TypeScript and provides full type safety:
```typescript
import {
SimStudioClient,
WorkflowExecutionResult,
WorkflowStatus,
SimStudioError
} from 'simstudio-ts-sdk';
// Type-safe client initialization
const client: SimStudioClient = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
});
// Type-safe workflow execution
const result: WorkflowExecutionResult = await client.executeWorkflow('workflow-id', {
input: {
message: 'Hello, TypeScript!'
}
});
// Type-safe status checking
const status: WorkflowStatus = await client.getWorkflowStatus('workflow-id');
```
## License
Apache-2.0

View File

@@ -1,161 +0,0 @@
---
title: Discord
description: Interact with Discord
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="discord"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
viewBox='0 -28.5 256 256'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
preserveAspectRatio='xMidYMid'
>
<g>
<path
d='M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z'
fill='#5865F2'
fillRule='nonzero'
/>
</g>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Discord](https://discord.com) is a powerful communication platform that allows you to connect with friends, communities, and teams. It offers a range of features for team collaboration, including text channels, voice channels, and video calls.
With a Discord account or bot, you can:
- **Send messages**: Send messages to a specific channel
- **Get messages**: Get messages from a specific channel
- **Get server**: Get information about a specific server
- **Get user**: Get information about a specific user
In Sim Studio, the Discord integration enables your agents to access and leverage your organization's Discord servers. Agents can retrieve information from Discord channels, search for specific users, get server information, and send messages. This allows your workflows to integrate with your Discord communities, automate notifications, and create interactive experiences.
> **Important:** To read message content, your Discord bot needs the "Message Content Intent" enabled in the Discord Developer Portal. Without this permission, you'll still receive message metadata but the content field will appear empty.
Discord components in Sim Studio use efficient lazy loading, only fetching data when needed to minimize API calls and prevent rate limiting. Token refreshing happens automatically in the background to maintain your connection.
### Setting Up Your Discord Bot
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application and navigate to the "Bot" tab
3. Create a bot and copy your bot token
4. Under "Privileged Gateway Intents", enable the **Message Content Intent** to read message content
5. Invite your bot to your servers with appropriate permissions
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to Discord to send messages, manage channels, and interact with servers. Automate notifications, community management, and integrate Discord into your workflows.
## Tools
### `discord_send_message`
Send a message to a Discord channel
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `botToken` | string | Yes | The bot token for authentication |
| `channelId` | string | Yes | The Discord channel ID to send the message to |
| `content` | string | No | The text content of the message |
| `serverId` | string | Yes | The Discord server ID \(guild ID\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `message` | string |
### `discord_get_messages`
Retrieve messages from a Discord channel
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `botToken` | string | Yes | The bot token for authentication |
| `channelId` | string | Yes | The Discord channel ID to retrieve messages from |
| `limit` | number | No | Maximum number of messages to retrieve \(default: 10, max: 100\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `message` | string |
### `discord_get_server`
Retrieve information about a Discord server (guild)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `botToken` | string | Yes | The bot token for authentication |
| `serverId` | string | Yes | The Discord server ID \(guild ID\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `message` | string |
### `discord_get_user`
Retrieve information about a Discord user
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `botToken` | string | Yes | Discord bot token for authentication |
| `userId` | string | Yes | The Discord user ID |
#### Output
| Parameter | Type |
| --------- | ---- |
| `message` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `message` | string | message of the response |
| ↳ `data` | any | data of the response |
## Notes
- Category: `tools`
- Type: `discord`

View File

@@ -1,239 +0,0 @@
---
title: Google Calendar
description: Manage Google Calendar events
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_calendar"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
x='0px'
y='0px'
viewBox='0 0 200 200'
enableBackground='new 0 0 200 200'
xmlSpace='preserve'
>
<g>
<g transform='translate(3.75 3.75)'>
<path
fill='#FFFFFF'
d='M148.882,43.618l-47.368-5.263l-57.895,5.263L38.355,96.25l5.263,52.632l52.632,6.579l52.632-6.579
l5.263-53.947L148.882,43.618z'
/>
<path
fill='#1A73E8'
d='M65.211,125.276c-3.934-2.658-6.658-6.539-8.145-11.671l9.132-3.763c0.829,3.158,2.276,5.605,4.342,7.342
c2.053,1.737,4.553,2.592,7.474,2.592c2.987,0,5.553-0.908,7.697-2.724s3.224-4.132,3.224-6.934c0-2.868-1.132-5.211-3.395-7.026
s-5.105-2.724-8.5-2.724h-5.276v-9.039H76.5c2.921,0,5.382-0.789,7.382-2.368c2-1.579,3-3.737,3-6.487
c0-2.447-0.895-4.395-2.684-5.855s-4.053-2.197-6.803-2.197c-2.684,0-4.816,0.711-6.395,2.145s-2.724,3.197-3.447,5.276
l-9.039-3.763c1.197-3.395,3.395-6.395,6.618-8.987c3.224-2.592,7.342-3.895,12.342-3.895c3.697,0,7.026,0.711,9.974,2.145
c2.947,1.434,5.263,3.421,6.934,5.947c1.671,2.539,2.5,5.382,2.5,8.539c0,3.224-0.776,5.947-2.329,8.184
c-1.553,2.237-3.461,3.947-5.724,5.145v0.539c2.987,1.25,5.421,3.158,7.342,5.724c1.908,2.566,2.868,5.632,2.868,9.211
s-0.908,6.776-2.724,9.579c-1.816,2.803-4.329,5.013-7.513,6.618c-3.197,1.605-6.789,2.421-10.776,2.421
C73.408,129.263,69.145,127.934,65.211,125.276z'
/>
<path
fill='#1A73E8'
d='M121.25,79.961l-9.974,7.25l-5.013-7.605l17.987-12.974h6.895v61.197h-9.895L121.25,79.961z'
/>
<path
fill='#EA4335'
d='M148.882,196.25l47.368-47.368l-23.684-10.526l-23.684,10.526l-10.526,23.684L148.882,196.25z'
/>
<path
fill='#34A853'
d='M33.092,172.566l10.526,23.684h105.263v-47.368H43.618L33.092,172.566z'
/>
<path
fill='#4285F4'
d='M12.039-3.75C3.316-3.75-3.75,3.316-3.75,12.039v136.842l23.684,10.526l23.684-10.526V43.618h105.263
l10.526-23.684L148.882-3.75H12.039z'
/>
<path
fill='#188038'
d='M-3.75,148.882v31.579c0,8.724,7.066,15.789,15.789,15.789h31.579v-47.368H-3.75z'
/>
<path
fill='#FBBC04'
d='M148.882,43.618v105.263h47.368V43.618l-23.684-10.526L148.882,43.618z'
/>
<path
fill='#1967D2'
d='M196.25,43.618V12.039c0-8.724-7.066-15.789-15.789-15.789h-31.579v47.368H196.25z'
/>
</g>
</g>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Calendar](https://calendar.google.com) is Google's powerful calendar and scheduling service that provides a comprehensive platform for managing events, meetings, and appointments. With seamless integration across Google's ecosystem and widespread adoption, Google Calendar offers robust features for both personal and professional scheduling needs.
With Google Calendar, you can:
- **Create and manage events**: Schedule meetings, appointments, and reminders with detailed information
- **Send calendar invites**: Automatically notify and coordinate with attendees through email invitations
- **Natural language event creation**: Quickly add events using conversational language like "Meeting with John tomorrow at 3pm"
- **View and search events**: Easily find and access your scheduled events across multiple calendars
- **Manage multiple calendars**: Organize different types of events across various calendars
In Sim Studio, the Google Calendar integration enables your agents to programmatically create, read, and manage calendar events. This allows for powerful automation scenarios such as scheduling meetings, sending calendar invites, checking availability, and managing event details. Your agents can create events with natural language input, send automated calendar invitations to attendees, retrieve event information, and list upcoming events. This integration bridges the gap between your AI workflows and calendar management, enabling seamless scheduling automation and coordination with one of the world's most widely used calendar platforms.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Google Calendar functionality to create, read, update, and list calendar events within your workflow. Automate scheduling, check availability, and manage events using OAuth authentication. Email invitations are sent asynchronously and delivery depends on recipients
## Tools
### `google_calendar_create`
Create a new event in Google Calendar
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | Access token for Google Calendar API |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `summary` | string | Yes | Event title/summary |
| `description` | string | No | Event description |
| `location` | string | No | Event location |
| `startDateTime` | string | Yes | Start date and time \(RFC3339 format, e.g., 2025-06-03T10:00:00-08:00\) |
| `endDateTime` | string | Yes | End date and time \(RFC3339 format, e.g., 2025-06-03T11:00:00-08:00\) |
| `timeZone` | string | No | Time zone \(e.g., America/Los_Angeles\) |
| `attendees` | array | No | Array of attendee email addresses |
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
#### Output
| Parameter | Type |
| --------- | ---- |
| `content` | string |
### `google_calendar_list`
List events from Google Calendar
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | Access token for Google Calendar API |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `timeMin` | string | No | Lower bound for events \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\) |
| `timeMax` | string | No | Upper bound for events \(RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z\) |
| `orderBy` | string | No | Order of events returned \(startTime or updated\) |
| `showDeleted` | boolean | No | Include deleted events |
#### Output
| Parameter | Type |
| --------- | ---- |
| `content` | string |
### `google_calendar_get`
Get a specific event from Google Calendar
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | Access token for Google Calendar API |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Event ID to retrieve |
#### Output
| Parameter | Type |
| --------- | ---- |
| `content` | string |
### `google_calendar_quick_add`
Create events from natural language text
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | Access token for Google Calendar API |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `text` | string | Yes | Natural language text describing the event \(e.g., |
| `attendees` | array | No | Array of attendee email addresses \(comma-separated string also accepted\) |
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
#### Output
| Parameter | Type |
| --------- | ---- |
| `content` | string |
### `google_calendar_invite`
Invite attendees to an existing Google Calendar event
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | Access token for Google Calendar API |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Event ID to invite attendees to |
| `attendees` | array | Yes | Array of attendee email addresses to invite |
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
| `replaceExisting` | boolean | No | Whether to replace existing attendees or add to them \(defaults to false\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `metadata` | string |
| `htmlLink` | string |
| `status` | string |
| `summary` | string |
| `description` | string |
| `location` | string |
| `start` | string |
| `end` | string |
| `attendees` | string |
| `creator` | string |
| `organizer` | string |
| `content` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `content` | string | content of the response |
| ↳ `metadata` | json | metadata of the response |
## Notes
- Category: `tools`
- Type: `google_calendar`

File diff suppressed because one or more lines are too long

View File

@@ -1,147 +0,0 @@
---
title: Knowledge
description: Use vector search
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="knowledge"
color="#00B0B0"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14' />
<path d='m7.5 4.27 9 5.15' />
<polyline points='3.29 7 12 12 20.71 7' />
<line x1='12' x2='12' y1='22' y2='12' />
<circle cx='18.5' cy='15.5' r='2.5' />
<path d='M20.27 17.27 22 19' />
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
Sim Studio's Knowledge Base is a powerful native feature that enables you to create, manage, and query custom knowledge bases directly within the platform. Using advanced AI embeddings and vector search technology, the Knowledge Base block allows you to build intelligent search capabilities into your workflows, making it easy to find and utilize relevant information across your organization.
The Knowledge Base system provides a comprehensive solution for managing organizational knowledge through its flexible and scalable architecture. With its built-in vector search capabilities, teams can perform semantic searches that understand meaning and context, going beyond traditional keyword matching.
Key features of the Knowledge Base include:
- Semantic Search: Advanced AI-powered search that understands meaning and context, not just keywords
- Vector Embeddings: Automatic conversion of text into high-dimensional vectors for intelligent similarity matching
- Custom Knowledge Bases: Create and manage multiple knowledge bases for different purposes or departments
- Flexible Content Types: Support for various document formats and content types
- Real-time Updates: Immediate indexing of new content for instant searchability
In Sim Studio, the Knowledge Base block enables your agents to perform intelligent semantic searches across your custom knowledge bases. This creates opportunities for automated information retrieval, content recommendations, and knowledge discovery as part of your AI workflows. The integration allows agents to search and retrieve relevant information programmatically, facilitating automated knowledge management tasks and ensuring that important information is easily accessible. By leveraging the Knowledge Base block, you can build intelligent agents that enhance information discovery while automating routine knowledge management tasks, improving team efficiency and ensuring consistent access to organizational knowledge.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Perform semantic vector search across knowledge bases, upload individual chunks to existing documents, or create new documents from text content. Uses advanced AI embeddings to understand meaning and context for search operations.
## Tools
### `knowledge_search`
Search for similar content in one or more knowledge bases using vector similarity
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseIds` | string | Yes | ID of the knowledge base to search in, or comma-separated IDs for multiple knowledge bases |
| `query` | string | Yes | Search query text |
| `topK` | number | No | Number of most similar results to return \(1-100\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `results` | string |
| `query` | string |
| `totalResults` | string |
### `knowledge_upload_chunk`
Upload a new chunk to a document in a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
| `documentId` | string | Yes | ID of the document to upload the chunk to |
| `content` | string | Yes | Content of the chunk to upload |
#### Output
| Parameter | Type |
| --------- | ---- |
| `data` | string |
| `chunkIndex` | string |
| `content` | string |
| `contentLength` | string |
| `tokenCount` | string |
| `enabled` | string |
| `createdAt` | string |
| `updatedAt` | string |
### `knowledge_create_document`
Create a new document in a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
| `name` | string | Yes | Name of the document |
| `content` | string | Yes | Content of the document |
#### Output
| Parameter | Type |
| --------- | ---- |
| `data` | string |
| `name` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `results` | json | results of the response |
| ↳ `query` | string | query of the response |
| ↳ `totalResults` | number | totalResults of the response |
## Notes
- Category: `blocks`
- Type: `knowledge`

View File

@@ -1,116 +0,0 @@
---
title: Linear
description: Read and create issues in Linear
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="linear"
color="#5E6AD2"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
fill='currentColor'
viewBox='0 0 100 100'
>
<path
fill='currentColor'
d='M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Linear](https://linear.app) is a leading project management and issue tracking platform that helps teams plan, track, and manage their work effectively. As a modern project management tool, Linear has become increasingly popular among software development teams and project management professionals for its streamlined interface and powerful features.
Linear provides a comprehensive set of tools for managing complex projects through its flexible and customizable workflow system. With its robust API and integration capabilities, Linear enables teams to streamline their development processes and maintain clear visibility of project progress.
Key features of Linear include:
- Agile Project Management: Support for Scrum and Kanban methodologies with customizable boards and workflows
- Issue Tracking: Sophisticated tracking system for bugs, stories, epics, and tasks with detailed reporting
- Workflow Automation: Powerful automation rules to streamline repetitive tasks and processes
- Advanced Search: Complex filtering and reporting capabilities for efficient issue management
In Sim Studio, the Linear integration allows your agents to seamlessly interact with your project management workflow. This creates opportunities for automated issue creation, updates, and tracking as part of your AI workflows. The integration enables agents to read existing issues and create new ones programmatically, facilitating automated project management tasks and ensuring that important information is properly tracked and documented. By connecting Sim Studio with Linear, you can build intelligent agents that maintain project visibility while automating routine project management tasks, enhancing team productivity and ensuring consistent project tracking.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate with Linear to fetch, filter, and create issues directly from your workflow.
## Tools
### `linear_read_issues`
Fetch and filter issues from Linear
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `teamId` | string | Yes | Linear team ID |
| `projectId` | string | Yes | Linear project ID |
#### Output
| Parameter | Type |
| --------- | ---- |
| `issues` | string |
### `linear_create_issue`
Create a new issue in Linear
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `teamId` | string | Yes | Linear team ID |
| `projectId` | string | Yes | Linear project ID |
| `title` | string | Yes | Issue title |
| `description` | string | No | Issue description |
#### Output
| Parameter | Type |
| --------- | ---- |
| `issue` | string |
| `title` | string |
| `description` | string |
| `state` | string |
| `teamId` | string |
| `projectId` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `issues` | json | issues of the response |
| ↳ `issue` | json | issue of the response |
## Notes
- Category: `tools`
- Type: `linear`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,135 +0,0 @@
---
title: Memory
description: Add memory store
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="memory"
color="#F64F9E"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z' />
<path d='M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z' />
<path d='M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4' />
<path d='M17.599 6.5a3 3 0 0 0 .399-1.375' />
<path d='M6.003 5.125A3 3 0 0 0 6.401 6.5' />
<path d='M3.477 10.896a4 4 0 0 1 .585-.396' />
<path d='M19.938 10.5a4 4 0 0 1 .585.396' />
<path d='M6 18a4 4 0 0 1-1.967-.516' />
<path d='M19.967 17.484A4 4 0 0 1 18 18' />
</svg>`}
/>
## Usage Instructions
Create persistent storage for data that needs to be accessed across multiple workflow steps. Store and retrieve information throughout your workflow execution to maintain context and state.
## Tools
### `memory_add`
Add a new memory to the database or append to existing memory with the same ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | string | Yes | Identifier for the memory. If a memory with this ID already exists, the new data will be appended to it. |
| `role` | string | Yes | Role for agent memory \(user, assistant, or system\) |
| `content` | string | Yes | Content for agent memory |
#### Output
| Parameter | Type |
| --------- | ---- |
| `memories` | string |
### `memory_get`
Retrieve a specific memory by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | string | Yes | Identifier for the memory to retrieve |
#### Output
| Parameter | Type |
| --------- | ---- |
| `memories` | string |
| `message` | string |
### `memory_get_all`
Retrieve all memories from the database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type |
| --------- | ---- |
| `message` | string |
| `memories` | string |
### `memory_delete`
Delete a specific memory by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | string | Yes | Identifier for the memory to delete |
#### Output
| Parameter | Type |
| --------- | ---- |
| `message` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `memories` | any | memories of the response |
| ↳ `id` | string | id of the response |
## Notes
- Category: `blocks`
- Type: `memory`

View File

@@ -1,197 +0,0 @@
---
title: Microsoft Excel
description: Read, write, and update data
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="microsoft_excel"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
version='1.1'
id='Livello_1'
x='0px'
y='0px'
viewBox='0 0 2289.75 2130'
enableBackground='new 0 0 2289.75 2130'
>
<path
fill='#185C37'
d='M1437.75,1011.75L532.5,852v1180.393c0,53.907,43.7,97.607,97.607,97.607l0,0h1562.036 c53.907,0,97.607-43.7,97.607-97.607l0,0V1597.5L1437.75,1011.75z'
/>
<path
fill='#21A366'
d='M1437.75,0H630.107C576.2,0,532.5,43.7,532.5,97.607c0,0,0,0,0,0V532.5l905.25,532.5L1917,1224.75 L2289.75,1065V532.5L1437.75,0z'
/>
<path fill='#107C41' d='M532.5,532.5h905.25V1065H532.5V532.5z' />
<path
opacity='0.1'
enableBackground='new'
d='M1180.393,426H532.5v1331.25h647.893c53.834-0.175,97.432-43.773,97.607-97.607 V523.607C1277.825,469.773,1234.227,426.175,1180.393,426z'
/>
<path
opacity='0.2'
enableBackground='new'
d='M1127.143,479.25H532.5V1810.5h594.643 c53.834-0.175,97.432-43.773,97.607-97.607V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
/>
<path
opacity='0.2'
enableBackground='new'
d='M1127.143,479.25H532.5V1704h594.643c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
/>
<path
opacity='0.2'
enableBackground='new'
d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z'
/>
<linearGradient
id='SVGID_1_'
gradientUnits='userSpaceOnUse'
x1='203.5132'
y1='1729.0183'
x2='967.9868'
y2='404.9817'
gradientTransform='matrix(1 0 0 -1 0 2132)'
>
<stop offset='0' style={{ stopColor: '#18884F' }} />
<stop offset='0.5' style={{ stopColor: '#117E43' }} />
<stop offset='1' style={{ stopColor: '#0B6631' }} />
</linearGradient>
<path
fill='url(#SVGID_1_)'
d='M97.607,479.25h976.285c53.907,0,97.607,43.7,97.607,97.607v976.285 c0,53.907-43.7,97.607-97.607,97.607H97.607C43.7,1650.75,0,1607.05,0,1553.143V576.857C0,522.95,43.7,479.25,97.607,479.25z'
/>
<path
fill='#FFFFFF'
d='M302.3,1382.264l205.332-318.169L319.5,747.683h151.336l102.666,202.35 c9.479,19.223,15.975,33.494,19.49,42.919h1.331c6.745-15.336,13.845-30.228,21.3-44.677L725.371,747.79h138.929l-192.925,314.548 L869.2,1382.263H721.378L602.79,1160.158c-5.586-9.45-10.326-19.376-14.164-29.66h-1.757c-3.474,10.075-8.083,19.722-13.739,28.755 l-122.102,223.011H302.3z'
/>
<path
fill='#33C481'
d='M2192.143,0H1437.75v532.5h852V97.607C2289.75,43.7,2246.05,0,2192.143,0L2192.143,0z'
/>
<path fill='#107C41' d='M1437.75,1065h852v532.5h-852V1065z' />
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Microsoft Teams](https://www.microsoft.com/en-us/microsoft-365/excel) is a powerful spreadsheet application that enables data management, analysis, and visualization. Through the Microsoft Excel integration in Sim Studio, you can programmatically read, write, and manipulate spreadsheet data to support your workflow automation needs.
With Microsoft Excel integration, you can:
- **Read Spreadsheet Data**: Access data from specific ranges, sheets, and cells
- **Write and Update Data**: Add new data or modify existing spreadsheet content
- **Manage Tables**: Create and manipulate tabular data structures
- **Handle Multiple Sheets**: Work with multiple worksheets in a workbook
- **Process Data**: Import, export, and transform spreadsheet data
In Sim Studio, the Microsoft Excel integration provides seamless access to spreadsheet functionality through OAuth authentication. You can read data from specific ranges, write new information, update existing cells, and handle various data formats. The integration supports both reading and writing operations with flexible input and output options. This enables you to build workflows that can effectively manage spreadsheet data, whether you're extracting information for analysis, updating records automatically, or maintaining data consistency across your applications.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Microsoft Excel functionality to manage spreadsheet data. Read data from specific ranges, write new data, update existing cells, and manipulate table data using OAuth authentication. Supports various input and output formats for flexible data handling.
## Tools
### `microsoft_excel_read`
Read data from a Microsoft Excel spreadsheet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Excel API |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to read from |
| `range` | string | No | The range of cells to read from |
#### Output
| Parameter | Type |
| --------- | ---- |
| `data` | json |
### `microsoft_excel_write`
Write data to a Microsoft Excel spreadsheet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Excel API |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to write to |
| `range` | string | No | The range of cells to write to |
| `values` | array | Yes | The data to write to the spreadsheet |
| `valueInputOption` | string | No | The format of the data to write |
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |
#### Output
| Parameter | Type |
| --------- | ---- |
| `updatedRange` | string |
| `updatedRows` | string |
| `updatedColumns` | string |
| `updatedCells` | string |
| `metadata` | string |
| `spreadsheetId` | string |
| `spreadsheetUrl` | string |
### `microsoft_excel_table_add`
Add new rows to a Microsoft Excel table
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Excel API |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet containing the table |
| `tableName` | string | Yes | The name of the table to add rows to |
| `values` | array | Yes | The data to add to the table \(array of arrays or array of objects\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `data` | json |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `data` | json | data of the response |
| ↳ `metadata` | json | metadata of the response |
| ↳ `updatedRange` | string | updatedRange of the response |
| ↳ `updatedRows` | number | updatedRows of the response |
| ↳ `updatedColumns` | number | updatedColumns of the response |
| ↳ `updatedCells` | number | updatedCells of the response |
| ↳ `index` | number | index of the response |
| ↳ `values` | json | values of the response |
## Notes
- Category: `tools`
- Type: `microsoft_excel`

View File

@@ -1,217 +0,0 @@
---
title: Microsoft Teams
description: Read, write, and create messages
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="microsoft_teams"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2228.833 2073.333'>
<path
fill='#5059C9'
d='M1554.637,777.5h575.713c54.391,0,98.483,44.092,98.483,98.483c0,0,0,0,0,0v524.398 c0,199.901-162.051,361.952-361.952,361.952h0h-1.711c-199.901,0.028-361.975-162-362.004-361.901c0-0.017,0-0.034,0-0.052V828.971 C1503.167,800.544,1526.211,777.5,1554.637,777.5L1554.637,777.5z'
/>
<circle fill='#5059C9' cx='1943.75' cy='440.583' r='233.25' />
<circle fill='#7B83EB' cx='1218.083' cy='336.917' r='336.917' />
<path
fill='#7B83EB'
d='M1667.323,777.5H717.01c-53.743,1.33-96.257,45.931-95.01,99.676v598.105 c-7.505,322.519,247.657,590.16,570.167,598.053c322.51-7.893,577.671-275.534,570.167-598.053V877.176 C1763.579,823.431,1721.066,778.83,1667.323,777.5z'
/>
<path
opacity='.1'
d='M1244,777.5v838.145c-0.258,38.435-23.549,72.964-59.09,87.598 c-11.316,4.787-23.478,7.254-35.765,7.257H667.613c-6.738-17.105-12.958-34.21-18.142-51.833 c-18.144-59.477-27.402-121.307-27.472-183.49V877.02c-1.246-53.659,41.198-98.19,94.855-99.52H1244z'
/>
<path
opacity='.2'
d='M1192.167,777.5v889.978c-0.002,12.287-2.47,24.449-7.257,35.765 c-14.634,35.541-49.163,58.833-87.598,59.09H691.975c-8.812-17.105-17.105-34.21-24.362-51.833 c-7.257-17.623-12.958-34.21-18.142-51.833c-18.144-59.476-27.402-121.307-27.472-183.49V877.02 c-1.246-53.659,41.198-98.19,94.855-99.52H1192.167z'
/>
<path
opacity='.2'
d='M1192.167,777.5v786.312c-0.395,52.223-42.632,94.46-94.855,94.855h-447.84 c-18.144-59.476-27.402-121.307-27.472-183.49V877.02c-1.246-53.659,41.198-98.19,94.855-99.52H1192.167z'
/>
<path
opacity='.2'
d='M1140.333,777.5v786.312c-0.395,52.223-42.632,94.46-94.855,94.855H649.472 c-18.144-59.476-27.402-121.307-27.472-183.49V877.02c-1.246-53.659,41.198-98.19,94.855-99.52H1140.333z'
/>
<path
opacity='.1'
d='M1244,509.522v163.275c-8.812,0.518-17.105,1.037-25.917,1.037 c-8.812,0-17.105-0.518-25.917-1.037c-17.496-1.161-34.848-3.937-51.833-8.293c-104.963-24.857-191.679-98.469-233.25-198.003 c-7.153-16.715-12.706-34.071-16.587-51.833h258.648C1201.449,414.866,1243.801,457.217,1244,509.522z'
/>
<path
opacity='.2'
d='M1192.167,561.355v111.442c-17.496-1.161-34.848-3.937-51.833-8.293 c-104.963-24.857-191.679-98.469-233.25-198.003h190.228C1149.616,466.699,1191.968,509.051,1192.167,561.355z'
/>
<path
opacity='.2'
d='M1192.167,561.355v111.442c-17.496-1.161-34.848-3.937-51.833-8.293 c-104.963-24.857-191.679-98.469-233.25-198.003h190.228C1149.616,466.699,1191.968,509.051,1192.167,561.355z'
/>
<path
opacity='.2'
d='M1140.333,561.355v103.148c-104.963-24.857-191.679-98.469-233.25-198.003 h138.395C1097.783,466.699,1140.134,509.051,1140.333,561.355z'
/>
<linearGradient
id='a'
gradientUnits='userSpaceOnUse'
x1='198.099'
y1='1683.0726'
x2='942.2344'
y2='394.2607'
gradientTransform='matrix(1 0 0 -1 0 2075.3333)'
>
<stop offset='0' stopColor='#5a62c3' />
<stop offset='.5' stopColor='#4d55bd' />
<stop offset='1' stopColor='#3940ab' />
<stop offset='0' stopColor='#5a62c3' />
<stop offset='.5' stopColor='#4d55bd' />
<stop offset='1' stopColor='#3940ab' />
</linearGradient>
<path
fill='url(#a)'
d='M95.01,466.5h950.312c52.473,0,95.01,42.538,95.01,95.01v950.312c0,52.473-42.538,95.01-95.01,95.01 H95.01c-52.473,0-95.01-42.538-95.01-95.01V561.51C0,509.038,42.538,466.5,95.01,466.5z'
/>
<path
fill='#FFF'
d='M820.211,828.193H630.241v517.297H509.211V828.193H320.123V727.844h500.088V828.193z'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Microsoft Teams](https://teams.microsoft.com) is a robust communication and collaboration platform that enables users to engage in real-time messaging, meetings, and content sharing within teams and organizations. As part of Microsoft's productivity ecosystem, Microsoft Teams offers seamless chat functionality integrated with Office 365, allowing users to post messages, coordinate work, and stay connected across devices and workflows.
With Microsoft Teams, you can:
- **Send and receive messages**: Communicate instantly with individuals or groups in chat threads
- **Collaborate in real-time**: Share updates and information across teams within channels and chats
- **Organize conversations**: Maintain context with threaded discussions and persistent chat history
- **Share files and content**: Attach and view documents, images, and links directly in chat
- **Integrate with Microsoft 365**: Seamlessly connect with Outlook, SharePoint, OneDrive, and more
- **Access across devices**: Use Teams on desktop, web, and mobile with cloud-synced conversations
- **Secure communication**: Leverage enterprise-grade security and compliance features
In Sim Studio, the Microsoft Teams integration enables your agents to interact directly with chat messages programmatically. This allows for powerful automation scenarios such as sending updates, posting alerts, coordinating tasks, and responding to conversations in real time. Your agents can write new messages to chats or channels, update content based on workflow data, and engage with users where collaboration happens. By integrating Sim Studio with Microsoft Teams, you bridge the gap between intelligent workflows and team communication — empowering your agents to streamline collaboration, automate communication tasks, and keep your teams aligned.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Microsoft Teams functionality to manage messages. Read content from existing messages and write to messages using OAuth authentication. Supports text content manipulation for message creation and editing.
## Tools
### `microsoft_teams_read_chat`
Read content from a Microsoft Teams chat
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Teams API |
| `chatId` | string | Yes | The ID of the chat to read from |
#### Output
| Parameter | Type |
| --------- | ---- |
| `content` | string |
| `metadata` | string |
| `messageCount` | string |
| `messages` | string |
| `totalAttachments` | string |
| `attachmentTypes` | string |
### `microsoft_teams_write_chat`
Write or update content in a Microsoft Teams chat
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Teams API |
| `chatId` | string | Yes | The ID of the chat to write to |
| `content` | string | Yes | The content to write to the message |
#### Output
| Parameter | Type |
| --------- | ---- |
| `updatedContent` | string |
| `metadata` | string |
### `microsoft_teams_read_channel`
Read content from a Microsoft Teams channel
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Teams API |
| `teamId` | string | Yes | The ID of the team to read from |
| `channelId` | string | Yes | The ID of the channel to read from |
#### Output
| Parameter | Type |
| --------- | ---- |
| `content` | string |
| `metadata` | string |
| `channelId` | string |
| `messageCount` | string |
| `messages` | string |
| `totalAttachments` | string |
| `attachmentTypes` | string |
### `microsoft_teams_write_channel`
Write or send a message to a Microsoft Teams channel
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Teams API |
| `teamId` | string | Yes | The ID of the team to write to |
| `channelId` | string | Yes | The ID of the channel to write to |
| `content` | string | Yes | The content to write to the channel |
#### Output
| Parameter | Type |
| --------- | ---- |
| `updatedContent` | string |
| `metadata` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `content` | string | content of the response |
| ↳ `metadata` | json | metadata of the response |
| ↳ `updatedContent` | boolean | updatedContent of the response |
## Notes
- Category: `tools`
- Type: `microsoft_teams`

View File

@@ -1,133 +0,0 @@
---
title: Mistral Parser
description: Extract text from PDF documents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="mistral_parse"
color="#000000"
icon={true}
iconSvg={`<svg className="block-icon"
viewBox='1 0.5 24 22'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
>
<g clipPath='url(#clip0_1621_58)'>
<path d='M17.4541 0H21.8177V4.39481H17.4541V0Z' fill='black' />
<path d='M19.6367 0H24.0003V4.39481H19.6367V0Z' fill='#F7D046' />
<path
d='M0 0H4.36359V4.39481H0V0ZM0 4.39481H4.36359V8.78961H0V4.39481ZM0 8.78971H4.36359V13.1845H0V8.78971ZM0 13.1845H4.36359V17.5793H0V13.1845ZM0 17.5794H4.36359V21.9742H0V17.5794Z'
fill='black'
/>
<path d='M2.18164 0H6.54523V4.39481H2.18164V0Z' fill='#F7D046' />
<path
d='M19.6362 4.39478H23.9998V8.78958H19.6362V4.39478ZM2.18164 4.39478H6.54523V8.78958H2.18164V4.39478Z'
fill='#F2A73B'
/>
<path d='M13.0908 4.39478H17.4544V8.78958H13.0908V4.39478Z' fill='black' />
<path
d='M15.2732 4.39478H19.6368V8.78958H15.2732V4.39478ZM6.5459 4.39478H10.9095V8.78958H6.5459V4.39478Z'
fill='#F2A73B'
/>
<path
d='M10.9096 8.78979H15.2732V13.1846H10.9096V8.78979ZM15.2732 8.78979H19.6368V13.1846H15.2732V8.78979ZM6.5459 8.78979H10.9096V13.1846H6.5459V8.78979Z'
fill='#EE792F'
/>
<path d='M8.72754 13.1846H13.0911V17.5794H8.72754V13.1846Z' fill='black' />
<path d='M10.9092 13.1846H15.2728V17.5794H10.9092V13.1846Z' fill='#EB5829' />
<path
d='M19.6362 8.78979H23.9998V13.1846H19.6362V8.78979ZM2.18164 8.78979H6.54523V13.1846H2.18164V8.78979Z'
fill='#EE792F'
/>
<path d='M17.4541 13.1846H21.8177V17.5794H17.4541V13.1846Z' fill='black' />
<path d='M19.6367 13.1846H24.0003V17.5794H19.6367V13.1846Z' fill='#EB5829' />
<path d='M17.4541 17.5793H21.8177V21.9742H17.4541V17.5793Z' fill='black' />
<path d='M2.18164 13.1846H6.54523V17.5794H2.18164V13.1846Z' fill='#EB5829' />
<path
d='M19.6362 17.5793H23.9998V21.9742H19.6362V17.5793ZM2.18164 17.5793H6.54523V21.9742H2.18164V17.5793Z'
fill='#EA3326'
/>
</g>
<defs>
<clipPath id='clip0_1621_58'>
<rect fill='white' />
</clipPath>
</defs>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
The Mistral Parse tool provides a powerful way to extract and process content from PDF documents using [Mistral's OCR API](https://mistral.ai/). This tool leverages advanced optical character recognition to accurately extract text and structure from PDF files, making it easy to incorporate document data into your agent workflows.
With the Mistral Parse tool, you can:
- **Extract text from PDFs**: Accurately convert PDF content to text, markdown, or JSON formats
- **Process PDFs from URLs**: Directly extract content from PDFs hosted online by providing their URLs
- **Maintain document structure**: Preserve formatting, tables, and layout from the original PDFs
- **Extract images**: Optionally include embedded images from the PDFs
- **Select specific pages**: Process only the pages you need from multi-page documents
The Mistral Parse tool is particularly useful for scenarios where your agents need to work with PDF content, such as analyzing reports, extracting data from forms, or processing text from scanned documents. It simplifies the process of making PDF content available to your agents, allowing them to work with information stored in PDFs just as easily as with direct text input.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Extract text and structure from PDF documents using Mistral's OCR API. Configure processing options and get the content in your preferred format. For URLs, they must be publicly accessible and point to a valid PDF file. Note: Google Drive, Dropbox, and other cloud storage links are not supported; use a direct download URL from a web server instead.
## Tools
### `mistral_parser`
Parse PDF documents using Mistral OCR API
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `filePath` | string | Yes | URL to a PDF document to be processed |
| `fileUpload` | object | No | File upload data from file-upload component |
| `resultType` | string | No | Type of parsed result \(markdown, text, or json\). Defaults to markdown. |
| `apiKey` | string | Yes | Mistral API key \(MISTRAL_API_KEY\) |
| `includeImageBase64` | boolean | No | Include base64-encoded images in the response |
| `pages` | array | No | Specific pages to process \(array of page numbers, starting from 0\) |
| `imageLimit` | number | No | Maximum number of images to extract from the PDF |
| `imageMinSize` | number | No | Minimum height and width of images to extract from the PDF |
#### Output
This tool does not produce any outputs.
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `inputMethod` | string | No | |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `content` | string | content of the response |
| ↳ `metadata` | json | metadata of the response |
## Notes
- Category: `tools`
- Type: `mistral_parse`

View File

@@ -1,236 +0,0 @@
---
title: Outlook
description: Access Outlook
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="outlook"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
version='1.1'
id='Livello_1'
x='0px'
y='0px'
viewBox='0 0 1831.085 1703.335'
enableBackground='new 0 0 1831.085 1703.335'
>
<path
fill='#0A2767'
d='M1831.083,894.25c0.1-14.318-7.298-27.644-19.503-35.131h-0.213l-0.767-0.426l-634.492-375.585 c-2.74-1.851-5.583-3.543-8.517-5.067c-24.498-12.639-53.599-12.639-78.098,0c-2.934,1.525-5.777,3.216-8.517,5.067L446.486,858.693 l-0.766,0.426c-19.392,12.059-25.337,37.556-13.278,56.948c3.553,5.714,8.447,10.474,14.257,13.868l634.492,375.585 c2.749,1.835,5.592,3.527,8.517,5.068c24.498,12.639,53.599,12.639,78.098,0c2.925-1.541,5.767-3.232,8.517-5.068l634.492-375.585 C1823.49,922.545,1831.228,908.923,1831.083,894.25z'
/>
<path
fill='#0364B8'
d='M520.453,643.477h416.38v381.674h-416.38V643.477z M1745.917,255.5V80.908 c1-43.652-33.552-79.862-77.203-80.908H588.204C544.552,1.046,510,37.256,511,80.908V255.5l638.75,170.333L1745.917,255.5z'
/>
<path fill='#0078D4' d='M511,255.5h425.833v383.25H511V255.5z' />
<path
fill='#28A8EA'
d='M1362.667,255.5H936.833v383.25L1362.667,1022h383.25V638.75L1362.667,255.5z'
/>
<path fill='#0078D4' d='M936.833,638.75h425.833V1022H936.833V638.75z' />
<path fill='#0364B8' d='M936.833,1022h425.833v383.25H936.833V1022z' />
<path fill='#14447D' d='M520.453,1025.151h416.38v346.969h-416.38V1025.151z' />
<path fill='#0078D4' d='M1362.667,1022h383.25v383.25h-383.25V1022z' />
<linearGradient
id='SVGID_1_'
gradientUnits='userSpaceOnUse'
x1='1128.4584'
y1='811.0833'
x2='1128.4584'
y2='1.9982'
gradientTransform='matrix(1 0 0 -1 0 1705.3334)'
>
<stop offset='0' style={{ stopColor: '#35B8F1' }} />
<stop offset='1' style={{ stopColor: '#28A8EA' }} />
</linearGradient>
<path
fill='url(#SVGID_1_)'
d='M1811.58,927.593l-0.809,0.426l-634.492,356.848c-2.768,1.703-5.578,3.321-8.517,4.769 c-10.777,5.132-22.481,8.029-34.407,8.517l-34.663-20.27c-2.929-1.47-5.773-3.105-8.517-4.897L447.167,906.003h-0.298 l-21.036-11.753v722.384c0.328,48.196,39.653,87.006,87.849,86.7h1230.914c0.724,0,1.363-0.341,2.129-0.341 c10.18-0.651,20.216-2.745,29.808-6.217c4.145-1.756,8.146-3.835,11.966-6.217c2.853-1.618,7.75-5.152,7.75-5.152 c21.814-16.142,34.726-41.635,34.833-68.772V894.25C1831.068,908.067,1823.616,920.807,1811.58,927.593z'
/>
<path
opacity='0.5'
fill='#0A2767'
enableBackground='new '
d='M1797.017,891.397v44.287l-663.448,456.791L446.699,906.301 c0-0.235-0.191-0.426-0.426-0.426l0,0l-63.023-37.899v-31.938l25.976-0.426l54.932,31.512l1.277,0.426l4.684,2.981 c0,0,645.563,368.346,647.267,369.197l24.698,14.478c2.129-0.852,4.258-1.703,6.813-2.555 c1.278-0.852,640.879-360.681,640.879-360.681L1797.017,891.397z'
/>
<path
fill='#1490DF'
d='M1811.58,927.593l-0.809,0.468l-634.492,356.848c-2.768,1.703-5.578,3.321-8.517,4.769 c-24.641,12.038-53.457,12.038-78.098,0c-2.918-1.445-5.76-3.037-8.517-4.769L446.657,928.061l-0.766-0.468 c-12.25-6.642-19.93-19.409-20.057-33.343v722.384c0.305,48.188,39.616,87.004,87.803,86.7c0.001,0,0.002,0,0.004,0h1229.636 c48.188,0.307,87.5-38.509,87.807-86.696c0-0.001,0-0.002,0-0.004V894.25C1831.068,908.067,1823.616,920.807,1811.58,927.593z'
/>
<path
opacity='0.1'
enableBackground='new '
d='M1185.52,1279.629l-9.496,5.323c-2.752,1.752-5.595,3.359-8.517,4.812 c-10.462,5.135-21.838,8.146-33.47,8.857l241.405,285.479l421.107,101.476c11.539-8.716,20.717-20.178,26.7-33.343L1185.52,1279.629 z'
/>
<path
opacity='0.05'
enableBackground='new '
d='M1228.529,1255.442l-52.505,29.51c-2.752,1.752-5.595,3.359-8.517,4.812 c-10.462,5.135-21.838,8.146-33.47,8.857l113.101,311.838l549.538,74.989c21.649-16.254,34.394-41.743,34.407-68.815v-9.326 L1228.529,1255.442z'
/>
<path
fill='#28A8EA'
d='M514.833,1703.333h1228.316c18.901,0.096,37.335-5.874,52.59-17.033l-697.089-408.331 c-2.929-1.47-5.773-3.105-8.517-4.897L447.125,906.088h-0.298l-20.993-11.838v719.914 C425.786,1663.364,465.632,1703.286,514.833,1703.333C514.832,1703.333,514.832,1703.333,514.833,1703.333z'
/>
<path
opacity='0.1'
enableBackground='new '
d='M1022,418.722v908.303c-0.076,31.846-19.44,60.471-48.971,72.392 c-9.148,3.931-19,5.96-28.957,5.962H425.833V383.25H511v-42.583h433.073C987.092,340.83,1021.907,375.702,1022,418.722z'
/>
<path
opacity='0.2'
enableBackground='new '
d='M979.417,461.305v908.302c0.107,10.287-2.074,20.469-6.388,29.808 c-11.826,29.149-40.083,48.273-71.54,48.417H425.833V383.25h475.656c12.356-0.124,24.533,2.958,35.344,8.943 C962.937,405.344,979.407,432.076,979.417,461.305z'
/>
<path
opacity='0.2'
enableBackground='new '
d='M979.417,461.305v823.136c-0.208,43-34.928,77.853-77.927,78.225H425.833V383.25 h475.656c12.356-0.124,24.533,2.958,35.344,8.943C962.937,405.344,979.407,432.076,979.417,461.305z'
/>
<path
opacity='0.2'
enableBackground='new '
d='M936.833,461.305v823.136c-0.046,43.067-34.861,78.015-77.927,78.225H425.833 V383.25h433.072c43.062,0.023,77.951,34.951,77.927,78.013C936.833,461.277,936.833,461.291,936.833,461.305z'
/>
<linearGradient
id='SVGID_2_'
gradientUnits='userSpaceOnUse'
x1='162.7469'
y1='1383.0741'
x2='774.0864'
y2='324.2592'
gradientTransform='matrix(1 0 0 -1 0 1705.3334)'
>
<stop offset='0' style={{ stopColor: '#1784D9' }} />
<stop offset='0.5' style={{ stopColor: '#107AD5' }} />
<stop offset='1' style={{ stopColor: '#0A63C9' }} />
</linearGradient>
<path
fill='url(#SVGID_2_)'
d='M78.055,383.25h780.723c43.109,0,78.055,34.947,78.055,78.055v780.723 c0,43.109-34.946,78.055-78.055,78.055H78.055c-43.109,0-78.055-34.947-78.055-78.055V461.305 C0,418.197,34.947,383.25,78.055,383.25z'
/>
<path
fill='#FFFFFF'
d='M243.96,710.631c19.238-40.988,50.29-75.289,89.17-98.495c43.057-24.651,92.081-36.94,141.675-35.515 c45.965-0.997,91.321,10.655,131.114,33.683c37.414,22.312,67.547,55.004,86.742,94.109c20.904,43.09,31.322,90.512,30.405,138.396 c1.013,50.043-9.706,99.628-31.299,144.783c-19.652,40.503-50.741,74.36-89.425,97.388c-41.327,23.734-88.367,35.692-136.011,34.578 c-46.947,1.133-93.303-10.651-134.01-34.067c-37.738-22.341-68.249-55.07-87.892-94.28c-21.028-42.467-31.57-89.355-30.745-136.735 C212.808,804.859,223.158,755.686,243.96,710.631z M339.006,941.858c10.257,25.912,27.651,48.385,50.163,64.812 c22.93,16.026,50.387,24.294,78.353,23.591c29.783,1.178,59.14-7.372,83.634-24.358c22.227-16.375,39.164-38.909,48.715-64.812 c10.677-28.928,15.946-59.572,15.543-90.404c0.33-31.127-4.623-62.084-14.649-91.554c-8.855-26.607-25.246-50.069-47.182-67.537 c-23.88-17.79-53.158-26.813-82.91-25.55c-28.572-0.74-56.644,7.593-80.184,23.804c-22.893,16.496-40.617,39.168-51.1,65.365 c-23.255,60.049-23.376,126.595-0.341,186.728L339.006,941.858z'
/>
<path fill='#50D9FF' d='M1362.667,255.5h383.25v383.25h-383.25V255.5z' />
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Microsoft Outlook](https://outlook.office365.com) is a comprehensive email and calendar platform that helps users manage communications, schedules, and tasks efficiently. As part of Microsoft's productivity suite, Outlook offers robust tools for sending and organizing emails, coordinating meetings, and integrating seamlessly with Microsoft 365 applications — enabling individuals and teams to stay organized and connected across devices.
With Microsoft Outlook, you can:
- **Send and receive emails**: Communicate clearly and professionally with individuals or distribution lists
- **Manage calendars and events**: Schedule meetings, set reminders, and view availability
- **Organize your inbox**: Use folders, categories, and rules to keep your email streamlined
- **Access contacts and tasks**: Keep track of key people and action items in one place
- **Integrate with Microsoft 365**: Work seamlessly with Word, Excel, Teams, and other Microsoft apps
- **Access across devices**: Use Outlook on desktop, web, and mobile with real-time sync
- **Maintain privacy and security**: Leverage enterprise-grade encryption and compliance controls
In Sim Studio, the Microsoft Outlook integration enables your agents to interact directly with email and calendar data programmatically. This allows for powerful automation scenarios such as sending custom email updates, parsing incoming messages for workflow triggers, creating calendar events, and managing task reminders. By connecting Sim Studio with Microsoft Outlook, you enable intelligent agents to automate communications, streamline scheduling, and maintain visibility into organizational correspondence — all within your workflow ecosystem.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Outlook functionality to read, draft, andsend email messages within your workflow. Automate email communications and process email content using OAuth authentication.
## Tools
### `outlook_send`
Send emails using Outlook
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | Access token for Outlook API |
| `to` | string | Yes | Recipient email address |
| `subject` | string | Yes | Email subject |
| `body` | string | Yes | Email body content |
#### Output
| Parameter | Type |
| --------- | ---- |
| `message` | string |
| `results` | string |
| `timestamp` | string |
### `outlook_draft`
Draft emails using Outlook
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | Access token for Outlook API |
| `to` | string | Yes | Recipient email address |
| `subject` | string | Yes | Email subject |
| `body` | string | Yes | Email body content |
#### Output
| Parameter | Type |
| --------- | ---- |
| `message` | string |
| `results` | string |
| `subject` | string |
| `status` | string |
| `timestamp` | string |
### `outlook_read`
Read emails from Outlook
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | OAuth access token for Outlook |
| `folder` | string | No | Folder ID to read emails from \(default: Inbox\) |
| `maxResults` | number | No | Maximum number of emails to retrieve \(default: 1, max: 10\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `message` | string |
| `results` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `message` | string | message of the response |
| ↳ `results` | json | results of the response |
## Notes
- Category: `tools`
- Type: `outlook`

View File

@@ -1,100 +0,0 @@
---
title: S3
description: View S3 files
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="s3"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
preserveAspectRatio='xMidYMid'
viewBox='0 0 256 310'
xmlns='http://www.w3.org/2000/svg'
>
<path d='m20.624 53.686-20.624 10.314v181.02l20.624 10.254.124-.149v-201.297z' fill='#8c3123' />
<path d='m131 229-110.376 26.274v-201.588l110.376 25.701z' fill='#e05243' />
<path d='m81.178 187.866 46.818 5.96.294-.678.263-76.77-.557-.6-46.818 5.874z' fill='#8c3123' />
<path
d='m127.996 229.295 107.371 26.035.169-.269-.003-201.195-.17-.18-107.367 25.996z'
fill='#8c3123'
/>
<path d='m174.827 187.866-46.831 5.96v-78.048l46.831 5.874z' fill='#e05243' />
<path d='m174.827 89.631-46.831 8.535-46.818-8.535 46.759-12.256z' fill='#5e1f18' />
<path d='m174.827 219.801-46.831-8.591-46.818 8.591 46.761 13.053z' fill='#f2b0a9' />
<path
d='m81.178 89.631 46.818-11.586.379-.117v-77.615l-.379-.313-46.818 23.413z'
fill='#8c3123'
/>
<path d='m174.827 89.631-46.831-11.586v-78.045l46.831 23.413z' fill='#e05243' />
<path
d='m127.996 309.428-46.823-23.405v-66.217l46.823 11.582.689.783-.187 75.906z'
fill='#8c3123'
/>
<g fill='#e05243'>
<path d='m127.996 309.428 46.827-23.405v-66.217l-46.827 11.582z' />
<path d='m235.367 53.686 20.633 10.314v181.02l-20.633 10.31z' />
</g>
</svg>`}
/>
## Usage Instructions
Retrieve and view files from Amazon S3 buckets using presigned URLs.
## Tools
### `s3_get_object`
Retrieve an object from an AWS S3 bucket
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKeyId` | string | Yes | Your AWS Access Key ID |
| `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
| `s3Uri` | string | Yes | S3 Object URL \(e.g., https://bucket-name.s3.region.amazonaws.com/path/to/file\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `metadata` | string |
| `size` | string |
| `name` | string |
| `lastModified` | string |
| `url` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKeyId` | string | Yes | Access Key ID - Enter your AWS Access Key ID |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `url` | string | url of the response |
| ↳ `metadata` | json | metadata of the response |
## Notes
- Category: `tools`
- Type: `s3`

View File

@@ -1,131 +0,0 @@
---
title: Serper
description: Search the web using Serper
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="serper"
color="#2B3543"
icon={true}
iconSvg={`<svg className="block-icon" viewBox='0 0 654 600' xmlns='http://www.w3.org/2000/svg' >
<path
d='M324 38C356 37 389 36 417 47C452 56 484 72 509 94C539 118 561 145 577 176C593 205 601 238 606 271C610 343 590 403 552 452C528 482 499 507 467 523C438 539 404 547 372 552C297 556 235 534 184 492C133 449 103 392 93 330C93 292 89 255 102 224C112 189 128 158 149 132C194 78 255 46 322 38'
fill='rgb(71,97,118)'
/>
<path
d='M326 39C286 43 250 55 217 75C185 94 156 120 137 150C100 204 87 266 95 336C107 402 142 462 198 502C249 538 309 556 378 551C415 545 449 533 477 516C511 497 535 472 557 445C592 393 611 333 605 265C595 196 563 140 511 95C484 73 452 57 419 48C390 38 359 38 327 39'
fill='rgb(71,97,119)'
/>
<path
d='M342 40C407 42 465 61 513 103C541 126 562 155 576 184C592 217 600 251 600 288C602 357 579 416 535 465C510 493 478 515 445 528C416 541 385 546 352 546C284 548 225 523 178 481C130 436 103 379 96 313C94 244 113 186 151 138C179 103 209 80 245 64C276 50 307 44 340 41'
fill='rgb(71,97,119)'
/>
<path
d='M344 42C309 44 277 51 247 64C209 81 180 103 153 136C114 186 95 244 96 312C104 379 131 435 177 480C225 522 284 547 351 546C385 545 416 540 443 528C478 514 509 492 533 466C578 416 601 357 600 289C599 251 591 217 576 187C561 156 541 127 515 105C466 63 409 44 346 41'
fill='rgb(71,97,118)'
/>
<path
d='M327 81C378 78 423 89 462 114C511 144 546 196 557 248C567 306 559 363 530 406C498 457 448 492 395 503C338 513 282 506 239 477C192 450 156 402 143 351C126 296 137 235 163 190C198 130 258 89 325 82'
fill='rgb(44,56,71)'
/>
<path
d='M329 83C260 89 199 129 165 189C138 235 127 296 144 349C157 401 193 449 237 475C282 505 338 512 393 503C448 491 497 457 529 408C558 363 566 306 557 250C545 196 511 145 464 116C424 91 380 79 330 82'
fill='rgb(43,55,70)'
/>
<path
d='M334 87C381 83 423 94 458 117C510 148 544 201 554 258C562 317 551 370 521 412C487 460 440 491 385 500C331 507 281 499 241 473C191 444 157 394 145 339C136 284 143 227 171 186C207 129 265 91 332 87'
fill='rgb(41,53,67)'
/>
<path
d='M335 88C267 90 208 129 173 184C144 227 137 284 145 338C158 393 191 443 240 471C281 498 331 506 384 500C439 490 487 459 519 413C550 370 561 317 554 259C543 201 509 149 460 119C424 96 383 85 337 88'
fill='rgb(41,53,67)'
/>
<path
d='M347 166C361 164 373 169 387 168C412 180 437 193 447 221C449 232 443 243 434 248C403 245 398 204 365 207C338 206 315 210 297 228C294 238 289 257 303 260C337 280 382 276 417 292C436 300 448 314 455 330C457 349 462 373 449 385C435 408 413 418 391 427C361 429 328 436 304 421C280 413 260 392 250 370C246 356 255 343 268 343C293 360 316 398 356 389C382 390 409 380 416 357C389 295 298 335 260 276C246 256 248 233 258 214C279 184 309 167 346 167'
fill='rgb(121,172,205)'
/>
<path
d='M349 168C312 167 280 183 259 212C249 233 247 256 260 274C299 334 390 294 422 354C409 381 382 391 357 389C316 399 293 361 272 342C255 344 247 356 251 368C260 391 280 412 302 420C328 435 361 428 389 428C412 417 434 407 447 386C461 373 456 349 456 332C428 270 351 289 304 262C288 258 293 239 295 229C314 209 338 204 363 204C398 203 403 244 431 249C443 242 448 232 449 222C436 193 412 181 388 172C374 170 363 166 350 167'
fill='rgb(125,177,211)'
/>
<path
d='M349 169C386 169 425 185 441 220C444 231 441 240 432 243C409 237 402 209 380 206C347 200 314 201 293 226C290 238 286 256 297 262C332 283 375 281 411 295C431 304 446 317 452 337C455 360 452 383 434 396C415 415 391 421 366 426C338 430 316 422 295 413C276 402 261 385 254 366C254 353 261 343 275 348C290 381 325 398 360 394C388 395 411 382 420 360C425 342 413 334 404 327C359 304 298 318 265 276C253 254 255 235 261 214C280 187 314 173 346 170'
fill='rgb(137,195,233)'
/>
<path
d='M349 171C316 173 281 187 263 214C256 235 254 254 266 273C300 316 359 304 401 325C413 333 426 342 422 358C412 382 388 396 363 395C326 399 290 382 278 348C262 345 254 353 253 365C261 384 277 401 292 411C316 421 338 429 365 426C390 420 415 414 432 398C451 383 454 360 453 338C445 317 430 305 413 296C375 282 332 284 299 264C285 257 288 239 291 228C304 212 319 205 336 202C378 193 403 213 423 244C438 244 443 232 441 222C425 186 388 171 352 170'
fill='rgb(139,198,236)'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Serper](https://www.serper.com/) is a Google Search API that provides developers with programmatic access to Google search results. It offers a reliable, high-performance way to integrate Google search capabilities into applications without the complexity of web scraping or the limitations of other search APIs.
With Serper, you can:
- **Access Google search results**: Get structured data from Google search results programmatically
- **Perform different search types**: Run web searches, image searches, news searches, and more
- **Retrieve rich metadata**: Obtain titles, snippets, URLs, and other relevant information from search results
- **Scale your applications**: Build search-powered features with a reliable and fast API
- **Avoid rate limiting**: Get consistent access to search results without worrying about IP blocks
In Sim Studio, the Serper integration enables your agents to leverage the power of web search as part of their workflows. This allows for sophisticated automation scenarios that require up-to-date information from the internet. Your agents can formulate search queries, retrieve relevant results, and use this information to make decisions or provide responses. This integration bridges the gap between your workflow automation and the vast knowledge available on the web, enabling your agents to access real-time information without manual intervention. By connecting Sim Studio with Serper, you can create agents that stay current with the latest information, provide more accurate responses, and deliver more value to users.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Access real-time web search results with Serper
## Tools
### `serper_search`
A powerful web search tool that provides access to Google search results through Serper.dev API. Supports different types of searches including regular web search, news, places, and images, with each result containing relevant metadata like titles, URLs, snippets, and type-specific information.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | The search query |
| `apiKey` | string | Yes | Serper API Key |
| `num` | number | No | Number of results to return |
| `gl` | string | No | Country code for search results |
| `hl` | string | No | Language code for search results |
| `type` | string | No | Type of search to perform |
#### Output
| Parameter | Type |
| --------- | ---- |
| `searchResults` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | Search Query - Enter your search query... |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `searchResults` | json | searchResults of the response |
## Notes
- Category: `tools`
- Type: `serper`

View File

@@ -1,109 +0,0 @@
---
title: Slack
description: Send messages to Slack
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="slack"
color="#611f69"
icon={true}
iconSvg={`<svg className="block-icon" viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg' >
<g>
<path
d='M53.8412698,161.320635 C53.8412698,176.152381 41.8539683,188.139683 27.0222222,188.139683 C12.1904762,188.139683 0.203174603,176.152381 0.203174603,161.320635 C0.203174603,146.488889 12.1904762,134.501587 27.0222222,134.501587 L53.8412698,134.501587 L53.8412698,161.320635 Z M67.2507937,161.320635 C67.2507937,146.488889 79.2380952,134.501587 94.0698413,134.501587 C108.901587,134.501587 120.888889,146.488889 120.888889,161.320635 L120.888889,228.368254 C120.888889,243.2 108.901587,255.187302 94.0698413,255.187302 C79.2380952,255.187302 67.2507937,243.2 67.2507937,228.368254 L67.2507937,161.320635 Z'
fill='#E01E5A'
/>
<path
d='M94.0698413,53.6380952 C79.2380952,53.6380952 67.2507937,41.6507937 67.2507937,26.8190476 C67.2507937,11.9873016 79.2380952,-7.10542736e-15 94.0698413,-7.10542736e-15 C108.901587,-7.10542736e-15 120.888889,11.9873016 120.888889,26.8190476 L120.888889,53.6380952 L94.0698413,53.6380952 Z M94.0698413,67.2507937 C108.901587,67.2507937 120.888889,79.2380952 120.888889,94.0698413 C120.888889,108.901587 108.901587,120.888889 94.0698413,120.888889 L26.8190476,120.888889 C11.9873016,120.888889 0,108.901587 0,94.0698413 C0,79.2380952 11.9873016,67.2507937 26.8190476,67.2507937 L94.0698413,67.2507937 Z'
fill='#36C5F0'
/>
<path
d='M201.549206,94.0698413 C201.549206,79.2380952 213.536508,67.2507937 228.368254,67.2507937 C243.2,67.2507937 255.187302,79.2380952 255.187302,94.0698413 C255.187302,108.901587 243.2,120.888889 228.368254,120.888889 L201.549206,120.888889 L201.549206,94.0698413 Z M188.139683,94.0698413 C188.139683,108.901587 176.152381,120.888889 161.320635,120.888889 C146.488889,120.888889 134.501587,108.901587 134.501587,94.0698413 L134.501587,26.8190476 C134.501587,11.9873016 146.488889,-1.42108547e-14 161.320635,-1.42108547e-14 C176.152381,-1.42108547e-14 188.139683,11.9873016 188.139683,26.8190476 L188.139683,94.0698413 Z'
fill='#2EB67D'
/>
<path
d='M161.320635,201.549206 C176.152381,201.549206 188.139683,213.536508 188.139683,228.368254 C188.139683,243.2 176.152381,255.187302 161.320635,255.187302 C146.488889,255.187302 134.501587,243.2 134.501587,228.368254 L134.501587,201.549206 L161.320635,201.549206 Z M161.320635,188.139683 C146.488889,188.139683 134.501587,176.152381 134.501587,161.320635 C134.501587,146.488889 146.488889,134.501587 161.320635,134.501587 L228.571429,134.501587 C243.403175,134.501587 255.390476,146.488889 255.390476,161.320635 C255.390476,176.152381 243.403175,188.139683 228.571429,188.139683 L161.320635,188.139683 Z'
fill='#ECB22E'
/>
</g>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Slack](https://www.slack.com/) is a business communication platform that offers teams a unified place for messaging, tools, and files.
<iframe
width="100%"
height="400"
src="https://www.youtube.com/embed/J5jz3UaWmE8"
title="Slack Integration with Sim Studio"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
With Slack, you can:
- **Automate agent notifications**: Send real-time updates from your Sim Studio agents to any Slack channel
- **Create webhook endpoints**: Configure Slack bots as webhooks to trigger Sim Studio workflows from Slack activities
- **Enhance agent workflows**: Integrate Slack messaging into your agents to deliver results, alerts, and status updates
In Sim Studio, the Slack integration enables your agents to programmatically send messages to any Slack channel or user as part of their workflows. This allows for powerful automation scenarios such as sending notifications, alerts, updates, and reports directly to your team's communication hub. Your agents can deliver timely information, share results from processes they've completed, or alert team members when attention is needed. This integration bridges the gap between your AI workflows and your team's communication, ensuring everyone stays informed without manual intervention. By connecting Sim Studio with Slack, you can create agents that keep your team updated with relevant information at the right time, enhance collaboration by sharing insights automatically, and reduce the need for manual status updates - all while leveraging your existing Slack workspace where your team already communicates.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Comprehensive Slack integration with OAuth authentication. Send formatted messages using Slack
## Tools
### `slack_message`
Send messages to Slack channels or users through the Slack API. Supports Slack mrkdwn formatting.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `botToken` | string | No | Bot token for Custom Bot |
| `accessToken` | string | No | OAuth access token or bot token for Slack API |
| `channel` | string | Yes | Target Slack channel \(e.g., #general\) |
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `ts` | string |
| `channel` | string |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `ts` | string | ts of the response |
| ↳ `channel` | string | channel of the response |
## Notes
- Category: `tools`
- Type: `slack`

View File

@@ -1,22 +0,0 @@
import type { InferPageType } from 'fumadocs-core/source'
import { remarkInclude } from 'fumadocs-mdx/config'
import { remark } from 'remark'
import remarkGfm from 'remark-gfm'
import remarkMdx from 'remark-mdx'
import type { source } from '@/lib/source'
const processor = remark().use(remarkMdx).use(remarkInclude).use(remarkGfm)
export async function getLLMText(page: InferPageType<typeof source>) {
const processed = await processor.process({
path: page.data._file.absolutePath,
value: page.data.content,
})
return `# ${page.data.title}
URL: ${page.url}
${page.data.description}
${processed.value}`
}

View File

@@ -1,24 +0,0 @@
import { createMDX } from 'fumadocs-mdx/next'
const withMDX = createMDX()
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
async redirects() {
return [
{
source: '/',
destination: '/introduction',
permanent: true,
},
{
source: '/docs/:path*.mdx',
destination: '/llms.mdx/:path*',
permanent: true,
},
]
},
}
export default withMDX(config)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,22 +0,0 @@
'use server'
import { env } from '@/lib/env'
import { isProd } from '@/lib/environment'
export async function getOAuthProviderStatus() {
const githubAvailable = !!(
env.GITHUB_CLIENT_ID &&
env.GITHUB_CLIENT_SECRET &&
env.GITHUB_CLIENT_ID !== 'placeholder' &&
env.GITHUB_CLIENT_SECRET !== 'placeholder'
)
const googleAvailable = !!(
env.GOOGLE_CLIENT_ID &&
env.GOOGLE_CLIENT_SECRET &&
env.GOOGLE_CLIENT_ID !== 'placeholder' &&
env.GOOGLE_CLIENT_SECRET !== 'placeholder'
)
return { githubAvailable, googleAvailable, isProduction: isProd }
}

View File

@@ -1,41 +0,0 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { GridPattern } from '../(landing)/components/grid-pattern'
import { NotificationList } from '../workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications'
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<main className='relative flex min-h-screen flex-col bg-[#0C0C0C] font-geist-sans text-white'>
{/* Background pattern */}
<GridPattern
x={-5}
y={-5}
className='absolute inset-0 z-0 stroke-[#ababab]/5'
width={90}
height={90}
aria-hidden='true'
/>
{/* Header */}
<div className='relative z-10 px-6 pt-9'>
<div className='mx-auto max-w-7xl'>
<Link href='/' className='inline-flex'>
<Image src='/sim.svg' alt='Sim Logo' width={42} height={42} />
</Link>
</div>
</div>
{/* Content */}
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
<div className='w-full max-w-md'>{children}</div>
</div>
{/* Notifications */}
<div className='fixed right-4 bottom-4 z-50'>
<NotificationList />
</div>
</main>
)
}

View File

@@ -1,258 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { client } from '@/lib/auth-client'
import LoginPage from './login-form'
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/lib/auth-client', () => ({
client: {
signIn: {
email: vi.fn(),
},
emailOtp: {
sendVerificationOtp: vi.fn(),
},
},
}))
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
}))
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}
const mockSearchParams = {
get: vi.fn(),
}
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks()
;(useRouter as any).mockReturnValue(mockRouter)
;(useSearchParams as any).mockReturnValue(mockSearchParams)
mockSearchParams.get.mockReturnValue(null)
})
const defaultProps = {
githubAvailable: true,
googleAvailable: true,
isProduction: false,
}
describe('Basic Rendering', () => {
it('should render login form with all required elements', () => {
render(<LoginPage {...defaultProps} />)
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
})
it('should render social login buttons', () => {
render(<LoginPage {...defaultProps} />)
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
})
})
describe('Password Visibility Toggle', () => {
it('should toggle password visibility when button is clicked', () => {
render(<LoginPage {...defaultProps} />)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const toggleButton = screen.getByLabelText(/show password/i)
expect(passwordInput).toHaveAttribute('type', 'password')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'text')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'password')
})
})
describe('Form Interaction', () => {
it('should allow users to type in form fields', () => {
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
expect(emailInput).toHaveValue('test@example.com')
expect(passwordInput).toHaveValue('password123')
})
it('should show loading state during form submission', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
mockSignIn.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ data: { user: { id: '1' } }, error: null }), 100)
)
)
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await act(async () => {
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
fireEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText('Signing in...')).toBeInTheDocument()
expect(submitButton).toBeDisabled()
})
})
})
describe('Form Submission', () => {
it('should call signIn with correct credentials', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
mockSignIn.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSignIn).toHaveBeenCalledWith(
{
email: 'test@example.com',
password: 'password123',
callbackURL: '/workspace',
},
expect.objectContaining({
onError: expect.any(Function),
})
)
})
})
it('should handle authentication errors', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
mockSignIn.mockImplementation((credentials, options) => {
if (options?.onError) {
options.onError({
error: {
code: 'INVALID_CREDENTIALS',
message: 'Invalid credentials',
} as any,
response: {} as any,
request: {} as any,
} as any)
}
return Promise.resolve({ data: null, error: 'Invalid credentials' })
})
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Invalid email or password')).toBeInTheDocument()
})
})
})
describe('Forgot Password', () => {
it('should open forgot password dialog', () => {
render(<LoginPage {...defaultProps} />)
const forgotPasswordButton = screen.getByText(/forgot password/i)
fireEvent.click(forgotPasswordButton)
expect(screen.getByText('Reset Password')).toBeInTheDocument()
})
})
describe('URL Parameters', () => {
it('should handle invite flow parameter in signup link', () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'invite_flow') return 'true'
if (param === 'callbackUrl') return '/invite/123'
return null
})
render(<LoginPage {...defaultProps} />)
const signupLink = screen.getByText(/sign up/i)
expect(signupLink).toHaveAttribute('href', '/signup?invite_flow=true&callbackUrl=/invite/123')
})
it('should default to regular signup link when no invite flow', () => {
render(<LoginPage {...defaultProps} />)
const signupLink = screen.getByText(/sign up/i)
expect(signupLink).toHaveAttribute('href', '/signup')
})
})
describe('Email Verification Flow', () => {
it('should redirect to verification page when email not verified', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
mockSignIn.mockRejectedValue({
message: 'Email not verified',
code: 'EMAIL_NOT_VERIFIED',
})
mockSendOtp.mockResolvedValue({ data: null, error: null })
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSendOtp).toHaveBeenCalledWith({
email: 'test@example.com',
type: 'email-verification',
})
expect(mockRouter.push).toHaveBeenCalledWith('/verify')
})
})
})
})

View File

@@ -1,544 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
const logger = createLogger('LoginForm')
const EMAIL_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Email is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Email cannot be empty.',
},
basicFormat: {
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address.',
},
}
const PASSWORD_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Password is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Password cannot be empty.',
},
}
// Validate callback URL to prevent open redirect vulnerabilities
const validateCallbackUrl = (url: string): boolean => {
try {
// If it's a relative URL, it's safe
if (url.startsWith('/')) {
return true
}
// If absolute URL, check if it belongs to the same origin
const currentOrigin = typeof window !== 'undefined' ? window.location.origin : ''
if (url.startsWith(currentOrigin)) {
return true
}
return false
} catch (error) {
logger.error('Error validating callback URL:', { error, url })
return false
}
}
// Validate email and return array of error messages
const validateEmail = (emailValue: string): string[] => {
const errors: string[] = []
if (!EMAIL_VALIDATIONS.required.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.required.message)
return errors // Return early for required field
}
if (!EMAIL_VALIDATIONS.notEmpty.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.notEmpty.message)
return errors // Return early for empty field
}
if (!EMAIL_VALIDATIONS.basicFormat.regex.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.basicFormat.message)
}
return errors
}
// Validate password and return array of error messages
const validatePassword = (passwordValue: string): string[] => {
const errors: string[] = []
if (!PASSWORD_VALIDATIONS.required.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.required.message)
return errors // Return early for required field
}
if (!PASSWORD_VALIDATIONS.notEmpty.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.notEmpty.message)
return errors // Return early for empty field
}
return errors
}
export default function LoginPage({
githubAvailable,
googleAvailable,
isProduction,
}: {
githubAvailable: boolean
googleAvailable: boolean
isProduction: boolean
}) {
const router = useRouter()
const searchParams = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const [_mounted, setMounted] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
// Initialize state for URL parameters
const [callbackUrl, setCallbackUrl] = useState('/workspace')
const [isInviteFlow, setIsInviteFlow] = useState(false)
// Forgot password states
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
const [resetStatus, setResetStatus] = useState<{
type: 'success' | 'error' | null
message: string
}>({ type: null, message: '' })
// Email validation state
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
// Extract URL parameters after component mounts to avoid SSR issues
useEffect(() => {
setMounted(true)
// Only access search params on the client side
if (searchParams) {
const callback = searchParams.get('callbackUrl')
if (callback) {
// Validate the callbackUrl before setting it
if (validateCallbackUrl(callback)) {
setCallbackUrl(callback)
} else {
logger.warn('Invalid callback URL detected and blocked:', { url: callback })
// Keep the default safe value ('/workspace')
}
}
const inviteFlow = searchParams.get('invite_flow') === 'true'
setIsInviteFlow(inviteFlow)
}
}, [searchParams])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && forgotPasswordOpen) {
handleForgotPassword()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [forgotPasswordEmail, forgotPasswordOpen])
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
setEmail(newEmail)
// Silently validate but don't show errors until submit
const errors = validateEmail(newEmail)
setEmailErrors(errors)
setShowEmailValidationError(false)
}
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPassword = e.target.value
setPassword(newPassword)
// Silently validate but don't show errors until submit
const errors = validatePassword(newPassword)
setPasswordErrors(errors)
setShowValidationError(false)
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.currentTarget)
const email = formData.get('email') as string
// Validate email on submit
const emailValidationErrors = validateEmail(email)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
// Validate password on submit
const passwordValidationErrors = validatePassword(password)
setPasswordErrors(passwordValidationErrors)
setShowValidationError(passwordValidationErrors.length > 0)
// If there are validation errors, stop submission
if (emailValidationErrors.length > 0 || passwordValidationErrors.length > 0) {
setIsLoading(false)
return
}
try {
// Final validation before submission
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
const result = await client.signIn.email(
{
email,
password,
callbackURL: safeCallbackUrl,
},
{
onError: (ctx) => {
console.error('Login error:', ctx.error)
const errorMessage: string[] = ['Invalid email or password']
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
return
}
if (
ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign in is not enabled')
) {
errorMessage.push('Email sign in is currently disabled.')
} else if (
ctx.error.code?.includes('INVALID_CREDENTIALS') ||
ctx.error.message?.includes('invalid password')
) {
errorMessage.push('Invalid email or password. Please try again.')
} else if (
ctx.error.code?.includes('USER_NOT_FOUND') ||
ctx.error.message?.includes('not found')
) {
errorMessage.push('No account found with this email. Please sign up first.')
} else if (ctx.error.code?.includes('MISSING_CREDENTIALS')) {
errorMessage.push('Please enter both email and password.')
} else if (ctx.error.code?.includes('EMAIL_PASSWORD_DISABLED')) {
errorMessage.push('Email and password login is disabled.')
} else if (ctx.error.code?.includes('FAILED_TO_CREATE_SESSION')) {
errorMessage.push('Failed to create session. Please try again later.')
} else if (ctx.error.code?.includes('too many attempts')) {
errorMessage.push(
'Too many login attempts. Please try again later or reset your password.'
)
} else if (ctx.error.code?.includes('account locked')) {
errorMessage.push(
'Your account has been locked for security. Please reset your password.'
)
} else if (ctx.error.code?.includes('network')) {
errorMessage.push('Network error. Please check your connection and try again.')
} else if (ctx.error.message?.includes('rate limit')) {
errorMessage.push('Too many requests. Please wait a moment before trying again.')
}
setPasswordErrors(errorMessage)
setShowValidationError(true)
},
}
)
if (!result || result.error) {
setIsLoading(false)
return
}
// Mark that the user has previously logged in
if (typeof window !== 'undefined') {
localStorage.setItem('has_logged_in_before', 'true')
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
}
} catch (err: any) {
// Handle only the special verification case that requires a redirect
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
try {
await client.emailOtp.sendVerificationOtp({
email,
type: 'email-verification',
})
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', email)
}
router.push('/verify')
return
} catch (_verifyErr) {
setPasswordErrors(['Failed to send verification code. Please try again later.'])
setShowValidationError(true)
setIsLoading(false)
return
}
}
console.error('Uncaught login error:', err)
} finally {
setIsLoading(false)
}
}
const handleForgotPassword = async () => {
if (!forgotPasswordEmail) {
setResetStatus({
type: 'error',
message: 'Please enter your email address',
})
return
}
try {
setIsSubmittingReset(true)
setResetStatus({ type: null, message: '' })
const response = await fetch('/api/auth/forget-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: forgotPasswordEmail,
redirectTo: `${window.location.origin}/reset-password`,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to request password reset')
}
setResetStatus({
type: 'success',
message: 'Password reset link sent to your email',
})
setTimeout(() => {
setForgotPasswordOpen(false)
setResetStatus({ type: null, message: '' })
}, 2000)
} catch (error) {
logger.error('Error requesting password reset:', { error })
setResetStatus({
type: 'error',
message: error instanceof Error ? error.message : 'Failed to request password reset',
})
} finally {
setIsSubmittingReset(false)
}
}
return (
<div className='space-y-6'>
<div className='space-y-2 text-center'>
<h1 className='font-semibold text-[32px] text-white tracking-tight'>Sign In</h1>
<p className='text-neutral-400 text-sm'>
Enter your email below to sign in to your account
</p>
</div>
<div className='flex flex-col gap-6'>
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
<SocialLoginButtons
googleAvailable={googleAvailable}
githubAvailable={githubAvailable}
isProduction={isProduction}
callbackURL={callbackUrl}
/>
<div className='relative mt-2 py-4'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-neutral-700/50 border-t' />
</div>
</div>
<form onSubmit={onSubmit} className='space-y-5'>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='email' className='text-neutral-300'>
Email
</Label>
<Input
id='email'
name='email'
placeholder='Enter your email'
required
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
value={email}
onChange={handleEmailChange}
className={cn(
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
showEmailValidationError &&
emailErrors.length > 0 &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='password' className='text-neutral-300'>
Password
</Label>
<button
type='button'
onClick={() => setForgotPasswordOpen(true)}
className='font-medium text-neutral-400 text-xs transition hover:text-white'
>
Forgot password?
</button>
</div>
<div className='relative'>
<Input
id='password'
name='password'
required
type={showPassword ? 'text' : 'password'}
autoCapitalize='none'
autoComplete='current-password'
autoCorrect='off'
placeholder='Enter your password'
value={password}
onChange={handlePasswordChange}
className={cn(
'border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60',
showValidationError &&
passwordErrors.length > 0 &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-neutral-400 transition hover:text-white'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{showValidationError && passwordErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{passwordErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
<Button
type='submit'
className='flex h-11 w-full items-center justify-center gap-2 bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
</div>
<div className='text-center text-sm'>
<span className='text-neutral-400'>Don't have an account? </span>
<Link
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
className='font-medium text-[#9D54FF] underline-offset-4 transition hover:text-[#a66fff] hover:underline'
>
Sign up
</Link>
</div>
</div>
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
<DialogContent className='border border-neutral-700/50 bg-neutral-800/90 text-white backdrop-blur-sm'>
<DialogHeader>
<DialogTitle className='font-semibold text-white text-xl tracking-tight'>
Reset Password
</DialogTitle>
<DialogDescription className='text-neutral-300 text-sm'>
Enter your email address and we'll send you a link to reset your password.
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='reset-email' className='text-neutral-300'>
Email
</Label>
<Input
id='reset-email'
value={forgotPasswordEmail}
onChange={(e) => setForgotPasswordEmail(e.target.value)}
placeholder='Enter your email'
required
type='email'
className='border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[#802FFF]/70 focus:ring-[#802FFF]/20'
/>
</div>
{resetStatus.type && (
<div
className={`text-sm ${
resetStatus.type === 'success' ? 'text-[#4CAF50]' : 'text-red-500'
}`}
>
{resetStatus.message}
</div>
)}
<Button
type='button'
onClick={handleForgotPassword}
className='h-11 w-full bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
disabled={isSubmittingReset}
>
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,417 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { client } from '@/lib/auth-client'
import SignupPage from './signup-form'
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/lib/auth-client', () => ({
client: {
signUp: {
email: vi.fn(),
},
emailOtp: {
sendVerificationOtp: vi.fn(),
},
},
}))
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
}))
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}
const mockSearchParams = {
get: vi.fn(),
}
describe('SignupPage', () => {
beforeEach(() => {
vi.clearAllMocks()
;(useRouter as any).mockReturnValue(mockRouter)
;(useSearchParams as any).mockReturnValue(mockSearchParams)
mockSearchParams.get.mockReturnValue(null)
})
const defaultProps = {
githubAvailable: true,
googleAvailable: true,
isProduction: false,
}
describe('Basic Rendering', () => {
it('should render signup form with all required elements', () => {
render(<SignupPage {...defaultProps} />)
expect(screen.getByPlaceholderText(/enter your name/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument()
expect(screen.getByText(/sign in/i)).toBeInTheDocument()
})
it('should render social login buttons', () => {
render(<SignupPage {...defaultProps} />)
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
})
})
describe('Password Visibility Toggle', () => {
it('should toggle password visibility when button is clicked', () => {
render(<SignupPage {...defaultProps} />)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const toggleButton = screen.getByLabelText(/show password/i)
expect(passwordInput).toHaveAttribute('type', 'password')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'text')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'password')
})
})
describe('Form Interaction', () => {
it('should allow users to type in form fields', () => {
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
expect(nameInput).toHaveValue('John Doe')
expect(emailInput).toHaveValue('test@example.com')
expect(passwordInput).toHaveValue('Password123!')
})
it('should show loading state during form submission', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation(
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
)
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
expect(screen.getByText('Creating account...')).toBeInTheDocument()
expect(submitButton).toBeDisabled()
})
})
describe('Form Submission', () => {
it('should call signUp with correct credentials and trimmed name', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
mockSendOtp.mockResolvedValue({ data: null, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
// Use valid input that passes all validation rules
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSignUp).toHaveBeenCalledWith(
{
email: 'test@example.com',
password: 'Password123!',
name: 'John Doe',
},
expect.objectContaining({
onError: expect.any(Function),
})
)
})
})
it('should prevent submission with invalid name validation', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
// Use name with leading/trailing spaces which should fail validation
fireEvent.change(nameInput, { target: { value: ' John Doe ' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
// Should not call signUp because validation failed
expect(mockSignUp).not.toHaveBeenCalled()
// Should show validation error
await waitFor(() => {
expect(
screen.getByText(
/Name cannot contain consecutive spaces|Name cannot start or end with spaces/
)
).toBeInTheDocument()
})
})
it('should redirect to verification page after successful signup', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
mockSendOtp.mockResolvedValue({ data: null, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSendOtp).toHaveBeenCalledWith({
email: 'test@example.com',
type: 'email-verification',
})
expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true')
})
})
it('should handle signup errors', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation((credentials, options) => {
if (options?.onError) {
options.onError({
error: {
code: 'USER_ALREADY_EXISTS',
message: 'User already exists',
} as any,
response: {} as any,
request: {} as any,
} as any)
}
return Promise.resolve({ data: null, error: 'User already exists' })
})
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
await act(async () => {
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'existing@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
})
})
it('should show warning for names that would be truncated (over 100 characters)', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
const longName = 'a'.repeat(101) // 101 characters
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: longName } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText(/name will be truncated to 100 characters/i)).toBeInTheDocument()
})
// Ensure signUp was not called
expect(mockSignUp).not.toHaveBeenCalled()
})
it('should handle names exactly at 100 characters without warning', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation(
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
)
const exactLengthName = 'a'.repeat(100) // Exactly 100 characters
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: exactLengthName } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
fireEvent.click(submitButton)
// Should not show truncation warning
await waitFor(() => {
expect(screen.queryByText(/name will be truncated/i)).not.toBeInTheDocument()
})
// Should proceed with form submission
await waitFor(() => {
expect(mockSignUp).toHaveBeenCalledWith(
{
email: 'test@example.com',
password: 'ValidPass123!',
name: exactLengthName,
},
expect.any(Object)
)
})
})
it('should handle names exactly at validation errors', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation((credentials, options) => {
if (options?.onError) {
options.onError({
error: {
code: 'NAME_VALIDATION_ERROR',
message: 'Name validation error',
} as any,
response: {} as any,
request: {} as any,
} as any)
}
return Promise.resolve({ data: null, error: 'Name validation error' })
})
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
await act(async () => {
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
})
})
})
describe('URL Parameters', () => {
it('should prefill email from URL parameter', () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'email') return 'prefilled@example.com'
return null
})
render(<SignupPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
expect(emailInput).toHaveValue('prefilled@example.com')
})
it('should handle invite flow redirect', async () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'redirect') return '/invite/123'
if (param === 'invite_flow') return 'true'
return null
})
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/invite/123')
})
})
it('should link to login with invite flow parameters', () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'invite_flow') return 'true'
if (param === 'redirect') return '/invite/123'
return null
})
render(<SignupPage {...defaultProps} />)
const loginLink = screen.getByText(/sign in/i)
expect(loginLink).toHaveAttribute('href', '/login?invite_flow=true&callbackUrl=/invite/123')
})
it('should default to regular login link when no invite flow', () => {
render(<SignupPage {...defaultProps} />)
const loginLink = screen.getByText(/sign in/i)
expect(loginLink).toHaveAttribute('href', '/login')
})
})
})

View File

@@ -1,566 +0,0 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client } from '@/lib/auth-client'
import { cn } from '@/lib/utils'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
const PASSWORD_VALIDATIONS = {
minLength: { regex: /.{8,}/, message: 'Password must be at least 8 characters long.' },
uppercase: {
regex: /(?=.*?[A-Z])/,
message: 'Password must include at least one uppercase letter.',
},
lowercase: {
regex: /(?=.*?[a-z])/,
message: 'Password must include at least one lowercase letter.',
},
number: { regex: /(?=.*?[0-9])/, message: 'Password must include at least one number.' },
special: {
regex: /(?=.*?[#?!@$%^&*-])/,
message: 'Password must include at least one special character.',
},
}
const NAME_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Name is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Name cannot be empty.',
},
validCharacters: {
regex: /^[\p{L}\s\-']+$/u,
message: 'Name can only contain letters, spaces, hyphens, and apostrophes.',
},
noConsecutiveSpaces: {
regex: /^(?!.*\s\s).*$/,
message: 'Name cannot contain consecutive spaces.',
},
noLeadingTrailingSpaces: {
test: (value: string) => value === value.trim(),
message: 'Name cannot start or end with spaces.',
},
}
const EMAIL_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Email is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Email cannot be empty.',
},
maxLength: {
test: (value: string) => value.length <= 254,
message: 'Email must be less than 254 characters.',
},
basicFormat: {
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address.',
},
noSpaces: {
regex: /^[^\s]*$/,
message: 'Email cannot contain spaces.',
},
validStart: {
regex: /^[a-zA-Z0-9]/,
message: 'Email must start with a letter or number.',
},
}
function SignupFormContent({
githubAvailable,
googleAvailable,
isProduction,
}: {
githubAvailable: boolean
googleAvailable: boolean
isProduction: boolean
}) {
const router = useRouter()
const searchParams = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const [, setMounted] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
// Name validation state
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([])
const [showNameValidationError, setShowNameValidationError] = useState(false)
useEffect(() => {
setMounted(true)
const emailParam = searchParams.get('email')
if (emailParam) {
setEmail(emailParam)
}
// Handle redirection for invitation flow
const redirectParam = searchParams.get('redirect')
if (redirectParam) {
setRedirectUrl(redirectParam)
// Check if this is part of an invitation flow
if (redirectParam.startsWith('/invite/')) {
setIsInviteFlow(true)
}
}
// Explicitly check for invite_flow parameter
const inviteFlowParam = searchParams.get('invite_flow')
if (inviteFlowParam === 'true') {
setIsInviteFlow(true)
}
}, [searchParams])
// Validate password and return array of error messages
const validatePassword = (passwordValue: string): string[] => {
const errors: string[] = []
if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.minLength.message)
}
if (!PASSWORD_VALIDATIONS.uppercase.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.uppercase.message)
}
if (!PASSWORD_VALIDATIONS.lowercase.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.lowercase.message)
}
if (!PASSWORD_VALIDATIONS.number.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.number.message)
}
if (!PASSWORD_VALIDATIONS.special.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.special.message)
}
return errors
}
// Validate name and return array of error messages
const validateName = (nameValue: string): string[] => {
const errors: string[] = []
if (!NAME_VALIDATIONS.required.test(nameValue)) {
errors.push(NAME_VALIDATIONS.required.message)
return errors // Return early for required field
}
if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) {
errors.push(NAME_VALIDATIONS.notEmpty.message)
return errors // Return early for empty field
}
if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) {
errors.push(NAME_VALIDATIONS.validCharacters.message)
}
if (!NAME_VALIDATIONS.noConsecutiveSpaces.regex.test(nameValue)) {
errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message)
}
if (!NAME_VALIDATIONS.noLeadingTrailingSpaces.test(nameValue)) {
errors.push(NAME_VALIDATIONS.noLeadingTrailingSpaces.message)
}
return errors
}
// Validate email and return array of error messages
const validateEmail = (emailValue: string): string[] => {
const errors: string[] = []
if (!EMAIL_VALIDATIONS.required.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.required.message)
return errors // Return early for required field
}
if (!EMAIL_VALIDATIONS.notEmpty.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.notEmpty.message)
return errors // Return early for empty field
}
if (!EMAIL_VALIDATIONS.maxLength.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.maxLength.message)
}
if (!EMAIL_VALIDATIONS.noSpaces.regex.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.noSpaces.message)
}
if (!EMAIL_VALIDATIONS.validStart.regex.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.validStart.message)
}
if (!EMAIL_VALIDATIONS.basicFormat.regex.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.basicFormat.message)
}
return errors
}
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPassword = e.target.value
setPassword(newPassword)
// Silently validate but don't show errors
const errors = validatePassword(newPassword)
setPasswordErrors(errors)
setShowValidationError(false)
}
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value
setName(newName)
// Silently validate but don't show errors until submit
const errors = validateName(newName)
setNameErrors(errors)
setShowNameValidationError(false)
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
setEmail(newEmail)
// Silently validate but don't show errors until submit
const errors = validateEmail(newEmail)
setEmailErrors(errors)
setShowEmailValidationError(false)
// Clear any previous server-side email errors when the user starts typing
if (emailError) {
setEmailError('')
}
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.currentTarget)
const emailValue = formData.get('email') as string
const passwordValue = formData.get('password') as string
const name = formData.get('name') as string
// Validate name on submit
const nameValidationErrors = validateName(name)
setNameErrors(nameValidationErrors)
setShowNameValidationError(nameValidationErrors.length > 0)
// Validate email on submit
const emailValidationErrors = validateEmail(emailValue)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
// Validate password on submit
const errors = validatePassword(passwordValue)
setPasswordErrors(errors)
// Only show validation errors if there are any
setShowValidationError(errors.length > 0)
try {
if (
nameValidationErrors.length > 0 ||
emailValidationErrors.length > 0 ||
errors.length > 0
) {
// Prioritize name errors first, then email errors, then password errors
if (nameValidationErrors.length > 0) {
setNameErrors([nameValidationErrors[0]])
setShowNameValidationError(true)
}
if (emailValidationErrors.length > 0) {
setEmailErrors([emailValidationErrors[0]])
setShowEmailValidationError(true)
}
if (errors.length > 0) {
setPasswordErrors([errors[0]])
setShowValidationError(true)
}
setIsLoading(false)
return
}
// Check if name will be truncated and warn user
const trimmedName = name.trim()
if (trimmedName.length > 100) {
setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.'])
setShowNameValidationError(true)
setIsLoading(false)
return
}
const sanitizedName = trimmedName
const response = await client.signUp.email(
{
email: emailValue,
password: passwordValue,
name: sanitizedName,
},
{
onError: (ctx) => {
console.error('Signup error:', ctx.error)
const errorMessage: string[] = ['Failed to create account']
if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) {
errorMessage.push(
'An account with this email already exists. Please sign in instead.'
)
setEmailError(errorMessage[0])
} else if (
ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign up is not enabled')
) {
errorMessage.push('Email signup is currently disabled.')
setEmailError(errorMessage[0])
} else if (ctx.error.code?.includes('INVALID_EMAIL')) {
errorMessage.push('Please enter a valid email address.')
setEmailError(errorMessage[0])
} else if (ctx.error.code?.includes('PASSWORD_TOO_SHORT')) {
errorMessage.push('Password must be at least 8 characters long.')
setPasswordErrors(errorMessage)
setShowValidationError(true)
} else if (ctx.error.code?.includes('PASSWORD_TOO_LONG')) {
errorMessage.push('Password must be less than 128 characters long.')
setPasswordErrors(errorMessage)
setShowValidationError(true)
} else if (ctx.error.code?.includes('network')) {
errorMessage.push('Network error. Please check your connection and try again.')
setPasswordErrors(errorMessage)
setShowValidationError(true)
} else if (ctx.error.code?.includes('rate limit')) {
errorMessage.push('Too many requests. Please wait a moment before trying again.')
setPasswordErrors(errorMessage)
setShowValidationError(true)
} else {
setPasswordErrors(errorMessage)
setShowValidationError(true)
}
},
}
)
if (!response || response.error) {
setIsLoading(false)
return
}
// Handle invitation flow redirect
if (isInviteFlow && redirectUrl) {
router.push(redirectUrl)
return
}
try {
await client.emailOtp.sendVerificationOtp({
email: emailValue,
type: 'email-verification',
})
} catch (err) {
console.error('Failed to send verification OTP:', err)
}
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailValue)
localStorage.setItem('has_logged_in_before', 'true')
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
}
router.push('/verify?fromSignup=true')
} catch (error) {
console.error('Signup error:', error)
setIsLoading(false)
}
}
return (
<div className='space-y-6'>
<div className='space-y-2 text-center'>
<h1 className='font-semibold text-[32px] text-white tracking-tight'>Create Account</h1>
<p className='text-neutral-400 text-sm'>Enter your details to create a new account</p>
</div>
<div className='flex flex-col gap-6'>
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
<SocialLoginButtons
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
callbackURL={redirectUrl || '/workspace'}
isProduction={isProduction}
/>
<div className='relative mt-2 py-4'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-neutral-700/50 border-t' />
</div>
</div>
<form onSubmit={onSubmit} className='space-y-5'>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='name' className='text-neutral-300'>
Full Name
</Label>
<Input
id='name'
name='name'
placeholder='Enter your name'
type='text'
autoCapitalize='words'
autoComplete='name'
title='Name can only contain letters, spaces, hyphens, and apostrophes'
value={name}
onChange={handleNameChange}
className={cn(
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
showNameValidationError &&
nameErrors.length > 0 &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
{showNameValidationError && nameErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{nameErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='email' className='text-neutral-300'>
Email
</Label>
<Input
id='email'
name='email'
placeholder='Enter your email'
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
value={email}
onChange={handleEmailChange}
className={cn(
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
{emailError && !showEmailValidationError && (
<div className='mt-1 text-red-400 text-xs'>
<p>{emailError}</p>
</div>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='password' className='text-neutral-300'>
Password
</Label>
<div className='relative'>
<Input
id='password'
name='password'
type={showPassword ? 'text' : 'password'}
autoCapitalize='none'
autoComplete='new-password'
placeholder='Enter your password'
autoCorrect='off'
value={password}
onChange={handlePasswordChange}
className='border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60'
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-neutral-400 transition hover:text-white'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{showValidationError && passwordErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{passwordErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
<Button
type='submit'
className='flex h-11 w-full items-center justify-center gap-2 bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create Account'}
</Button>
</form>
</div>
<div className='text-center text-sm'>
<span className='text-neutral-400'>Already have an account? </span>
<Link
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
className='font-medium text-[#9D54FF] underline-offset-4 transition hover:text-[#a66fff] hover:underline'
>
Sign in
</Link>
</div>
</div>
</div>
)
}
export default function SignupPage({
githubAvailable,
googleAvailable,
isProduction,
}: {
githubAvailable: boolean
googleAvailable: boolean
isProduction: boolean
}) {
return (
<Suspense
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
>
<SignupFormContent
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
isProduction={isProduction}
/>
</Suspense>
)
}

View File

@@ -1,14 +0,0 @@
import { env } from '@/lib/env'
import { isProd } from '@/lib/environment'
import { getBaseUrl } from '@/lib/urls/utils'
import { VerifyContent } from './verify-content'
// Force dynamic rendering to avoid prerender errors with search params
export const dynamic = 'force-dynamic'
export default function VerifyPage() {
const baseUrl = getBaseUrl()
const hasResendKey = Boolean(env.RESEND_API_KEY && env.RESEND_API_KEY !== 'placeholder')
return <VerifyContent hasResendKey={hasResendKey} baseUrl={baseUrl} isProduction={isProd} />
}

View File

@@ -1,178 +0,0 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
import { cn } from '@/lib/utils'
import { useVerification } from './use-verification'
interface VerifyContentProps {
hasResendKey: boolean
baseUrl: string
isProduction: boolean
}
function VerificationForm({
hasResendKey,
isProduction,
}: {
hasResendKey: boolean
isProduction: boolean
}) {
const {
otp,
email,
isLoading,
isVerified,
isInvalidOtp,
errorMessage,
isOtpComplete,
verifyCode,
resendCode,
handleOtpChange,
} = useVerification({ hasResendKey, isProduction })
const [countdown, setCountdown] = useState(0)
const [isResendDisabled, setIsResendDisabled] = useState(false)
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
}
if (countdown === 0 && isResendDisabled) {
setIsResendDisabled(false)
}
}, [countdown, isResendDisabled])
const handleResend = () => {
resendCode()
setIsResendDisabled(true)
setCountdown(30)
}
return (
<div className='space-y-6'>
<div className='space-y-2 text-center'>
<h1 className='font-semibold text-[32px] text-white tracking-tight'>
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
</h1>
<p className='text-neutral-400 text-sm'>
{isVerified
? 'Your email has been verified. Redirecting to dashboard...'
: hasResendKey
? `A verification code has been sent to ${email || 'your email'}`
: !isProduction
? 'Development mode: Check your console logs for the verification code'
: 'Error: Invalid API key configuration'}
</p>
</div>
{!isVerified && (
<div className='flex flex-col gap-6'>
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
<p className='mb-4 text-neutral-400 text-sm'>
Enter the 6-digit code to verify your account.
{hasResendKey ? " If you don't see it in your inbox, check your spam folder." : ''}
</p>
<div className='flex justify-center py-4'>
<InputOTP
maxLength={6}
value={otp}
onChange={handleOtpChange}
disabled={isLoading}
className={cn(
isInvalidOtp ? 'border-red-500 focus-visible:ring-red-500' : 'border-neutral-700'
)}
>
<InputOTPGroup>
<InputOTPSlot
index={0}
className='border-neutral-700 bg-neutral-900 text-white'
/>
<InputOTPSlot
index={1}
className='border-neutral-700 bg-neutral-900 text-white'
/>
<InputOTPSlot
index={2}
className='border-neutral-700 bg-neutral-900 text-white'
/>
<InputOTPSlot
index={3}
className='border-neutral-700 bg-neutral-900 text-white'
/>
<InputOTPSlot
index={4}
className='border-neutral-700 bg-neutral-900 text-white'
/>
<InputOTPSlot
index={5}
className='border-neutral-700 bg-neutral-900 text-white'
/>
</InputOTPGroup>
</InputOTP>
</div>
{/* Error message */}
{errorMessage && (
<div className='mt-2 mb-4 rounded-md border border-red-900/20 bg-red-900/10 py-2 text-center'>
<p className='font-medium text-red-400 text-sm'>{errorMessage}</p>
</div>
)}
<Button
onClick={verifyCode}
className='h-11 w-full bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
disabled={!isOtpComplete || isLoading}
>
{isLoading ? 'Verifying...' : 'Verify Email'}
</Button>
{hasResendKey && (
<div className='mt-4 text-center'>
<p className='text-neutral-400 text-sm'>
Didn't receive a code?{' '}
{countdown > 0 ? (
<span>
Resend in <span className='font-medium text-neutral-300'>{countdown}s</span>
</span>
) : (
<button
className='font-medium text-[#9D54FF] underline-offset-4 transition hover:text-[#a66fff] hover:underline'
onClick={handleResend}
disabled={isLoading || isResendDisabled}
>
Resend
</button>
)}
</p>
</div>
)}
</div>
</div>
)}
</div>
)
}
// Fallback component while the verification form is loading
function VerificationFormFallback() {
return (
<div className='p-8 text-center'>
<div className='animate-pulse'>
<div className='mx-auto mb-4 h-8 w-48 rounded bg-neutral-800' />
<div className='mx-auto h-4 w-64 rounded bg-neutral-800' />
</div>
</div>
)
}
export function VerifyContent({ hasResendKey, baseUrl, isProduction }: VerifyContentProps) {
return (
<Suspense fallback={<VerificationFormFallback />}>
<VerificationForm hasResendKey={hasResendKey} isProduction={isProduction} />
</Suspense>
)
}

View File

@@ -1,204 +0,0 @@
'use server'
import { env } from '@/lib/env'
/**
* Format a number to a human-readable format (e.g., 1000 -> 1k, 1100 -> 1.1k)
*/
function formatNumber(num: number): string {
if (num < 1000) {
return num.toString()
}
const formatted = (Math.round(num / 100) / 10).toFixed(1)
return formatted.endsWith('.0') ? `${formatted.slice(0, -2)}k` : `${formatted}k`
}
/**
* Server action to fetch GitHub stars
*/
export async function getFormattedGitHubStars(): Promise<string> {
try {
const token = env.GITHUB_TOKEN
const response = await fetch('https://api.github.com/repos/simstudioai/sim', {
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'SimStudio/1.0',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
next: { revalidate: 3600 },
})
if (!response.ok) {
console.error(`GitHub API error: ${response.status} ${response.statusText}`)
return formatNumber(3867)
}
const data = await response.json()
return formatNumber(data.stargazers_count || 3867)
} catch (error) {
console.error('Error fetching GitHub stars:', error)
return formatNumber(3867)
}
}
interface Contributor {
login: string
avatar_url: string
contributions: number
html_url: string
}
interface CommitData {
sha: string
commit: {
author: {
name: string
email: string
date: string
}
message: string
}
html_url: string
}
interface RepoStats {
stars: number
forks: number
watchers: number
openIssues: number
openPRs: number
}
/**
* Server action to fetch repository statistics
*/
export async function getRepositoryStats(): Promise<RepoStats> {
try {
const token = env.GITHUB_TOKEN
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'SimStudio/1.0',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
const repoResponse = await fetch('https://api.github.com/repos/simstudioai/sim', {
headers,
next: { revalidate: 3600 },
})
const prsResponse = await fetch(
'https://api.github.com/repos/simstudioai/sim/pulls?state=open',
{
headers,
next: { revalidate: 3600 },
}
)
if (!repoResponse.ok || !prsResponse.ok) {
console.error('GitHub API error fetching repo stats')
return {
stars: 3867,
forks: 581,
watchers: 26,
openIssues: 23,
openPRs: 3,
}
}
const repoData = await repoResponse.json()
const prsData = await prsResponse.json()
return {
stars: repoData.stargazers_count || 3867,
forks: repoData.forks_count || 581,
watchers: repoData.subscribers_count || 26,
openIssues: (repoData.open_issues_count || 26) - prsData.length,
openPRs: prsData.length || 3,
}
} catch (error) {
console.error('Error fetching repository stats:', error)
return {
stars: 3867,
forks: 581,
watchers: 26,
openIssues: 23,
openPRs: 3,
}
}
}
/**
* Server action to fetch contributors
*/
export async function getContributors(): Promise<Contributor[]> {
try {
const token = env.GITHUB_TOKEN
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'SimStudio/1.0',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
const response = await fetch(
'https://api.github.com/repos/simstudioai/sim/contributors?per_page=100',
{
headers,
next: { revalidate: 3600 },
}
)
if (!response.ok) {
console.error('GitHub API error fetching contributors')
return []
}
const contributors = await response.json()
return contributors || []
} catch (error) {
console.error('Error fetching contributors:', error)
return []
}
}
/**
* Server action to fetch recent commits for timeline data
*/
export async function getCommitsData(): Promise<CommitData[]> {
try {
const token = env.GITHUB_TOKEN
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'SimStudio/1.0',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
const response = await fetch(
'https://api.github.com/repos/simstudioai/sim/commits?per_page=100',
{
headers,
next: { revalidate: 3600 },
}
)
if (!response.ok) {
console.error('GitHub API error fetching commits')
return []
}
const commits = await response.json()
return commits || []
} catch (error) {
console.error('Error fetching commits:', error)
return []
}
}

View File

@@ -1,58 +0,0 @@
import { useId } from 'react'
import { cn } from '@/lib/utils'
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
width?: number
height?: number
x?: number
y?: number
squares?: Array<[x: number, y: number]>
strokeDasharray?: string
className?: string
[key: string]: unknown
}
export function GridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = '0',
squares,
className,
...props
}: GridPatternProps) {
const id = useId()
return (
<svg
aria-hidden='true'
className={cn(
'pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30',
className
)}
{...props}
>
<defs>
<pattern id={id} width={width} height={height} patternUnits='userSpaceOnUse' x={x} y={y}>
<path d={`M.5 ${height}V.5H${width}`} fill='none' strokeDasharray={strokeDasharray} />
</pattern>
</defs>
<rect width='100%' height='100%' strokeWidth={0} fill={`url(#${id})`} />
{squares && (
<svg x={x} y={y} className='overflow-visible'>
{squares.map(([x, y]) => (
<rect
strokeWidth='0'
key={`${x}-${y}`}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
/>
))}
</svg>
)}
</svg>
)
}

View File

@@ -1,64 +0,0 @@
import React from 'react'
import { cn } from '@/lib/utils'
export interface OrbitingCirclesProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string
children?: React.ReactNode
reverse?: boolean
duration?: number
delay?: number
radius?: number
path?: boolean
iconSize?: number
speed?: number
}
export function OrbitingCircles({
className,
children,
reverse,
duration = 20,
radius = 160,
path = true,
iconSize = 30,
speed = 1,
...props
}: OrbitingCirclesProps) {
const calculatedDuration = duration / speed
return (
<>
{path && (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
className='pointer-events-none absolute inset-0 size-full'
>
<circle className='stroke-1 stroke-white/10' cx='50%' cy='50%' r={radius} fill='none' />
</svg>
)}
{React.Children.map(children, (child, index) => {
const angle = (360 / React.Children.count(children)) * index
return (
<div
style={
{
'--duration': calculatedDuration,
'--radius': radius,
'--angle': angle,
'--icon-size': `${iconSize}px`,
} as React.CSSProperties
}
className={cn(
'absolute flex size-[var(--icon-size)] transform-gpu animate-orbit items-center justify-center rounded-full',
{ '[animation-direction:reverse]': reverse },
className
)}
{...props}
>
{child}
</div>
)
})}
</>
)
}

View File

@@ -1,152 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Command, CornerDownLeft } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { useSession } from '@/lib/auth-client'
import { GridPattern } from '../grid-pattern'
import HeroWorkflowProvider from '../hero-workflow'
function Hero() {
const router = useRouter()
const [isTransitioning, setIsTransitioning] = useState(true)
const { data: session, isPending } = useSession()
const isAuthenticated = !isPending && !!session?.user
const handleNavigate = () => {
if (typeof window !== 'undefined') {
// Check if user has an active session
if (isAuthenticated) {
router.push('/workspace')
} else {
// Check if user has logged in before
const hasLoggedInBefore =
localStorage.getItem('has_logged_in_before') === 'true' ||
document.cookie.includes('has_logged_in_before=true')
if (hasLoggedInBefore) {
// User has logged in before but doesn't have an active session
router.push('/login')
} else {
// User has never logged in before
router.push('/signup')
}
}
}
}
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
handleNavigate()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isAuthenticated])
useEffect(() => {
const timer = setTimeout(() => {
setIsTransitioning(false)
}, 300) // Reduced delay for faster button appearance
return () => clearTimeout(timer)
}, [])
const renderActionUI = () => {
if (isTransitioning || isPending) {
return <div className='h-[56px] md:h-[64px]' />
}
return (
<Button
variant={'secondary'}
onClick={handleNavigate}
className='animate-fade-in items-center bg-[#701ffc] px-7 py-6 font-[420] font-geist-sans text-lg text-neutral-100 tracking-normal shadow-[#701ffc]/30 shadow-lg hover:bg-[#802FFF]'
aria-label='Start using the platform'
>
<div className='text-[1.15rem]'>Start now</div>
<div className='flex items-center gap-1 pl-2 opacity-80' aria-hidden='true'>
<Command size={24} />
<CornerDownLeft />
</div>
</Button>
)
}
return (
<section
className='animation-container relative min-h-screen overflow-hidden border-[#181818] border-b pt-28 text-white will-change-[opacity,transform] sm:pt-32 md:pt-40'
aria-label='Main hero section'
>
<GridPattern
x={-5}
y={-5}
className='absolute inset-0 z-0 stroke-[#ababab]/5'
width={90}
height={90}
aria-hidden='true'
/>
{/* Centered black background behind text and button */}
<div
className='-translate-x-1/2 -translate-y-1/2 absolute top-[28%] left-1/2 w-[95%] md:top-[38%] md:w-[60%] lg:w-[50%]'
aria-hidden='true'
>
<svg
width='100%'
height='100%'
viewBox='0 0 600 480'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
aria-hidden='true'
className='aspect-[5/3] h-auto md:aspect-auto'
>
<g filter='url(#filter0_b_0_1)'>
<ellipse cx='300' cy='240' rx='290' ry='220' fill='#0C0C0C' />
</g>
<defs>
<filter
id='filter0_b_0_1'
x='0'
y='10'
width='600'
height='460'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feGaussianBlur stdDeviation='5' />
</filter>
</defs>
</svg>
</div>
<div
className='absolute inset-0 z-10 flex h-full items-center justify-center'
aria-hidden='true'
>
<HeroWorkflowProvider />
</div>
<div className='animation-container relative z-20 space-y-4 px-4 text-center'>
<h1 className='animation-container animate-fade-up font-semibold text-[42px] leading-[1.10] opacity-0 will-change-[opacity,transform] [animation-delay:200ms] md:text-[68px]'>
Build / Deploy
<br />
Agent Workflows
</h1>
<p className='animation-container mx-auto max-w-3xl animate-fade-up font-normal text-base text-neutral-400/80 leading-[1.5] tracking-normal opacity-0 will-change-[opacity,transform] [animation-delay:400ms] md:text-xl'>
Launch agentic workflows with an open source, <br />
user-friendly environment for devs and agents
</p>
<div className='animation-container translate-y-[-10px] animate-fade-up pt-4 pb-10 opacity-0 will-change-[opacity,transform] [animation-delay:600ms]'>
{renderActionUI()}
</div>
</div>
</section>
)
}
export default Hero

View File

@@ -1,857 +0,0 @@
'use client'
import { motion } from 'framer-motion'
import { GitBranch, RefreshCcw } from 'lucide-react'
import ReactFlow, { ConnectionLineType, Position, ReactFlowProvider } from 'reactflow'
import { OrbitingCircles } from '@/app/(landing)/components/magicui/orbiting-circles'
import { DotPattern } from '../dot-pattern'
import { HeroBlock } from '../hero-block'
function Integrations() {
return (
<section className='flex w-full flex-col gap-10 px-8 py-12 md:px-16 lg:px-28 xl:px-32'>
<div className='flex flex-col gap-5'>
<motion.p
className='font-medium text-[42px] text-white leading-none tracking-normal md:text-5xl md:leading-tight'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.05, ease: 'easeOut' }}
>
Everything you need,
<br />
connected
</motion.p>
<motion.p
className='max-w-md font-light text-white/60 text-xl tracking-normal'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.15, ease: 'easeOut' }}
>
Seamlessly connect your agents with the tools you already useno extra setup required.
</motion.p>
</div>
{/* Desktop view */}
<div className='relative z-10 hidden min-h-[36rem] w-full items-center justify-center overflow-hidden rounded-3xl border border-[#606060]/30 bg-[#0f0f0f] md:flex'>
<DotPattern className='rounded-3xl opacity-10' x={-5} y={-5} />
<div className='-translate-x-1/2 absolute bottom-0 left-1/2'>
<svg
width='800'
height='450'
viewBox='0 0 1076 623'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g filter='url(#filter0_f_113_56)'>
<path
d='M278.98 498.157L657.323 493.454L573.161 1204.36L499.788 1191.21L278.98 498.157Z'
fill='url(#paint0_linear_113_56)'
/>
</g>
<g filter='url(#filter1_f_113_56)'>
<path
d='M396.125 258.957L774.468 254.254L690.306 965.155L616.933 952.012L396.125 258.957Z'
fill='url(#paint1_linear_113_56)'
/>
</g>
<g filter='url(#filter2_f_113_56)'>
<path
d='M357.731 305.714L604.127 503.599L628.26 978.086L578.913 929.443L357.731 305.714Z'
fill='url(#paint2_linear_113_56)'
/>
</g>
<g filter='url(#filter3_f_113_56)'>
<path
d='M534.274 220.998L736.222 455.766L755.909 905.149L715.466 849.688L534.274 220.998Z'
fill='url(#paint3_linear_113_56)'
/>
</g>
<defs>
<filter
id='filter0_f_113_56'
x='-21.02'
y='193.454'
width='978.342'
height='1310.9'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feFlood floodOpacity={0} result='BackgroundImageFix' />
<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />
<feGaussianBlur stdDeviation='150' result='effect1_foregroundBlur_113_56' />
</filter>
<filter
id='filter1_f_113_56'
x='96.125'
y='-45.7463'
width='978.342'
height='1310.9'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feFlood floodOpacity='0' result='BackgroundImageFix' />
<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />
<feGaussianBlur stdDeviation='150' result='effect1_foregroundBlur_113_56' />
</filter>
<filter
id='filter2_f_113_56'
x='257.731'
y='205.714'
width='470.529'
height='872.372'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feFlood floodOpacity='0' result='BackgroundImageFix' />
<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />
<feGaussianBlur stdDeviation='50' result='effect1_foregroundBlur_113_56' />
</filter>
<filter
id='filter3_f_113_56'
x='434.274'
y='120.998'
width='421.636'
height='884.151'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feFlood floodOpacity='0' result='BackgroundImageFix' />
<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />
<feGaussianBlur stdDeviation='50' result='effect1_foregroundBlur_113_56' />
</filter>
<linearGradient
id='paint0_linear_113_56'
x1='451.681'
y1='1151.32'
x2='661.061'
y2='557.954'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#9C75D7' />
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
</linearGradient>
<linearGradient
id='paint1_linear_113_56'
x1='568.826'
y1='912.119'
x2='778.206'
y2='318.753'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#9C75D7' />
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
</linearGradient>
<linearGradient
id='paint2_linear_113_56'
x1='543.08'
y1='874.705'
x2='742.662'
y2='699.882'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#9C75D7' />
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
</linearGradient>
<linearGradient
id='paint3_linear_113_56'
x1='686.102'
y1='791.225'
x2='858.04'
y2='680.269'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#9C75D7' />
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
</linearGradient>
</defs>
</svg>
</div>
<OrbitingCircles radius={160}>
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.pinecone />
</div>
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.slack />
</div>
</OrbitingCircles>
<OrbitingCircles iconSize={40} radius={320} reverse>
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-2 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.gitHub />
</div>
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.supabase />
</div>
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.perplexity />
</div>
</OrbitingCircles>
<OrbitingCircles iconSize={40} radius={480}>
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-2 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.youtube />
</div>
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.reddit />
</div>
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.notion />
</div>
</OrbitingCircles>
</div>
{/* Mobile view */}
<div className='relative z-10 flex min-h-[28rem] w-full items-center justify-center overflow-hidden rounded-3xl border border-[#606060]/30 bg-[#0f0f0f] md:hidden'>
<DotPattern className='rounded-3xl opacity-10' x={-5} y={-5} />
<div className='absolute inset-0 z-0 flex items-center justify-center'>
<div className='-translate-x-1/2 absolute bottom-[-80px] left-[45%] w-[130%]'>
<svg
width='100%'
height='350'
viewBox='0 0 600 450'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
>
<path
d='M180 150L380 150L350 380L220 365L180 150Z'
fill='url(#mobile_paint0)'
filter='url(#mobile_filter0)'
/>
<path
d='M220 70L420 70L390 300L260 285L220 70Z'
fill='url(#mobile_paint1)'
filter='url(#mobile_filter1)'
/>
<defs>
<filter
id='mobile_filter0'
x='100'
y='70'
width='360'
height='390'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feFlood floodOpacity='0' result='BackgroundImageFix' />
<feBlend
mode='normal'
in='SourceGraphic'
in2='BackgroundImageFix'
result='shape'
/>
<feGaussianBlur stdDeviation='35' result='effect1_foregroundBlur' />
</filter>
<filter
id='mobile_filter1'
x='140'
y='-10'
width='360'
height='390'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feFlood floodOpacity='0' result='BackgroundImageFix' />
<feBlend
mode='normal'
in='SourceGraphic'
in2='BackgroundImageFix'
result='shape'
/>
<feGaussianBlur stdDeviation='35' result='effect1_foregroundBlur' />
</filter>
<linearGradient
id='mobile_paint0'
x1='280'
y1='360'
x2='370'
y2='160'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#9C75D7' stopOpacity='0.4' />
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
</linearGradient>
<linearGradient
id='mobile_paint1'
x1='320'
y1='280'
x2='410'
y2='80'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#9C75D7' stopOpacity='0.9' />
<stop offset='1' stopColor='#9C75D7' stopOpacity='0' />
</linearGradient>
</defs>
</svg>
</div>
</div>
<OrbitingCircles radius={100}>
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.pinecone />
</div>
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.slack />
</div>
</OrbitingCircles>
<OrbitingCircles iconSize={32} radius={180} reverse>
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.gitHub />
</div>
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
<Icons.notion />
</div>
</OrbitingCircles>
</div>
<div className='relative flex w-full flex-col gap-20 text-white lg:flex-row'>
<div className='flex w-full flex-col gap-8'>
<div className='flex flex-col gap-6'>
<motion.div
className='flex items-center gap-6'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.1, ease: 'easeOut' }}
>
<RefreshCcw size={24} />
<span className='text-2xl'>Sync Knowledge in Seconds</span>
</motion.div>
<motion.p
className='max-w-lg font-light text-lg text-white/60'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.18, ease: 'easeOut' }}
>
Import data from your favorite tools to power your AI agents&apos; knowledge basesno
manual uploads needed.
</motion.p>
</div>
<div className='relative z-10 flex h-80 w-full items-center justify-center overflow-hidden rounded-3xl border border-[#606060]/30 bg-[#0f0f0f]'>
<DotPattern className='z-0 rounded-3xl opacity-10' x={-5} y={-5} />
<motion.div
className='z-10 flex h-full w-full justify-end'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.2, ease: 'easeOut' }}
>
<ReactFlowProvider>
<ReactFlow
nodes={[
{
id: 'agent1',
type: 'heroBlock',
position: { x: 50, y: 100 },
data: { type: 'agent' },
sourcePosition: Position.Right,
targetPosition: Position.Left,
},
{
id: 'slack1',
type: 'heroBlock',
position: { x: 450, y: -30 },
data: { type: 'slack' },
sourcePosition: Position.Left,
targetPosition: Position.Right,
},
]}
edges={[
{
id: 'agent1-slack1',
source: 'agent1',
target: 'slack1',
type: 'smoothstep',
style: { stroke: '#404040', strokeWidth: 1.5, strokeDasharray: '4 4' },
animated: true,
},
]}
nodeTypes={{ heroBlock: HeroBlock }}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{
stroke: '#404040',
strokeWidth: 1.5,
strokeDasharray: '4 4',
}}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnScroll={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
panOnDrag={false}
selectionOnDrag={false}
preventScrolling={true}
proOptions={{ hideAttribution: true }}
className='pointer-events-none h-full w-full'
style={{ width: '100%', height: '100%' }}
/>
</ReactFlowProvider>
</motion.div>
</div>
</div>
<div className='flex w-full flex-col gap-8'>
<div className='flex flex-col gap-6'>
<motion.div
className='flex items-center gap-6'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.2, ease: 'easeOut' }}
>
<GitBranch size={24} />
<span className='text-2xl'>Automate Workflows with Ease</span>
</motion.div>
<motion.p
className='max-w-lg font-light text-lg text-white/60'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.28, ease: 'easeOut' }}
>
Trigger actions and automate tasks across your apps with pre-built integrations.
</motion.p>
</div>
<div className='relative z-10 flex h-80 w-full items-center justify-center overflow-hidden rounded-3xl border border-[#606060]/30 bg-[#0f0f0f]'>
<DotPattern className='z-0 rounded-3xl opacity-10' x={-5} y={-5} />
<motion.div
className='z-10 flex h-full w-full justify-end'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.4, ease: 'easeOut' }}
>
<ReactFlowProvider>
<ReactFlow
nodes={[
{
id: 'start',
type: 'heroBlock',
position: { x: 50, y: 120 },
data: { type: 'start' },
sourcePosition: Position.Right,
targetPosition: Position.Left,
},
{
id: 'function1',
type: 'heroBlock',
position: { x: 450, y: 80 },
data: { type: 'function', isHeroSection: false },
sourcePosition: Position.Right,
targetPosition: Position.Left,
},
]}
edges={[
{
id: 'start-func1',
source: 'start',
target: 'function1',
type: 'smoothstep',
style: { stroke: '#404040', strokeWidth: 1.5, strokeDasharray: '4 4' },
animated: true,
},
]}
nodeTypes={{ heroBlock: HeroBlock }}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{
stroke: '#404040',
strokeWidth: 1.5,
strokeDasharray: '4 4',
}}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnScroll={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
panOnDrag={false}
selectionOnDrag={false}
preventScrolling={true}
proOptions={{ hideAttribution: true }}
className='pointer-events-none h-full w-full'
style={{ width: '100%', height: '100%' }}
/>
</ReactFlowProvider>
</motion.div>
</div>
</div>
</div>
</section>
)
}
const Icons = {
gitHub: () => (
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clipPath='url(#clip0_82_6269)'>
<path
d='M24.0492 0C10.7213 0 0 11 0 24.6C0 35.5 6.88525 44.7 16.4262 47.95C17.6066 48.2 18.0492 47.4 18.0492 46.75C18.0492 46.2 18 44.2 18 42.2C11.3115 43.65 9.93443 39.25 9.93443 39.25C8.85246 36.4 7.27869 35.65 7.27869 35.65C5.11475 34.15 7.42623 34.15 7.42623 34.15C9.83607 34.3 11.1148 36.7 11.1148 36.7C13.2787 40.45 16.7213 39.4 18.0984 38.75C18.2951 37.15 18.9344 36.05 19.623 35.45C14.3115 34.9 8.70492 32.75 8.70492 23.3C8.70492 20.6 9.63935 18.4 11.1639 16.7C10.918 16.1 10.082 13.55 11.4098 10.2C11.4098 10.2 13.4262 9.55 18 12.75C19.918 12.2 21.9836 11.95 24 11.95C26.0164 11.95 28.082 12.25 30 12.75C34.5738 9.55 36.5902 10.2 36.5902 10.2C37.918 13.6 37.082 16.1 36.8361 16.7C38.4098 18.4 39.2951 20.6 39.2951 23.3C39.2951 32.75 33.6885 34.85 28.3279 35.45C29.2131 36.2 29.9508 37.7 29.9508 40C29.9508 43.3 29.9016 45.95 29.9016 46.75C29.9016 47.4 30.3443 48.2 31.5246 47.95C41.1148 44.7 48 35.5 48 24.6C48.0492 11 37.2787 0 24.0492 0Z'
fill='white'
/>
</g>
<defs>
<clipPath id='clip0_82_6269'>
<rect width='48' height='48' fill='white' />
</clipPath>
</defs>
</svg>
),
pinecone: () => (
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M30.3124 0.546056C29.7865 -0.0256784 28.9098 -0.162895 28.2085 0.203015L27.5573 0.523187L20.469 4.20515L22.1221 6.858L26.7307 4.45672L25.6036 10.0826L28.8848 10.6314L30.0369 4.98271L33.4433 8.68755L35.9981 6.72079L30.8384 1.09492H30.8134L30.3124 0.546056ZM20.3688 48C22.072 48 23.4496 46.7651 23.4496 45.2557C23.4496 43.7463 22.072 42.5113 20.3688 42.5113C18.6656 42.5113 17.2881 43.7463 17.2881 45.2557C17.263 46.7651 18.6656 48 20.3688 48ZM24.5016 32.9291L23.3995 38.5778L20.0933 38.029L21.1954 32.4031L16.5867 34.8272L14.9086 32.1744L21.9468 28.4924L22.598 28.1494C23.2993 27.7835 24.176 27.9207 24.7019 28.4924L25.2029 29.0413L30.4377 34.6443L27.8829 36.6339L24.5016 32.9291ZM27.1816 19.4362L26.0795 25.0849L22.7733 24.536L23.8754 18.933L19.2918 21.3343L17.6387 18.6815L24.6518 15.0224V14.9766H24.7019L25.3532 14.6336C26.0545 14.2677 26.9311 14.4049 27.4571 14.9766L27.958 15.5026L33.1678 21.1285L30.613 23.1181L27.1816 19.4362ZM5.56612 41.7567H5.54107L4.8648 41.5737C4.13844 41.3908 3.66255 40.7504 3.71265 40.0643L4.31377 32.426L7.49473 32.6318L7.11902 37.2743L12.0533 34.2098L13.8316 36.6111L8.99754 39.6069L13.9318 40.9105L13.0551 43.7006L5.56612 41.7567ZM38.3024 44.9126L38.077 45.5758C37.8516 46.2162 37.2003 46.6507 36.4489 46.605L35.7476 46.5592L35.6975 46.5821L35.6725 46.5592L27.9079 46.079L28.1083 43.1746L33.268 43.4947L29.8866 39.1724L32.4665 37.4801L35.9229 41.9167L37.4258 37.4343L40.4564 38.2805L38.3024 44.9126ZM47.4195 29.1785L47.7952 29.796C48.1709 30.4135 48.0206 31.191 47.4195 31.6484L46.8684 32.0829V32.1058H46.8434L40.8071 36.7711L38.7032 34.5071L42.6606 31.4426L36.7244 30.4821L37.3005 27.5548L43.2867 28.5153L40.782 24.3988L43.6123 22.958L47.4195 29.1785ZM41.283 16.4174L35.9229 19.0474L34.37 16.4403L39.6549 13.856L34.8209 12.0493L36.0482 9.30503L43.412 12.0265L43.437 12.0036L43.4621 12.0493L44.1383 12.3009C44.8647 12.5753 45.2654 13.2614 45.1402 13.9475L45.015 14.6336L43.6374 21.6087L40.4314 21.0828L41.283 16.4174ZM5.31565 22.5464L11.2768 23.4612L10.7258 26.3884L4.71452 25.4508L7.26931 29.5673L4.43901 31.0309L0.581787 24.8333L0.206084 24.2387C-0.16962 23.6213 -0.0193384 22.8437 0.55674 22.3863L1.10777 21.9518V21.9289H1.13282L7.09398 17.2407L9.22296 19.5048L5.31565 22.5464ZM14.3075 9.87676L18.2649 13.9018L15.8353 15.8914L11.7777 11.7749L10.851 16.4631L7.64501 15.9371L9.04764 8.98485L9.19792 8.2759C9.32315 7.58982 9.97437 7.0867 10.7258 7.06383L11.4271 7.04096L11.4521 7.01809L11.4772 7.04096L19.4421 6.74366L19.5673 9.71667L14.3075 9.87676Z'
fill='white'
/>
</svg>
),
slack: () => (
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clipPath='url(#clip0_82_6239)'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M17.599 0C14.9456 0.00195719 12.7982 2.15095 12.8001 4.79902C12.7982 7.44709 14.9475 9.59609 17.6009 9.59804H22.4017V4.80098C22.4037 2.15291 20.2543 0.00391437 17.599 0C17.6009 0 17.6009 0 17.599 0ZM17.599 12.8H4.80079C2.14741 12.802 -0.00195575 14.9509 5.35946e-06 17.599C-0.00391685 20.2471 2.14545 22.3961 4.79883 22.4H17.599C20.2523 22.398 22.4017 20.2491 22.3997 17.601C22.4017 14.9509 20.2523 12.802 17.599 12.8Z'
fill='#36C5F0'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M47.9998 17.599C48.0018 14.9509 45.8524 12.802 43.1991 12.8C40.5457 12.802 38.3963 14.9509 38.3983 17.599V22.4H43.1991C45.8524 22.398 48.0018 20.2491 47.9998 17.599ZM35.1997 17.599V4.79902C35.2017 2.15291 33.0543 0.00391437 30.4009 0C27.7475 0.00195719 25.5981 2.15095 25.6001 4.79902V17.599C25.5962 20.2471 27.7456 22.3961 30.3989 22.4C33.0523 22.398 35.2017 20.2491 35.1997 17.599Z'
fill='#2EB67D'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M30.3989 48.0001C33.0523 47.9981 35.2017 45.8492 35.1997 43.2011C35.2017 40.553 33.0523 38.404 30.3989 38.4021H25.5981V43.2011C25.5962 45.8472 27.7456 47.9962 30.3989 48.0001ZM30.3989 35.1981H43.1991C45.8524 35.1962 48.0018 33.0472 47.9998 30.3991C48.0038 27.751 45.8544 25.6021 43.201 25.5981H30.4009C27.7475 25.6001 25.5981 27.7491 25.6001 30.3972C25.5981 33.0472 27.7456 35.1962 30.3989 35.1981Z'
fill='#ECB22E'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M1.34093e-06 30.3991C-0.00195976 33.0472 2.14741 35.1962 4.80079 35.1981C7.45416 35.1962 9.60353 33.0472 9.60157 30.3991V25.6001H4.80079C2.14741 25.6021 -0.00195976 27.751 1.34093e-06 30.3991ZM12.8001 30.3991V43.1991C12.7962 45.8472 14.9456 47.9962 17.599 48.0001C20.2523 47.9981 22.4017 45.8491 22.3997 43.2011V30.403C22.4037 27.755 20.2543 25.606 17.6009 25.6021C14.9456 25.6021 12.7982 27.751 12.8001 30.3991Z'
fill='#E01E5A'
/>
</g>
<defs>
<clipPath id='clip0_82_6239'>
<rect width='48' height='48' fill='white' />
</clipPath>
</defs>
</svg>
),
perplexity: () => (
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M24 4.5V43.5M13.73 16.573V6.583L24 16.573M24 16.573L13.73 27.01V41.417L24 31.073M24 16.573L34.27 6.583V16.573'
stroke='#20808D'
strokeWidth='1.66667'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M13.7299 31.396H9.43994V16.573H38.5599V31.396H34.2699'
stroke='#20808D'
strokeWidth='1.66667'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M24 16.573L34.27 27.01V41.417L24 31.073'
stroke='#20808D'
strokeWidth='1.66667'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
),
supabase: () => (
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clipPath='url(#clip0_82_6260)'>
<path
d='M28.0545 46.8463C26.7953 48.376 24.2421 47.5379 24.2117 45.5847L23.7681 17.0178H43.6813C47.2882 17.0178 49.2997 21.0363 47.057 23.7611L28.0545 46.8463Z'
fill='url(#paint0_linear_82_6260)'
/>
<path
d='M28.0545 46.8463C26.7953 48.376 24.2421 47.5379 24.2117 45.5847L23.7681 17.0178H43.6813C47.2882 17.0178 49.2997 21.0363 47.057 23.7611L28.0545 46.8463Z'
fill='url(#paint1_linear_82_6260)'
fillOpacity='0.2'
/>
<path
d='M19.956 0.879624C21.2152 -0.650174 23.7685 0.188045 23.7988 2.1412L23.9932 30.7081H4.32919C0.722252 30.7081 -1.2894 26.6896 0.953498 23.9648L19.956 0.879624Z'
fill='#3ECF8E'
/>
</g>
<defs>
<linearGradient
id='paint0_linear_82_6260'
x1='23.7681'
y1='23.3518'
x2='41.2706'
y2='30.9617'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#249361' />
<stop offset='1' stopColor='#3ECF8E' />
</linearGradient>
<linearGradient
id='paint1_linear_82_6260'
x1='15.9216'
y1='12.9889'
x2='23.5483'
y2='27.8727'
gradientUnits='userSpaceOnUse'
>
<stop />
<stop offset='1' stopOpacity='0' />
</linearGradient>
<clipPath id='clip0_82_6260'>
<rect width='48' height='48' fill='white' />
</clipPath>
</defs>
</svg>
),
notion: () => (
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clipPath='url(#clip0_82_6265)'>
<path
d='M3.01725 2.0665L30.7669 0.108884C34.1754 -0.170519 35.0513 0.0178987 37.1944 1.50608L46.0524 7.46703C47.5136 8.49205 48 8.77163 48 9.88781V42.5792C48 44.628 47.2209 45.8398 44.4945 46.0252L12.27 47.8886C10.2238 47.9812 9.24938 47.7018 8.17781 46.3972L1.65488 38.295C0.484875 36.8036 0 35.6873 0 34.3827V5.32405C0 3.64906 0.779062 2.25187 3.01725 2.0665Z'
fill='white'
/>
<path
d='M30.7669 0.108884L3.01725 2.0665C0.779062 2.25187 0 3.64906 0 5.32405V34.3829C0 35.6875 0.484688 36.8036 1.65488 38.295L8.17781 46.3972C9.24938 47.7018 10.2238 47.9812 12.27 47.8886L44.4945 46.0252C47.2192 45.8398 48 44.628 48 42.5792V9.88781C48 8.82912 47.562 8.52411 46.2731 7.62035L46.0509 7.46703L37.1944 1.50608C35.0513 0.0178987 34.1756 -0.170519 30.7669 0.108884ZM12.9988 9.35282C10.3676 9.52208 9.77081 9.56041 8.27644 8.39945L4.47675 5.51247C4.0905 5.13885 4.28437 4.67247 5.25731 4.57987L31.9337 2.71808C34.1738 2.53127 35.3406 3.27688 36.2166 3.92847L40.7917 7.09503C40.9873 7.18906 41.4739 7.74644 40.8887 7.74644L13.3399 9.33044L12.9988 9.35282ZM9.93131 42.2998V14.5472C9.93131 13.3352 10.3207 12.7764 11.4876 12.6822L43.1287 10.9128C44.202 10.8202 44.6869 11.4716 44.6869 12.6822V40.2494C44.6869 41.4614 44.4911 42.4864 42.7393 42.5792L12.4605 44.2558C10.7087 44.3484 9.93131 43.7912 9.93131 42.2998ZM39.8207 16.0352C40.0146 16.8736 39.8207 17.712 38.943 17.8078L37.4837 18.084V38.5743C36.2166 39.2257 35.0498 39.5978 34.0749 39.5978C32.5172 39.5978 32.1276 39.1315 30.9607 37.7359L21.4174 23.3934V37.2697L24.4361 37.9227C24.4361 37.9227 24.4361 39.5995 22.0007 39.5995L15.2856 39.9715C15.09 39.5978 15.2856 38.6669 15.9662 38.4817L17.7195 38.0169V19.6698L15.2858 19.4814C15.09 18.6432 15.5764 17.4326 16.9406 17.3384L24.1455 16.8754L34.0751 31.4031V18.5506L31.5442 18.2726C31.3487 17.2458 32.1276 16.5002 33.1005 16.4092L39.8205 16.0354L39.8207 16.0352Z'
fill='black'
/>
</g>
<defs>
<clipPath id='clip0_82_6265'>
<rect width='48' height='48' fill='white' />
</clipPath>
</defs>
</svg>
),
reddit: () => (
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clipPath='url(#clip0_82_6246)'>
<path
d='M24 0C10.7444 0 0 10.7444 0 24C0 30.6267 2.68667 36.6267 7.02889 40.9711L2.45778 45.5422C1.55111 46.4489 2.19333 48 3.47556 48H24C37.2556 48 48 37.2556 48 24C48 10.7444 37.2556 0 24 0Z'
fill='#FF4500'
/>
<path
d='M37.6044 29.3778C40.6997 29.3778 43.2089 26.8686 43.2089 23.7734C43.2089 20.6781 40.6997 18.1689 37.6044 18.1689C34.5092 18.1689 32 20.6781 32 23.7734C32 26.8686 34.5092 29.3778 37.6044 29.3778Z'
fill='url(#paint0_radial_82_6246)'
/>
<path
d='M10.3955 29.3778C13.4907 29.3778 15.9999 26.8686 15.9999 23.7734C15.9999 20.6781 13.4907 18.1689 10.3955 18.1689C7.30021 18.1689 4.79102 20.6781 4.79102 23.7734C4.79102 26.8686 7.30021 29.3778 10.3955 29.3778Z'
fill='url(#paint1_radial_82_6246)'
/>
<path
d='M24.0132 40.5867C32.8497 40.5867 40.0132 35.2141 40.0132 28.5867C40.0132 21.9593 32.8497 16.5867 24.0132 16.5867C15.1766 16.5867 8.01318 21.9593 8.01318 28.5867C8.01318 35.2141 15.1766 40.5867 24.0132 40.5867Z'
fill='url(#paint2_radial_82_6246)'
/>
<path
d='M19.2843 27.44C19.191 29.4578 17.8421 30.1911 16.271 30.1911C14.6999 30.1911 13.5021 29.0955 13.5954 27.0778C13.6888 25.06 15.0377 23.74 16.6088 23.74C18.1799 23.74 19.3777 25.4244 19.2843 27.4422V27.44Z'
fill='url(#paint3_radial_82_6246)'
/>
<path
d='M28.7444 27.44C28.8377 29.4578 30.1866 30.1911 31.7577 30.1911C33.3288 30.1911 34.5266 29.0955 34.4332 27.0778C34.3399 25.06 32.991 23.74 31.4199 23.74C29.8488 23.74 28.651 25.4244 28.7444 27.4422V27.44Z'
fill='url(#paint4_radial_82_6246)'
/>
<path
d='M17.6955 26.5377C18.0391 26.5377 18.3177 26.2342 18.3177 25.8599C18.3177 25.4856 18.0391 25.1821 17.6955 25.1821C17.3518 25.1821 17.0732 25.4856 17.0732 25.8599C17.0732 26.2342 17.3518 26.5377 17.6955 26.5377Z'
fill='#FFC49C'
/>
<path
d='M32.4909 26.5377C32.8345 26.5377 33.1131 26.2342 33.1131 25.8599C33.1131 25.4856 32.8345 25.1821 32.4909 25.1821C32.1472 25.1821 31.8687 25.4856 31.8687 25.8599C31.8687 26.2342 32.1472 26.5377 32.4909 26.5377Z'
fill='#FFC49C'
/>
<path
d='M24.0133 31.76C22.0666 31.76 20.2 31.8556 18.4755 32.0311C18.18 32.06 17.9933 32.3667 18.1088 32.64C19.0755 34.9489 21.3555 36.5711 24.0133 36.5711C26.6711 36.5711 28.9533 34.9489 29.9177 32.64C30.0333 32.3667 29.8444 32.06 29.5511 32.0311C27.8244 31.8556 25.96 31.76 24.0133 31.76Z'
fill='url(#paint5_radial_82_6246)'
/>
<path
d='M32.7758 14.9557C34.969 14.9557 36.7469 13.1777 36.7469 10.9845C36.7469 8.79135 34.969 7.01343 32.7758 7.01343C30.5826 7.01343 28.8047 8.79135 28.8047 10.9845C28.8047 13.1777 30.5826 14.9557 32.7758 14.9557Z'
fill='url(#paint6_radial_82_6246)'
/>
<path
d='M23.9557 17.0933C23.4801 17.0933 23.0957 16.8955 23.0957 16.5888C23.0957 13.0311 25.9913 10.1355 29.549 10.1355C30.0246 10.1355 30.409 10.5199 30.409 10.9955C30.409 11.4711 30.0246 11.8555 29.549 11.8555C26.9401 11.8555 24.8179 13.9777 24.8179 16.5866C24.8179 16.8933 24.4335 17.0911 23.9579 17.0911L23.9557 17.0933Z'
fill='url(#paint7_radial_82_6246)'
/>
<path
d='M13.9599 27.2555C14.0465 25.3533 15.311 24.1089 16.7799 24.1089C18.171 24.1089 19.2465 25.5289 19.2865 27.2933C19.3243 25.32 18.1465 23.74 16.6088 23.74C15.071 23.74 13.6888 25.0844 13.5954 27.1178C13.5021 29.1511 14.6999 30.1911 16.271 30.1911H16.3865C14.9554 30.1555 13.8754 29.1267 13.9621 27.2578L13.9599 27.2555ZM34.0665 27.2555C33.9799 25.3533 32.7154 24.1089 31.2465 24.1089C29.8554 24.1089 28.7799 25.5289 28.7399 27.2933C28.7021 25.32 29.8799 23.74 31.4177 23.74C32.9888 23.74 34.3377 25.0844 34.431 27.1178C34.5243 29.1511 33.3265 30.1911 31.7554 30.1911H31.6399C33.071 30.1555 34.151 29.1267 34.0643 27.2578L34.0665 27.2555Z'
fill='#842123'
/>
</g>
<defs>
<radialGradient
id='paint0_radial_82_6246'
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(37.7222 20.4101) scale(11.3289 9.85613)'
>
<stop stopColor='#FEFFFF' />
<stop offset='0.4' stopColor='#FEFFFF' />
<stop offset='0.51' stopColor='#F9FCFC' />
<stop offset='0.62' stopColor='#EDF3F5' />
<stop offset='0.7' stopColor='#DEE9EC' />
<stop offset='0.72' stopColor='#D8E4E8' />
<stop offset='0.76' stopColor='#CCD8DF' />
<stop offset='0.8' stopColor='#C8D5DD' />
<stop offset='0.83' stopColor='#CCD6DE' />
<stop offset='0.85' stopColor='#D8DBE2' />
<stop offset='0.88' stopColor='#EDE3E9' />
<stop offset='0.9' stopColor='#FFEBEF' />
</radialGradient>
<radialGradient
id='paint1_radial_82_6246'
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(10.5132 2.68339) scale(11.3289 9.85613)'
>
<stop stopColor='#FEFFFF' />
<stop offset='0.4' stopColor='#FEFFFF' />
<stop offset='0.51' stopColor='#F9FCFC' />
<stop offset='0.62' stopColor='#EDF3F5' />
<stop offset='0.7' stopColor='#DEE9EC' />
<stop offset='0.72' stopColor='#D8E4E8' />
<stop offset='0.76' stopColor='#CCD8DF' />
<stop offset='0.8' stopColor='#C8D5DD' />
<stop offset='0.83' stopColor='#CCD6DE' />
<stop offset='0.85' stopColor='#D8DBE2' />
<stop offset='0.88' stopColor='#EDE3E9' />
<stop offset='0.9' stopColor='#FFEBEF' />
</radialGradient>
<radialGradient
id='paint2_radial_82_6246'
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(24.3576 18.994) scale(34.1733 23.9213)'
>
<stop stopColor='#FEFFFF' />
<stop offset='0.4' stopColor='#FEFFFF' />
<stop offset='0.51' stopColor='#F9FCFC' />
<stop offset='0.62' stopColor='#EDF3F5' />
<stop offset='0.7' stopColor='#DEE9EC' />
<stop offset='0.72' stopColor='#D8E4E8' />
<stop offset='0.76' stopColor='#CCD8DF' />
<stop offset='0.8' stopColor='#C8D5DD' />
<stop offset='0.83' stopColor='#CCD6DE' />
<stop offset='0.85' stopColor='#D8DBE2' />
<stop offset='0.88' stopColor='#EDE3E9' />
<stop offset='0.9' stopColor='#FFEBEF' />
</radialGradient>
<radialGradient
id='paint3_radial_82_6246'
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(16.5886 28.3364) scale(3.05544 4.42611)'
>
<stop stopColor='#FF6600' />
<stop offset='0.5' stopColor='#FF4500' />
<stop offset='0.7' stopColor='#FC4301' />
<stop offset='0.82' stopColor='#F43F07' />
<stop offset='0.92' stopColor='#E53812' />
<stop offset='1' stopColor='#D4301F' />
</radialGradient>
<radialGradient
id='paint4_radial_82_6246'
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(31.4596 28.3364) rotate(180) scale(3.05544 4.42611)'
>
<stop stopColor='#FF6600' />
<stop offset='0.5' stopColor='#FF4500' />
<stop offset='0.7' stopColor='#FC4301' />
<stop offset='0.82' stopColor='#F43F07' />
<stop offset='0.92' stopColor='#E53812' />
<stop offset='1' stopColor='#D4301F' />
</radialGradient>
<radialGradient
id='paint5_radial_82_6246'
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(23.9844 37.243) scale(10.0667 6.644)'
>
<stop stopColor='#172E35' />
<stop offset='0.29' stopColor='#0E1C21' />
<stop offset='0.73' stopColor='#030708' />
<stop offset='1' />
</radialGradient>
<radialGradient
id='paint6_radial_82_6246'
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(32.8625 7.29369) scale(8.83778 8.66102)'
>
<stop stopColor='#FEFFFF' />
<stop offset='0.4' stopColor='#FEFFFF' />
<stop offset='0.51' stopColor='#F9FCFC' />
<stop offset='0.62' stopColor='#EDF3F5' />
<stop offset='0.7' stopColor='#DEE9EC' />
<stop offset='0.72' stopColor='#D8E4E8' />
<stop offset='0.76' stopColor='#CCD8DF' />
<stop offset='0.8' stopColor='#C8D5DD' />
<stop offset='0.83' stopColor='#CCD6DE' />
<stop offset='0.85' stopColor='#D8DBE2' />
<stop offset='0.88' stopColor='#EDE3E9' />
<stop offset='0.9' stopColor='#FFEBEF' />
</radialGradient>
<radialGradient
id='paint7_radial_82_6246'
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(29.1801 16.2399) scale(7.24444)'
>
<stop offset='0.48' stopColor='#7A9299' />
<stop offset='0.67' stopColor='#172E35' />
<stop offset='0.75' />
<stop offset='0.82' stopColor='#172E35' />
</radialGradient>
<clipPath id='clip0_82_6246'>
<rect width='48' height='48' fill='white' />
</clipPath>
</defs>
</svg>
),
youtube: () => (
<svg width='48' height='34' viewBox='0 0 48 34' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clipPath='url(#clip0_82_6277)'>
<path
d='M46.9399 5.38906C46.6646 4.3716 46.1275 3.44402 45.3822 2.69868C44.6369 1.95333 43.7094 1.41624 42.6919 1.14087C38.967 0.125 23.9757 0.125 23.9757 0.125C23.9757 0.125 8.98354 0.15575 5.25866 1.17162C4.24119 1.44701 3.31362 1.98413 2.56831 2.72951C1.82299 3.47488 1.28595 4.40251 1.01066 5.42C-0.116026 12.0384 -0.553089 22.1232 1.0416 28.4769C1.31692 29.4943 1.85397 30.4219 2.59928 31.1673C3.34459 31.9126 4.27215 32.4497 5.2896 32.7251C9.01447 33.7409 24.0062 33.7409 24.0062 33.7409C24.0062 33.7409 38.9978 33.7409 42.7225 32.7251C43.74 32.4497 44.6676 31.9126 45.4129 31.1673C46.1582 30.422 46.6953 29.4944 46.9707 28.4769C48.159 21.8491 48.5252 11.7704 46.9399 5.38906Z'
fill='#FF0000'
/>
<path d='M19.2041 24.1362L31.6406 16.9329L19.2041 9.72949V24.1362Z' fill='white' />
</g>
<defs>
<clipPath id='clip0_82_6277'>
<rect width='48' height='33.75' fill='white' transform='translate(0 0.125)' />
</clipPath>
</defs>
</svg>
),
}
export default Integrations

View File

@@ -1,604 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import {
ChartAreaIcon,
GitFork,
GitGraph,
Github,
GitPullRequest,
LayoutGrid,
MessageCircle,
Star,
} from 'lucide-react'
import Image from 'next/image'
import {
Area,
AreaChart,
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { GridPattern } from '../components/grid-pattern'
import NavWrapper from '../components/nav-wrapper'
import Footer from '../components/sections/footer'
import { getCachedContributorsData, prefetchContributorsData } from '../utils/prefetch'
interface Contributor {
login: string
avatar_url: string
contributions: number
html_url: string
}
interface RepoStats {
stars: number
forks: number
watchers: number
openIssues: number
openPRs: number
}
interface CommitTimelineData {
date: string
commits: number
additions: number
deletions: number
}
interface ActivityData {
date: string
commits: number
issues: number
pullRequests: number
}
const excludedUsernames = ['dependabot[bot]', 'github-actions']
const ChartControls = ({
showAll,
setShowAll,
total,
}: {
showAll: boolean
setShowAll: (show: boolean) => void
total: number
}) => (
<div className='mb-4 flex items-center justify-between'>
<span className='text-neutral-400 text-sm'>
Showing {showAll ? 'all' : 'top 10'} contributors
</span>
<Button
variant='outline'
size='sm'
onClick={() => setShowAll(!showAll)}
className='border-[#606060]/30 bg-[#0f0f0f] text-neutral-300 text-xs backdrop-blur-sm hover:bg-neutral-700/50 hover:text-white'
>
Show {showAll ? 'less' : 'all'} ({total})
</Button>
</div>
)
export default function ContributorsPage() {
const [repoStats, setRepoStats] = useState<RepoStats>({
stars: 0,
forks: 0,
watchers: 0,
openIssues: 0,
openPRs: 0,
})
const [timelineData, setTimelineData] = useState<CommitTimelineData[]>([])
const [activityData, setActivityData] = useState<ActivityData[]>([])
const [showAllContributors, setShowAllContributors] = useState(false)
const [allContributors, setAllContributors] = useState<Contributor[]>([])
const handleOpenTypeformLink = () => {
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
}
useEffect(() => {
const loadData = async () => {
// First, try to get cached data
const cachedData = getCachedContributorsData()
if (cachedData) {
// Use cached data immediately
setAllContributors(cachedData.contributors)
setRepoStats(cachedData.repoStats)
setTimelineData(cachedData.timelineData)
setActivityData(cachedData.activityData)
} else {
// If no cached data, fetch it
try {
const data = await prefetchContributorsData()
setAllContributors(data.contributors)
setRepoStats(data.repoStats)
setTimelineData(data.timelineData)
setActivityData(data.activityData)
} catch (err) {
console.error('Error fetching data:', err)
// Set default values if fetch fails
setAllContributors([])
setRepoStats({
stars: 3867,
forks: 581,
watchers: 26,
openIssues: 23,
openPRs: 3,
})
setTimelineData([])
setActivityData([])
}
}
}
loadData()
}, [])
const filteredContributors = useMemo(
() =>
allContributors
?.filter((contributor) => !excludedUsernames.includes(contributor.login))
.sort((a, b) => b.contributions - a.contributions),
[allContributors]
)
return (
<main className='relative min-h-screen bg-[#0C0C0C] font-geist-sans text-white'>
{/* Grid pattern background */}
<div className='absolute inset-0 bottom-[400px] z-0'>
<GridPattern
x={-5}
y={-5}
className='absolute inset-0 stroke-[#ababab]/5'
width={90}
height={90}
aria-hidden='true'
/>
</div>
{/* Header/Navigation */}
<NavWrapper onOpenTypeformLink={handleOpenTypeformLink} />
{/* Content */}
<div className='relative z-10'>
{/* Hero Section with Integrated Stats */}
<section className='px-4 pt-20 pb-12 sm:px-8 sm:pt-28 sm:pb-16 md:px-16 md:pt-40 md:pb-24 lg:px-28 xl:px-32'>
<div className='mx-auto max-w-6xl'>
{/* Main Hero Content */}
<div className='mb-12 text-center sm:mb-16'>
<motion.h1
className='font-medium text-4xl text-white tracking-tight sm:text-5xl'
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: 'easeOut' }}
>
Contributors
</motion.h1>
<motion.p
className='mx-auto mt-3 max-w-2xl font-light text-lg text-neutral-400 sm:mt-4 sm:text-xl'
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
Meet the amazing people who have helped build and improve Sim Studio
</motion.p>
</div>
{/* Integrated Project Stats */}
<motion.div
className='overflow-hidden rounded-2xl border border-[#606060]/30 bg-[#0f0f0f] p-4 backdrop-blur-sm sm:rounded-3xl sm:p-8'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.1 }}
>
{/* Project Header */}
<div className='mb-6 flex flex-col items-start justify-between gap-3 sm:mb-8 sm:flex-row sm:items-center sm:gap-4'>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<div className='relative h-6 w-6 sm:h-8 sm:w-8'>
<Image
src='/favicon.ico'
alt='Sim Studio Logo'
className='object-contain'
width={32}
height={32}
/>
</div>
<h2 className='font-semibold text-lg text-white sm:text-xl'>Sim Studio</h2>
</div>
<p className='text-neutral-400 text-xs sm:text-sm'>
An open source platform for building, testing, and optimizing agentic workflows
</p>
</div>
<div className='flex gap-2 self-end sm:self-auto'>
<Button
asChild
variant='outline'
size='sm'
className='gap-1 border-[#606060]/30 bg-[#0f0f0f] text-neutral-300 text-xs backdrop-blur-sm hover:bg-neutral-700/50 hover:text-white sm:gap-2 sm:text-sm'
>
<a href='https://github.com/simstudioai/sim' target='_blank' rel='noopener'>
<Github className='h-3 w-3 sm:h-4 sm:w-4' />
<span className='hidden sm:inline'>View on GitHub</span>
<span className='sm:hidden'>GitHub</span>
</a>
</Button>
</div>
</div>
{/* Stats Grid - Mobile: 1 column, Tablet: 2 columns, Desktop: 5 columns */}
<div className='mb-6 grid grid-cols-1 gap-3 sm:mb-8 sm:grid-cols-2 sm:gap-4 lg:grid-cols-5'>
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<Star className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.stars}</div>
<div className='text-neutral-400 text-xs'>Stars</div>
</div>
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<GitFork className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.forks}</div>
<div className='text-neutral-400 text-xs'>Forks</div>
</div>
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<GitGraph className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>
{filteredContributors?.length || 0}
</div>
<div className='text-neutral-400 text-xs'>Contributors</div>
</div>
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<MessageCircle className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>
{repoStats.openIssues}
</div>
<div className='text-neutral-400 text-xs'>Open Issues</div>
</div>
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<GitPullRequest className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.openPRs}</div>
<div className='text-neutral-400 text-xs'>Pull Requests</div>
</div>
</div>
{/* Activity Chart - Mobile responsive */}
<div className='rounded-xl border border-[#606060]/30 bg-[#0f0f0f] p-4 sm:rounded-2xl sm:p-6'>
<h3 className='mb-3 font-medium text-base text-white sm:mb-4 sm:text-lg'>
Commit Activity
</h3>
<ResponsiveContainer width='100%' height={150} className='sm:!h-[200px]'>
<AreaChart data={timelineData} className='-mx-2 sm:-mx-5 mt-1 sm:mt-2'>
<defs>
<linearGradient id='commits' x1='0' y1='0' x2='0' y2='1'>
<stop offset='5%' stopColor='#701ffc' stopOpacity={0.3} />
<stop offset='95%' stopColor='#701ffc' stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey='date'
stroke='currentColor'
fontSize={10}
tickLine={false}
axisLine={false}
className='text-neutral-400 sm:text-xs'
interval={4}
/>
<YAxis
stroke='currentColor'
fontSize={10}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
className='text-neutral-400 sm:text-xs'
width={30}
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className='rounded-lg border border-[#606060]/30 bg-[#0f0f0f] p-2 shadow-lg backdrop-blur-sm sm:p-3'>
<div className='grid gap-1 sm:gap-2'>
<div className='flex items-center gap-1 sm:gap-2'>
<GitGraph className='h-3 w-3 text-[#701ffc] sm:h-4 sm:w-4' />
<span className='text-neutral-400 text-xs sm:text-sm'>
Commits:
</span>
<span className='font-medium text-white text-xs sm:text-sm'>
{payload[0]?.value}
</span>
</div>
</div>
</div>
)
}
return null
}}
/>
<Area
type='monotone'
dataKey='commits'
stroke='#701ffc'
strokeWidth={2}
fill='url(#commits)'
/>
</AreaChart>
</ResponsiveContainer>
</div>
</motion.div>
</div>
</section>
{/* Contributors Display */}
<section className='px-4 py-12 sm:px-8 sm:py-16 md:px-16 lg:px-28 xl:px-32'>
<div className='mx-auto max-w-6xl'>
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.2 }}
>
<Tabs defaultValue='grid' className='w-full'>
<div className='mb-6 flex justify-center sm:mb-8'>
<TabsList className='grid h-full w-full max-w-[300px] grid-cols-2 border border-[#606060]/30 bg-[#0f0f0f] p-1 backdrop-blur-sm sm:w-[200px]'>
<TabsTrigger
value='grid'
className='flex items-center gap-1 text-neutral-400 text-xs data-[state=active]:bg-neutral-700/50 data-[state=active]:text-white data-[state=active]:shadow-sm sm:gap-2 sm:text-sm'
>
<LayoutGrid className='h-3 w-3 sm:h-4 sm:w-4' />
Grid
</TabsTrigger>
<TabsTrigger
value='chart'
className='flex items-center gap-1 text-neutral-400 text-xs data-[state=active]:bg-neutral-700/50 data-[state=active]:text-white data-[state=active]:shadow-sm sm:gap-2 sm:text-sm'
>
<ChartAreaIcon className='h-3 w-3 sm:h-4 sm:w-4' />
Chart
</TabsTrigger>
</TabsList>
</div>
<TabsContent value='grid'>
{/* Mobile: 2 columns, Small: 3 columns, Large: 4 columns, XL: 6 columns */}
<div className='grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 xl:grid-cols-6'>
{filteredContributors?.map((contributor, index) => (
<motion.a
key={contributor.login}
href={contributor.html_url}
target='_blank'
className='group relative flex flex-col items-center rounded-lg border border-[#606060]/30 bg-[#0f0f0f] p-3 backdrop-blur-sm transition-all hover:bg-neutral-700/50 sm:rounded-xl sm:p-4'
whileHover={{ scale: 1.02, y: -2 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
style={{ animationDelay: `${index * 50}ms` }}
>
<Avatar className='h-12 w-12 ring-2 ring-[#606060]/30 transition-transform group-hover:scale-105 group-hover:ring-[#701ffc]/60 sm:h-16 sm:w-16'>
<AvatarImage
src={contributor.avatar_url}
alt={contributor.login}
className='object-cover'
/>
<AvatarFallback className='bg-[#0f0f0f] text-[10px] sm:text-xs'>
{contributor.login.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className='mt-2 text-center sm:mt-3'>
<span className='block font-medium text-white text-xs transition-colors group-hover:text-[#701ffc] sm:text-sm'>
{contributor.login.length > 12
? `${contributor.login.slice(0, 12)}...`
: contributor.login}
</span>
<div className='mt-1 flex items-center justify-center gap-1 sm:mt-2'>
<GitGraph className='h-2 w-2 text-neutral-400 transition-colors group-hover:text-[#701ffc] sm:h-3 sm:w-3' />
<span className='font-medium text-neutral-300 text-xs transition-colors group-hover:text-white sm:text-sm'>
{contributor.contributions}
</span>
</div>
</div>
</motion.a>
))}
</div>
</TabsContent>
<TabsContent value='chart'>
<div className='rounded-2xl border border-[#606060]/30 bg-[#0f0f0f] p-4 backdrop-blur-sm sm:rounded-3xl sm:p-6'>
<ChartControls
showAll={showAllContributors}
setShowAll={setShowAllContributors}
total={filteredContributors?.length || 0}
/>
<ResponsiveContainer width='100%' height={300} className='sm:!h-[400px]'>
<BarChart
data={filteredContributors?.slice(0, showAllContributors ? undefined : 10)}
margin={{ top: 10, right: 10, bottom: 60, left: 10 }}
className='sm:!mx-2.5 sm:!mb-2.5'
>
<XAxis
dataKey='login'
interval={0}
tick={(props) => {
const { x, y, payload } = props
const contributor = allContributors?.find(
(c) => c.login === payload.value
)
return (
<g transform={`translate(${x},${y})`}>
<foreignObject
x='-16'
y='8'
width='32'
height='32'
style={{ overflow: 'visible' }}
>
<Avatar className='h-6 w-6 ring-1 ring-[#606060]/30 sm:h-8 sm:w-8'>
<AvatarImage src={contributor?.avatar_url} />
<AvatarFallback className='bg-[#0f0f0f] text-[6px] sm:text-[8px]'>
{payload.value.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</foreignObject>
</g>
)
}}
height={60}
className='text-neutral-400'
/>
<YAxis
stroke='currentColor'
fontSize={10}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
className='text-neutral-400 sm:text-xs'
width={25}
/>
<Tooltip
cursor={{ fill: 'rgb(255 255 255 / 0.05)' }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0]?.payload
return (
<div className='rounded-lg border border-[#606060]/30 bg-[#0f0f0f] p-2 shadow-lg backdrop-blur-sm sm:p-3'>
<div className='flex items-center gap-2'>
<Avatar className='h-6 w-6 ring-1 ring-[#606060]/30 sm:h-8 sm:w-8'>
<AvatarImage src={data.avatar_url} />
<AvatarFallback className='bg-[#0f0f0f] text-[8px] sm:text-xs'>
{data.login.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<div className='font-medium text-white text-xs sm:text-sm'>
{data.login}
</div>
<div className='flex items-center gap-1 text-[10px] text-neutral-400 sm:text-xs'>
<GitGraph className='h-2 w-2 sm:h-3 sm:w-3' />
<span>{data.contributions} commits</span>
</div>
</div>
</div>
</div>
)
}
return null
}}
/>
<Bar
dataKey='contributions'
className='fill-[#701ffc]'
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</TabsContent>
</Tabs>
</motion.div>
</div>
</section>
{/* Call to Action */}
<section className='px-4 py-8 sm:px-8 sm:py-10 md:px-16 md:py-16 lg:px-28 xl:px-32'>
<div className='mx-auto max-w-6xl'>
<motion.div
className='relative overflow-hidden rounded-2xl border border-[#606060]/30 bg-[#0f0f0f] sm:rounded-3xl'
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.7, delay: 0.3 }}
>
<div className='relative p-6 sm:p-8 md:p-12 lg:p-16'>
<div className='text-center'>
<div className='mb-4 inline-flex items-center rounded-full border border-[#701ffc]/20 bg-[#701ffc]/10 px-3 py-1 font-medium text-[#701ffc] text-xs sm:mb-6 sm:px-4 sm:py-2 sm:text-sm'>
<Github className='mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4' />
Apache-2.0 Licensed
</div>
<h3 className='font-medium text-2xl text-white leading-[1.1] tracking-tight sm:text-[42px] md:text-5xl'>
Want to contribute?
</h3>
<p className='mx-auto mt-3 max-w-2xl font-light text-base text-neutral-400 sm:mt-4 sm:text-xl'>
Whether you&apos;re fixing bugs, adding features, or improving documentation,
every contribution helps build the future of AI workflows.
</p>
<div className='mt-6 flex flex-col gap-3 sm:mt-8 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4'>
<Button
asChild
size='lg'
className='bg-[#701ffc] text-white transition-colors duration-500 hover:bg-[#802FFF]'
>
<a
href='https://github.com/simstudioai/sim/blob/main/.github/CONTRIBUTING.md'
target='_blank'
rel='noopener'
>
<GitGraph className='mr-2 h-4 w-4 sm:h-5 sm:w-5' />
Start Contributing
</a>
</Button>
<Button
asChild
variant='outline'
size='lg'
className='border-[#606060]/30 bg-transparent text-neutral-300 transition-colors duration-500 hover:bg-neutral-700/50 hover:text-white'
>
<a href='https://github.com/simstudioai/sim' target='_blank' rel='noopener'>
<Github className='mr-2 h-4 w-4 sm:h-5 sm:w-5' />
View Repository
</a>
</Button>
<Button
asChild
variant='outline'
size='lg'
className='border-[#606060]/30 bg-transparent text-neutral-300 transition-colors duration-500 hover:bg-neutral-700/50 hover:text-white'
>
<a
href='https://github.com/simstudioai/sim/issues'
target='_blank'
rel='noopener'
>
<MessageCircle className='mr-2 h-4 w-4 sm:h-5 sm:w-5' />
Open Issues
</a>
</Button>
</div>
</div>
</div>
</motion.div>
</div>
</section>
</div>
{/* Footer */}
<Footer />
</main>
)
}

View File

@@ -1,746 +0,0 @@
'use client'
import Link from 'next/link'
import { GridPattern } from '../components/grid-pattern'
import NavWrapper from '../components/nav-wrapper'
import Footer from '../components/sections/footer'
export default function PrivacyPolicy() {
const handleOpenTypeformLink = () => {
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
}
return (
<main className='relative min-h-screen overflow-hidden bg-[#0C0C0C] text-white'>
{/* Grid pattern background - only covers content area */}
<div className='absolute inset-0 bottom-[400px] z-0 overflow-hidden'>
<GridPattern
x={-5}
y={-5}
className='absolute inset-0 stroke-[#ababab]/5'
width={90}
height={90}
aria-hidden='true'
/>
</div>
{/* Header/Navigation */}
<NavWrapper onOpenTypeformLink={handleOpenTypeformLink} />
{/* SVG background blur centered behind content */}
<div
className='-translate-x-1/2 absolute top-0 bottom-0 left-1/2 z-[1] h-full w-[95%] max-w-5xl md:w-[90%] lg:w-[80%]'
aria-hidden='true'
>
<svg
width='100%'
height='100%'
viewBox='0 0 600 1600'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid slice'
className='h-full w-full'
>
<g filter='url(#filter0_b_privacy)'>
<rect width='600' height='1600' rx='0' fill='#0C0C0C' />
</g>
<defs>
<filter
id='filter0_b_privacy'
x='-20'
y='-20'
width='640'
height='1640'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feGaussianBlur stdDeviation='7' />
</filter>
</defs>
</svg>
</div>
{/* Content */}
<div className='relative z-10'>
<div className='mx-auto max-w-4xl px-4 py-16 pt-36'>
<div className='relative px-4 py-4 sm:px-8'>
<h1 className='mb-8 font-bold text-4xl text-white'>Privacy Policy</h1>
<div className='space-y-8 text-white/80'>
<section>
<p className='mb-4'>Last Updated: April 22, 2025</p>
<p>
This Privacy Policy describes how your personal information is collected, used,
and shared when you visit or use Sim Studio ("the Service", "we", "us", or "our").
</p>
<p className='mt-4'>
By using the Service, you agree to the collection and use of information in
accordance with this policy. Unless otherwise defined in this Privacy Policy,
terms used in this Privacy Policy have the same meanings as in our Terms of
Service.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
Interpretation and Definitions
</h2>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Interpretation</h3>
<p className='mb-4'>
Under the following conditions, the meanings of words with capitalized first
letters are defined. The following definitions have the same meaning whether they
are written in singular or plural form.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Definitions</h3>
<p className='mb-4'>For the purposes of this Privacy Policy:</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>
<span className='text-[#B5A1D4]'>Account</span> means a unique account created
for You to access our Service or parts of our Service.
</li>
<li>
<span className='text-[#B5A1D4]'>Affiliate</span> means an entity that controls,
is controlled by or is under common control with a party, where "control" means
ownership of 50% or more of the shares, equity interest or other securities
entitled to vote for election of directors or other managing authority.
</li>
<li>
<span className='text-[#B5A1D4]'>Application</span> means the software program
provided by the Company downloaded by You on any electronic device.
</li>
<li>
<span className='text-[#B5A1D4]'>Business</span>, for the purpose of the CCPA
(California Consumer Privacy Act), refers to the Company as the legal entity
that collects Consumers' personal information and determines the purposes and
means of the processing of Consumers' personal information, or on behalf of
which such information is collected and that alone, or jointly with others,
determines the purposes and means of the processing of consumers' personal
information, that does business in the State of California.
</li>
<li>
<span className='text-[#B5A1D4]'>Company</span> (referred to as either "the
Company", "We", "Us" or "Our" in this Agreement) refers to Sim Studio. For the
purpose of the GDPR, the Company is the Data Controller.
</li>
<li>
<span className='text-[#B5A1D4]'>Cookies</span> are small files that are placed
on Your computer, mobile device or any other device by a website, containing the
details of Your browsing history on that website among its many uses.
</li>
<li>
<span className='text-[#B5A1D4]'>Country</span> refers to: Quebec, Canada
</li>
<li>
<span className='text-[#B5A1D4]'>Data Controller</span>, for the purposes of the
GDPR (General Data Protection Regulation), refers to the Company as the legal
person which alone or jointly with others determines the purposes and means of
the processing of Personal Data.
</li>
<li>
<span className='text-[#B5A1D4]'>Device</span> means any device that can access
the Service such as a computer, a cellphone or a digital tablet.
</li>
<li>
<span className='text-[#B5A1D4]'>Do Not Track (DNT)</span> is a concept that has
been promoted by US regulatory authorities, in particular the U.S. Federal Trade
Commission (FTC), for the Internet industry to develop and implement a mechanism
for allowing internet users to control the tracking of their online activities
across websites.
</li>
<li>
<span className='text-[#B5A1D4]'>Personal Data</span> is any information that
relates to an identified or identifiable individual. For the purposes for GDPR,
Personal Data means any information relating to You such as a name, an
identification number, location data, online identifier or to one or more
factors specific to the physical, physiological, genetic, mental, economic,
cultural or social identity. For the purposes of the CCPA, Personal Data means
any information that identifies, relates to, describes or is capable of being
associated with, or could reasonably be linked, directly or indirectly, with
You.
</li>
<li>
<span className='text-[#B5A1D4]'>Sale</span>, for the purpose of the CCPA
(California Consumer Privacy Act), means selling, renting, releasing,
disclosing, disseminating, making available, transferring, or otherwise
communicating orally, in writing, or by electronic or other means, a Consumer's
Personal information to another business or a third party for monetary or other
valuable consideration.
</li>
<li>
<span className='text-[#B5A1D4]'>Service</span> refers to the Application or the
Website or both.
</li>
<li>
<span className='text-[#B5A1D4]'>Service Provider</span> means any natural or
legal person who processes the data on behalf of the Company. It refers to
third-party companies or individuals employed by the Company to facilitate the
Service, to provide the Service on behalf of the Company, to perform services
related to the Service or to assist the Company in analyzing how the Service is
used. For the purpose of the GDPR, Service Providers are considered Data
Processors.
</li>
<li>
<span className='text-[#B5A1D4]'>Third-party Social Media Service</span> refers
to any website or any social network website through which a User can log in or
create an account to use the Service.
</li>
<li>
<span className='text-[#B5A1D4]'>Usage Data</span> refers to data collected
automatically, either generated by the use of the Service or from the Service
infrastructure itself (for example, the duration of a page visit).
</li>
<li>
<span className='text-[#B5A1D4]'>Website</span> refers to Sim Studio, accessible
from simstudio.ai
</li>
<li>
<span className='text-[#B5A1D4]'>You</span> means the individual accessing or
using the Service, or the company, or other legal entity on behalf of which such
individual is accessing or using the Service, as applicable. Under GDPR (General
Data Protection Regulation), You can be referred to as the Data Subject or as
the User as you are the individual using the Service.
</li>
</ul>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
1. Information We Collect
</h2>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Personal Information</h3>
<p className='mb-4'>
While using our Service, we may ask you to provide us with certain personally
identifiable information that can be used to contact or identify you (
<span className='text-[#B5A1D4]'>"Personal Information"</span>). Personally
identifiable information may include, but is not limited to:
</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>Email address</li>
<li>First name and last name</li>
<li>Phone number</li>
<li>Address, State, Province, ZIP/Postal code, City</li>
<li>Cookies and Usage Data</li>
</ul>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Usage Data</h3>
<p className='mb-4'>
We may also collect information on how the Service is accessed and used (
<span className='text-[#B5A1D4]'>"Usage Data"</span>). This Usage Data may include
information such as your computer's Internet Protocol address (e.g. IP address),
browser type, browser version, the pages of our Service that you visit, the time
and date of your visit, the time spent on those pages, unique device identifiers
and other diagnostic data.
</p>
<p className='mb-4'>
When You access the Service by or through a mobile device, We may collect certain
information automatically, including, but not limited to, the type of mobile
device You use, Your mobile device unique ID, the IP address of Your mobile
device, Your mobile operating system, the type of mobile Internet browser You use,
unique device identifiers and other diagnostic data.
</p>
<p className='mb-4'>
We may also collect information that Your browser sends whenever You visit our
Service or when You access the Service by or through a mobile device.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Tracking & Cookies Data</h3>
<p className='mb-4'>
We use cookies and similar tracking technologies to track the activity on our
Service and hold certain information.
</p>
<p className='mb-4'>
Cookies are files with small amount of data which may include an anonymous unique
identifier. Cookies are sent to your browser from a website and stored on your
device. Tracking technologies also used are beacons, tags, and scripts to collect
and track information and to improve and analyze our Service.
</p>
<p>
You can instruct your browser to refuse all cookies or to indicate when a cookie
is being sent. However, if you do not accept cookies, you may not be able to use
some portions of our Service.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
2. How We Use Your Information
</h2>
<p className='mb-4'>We use the collected data for various purposes:</p>
<ul className='list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>To provide and maintain our Service</li>
<li>To notify you about changes to our Service</li>
<li>
To allow you to participate in interactive features of our Service when you
choose to do so
</li>
<li>To provide customer care and support</li>
<li>
To provide analysis or valuable information so that we can improve the Service
</li>
<li>To monitor the usage of the Service</li>
<li>To detect, prevent and address technical issues</li>
<li>To manage Your Account</li>
<li>For the performance of a contract</li>
<li>
To contact You by email, telephone calls, SMS, or other equivalent forms of
electronic communication
</li>
</ul>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>3. Transfer Of Data</h2>
<p className='mb-4'>
Your information, including Personal Information, may be transferred to — and
maintained on — computers located outside of your state, province, country or
other governmental jurisdiction where the data protection laws may differ than
those from your jurisdiction.
</p>
<p className='mb-4'>
If you are located outside United States and choose to provide information to us,
please note that we transfer the data, including Personal Information, to United
States and process it there.
</p>
<p>
Your consent to this Privacy Policy followed by your submission of such
information represents your agreement to that transfer.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>4. Disclosure Of Data</h2>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Business Transactions</h3>
<p className='mb-4'>
If the Company is involved in a merger, acquisition or asset sale, Your Personal
Data may be transferred. We will provide notice before Your Personal Data is
transferred and becomes subject to a different Privacy Policy.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Law Enforcement</h3>
<p className='mb-4'>
Under certain circumstances, the Company may be required to disclose Your Personal
Data if required to do so by law or in response to valid requests by public
authorities (e.g. a court or a government agency).
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Legal Requirements</h3>
<p className='mb-4'>
Sim Studio may disclose your Personal Information in the good faith belief that
such action is necessary to:
</p>
<ul className='list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>To comply with a legal obligation</li>
<li>To protect and defend the rights or property of Sim Studio</li>
<li>
To prevent or investigate possible wrongdoing in connection with the Service
</li>
<li>To protect the personal safety of users of the Service or the public</li>
<li>To protect against legal liability</li>
</ul>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>5. Security Of Data</h2>
<p className='mb-4'>
The security of your data is important to us, but remember that no method of
transmission over the Internet, or method of electronic storage is 100% secure.
While we strive to use commercially acceptable means to protect your Personal
Information, we cannot guarantee its absolute security.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>6. Service Providers</h2>
<p className='mb-4'>
We may employ third party companies and individuals to facilitate our Service (
<span className='text-[#B5A1D4]'>"Service Providers"</span>), to provide the
Service on our behalf, to perform Service-related services or to assist us in
analyzing how our Service is used.
</p>
<p>
These third parties have access to your Personal Information only to perform these
tasks on our behalf and are obligated not to disclose or use it for any other
purpose.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>7. Analytics</h2>
<p className='mb-4'>
We may use third-party Service Providers to monitor and analyze the use of our
Service.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Google Analytics</h3>
<p className='mb-4'>
Google Analytics is a web analytics service offered by Google that tracks and
reports website traffic. Google uses the data collected to track and monitor the
use of our Service. This data is shared with other Google services. Google may use
the collected data to contextualize and personalize the ads of its own advertising
network.
</p>
<p className='mb-4'>
You can opt-out of having made your activity on the Service available to Google
Analytics by installing the Google Analytics opt-out browser add-on. The add-on
prevents the Google Analytics JavaScript (ga.js, analytics.js, and dc.js) from
sharing information with Google Analytics about visits activity.
</p>
<p>
For more information on the privacy practices of Google, please visit the Google
Privacy & Terms web page:{' '}
<Link
href='https://policies.google.com/privacy?hl=en'
className='text-[#B5A1D4] hover:text-[#701ffc]'
target='_blank'
rel='noopener noreferrer'
>
https://policies.google.com/privacy
</Link>
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
8. Behavioral Remarketing
</h2>
<p className='mb-4'>
The Company uses remarketing services to advertise on third party websites to You
after You visited our Service. We and Our third-party vendors use cookies to
inform, optimize and serve ads based on Your past visits to our Service.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Google Ads (AdWords)</h3>
<p className='mb-4'>
Google Ads remarketing service is provided by Google Inc. You can opt-out of
Google Analytics for Display Advertising and customize the Google Display Network
ads by visiting the Google Ads Settings page.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Twitter</h3>
<p className='mb-4'>
Twitter remarketing service is provided by Twitter Inc. You can opt-out from
Twitter's interest-based ads by following their instructions.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Facebook</h3>
<p className='mb-4'>
Facebook remarketing service is provided by Facebook Inc. You can learn more about
interest-based advertising from Facebook by visiting their Privacy Policy.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>9. Payments</h2>
<p className='mb-4'>
We may provide paid products and/or services within the Service. In that case, we
may use third-party services for payment processing (e.g. payment processors).
</p>
<p className='mb-4'>
We will not store or collect Your payment card details. That information is
provided directly to Our third-party payment processors whose use of Your personal
information is governed by their Privacy Policy. These payment processors adhere
to the standards set by PCI-DSS as managed by the PCI Security Standards Council,
which is a joint effort of brands like Visa, Mastercard, American Express and
Discover. PCI-DSS requirements help ensure the secure handling of payment
information.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>
Payment processors we work with:
</h3>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>Stripe</li>
</ul>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
10. Google Workspace APIs
</h2>
<p className='mb-4'>
We want to explicitly affirm that any user data obtained through Google Workspace
APIs is <span className='text-[#B5A1D4]'>not</span> used to develop, improve, or
train generalized AI and/or machine learning models. We use data obtained through
Google Workspace APIs solely for the purpose of providing and improving the
specific functionality of our Service for which the API access was granted.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
11. Information Collected while Using Google APIs
</h2>
<p className='mb-4'>
Sim Studio's use and transfer to any other app of information received from Google
APIs will adhere to Google API Services User Data Policy, including the Limited
Use requirements.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>12. Links To Other Sites</h2>
<p className='mb-4'>
Our Service may contain links to other sites that are not operated by us. If you
click on a third party link, you will be directed to that third party's site. We
strongly advise you to review the Privacy Policy of every site you visit.
</p>
<p>
We have no control over and assume no responsibility for the content, privacy
policies or practices of any third party sites or services.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>13. Children's Privacy</h2>
<p className='mb-4'>
Our Service does not address anyone under the age of 18 (
<span className='text-[#B5A1D4]'>"Children"</span>).
</p>
<p className='mb-4'>
We do not knowingly collect personally identifiable information from anyone under
the age of 18. If you are a parent or guardian and you are aware that your
Children has provided us with Personal Information, please contact us. If we
become aware that we have collected Personal Information from children without
verification of parental consent, we take steps to remove that information from
our servers.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
14. Changes To This Privacy Policy
</h2>
<p className='mb-4'>
We may update our Privacy Policy from time to time. We will notify you of any
changes by posting the new Privacy Policy on this page.
</p>
<p className='mb-4'>
We will let you know via email and/or a prominent notice on our Service, prior to
the change becoming effective and update the "Last updated" date at the top of
this Privacy Policy.
</p>
<p>
You are advised to review this Privacy Policy periodically for any changes.
Changes to this Privacy Policy are effective when they are posted on this page.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
15. Your Data Protection Rights Under General Data Protection Regulation (GDPR)
</h2>
<p className='mb-4'>
If you are a resident of the European Economic Area (EEA), you have certain data
protection rights. Sim Studio aims to take reasonable steps to allow you to
correct, amend, delete, or limit the use of your Personal Information.
</p>
<p className='mb-4'>
If you wish to be informed what Personal Information we hold about you and if you
want it to be removed from our systems, please contact us.
</p>
<p className='mb-4'>
In certain circumstances, you have the following data protection rights:
</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>The right to access, update or to delete the information we have on you.</li>
<li>
The right of rectification. You have the right to have your information
rectified if that information is inaccurate or incomplete.
</li>
<li>
The right to object. You have the right to object to our processing of your
Personal Information.
</li>
<li>
The right of restriction. You have the right to request that we restrict the
processing of your personal information.
</li>
<li>
The right to data portability. You have the right to be provided with a copy of
the information we have on you in a structured, machine-readable and commonly
used format.
</li>
<li>
The right to withdraw consent. You also have the right to withdraw your consent
at any time where Sim Studio relied on your consent to process your personal
information.
</li>
</ul>
<p className='mb-4'>
Please note that we may ask you to verify your identity before responding to such
requests.
</p>
<p className='mb-4 border-[#701ffc] border-l-4 bg-[#701ffc]/10 p-3'>
You have the right to complain to a Data Protection Authority about our collection
and use of your Personal Information. For more information, please contact your
local data protection authority in the European Economic Area (EEA).
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
16. California Privacy Rights
</h2>
<p className='mb-4'>
California Civil Code Section 1798.83, also known as the{' '}
<span className='text-[#B5A1D4]'>"Shine The Light"</span> law, permits our users
who are California residents to request and obtain from us, once a year and free
of charge, information about categories of personal information (if any) we
disclosed to third parties for direct marketing purposes and the names and
addresses of all third parties with which we shared personal information in the
immediately preceding calendar year.
</p>
<p className='mb-4'>
If you are a California resident and would like to make such a request, please
submit your request in writing to us using the contact information provided below.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
17. Vulnerability Disclosure Policy
</h2>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Introduction</h3>
<p className='mb-4'>
Sim Studio is dedicated to preserving data security by preventing unauthorized
disclosure of information. This policy was created to provide security researchers
with instructions for conducting vulnerability discovery activities and to provide
information on how to report vulnerabilities that have been discovered. This
policy explains which systems and sorts of activity are covered, how to send
vulnerability reports, and how long we require you to wait before publicly
reporting vulnerabilities identified.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Guidelines</h3>
<p className='mb-4'>We request that you:</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>
Notify us as soon as possible after you discover a real or potential security
issue.
</li>
<li>
Provide us a reasonable amount of time to resolve the issue before you disclose
it publicly.
</li>
<li>
Make every effort to avoid privacy violations, degradation of user experience,
disruption to production systems, and destruction or manipulation of data.
</li>
<li>
Only use exploits to the extent necessary to confirm a vulnerability's presence.
Do not use an exploit to compromise or obtain data, establish command line
access and/or persistence, or use the exploit to "pivot" to other systems.
</li>
<li>
Once you've established that a vulnerability exists or encounter any sensitive
data (including personal data, financial information, or proprietary information
or trade secrets of any party), you must stop your test, notify us immediately,
and keep the data strictly confidential.
</li>
<li>Do not submit a high volume of low-quality reports.</li>
</ul>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Authorization</h3>
<p className='mb-4'>
Security research carried out in conformity with this policy is deemed
permissible. We'll work with you to swiftly understand and fix the problem, and
Sim Studio will not suggest or pursue legal action in connection with your study.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Scope</h3>
<p className='mb-4'>This policy applies to the following systems and services:</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>simstudio.ai website</li>
<li>Sim Studio web application</li>
<li>Sim Studio API services</li>
</ul>
<p className='mb-4'>
Any service that isn't explicitly specified above, such as related services, is
out of scope and isn't allowed to be tested. Vulnerabilities discovered in
third-party solutions Sim Studio interacts with are not covered by this policy and
should be reported directly to the solution vendor in accordance with their
disclosure policy (if any). Before beginning your inquiry, email us at{' '}
<Link
href='mailto:security@simstudio.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
>
security@simstudio.ai
</Link>{' '}
if you're unsure whether a system or endpoint is in scope.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Types of testing</h3>
<p className='mb-4'>The following test types are not authorized:</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>Network denial of service (DoS or DDoS) tests</li>
<li>
Physical testing (e.g., office access, open doors, tailgating), social
engineering (e.g., phishing, vishing), or any other non-technical vulnerability
testing
</li>
</ul>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>
Reporting a vulnerability
</h3>
<p className='mb-4'>
To report any security flaws, send an email to{' '}
<Link
href='mailto:security@simstudio.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
>
security@simstudio.ai
</Link>
. The next business day, we'll acknowledge receipt of your vulnerability report
and keep you updated on our progress. Reports can be anonymously submitted.
</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Desirable information</h3>
<p className='mb-4'>
In order to process and react to a vulnerability report, we recommend to include
the following information:
</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>Vulnerability description</li>
<li>Place of discovery</li>
<li>Potential Impact</li>
<li>
Steps required to reproduce a vulnerability (include scripts and screenshots if
possible)
</li>
</ul>
<p className='mb-4'>If possible, please provide your report in English.</p>
<h3 className='mb-2 font-medium text-[#B5A1D4] text-xl'>Our commitment</h3>
<p className='mb-4'>
If you choose to give your contact information, we promise to communicate with you
in a transparent and timely manner. We will acknowledge receipt of your report
within three business days. We will keep you informed on vulnerability
confirmation and remedy to the best of our capabilities. We welcome a discussion
of concerns and are willing to engage in a discourse.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>18. Contact Us</h2>
<p>
If you have any questions about this Privacy Policy, please contact us at:{' '}
<Link
href='mailto:privacy@simstudio.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
>
privacy@simstudio.ai
</Link>
</p>
</section>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className='relative z-20'>
<Footer />
</div>
</main>
)
}

View File

@@ -1,367 +0,0 @@
'use client'
import Link from 'next/link'
import { GridPattern } from '../components/grid-pattern'
import NavWrapper from '../components/nav-wrapper'
import Footer from '../components/sections/footer'
export default function TermsOfService() {
const handleOpenTypeformLink = () => {
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
}
return (
<main className='relative min-h-screen overflow-hidden bg-[#0C0C0C] text-white'>
{/* Grid pattern background */}
<div className='absolute inset-0 bottom-[400px] z-0 overflow-hidden'>
<GridPattern
x={-5}
y={-5}
className='absolute inset-0 stroke-[#ababab]/5'
width={90}
height={90}
aria-hidden='true'
/>
</div>
{/* Header/Navigation */}
<NavWrapper onOpenTypeformLink={handleOpenTypeformLink} />
{/* background blur */}
<div
className='-translate-x-1/2 absolute top-0 bottom-0 left-1/2 z-[1] h-full w-[95%] max-w-5xl md:w-[90%] lg:w-[80%]'
aria-hidden='true'
>
<svg
width='100%'
height='100%'
viewBox='0 0 600 1600'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid slice'
className='h-full w-full'
>
<g filter='url(#filter0_b_terms)'>
<rect width='600' height='1600' rx='0' fill='#0C0C0C' />
</g>
<defs>
<filter
id='filter0_b_terms'
x='-20'
y='-20'
width='640'
height='1640'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feGaussianBlur stdDeviation='7' />
</filter>
</defs>
</svg>
</div>
{/* Content */}
<div className='relative z-10'>
<div className='mx-auto max-w-4xl px-4 py-16 pt-36'>
<div className='relative px-4 py-4 sm:px-8'>
<h1 className='mb-8 font-bold text-4xl text-white'>Terms of Service</h1>
<div className='space-y-8 text-white/80'>
<section>
<p className='mb-4'>Last Updated: April 20, 2025</p>
<p>
Please read these Terms of Service ("Terms") carefully before using the Sim Studio
platform (the "Service") operated by Sim Studio, Inc ("us", "we", or "our").
</p>
<p className='mt-4'>
By accessing or using the Service, you agree to be bound by these Terms. If you
disagree with any part of the terms, you may not access the Service.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>1. Accounts</h2>
<p className='mb-4'>
When you create an account with us, you must provide accurate, complete, and
current information. Failure to do so constitutes a breach of the Terms, which may
result in immediate termination of your account on our Service.
</p>
<p className='mb-4'>
You are responsible for safeguarding the password that you use to access the
Service and for any activities or actions under your password.
</p>
<p>
You agree not to disclose your password to any third party. You must notify us
immediately upon becoming aware of any breach of security or unauthorized use of
your account.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>2. Intellectual Property</h2>
<p className='mb-4'>
The Service and its original content, features, and functionality are and will
remain the exclusive property of Sim Studio, Inc and its licensors. The Service is
protected by copyright, trademark, and other laws of both the United States and
foreign countries.
</p>
<p>
Our trademarks and trade dress may not be used in connection with any product or
service without the prior written consent of Sim Studio, Inc.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>3. User Content</h2>
<p className='mb-4'>
Our Service allows you to post, link, store, share and otherwise make available
certain information, text, graphics, videos, or other material ("User Content").
You are responsible for the User Content that you post on or through the Service,
including its legality, reliability, and appropriateness.
</p>
<p className='mb-4'>
By posting User Content on or through the Service, you represent and warrant that:
</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>
The User Content is yours (you own it) or you have the right to use it and grant
us the rights and license as provided in these Terms.
</li>
<li>
The posting of your User Content on or through the Service does not violate the
privacy rights, publicity rights, copyrights, contract rights or any other
rights of any person.
</li>
</ul>
<p>
We reserve the right to terminate the account of any user found to be infringing
on a copyright.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>4. Acceptable Use</h2>
<p className='mb-4'>You agree not to use the Service:</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>
In any way that violates any applicable national or international law or
regulation.
</li>
<li>
For the purpose of exploiting, harming, or attempting to exploit or harm minors
in any way.
</li>
<li>
To transmit, or procure the sending of, any advertising or promotional material,
including any "junk mail", "chain letter," "spam," or any other similar
solicitation.
</li>
<li>
To impersonate or attempt to impersonate Sim Studio, Inc, a Sim Studio employee,
another user, or any other person or entity.
</li>
<li>
In any way that infringes upon the rights of others, or in any way is illegal,
threatening, fraudulent, or harmful.
</li>
<li>
To engage in any other conduct that restricts or inhibits anyone's use or
enjoyment of the Service, or which, as determined by us, may harm Sim Studio,
Inc or users of the Service or expose them to liability.
</li>
</ul>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>5. Termination</h2>
<p className='mb-4'>
We may terminate or suspend your account immediately, without prior notice or
liability, for any reason whatsoever, including without limitation if you breach
the Terms.
</p>
<p>
Upon termination, your right to use the Service will immediately cease. If you
wish to terminate your account, you may simply discontinue using the Service.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>
6. Limitation of Liability
</h2>
<p className='mb-4'>
In no event shall Sim Studio, Inc, nor its directors, employees, partners, agents,
suppliers, or affiliates, be liable for any indirect, incidental, special,
consequential or punitive damages, including without limitation, loss of profits,
data, use, goodwill, or other intangible losses, resulting from:
</p>
<ul className='list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>Your access to or use of or inability to access or use the Service;</li>
<li>Any conduct or content of any third party on the Service;</li>
<li>Any content obtained from the Service; and</li>
<li>
Unauthorized access, use or alteration of your transmissions or content, whether
based on warranty, contract, tort (including negligence) or any other legal
theory, whether or not we have been informed of the possibility of such damage.
</li>
</ul>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>7. Disclaimer</h2>
<p className='mb-4'>
Your use of the Service is at your sole risk. The Service is provided on an "AS
IS" and "AS AVAILABLE" basis. The Service is provided without warranties of any
kind, whether express or implied, including, but not limited to, implied
warranties of merchantability, fitness for a particular purpose, non-infringement
or course of performance.
</p>
<p>
Sim Studio, Inc, its subsidiaries, affiliates, and its licensors do not warrant
that:
</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>
The Service will function uninterrupted, secure or available at any particular
time or location;
</li>
<li>Any errors or defects will be corrected;</li>
<li>The Service is free of viruses or other harmful components; or</li>
<li>The results of using the Service will meet your requirements.</li>
</ul>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>8. Governing Law</h2>
<p>
These Terms shall be governed and construed in accordance with the laws of the
United States, without regard to its conflict of law provisions.
</p>
<p className='mt-4'>
Our failure to enforce any right or provision of these Terms will not be
considered a waiver of those rights. If any provision of these Terms is held to be
invalid or unenforceable by a court, the remaining provisions of these Terms will
remain in effect.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>9. Arbitration Agreement</h2>
<p className='mb-4'>
Please read the following arbitration agreement carefully. It requires you to
arbitrate disputes with Sim Studio, Inc, its parent companies, subsidiaries,
affiliates, successors and assigns and all of their respective officers,
directors, employees, agents, and representatives (collectively, the{' '}
<span className='text-[#B5A1D4]'>"Company Parties"</span>) and limits the manner
in which you can seek relief from the Company Parties.
</p>
<p className='mb-4'>
You agree that any dispute between you and any of the Company Parties relating to
the Site, the Service or these Terms will be resolved by binding arbitration,
rather than in court, except that (1) you and the Company Parties may assert
individualized claims in small claims court if the claims qualify, remain in such
court and advance solely on an individual, non-class basis; and (2) you or the
Company Parties may seek equitable relief in court for infringement or other
misuse of intellectual property rights.
</p>
<p className='mb-4'>
The Federal Arbitration Act governs the interpretation and enforcement of this
Arbitration Agreement. The arbitration will be conducted by JAMS, an established
alternative dispute resolution provider.
</p>
<p className='mb-4 border-[#701ffc] border-l-4 bg-[#701ffc]/10 p-3'>
YOU AND COMPANY AGREE THAT EACH OF US MAY BRING CLAIMS AGAINST THE OTHER ONLY ON
AN INDIVIDUAL BASIS AND NOT ON A CLASS, REPRESENTATIVE, OR COLLECTIVE BASIS. ONLY
INDIVIDUAL RELIEF IS AVAILABLE, AND DISPUTES OF MORE THAN ONE CUSTOMER OR USER
CANNOT BE ARBITRATED OR CONSOLIDATED WITH THOSE OF ANY OTHER CUSTOMER OR USER.
</p>
<p className='mb-4'>
You have the right to opt out of the provisions of this Arbitration Agreement by
sending a timely written notice of your decision to opt out to:{' '}
<Link
href='mailto:legal@simstudio.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
>
legal@simstudio.ai{' '}
</Link>
within 30 days after first becoming subject to this Arbitration Agreement.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>10. Changes to Terms</h2>
<p>
We reserve the right, at our sole discretion, to modify or replace these Terms at
any time. If a revision is material, we will try to provide at least 30 days'
notice prior to any new terms taking effect. What constitutes a material change
will be determined at our sole discretion.
</p>
<p className='mt-4'>
By continuing to access or use our Service after those revisions become effective,
you agree to be bound by the revised terms. If you do not agree to the new terms,
please stop using the Service.
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>11. Copyright Policy</h2>
<p className='mb-4'>
We respect the intellectual property of others and ask that users of our Service
do the same. If you believe that one of our users is, through the use of our
Service, unlawfully infringing the copyright(s) in a work, please send a notice to
our designated Copyright Agent, including the following information:
</p>
<ul className='mb-4 list-disc space-y-2 pl-6 marker:text-[#B5A1D4]'>
<li>Your physical or electronic signature;</li>
<li>
Identification of the copyrighted work(s) that you claim to have been infringed;
</li>
<li>
Identification of the material on our services that you claim is infringing;
</li>
<li>Your address, telephone number, and e-mail address;</li>
<li>
A statement that you have a good-faith belief that the disputed use is not
authorized by the copyright owner, its agent, or the law; and
</li>
<li>
A statement, made under the penalty of perjury, that the above information in
your notice is accurate and that you are the copyright owner or authorized to
act on the copyright owner's behalf.
</li>
</ul>
<p>
Our Copyright Agent can be reached at:{' '}
<Link
href='mailto:copyright@simstudio.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
>
copyright@simstudio.ai
</Link>
</p>
</section>
<section>
<h2 className='mb-4 font-semibold text-2xl text-white'>12. Contact Us</h2>
<p>
If you have any questions about these Terms, please contact us at:{' '}
<Link
href='mailto:legal@simstudio.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
>
legal@simstudio.ai
</Link>
</p>
</section>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className='relative z-20'>
<Footer />
</div>
</main>
)
}

View File

@@ -1,100 +0,0 @@
interface RepoStats {
stars: number
forks: number
watchers: number
openIssues: number
openPRs: number
}
interface CommitTimelineData {
date: string
commits: number
additions: number
deletions: number
}
interface ActivityData {
date: string
commits: number
issues: number
pullRequests: number
}
interface CommitData {
sha: string
commit: {
author: {
name: string
email: string
date: string
}
message: string
}
html_url: string
stats?: {
additions: number
deletions: number
}
}
/**
* Generate commit timeline data for the last 30 days using real commit data
*/
export function generateCommitTimelineData(commitsData: CommitData[]): CommitTimelineData[] {
return Array.from({ length: 30 }, (_, i) => {
const date = new Date()
date.setDate(date.getDate() - (29 - i))
const dateStr = date.toISOString().split('T')[0]
const dayCommits = commitsData.filter((commit) => commit.commit.author.date.startsWith(dateStr))
const stats = dayCommits.reduce(
(acc, commit) => {
if (commit.stats) {
acc.additions += commit.stats.additions || 0
acc.deletions += commit.stats.deletions || 0
}
return acc
},
{ additions: 0, deletions: 0 }
)
return {
date: date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}),
commits: dayCommits.length,
additions: stats.additions,
deletions: stats.deletions,
}
})
}
/**
* Generate activity data for the last 7 days using actual commit data
*/
export function generateActivityData(
commitsData: CommitData[],
repoStats?: RepoStats
): ActivityData[] {
return Array.from({ length: 7 }, (_, i) => {
const date = new Date()
const today = date.getDay()
const daysToSubtract = today + (6 - i)
date.setDate(date.getDate() - daysToSubtract)
const dateStr = date.toISOString().split('T')[0]
const dayCommits = commitsData.filter((commit) =>
commit.commit.author.date.startsWith(dateStr)
).length
return {
date: date.toLocaleDateString('en-US', { weekday: 'short' }),
commits: dayCommits,
issues: repoStats ? Math.floor(repoStats.openIssues / 7) : 0,
pullRequests: repoStats ? Math.floor(repoStats.openPRs / 7) : 0,
}
})
}

View File

@@ -1,133 +0,0 @@
// Utility for prefetching and caching contributors page data
import { getCommitsData, getContributors, getRepositoryStats } from '../actions/github'
import { generateActivityData, generateCommitTimelineData } from './github'
interface Contributor {
login: string
avatar_url: string
contributions: number
html_url: string
}
interface RepoStats {
stars: number
forks: number
watchers: number
openIssues: number
openPRs: number
}
interface CommitTimelineData {
date: string
commits: number
additions: number
deletions: number
}
interface ActivityData {
date: string
commits: number
issues: number
pullRequests: number
}
interface ContributorsPageData {
contributors: Contributor[]
repoStats: RepoStats
timelineData: CommitTimelineData[]
activityData: ActivityData[]
}
// Debounce utility function
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
// Cache for the prefetched data
let cachedData: ContributorsPageData | null = null
let isPreFetching = false
let prefetchPromise: Promise<ContributorsPageData> | null = null
// Create a debounced version of the prefetch function
const debouncedPrefetchContributorsData = debounce(() => {
prefetchContributorsData().catch((err: unknown) => {
console.error('Failed to prefetch contributors data:', err)
})
}, 100)
/**
* Debounced prefetch function for use in hover handlers
* Only triggers after 100ms of stable hover to prevent rapid API calls
*/
export function usePrefetchOnHover(): () => void {
return debouncedPrefetchContributorsData
}
/**
* Prefetch contributors page data
*/
export async function prefetchContributorsData(): Promise<ContributorsPageData> {
// If data is already cached, return it
if (cachedData) {
return cachedData
}
// If already prefetching, return the existing promise
if (isPreFetching && prefetchPromise) {
return prefetchPromise
}
// Start prefetching
prefetchPromise = fetchContributorsData()
isPreFetching = true
try {
const data = await prefetchPromise
cachedData = data
return data
} finally {
isPreFetching = false
prefetchPromise = null
}
}
/**
* Get cached contributors data if available
*/
export function getCachedContributorsData(): ContributorsPageData | null {
return cachedData
}
/**
* Clear the cached data (useful for refreshing)
*/
export function clearContributorsCache(): void {
cachedData = null
isPreFetching = false
prefetchPromise = null
}
/**
* Internal function to fetch all contributors data
*/
async function fetchContributorsData(): Promise<ContributorsPageData> {
const [contributors, stats, commits] = await Promise.all([
getContributors(),
getRepositoryStats(),
getCommitsData(),
])
return {
contributors,
repoStats: stats,
timelineData: generateCommitTimelineData(commits),
activityData: generateActivityData(commits),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,171 +0,0 @@
/**
* Tests for forget password API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils'
describe('Forget Password API Route', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.clearAllMocks()
})
it('should send password reset email successfully', async () => {
setupAuthApiMocks({
operations: {
forgetPassword: { success: true },
},
})
const req = createMockRequest('POST', {
email: 'test@example.com',
redirectTo: 'https://example.com/reset',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
body: {
email: 'test@example.com',
redirectTo: 'https://example.com/reset',
},
method: 'POST',
})
})
it('should send password reset email without redirectTo', async () => {
setupAuthApiMocks({
operations: {
forgetPassword: { success: true },
},
})
const req = createMockRequest('POST', {
email: 'test@example.com',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
body: {
email: 'test@example.com',
redirectTo: undefined,
},
method: 'POST',
})
})
it('should handle missing email', async () => {
setupAuthApiMocks()
const req = createMockRequest('POST', {})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Email is required')
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
})
it('should handle empty email', async () => {
setupAuthApiMocks()
const req = createMockRequest('POST', {
email: '',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Email is required')
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
})
it('should handle auth service error with message', async () => {
const errorMessage = 'User not found'
setupAuthApiMocks({
operations: {
forgetPassword: {
success: false,
error: errorMessage,
},
},
})
const req = createMockRequest('POST', {
email: 'nonexistent@example.com',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe(errorMessage)
const logger = await import('@/lib/logs/console-logger')
const mockLogger = logger.createLogger('ForgetPasswordTest')
expect(mockLogger.error).toHaveBeenCalledWith('Error requesting password reset:', {
error: expect.any(Error),
})
})
it('should handle unknown error', async () => {
setupAuthApiMocks()
vi.doMock('@/lib/auth', () => ({
auth: {
api: {
forgetPassword: vi.fn().mockRejectedValue('Unknown error'),
},
},
}))
const req = createMockRequest('POST', {
email: 'test@example.com',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe('Failed to send password reset email. Please try again later.')
const logger = await import('@/lib/logs/console-logger')
const mockLogger = logger.createLogger('ForgetPasswordTest')
expect(mockLogger.error).toHaveBeenCalled()
})
})

View File

@@ -1,220 +0,0 @@
/**
* Tests for OAuth connections API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('OAuth Connections API Route', () => {
const mockGetSession = vi.fn()
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(),
}
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
beforeEach(() => {
vi.resetModules()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue(mockUUID),
})
vi.doMock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.doMock('@/db', () => ({
db: mockDb,
}))
vi.doMock('@/db/schema', () => ({
account: { userId: 'userId', providerId: 'providerId' },
user: { email: 'email', id: 'id' },
}))
vi.doMock('drizzle-orm', () => ({
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
}))
vi.doMock('jwt-decode', () => ({
jwtDecode: vi.fn(),
}))
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should return connections successfully', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const mockAccounts = [
{
id: 'account-1',
providerId: 'google-email',
accountId: 'test@example.com',
scope: 'email profile',
updatedAt: new Date('2024-01-01'),
idToken: null,
},
{
id: 'account-2',
providerId: 'github',
accountId: 'testuser',
scope: 'repo',
updatedAt: new Date('2024-01-02'),
idToken: null,
},
]
const mockUserRecord = [{ email: 'user@example.com' }]
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(mockAccounts)
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockReturnValueOnce(mockDb)
mockDb.limit.mockResolvedValueOnce(mockUserRecord)
const req = createMockRequest('GET')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.connections).toHaveLength(2)
expect(data.connections[0]).toMatchObject({
provider: 'google-email',
baseProvider: 'google',
featureType: 'email',
isConnected: true,
})
expect(data.connections[1]).toMatchObject({
provider: 'github',
baseProvider: 'github',
featureType: 'default',
isConnected: true,
})
})
it('should handle unauthenticated user', async () => {
mockGetSession.mockResolvedValueOnce(null)
const req = createMockRequest('GET')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('User not authenticated')
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should handle user with no connections', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce([])
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockReturnValueOnce(mockDb)
mockDb.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.connections).toHaveLength(0)
})
it('should handle database error', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequest('GET')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Internal server error')
expect(mockLogger.error).toHaveBeenCalled()
})
it('should decode ID token for display name', async () => {
const { jwtDecode } = await import('jwt-decode')
const mockJwtDecode = jwtDecode as any
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const mockAccounts = [
{
id: 'account-1',
providerId: 'google',
accountId: 'google-user-id',
scope: 'email profile',
updatedAt: new Date('2024-01-01'),
idToken: 'mock-jwt-token',
},
]
mockJwtDecode.mockReturnValueOnce({
email: 'decoded@example.com',
name: 'Decoded User',
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(mockAccounts)
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockReturnValueOnce(mockDb)
mockDb.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.connections[0].accounts[0].name).toBe('decoded@example.com')
})
})

View File

@@ -1,256 +0,0 @@
/**
* Tests for OAuth credentials API route
*
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('OAuth Credentials API Route', () => {
const mockGetSession = vi.fn()
const mockParseProvider = vi.fn()
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(),
}
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
function createMockRequestWithQuery(method = 'GET', queryParams = ''): NextRequest {
const url = `http://localhost:3000/api/auth/oauth/credentials${queryParams}`
return new NextRequest(new URL(url), { method })
}
beforeEach(() => {
vi.resetModules()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue(mockUUID),
})
vi.doMock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.doMock('@/lib/oauth', () => ({
parseProvider: mockParseProvider,
}))
vi.doMock('@/db', () => ({
db: mockDb,
}))
vi.doMock('@/db/schema', () => ({
account: { userId: 'userId', providerId: 'providerId' },
user: { email: 'email', id: 'id' },
}))
vi.doMock('drizzle-orm', () => ({
and: vi.fn((...conditions) => ({ conditions, type: 'and' })),
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
}))
vi.doMock('jwt-decode', () => ({
jwtDecode: vi.fn(),
}))
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should return credentials successfully', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'google',
})
const mockAccounts = [
{
id: 'credential-1',
userId: 'user-123',
providerId: 'google-email',
accountId: 'test@example.com',
updatedAt: new Date('2024-01-01'),
idToken: null,
},
{
id: 'credential-2',
userId: 'user-123',
providerId: 'google-default',
accountId: 'user-id',
updatedAt: new Date('2024-01-02'),
idToken: null,
},
]
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(mockAccounts)
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockReturnValueOnce(mockDb)
mockDb.limit.mockResolvedValueOnce([{ email: 'user@example.com' }])
const req = createMockRequestWithQuery('GET', '?provider=google-email')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(2)
expect(data.credentials[0]).toMatchObject({
id: 'credential-1',
provider: 'google-email',
isDefault: false,
})
expect(data.credentials[1]).toMatchObject({
id: 'credential-2',
provider: 'google-email',
isDefault: true,
})
})
it('should handle unauthenticated user', async () => {
mockGetSession.mockResolvedValueOnce(null)
const req = createMockRequestWithQuery('GET', '?provider=google')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('User not authenticated')
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should handle missing provider parameter', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const req = createMockRequestWithQuery('GET')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Provider is required')
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should handle no credentials found', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'github',
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce([])
const req = createMockRequestWithQuery('GET', '?provider=github')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(0)
})
it('should decode ID token for display name', async () => {
const { jwtDecode } = await import('jwt-decode')
const mockJwtDecode = jwtDecode as any
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'google',
})
const mockAccounts = [
{
id: 'credential-1',
userId: 'user-123',
providerId: 'google-default',
accountId: 'google-user-id',
updatedAt: new Date('2024-01-01'),
idToken: 'mock-jwt-token',
},
]
mockJwtDecode.mockReturnValueOnce({
email: 'decoded@example.com',
name: 'Decoded User',
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(mockAccounts)
const req = createMockRequestWithQuery('GET', '?provider=google')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials[0].name).toBe('decoded@example.com')
})
it('should handle database error', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'google',
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequestWithQuery('GET', '?provider=google')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Internal server error')
expect(mockLogger.error).toHaveBeenCalled()
})
})

View File

@@ -1,159 +0,0 @@
/**
* Tests for OAuth disconnect API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('OAuth Disconnect API Route', () => {
const mockGetSession = vi.fn()
const mockDb = {
delete: vi.fn().mockReturnThis(),
where: vi.fn(),
}
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
beforeEach(() => {
vi.resetModules()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue(mockUUID),
})
vi.doMock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.doMock('@/db', () => ({
db: mockDb,
}))
vi.doMock('@/db/schema', () => ({
account: { userId: 'userId', providerId: 'providerId' },
}))
vi.doMock('drizzle-orm', () => ({
and: vi.fn((...conditions) => ({ conditions, type: 'and' })),
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
like: vi.fn((field, value) => ({ field, value, type: 'like' })),
or: vi.fn((...conditions) => ({ conditions, type: 'or' })),
}))
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should disconnect provider successfully', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockDb.delete.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(undefined)
const req = createMockRequest('POST', {
provider: 'google',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockLogger.info).toHaveBeenCalled()
})
it('should disconnect specific provider ID successfully', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockDb.delete.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(undefined)
const req = createMockRequest('POST', {
provider: 'google',
providerId: 'google-email',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockLogger.info).toHaveBeenCalled()
})
it('should handle unauthenticated user', async () => {
mockGetSession.mockResolvedValueOnce(null)
const req = createMockRequest('POST', {
provider: 'google',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('User not authenticated')
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should handle missing provider', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const req = createMockRequest('POST', {})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Provider is required')
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should handle database error', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockDb.delete.mockReturnValueOnce(mockDb)
mockDb.where.mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequest('POST', {
provider: 'google',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Internal server error')
expect(mockLogger.error).toHaveBeenCalled()
})
})

View File

@@ -1,110 +0,0 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { account } from '@/db/schema'
import { refreshAccessTokenIfNeeded } from '../../utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('MicrosoftFileAPI')
/**
* Get a single file from Microsoft OneDrive
*/
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID and file ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const fileId = searchParams.get('fileId')
if (!credentialId || !fileId) {
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${fileId}?$select=id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: response.status,
error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive',
})
return NextResponse.json(
{
error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive',
},
{ status: response.status }
)
}
const file = await response.json()
// Transform the response to match expected format
const transformedFile = {
id: file.id,
name: file.name,
mimeType:
file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
iconLink: file.thumbnails?.[0]?.small?.url,
webViewLink: file.webUrl,
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
createdTime: file.createdDateTime,
modifiedTime: file.lastModifiedDateTime,
size: file.size?.toString(),
owners: file.createdBy
? [
{
displayName: file.createdBy.user?.displayName || 'Unknown',
emailAddress: file.createdBy.user?.email || '',
},
]
: [],
downloadUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${file.id}/content`,
}
return NextResponse.json({ file: transformedFile }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching file from Microsoft OneDrive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,138 +0,0 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { account } from '@/db/schema'
import { refreshAccessTokenIfNeeded } from '../../utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('MicrosoftFilesAPI')
/**
* Get Excel files from Microsoft OneDrive
*/
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8) // Generate a short request ID for correlation
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const query = searchParams.get('query') || ''
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build search query for Excel files
let searchQuery = '.xlsx'
if (query) {
searchQuery = `${query} .xlsx`
}
// Build the query parameters for Microsoft Graph API
const searchParams_new = new URLSearchParams()
searchParams_new.append(
'$select',
'id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy'
)
searchParams_new.append('$top', '50')
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: response.status,
error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive',
})
return NextResponse.json(
{
error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive',
},
{ status: response.status }
)
}
const data = await response.json()
let files = data.value || []
// Transform Microsoft Graph response to match expected format and filter for Excel files
files = files
.filter(
(file: any) =>
file.name?.toLowerCase().endsWith('.xlsx') ||
file.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
.map((file: any) => ({
id: file.id,
name: file.name,
mimeType:
file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
iconLink: file.thumbnails?.[0]?.small?.url,
webViewLink: file.webUrl,
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
createdTime: file.createdDateTime,
modifiedTime: file.lastModifiedDateTime,
size: file.size?.toString(),
owners: file.createdBy
? [
{
displayName: file.createdBy.user?.displayName || 'Unknown',
emailAddress: file.createdBy.user?.email || '',
},
]
: [],
}))
return NextResponse.json({ files }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching Excel files from Microsoft OneDrive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,188 +0,0 @@
/**
* Tests for reset password API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils'
describe('Reset Password API Route', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.clearAllMocks()
})
it('should reset password successfully', async () => {
setupAuthApiMocks({
operations: {
resetPassword: { success: true },
},
})
const req = createMockRequest('POST', {
token: 'valid-reset-token',
newPassword: 'newSecurePassword123',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
const auth = await import('@/lib/auth')
expect(auth.auth.api.resetPassword).toHaveBeenCalledWith({
body: {
token: 'valid-reset-token',
newPassword: 'newSecurePassword123',
},
method: 'POST',
})
})
it('should handle missing token', async () => {
setupAuthApiMocks()
const req = createMockRequest('POST', {
newPassword: 'newSecurePassword123',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Token and new password are required')
const auth = await import('@/lib/auth')
expect(auth.auth.api.resetPassword).not.toHaveBeenCalled()
})
it('should handle missing new password', async () => {
setupAuthApiMocks()
const req = createMockRequest('POST', {
token: 'valid-reset-token',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Token and new password are required')
const auth = await import('@/lib/auth')
expect(auth.auth.api.resetPassword).not.toHaveBeenCalled()
})
it('should handle empty token', async () => {
setupAuthApiMocks()
const req = createMockRequest('POST', {
token: '',
newPassword: 'newSecurePassword123',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Token and new password are required')
const auth = await import('@/lib/auth')
expect(auth.auth.api.resetPassword).not.toHaveBeenCalled()
})
it('should handle empty new password', async () => {
setupAuthApiMocks()
const req = createMockRequest('POST', {
token: 'valid-reset-token',
newPassword: '',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Token and new password are required')
const auth = await import('@/lib/auth')
expect(auth.auth.api.resetPassword).not.toHaveBeenCalled()
})
it('should handle auth service error with message', async () => {
const errorMessage = 'Invalid or expired token'
setupAuthApiMocks({
operations: {
resetPassword: {
success: false,
error: errorMessage,
},
},
})
const req = createMockRequest('POST', {
token: 'invalid-token',
newPassword: 'newSecurePassword123',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe(errorMessage)
const logger = await import('@/lib/logs/console-logger')
const mockLogger = logger.createLogger('PasswordReset')
expect(mockLogger.error).toHaveBeenCalledWith('Error during password reset:', {
error: expect.any(Error),
})
})
it('should handle unknown error', async () => {
setupAuthApiMocks()
vi.doMock('@/lib/auth', () => ({
auth: {
api: {
resetPassword: vi.fn().mockRejectedValue('Unknown error'),
},
},
}))
const req = createMockRequest('POST', {
token: 'valid-reset-token',
newPassword: 'newSecurePassword123',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe(
'Failed to reset password. Please try again or request a new reset link.'
)
const logger = await import('@/lib/logs/console-logger')
const mockLogger = logger.createLogger('PasswordReset')
expect(mockLogger.error).toHaveBeenCalled()
})
})

View File

@@ -1,20 +0,0 @@
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
export async function POST() {
try {
const response = await auth.api.generateOneTimeToken({
headers: await headers(),
})
if (!response) {
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
return NextResponse.json({ token: response.token })
} catch (error) {
console.error('Error generating one-time token:', error)
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
}

View File

@@ -1,234 +0,0 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import { chat, workflow } from '@/db/schema'
import {
addCorsHeaders,
executeWorkflowForChat,
setChatAuthCookie,
validateAuthToken,
validateChatAuth,
} from '../utils'
const logger = createLogger('ChatSubdomainAPI')
// This endpoint handles chat interactions via the subdomain
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ subdomain: string }> }
) {
const { subdomain } = await params
const requestId = crypto.randomUUID().slice(0, 8)
try {
logger.debug(`[${requestId}] Processing chat request for subdomain: ${subdomain}`)
// Parse the request body once
let parsedBody
try {
parsedBody = await request.json()
} catch (_error) {
return addCorsHeaders(createErrorResponse('Invalid request body', 400), request)
}
// Find the chat deployment for this subdomain
const deploymentResult = await db
.select({
id: chat.id,
workflowId: chat.workflowId,
userId: chat.userId,
isActive: chat.isActive,
authType: chat.authType,
password: chat.password,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
})
.from(chat)
.where(eq(chat.subdomain, subdomain))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
}
const deployment = deploymentResult[0]
// Check if the chat is active
if (!deployment.isActive) {
logger.warn(`[${requestId}] Chat is not active: ${subdomain}`)
return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request)
}
// Validate authentication with the parsed body
const authResult = await validateChatAuth(requestId, deployment, request, parsedBody)
if (!authResult.authorized) {
return addCorsHeaders(
createErrorResponse(authResult.error || 'Authentication required', 401),
request
)
}
// Use the already parsed body
const { input, password, email, conversationId } = parsedBody
// If this is an authentication request (has password or email but no input),
// set auth cookie and return success
if ((password || email) && !input) {
const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request)
// Set authentication cookie
setChatAuthCookie(response, deployment.id, deployment.authType)
return response
}
// For chat messages, create regular response
if (!input) {
return addCorsHeaders(createErrorResponse('No input provided', 400), request)
}
// Get the workflow for this chat
const workflowResult = await db
.select({
isDeployed: workflow.isDeployed,
})
.from(workflow)
.where(eq(workflow.id, deployment.workflowId))
.limit(1)
if (workflowResult.length === 0 || !workflowResult[0].isDeployed) {
logger.warn(`[${requestId}] Workflow not found or not deployed: ${deployment.workflowId}`)
return addCorsHeaders(createErrorResponse('Chat workflow is not available', 503), request)
}
try {
// Execute workflow with structured input (input + conversationId for context)
const result = await executeWorkflowForChat(deployment.id, input, conversationId)
// The result is always a ReadableStream that we can pipe to the client
const streamResponse = new NextResponse(result, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
return addCorsHeaders(streamResponse, request)
} catch (error: any) {
logger.error(`[${requestId}] Error processing chat request:`, error)
return addCorsHeaders(
createErrorResponse(error.message || 'Failed to process request', 500),
request
)
}
} catch (error: any) {
logger.error(`[${requestId}] Error processing chat request:`, error)
return addCorsHeaders(
createErrorResponse(error.message || 'Failed to process request', 500),
request
)
}
}
// This endpoint returns information about the chat
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ subdomain: string }> }
) {
const { subdomain } = await params
const requestId = crypto.randomUUID().slice(0, 8)
try {
logger.debug(`[${requestId}] Fetching chat info for subdomain: ${subdomain}`)
// Find the chat deployment for this subdomain
const deploymentResult = await db
.select({
id: chat.id,
title: chat.title,
description: chat.description,
customizations: chat.customizations,
isActive: chat.isActive,
workflowId: chat.workflowId,
authType: chat.authType,
password: chat.password,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
})
.from(chat)
.where(eq(chat.subdomain, subdomain))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
}
const deployment = deploymentResult[0]
// Check if the chat is active
if (!deployment.isActive) {
logger.warn(`[${requestId}] Chat is not active: ${subdomain}`)
return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request)
}
// Check for auth cookie first
const cookieName = `chat_auth_${deployment.id}`
const authCookie = request.cookies.get(cookieName)
if (
deployment.authType !== 'public' &&
authCookie &&
validateAuthToken(authCookie.value, deployment.id)
) {
// Cookie valid, return chat info
return addCorsHeaders(
createSuccessResponse({
id: deployment.id,
title: deployment.title,
description: deployment.description,
customizations: deployment.customizations,
authType: deployment.authType,
outputConfigs: deployment.outputConfigs,
}),
request
)
}
// If no valid cookie, proceed with standard auth check
const authResult = await validateChatAuth(requestId, deployment, request)
if (!authResult.authorized) {
logger.info(
`[${requestId}] Authentication required for chat: ${subdomain}, type: ${deployment.authType}`
)
return addCorsHeaders(
createErrorResponse(authResult.error || 'Authentication required', 401),
request
)
}
// Return public information about the chat including auth type
return addCorsHeaders(
createSuccessResponse({
id: deployment.id,
title: deployment.title,
description: deployment.description,
customizations: deployment.customizations,
authType: deployment.authType,
outputConfigs: deployment.outputConfigs,
}),
request
)
} catch (error: any) {
logger.error(`[${requestId}] Error fetching chat info:`, error)
return addCorsHeaders(
createErrorResponse(error.message || 'Failed to fetch chat information', 500),
request
)
}
}

View File

@@ -1,568 +0,0 @@
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import { decryptSecret } from '@/lib/utils'
import { db } from '@/db'
import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
import { Executor } from '@/executor'
import type { BlockLog } from '@/executor/types'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
declare global {
var __chatStreamProcessingTasks: Promise<{ success: boolean; error?: any }>[] | undefined
}
const logger = createLogger('ChatAuthUtils')
const isDevelopment = env.NODE_ENV === 'development'
export const encryptAuthToken = (subdomainId: string, type: string): string => {
return Buffer.from(`${subdomainId}:${type}:${Date.now()}`).toString('base64')
}
export const validateAuthToken = (token: string, subdomainId: string): boolean => {
try {
const decoded = Buffer.from(token, 'base64').toString()
const [storedId, _type, timestamp] = decoded.split(':')
// Check if token is for this subdomain
if (storedId !== subdomainId) {
return false
}
// Check if token is not expired (24 hours)
const createdAt = Number.parseInt(timestamp)
const now = Date.now()
const expireTime = 24 * 60 * 60 * 1000 // 24 hours
if (now - createdAt > expireTime) {
return false
}
return true
} catch (_e) {
return false
}
}
// Set cookie helper function
export const setChatAuthCookie = (
response: NextResponse,
subdomainId: string,
type: string
): void => {
const token = encryptAuthToken(subdomainId, type)
// Set cookie with HttpOnly and secure flags
response.cookies.set({
name: `chat_auth_${subdomainId}`,
value: token,
httpOnly: true,
secure: !isDevelopment,
sameSite: 'lax',
path: '/',
// Using subdomain for the domain in production
domain: isDevelopment ? undefined : '.simstudio.ai',
maxAge: 60 * 60 * 24, // 24 hours
})
}
// Helper function to add CORS headers to responses
export function addCorsHeaders(response: NextResponse, request: NextRequest) {
// Get the origin from the request
const origin = request.headers.get('origin') || ''
// In development, allow any localhost subdomain
if (isDevelopment && origin.includes('localhost')) {
response.headers.set('Access-Control-Allow-Origin', origin)
response.headers.set('Access-Control-Allow-Credentials', 'true')
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With')
}
return response
}
// Handle OPTIONS requests for CORS preflight
export async function OPTIONS(request: NextRequest) {
const response = new NextResponse(null, { status: 204 })
return addCorsHeaders(response, request)
}
// Validate authentication for chat access
export async function validateChatAuth(
requestId: string,
deployment: any,
request: NextRequest,
parsedBody?: any
): Promise<{ authorized: boolean; error?: string }> {
const authType = deployment.authType || 'public'
// Public chats are accessible to everyone
if (authType === 'public') {
return { authorized: true }
}
// Check for auth cookie first
const cookieName = `chat_auth_${deployment.id}`
const authCookie = request.cookies.get(cookieName)
if (authCookie && validateAuthToken(authCookie.value, deployment.id)) {
return { authorized: true }
}
// For password protection, check the password in the request body
if (authType === 'password') {
// For GET requests, we just notify the client that authentication is required
if (request.method === 'GET') {
return { authorized: false, error: 'auth_required_password' }
}
try {
// Use the parsed body if provided, otherwise the auth check is not applicable
if (!parsedBody) {
return { authorized: false, error: 'Password is required' }
}
const { password, input } = parsedBody
// If this is a chat message, not an auth attempt
if (input && !password) {
return { authorized: false, error: 'auth_required_password' }
}
if (!password) {
return { authorized: false, error: 'Password is required' }
}
if (!deployment.password) {
logger.error(`[${requestId}] No password set for password-protected chat: ${deployment.id}`)
return { authorized: false, error: 'Authentication configuration error' }
}
// Decrypt the stored password and compare
const { decrypted } = await decryptSecret(deployment.password)
if (password !== decrypted) {
return { authorized: false, error: 'Invalid password' }
}
return { authorized: true }
} catch (error) {
logger.error(`[${requestId}] Error validating password:`, error)
return { authorized: false, error: 'Authentication error' }
}
}
// For email access control, check the email in the request body
if (authType === 'email') {
// For GET requests, we just notify the client that authentication is required
if (request.method === 'GET') {
return { authorized: false, error: 'auth_required_email' }
}
try {
// Use the parsed body if provided, otherwise the auth check is not applicable
if (!parsedBody) {
return { authorized: false, error: 'Email is required' }
}
const { email, input } = parsedBody
// If this is a chat message, not an auth attempt
if (input && !email) {
return { authorized: false, error: 'auth_required_email' }
}
if (!email) {
return { authorized: false, error: 'Email is required' }
}
const allowedEmails = deployment.allowedEmails || []
// Check exact email matches
if (allowedEmails.includes(email)) {
// Email is allowed but still needs OTP verification
// Return a special error code that the client will recognize
return { authorized: false, error: 'otp_required' }
}
// Check domain matches (prefixed with @)
const domain = email.split('@')[1]
if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) {
// Domain is allowed but still needs OTP verification
return { authorized: false, error: 'otp_required' }
}
return { authorized: false, error: 'Email not authorized' }
} catch (error) {
logger.error(`[${requestId}] Error validating email:`, error)
return { authorized: false, error: 'Authentication error' }
}
}
// Unknown auth type
return { authorized: false, error: 'Unsupported authentication type' }
}
/**
* Executes a workflow for a chat request and returns the formatted output.
*
* When workflows reference <start.input>, they receive the input directly.
* The conversationId is available at <start.conversationId> for maintaining chat context.
*
* @param chatId - Chat deployment identifier
* @param input - User's chat input
* @param conversationId - Optional ID for maintaining conversation context
* @returns Workflow execution result formatted for the chat interface
*/
export async function executeWorkflowForChat(
chatId: string,
input: string,
conversationId?: string
): Promise<any> {
const requestId = crypto.randomUUID().slice(0, 8)
logger.debug(
`[${requestId}] Executing workflow for chat: ${chatId}${
conversationId ? `, conversationId: ${conversationId}` : ''
}`
)
// Find the chat deployment
const deploymentResult = await db
.select({
id: chat.id,
workflowId: chat.workflowId,
userId: chat.userId,
outputConfigs: chat.outputConfigs,
customizations: chat.customizations,
})
.from(chat)
.where(eq(chat.id, chatId))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found: ${chatId}`)
throw new Error('Chat not found')
}
const deployment = deploymentResult[0]
const workflowId = deployment.workflowId
const executionId = uuidv4()
// Set up enhanced logging for chat execution
const loggingSession = new EnhancedLoggingSession(workflowId, executionId, 'chat', requestId)
// Check for multi-output configuration in customizations
const customizations = (deployment.customizations || {}) as Record<string, any>
let outputBlockIds: string[] = []
// Extract output configs from the new schema format
let selectedOutputIds: string[] = []
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
// Extract output IDs in the format expected by the streaming processor
logger.debug(
`[${requestId}] Found ${deployment.outputConfigs.length} output configs in deployment`
)
selectedOutputIds = deployment.outputConfigs.map((config) => {
const outputId = config.path
? `${config.blockId}_${config.path}`
: `${config.blockId}.content`
logger.debug(
`[${requestId}] Processing output config: blockId=${config.blockId}, path=${config.path || 'content'} -> outputId=${outputId}`
)
return outputId
})
// Also extract block IDs for legacy compatibility
outputBlockIds = deployment.outputConfigs.map((config) => config.blockId)
} else {
// Use customizations as fallback
outputBlockIds = Array.isArray(customizations.outputBlockIds)
? customizations.outputBlockIds
: []
}
// Fall back to customizations if we still have no outputs
if (
outputBlockIds.length === 0 &&
customizations.outputBlockIds &&
customizations.outputBlockIds.length > 0
) {
outputBlockIds = customizations.outputBlockIds
}
logger.debug(
`[${requestId}] Using ${outputBlockIds.length} output blocks and ${selectedOutputIds.length} selected output IDs for extraction`
)
// Find the workflow (deployedState is NOT deprecated - needed for chat execution)
const workflowResult = await db
.select({
isDeployed: workflow.isDeployed,
deployedState: workflow.deployedState,
variables: workflow.variables,
})
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowResult.length === 0 || !workflowResult[0].isDeployed) {
logger.warn(`[${requestId}] Workflow not found or not deployed: ${workflowId}`)
throw new Error('Workflow not available')
}
// For chat execution, use ONLY the deployed state (no fallback)
if (!workflowResult[0].deployedState) {
throw new Error(`Workflow must be deployed to be available for chat`)
}
// Use deployed state for chat execution (this is the stable, deployed version)
const deployedState = workflowResult[0].deployedState as WorkflowState
const { blocks, edges, loops, parallels } = deployedState
// Prepare for execution, similar to use-workflow-execution.ts
const mergedStates = mergeSubblockState(blocks)
const currentBlockStates = Object.entries(mergedStates).reduce(
(acc, [id, block]) => {
acc[id] = Object.entries(block.subBlocks).reduce(
(subAcc, [key, subBlock]) => {
subAcc[key] = subBlock.value
return subAcc
},
{} as Record<string, any>
)
return acc
},
{} as Record<string, Record<string, any>>
)
// Get user environment variables for this workflow
let envVars: Record<string, string> = {}
try {
const envResult = await db
.select()
.from(envTable)
.where(eq(envTable.userId, deployment.userId))
.limit(1)
if (envResult.length > 0 && envResult[0].variables) {
envVars = envResult[0].variables as Record<string, string>
}
} catch (error) {
logger.warn(`[${requestId}] Could not fetch environment variables:`, error)
}
let workflowVariables = {}
try {
if (workflowResult[0].variables) {
workflowVariables =
typeof workflowResult[0].variables === 'string'
? JSON.parse(workflowResult[0].variables)
: workflowResult[0].variables
}
} catch (error) {
logger.warn(`[${requestId}] Could not parse workflow variables:`, error)
}
// Create serialized workflow
const serializedWorkflow = new Serializer().serializeWorkflow(
mergedStates,
edges,
loops,
parallels
)
// Decrypt environment variables
const decryptedEnvVars: Record<string, string> = {}
for (const [key, encryptedValue] of Object.entries(envVars)) {
try {
const { decrypted } = await decryptSecret(encryptedValue)
decryptedEnvVars[key] = decrypted
} catch (error: any) {
logger.error(`[${requestId}] Failed to decrypt environment variable "${key}"`, error)
// Log but continue - we don't want to break execution if just one var fails
}
}
// Process block states to ensure response formats are properly parsed
const processedBlockStates = Object.entries(currentBlockStates).reduce(
(acc, [blockId, blockState]) => {
// Check if this block has a responseFormat that needs to be parsed
if (blockState.responseFormat && typeof blockState.responseFormat === 'string') {
try {
logger.debug(`[${requestId}] Parsing responseFormat for block ${blockId}`)
// Attempt to parse the responseFormat if it's a string
const parsedResponseFormat = JSON.parse(blockState.responseFormat)
acc[blockId] = {
...blockState,
responseFormat: parsedResponseFormat,
}
} catch (error) {
logger.warn(`[${requestId}] Failed to parse responseFormat for block ${blockId}`, error)
acc[blockId] = blockState
}
} else {
acc[blockId] = blockState
}
return acc
},
{} as Record<string, Record<string, any>>
)
// Start enhanced logging session
await loggingSession.safeStart({
userId: deployment.userId,
workspaceId: '', // TODO: Get from workflow
variables: workflowVariables,
})
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
const streamedContent = new Map<string, string>()
const onStream = async (streamingExecution: any): Promise<void> => {
if (!streamingExecution.stream) return
const blockId = streamingExecution.execution?.blockId
const reader = streamingExecution.stream.getReader()
if (blockId) {
streamedContent.set(blockId, '')
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ blockId, event: 'end' })}\n\n`)
)
break
}
const chunk = new TextDecoder().decode(value)
if (blockId) {
streamedContent.set(blockId, (streamedContent.get(blockId) || '') + chunk)
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ blockId, chunk })}\n\n`))
}
} catch (error) {
logger.error('Error while reading from stream:', error)
controller.error(error)
}
}
const executor = new Executor({
workflow: serializedWorkflow,
currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars,
workflowInput: { input: input, conversationId },
workflowVariables,
contextExtensions: {
stream: true,
selectedOutputIds: selectedOutputIds.length > 0 ? selectedOutputIds : outputBlockIds,
edges: edges.map((e: any) => ({
source: e.source,
target: e.target,
})),
onStream,
},
})
// Set up enhanced logging on the executor
loggingSession.setupExecutor(executor)
let result
try {
result = await executor.execute(workflowId)
} catch (error: any) {
logger.error(`[${requestId}] Chat workflow execution failed:`, error)
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: {
message: error.message || 'Chat workflow execution failed',
stackTrace: error.stack,
},
})
throw error
}
if (result && 'success' in result) {
// Update streamed content and apply tokenization
if (result.logs) {
result.logs.forEach((log: BlockLog) => {
if (streamedContent.has(log.blockId)) {
const content = streamedContent.get(log.blockId)
if (log.output) {
log.output.content = content
}
}
})
// Process all logs for streaming tokenization
const processedCount = processStreamingBlockLogs(result.logs, streamedContent)
logger.info(`[CHAT-API] Processed ${processedCount} blocks for streaming tokenization`)
}
const { traceSpans, totalDuration } = buildTraceSpans(result)
const enrichedResult = { ...result, traceSpans, totalDuration }
if (conversationId) {
if (!enrichedResult.metadata) {
enrichedResult.metadata = {
duration: totalDuration,
startTime: new Date().toISOString(),
}
}
;(enrichedResult.metadata as any).conversationId = conversationId
}
const executionId = uuidv4()
logger.debug(`Generated execution ID for deployed chat: ${executionId}`)
if (result.success) {
try {
await db
.update(userStats)
.set({
totalChatExecutions: sql`total_chat_executions + 1`,
lastActive: new Date(),
})
.where(eq(userStats.userId, deployment.userId))
logger.debug(`Updated user stats for deployed chat: ${deployment.userId}`)
} catch (error) {
logger.error(`Failed to update user stats for deployed chat:`, error)
}
}
}
if (!(result && typeof result === 'object' && 'stream' in result)) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ event: 'final', data: result })}\n\n`)
)
}
// Complete enhanced logging session (for both success and failure)
if (result && 'success' in result) {
const { traceSpans } = buildTraceSpans(result)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: result.metadata?.duration || 0,
finalOutput: result.output,
traceSpans,
})
}
controller.close()
},
})
return stream
}

View File

@@ -1,390 +0,0 @@
/**
* Tests for codegen API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('Codegen API Route', () => {
const mockOpenAI = {
chat: {
completions: {
create: vi.fn(),
},
},
}
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}
const mockEnv = {
OPENAI_API_KEY: 'test-api-key',
}
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
beforeEach(() => {
vi.resetModules()
mockEnv.OPENAI_API_KEY = 'test-api-key'
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue(mockUUID),
})
const MockAPIError = class extends Error {
status: number
constructor(message: string, status?: number) {
super(message)
this.status = status || 500
}
}
vi.doMock('openai', () => ({
default: vi.fn().mockImplementation(() => mockOpenAI),
APIError: MockAPIError,
}))
vi.doMock('@/lib/env', () => ({
env: mockEnv,
}))
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('next/cache', () => ({
unstable_noStore: vi.fn(),
}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should generate JSON schema successfully', async () => {
const mockResponse = {
choices: [
{
message: {
content: JSON.stringify({
name: 'test_function',
description: 'A test function',
strict: true,
schema: {
type: 'object',
properties: {
input: { type: 'string', description: 'Test input' },
},
additionalProperties: false,
required: ['input'],
},
}),
},
},
],
}
mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse)
const req = createMockRequest('POST', {
prompt: 'Create a function that takes a string input',
generationType: 'json-schema',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.generatedContent).toBeDefined()
expect(() => JSON.parse(data.generatedContent)).not.toThrow()
expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({
model: 'gpt-4o',
messages: expect.arrayContaining([
expect.objectContaining({ role: 'system' }),
expect.objectContaining({
role: 'user',
content: 'Create a function that takes a string input',
}),
]),
temperature: 0.2,
max_tokens: 1500,
response_format: { type: 'json_object' },
})
})
it('should generate JavaScript function body successfully', async () => {
const mockResponse = {
choices: [
{
message: {
content: 'const input = <input>;\nreturn input.toUpperCase();',
},
},
],
}
mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse)
const req = createMockRequest('POST', {
prompt: 'Convert input to uppercase',
generationType: 'javascript-function-body',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.generatedContent).toBe('const input = <input>;\nreturn input.toUpperCase();')
expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({
model: 'gpt-4o',
messages: expect.arrayContaining([
expect.objectContaining({ role: 'system' }),
expect.objectContaining({ role: 'user' }),
]),
temperature: 0.2,
max_tokens: 1500,
response_format: undefined,
})
})
it('should generate custom tool schema successfully', async () => {
const mockResponse = {
choices: [
{
message: {
content: JSON.stringify({
type: 'function',
function: {
name: 'testFunction',
description: 'A test function',
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: 'Test input' },
},
required: ['input'],
additionalProperties: false,
},
},
}),
},
},
],
}
mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse)
const req = createMockRequest('POST', {
prompt: 'Create a custom tool for testing',
generationType: 'custom-tool-schema',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.generatedContent).toBeDefined()
})
it('should include context in the prompt', async () => {
const mockResponse = {
choices: [
{
message: {
content: 'const result = <input>;\nreturn result;',
},
},
],
}
mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse)
const req = createMockRequest('POST', {
prompt: 'Modify this function',
generationType: 'javascript-function-body',
context: 'existing function code here',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({
model: 'gpt-4o',
messages: expect.arrayContaining([
expect.objectContaining({ role: 'system' }),
expect.objectContaining({
role: 'user',
content:
'Prompt: Modify this function\\n\\nExisting Content/Context:\\nexisting function code here',
}),
]),
temperature: 0.2,
max_tokens: 1500,
response_format: undefined,
})
})
it('should include conversation history', async () => {
const mockResponse = {
choices: [
{
message: {
content: 'Updated function code',
},
},
],
}
mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse)
const req = createMockRequest('POST', {
prompt: 'Update the function',
generationType: 'javascript-function-body',
history: [
{ role: 'user', content: 'Create a function' },
{ role: 'assistant', content: 'function created' },
],
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({
model: 'gpt-4o',
messages: expect.arrayContaining([
expect.objectContaining({ role: 'system' }),
expect.objectContaining({ role: 'user', content: 'Create a function' }),
expect.objectContaining({ role: 'assistant', content: 'function created' }),
expect.objectContaining({ role: 'user', content: 'Update the function' }),
]),
temperature: 0.2,
max_tokens: 1500,
response_format: undefined,
})
})
it('should handle missing OpenAI API key', async () => {
mockEnv.OPENAI_API_KEY = ''
const req = createMockRequest('POST', {
prompt: 'Test prompt',
generationType: 'json-schema',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(503)
expect(data.success).toBe(false)
expect(data.error).toBe('Code generation service is not configured.')
})
it('should handle missing required fields', async () => {
const req = createMockRequest('POST', {
prompt: '',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.success).toBe(false)
expect(data.error).toBe('Missing required fields: prompt and generationType.')
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should handle invalid generation type', async () => {
const req = createMockRequest('POST', {
prompt: 'Test prompt',
generationType: 'invalid-type',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.success).toBe(false)
expect(data.error).toBe('Invalid generationType: invalid-type')
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should handle empty OpenAI response', async () => {
const mockResponse = {
choices: [
{
message: {
content: null,
},
},
],
}
mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse)
const req = createMockRequest('POST', {
prompt: 'Test prompt',
generationType: 'javascript-function-body',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
expect(data.error).toBe('Failed to generate content. OpenAI response was empty.')
expect(mockLogger.error).toHaveBeenCalled()
})
it('should handle invalid JSON schema generation', async () => {
const mockResponse = {
choices: [
{
message: {
content: 'invalid json content',
},
},
],
}
mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse)
const req = createMockRequest('POST', {
prompt: 'Create a schema',
generationType: 'json-schema',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
expect(data.error).toBe('Generated JSON schema was invalid.')
expect(mockLogger.error).toHaveBeenCalled()
})
})

View File

@@ -1,149 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, setupFileApiMocks } from '@/app/api/__test-utils__/utils'
describe('File Delete API Route', () => {
beforeEach(() => {
vi.resetModules()
vi.doMock('@/lib/uploads/setup.server', () => ({}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should handle local file deletion successfully', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 'local',
})
const req = createMockRequest('POST', {
filePath: '/api/files/serve/test-file.txt',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('message')
expect(['File deleted successfully', "File not found, but that's okay"]).toContain(data.message)
})
it('should handle file not found gracefully', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 'local',
})
const req = createMockRequest('POST', {
filePath: '/api/files/serve/nonexistent.txt',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('message')
})
it('should handle S3 file deletion successfully', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
vi.doMock('@/lib/uploads', () => ({
deleteFile: vi.fn().mockResolvedValue(undefined),
isUsingCloudStorage: vi.fn().mockReturnValue(true),
uploadFile: vi.fn().mockResolvedValue({
path: '/api/files/serve/test-key',
key: 'test-key',
name: 'test.txt',
size: 100,
type: 'text/plain',
}),
}))
const req = createMockRequest('POST', {
filePath: '/api/files/serve/s3/1234567890-test-file.txt',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('message', 'File deleted successfully from cloud storage')
const uploads = await import('@/lib/uploads')
expect(uploads.deleteFile).toHaveBeenCalledWith('1234567890-test-file.txt')
})
it('should handle Azure Blob file deletion successfully', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 'blob',
})
vi.doMock('@/lib/uploads', () => ({
deleteFile: vi.fn().mockResolvedValue(undefined),
isUsingCloudStorage: vi.fn().mockReturnValue(true),
uploadFile: vi.fn().mockResolvedValue({
path: '/api/files/serve/test-key',
key: 'test-key',
name: 'test.txt',
size: 100,
type: 'text/plain',
}),
}))
const req = createMockRequest('POST', {
filePath: '/api/files/serve/blob/1234567890-test-document.pdf',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('message', 'File deleted successfully from cloud storage')
const uploads = await import('@/lib/uploads')
expect(uploads.deleteFile).toHaveBeenCalledWith('1234567890-test-document.pdf')
})
it('should handle missing file path', async () => {
setupFileApiMocks()
const req = createMockRequest('POST', {})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'InvalidRequestError')
expect(data).toHaveProperty('message', 'No file path provided')
})
it('should handle CORS preflight requests', async () => {
const { OPTIONS } = await import('./route')
const response = await OPTIONS()
expect(response.status).toBe(204)
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, DELETE, OPTIONS')
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type')
})
})

View File

@@ -1,377 +0,0 @@
import path from 'path'
import { NextRequest } from 'next/server'
/**
* Tests for file parse API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, setupFileApiMocks } from '@/app/api/__test-utils__/utils'
import { POST } from './route'
const mockJoin = vi.fn((...args: string[]): string => {
if (args[0] === '/test/uploads') {
return `/test/uploads/${args[args.length - 1]}`
}
return path.join(...args)
})
describe('File Parse API Route', () => {
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
vi.doMock('@/lib/file-parsers', () => ({
isSupportedFileType: vi.fn().mockReturnValue(true),
parseFile: vi.fn().mockResolvedValue({
content: 'parsed content',
metadata: { pageCount: 1 },
}),
parseBuffer: vi.fn().mockResolvedValue({
content: 'parsed buffer content',
metadata: { pageCount: 1 },
}),
}))
vi.doMock('path', () => {
return {
...path,
join: mockJoin,
basename: path.basename,
extname: path.extname,
}
})
vi.doMock('@/lib/uploads/setup.server', () => ({}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should handle missing file path', async () => {
setupFileApiMocks()
const req = createMockRequest('POST', {})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'No file path provided')
})
it('should accept and process a local file', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 'local',
})
const req = createMockRequest('POST', {
filePath: '/api/files/serve/test-file.txt',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).not.toBeNull()
if (data.success === true) {
expect(data).toHaveProperty('output')
} else {
expect(data).toHaveProperty('error')
expect(typeof data.error).toBe('string')
}
})
it('should process S3 files', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const req = createMockRequest('POST', {
filePath: '/api/files/serve/s3/test-file.pdf',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
if (data.success === true) {
expect(data).toHaveProperty('output')
} else {
expect(data).toHaveProperty('error')
}
})
it('should handle multiple files', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 'local',
})
const req = createMockRequest('POST', {
filePath: ['/api/files/serve/file1.txt', '/api/files/serve/file2.txt'],
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('success')
expect(data).toHaveProperty('results')
expect(Array.isArray(data.results)).toBe(true)
expect(data.results).toHaveLength(2)
})
it('should handle S3 access errors gracefully', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
// Override with error-throwing mock
vi.doMock('@/lib/uploads', () => ({
downloadFile: vi.fn().mockRejectedValue(new Error('Access denied')),
isUsingCloudStorage: vi.fn().mockReturnValue(true),
uploadFile: vi.fn().mockResolvedValue({
path: '/api/files/serve/test-key',
key: 'test-key',
name: 'test.txt',
size: 100,
type: 'text/plain',
}),
}))
const req = new NextRequest('http://localhost:3000/api/files/parse', {
method: 'POST',
body: JSON.stringify({
filePath: '/api/files/serve/s3/test-file.txt',
}),
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data).toHaveProperty('success', false)
expect(data).toHaveProperty('error')
expect(data.error).toContain('Access denied')
})
it('should handle access errors gracefully', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 'local',
})
vi.doMock('fs/promises', () => ({
access: vi.fn().mockRejectedValue(new Error('ENOENT: no such file')),
stat: vi.fn().mockImplementation(() => ({ isFile: () => true })),
readFile: vi.fn().mockResolvedValue(Buffer.from('test file content')),
writeFile: vi.fn().mockResolvedValue(undefined),
}))
const req = createMockRequest('POST', {
filePath: '/api/files/serve/nonexistent.txt',
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('success')
expect(data).toHaveProperty('error')
})
})
describe('Files Parse API - Path Traversal Security', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Path Traversal Prevention', () => {
it('should reject path traversal attempts with .. segments', async () => {
const maliciousRequests = [
'../../../etc/passwd',
'/api/files/serve/../../../etc/passwd',
'/api/files/serve/../../app.js',
'/api/files/serve/../.env',
'uploads/../../../etc/hosts',
]
for (const maliciousPath of maliciousRequests) {
const request = new NextRequest('http://localhost:3000/api/files/parse', {
method: 'POST',
body: JSON.stringify({
filePath: maliciousPath,
}),
})
const response = await POST(request)
const result = await response.json()
expect(result.success).toBe(false)
expect(result.error).toMatch(/Access denied|Invalid path|Path outside allowed directory/)
}
})
it('should reject paths with tilde characters', async () => {
const maliciousPaths = [
'~/../../etc/passwd',
'/api/files/serve/~/secret.txt',
'~root/.ssh/id_rsa',
]
for (const maliciousPath of maliciousPaths) {
const request = new NextRequest('http://localhost:3000/api/files/parse', {
method: 'POST',
body: JSON.stringify({
filePath: maliciousPath,
}),
})
const response = await POST(request)
const result = await response.json()
expect(result.success).toBe(false)
expect(result.error).toMatch(/Access denied|Invalid path/)
}
})
it('should reject absolute paths outside upload directory', async () => {
const maliciousPaths = [
'/etc/passwd',
'/root/.bashrc',
'/app/.env',
'/var/log/auth.log',
'C:\\Windows\\System32\\drivers\\etc\\hosts',
]
for (const maliciousPath of maliciousPaths) {
const request = new NextRequest('http://localhost:3000/api/files/parse', {
method: 'POST',
body: JSON.stringify({
filePath: maliciousPath,
}),
})
const response = await POST(request)
const result = await response.json()
expect(result.success).toBe(false)
expect(result.error).toMatch(/Access denied|Path outside allowed directory/)
}
})
it('should allow valid paths within upload directory', async () => {
const validPaths = [
'/api/files/serve/document.txt',
'/api/files/serve/folder/file.pdf',
'/api/files/serve/subfolder/image.png',
]
for (const validPath of validPaths) {
const request = new NextRequest('http://localhost:3000/api/files/parse', {
method: 'POST',
body: JSON.stringify({
filePath: validPath,
}),
})
const response = await POST(request)
const result = await response.json()
if (result.error) {
expect(result.error).not.toMatch(
/Access denied|Path outside allowed directory|Invalid path/
)
}
}
})
it('should handle encoded path traversal attempts', async () => {
const encodedMaliciousPaths = [
'/api/files/serve/%2e%2e%2f%2e%2e%2fetc%2fpasswd', // ../../../etc/passwd
'/api/files/serve/..%2f..%2f..%2fetc%2fpasswd',
'/api/files/serve/%2e%2e/%2e%2e/etc/passwd',
]
for (const maliciousPath of encodedMaliciousPaths) {
const request = new NextRequest('http://localhost:3000/api/files/parse', {
method: 'POST',
body: JSON.stringify({
filePath: decodeURIComponent(maliciousPath),
}),
})
const response = await POST(request)
const result = await response.json()
expect(result.success).toBe(false)
expect(result.error).toMatch(/Access denied|Invalid path|Path outside allowed directory/)
}
})
it('should handle null byte injection attempts', async () => {
const nullBytePaths = [
'/api/files/serve/file.txt\0../../etc/passwd',
'file.txt\0/etc/passwd',
'/api/files/serve/document.pdf\0/var/log/auth.log',
]
for (const maliciousPath of nullBytePaths) {
const request = new NextRequest('http://localhost:3000/api/files/parse', {
method: 'POST',
body: JSON.stringify({
filePath: maliciousPath,
}),
})
const response = await POST(request)
const result = await response.json()
expect(result.success).toBe(false)
}
})
})
describe('Edge Cases', () => {
it('should handle empty file paths', async () => {
const request = new NextRequest('http://localhost:3000/api/files/parse', {
method: 'POST',
body: JSON.stringify({
filePath: '',
}),
})
const response = await POST(request)
const result = await response.json()
expect(response.status).toBe(400)
expect(result.error).toBe('No file path provided')
})
it('should handle missing filePath parameter', async () => {
const request = new NextRequest('http://localhost:3000/api/files/parse', {
method: 'POST',
body: JSON.stringify({}),
})
const response = await POST(request)
const result = await response.json()
expect(response.status).toBe(400)
expect(result.error).toBe('No file path provided')
})
})
})

View File

@@ -1,657 +0,0 @@
import { Buffer } from 'buffer'
import { createHash } from 'crypto'
import fsPromises, { readFile } from 'fs/promises'
import path from 'path'
import binaryExtensionsList from 'binary-extensions'
import { type NextRequest, NextResponse } from 'next/server'
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
import { createLogger } from '@/lib/logs/console-logger'
import { downloadFile, isUsingCloudStorage } from '@/lib/uploads'
import { UPLOAD_DIR } from '@/lib/uploads/setup'
import '@/lib/uploads/setup.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('FilesParseAPI')
const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB
const DOWNLOAD_TIMEOUT_MS = 30000 // 30 seconds
interface ParseResult {
success: boolean
content?: string
error?: string
filePath: string
metadata?: {
fileType: string
size: number
hash: string
processingTime: number
}
}
const fileTypeMap: Record<string, string> = {
// Text formats
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
md: 'text/markdown',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'application/typescript',
// Document formats
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
// Spreadsheet formats
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// Presentation formats
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Image formats
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
// Archive formats
zip: 'application/zip',
}
/**
* Main API route handler
*/
export async function POST(request: NextRequest) {
const startTime = Date.now()
try {
const requestData = await request.json()
const { filePath, fileType } = requestData
if (!filePath) {
return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 })
}
logger.info('File parse request received:', { filePath, fileType })
// Handle multiple files
if (Array.isArray(filePath)) {
const results = []
for (const path of filePath) {
const result = await parseFileSingle(path, fileType)
// Add processing time to metadata
if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
}
// Transform each result to match expected frontend format
if (result.success) {
results.push({
success: true,
output: {
content: result.content,
name: result.filePath.split('/').pop() || 'unknown',
fileType: result.metadata?.fileType || 'application/octet-stream',
size: result.metadata?.size || 0,
binary: false, // We only return text content
},
filePath: result.filePath,
})
} else {
results.push(result)
}
}
return NextResponse.json({
success: true,
results,
})
}
// Handle single file
const result = await parseFileSingle(filePath, fileType)
// Add processing time to metadata
if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
}
// Transform single file result to match expected frontend format
if (result.success) {
return NextResponse.json({
success: true,
output: {
content: result.content,
name: result.filePath.split('/').pop() || 'unknown',
fileType: result.metadata?.fileType || 'application/octet-stream',
size: result.metadata?.size || 0,
binary: false, // We only return text content
},
})
}
// Only return 500 for actual server errors, not file processing failures
// File processing failures (like file not found, parsing errors) should return 200 with success:false
return NextResponse.json(result)
} catch (error) {
logger.error('Error in file parse API:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
filePath: '',
},
{ status: 500 }
)
}
}
/**
* Parse a single file and return its content
*/
async function parseFileSingle(filePath: string, fileType?: string): Promise<ParseResult> {
logger.info('Parsing file:', filePath)
// Validate path for security before any processing
const pathValidation = validateFilePath(filePath)
if (!pathValidation.isValid) {
return {
success: false,
error: pathValidation.error || 'Invalid path',
filePath,
}
}
// Check if this is an external URL
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return handleExternalUrl(filePath, fileType)
}
// Check if this is a cloud storage path (S3 or Blob)
const isS3Path = filePath.includes('/api/files/serve/s3/')
const isBlobPath = filePath.includes('/api/files/serve/blob/')
// Use cloud handler if it's a cloud path or we're in cloud mode
if (isS3Path || isBlobPath || isUsingCloudStorage()) {
return handleCloudFile(filePath, fileType)
}
// Use local handler for local files
return handleLocalFile(filePath, fileType)
}
/**
* Validate file path for security
*/
function validateFilePath(filePath: string): { isValid: boolean; error?: string } {
// Check for null bytes
if (filePath.includes('\0')) {
return { isValid: false, error: 'Invalid path: null byte detected' }
}
// Check for path traversal attempts
if (filePath.includes('..')) {
return { isValid: false, error: 'Access denied: path traversal detected' }
}
// Check for tilde characters (home directory access)
if (filePath.includes('~')) {
return { isValid: false, error: 'Invalid path: tilde character not allowed' }
}
// Check for absolute paths outside allowed directories
if (filePath.startsWith('/') && !filePath.startsWith('/api/files/serve/')) {
return { isValid: false, error: 'Path outside allowed directory' }
}
// Check for Windows absolute paths
if (/^[A-Za-z]:\\/.test(filePath)) {
return { isValid: false, error: 'Path outside allowed directory' }
}
return { isValid: true }
}
/**
* Handle external URL
*/
async function handleExternalUrl(url: string, fileType?: string): Promise<ParseResult> {
try {
logger.info('Fetching external URL:', url)
const response = await fetch(url, {
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
})
if (!response.ok) {
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)
}
const contentLength = response.headers.get('content-length')
if (contentLength && Number.parseInt(contentLength) > MAX_DOWNLOAD_SIZE_BYTES) {
throw new Error(`File too large: ${contentLength} bytes (max: ${MAX_DOWNLOAD_SIZE_BYTES})`)
}
const buffer = Buffer.from(await response.arrayBuffer())
if (buffer.length > MAX_DOWNLOAD_SIZE_BYTES) {
throw new Error(`File too large: ${buffer.length} bytes (max: ${MAX_DOWNLOAD_SIZE_BYTES})`)
}
logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`)
// Extract filename from URL
const urlPath = new URL(url).pathname
const filename = urlPath.split('/').pop() || 'download'
const extension = path.extname(filename).toLowerCase().substring(1)
// Process the file based on its content type
if (extension === 'pdf') {
return await handlePdfBuffer(buffer, filename, fileType, url)
}
if (extension === 'csv') {
return await handleCsvBuffer(buffer, filename, fileType, url)
}
if (isSupportedFileType(extension)) {
return await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
}
// For binary or unknown files
return handleGenericBuffer(buffer, filename, extension, fileType)
} catch (error) {
logger.error(`Error handling external URL ${url}:`, error)
return {
success: false,
error: `Error fetching URL: ${(error as Error).message}`,
filePath: url,
}
}
}
/**
* Handle file stored in cloud storage (S3 or Azure Blob)
*/
async function handleCloudFile(filePath: string, fileType?: string): Promise<ParseResult> {
try {
// Extract the cloud key from the path
let cloudKey: string
if (filePath.includes('/api/files/serve/s3/')) {
cloudKey = decodeURIComponent(filePath.split('/api/files/serve/s3/')[1])
} else if (filePath.includes('/api/files/serve/blob/')) {
cloudKey = decodeURIComponent(filePath.split('/api/files/serve/blob/')[1])
} else if (filePath.startsWith('/api/files/serve/')) {
// Backwards-compatibility: path like "/api/files/serve/<key>"
cloudKey = decodeURIComponent(filePath.substring('/api/files/serve/'.length))
} else {
// Assume raw key provided
cloudKey = filePath
}
logger.info('Extracted cloud key:', cloudKey)
// Download the file from cloud storage - this can throw for access errors
const fileBuffer = await downloadFile(cloudKey)
logger.info(`Downloaded file from cloud storage: ${cloudKey}, size: ${fileBuffer.length} bytes`)
// Extract the filename from the cloud key
const filename = cloudKey.split('/').pop() || cloudKey
const extension = path.extname(filename).toLowerCase().substring(1)
// Process the file based on its content type
if (extension === 'pdf') {
return await handlePdfBuffer(fileBuffer, filename, fileType, filePath)
}
if (extension === 'csv') {
return await handleCsvBuffer(fileBuffer, filename, fileType, filePath)
}
if (isSupportedFileType(extension)) {
// For other supported types that we have parsers for
return await handleGenericTextBuffer(fileBuffer, filename, extension, fileType, filePath)
}
// For binary or unknown files
return handleGenericBuffer(fileBuffer, filename, extension, fileType)
} catch (error) {
logger.error(`Error handling cloud file ${filePath}:`, error)
// Check if this is a download/access error that should trigger a 500 response
const errorMessage = (error as Error).message
if (errorMessage.includes('Access denied') || errorMessage.includes('Forbidden')) {
// For access errors, throw to trigger 500 response
throw new Error(`Error accessing file from cloud storage: ${errorMessage}`)
}
// For other errors (parsing, processing), return success:false
return {
success: false,
error: `Error accessing file from cloud storage: ${errorMessage}`,
filePath,
}
}
}
/**
* Handle local file
*/
async function handleLocalFile(filePath: string, fileType?: string): Promise<ParseResult> {
try {
// Extract filename from path
const filename = filePath.split('/').pop() || filePath
const fullPath = path.join(UPLOAD_DIR, filename)
logger.info('Processing local file:', fullPath)
// Check if file exists
try {
await fsPromises.access(fullPath)
} catch {
throw new Error(`File not found: ${filename}`)
}
// Parse the file directly
const result = await parseFile(fullPath)
// Get file stats for metadata
const stats = await fsPromises.stat(fullPath)
const fileBuffer = await readFile(fullPath)
const hash = createHash('md5').update(fileBuffer).digest('hex')
// Extract file extension for type detection
const extension = path.extname(filename).toLowerCase().substring(1)
return {
success: true,
content: result.content,
filePath,
metadata: {
fileType: fileType || getMimeType(extension),
size: stats.size,
hash,
processingTime: 0, // Will be set by caller
},
}
} catch (error) {
logger.error(`Error handling local file ${filePath}:`, error)
return {
success: false,
error: `Error processing local file: ${(error as Error).message}`,
filePath,
}
}
}
/**
* Handle a PDF buffer directly in memory
*/
async function handlePdfBuffer(
fileBuffer: Buffer,
filename: string,
fileType?: string,
originalPath?: string
): Promise<ParseResult> {
try {
logger.info(`Parsing PDF in memory: ${filename}`)
const result = await parseBufferAsPdf(fileBuffer)
const content =
result.content ||
createPdfFallbackMessage(result.metadata?.pageCount || 0, fileBuffer.length, originalPath)
return {
success: true,
content,
filePath: originalPath || filename,
metadata: {
fileType: fileType || 'application/pdf',
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0, // Will be set by caller
},
}
} catch (error) {
logger.error('Failed to parse PDF in memory:', error)
// Create fallback message for PDF parsing failure
const content = createPdfFailureMessage(
0, // We can't determine page count without parsing
fileBuffer.length,
originalPath || filename,
(error as Error).message
)
return {
success: true,
content,
filePath: originalPath || filename,
metadata: {
fileType: fileType || 'application/pdf',
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0, // Will be set by caller
},
}
}
}
/**
* Handle a CSV buffer directly in memory
*/
async function handleCsvBuffer(
fileBuffer: Buffer,
filename: string,
fileType?: string,
originalPath?: string
): Promise<ParseResult> {
try {
logger.info(`Parsing CSV in memory: ${filename}`)
// Use the parseBuffer function from our library
const { parseBuffer } = await import('../../../../lib/file-parsers')
const result = await parseBuffer(fileBuffer, 'csv')
return {
success: true,
content: result.content,
filePath: originalPath || filename,
metadata: {
fileType: fileType || 'text/csv',
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0, // Will be set by caller
},
}
} catch (error) {
logger.error('Failed to parse CSV in memory:', error)
return {
success: false,
error: `Failed to parse CSV: ${(error as Error).message}`,
filePath: originalPath || filename,
metadata: {
fileType: 'text/csv',
size: 0,
hash: '',
processingTime: 0, // Will be set by caller
},
}
}
}
/**
* Handle a generic text file buffer in memory
*/
async function handleGenericTextBuffer(
fileBuffer: Buffer,
filename: string,
extension: string,
fileType?: string,
originalPath?: string
): Promise<ParseResult> {
try {
logger.info(`Parsing text file in memory: ${filename}`)
// Try to use a specialized parser if available
try {
const { parseBuffer, isSupportedFileType } = await import('../../../../lib/file-parsers')
if (isSupportedFileType(extension)) {
const result = await parseBuffer(fileBuffer, extension)
return {
success: true,
content: result.content,
filePath: originalPath || filename,
metadata: {
fileType: fileType || getMimeType(extension),
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0, // Will be set by caller
},
}
}
} catch (parserError) {
logger.warn('Specialized parser failed, falling back to generic parsing:', parserError)
}
// Fallback to generic text parsing
const content = fileBuffer.toString('utf-8')
return {
success: true,
content,
filePath: originalPath || filename,
metadata: {
fileType: fileType || getMimeType(extension),
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0, // Will be set by caller
},
}
} catch (error) {
logger.error('Failed to parse text file in memory:', error)
return {
success: false,
error: `Failed to parse file: ${(error as Error).message}`,
filePath: originalPath || filename,
metadata: {
fileType: 'text/plain',
size: 0,
hash: '',
processingTime: 0, // Will be set by caller
},
}
}
}
/**
* Handle a generic binary buffer
*/
function handleGenericBuffer(
fileBuffer: Buffer,
filename: string,
extension: string,
fileType?: string
): ParseResult {
const isBinary = binaryExtensionsList.includes(extension)
const content = isBinary
? `[Binary ${extension.toUpperCase()} file - ${fileBuffer.length} bytes]`
: fileBuffer.toString('utf-8')
return {
success: true,
content,
filePath: filename,
metadata: {
fileType: fileType || getMimeType(extension),
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0, // Will be set by caller
},
}
}
/**
* Parse a PDF buffer
*/
async function parseBufferAsPdf(buffer: Buffer) {
try {
// Import parsers dynamically to avoid initialization issues in tests
// First try to use the main PDF parser
try {
const { PdfParser } = await import('../../../../lib/file-parsers/pdf-parser')
const parser = new PdfParser()
logger.info('Using main PDF parser for buffer')
if (parser.parseBuffer) {
return await parser.parseBuffer(buffer)
}
throw new Error('PDF parser does not support buffer parsing')
} catch (error) {
// Fallback to raw PDF parser
logger.warn('Main PDF parser failed, using raw parser for buffer:', error)
const { RawPdfParser } = await import('../../../../lib/file-parsers/raw-pdf-parser')
const rawParser = new RawPdfParser()
return await rawParser.parseBuffer(buffer)
}
} catch (error) {
throw new Error(`PDF parsing failed: ${(error as Error).message}`)
}
}
/**
* Get MIME type from file extension
*/
function getMimeType(extension: string): string {
return fileTypeMap[extension] || 'application/octet-stream'
}
/**
* Format bytes to human readable size
*/
function prettySize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${Number.parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`
}
/**
* Create a formatted message for PDF content
*/
function createPdfFallbackMessage(pageCount: number, size: number, path?: string): string {
const formattedPath = path
? path.includes('/api/files/serve/s3/')
? `S3 path: ${decodeURIComponent(path.split('/api/files/serve/s3/')[1])}`
: `Local path: ${path}`
: 'Unknown path'
return `PDF document - ${pageCount} page(s), ${prettySize(size)}
Path: ${formattedPath}
This file appears to be a PDF document that could not be fully processed as text.
Please use a PDF viewer for best results.`
}
/**
* Create error message for PDF parsing failure
*/
function createPdfFailureMessage(
pageCount: number,
size: number,
path: string,
error: string
): string {
const formattedPath = path.includes('/api/files/serve/s3/')
? `S3 path: ${decodeURIComponent(path.split('/api/files/serve/s3/')[1])}`
: `Local path: ${path}`
return `PDF document - Processing failed, ${prettySize(size)}
Path: ${formattedPath}
Error: ${error}
This file appears to be a PDF document that could not be processed.
Please use a PDF viewer for best results.`
}

View File

@@ -1,396 +0,0 @@
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { setupFileApiMocks } from '@/app/api/__test-utils__/utils'
describe('/api/files/presigned', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'))
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
})
})
afterEach(() => {
vi.useRealTimers()
})
describe('POST', () => {
test('should return error when cloud storage is not enabled', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 's3',
})
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError)
expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled')
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
expect(data.directUploadSupported).toBe(false)
})
it('should return error when fileName is missing', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
contentType: 'text/plain',
fileSize: 1024,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('fileName is required and cannot be empty')
expect(data.code).toBe('VALIDATION_ERROR')
})
it('should return error when contentType is missing', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
fileSize: 1024,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('contentType is required and cannot be empty')
expect(data.code).toBe('VALIDATION_ERROR')
})
it('should return error when fileSize is invalid', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 0,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('fileSize must be a positive number')
expect(data.code).toBe('VALIDATION_ERROR')
})
it('should return error when file size exceeds limit', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const largeFileSize = 150 * 1024 * 1024 // 150MB (exceeds 100MB limit)
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'large-file.txt',
contentType: 'text/plain',
fileSize: largeFileSize,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('exceeds maximum allowed size')
expect(data.code).toBe('VALIDATION_ERROR')
})
it('should generate S3 presigned URL successfully', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'test document.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.presignedUrl).toBe('https://example.com/presigned-url')
expect(data.fileInfo).toMatchObject({
path: expect.stringContaining('/api/files/serve/s3/'),
key: expect.stringContaining('test-document.txt'),
name: 'test document.txt',
size: 1024,
type: 'text/plain',
})
expect(data.directUploadSupported).toBe(true)
})
it('should generate knowledge-base S3 presigned URL with kb prefix', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=knowledge-base',
{
method: 'POST',
body: JSON.stringify({
fileName: 'knowledge-doc.pdf',
contentType: 'application/pdf',
fileSize: 2048,
}),
}
)
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.fileInfo.key).toMatch(/^kb\/.*knowledge-doc\.pdf$/)
expect(data.directUploadSupported).toBe(true)
})
it('should generate Azure Blob presigned URL successfully', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 'blob',
})
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'test document.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.presignedUrl).toContain('https://example.com/presigned-url')
expect(data.presignedUrl).toContain('sas-token-string')
expect(data.fileInfo).toMatchObject({
path: expect.stringContaining('/api/files/serve/blob/'),
key: expect.stringContaining('test-document.txt'),
name: 'test document.txt',
size: 1024,
type: 'text/plain',
})
expect(data.directUploadSupported).toBe(true)
expect(data.uploadHeaders).toMatchObject({
'x-ms-blob-type': 'BlockBlob',
'x-ms-blob-content-type': 'text/plain',
'x-ms-meta-originalname': expect.any(String),
'x-ms-meta-uploadedat': '2024-01-01T00:00:00.000Z',
})
})
it('should return error for unknown storage provider', async () => {
// For unknown provider, we'll need to mock manually since our helper doesn't support it
vi.doMock('@/lib/uploads', () => ({
getStorageProvider: vi.fn().mockReturnValue('unknown'),
isUsingCloudStorage: vi.fn().mockReturnValue(true),
}))
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError)
expect(data.error).toBe('Unknown storage provider: unknown') // Updated error message
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
expect(data.directUploadSupported).toBe(false)
})
it('should handle S3 errors gracefully', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
// Override with error-throwing mock while preserving other exports
vi.doMock('@/lib/uploads', () => ({
getStorageProvider: vi.fn().mockReturnValue('s3'),
isUsingCloudStorage: vi.fn().mockReturnValue(true),
uploadFile: vi.fn().mockResolvedValue({
path: '/api/files/serve/test-key',
key: 'test-key',
name: 'test.txt',
size: 100,
type: 'text/plain',
}),
}))
vi.doMock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: vi.fn().mockRejectedValue(new Error('S3 service unavailable')),
}))
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe(
'Failed to generate S3 presigned URL - check AWS credentials and permissions'
) // Updated error message
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
})
it('should handle Azure Blob errors gracefully', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 'blob',
})
vi.doMock('@/lib/uploads', () => ({
getStorageProvider: vi.fn().mockReturnValue('blob'),
isUsingCloudStorage: vi.fn().mockReturnValue(true),
uploadFile: vi.fn().mockResolvedValue({
path: '/api/files/serve/test-key',
key: 'test-key',
name: 'test.txt',
size: 100,
type: 'text/plain',
}),
}))
vi.doMock('@/lib/uploads/blob/blob-client', () => ({
getBlobServiceClient: vi.fn().mockImplementation(() => {
throw new Error('Azure service unavailable')
}),
sanitizeFilenameForMetadata: vi.fn((filename) => filename),
}))
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Failed to generate Azure Blob presigned URL') // Updated error message
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
})
it('should handle malformed JSON gracefully', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: 'invalid json',
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400) // Changed from 500 to 400 (ValidationError)
expect(data.error).toBe('Invalid JSON in request body') // Updated error message
expect(data.code).toBe('VALIDATION_ERROR')
})
})
describe('OPTIONS', () => {
it('should handle CORS preflight requests', async () => {
const { OPTIONS } = await import('./route')
const response = await OPTIONS()
expect(response.status).toBe(204)
expect(response.headers.get('Access-Control-Allow-Methods')).toBe(
'GET, POST, DELETE, OPTIONS'
)
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type')
})
})
})

View File

@@ -1,272 +0,0 @@
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger'
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
import { getBlobServiceClient } from '@/lib/uploads/blob/blob-client'
import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3/s3-client'
import { BLOB_CONFIG, BLOB_KB_CONFIG, S3_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/setup'
import { createErrorResponse, createOptionsResponse } from '../utils'
const logger = createLogger('PresignedUploadAPI')
interface PresignedUrlRequest {
fileName: string
contentType: string
fileSize: number
}
type UploadType = 'general' | 'knowledge-base'
class PresignedUrlError extends Error {
constructor(
message: string,
public code: string,
public statusCode = 400
) {
super(message)
this.name = 'PresignedUrlError'
}
}
class StorageConfigError extends PresignedUrlError {
constructor(message: string) {
super(message, 'STORAGE_CONFIG_ERROR', 500)
}
}
class ValidationError extends PresignedUrlError {
constructor(message: string) {
super(message, 'VALIDATION_ERROR', 400)
}
}
export async function POST(request: NextRequest) {
try {
let data: PresignedUrlRequest
try {
data = await request.json()
} catch {
throw new ValidationError('Invalid JSON in request body')
}
const { fileName, contentType, fileSize } = data
if (!fileName?.trim()) {
throw new ValidationError('fileName is required and cannot be empty')
}
if (!contentType?.trim()) {
throw new ValidationError('contentType is required and cannot be empty')
}
if (!fileSize || fileSize <= 0) {
throw new ValidationError('fileSize must be a positive number')
}
const MAX_FILE_SIZE = 100 * 1024 * 1024
if (fileSize > MAX_FILE_SIZE) {
throw new ValidationError(
`File size (${fileSize} bytes) exceeds maximum allowed size (${MAX_FILE_SIZE} bytes)`
)
}
const uploadTypeParam = request.nextUrl.searchParams.get('type')
const uploadType: UploadType =
uploadTypeParam === 'knowledge-base' ? 'knowledge-base' : 'general'
if (!isUsingCloudStorage()) {
throw new StorageConfigError(
'Direct uploads are only available when cloud storage is enabled'
)
}
const storageProvider = getStorageProvider()
logger.info(`Generating ${uploadType} presigned URL for ${fileName} using ${storageProvider}`)
switch (storageProvider) {
case 's3':
return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType)
case 'blob':
return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType)
default:
throw new StorageConfigError(`Unknown storage provider: ${storageProvider}`)
}
} catch (error) {
logger.error('Error generating presigned URL:', error)
if (error instanceof PresignedUrlError) {
return NextResponse.json(
{
error: error.message,
code: error.code,
directUploadSupported: false,
},
{ status: error.statusCode }
)
}
return createErrorResponse(
error instanceof Error ? error : new Error('Failed to generate presigned URL')
)
}
}
async function handleS3PresignedUrl(
fileName: string,
contentType: string,
fileSize: number,
uploadType: UploadType
) {
try {
const config = uploadType === 'knowledge-base' ? S3_KB_CONFIG : S3_CONFIG
if (!config.bucket || !config.region) {
throw new StorageConfigError(`S3 configuration missing for ${uploadType} uploads`)
}
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const prefix = uploadType === 'knowledge-base' ? 'kb/' : ''
const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}`
const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName)
const metadata: Record<string, string> = {
originalName: sanitizedOriginalName,
uploadedAt: new Date().toISOString(),
}
if (uploadType === 'knowledge-base') {
metadata.purpose = 'knowledge-base'
}
const command = new PutObjectCommand({
Bucket: config.bucket,
Key: uniqueKey,
ContentType: contentType,
Metadata: metadata,
})
let presignedUrl: string
try {
presignedUrl = await getSignedUrl(getS3Client(), command, { expiresIn: 3600 })
} catch (s3Error) {
logger.error('Failed to generate S3 presigned URL:', s3Error)
throw new StorageConfigError(
'Failed to generate S3 presigned URL - check AWS credentials and permissions'
)
}
const servePath = `/api/files/serve/s3/${encodeURIComponent(uniqueKey)}`
logger.info(`Generated ${uploadType} S3 presigned URL for ${fileName} (${uniqueKey})`)
return NextResponse.json({
presignedUrl,
fileInfo: {
path: servePath,
key: uniqueKey,
name: fileName,
size: fileSize,
type: contentType,
},
directUploadSupported: true,
})
} catch (error) {
if (error instanceof PresignedUrlError) {
throw error
}
logger.error('Error in S3 presigned URL generation:', error)
throw new StorageConfigError('Failed to generate S3 presigned URL')
}
}
async function handleBlobPresignedUrl(
fileName: string,
contentType: string,
fileSize: number,
uploadType: UploadType
) {
try {
const config = uploadType === 'knowledge-base' ? BLOB_KB_CONFIG : BLOB_CONFIG
if (
!config.accountName ||
!config.containerName ||
(!config.accountKey && !config.connectionString)
) {
throw new StorageConfigError(`Azure Blob configuration missing for ${uploadType} uploads`)
}
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const prefix = uploadType === 'knowledge-base' ? 'kb/' : ''
const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}`
const blobServiceClient = getBlobServiceClient()
const containerClient = blobServiceClient.getContainerClient(config.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(uniqueKey)
const { BlobSASPermissions, generateBlobSASQueryParameters, StorageSharedKeyCredential } =
await import('@azure/storage-blob')
const sasOptions = {
containerName: config.containerName,
blobName: uniqueKey,
permissions: BlobSASPermissions.parse('w'), // Write permission for upload
startsOn: new Date(),
expiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour expiration
}
let sasToken: string
try {
sasToken = generateBlobSASQueryParameters(
sasOptions,
new StorageSharedKeyCredential(config.accountName, config.accountKey || '')
).toString()
} catch (blobError) {
logger.error('Failed to generate Azure Blob SAS token:', blobError)
throw new StorageConfigError(
'Failed to generate Azure Blob SAS token - check Azure credentials and permissions'
)
}
const presignedUrl = `${blockBlobClient.url}?${sasToken}`
const servePath = `/api/files/serve/blob/${encodeURIComponent(uniqueKey)}`
logger.info(`Generated ${uploadType} Azure Blob presigned URL for ${fileName} (${uniqueKey})`)
const uploadHeaders: Record<string, string> = {
'x-ms-blob-type': 'BlockBlob',
'x-ms-blob-content-type': contentType,
'x-ms-meta-originalname': encodeURIComponent(fileName),
'x-ms-meta-uploadedat': new Date().toISOString(),
}
if (uploadType === 'knowledge-base') {
uploadHeaders['x-ms-meta-purpose'] = 'knowledge-base'
}
return NextResponse.json({
presignedUrl,
fileInfo: {
path: servePath,
key: uniqueKey,
name: fileName,
size: fileSize,
type: contentType,
},
directUploadSupported: true,
uploadHeaders,
})
} catch (error) {
if (error instanceof PresignedUrlError) {
throw error
}
logger.error('Error in Azure Blob presigned URL generation:', error)
throw new StorageConfigError('Failed to generate Azure Blob presigned URL')
}
}
export async function OPTIONS() {
return createOptionsResponse()
}

View File

@@ -1,260 +0,0 @@
import { NextRequest } from 'next/server'
/**
* Tests for file serve API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupApiTestMocks } from '@/app/api/__test-utils__/utils'
describe('File Serve API Route', () => {
beforeEach(() => {
vi.resetModules()
setupApiTestMocks({
withFileSystem: true,
withUploadUtils: true,
})
vi.doMock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
}))
vi.doMock('@/app/api/files/utils', () => ({
FileNotFoundError: class FileNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = 'FileNotFoundError'
}
},
createFileResponse: vi.fn().mockImplementation((file) => {
return new Response(file.buffer, {
status: 200,
headers: {
'Content-Type': file.contentType,
'Content-Disposition': `inline; filename="${file.filename}"`,
},
})
}),
createErrorResponse: vi.fn().mockImplementation((error) => {
return new Response(JSON.stringify({ error: error.name, message: error.message }), {
status: error.name === 'FileNotFoundError' ? 404 : 500,
headers: { 'Content-Type': 'application/json' },
})
}),
getContentType: vi.fn().mockReturnValue('text/plain'),
isS3Path: vi.fn().mockReturnValue(false),
isBlobPath: vi.fn().mockReturnValue(false),
extractS3Key: vi.fn().mockImplementation((path) => path.split('/').pop()),
extractBlobKey: vi.fn().mockImplementation((path) => path.split('/').pop()),
extractFilename: vi.fn().mockImplementation((path) => path.split('/').pop()),
findLocalFile: vi.fn().mockReturnValue('/test/uploads/test-file.txt'),
}))
vi.doMock('@/lib/uploads/setup.server', () => ({}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should serve local file successfully', async () => {
const req = new NextRequest('http://localhost:3000/api/files/serve/test-file.txt')
const params = { path: ['test-file.txt'] }
const { GET } = await import('./route')
const response = await GET(req, { params: Promise.resolve(params) })
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('text/plain')
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="test-file.txt"')
const fs = await import('fs/promises')
expect(fs.readFile).toHaveBeenCalledWith('/test/uploads/test-file.txt')
})
it('should handle nested paths correctly', async () => {
vi.doMock('@/app/api/files/utils', () => ({
FileNotFoundError: class FileNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = 'FileNotFoundError'
}
},
createFileResponse: vi.fn().mockImplementation((file) => {
return new Response(file.buffer, {
status: 200,
headers: {
'Content-Type': file.contentType,
'Content-Disposition': `inline; filename="${file.filename}"`,
},
})
}),
createErrorResponse: vi.fn().mockImplementation((error) => {
return new Response(JSON.stringify({ error: error.name, message: error.message }), {
status: error.name === 'FileNotFoundError' ? 404 : 500,
headers: { 'Content-Type': 'application/json' },
})
}),
getContentType: vi.fn().mockReturnValue('text/plain'),
isS3Path: vi.fn().mockReturnValue(false),
isBlobPath: vi.fn().mockReturnValue(false),
extractS3Key: vi.fn().mockImplementation((path) => path.split('/').pop()),
extractBlobKey: vi.fn().mockImplementation((path) => path.split('/').pop()),
extractFilename: vi.fn().mockImplementation((path) => path.split('/').pop()),
findLocalFile: vi.fn().mockReturnValue('/test/uploads/nested/path/file.txt'),
}))
const req = new NextRequest('http://localhost:3000/api/files/serve/nested/path/file.txt')
const params = { path: ['nested', 'path', 'file.txt'] }
const { GET } = await import('./route')
const response = await GET(req, { params: Promise.resolve(params) })
expect(response.status).toBe(200)
const fs = await import('fs/promises')
expect(fs.readFile).toHaveBeenCalledWith('/test/uploads/nested/path/file.txt')
})
it('should serve cloud file by downloading and proxying', async () => {
vi.doMock('@/lib/uploads', () => ({
downloadFile: vi.fn().mockResolvedValue(Buffer.from('test cloud file content')),
getPresignedUrl: vi.fn().mockResolvedValue('https://example-s3.com/presigned-url'),
isUsingCloudStorage: vi.fn().mockReturnValue(true),
}))
vi.doMock('@/lib/uploads/setup', () => ({
UPLOAD_DIR: '/test/uploads',
USE_S3_STORAGE: true,
USE_BLOB_STORAGE: false,
}))
vi.doMock('@/app/api/files/utils', () => ({
FileNotFoundError: class FileNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = 'FileNotFoundError'
}
},
createFileResponse: vi.fn().mockImplementation((file) => {
return new Response(file.buffer, {
status: 200,
headers: {
'Content-Type': file.contentType,
'Content-Disposition': `inline; filename="${file.filename}"`,
},
})
}),
createErrorResponse: vi.fn().mockImplementation((error) => {
return new Response(JSON.stringify({ error: error.name, message: error.message }), {
status: error.name === 'FileNotFoundError' ? 404 : 500,
headers: { 'Content-Type': 'application/json' },
})
}),
getContentType: vi.fn().mockReturnValue('image/png'),
isS3Path: vi.fn().mockReturnValue(false),
isBlobPath: vi.fn().mockReturnValue(false),
extractS3Key: vi.fn().mockImplementation((path) => path.split('/').pop()),
extractBlobKey: vi.fn().mockImplementation((path) => path.split('/').pop()),
extractFilename: vi.fn().mockImplementation((path) => path.split('/').pop()),
findLocalFile: vi.fn().mockReturnValue('/test/uploads/test-file.txt'),
}))
const req = new NextRequest('http://localhost:3000/api/files/serve/s3/1234567890-image.png')
const params = { path: ['s3', '1234567890-image.png'] }
const { GET } = await import('./route')
const response = await GET(req, { params: Promise.resolve(params) })
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('image/png')
const uploads = await import('@/lib/uploads')
expect(uploads.downloadFile).toHaveBeenCalledWith('1234567890-image.png')
})
it('should return 404 when file not found', async () => {
vi.doMock('fs', () => ({
existsSync: vi.fn().mockReturnValue(false),
}))
vi.doMock('fs/promises', () => ({
readFile: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')),
}))
vi.doMock('@/app/api/files/utils', () => ({
FileNotFoundError: class FileNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = 'FileNotFoundError'
}
},
createFileResponse: vi.fn(),
createErrorResponse: vi.fn().mockImplementation((error) => {
return new Response(JSON.stringify({ error: error.name, message: error.message }), {
status: error.name === 'FileNotFoundError' ? 404 : 500,
headers: { 'Content-Type': 'application/json' },
})
}),
getContentType: vi.fn().mockReturnValue('text/plain'),
isS3Path: vi.fn().mockReturnValue(false),
isBlobPath: vi.fn().mockReturnValue(false),
extractS3Key: vi.fn(),
extractBlobKey: vi.fn(),
extractFilename: vi.fn(),
findLocalFile: vi.fn().mockReturnValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/files/serve/nonexistent.txt')
const params = { path: ['nonexistent.txt'] }
const { GET } = await import('./route')
const response = await GET(req, { params: Promise.resolve(params) })
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData).toEqual({
error: 'FileNotFoundError',
message: expect.stringContaining('File not found'),
})
})
describe('content type detection', () => {
const contentTypeTests = [
{ ext: 'pdf', contentType: 'application/pdf' },
{ ext: 'json', contentType: 'application/json' },
{ ext: 'jpg', contentType: 'image/jpeg' },
{ ext: 'txt', contentType: 'text/plain' },
{ ext: 'unknown', contentType: 'application/octet-stream' },
]
for (const test of contentTypeTests) {
it(`should serve ${test.ext} file with correct content type`, async () => {
vi.doMock('@/app/api/files/utils', () => ({
getContentType: () => test.contentType,
findLocalFile: () => `/test/uploads/file.${test.ext}`,
createFileResponse: (obj: { buffer: Buffer; contentType: string; filename: string }) =>
new Response(obj.buffer, {
status: 200,
headers: {
'Content-Type': obj.contentType,
'Content-Disposition': `inline; filename="${obj.filename}"`,
'Cache-Control': 'public, max-age=31536000',
},
}),
createErrorResponse: () => new Response(null, { status: 404 }),
}))
const req = new NextRequest(`http://localhost:3000/api/files/serve/file.${test.ext}`)
const params = { path: [`file.${test.ext}`] }
const { GET } = await import('./route')
const response = await GET(req, { params: Promise.resolve(params) })
expect(response.headers.get('Content-Type')).toBe(test.contentType)
})
}
})
})

View File

@@ -1,175 +0,0 @@
import { readFile } from 'fs/promises'
import type { NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
import { downloadFile, getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
import { BLOB_KB_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/setup'
import '@/lib/uploads/setup.server'
import {
createErrorResponse,
createFileResponse,
FileNotFoundError,
findLocalFile,
getContentType,
} from '../../utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('FilesServeAPI')
async function streamToBuffer(readableStream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
readableStream.on('data', (data) => {
chunks.push(data instanceof Buffer ? data : Buffer.from(data))
})
readableStream.on('end', () => {
resolve(Buffer.concat(chunks))
})
readableStream.on('error', reject)
})
}
/**
* Main API route handler for serving files
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const { path } = await params
if (!path || path.length === 0) {
throw new FileNotFoundError('No file path provided')
}
logger.info('File serve request:', { path })
// Join the path segments to get the filename or cloud key
const fullPath = path.join('/')
// Check if this is a cloud file (path starts with 's3/' or 'blob/')
const isS3Path = path[0] === 's3'
const isBlobPath = path[0] === 'blob'
const isCloudPath = isS3Path || isBlobPath
// Use cloud handler if in production, path explicitly specifies cloud storage, or we're using cloud storage
if (isUsingCloudStorage() || isCloudPath) {
// Extract the actual key (remove 's3/' or 'blob/' prefix if present)
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
return await handleCloudProxy(cloudKey)
}
// Use local handler for local files
return await handleLocalFile(fullPath)
} catch (error) {
logger.error('Error serving file:', error)
if (error instanceof FileNotFoundError) {
return createErrorResponse(error)
}
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
}
}
/**
* Handle local file serving
*/
async function handleLocalFile(filename: string): Promise<NextResponse> {
try {
const filePath = findLocalFile(filename)
if (!filePath) {
throw new FileNotFoundError(`File not found: ${filename}`)
}
const fileBuffer = await readFile(filePath)
const contentType = getContentType(filename)
return createFileResponse({
buffer: fileBuffer,
contentType,
filename,
})
} catch (error) {
logger.error('Error reading local file:', error)
throw error
}
}
async function downloadKBFile(cloudKey: string): Promise<Buffer> {
const storageProvider = getStorageProvider()
if (storageProvider === 'blob') {
logger.info(`Downloading KB file from Azure Blob Storage: ${cloudKey}`)
// Use KB-specific blob configuration
const { getBlobServiceClient } = await import('@/lib/uploads/blob/blob-client')
const blobServiceClient = getBlobServiceClient()
const containerClient = blobServiceClient.getContainerClient(BLOB_KB_CONFIG.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(cloudKey)
const downloadBlockBlobResponse = await blockBlobClient.download()
if (!downloadBlockBlobResponse.readableStreamBody) {
throw new Error('Failed to get readable stream from blob download')
}
// Convert stream to buffer
return await streamToBuffer(downloadBlockBlobResponse.readableStreamBody)
}
if (storageProvider === 's3') {
logger.info(`Downloading KB file from S3: ${cloudKey}`)
// Use KB-specific S3 configuration
const { getS3Client } = await import('@/lib/uploads/s3/s3-client')
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const s3Client = getS3Client()
const command = new GetObjectCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: cloudKey,
})
const response = await s3Client.send(command)
if (!response.Body) {
throw new Error('No body in S3 response')
}
// Convert stream to buffer using the same method as the regular S3 client
const stream = response.Body as any
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
stream.on('end', () => resolve(Buffer.concat(chunks)))
stream.on('error', reject)
})
}
throw new Error(`Unsupported storage provider for KB files: ${storageProvider}`)
}
/**
* Proxy cloud file through our server
*/
async function handleCloudProxy(cloudKey: string): Promise<NextResponse> {
try {
// Check if this is a KB file (starts with 'kb/')
const isKBFile = cloudKey.startsWith('kb/')
const fileBuffer = isKBFile ? await downloadKBFile(cloudKey) : await downloadFile(cloudKey)
// Extract the original filename from the key (last part after last /)
const originalFilename = cloudKey.split('/').pop() || 'download'
const contentType = getContentType(originalFilename)
return createFileResponse({
buffer: fileBuffer,
contentType,
filename: originalFilename,
})
} catch (error) {
logger.error('Error downloading from cloud storage:', error)
throw error
}
}

View File

@@ -1,179 +0,0 @@
import { NextRequest } from 'next/server'
/**
* Tests for file upload API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupFileApiMocks } from '@/app/api/__test-utils__/utils'
describe('File Upload API Route', () => {
const createMockFormData = (files: File[]): FormData => {
const formData = new FormData()
files.forEach((file) => {
formData.append('file', file)
})
return formData
}
const createMockFile = (
name = 'test.txt',
type = 'text/plain',
content = 'test content'
): File => {
return new File([content], name, { type })
}
beforeEach(() => {
vi.resetModules()
vi.doMock('@/lib/uploads/setup.server', () => ({}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should upload a file to local storage', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 'local',
})
const mockFile = createMockFile()
const formData = createMockFormData([mockFile])
const req = new NextRequest('http://localhost:3000/api/files/upload', {
method: 'POST',
body: formData,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('path')
expect(data.path).toMatch(/\/api\/files\/serve\/.*\.txt$/)
expect(data).toHaveProperty('name', 'test.txt')
expect(data).toHaveProperty('size')
expect(data).toHaveProperty('type', 'text/plain')
const fs = await import('fs/promises')
expect(fs.writeFile).toHaveBeenCalled()
})
it('should upload a file to S3 when in S3 mode', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const mockFile = createMockFile()
const formData = createMockFormData([mockFile])
const req = new NextRequest('http://localhost:3000/api/files/upload', {
method: 'POST',
body: formData,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('path')
expect(data.path).toContain('/api/files/serve/')
expect(data).toHaveProperty('name', 'test.txt')
expect(data).toHaveProperty('size')
expect(data).toHaveProperty('type', 'text/plain')
const uploads = await import('@/lib/uploads')
expect(uploads.uploadFile).toHaveBeenCalled()
})
it('should handle multiple file uploads', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 'local',
})
const mockFile1 = createMockFile('file1.txt', 'text/plain')
const mockFile2 = createMockFile('file2.txt', 'text/plain')
const formData = createMockFormData([mockFile1, mockFile2])
const req = new NextRequest('http://localhost:3000/api/files/upload', {
method: 'POST',
body: formData,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBeGreaterThanOrEqual(200)
expect(response.status).toBeLessThan(600)
expect(data).toBeDefined()
})
it('should handle missing files', async () => {
setupFileApiMocks()
const formData = new FormData()
const req = new NextRequest('http://localhost:3000/api/files/upload', {
method: 'POST',
body: formData,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'InvalidRequestError')
expect(data).toHaveProperty('message', 'No files provided')
})
it('should handle S3 upload errors', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
vi.doMock('@/lib/uploads', () => ({
uploadFile: vi.fn().mockRejectedValue(new Error('Upload failed')),
isUsingCloudStorage: vi.fn().mockReturnValue(true),
}))
const mockFile = createMockFile()
const formData = createMockFormData([mockFile])
const req = new NextRequest('http://localhost:3000/api/files/upload', {
method: 'POST',
body: formData,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data).toHaveProperty('error', 'Error')
expect(data).toHaveProperty('message', 'Upload failed')
})
it('should handle CORS preflight requests', async () => {
const { OPTIONS } = await import('./route')
const response = await OPTIONS()
expect(response.status).toBe(204)
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, DELETE, OPTIONS')
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type')
})
})

View File

@@ -1,549 +0,0 @@
/**
* Tests for individual folder API route (/api/folders/[id])
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
type CapturedFolderValues,
createMockRequest,
type MockUser,
mockAuth,
mockLogger,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
interface FolderDbMockOptions {
folderLookupResult?: any
updateResult?: any[]
throwError?: boolean
circularCheckResults?: any[]
}
describe('Individual Folder API Route', () => {
const TEST_USER: MockUser = {
id: 'user-123',
email: 'test@example.com',
name: 'Test User',
}
const mockFolder = {
id: 'folder-1',
name: 'Test Folder',
userId: TEST_USER.id,
workspaceId: 'workspace-123',
parentId: null,
color: '#6B7280',
sortOrder: 1,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth(TEST_USER)
const mockGetUserEntityPermissions = vi.fn()
function createFolderDbMock(options: FolderDbMockOptions = {}) {
const {
folderLookupResult = mockFolder,
updateResult = [{ ...mockFolder, name: 'Updated Folder' }],
throwError = false,
circularCheckResults = [],
} = options
let callCount = 0
const mockSelect = vi.fn().mockImplementation(() => ({
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
then: vi.fn().mockImplementation((callback) => {
if (throwError) {
throw new Error('Database error')
}
callCount++
// First call: folder lookup
if (callCount === 1) {
// The route code does .then((rows) => rows[0])
// So we need to return an array for folderLookupResult
const result = folderLookupResult === undefined ? [] : [folderLookupResult]
return Promise.resolve(callback(result))
}
// Subsequent calls: circular reference checks
if (callCount > 1 && circularCheckResults.length > 0) {
const index = callCount - 2
const result = circularCheckResults[index] ? [circularCheckResults[index]] : []
return Promise.resolve(callback(result))
}
return Promise.resolve(callback([]))
}),
})),
})),
}))
const mockUpdate = vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
returning: vi.fn().mockReturnValue(updateResult),
})),
})),
}))
const mockDelete = vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => Promise.resolve()),
}))
return {
db: {
select: mockSelect,
update: mockUpdate,
delete: mockDelete,
},
mocks: {
select: mockSelect,
update: mockUpdate,
delete: mockDelete,
},
}
}
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
setupCommonApiMocks()
mockGetUserEntityPermissions.mockResolvedValue('admin')
vi.doMock('@/lib/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('PUT /api/folders/[id]', () => {
it('should update folder successfully', async () => {
mockAuthenticatedUser()
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder Name',
color: '#FF0000',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
expect(data.folder).toMatchObject({
name: 'Updated Folder',
})
})
it('should update parent folder successfully', async () => {
mockAuthenticatedUser()
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
parentId: 'parent-folder-1',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(200)
})
it('should return 401 for unauthenticated requests', async () => {
mockUnauthenticated()
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty('error', 'Unauthorized')
})
it('should return 403 when user has only read permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Write access required to update folders')
})
it('should allow folder update for write permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
})
it('should allow folder update for admin permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
})
it('should return 400 when trying to set folder as its own parent', async () => {
mockAuthenticatedUser()
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
parentId: 'folder-1', // Same as the folder ID
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error', 'Folder cannot be its own parent')
})
it('should trim folder name when updating', async () => {
mockAuthenticatedUser()
let capturedUpdates: CapturedFolderValues | null = null
const dbMock = createFolderDbMock({
updateResult: [{ ...mockFolder, name: 'Folder With Spaces' }],
})
// Override the set implementation to capture updates
const originalSet = dbMock.mocks.update().set
dbMock.mocks.update.mockReturnValue({
set: vi.fn().mockImplementation((updates) => {
capturedUpdates = updates
return originalSet(updates)
}),
})
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: ' Folder With Spaces ',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
await PUT(req, { params })
expect(capturedUpdates).not.toBeNull()
expect(capturedUpdates!.name).toBe('Folder With Spaces')
})
it('should handle database errors gracefully', async () => {
mockAuthenticatedUser()
const dbMock = createFolderDbMock({
throwError: true,
})
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(500)
const data = await response.json()
expect(data).toHaveProperty('error', 'Internal server error')
expect(mockLogger.error).toHaveBeenCalledWith('Error updating folder:', {
error: expect.any(Error),
})
})
})
describe('Input Validation', () => {
it('should handle empty folder name', async () => {
mockAuthenticatedUser()
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: '', // Empty name
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
// Should still work as the API doesn't validate empty names
expect(response.status).toBe(200)
})
it('should handle invalid JSON payload', async () => {
mockAuthenticatedUser()
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
// Create a request with invalid JSON
const req = new Request('http://localhost:3000/api/folders/folder-1', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: 'invalid-json',
}) as any
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(500) // Should handle JSON parse error gracefully
})
})
describe('Circular Reference Prevention', () => {
it('should prevent circular references when updating parent', async () => {
mockAuthenticatedUser()
// Mock the circular reference scenario
// folder-3 trying to set folder-1 as parent,
// but folder-1 -> folder-2 -> folder-3 (would create cycle)
const circularCheckResults = [
{ parentId: 'folder-2' }, // folder-1 has parent folder-2
{ parentId: 'folder-3' }, // folder-2 has parent folder-3 (creates cycle!)
]
const dbMock = createFolderDbMock({
folderLookupResult: { id: 'folder-3', parentId: null, name: 'Folder 3' },
circularCheckResults,
})
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder 3',
parentId: 'folder-1', // This would create a circular reference
})
const params = Promise.resolve({ id: 'folder-3' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
// Should return 400 due to circular reference
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error', 'Cannot create circular folder reference')
})
})
describe('DELETE /api/folders/[id]', () => {
it('should delete folder and all contents successfully', async () => {
mockAuthenticatedUser()
const dbMock = createFolderDbMock({
folderLookupResult: mockFolder,
})
// Mock the recursive deletion function
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const { DELETE } = await import('./route')
const response = await DELETE(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('deletedItems')
})
it('should return 401 for unauthenticated delete requests', async () => {
mockUnauthenticated()
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const { DELETE } = await import('./route')
const response = await DELETE(req, { params })
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty('error', 'Unauthorized')
})
it('should return 403 when user has only read permissions for delete', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const { DELETE } = await import('./route')
const response = await DELETE(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Admin access required to delete folders')
})
it('should return 403 when user has only write permissions for delete', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions (not enough for delete)
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const { DELETE } = await import('./route')
const response = await DELETE(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Admin access required to delete folders')
})
it('should allow folder deletion for admin permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
const dbMock = createFolderDbMock({
folderLookupResult: mockFolder,
})
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const { DELETE } = await import('./route')
const response = await DELETE(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('success', true)
})
it('should handle database errors during deletion', async () => {
mockAuthenticatedUser()
const dbMock = createFolderDbMock({
throwError: true,
})
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const { DELETE } = await import('./route')
const response = await DELETE(req, { params })
expect(response.status).toBe(500)
const data = await response.json()
expect(data).toHaveProperty('error', 'Internal server error')
expect(mockLogger.error).toHaveBeenCalledWith('Error deleting folder:', {
error: expect.any(Error),
})
})
})
})

View File

@@ -1,212 +0,0 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { workflow, workflowFolder } from '@/db/schema'
const logger = createLogger('FoldersIDAPI')
// PUT - Update a folder
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await request.json()
const { name, color, isExpanded, parentId } = body
// Verify the folder exists
const existingFolder = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.id, id))
.then((rows) => rows[0])
if (!existingFolder) {
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
}
// Check if user has write permissions for the workspace
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
existingFolder.workspaceId
)
if (!workspacePermission || workspacePermission === 'read') {
return NextResponse.json(
{ error: 'Write access required to update folders' },
{ status: 403 }
)
}
// Prevent setting a folder as its own parent or creating circular references
if (parentId && parentId === id) {
return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 })
}
// Check for circular references if parentId is provided
if (parentId) {
const wouldCreateCycle = await checkForCircularReference(id, parentId)
if (wouldCreateCycle) {
return NextResponse.json(
{ error: 'Cannot create circular folder reference' },
{ status: 400 }
)
}
}
// Update the folder
const updates: any = { updatedAt: new Date() }
if (name !== undefined) updates.name = name.trim()
if (color !== undefined) updates.color = color
if (isExpanded !== undefined) updates.isExpanded = isExpanded
if (parentId !== undefined) updates.parentId = parentId || null
const [updatedFolder] = await db
.update(workflowFolder)
.set(updates)
.where(eq(workflowFolder.id, id))
.returning()
logger.info('Updated folder:', { id, updates })
return NextResponse.json({ folder: updatedFolder })
} catch (error) {
logger.error('Error updating folder:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// DELETE - Delete a folder and all its contents
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
// Verify the folder exists
const existingFolder = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.id, id))
.then((rows) => rows[0])
if (!existingFolder) {
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
}
// Check if user has admin permissions for the workspace (admin-only for deletions)
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
existingFolder.workspaceId
)
if (workspacePermission !== 'admin') {
return NextResponse.json(
{ error: 'Admin access required to delete folders' },
{ status: 403 }
)
}
// Recursively delete folder and all its contents
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
logger.info('Deleted folder and all contents:', {
id,
deletionStats,
})
return NextResponse.json({
success: true,
deletedItems: deletionStats,
})
} catch (error) {
logger.error('Error deleting folder:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Helper function to recursively delete a folder and all its contents
async function deleteFolderRecursively(
folderId: string,
workspaceId: string
): Promise<{ folders: number; workflows: number }> {
const stats = { folders: 0, workflows: 0 }
// Get all child folders first (workspace-scoped, not user-scoped)
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
// Recursively delete child folders
for (const childFolder of childFolders) {
const childStats = await deleteFolderRecursively(childFolder.id, workspaceId)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
// Delete all workflows in this folder (workspace-scoped, not user-scoped)
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
if (workflowsInFolder.length > 0) {
await db
.delete(workflow)
.where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
stats.workflows += workflowsInFolder.length
}
// Delete this folder
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
stats.folders += 1
return stats
}
// Helper function to check for circular references
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
let currentParentId: string | null = parentId
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
return true // Circular reference detected
}
if (currentParentId === folderId) {
return true // Would create a cycle
}
visited.add(currentParentId)
// Get the parent of the current parent
const parent: { parentId: string | null } | undefined = await db
.select({ parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(eq(workflowFolder.id, currentParentId))
.then((rows) => rows[0])
currentParentId = parent?.parentId || null
}
return false
}

View File

@@ -1,563 +0,0 @@
/**
* Tests for folders API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
type CapturedFolderValues,
createMockRequest,
createMockTransaction,
mockAuth,
mockLogger,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
describe('Folders API Route', () => {
const mockFolders = [
{
id: 'folder-1',
name: 'Test Folder 1',
userId: 'user-123',
workspaceId: 'workspace-123',
parentId: null,
color: '#6B7280',
isExpanded: true,
sortOrder: 0,
createdAt: new Date('2023-01-01T00:00:00.000Z'),
updatedAt: new Date('2023-01-01T00:00:00.000Z'),
},
{
id: 'folder-2',
name: 'Test Folder 2',
userId: 'user-123',
workspaceId: 'workspace-123',
parentId: 'folder-1',
color: '#EF4444',
isExpanded: false,
sortOrder: 1,
createdAt: new Date('2023-01-02T00:00:00.000Z'),
updatedAt: new Date('2023-01-02T00:00:00.000Z'),
},
]
const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth()
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
const mockSelect = vi.fn()
const mockFrom = vi.fn()
const mockWhere = vi.fn()
const mockOrderBy = vi.fn()
const mockInsert = vi.fn()
const mockValues = vi.fn()
const mockReturning = vi.fn()
const mockTransaction = vi.fn()
const mockGetUserEntityPermissions = vi.fn()
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue(mockUUID),
})
setupCommonApiMocks()
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ orderBy: mockOrderBy })
mockOrderBy.mockReturnValue(mockFolders)
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
mockReturning.mockReturnValue([mockFolders[0]])
mockGetUserEntityPermissions.mockResolvedValue('admin')
vi.doMock('@/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
transaction: mockTransaction,
},
}))
vi.doMock('@/lib/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET /api/folders', () => {
it('should return folders for a valid workspace', async () => {
mockAuthenticatedUser()
const mockRequest = createMockRequest('GET')
Object.defineProperty(mockRequest, 'url', {
value: 'http://localhost:3000/api/folders?workspaceId=workspace-123',
})
const { GET } = await import('./route')
const response = await GET(mockRequest)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folders')
expect(data.folders).toHaveLength(2)
expect(data.folders[0]).toMatchObject({
id: 'folder-1',
name: 'Test Folder 1',
workspaceId: 'workspace-123',
})
})
it('should return 401 for unauthenticated requests', async () => {
mockUnauthenticated()
const mockRequest = createMockRequest('GET')
Object.defineProperty(mockRequest, 'url', {
value: 'http://localhost:3000/api/folders?workspaceId=workspace-123',
})
const { GET } = await import('./route')
const response = await GET(mockRequest)
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty('error', 'Unauthorized')
})
it('should return 400 when workspaceId is missing', async () => {
mockAuthenticatedUser()
const mockRequest = createMockRequest('GET')
Object.defineProperty(mockRequest, 'url', {
value: 'http://localhost:3000/api/folders',
})
const { GET } = await import('./route')
const response = await GET(mockRequest)
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error', 'Workspace ID is required')
})
it('should return 403 when user has no workspace permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue(null) // No permissions
const mockRequest = createMockRequest('GET')
Object.defineProperty(mockRequest, 'url', {
value: 'http://localhost:3000/api/folders?workspaceId=workspace-123',
})
const { GET } = await import('./route')
const response = await GET(mockRequest)
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Access denied to this workspace')
})
it('should return 403 when user has only read permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions
const mockRequest = createMockRequest('GET')
Object.defineProperty(mockRequest, 'url', {
value: 'http://localhost:3000/api/folders?workspaceId=workspace-123',
})
const { GET } = await import('./route')
const response = await GET(mockRequest)
expect(response.status).toBe(200) // Should work for read permissions
const data = await response.json()
expect(data).toHaveProperty('folders')
})
it('should handle database errors gracefully', async () => {
mockAuthenticatedUser()
mockSelect.mockImplementationOnce(() => {
throw new Error('Database connection failed')
})
const mockRequest = createMockRequest('GET')
Object.defineProperty(mockRequest, 'url', {
value: 'http://localhost:3000/api/folders?workspaceId=workspace-123',
})
const { GET } = await import('./route')
const response = await GET(mockRequest)
expect(response.status).toBe(500)
const data = await response.json()
expect(data).toHaveProperty('error', 'Internal server error')
expect(mockLogger.error).toHaveBeenCalledWith('Error fetching folders:', {
error: expect.any(Error),
})
})
})
describe('POST /api/folders', () => {
it('should create a new folder successfully', async () => {
mockAuthenticatedUser()
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
const req = createMockRequest('POST', {
name: 'New Test Folder',
workspaceId: 'workspace-123',
color: '#6B7280',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
expect(data.folder).toMatchObject({
id: 'folder-1',
name: 'Test Folder 1',
workspaceId: 'workspace-123',
})
})
it('should create folder with correct sort order', async () => {
mockAuthenticatedUser()
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ sortOrder: 5 }]), // Existing folder with sort order 5
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ ...mockFolders[0], sortOrder: 6 }]),
}),
}),
}
return await callback(tx)
})
const req = createMockRequest('POST', {
name: 'New Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
const data = await response.json()
expect(data.folder).toMatchObject({
sortOrder: 6,
})
})
it('should create subfolder with parent reference', async () => {
mockAuthenticatedUser()
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectData: [], // No existing folders
insertResult: [{ ...mockFolders[1] }],
})
)
const req = createMockRequest('POST', {
name: 'Subfolder',
workspaceId: 'workspace-123',
parentId: 'folder-1',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
const data = await response.json()
expect(data.folder).toMatchObject({
parentId: 'folder-1',
})
})
it('should return 401 for unauthenticated requests', async () => {
mockUnauthenticated()
const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty('error', 'Unauthorized')
})
it('should return 403 when user has only read permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions
const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Write or Admin access required to create folders')
})
it('should allow folder creation for write permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
})
it('should allow folder creation for admin permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
})
it('should return 400 when required fields are missing', async () => {
const testCases = [
{ name: '', workspaceId: 'workspace-123' }, // Missing name
{ name: 'Test Folder', workspaceId: '' }, // Missing workspaceId
{ workspaceId: 'workspace-123' }, // Missing name entirely
{ name: 'Test Folder' }, // Missing workspaceId entirely
]
for (const body of testCases) {
mockAuthenticatedUser()
const req = createMockRequest('POST', body)
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error', 'Name and workspace ID are required')
}
})
it('should handle database errors gracefully', async () => {
mockAuthenticatedUser()
// Make transaction throw an error
mockTransaction.mockImplementationOnce(() => {
throw new Error('Database transaction failed')
})
const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(500)
const data = await response.json()
expect(data).toHaveProperty('error', 'Internal server error')
expect(mockLogger.error).toHaveBeenCalledWith('Error creating folder:', {
error: expect.any(Error),
})
})
it('should trim folder name when creating', async () => {
mockAuthenticatedUser()
let capturedValues: CapturedFolderValues | null = null
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values) => {
capturedValues = values
return {
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}
}),
}),
}
return await callback(tx)
})
const req = createMockRequest('POST', {
name: ' Test Folder With Spaces ',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
await POST(req)
expect(capturedValues).not.toBeNull()
expect(capturedValues!.name).toBe('Test Folder With Spaces')
})
it('should use default color when not provided', async () => {
mockAuthenticatedUser()
let capturedValues: CapturedFolderValues | null = null
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values) => {
capturedValues = values
return {
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}
}),
}),
}
return await callback(tx)
})
const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
await POST(req)
expect(capturedValues).not.toBeNull()
expect(capturedValues!.color).toBe('#6B7280')
})
})
})

View File

@@ -1,126 +0,0 @@
import { and, asc, desc, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { workflowFolder } from '@/db/schema'
const logger = createLogger('FoldersAPI')
// GET - Fetch folders for a workspace
export async function GET(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
}
// Check if user has workspace permissions
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (!workspacePermission) {
return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 })
}
// If user has workspace permissions, fetch ALL folders in the workspace
// This allows shared workspace members to see folders created by other users
const folders = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))
.orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt))
return NextResponse.json({ folders })
} catch (error) {
logger.error('Error fetching folders:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// POST - Create a new folder
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { name, workspaceId, parentId, color } = body
if (!name || !workspaceId) {
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
}
// Check if user has workspace permissions (at least 'write' access to create folders)
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (!workspacePermission || workspacePermission === 'read') {
return NextResponse.json(
{ error: 'Write or Admin access required to create folders' },
{ status: 403 }
)
}
// Generate a new ID
const id = crypto.randomUUID()
// Use transaction to ensure sortOrder consistency
const newFolder = await db.transaction(async (tx) => {
// Get the next sort order for the parent (or root level)
// Consider all folders in the workspace, not just those created by current user
const existingFolders = await tx
.select({ sortOrder: workflowFolder.sortOrder })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.workspaceId, workspaceId),
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
)
)
.orderBy(desc(workflowFolder.sortOrder))
.limit(1)
const nextSortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
// Insert the new folder within the same transaction
const [folder] = await tx
.insert(workflowFolder)
.values({
id,
name: name.trim(),
userId: session.user.id,
workspaceId,
parentId: parentId || null,
color: color || '#6B7280',
sortOrder: nextSortOrder,
})
.returning()
return folder
})
logger.info('Created new folder:', { id, name, workspaceId, parentId })
return NextResponse.json({ folder: newFolder })
} catch (error) {
logger.error('Error creating folder:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,763 +0,0 @@
import { NextRequest } from 'next/server'
/**
* Tests for function execution API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
const mockFreestyleExecuteScript = vi.fn()
const mockCreateContext = vi.fn()
const mockRunInContext = vi.fn()
const mockLogger = {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}
describe('Function Execute API Route', () => {
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
vi.doMock('vm', () => ({
createContext: mockCreateContext,
Script: vi.fn().mockImplementation(() => ({
runInContext: mockRunInContext,
})),
}))
vi.doMock('freestyle-sandboxes', () => ({
FreestyleSandboxes: vi.fn().mockImplementation(() => ({
executeScript: mockFreestyleExecuteScript,
})),
}))
vi.doMock('@/lib/env', () => ({
env: {
FREESTYLE_API_KEY: 'test-freestyle-key',
},
}))
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
mockFreestyleExecuteScript.mockResolvedValue({
result: 'freestyle success',
logs: [],
})
mockRunInContext.mockResolvedValue('vm success')
mockCreateContext.mockReturnValue({})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('Basic Function Execution', () => {
it('should execute simple JavaScript code successfully', async () => {
const req = createMockRequest('POST', {
code: 'return "Hello World"',
timeout: 5000,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.output).toHaveProperty('result')
expect(data.output).toHaveProperty('executionTime')
})
it('should handle missing code parameter', async () => {
const req = createMockRequest('POST', {
timeout: 5000,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
expect(data).toHaveProperty('error')
})
it('should use default timeout when not provided', async () => {
const req = createMockRequest('POST', {
code: 'return "test"',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringMatching(/\[.*\] Function execution request/),
expect.objectContaining({
timeout: 5000, // default timeout
})
)
})
})
describe('Template Variable Resolution', () => {
it('should resolve environment variables with {{var_name}} syntax', async () => {
const req = createMockRequest('POST', {
code: 'return {{API_KEY}}',
envVars: {
API_KEY: 'secret-key-123',
},
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
// The code should be resolved to: return "secret-key-123"
})
it('should resolve tag variables with <tag_name> syntax', async () => {
const req = createMockRequest('POST', {
code: 'return <email>',
params: {
email: { id: '123', subject: 'Test Email' },
},
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
// The code should be resolved with the email object
})
it('should NOT treat email addresses as template variables', async () => {
const req = createMockRequest('POST', {
code: 'return "Email sent to user"',
params: {
email: {
from: 'Waleed Latif <waleed@simstudio.ai>',
to: 'User <user@example.com>',
},
},
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
// Should not try to replace <waleed@simstudio.ai> as a template variable
})
it('should only match valid variable names in angle brackets', async () => {
const req = createMockRequest('POST', {
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
params: {
validVar: 'hello',
another_valid: 'world',
},
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
// Should replace <validVar> and <another_valid> but not <invalid@email.com>
})
})
describe('Gmail Email Data Handling', () => {
it('should handle Gmail webhook data with email addresses containing angle brackets', async () => {
const gmailData = {
email: {
id: '123',
from: 'Waleed Latif <waleed@simstudio.ai>',
to: 'User <user@example.com>',
subject: 'Test Email',
bodyText: 'Hello world',
},
rawEmail: {
id: '123',
payload: {
headers: [
{ name: 'From', value: 'Waleed Latif <waleed@simstudio.ai>' },
{ name: 'To', value: 'User <user@example.com>' },
],
},
},
}
const req = createMockRequest('POST', {
code: 'return <email>',
params: gmailData,
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
const data = await response.json()
expect(data.success).toBe(true)
})
it('should properly serialize complex email objects with special characters', async () => {
const complexEmailData = {
email: {
from: 'Test User <test@example.com>',
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
bodyText: 'Text with\nnewlines\tand\ttabs',
},
}
const req = createMockRequest('POST', {
code: 'return <email>',
params: complexEmailData,
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
})
})
describe.skip('Freestyle Execution', () => {
it('should use Freestyle when API key is available', async () => {
const req = createMockRequest('POST', {
code: 'return "freestyle test"',
})
const { POST } = await import('./route')
await POST(req)
expect(mockFreestyleExecuteScript).toHaveBeenCalled()
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringMatching(/\[.*\] Using Freestyle for code execution/)
)
})
it('should handle Freestyle errors and fallback to VM', async () => {
mockFreestyleExecuteScript.mockRejectedValueOnce(new Error('Freestyle API error'))
const req = createMockRequest('POST', {
code: 'return "fallback test"',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(mockFreestyleExecuteScript).toHaveBeenCalled()
expect(mockRunInContext).toHaveBeenCalled()
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringMatching(/\[.*\] Freestyle API call failed, falling back to VM:/),
expect.any(Object)
)
})
it('should handle Freestyle script errors', async () => {
mockFreestyleExecuteScript.mockResolvedValueOnce({
result: null,
logs: [{ type: 'error', message: 'ReferenceError: undefined variable' }],
})
const req = createMockRequest('POST', {
code: 'return undefinedVariable',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(500)
const data = await response.json()
expect(data.success).toBe(false)
})
})
describe('VM Execution', () => {
it.skip('should use VM when Freestyle API key is not available', async () => {
// Mock no Freestyle API key
vi.doMock('@/lib/env', () => ({
env: {
FREESTYLE_API_KEY: undefined,
},
}))
const req = createMockRequest('POST', {
code: 'return "vm test"',
})
const { POST } = await import('./route')
await POST(req)
expect(mockFreestyleExecuteScript).not.toHaveBeenCalled()
expect(mockRunInContext).toHaveBeenCalled()
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringMatching(
/\[.*\] Using VM for code execution \(no Freestyle API key available\)/
)
)
})
it('should handle VM execution errors', async () => {
// Mock no Freestyle API key so it uses VM
vi.doMock('@/lib/env', () => ({
env: {
FREESTYLE_API_KEY: undefined,
},
}))
mockRunInContext.mockRejectedValueOnce(new Error('VM execution error'))
const req = createMockRequest('POST', {
code: 'return invalidCode(',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(500)
const data = await response.json()
expect(data.success).toBe(false)
expect(data.error).toContain('VM execution error')
})
})
describe('Custom Tools', () => {
it('should handle custom tool execution with direct parameter access', async () => {
const req = createMockRequest('POST', {
code: 'return location + " weather is sunny"',
params: {
location: 'San Francisco',
},
isCustomTool: true,
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
// For custom tools, parameters should be directly accessible as variables
})
})
describe('Security and Edge Cases', () => {
it('should handle malformed JSON in request body', async () => {
const req = new NextRequest('http://localhost:3000/api/function/execute', {
method: 'POST',
body: 'invalid json{',
headers: { 'Content-Type': 'application/json' },
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(500)
})
it('should handle timeout parameter', async () => {
const req = createMockRequest('POST', {
code: 'return "test"',
timeout: 10000,
})
const { POST } = await import('./route')
await POST(req)
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringMatching(/\[.*\] Function execution request/),
expect.objectContaining({
timeout: 10000,
})
)
})
it('should handle empty parameters object', async () => {
const req = createMockRequest('POST', {
code: 'return "no params"',
params: {},
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
})
})
describe('Enhanced Error Handling', () => {
it('should provide detailed syntax error with line content', async () => {
// Mock VM Script to throw a syntax error
const mockScript = vi.fn().mockImplementation(() => {
const error = new Error('Invalid or unexpected token')
error.name = 'SyntaxError'
error.stack = `user-function.js:5
description: "This has a missing closing quote
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: Invalid or unexpected token
at new Script (node:vm:117:7)
at POST (/path/to/route.ts:123:24)`
throw error
})
vi.doMock('vm', () => ({
createContext: mockCreateContext,
Script: mockScript,
}))
const req = createMockRequest('POST', {
code: 'const obj = {\n name: "test",\n description: "This has a missing closing quote\n};\nreturn obj;',
timeout: 5000,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
expect(data.error).toContain('Syntax Error')
expect(data.error).toContain('Line 3')
expect(data.error).toContain('description: "This has a missing closing quote')
expect(data.error).toContain('Invalid or unexpected token')
expect(data.error).toContain('(Check for missing quotes, brackets, or semicolons)')
// Check debug information
expect(data.debug).toBeDefined()
expect(data.debug.line).toBe(3)
expect(data.debug.errorType).toBe('SyntaxError')
expect(data.debug.lineContent).toBe('description: "This has a missing closing quote')
})
it('should provide detailed runtime error with line and column', async () => {
// Create the error object first
const runtimeError = new Error("Cannot read properties of null (reading 'someMethod')")
runtimeError.name = 'TypeError'
runtimeError.stack = `TypeError: Cannot read properties of null (reading 'someMethod')
at user-function.js:4:16
at user-function.js:9:3
at Script.runInContext (node:vm:147:14)`
// Mock successful script creation but runtime error
const mockScript = vi.fn().mockImplementation(() => ({
runInContext: vi.fn().mockRejectedValue(runtimeError),
}))
vi.doMock('vm', () => ({
createContext: mockCreateContext,
Script: mockScript,
}))
const req = createMockRequest('POST', {
code: 'const obj = null;\nreturn obj.someMethod();',
timeout: 5000,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
expect(data.error).toContain('Type Error')
expect(data.error).toContain('Line 2')
expect(data.error).toContain('return obj.someMethod();')
expect(data.error).toContain('Cannot read properties of null')
// Check debug information
expect(data.debug).toBeDefined()
expect(data.debug.line).toBe(2)
expect(data.debug.column).toBe(16)
expect(data.debug.errorType).toBe('TypeError')
expect(data.debug.lineContent).toBe('return obj.someMethod();')
})
it('should handle ReferenceError with enhanced details', async () => {
// Create the error object first
const referenceError = new Error('undefinedVariable is not defined')
referenceError.name = 'ReferenceError'
referenceError.stack = `ReferenceError: undefinedVariable is not defined
at user-function.js:4:8
at Script.runInContext (node:vm:147:14)`
const mockScript = vi.fn().mockImplementation(() => ({
runInContext: vi.fn().mockRejectedValue(referenceError),
}))
vi.doMock('vm', () => ({
createContext: mockCreateContext,
Script: mockScript,
}))
const req = createMockRequest('POST', {
code: 'const x = 42;\nreturn undefinedVariable + x;',
timeout: 5000,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
expect(data.error).toContain('Reference Error')
expect(data.error).toContain('Line 2')
expect(data.error).toContain('return undefinedVariable + x;')
expect(data.error).toContain('undefinedVariable is not defined')
})
it('should handle errors without line content gracefully', async () => {
const mockScript = vi.fn().mockImplementation(() => {
const error = new Error('Generic error without stack trace')
error.name = 'Error'
// No stack trace
throw error
})
vi.doMock('vm', () => ({
createContext: mockCreateContext,
Script: mockScript,
}))
const req = createMockRequest('POST', {
code: 'return "test";',
timeout: 5000,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
expect(data.error).toBe('Generic error without stack trace')
// Should still have debug info, but without line details
expect(data.debug).toBeDefined()
expect(data.debug.errorType).toBe('Error')
expect(data.debug.line).toBeUndefined()
expect(data.debug.lineContent).toBeUndefined()
})
it('should extract line numbers from different stack trace formats', async () => {
const mockScript = vi.fn().mockImplementation(() => {
const error = new Error('Test error')
error.name = 'Error'
error.stack = `Error: Test error
at user-function.js:7:25
at async function
at Script.runInContext (node:vm:147:14)`
throw error
})
vi.doMock('vm', () => ({
createContext: mockCreateContext,
Script: mockScript,
}))
const req = createMockRequest('POST', {
code: 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nreturn a + b + c + d;',
timeout: 5000,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
// Line 7 in VM should map to line 5 in user code (7 - 3 + 1 = 5)
expect(data.debug.line).toBe(5)
expect(data.debug.column).toBe(25)
expect(data.debug.lineContent).toBe('return a + b + c + d;')
})
it('should provide helpful suggestions for common syntax errors', async () => {
const mockScript = vi.fn().mockImplementation(() => {
const error = new Error('Unexpected end of input')
error.name = 'SyntaxError'
error.stack = 'user-function.js:4\nSyntaxError: Unexpected end of input'
throw error
})
vi.doMock('vm', () => ({
createContext: mockCreateContext,
Script: mockScript,
}))
const req = createMockRequest('POST', {
code: 'const obj = {\n name: "test"\n// Missing closing brace',
timeout: 5000,
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
expect(data.error).toContain('Syntax Error')
expect(data.error).toContain('Unexpected end of input')
expect(data.error).toContain('(Check for missing closing brackets or braces)')
})
})
describe('Utility Functions', () => {
it('should properly escape regex special characters', async () => {
// This tests the escapeRegExp function indirectly
const req = createMockRequest('POST', {
code: 'return {{special.chars+*?}}',
envVars: {
'special.chars+*?': 'escaped-value',
},
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
// Should handle special regex characters in variable names
})
it('should handle JSON serialization edge cases', async () => {
// Test with complex but not circular data first
const req = createMockRequest('POST', {
code: 'return <complexData>',
params: {
complexData: {
special: 'chars"with\'quotes',
unicode: '🎉 Unicode content',
nested: {
deep: {
value: 'test',
},
},
},
},
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
})
})
})
describe('Function Execute API - Template Variable Edge Cases', () => {
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/env', () => ({
env: {
FREESTYLE_API_KEY: 'test-freestyle-key',
},
}))
vi.doMock('vm', () => ({
createContext: mockCreateContext,
Script: vi.fn().mockImplementation(() => ({
runInContext: mockRunInContext,
})),
}))
vi.doMock('freestyle-sandboxes', () => ({
FreestyleSandboxes: vi.fn().mockImplementation(() => ({
executeScript: mockFreestyleExecuteScript,
})),
}))
mockFreestyleExecuteScript.mockResolvedValue({
result: 'freestyle success',
logs: [],
})
mockRunInContext.mockResolvedValue('vm success')
mockCreateContext.mockReturnValue({})
})
it.skip('should handle nested template variables', async () => {
mockFreestyleExecuteScript.mockResolvedValueOnce({
result: 'environment-valueparam-value',
logs: [],
})
const req = createMockRequest('POST', {
code: 'return {{outer}} + <inner>',
envVars: {
outer: 'environment-value',
},
params: {
inner: 'param-value',
},
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.output.result).toBe('environment-valueparam-value')
})
it.skip('should prioritize environment variables over params for {{}} syntax', async () => {
mockFreestyleExecuteScript.mockResolvedValueOnce({
result: 'env-wins',
logs: [],
})
const req = createMockRequest('POST', {
code: 'return {{conflictVar}}',
envVars: {
conflictVar: 'env-wins',
},
params: {
conflictVar: 'param-loses',
},
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
// Environment variable should take precedence
expect(data.output.result).toBe('env-wins')
})
it.skip('should handle missing template variables gracefully', async () => {
mockFreestyleExecuteScript.mockResolvedValueOnce({
result: '',
logs: [],
})
const req = createMockRequest('POST', {
code: 'return {{nonexistent}} + <alsoMissing>',
envVars: {},
params: {},
})
const { POST } = await import('./route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.output.result).toBe('')
})
})

Some files were not shown because too many files have changed in this diff Show More